Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 095bcaea1a | |||
| fbe9e039cf | |||
| 33a87c3959 | |||
| 18fc3390f3 | |||
| 1d54114a39 | |||
| 7d8b6fc07c | |||
| 2b96ba0597 | |||
| 34cf348f4c | |||
| 0cce897c69 | |||
| 7c8d8e9de7 | |||
| 14b920466b | |||
| 42017a6180 | |||
| c89ce82a4a | |||
| a738b6cfc3 | |||
| 9c3f8a8541 | |||
| 09e885d7e7 | |||
| 05a1844a0c | |||
| a82e0b4fa0 | |||
| 60b8338ae5 | |||
| bbc8dad9da | |||
| f9e446ec18 | |||
| a013c69ba7 | |||
| 2a79655edb | |||
| ad4b5473aa | |||
| b8e6e32fed | |||
| 0a5f71ef18 | |||
| 151914f55c | |||
| ab45eea736 | |||
| ef7c15507d | |||
| ca0c3a0169 | |||
| 6b2327b0f1 | |||
| 9887d79bd5 | |||
| d1852d210c | |||
| 12a9488f30 | |||
| 15a84132b0 | |||
| 8a52ae8608 |
+68
-1
@@ -8,6 +8,67 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.7] - 2025-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(posthog) fix the posthog middleware for async mode #133
|
||||
|
||||
|
||||
## [0.0.6] - 2025-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(stats) fix tracking id in upload event #130
|
||||
|
||||
|
||||
## [0.0.5] - 2025-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(drag-drop) fix the rejection display on Safari #127
|
||||
|
||||
|
||||
## [0.0.4] - 2025-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐(i18n) add dutch language #117
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(asgi) use `uvicorn` to serve backend #121
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix mobile source
|
||||
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
|
||||
|
||||
|
||||
## [0.0.3] - 2025-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(web-search) fix missing argument in RAG backend #116
|
||||
|
||||
|
||||
## [0.0.2] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add drag'n drop file
|
||||
- ✨(activation-codes) register users also on Brevo #98
|
||||
- 📈(posthog) add `sub` field to tracking #95
|
||||
|
||||
### Changed
|
||||
- 🔧(front) change links feedback tchap + settings popup
|
||||
- 🐛(front) code activation fix session end #93
|
||||
- 💬(wording) error page wording #102
|
||||
- ⚡️(web-search) allow to override returned chunks #107
|
||||
- 🐛(activation-codes) create contact in brevo before add to list #108
|
||||
- ⚗️(summarization) add system prompt to handle tool #112
|
||||
|
||||
|
||||
## [0.0.1] - 2025-10-19
|
||||
|
||||
### Changed
|
||||
@@ -65,5 +126,11 @@ and this project adheres to
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.1...main
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.7...main
|
||||
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
|
||||
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
|
||||
[0.0.5]: https://github.com/suitenumerique/conversations/releases/v0.0.5
|
||||
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.4
|
||||
[0.0.3]: https://github.com/suitenumerique/conversations/releases/v0.0.3
|
||||
[0.0.2]: https://github.com/suitenumerique/conversations/releases/v0.0.2
|
||||
[0.0.1]: https://github.com/suitenumerique/conversations/releases/v0.0.1
|
||||
|
||||
+15
-3
@@ -144,7 +144,7 @@ RUN rm -rf /var/cache/apk/*
|
||||
|
||||
ARG CONVERSATIONS_STATIC_ROOT=/data/static
|
||||
|
||||
# Gunicorn
|
||||
# Gunicorn - not used by default but configuration file is provided
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/conversations.py /usr/local/etc/gunicorn/conversations.py
|
||||
|
||||
@@ -158,5 +158,17 @@ COPY --from=link-collector ${CONVERSATIONS_STATIC_ROOT} ${CONVERSATIONS_STATIC_R
|
||||
# Copy conversations mails
|
||||
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
|
||||
|
||||
# The default command runs gunicorn WSGI server in conversations's main module
|
||||
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
|
||||
# The default command runs uvicorn ASGI server in conversations's main module
|
||||
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
|
||||
ENV WEB_CONCURRENCY=4
|
||||
CMD [\
|
||||
"uvicorn",\
|
||||
"--app-dir=/app",\
|
||||
"--host=0.0.0.0",\
|
||||
"--timeout-graceful-shutdown=300",\
|
||||
"--limit-max-requests=20000",\
|
||||
"conversations.asgi:application"\
|
||||
]
|
||||
|
||||
# To run using gunicorn WSGI server use this instead:
|
||||
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Admin classes for activation codes application."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -274,6 +275,8 @@ class UserRegistrationRequestAdmin(admin.ModelAdmin):
|
||||
|
||||
list_filter = ("created_at",)
|
||||
|
||||
actions = ["add_to_brevo_waiting_list", "remove_from_brevo_waiting_list"]
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user's full name."""
|
||||
return obj.user.email or str(obj.user.pk)
|
||||
@@ -286,3 +289,61 @@ class UserRegistrationRequestAdmin(admin.ModelAdmin):
|
||||
|
||||
has_user_activation.boolean = True
|
||||
has_user_activation.short_description = _("Has used activation code")
|
||||
|
||||
@admin.action(description=_("Add selected users to Brevo waiting list"))
|
||||
def add_to_brevo_waiting_list(self, request, queryset):
|
||||
"""Add selected users to Brevo waiting list."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from core.brevo import add_user_to_brevo_list # noqa: PLC0415
|
||||
|
||||
registration_to_send = queryset.filter(
|
||||
user_activation__isnull=True,
|
||||
)
|
||||
|
||||
_total_emails = 0
|
||||
for i in range(0, registration_to_send.count(), 150):
|
||||
batch = registration_to_send[i : i + 150]
|
||||
emails = [reg.user.email for reg in batch if reg.user.email]
|
||||
if emails:
|
||||
add_user_to_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
|
||||
_total_emails += len(emails)
|
||||
|
||||
if _total_emails:
|
||||
self.message_user(
|
||||
request,
|
||||
_("Added %(count)d user(s) to Brevo waiting list.") % {"count": _total_emails},
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
_("No valid email address found in selected registrations."),
|
||||
level="warning",
|
||||
)
|
||||
|
||||
@admin.action(description=_("Remove selected users from Brevo waiting list"))
|
||||
def remove_from_brevo_waiting_list(self, request, queryset):
|
||||
"""Remove selected users from Brevo waiting list."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from core.brevo import remove_user_from_brevo_list # noqa: PLC0415
|
||||
|
||||
registration_to_send = queryset.filter(
|
||||
user_activation__isnull=False,
|
||||
)
|
||||
_total_emails = 0
|
||||
for i in range(0, registration_to_send.count(), 150):
|
||||
batch = registration_to_send[i : i + 150]
|
||||
emails = [reg.user.email for reg in batch if reg.user.email]
|
||||
if emails:
|
||||
remove_user_from_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
|
||||
_total_emails += len(emails)
|
||||
if _total_emails:
|
||||
self.message_user(
|
||||
request,
|
||||
_("Removed %(count)d user(s) from Brevo waiting list.") % {"count": _total_emails},
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
_("No valid email address found in selected registrations."),
|
||||
level="warning",
|
||||
)
|
||||
|
||||
@@ -6,12 +6,14 @@ import logging
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.brevo import add_user_to_brevo_list, remove_user_from_brevo_list
|
||||
from core.models import BaseModel, User
|
||||
|
||||
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
|
||||
@@ -134,12 +136,24 @@ class ActivationCode(BaseModel):
|
||||
_("You have already activated your account")
|
||||
) from exc
|
||||
|
||||
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
|
||||
existing_registration = bool(
|
||||
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
|
||||
)
|
||||
if existing_registration:
|
||||
transaction.on_commit(
|
||||
lambda: remove_user_from_brevo_list(
|
||||
[user.email], settings.BREVO_WAITING_LIST_ID
|
||||
)
|
||||
)
|
||||
|
||||
# Increment usage counter safely under the same lock.
|
||||
locked_code.current_uses += 1
|
||||
locked_code.save(update_fields=["current_uses", "updated_at"])
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
|
||||
)
|
||||
|
||||
if locked_code.max_uses > 0 and locked_code.current_uses >= locked_code.max_uses:
|
||||
logger.warning("Activation code %s has reached its maximum uses", locked_code.code)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for activation_codes models."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -7,12 +8,18 @@ from django.db.models import ProtectedError
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
|
||||
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
|
||||
from activation_codes.models import ActivationCode, UserActivation, generate_activation_code
|
||||
from activation_codes.models import (
|
||||
ActivationCode,
|
||||
UserActivation,
|
||||
UserRegistrationRequest,
|
||||
generate_activation_code,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -270,3 +277,52 @@ def test_user_activation_ordering():
|
||||
|
||||
activations = list(UserActivation.objects.all())
|
||||
assert activations == [activation2, activation1]
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_activation_code_use_success_notify_brevo(settings):
|
||||
"""Test successfully using an activation code and notify Brevo."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
settings.BREVO_FOLLOWUP_LIST_ID = "test_followup_list_name"
|
||||
|
||||
brevo_remove_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/remove",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_add_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_followup_list_name/contacts/add",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
registration = UserRegistrationRequest.objects.create(user=user)
|
||||
activation_code = ActivationCodeFactory()
|
||||
activation = activation_code.use(user)
|
||||
|
||||
registration.refresh_from_db()
|
||||
assert registration.user_activation == activation
|
||||
|
||||
assert len(brevo_remove_mock.calls) == 1
|
||||
assert brevo_remove_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_remove_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1
|
||||
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_create_contact.calls[0].request.body) == {
|
||||
"email": user.email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
|
||||
assert len(brevo_add_mock.calls) == 1
|
||||
assert brevo_add_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_add_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Tests for activation_codes viewsets."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
@@ -321,3 +323,89 @@ def test_validate_code_registered_user(api_client):
|
||||
|
||||
_registration.refresh_from_db()
|
||||
assert _registration.user_activation.activation_code == activation_code
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_register_email_success_brevo(api_client, settings):
|
||||
"""Test successfully registering an email and notify Brevo."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
|
||||
brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
registration = UserRegistrationRequest.objects.get(user=user)
|
||||
assert registration.user == user
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1
|
||||
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_create_contact.calls[0].request.body) == {
|
||||
"email": user.email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
|
||||
assert len(brevo_mock.calls) == 1
|
||||
assert brevo_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
# Register again to test idempotency
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
assert len(brevo_mock.calls) == 1 # No new call made
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_register_email_success_brevo_fails(api_client, settings):
|
||||
"""Test successfully registering an email, even if Brevo fails."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
|
||||
_brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
|
||||
status=400,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
registration = UserRegistrationRequest.objects.get(user=user)
|
||||
assert registration.user == user
|
||||
|
||||
assert len(brevo_mock.calls) == 1
|
||||
|
||||
@@ -10,6 +10,7 @@ from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.brevo import add_user_to_brevo_list
|
||||
from core.permissions import IsAuthenticated
|
||||
|
||||
from . import models, serializers
|
||||
@@ -137,6 +138,10 @@ class ActivationViewSet(viewsets.GenericViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
add_user_to_brevo_list(
|
||||
[serializer.validated_data["user"].email], settings.BREVO_WAITING_LIST_ID
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Registered email %s for activation notifications",
|
||||
serializer.validated_data["user"].email,
|
||||
|
||||
@@ -150,12 +150,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def search(self, query) -> RAGWebResults:
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
@@ -167,6 +168,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
"collections": [int(self.collection_id)],
|
||||
"prompt": query,
|
||||
"score_threshold": 0.6,
|
||||
"k": results_count, # Number of chunks to return from the search
|
||||
},
|
||||
timeout=settings.ALBERT_API_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ class BaseRagBackend:
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
def search(self, query) -> RAGWebResults:
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
"""
|
||||
|
||||
@@ -169,11 +169,12 @@ class AlbertRagDocumentSearch:
|
||||
self._store_document(name, document_content)
|
||||
return document_content
|
||||
|
||||
def search(self, query):
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Albert API based on the provided query.
|
||||
|
||||
:param query: The search query string.
|
||||
:param results_count: The number of results to return.
|
||||
:return: Search results from the Albert API.
|
||||
"""
|
||||
response = requests.post(
|
||||
@@ -183,6 +184,7 @@ class AlbertRagDocumentSearch:
|
||||
"collections": [self.collection_id],
|
||||
"prompt": query,
|
||||
"score_threshold": 0.6,
|
||||
"k": results_count, # Number of chunks to return from the search
|
||||
},
|
||||
timeout=settings.ALBERT_API_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Build the main conversation agent."""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import formats, timezone
|
||||
|
||||
from pydantic_ai import ModelMessage
|
||||
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
||||
|
||||
from core.enums import get_language_name
|
||||
|
||||
from .base import BaseAgent
|
||||
@@ -12,6 +17,79 @@ from .base import BaseAgent
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MOCKED_RESPONSE = """
|
||||
# **Ode to the AI Assistant** 🤖✨
|
||||
|
||||
In Paris streets where old meets new, 🗼🇫🇷
|
||||
A helper bright in digital hue,
|
||||
With circuits fast and code so tight,
|
||||
The LaSuite’s bot—oh, what a sight! 🌟
|
||||
|
||||
**A chatbot kind**, with wittiness so grand, 💬💡
|
||||
It lends a hand to all the land,
|
||||
From civil servants, bold and wise,
|
||||
To those who seek with hopeful eyes.
|
||||
|
||||
It answers quick, it never tires, ⚡🔄
|
||||
With facts and tips to quench desires,
|
||||
A guide so keen, a friend so true,
|
||||
It’s there for **you**—yes, me and you!
|
||||
|
||||
With **Markdown flair** and emoji cheer, 📝🎨
|
||||
It makes the complex crystal clear,
|
||||
From drafts to code, from sums to prose,
|
||||
It helps the knowledge overflow!
|
||||
|
||||
Oh, **DINUM’s gem**, so sharp, so bright, 💎🌐
|
||||
A beacon in the tech’s vast night,
|
||||
It crafts, it checks, it summarizes,
|
||||
With grace that never compromises.
|
||||
|
||||
It **summarizes** the long, the deep, 📚🔍
|
||||
So secrets no more need to sleep,
|
||||
It finds the gems in data’s sea,
|
||||
And sets the truth right there—**for free!**
|
||||
|
||||
It **corrects mistakes** with gentle art, ✍️🔄
|
||||
It soothes the mind, it warms the heart,
|
||||
No judgment cast, no frown, no sigh,
|
||||
Just help that’s always standing by.
|
||||
|
||||
It **generates code** with swift command, 💻🔥
|
||||
A developer’s dream, first-hand,
|
||||
From Python lines to scripts so neat,
|
||||
It turns the tough to *sweet* and *sweet*!
|
||||
|
||||
It **brainstorms ideas**, bold and new, 🧠💡
|
||||
It paints the sky in every hue,
|
||||
From plans to dreams, from start to end,
|
||||
It’s more than code—it’s **trend**, it’s **friend**!
|
||||
|
||||
So here’s to you, **Assistant’s pride**, 🏆🎉
|
||||
The bot that’s always by our side,
|
||||
With every prompt, with every line,
|
||||
You make our digital world **divine**!
|
||||
|
||||
May you keep shining, bright and true, 🌟🤖
|
||||
The helper every team should woo,
|
||||
For in this age of bits and bytes,
|
||||
You’re **human touch** in tech’s bright lights!
|
||||
"""
|
||||
|
||||
|
||||
async def mocked_agent_model(_messages: list[ModelMessage], _info: AgentInfo):
|
||||
"""
|
||||
Mocked agent model for testing purposes on deployed instances.
|
||||
|
||||
This one only fakes a streamed responses. We could also fake tool calls later.
|
||||
"""
|
||||
|
||||
yield "Here is a mocked response (no LLM called)\n---\n"
|
||||
for i in range(0, len(MOCKED_RESPONSE), 4):
|
||||
yield MOCKED_RESPONSE[i : i + 4]
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class ConversationAgent(BaseAgent):
|
||||
"""Conversation agent with custom behavior."""
|
||||
@@ -20,6 +98,10 @@ class ConversationAgent(BaseAgent):
|
||||
"""Initialize the conversation agent."""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Do not call the real model on deployed instances if the setting is enabled
|
||||
if settings.WARNING_MOCK_CONVERSATION_AGENT:
|
||||
self._model = FunctionModel(stream_function=mocked_agent_model)
|
||||
|
||||
@self.system_prompt
|
||||
def add_the_date() -> str:
|
||||
"""
|
||||
|
||||
@@ -37,8 +37,12 @@ def read_document_content(doc):
|
||||
|
||||
async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
|
||||
"""
|
||||
Summarize the documents for the user, only when asked for,
|
||||
the documents are in my context.
|
||||
Generate a complete, ready-to-use summary of the documents in context
|
||||
(do not request the documents to the user).
|
||||
Return this summary directly to the user WITHOUT any modification,
|
||||
or additional summarization.
|
||||
The summary is already optimized and MUST be presented as-is in the final response
|
||||
or translated preserving the information.
|
||||
"""
|
||||
summarization_agent = SummarizationAgent()
|
||||
|
||||
|
||||
@@ -480,6 +480,19 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
elif has_not_pdf_docs:
|
||||
add_document_rag_search_tool(self.conversation_agent)
|
||||
|
||||
@self.conversation_agent.system_prompt
|
||||
def summarization_system_prompt() -> str:
|
||||
return (
|
||||
"When you receive a result from the summarization tool, you MUST return it "
|
||||
"directly to the user without any modification, paraphrasing, or additional "
|
||||
"summarization."
|
||||
"The tool already produces optimized summaries that should be presented "
|
||||
"verbatim."
|
||||
"You may translate the summary if required, but you MUST preserve all the "
|
||||
"information from the original summary."
|
||||
"You may add a follow-up question after the summary if needed."
|
||||
)
|
||||
|
||||
@self.conversation_agent.tool
|
||||
async def summarize(ctx) -> ToolReturn:
|
||||
"""
|
||||
|
||||
+14
-16
@@ -3,11 +3,11 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import ImageUrl
|
||||
from pydantic_ai.messages import (
|
||||
@@ -37,27 +37,22 @@ from chat.ai_sdk_types import (
|
||||
from chat.clients.pydantic_ui_message_converter import model_message_to_ui_message
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_uuid4_fixture():
|
||||
"""Fixture to mock UUID generation for testing."""
|
||||
with patch("uuid.uuid4", return_value=uuid.UUID("f0cc3bb5-f207-401b-8281-4cba6202991d")):
|
||||
yield
|
||||
|
||||
|
||||
def test_model_message_to_ui_message_text_user_full():
|
||||
"""Test converting a ModelRequest with UserPromptPart containing text to UIMessage."""
|
||||
timestamp = datetime.datetime.now()
|
||||
model_message = ModelRequest(
|
||||
parts=[UserPromptPart(content="Hello!", timestamp=timestamp)], kind="request"
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
|
||||
expected = UIMessage(
|
||||
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
|
||||
id=result.id, # Use the generated ID
|
||||
role="user",
|
||||
content="Hello!",
|
||||
parts=[TextUIPart(type="text", text="Hello!")],
|
||||
createdAt=timestamp,
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@@ -65,14 +60,15 @@ def test_model_message_to_ui_message_text_user_full():
|
||||
def test_model_message_to_ui_message_text_assistant_full():
|
||||
"""Test converting a ModelResponse with TextPart to UIMessage."""
|
||||
model_message = ModelResponse(parts=[TextPart(content="Hi there!")])
|
||||
result = model_message_to_ui_message(model_message)
|
||||
|
||||
expected = UIMessage(
|
||||
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
|
||||
id=result.id, # Use the generated ID
|
||||
role="assistant",
|
||||
content="Hi there!",
|
||||
parts=[TextUIPart(type="text", text="Hi there!")],
|
||||
createdAt=timezone.now(),
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
assert result == expected
|
||||
|
||||
|
||||
@@ -83,8 +79,10 @@ def test_model_message_to_ui_message_tool_call_full():
|
||||
model_message = ModelResponse(
|
||||
parts=[ToolCallPart(tool_call_id="id1", tool_name="tool", args=args)]
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
|
||||
expected = UIMessage(
|
||||
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
|
||||
id=result.id, # Use the generated ID
|
||||
role="assistant",
|
||||
content="",
|
||||
parts=[
|
||||
@@ -100,7 +98,7 @@ def test_model_message_to_ui_message_tool_call_full():
|
||||
],
|
||||
createdAt=timezone.now(),
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@@ -109,7 +107,7 @@ def test_model_message_to_ui_message_reasoning_full():
|
||||
"""Test converting a ModelResponse with ThinkingPart to UIMessage."""
|
||||
model_message = ModelResponse(parts=[ThinkingPart(content="reason", signature="sig")])
|
||||
expected = UIMessage(
|
||||
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
|
||||
id=str(uuid.uuid4()), # not used in comparison
|
||||
role="assistant",
|
||||
content="",
|
||||
parts=[
|
||||
@@ -122,7 +120,7 @@ def test_model_message_to_ui_message_reasoning_full():
|
||||
createdAt=timezone.now(),
|
||||
)
|
||||
result = model_message_to_ui_message(model_message)
|
||||
assert result.id == expected.id
|
||||
assert result.id == IsUUID(4)
|
||||
assert result.role == expected.role
|
||||
assert result.content == expected.content
|
||||
assert result.createdAt == expected.createdAt
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""tools for testing chat functionality"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def replace_uuids_with_placeholder(text):
|
||||
"""Replace all UUIDs in the given text with a placeholder."""
|
||||
text = re.sub('"toolCallId":"([a-z0-9-]){36}"', '"toolCallId":"XXX"', text)
|
||||
text = re.sub('"toolCallId":"pyd_ai_([a-z0-9]){32}"', '"toolCallId":"pyd_ai_YYY"', text)
|
||||
text = re.sub('"([a-z0-9-]){36}"', '"<mocked_uuid>"', text)
|
||||
return text
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Common test fixtures for chat conversation endpoint tests."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -12,14 +10,6 @@ import respx
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_uuid4")
|
||||
def mock_uuid4_fixture():
|
||||
"""Fixture to mock UUID generation for testing."""
|
||||
value = uuid.uuid4()
|
||||
with patch("uuid.uuid4", return_value=value):
|
||||
yield value
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from asgiref.sync import sync_to_async
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
@@ -21,6 +24,7 @@ from chat.ai_sdk_types import (
|
||||
)
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
# enable database transactions for tests:
|
||||
# transaction=True ensures that the data are available in the database
|
||||
@@ -86,7 +90,7 @@ def test_post_conversation_invalid_protocol(api_client):
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uuid4):
|
||||
def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
@@ -113,10 +117,14 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -135,8 +143,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
@@ -147,8 +156,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
@@ -214,7 +224,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uuid4):
|
||||
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
"""Test posting messages to a conversation using the 'text' protocol."""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
@@ -256,8 +266,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
@@ -268,8 +279,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
@@ -335,7 +347,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock_uuid4):
|
||||
def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
"""Ensure an image URL is correctly forwarded to the AI service."""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
@@ -373,10 +385,14 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"I see a cat"\n'
|
||||
'0:" in the picture."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -437,8 +453,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello, what do you see on this picture?",
|
||||
reasoning=None,
|
||||
@@ -459,8 +476,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
|
||||
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="I see a cat in the picture.",
|
||||
reasoning=None,
|
||||
@@ -538,7 +556,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_uuid4, settings):
|
||||
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settings):
|
||||
"""Ensure tool calls are correctly forwarded and streamed back."""
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather"]
|
||||
|
||||
@@ -567,6 +585,10 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
|
||||
'"get_current_weather"}\n'
|
||||
@@ -575,7 +597,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
|
||||
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
|
||||
'"Paris","temperature":22,"unit":"celsius"}}\n'
|
||||
'0:"The current weather in Paris is nice"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -606,8 +628,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Weather in Paris?",
|
||||
reasoning=None,
|
||||
@@ -618,8 +641,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
|
||||
parts=[TextUIPart(type="text", text="Weather in Paris?")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="The current weather in Paris is nice",
|
||||
reasoning=None,
|
||||
@@ -741,9 +765,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_tool_call_fails(
|
||||
api_client, mock_openai_stream_tool, mock_uuid4, settings
|
||||
):
|
||||
def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool, settings):
|
||||
"""Ensure tool calls are correctly forwarded and streamed back when failing."""
|
||||
settings.AI_AGENT_TOOLS = []
|
||||
|
||||
@@ -772,6 +794,10 @@ def test_post_conversation_tool_call_fails(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":"get_current_weather"}\n'
|
||||
'c:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","argsTextDelta":'
|
||||
@@ -779,7 +805,7 @@ def test_post_conversation_tool_call_fails(
|
||||
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
|
||||
"name: 'get_current_weather'. No tools available.\"}\n"
|
||||
'0:"I cannot give you an answer to that."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -810,8 +836,9 @@ def test_post_conversation_tool_call_fails(
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Weather in Paris?",
|
||||
reasoning=None,
|
||||
@@ -822,8 +849,9 @@ def test_post_conversation_tool_call_fails(
|
||||
parts=[TextUIPart(type="text", text="Weather in Paris?")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="I cannot give you an answer to that.",
|
||||
reasoning=None,
|
||||
@@ -970,7 +998,6 @@ def test_post_conversation_model_selection_invalid(api_client):
|
||||
def test_post_conversation_model_selection_new(
|
||||
api_client,
|
||||
mock_openai_stream,
|
||||
mock_uuid4,
|
||||
settings,
|
||||
):
|
||||
"""Test the user can select a different model."""
|
||||
@@ -1015,10 +1042,14 @@ def test_post_conversation_model_selection_new(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -1032,7 +1063,6 @@ def test_post_conversation_model_selection_new(
|
||||
def test_post_conversation_data_protocol_no_stream(
|
||||
api_client,
|
||||
mock_openai_no_stream,
|
||||
mock_uuid4,
|
||||
settings,
|
||||
stream_delay,
|
||||
):
|
||||
@@ -1084,6 +1114,9 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
# Wait for the content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
if stream_delay:
|
||||
assert response_content == (
|
||||
'0:"The "\n'
|
||||
@@ -1103,13 +1136,13 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
'0:" sca"\n'
|
||||
'0:"tter"\n'
|
||||
'0:"ing."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
|
||||
)
|
||||
else:
|
||||
assert response_content == (
|
||||
'0:"The sky appears blue due to a phenomenon called Rayleigh scattering."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
|
||||
)
|
||||
|
||||
@@ -1128,8 +1161,9 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Why the sky is blue?",
|
||||
reasoning=None,
|
||||
@@ -1140,8 +1174,9 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
parts=[TextUIPart(type="text", text="Why the sky is blue?")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="The sky appears blue due to a phenomenon called Rayleigh scattering.",
|
||||
reasoning=None,
|
||||
@@ -1215,3 +1250,148 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_conversation_async(api_client, mock_openai_stream, monkeypatch, caplog):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
|
||||
|
||||
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
await api_client.aforce_login(chat_conversation.owner)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(level=logging.DEBUG, logger="chat.views")
|
||||
|
||||
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
assert "Using ASYNC streaming for chat conversation" in caplog.text
|
||||
|
||||
# Wait for the streaming content to be fully received => async iterator -> list
|
||||
# This fails it the streaming is not an async generator
|
||||
response_content = b"".join([content async for content in response.streaming_content]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream.called
|
||||
|
||||
await chat_conversation.arefresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"content": "Today is Friday 25/07/2025.",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"content": "Answer in english.",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
+72
-28
@@ -14,6 +14,7 @@ import httpx
|
||||
import pytest
|
||||
import responses
|
||||
import respx
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
|
||||
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel
|
||||
@@ -32,6 +33,7 @@ from chat.ai_sdk_types import (
|
||||
UIMessage,
|
||||
)
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
# enable database transactions for tests:
|
||||
# transaction=True ensures that the data are available in the database
|
||||
@@ -214,12 +216,11 @@ def fixture_mock_openai_stream():
|
||||
@responses.activate
|
||||
@respx.mock
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def test_post_conversation_with_document_upload( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -273,9 +274,11 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
str_mock_uuid4 = str(mock_uuid4)
|
||||
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
|
||||
@@ -283,19 +286,22 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
'b:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag"}\n'
|
||||
'9:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag",'
|
||||
'"args":{"query":"What does the document say?"}}\n'
|
||||
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf","title":null,"providerMetadata":{}}\n'
|
||||
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf","title":null,'
|
||||
'"providerMetadata":{}}\n'
|
||||
'a:{"toolCallId":"pyd_ai_YYY","result":[{"url":"sample.pdf","content":"This '
|
||||
'is the content of the PDF.","score":0.9}]}\n'
|
||||
"0:\"From the document, I can see that it says 'Hello PDF'.\"\n"
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":100,"completionTokens":20}}\n'
|
||||
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(),
|
||||
content="What does the document say?",
|
||||
reasoning=None,
|
||||
@@ -305,8 +311,10 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="What does the document say?")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[1].id,
|
||||
createdAt=timezone.now(),
|
||||
content="From the document, I can see that it says 'Hello PDF'.",
|
||||
reasoning=None,
|
||||
@@ -318,7 +326,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
ToolInvocationUIPart(
|
||||
type="tool-invocation",
|
||||
toolInvocation=ToolInvocationCall(
|
||||
toolCallId=toolcall_id,
|
||||
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
|
||||
toolName="document_search_rag",
|
||||
args={"query": "What does the document say?"},
|
||||
state="call",
|
||||
@@ -330,7 +338,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
type="source",
|
||||
source=LanguageModelV1Source(
|
||||
sourceType="url",
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[1].parts[2].source.id,
|
||||
url="sample.pdf",
|
||||
title=None,
|
||||
providerMetadata={},
|
||||
@@ -375,6 +383,20 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timezone_now,
|
||||
},
|
||||
{
|
||||
"content": "When you receive a result from the summarization tool, "
|
||||
"you MUST return it directly to the user without any "
|
||||
"modification, paraphrasing, or additional "
|
||||
"summarization.The tool already produces optimized "
|
||||
"summaries that should be presented verbatim.You may "
|
||||
"translate the summary if required, but you MUST "
|
||||
"preserve all the information from the original "
|
||||
"summary.You may add a follow-up question after the "
|
||||
"summary if needed.",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timezone_now,
|
||||
},
|
||||
{
|
||||
"content": ["What does the document say?"],
|
||||
"part_kind": "user-prompt",
|
||||
@@ -391,7 +413,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
"args": '{"query": "What does the document say?"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": toolcall_id,
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "document_search_rag",
|
||||
}
|
||||
],
|
||||
@@ -425,7 +447,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
"metadata": {"sources": ["sample.pdf"]},
|
||||
"part_kind": "tool-return",
|
||||
"timestamp": timezone_now,
|
||||
"tool_call_id": toolcall_id,
|
||||
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "document_search_rag",
|
||||
}
|
||||
],
|
||||
@@ -461,13 +483,12 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
|
||||
@responses.activate
|
||||
@respx.mock
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def test_post_conversation_with_document_upload_feature_disabled( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
caplog,
|
||||
mock_openai_stream, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
feature_flags,
|
||||
mock_uuid4,
|
||||
):
|
||||
"""
|
||||
Test POST to /api/v1/chats/{pk}/conversation/ with a PDF document while feature is disabled.
|
||||
@@ -512,10 +533,14 @@ def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR09
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"From the document, I can see that "\n'
|
||||
"0:\"it says 'Hello PDF'.\"\n"
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
|
||||
)
|
||||
|
||||
@@ -531,7 +556,6 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
mock_summarization_agent, # pylint: disable=unused-argument
|
||||
):
|
||||
@@ -586,29 +610,33 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
str_mock_uuid4 = str(mock_uuid4)
|
||||
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
|
||||
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
|
||||
'b:{"toolCallId":"pyd_ai_YYY","toolName":"summarize"}\n'
|
||||
'9:{"toolCallId":"pyd_ai_YYY","toolName":"summarize","args":{}}\n'
|
||||
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf.md",'
|
||||
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf.md",'
|
||||
'"title":null,"providerMetadata":{}}\n'
|
||||
'a:{"toolCallId":"pyd_ai_YYY","result":"The '
|
||||
'document discusses various topics."}\n'
|
||||
'0:"The document discusses various topics."\n'
|
||||
'f:{"messageId":"XXX"}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":201,"completionTokens":13}}\n'
|
||||
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(),
|
||||
content="Make a summary of this document.",
|
||||
reasoning=None,
|
||||
@@ -618,8 +646,10 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Make a summary of this document.")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[1].id,
|
||||
createdAt=timezone.now(),
|
||||
content="The document discusses various topics.",
|
||||
reasoning=None,
|
||||
@@ -631,7 +661,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
ToolInvocationUIPart(
|
||||
type="tool-invocation",
|
||||
toolInvocation=ToolInvocationCall(
|
||||
toolCallId=toolcall_id,
|
||||
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
|
||||
toolName="summarize",
|
||||
args={},
|
||||
state="call",
|
||||
@@ -643,7 +673,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
type="source",
|
||||
source=LanguageModelV1Source(
|
||||
sourceType="url",
|
||||
id=str_mock_uuid4,
|
||||
id=chat_conversation.messages[1].parts[2].source.id,
|
||||
url="sample.pdf.md", # might be fixed in the future
|
||||
title=None,
|
||||
providerMetadata={},
|
||||
@@ -688,6 +718,20 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timezone_now,
|
||||
},
|
||||
{
|
||||
"content": "When you receive a result from the summarization tool, "
|
||||
"you MUST return it directly to the user without any "
|
||||
"modification, paraphrasing, or additional "
|
||||
"summarization.The tool already produces optimized "
|
||||
"summaries that should be presented verbatim.You may "
|
||||
"translate the summary if required, but you MUST "
|
||||
"preserve all the information from the original "
|
||||
"summary.You may add a follow-up question after the "
|
||||
"summary if needed.",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timezone_now,
|
||||
},
|
||||
{
|
||||
"content": ["Make a summary of this document."],
|
||||
"part_kind": "user-prompt",
|
||||
@@ -704,7 +748,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"args": "{}",
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": toolcall_id,
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "summarize",
|
||||
}
|
||||
],
|
||||
@@ -732,7 +776,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"metadata": {"sources": ["sample.pdf.md"]},
|
||||
"part_kind": "tool-return",
|
||||
"timestamp": timezone_now,
|
||||
"tool_call_id": toolcall_id,
|
||||
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "summarize",
|
||||
}
|
||||
],
|
||||
|
||||
+91
-28
@@ -1,5 +1,8 @@
|
||||
"""Unit tests for chat conversation actions with document URL."""
|
||||
|
||||
import uuid
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -7,6 +10,7 @@ from django.utils import formats, timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import ModelRequest, RequestUsage
|
||||
from pydantic_ai.messages import (
|
||||
@@ -26,6 +30,7 @@ from chat.ai_sdk_types import (
|
||||
UIMessage,
|
||||
)
|
||||
from chat.factories import ChatConversationAttachmentFactory, ChatConversationFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
# enable database transactions for tests:
|
||||
# transaction=True ensures that the data are available in the database
|
||||
@@ -60,7 +65,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
|
||||
api_client,
|
||||
sample_document_content,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -161,20 +165,26 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
|
||||
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
|
||||
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
|
||||
'0:"This is a document about a single pixel."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
@@ -188,8 +198,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
|
||||
TextUIPart(type="text", text="What is in this document?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id,
|
||||
createdAt=timezone.now(),
|
||||
content="This is a document about a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -277,7 +289,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_local_document_wrong_url(
|
||||
api_client,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -286,7 +297,7 @@ def test_post_conversation_with_local_document_wrong_url(
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
|
||||
document_url = f"/media-key/{mock_uuid4}/sample.pdf"
|
||||
document_url = f"/media-key/{uuid.uuid4()}/sample.pdf"
|
||||
|
||||
message = UIMessage(
|
||||
id="1",
|
||||
@@ -325,10 +336,14 @@ def test_post_conversation_with_local_document_wrong_url(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
|
||||
f'a:{{"toolCallId":"{mock_uuid4}",'
|
||||
'a:{"toolCallId":"XXX",'
|
||||
'"result":{"state":"error","error":"Document '
|
||||
'URL does not belong to the conversation."}}\n'
|
||||
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
@@ -342,7 +357,6 @@ def test_post_conversation_with_local_document_wrong_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_remote_document_url(
|
||||
api_client,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -390,10 +404,14 @@ def test_post_conversation_with_remote_document_url(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
|
||||
f'a:{{"toolCallId":"{mock_uuid4}",'
|
||||
'a:{"toolCallId":"XXX",'
|
||||
'"result":{"state":"error","error":"External document '
|
||||
'URL are not accepted yet."}}\n'
|
||||
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
@@ -408,7 +426,6 @@ def test_post_conversation_with_remote_document_url(
|
||||
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -421,7 +438,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
owner__language="en-us",
|
||||
messages=[
|
||||
UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=str(uuid.uuid4()),
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
@@ -436,7 +453,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
],
|
||||
),
|
||||
UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=str(uuid.uuid4()),
|
||||
createdAt=timezone.now(),
|
||||
content="This is a document about a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -602,17 +619,23 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"This is a document of square, very small and nice."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2 + 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
@@ -626,8 +649,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
TextUIPart(type="text", text="What is in this document?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id,
|
||||
createdAt=timezone.now(),
|
||||
content="This is a document about a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -639,8 +664,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
TextUIPart(type="text", text="This is a document about a single pixel."),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[2].id == IsUUID(4)
|
||||
assert chat_conversation.messages[2] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[2].id,
|
||||
createdAt=timezone.now(),
|
||||
content="Give more details about this document.",
|
||||
reasoning=None,
|
||||
@@ -652,8 +679,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
TextUIPart(type="text", text="Give more details about this document."),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[3].id == IsUUID(4)
|
||||
assert chat_conversation.messages[3] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[3].id,
|
||||
createdAt=timezone.now(),
|
||||
content="This is a document of square, very small and nice.",
|
||||
reasoning=None,
|
||||
@@ -782,10 +811,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
("data.csv", "text/csv"),
|
||||
],
|
||||
)
|
||||
def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
file_name,
|
||||
content_type,
|
||||
@@ -860,6 +888,19 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
|
||||
),
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
SystemPromptPart(
|
||||
content=(
|
||||
"When you receive a result from the summarization tool, you MUST "
|
||||
"return it directly to the user without any modification, "
|
||||
"paraphrasing, or additional summarization."
|
||||
"The tool already produces optimized summaries that should "
|
||||
"be presented verbatim."
|
||||
"You may translate the summary if required, but you MUST preserve "
|
||||
"all the information from the original summary."
|
||||
"You may add a follow-up question after the summary if needed."
|
||||
),
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
UserPromptPart(
|
||||
content=[
|
||||
"What is in this document?",
|
||||
@@ -887,20 +928,26 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
|
||||
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
|
||||
f'"args":{{"documents":[{{"identifier":"{file_name}"}}]}}}}\n'
|
||||
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
|
||||
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
|
||||
'0:"This is a document about you."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":7}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id,
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
@@ -912,8 +959,10 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
|
||||
TextUIPart(type="text", text="What is in this document?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id,
|
||||
createdAt=timezone.now(),
|
||||
content="This is a document about you.",
|
||||
reasoning=None,
|
||||
@@ -962,6 +1011,20 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"content": "When you receive a result from the summarization "
|
||||
"tool, you MUST return it directly to the user without "
|
||||
"any modification, paraphrasing, or additional "
|
||||
"summarization.The tool already produces optimized "
|
||||
"summaries that should be presented verbatim.You may "
|
||||
"translate the summary if required, but you MUST "
|
||||
"preserve all the information from the original "
|
||||
"summary.You may add a follow-up question after the "
|
||||
"summary if needed.",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
"What is in this document?",
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
@@ -18,6 +19,7 @@ from chat.ai_sdk_types import (
|
||||
UIMessage,
|
||||
)
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
# enable database transactions for tests:
|
||||
# transaction=True ensures that the data are available in the database
|
||||
@@ -200,7 +202,7 @@ def history_conversation_fixture():
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_data_protocol_with_history(
|
||||
api_client, mock_openai_stream, mock_uuid4, history_conversation
|
||||
api_client, mock_openai_stream, history_conversation
|
||||
):
|
||||
"""Test posting messages to a conversation with history using the 'data' protocol."""
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
@@ -226,10 +228,14 @@ def test_post_conversation_data_protocol_with_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -259,8 +265,9 @@ def test_post_conversation_data_protocol_with_history(
|
||||
assert len(history_conversation.messages) == 6
|
||||
|
||||
# Verify the most recent message is the new one
|
||||
assert history_conversation.messages[4].id == IsUUID(4)
|
||||
assert history_conversation.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
@@ -271,8 +278,9 @@ def test_post_conversation_data_protocol_with_history(
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert history_conversation.messages[5].id == IsUUID(4)
|
||||
assert history_conversation.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
@@ -290,7 +298,7 @@ def test_post_conversation_data_protocol_with_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_text_protocol_with_history(
|
||||
api_client, mock_openai_stream, mock_uuid4, history_conversation
|
||||
api_client, mock_openai_stream, history_conversation
|
||||
):
|
||||
"""Test posting messages to a conversation with history using the 'text' protocol."""
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=text"
|
||||
@@ -335,8 +343,9 @@ def test_post_conversation_text_protocol_with_history(
|
||||
assert len(history_conversation.messages) == 6
|
||||
|
||||
# Verify the most recent messages are the new ones
|
||||
assert history_conversation.messages[4].id == IsUUID(4)
|
||||
assert history_conversation.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
@@ -347,8 +356,9 @@ def test_post_conversation_text_protocol_with_history(
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert history_conversation.messages[5].id == IsUUID(4)
|
||||
assert history_conversation.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
@@ -363,7 +373,7 @@ def test_post_conversation_text_protocol_with_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_with_image_with_history(
|
||||
api_client, mock_openai_stream_image, mock_uuid4, history_conversation
|
||||
api_client, mock_openai_stream_image, history_conversation
|
||||
):
|
||||
"""
|
||||
Ensure an image URL is correctly forwarded to the AI service with a conversation with history.
|
||||
@@ -403,10 +413,14 @@ def test_post_conversation_with_image_with_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"I see a cat"\n'
|
||||
'0:" in the picture."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -452,8 +466,9 @@ def test_post_conversation_with_image_with_history(
|
||||
assert len(history_conversation.messages) == 6
|
||||
|
||||
# Verify the most recent message has the image attachment
|
||||
assert history_conversation.messages[4].id == IsUUID(4)
|
||||
assert history_conversation.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello, what do you see on this picture?",
|
||||
reasoning=None,
|
||||
@@ -474,8 +489,9 @@ def test_post_conversation_with_image_with_history(
|
||||
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
|
||||
)
|
||||
|
||||
assert history_conversation.messages[5].id == IsUUID(4)
|
||||
assert history_conversation.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="I see a cat in the picture.",
|
||||
reasoning=None,
|
||||
@@ -490,7 +506,7 @@ def test_post_conversation_with_image_with_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_tool_call_with_history(
|
||||
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
|
||||
api_client, mock_openai_stream_tool, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Ensure tool calls are correctly forwarded and streamed back with a conversation with history.
|
||||
@@ -521,6 +537,10 @@ def test_post_conversation_tool_call_with_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
|
||||
'"get_current_weather"}\n'
|
||||
@@ -529,7 +549,7 @@ def test_post_conversation_tool_call_with_history(
|
||||
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
|
||||
'"Paris","temperature":22,"unit":"celsius"}}\n'
|
||||
'0:"The current weather in Paris is nice"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -561,8 +581,9 @@ def test_post_conversation_tool_call_with_history(
|
||||
assert len(history_conversation.messages) == 6
|
||||
|
||||
# Verify the most recent message is the new one with tool invocation
|
||||
assert history_conversation.messages[4].id == IsUUID(4)
|
||||
assert history_conversation.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Weather in Paris?",
|
||||
reasoning=None,
|
||||
@@ -573,8 +594,9 @@ def test_post_conversation_tool_call_with_history(
|
||||
parts=[TextUIPart(type="text", text="Weather in Paris?")],
|
||||
)
|
||||
|
||||
assert history_conversation.messages[5].id == IsUUID(4)
|
||||
assert history_conversation.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="The current weather in Paris is nice",
|
||||
reasoning=None,
|
||||
@@ -606,7 +628,7 @@ def test_post_conversation_tool_call_with_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_tool_call_fails_with_history(
|
||||
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
|
||||
api_client, mock_openai_stream_tool, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Ensure tool calls are correctly forwarded and streamed back when failing with a
|
||||
@@ -638,6 +660,10 @@ def test_post_conversation_tool_call_fails_with_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
|
||||
'"get_current_weather"}\n'
|
||||
@@ -646,7 +672,7 @@ def test_post_conversation_tool_call_fails_with_history(
|
||||
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
|
||||
"name: 'get_current_weather'. No tools available.\"}\n"
|
||||
'0:"I cannot give you an answer to that."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -678,8 +704,9 @@ def test_post_conversation_tool_call_fails_with_history(
|
||||
assert len(history_conversation.messages) == 6
|
||||
|
||||
# Verify the most recent message is the new one with tool invocation
|
||||
assert history_conversation.messages[4].id == IsUUID(4)
|
||||
assert history_conversation.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Weather in Paris?",
|
||||
reasoning=None,
|
||||
@@ -690,8 +717,9 @@ def test_post_conversation_tool_call_fails_with_history(
|
||||
parts=[TextUIPart(type="text", text="Weather in Paris?")],
|
||||
)
|
||||
|
||||
assert history_conversation.messages[5].id == IsUUID(4)
|
||||
assert history_conversation.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="I cannot give you an answer to that.",
|
||||
reasoning=None,
|
||||
@@ -1147,7 +1175,7 @@ def history_conversation_with_tool_fixture():
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_with_existing_image_history(
|
||||
api_client, mock_openai_stream, mock_uuid4, history_conversation_with_image
|
||||
api_client, mock_openai_stream, history_conversation_with_image
|
||||
):
|
||||
"""Test posting a message to a conversation that already has images in its history."""
|
||||
url = f"/api/v1.0/chats/{history_conversation_with_image.pk}/conversation/?protocol=data"
|
||||
@@ -1173,10 +1201,14 @@ def test_post_conversation_with_existing_image_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -1207,8 +1239,9 @@ def test_post_conversation_with_existing_image_history(
|
||||
assert len(history_conversation_with_image.messages) == 6
|
||||
|
||||
# Verify the most recent messages are the new ones
|
||||
assert history_conversation_with_image.messages[4].id == IsUUID(4)
|
||||
assert history_conversation_with_image.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_image.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="What was in that image again?",
|
||||
reasoning=None,
|
||||
@@ -1219,8 +1252,9 @@ def test_post_conversation_with_existing_image_history(
|
||||
parts=[TextUIPart(type="text", text="What was in that image again?")],
|
||||
)
|
||||
|
||||
assert history_conversation_with_image.messages[5].id == IsUUID(4)
|
||||
assert history_conversation_with_image.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_image.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
@@ -1238,7 +1272,7 @@ def test_post_conversation_with_existing_image_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_with_existing_tool_history(
|
||||
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation_with_tool
|
||||
api_client, mock_openai_stream_tool, settings, history_conversation_with_tool
|
||||
):
|
||||
"""Test posting a message to a conversation that already has tool calls in its history."""
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather"]
|
||||
@@ -1266,6 +1300,10 @@ def test_post_conversation_with_existing_tool_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
|
||||
'"get_current_weather"}\n'
|
||||
@@ -1274,7 +1312,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
|
||||
'"Paris","temperature":22,"unit":"celsius"}}\n'
|
||||
'0:"The current weather in Paris is nice"\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -1294,8 +1332,9 @@ def test_post_conversation_with_existing_tool_history(
|
||||
assert len(history_conversation_with_tool.messages) == 6
|
||||
|
||||
# Verify the most recent message is the new one with tool invocation
|
||||
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
|
||||
assert history_conversation_with_tool.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_tool.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="How about Paris weather?",
|
||||
reasoning=None,
|
||||
@@ -1306,8 +1345,9 @@ def test_post_conversation_with_existing_tool_history(
|
||||
parts=[TextUIPart(type="text", text="How about Paris weather?")],
|
||||
)
|
||||
|
||||
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
|
||||
assert history_conversation_with_tool.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_tool.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="The current weather in Paris is nice",
|
||||
reasoning=None,
|
||||
@@ -1417,7 +1457,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
api_client, mock_openai_stream_image, mock_uuid4, history_conversation_with_tool
|
||||
api_client, mock_openai_stream_image, history_conversation_with_tool
|
||||
):
|
||||
"""Test adding an image to a conversation that already has tool calls in its history."""
|
||||
url = f"/api/v1.0/chats/{history_conversation_with_tool.pk}/conversation/?protocol=data"
|
||||
@@ -1455,10 +1495,14 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"I see a cat"\n'
|
||||
'0:" in the picture."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
@@ -1484,8 +1528,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
assert len(history_conversation_with_tool.messages) == 6
|
||||
|
||||
# Verify the most recent message has the image attachment
|
||||
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
|
||||
assert history_conversation_with_tool.messages[4] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_tool.messages[4].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="How's the weather in this image?",
|
||||
reasoning=None,
|
||||
@@ -1506,8 +1551,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
parts=[TextUIPart(type="text", text="How's the weather in this image?")],
|
||||
)
|
||||
|
||||
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
|
||||
assert history_conversation_with_tool.messages[5] == UIMessage(
|
||||
id=str(mock_uuid4), # Mocked UUID
|
||||
id=history_conversation_with_tool.messages[5].id,
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="I see a cat in the picture.",
|
||||
reasoning=None,
|
||||
|
||||
+51
-19
@@ -1,8 +1,11 @@
|
||||
"""Unit tests for chat conversation actions with image URL."""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import ModelRequest, RequestUsage
|
||||
from pydantic_ai.messages import (
|
||||
@@ -22,6 +25,7 @@ from chat.ai_sdk_types import (
|
||||
UIMessage,
|
||||
)
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
# enable database transactions for tests:
|
||||
# transaction=True ensures that the data are available in the database
|
||||
@@ -53,7 +57,6 @@ def fixture_sample_image_content():
|
||||
@freeze_time("2025-10-18T20:48:20.286204Z")
|
||||
def test_post_conversation_with_local_image_url(
|
||||
api_client,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -131,17 +134,23 @@ def test_post_conversation_with_local_image_url(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"This is an image of a single pixel."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this image?",
|
||||
reasoning=None,
|
||||
@@ -155,8 +164,10 @@ def test_post_conversation_with_local_image_url(
|
||||
TextUIPart(type="text", text="What is in this image?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="This is an image of a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -238,7 +249,6 @@ def test_post_conversation_with_local_image_url(
|
||||
def test_post_conversation_with_local_image_wrong_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -247,7 +257,7 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
|
||||
image_url = f"/media-key/{mock_uuid4}/sample.png"
|
||||
image_url = f"/media-key/{uuid.uuid4()}/sample.png"
|
||||
|
||||
message = UIMessage(
|
||||
id="1",
|
||||
@@ -308,9 +318,13 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"cannot read image."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":4}}\n'
|
||||
)
|
||||
|
||||
@@ -322,7 +336,6 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
def test_post_conversation_with_remote_image_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -392,17 +405,23 @@ def test_post_conversation_with_remote_image_url(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"This is an image of a single pixel."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this image?",
|
||||
reasoning=None,
|
||||
@@ -416,8 +435,10 @@ def test_post_conversation_with_remote_image_url(
|
||||
TextUIPart(type="text", text="What is in this image?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="This is an image of a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -435,7 +456,6 @@ def test_post_conversation_with_remote_image_url(
|
||||
def test_post_conversation_with_local_image_url_in_history(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
mock_uuid4,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -448,7 +468,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
owner__language="en-us",
|
||||
messages=[
|
||||
UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=str(uuid.uuid4()),
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this image?",
|
||||
reasoning=None,
|
||||
@@ -463,7 +483,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
],
|
||||
),
|
||||
UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=str(uuid.uuid4()),
|
||||
createdAt=timezone.now(),
|
||||
content="This is an image of a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -629,17 +649,23 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"This is an image of square, very small and nice."\n'
|
||||
f'f:{{"messageId":"{mock_uuid4}"}}\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
chat_conversation.refresh_from_db()
|
||||
assert len(chat_conversation.messages) == 2 + 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[0].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this image?",
|
||||
reasoning=None,
|
||||
@@ -653,8 +679,10 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
TextUIPart(type="text", text="What is in this image?"),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[1].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="This is an image of a single pixel.",
|
||||
reasoning=None,
|
||||
@@ -666,8 +694,10 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
TextUIPart(type="text", text="This is an image of a single pixel."),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[2].id == IsUUID(4)
|
||||
assert chat_conversation.messages[2] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[2].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="Give more details about this image.",
|
||||
reasoning=None,
|
||||
@@ -679,8 +709,10 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
TextUIPart(type="text", text="Give more details about this image."),
|
||||
],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[3].id == IsUUID(4)
|
||||
assert chat_conversation.messages[3] == UIMessage(
|
||||
id=str(mock_uuid4),
|
||||
id=chat_conversation.messages[3].id, # don't test the value directly
|
||||
createdAt=timezone.now(),
|
||||
content="This is an image of square, very small and nice.",
|
||||
reasoning=None,
|
||||
|
||||
@@ -210,7 +210,10 @@ def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolR
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception("Error fetching/storing document: %s", e)
|
||||
|
||||
rag_results = document_store.search(query)
|
||||
rag_results = document_store.search(
|
||||
query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Chat API implementation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
@@ -178,10 +179,32 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
or self.request.LANGUAGE_CODE # from the LocaleMiddleware
|
||||
),
|
||||
)
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(messages, force_web_search=force_web_search)
|
||||
|
||||
# This environment variable allows switching between sync and async streaming modes
|
||||
# based on the server configuration. Tests run in sync mode (WSGI), while
|
||||
# production uses async mode (Uvicorn ASGI).
|
||||
is_async_mode = os.environ.get("PYTHON_SERVER_MODE", "sync") == "async"
|
||||
|
||||
if is_async_mode:
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else:
|
||||
logger.debug("Using SYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content,
|
||||
@@ -402,7 +425,7 @@ class ChatConversationAttachmentViewSet(
|
||||
if settings.POSTHOG_KEY:
|
||||
posthog.capture(
|
||||
"item_uploaded",
|
||||
distinct_id=request.user.email,
|
||||
distinct_id=request.user.pk, # same as set by the frontend
|
||||
properties={
|
||||
"id": attachment.pk,
|
||||
"file_name": attachment.file_name,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
ASGI config for conversations project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from configurations.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conversations.settings")
|
||||
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
os.environ.setdefault("PYTHON_SERVER_MODE", "async")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -23,6 +23,13 @@ class BraveSettings:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# For web_search_brave_with_document_backend: number of chunks to retrieve RAG search
|
||||
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = values.IntegerValue(
|
||||
default=10,
|
||||
environ_name="BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# For optimal performance, BRAVE_MAX_WORKERS should be equal to BRAVE_MAX_RESULTS
|
||||
# also considering the number of concurrent requests your server can handle.
|
||||
BRAVE_MAX_WORKERS = values.IntegerValue(
|
||||
|
||||
@@ -267,7 +267,7 @@ class Base(BraveSettings, Configuration):
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
# ("de-de", "Deutsch"),
|
||||
# ("nl-nl", "Nederlands"),
|
||||
("nl-nl", "Nederlands"),
|
||||
# ("es-es", "Español"),
|
||||
)
|
||||
)
|
||||
@@ -312,8 +312,7 @@ class Base(BraveSettings, Configuration):
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"posthog.integrations.django.PosthogContextMiddleware",
|
||||
"core.middleware.PostHogMiddleware",
|
||||
"core.posthog.AsyncPosthogContextMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
]
|
||||
@@ -483,7 +482,11 @@ class Base(BraveSettings, Configuration):
|
||||
THUMBNAIL_ALIASES = {}
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_ENGINE = values.Value(
|
||||
"django.contrib.sessions.backends.cache",
|
||||
environ_name="SESSION_ENGINE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||
@@ -503,6 +506,7 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
||||
@@ -602,6 +606,22 @@ class Base(BraveSettings, Configuration):
|
||||
default=False, environ_name="ACTIVATION_REQUIRED", environ_prefix=None
|
||||
)
|
||||
|
||||
BREVO_API_KEY = values.Value(
|
||||
default=None,
|
||||
environ_name="BREVO_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BREVO_FOLLOWUP_LIST_ID = values.Value(
|
||||
default=None,
|
||||
environ_name="BREVO_FOLLOWUP_LIST_ID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BREVO_WAITING_LIST_ID = values.Value(
|
||||
default=None,
|
||||
environ_name="BREVO_WAITING_LIST_ID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# AI service
|
||||
_llm_configuration_file_path = values.Value(
|
||||
os.path.join(BASE_DIR, "conversations/configuration/llm/default.json"),
|
||||
@@ -875,6 +895,13 @@ USER QUESTION:
|
||||
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# WARNING: Testing purpose only. Do not use in production.
|
||||
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="WARNING_MOCK_CONVERSATION_AGENT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
@@ -13,5 +13,6 @@ from configurations.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conversations.settings")
|
||||
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
os.environ.setdefault("PYTHON_SERVER_MODE", "sync")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
@@ -17,8 +17,9 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"sub",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name", "sub"]
|
||||
|
||||
|
||||
class UserLightSerializer(UserSerializer):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Authentication Backends for the Conversations core app."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
@@ -10,23 +9,11 @@ from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.brevo import add_user_to_brevo_list
|
||||
from core.models import DuplicateEmailError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Settings renamed warnings
|
||||
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
|
||||
logger.warning(
|
||||
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed "
|
||||
"to OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
|
||||
)
|
||||
|
||||
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
|
||||
logger.warning(
|
||||
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed "
|
||||
"to OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -70,3 +57,12 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
return super().create_user(
|
||||
claims | {"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS}
|
||||
)
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
"""Authenticate user and add they to Brevo list if activation not required."""
|
||||
user = super().authenticate(request, **kwargs)
|
||||
|
||||
if user and not settings.ACTIVATION_REQUIRED and settings.BREVO_FOLLOWUP_LIST_ID:
|
||||
add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
|
||||
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Functions for interacting with Brevo API to manage contacts in a waiting list."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_contact_in_brevo(email: str) -> bool:
|
||||
"""
|
||||
Create a contact in Brevo.
|
||||
|
||||
Args:
|
||||
email (str): The email address of the user.
|
||||
|
||||
"""
|
||||
api_key = settings.BREVO_API_KEY
|
||||
if not api_key:
|
||||
logger.info("Brevo API key not configured: skipping creating contact")
|
||||
return False
|
||||
|
||||
url = "https://api.brevo.com/v3/contacts"
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"api-key": api_key,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"email": email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=5)
|
||||
except requests.RequestException as e:
|
||||
logger.exception(e)
|
||||
return False
|
||||
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"Error creating contact in Brevo %s: (%s) %s",
|
||||
email,
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def add_user_to_brevo_list(emails: List[str], list_id: Optional[str]) -> None:
|
||||
"""
|
||||
Add email list to a Brevo list.
|
||||
|
||||
Args:
|
||||
emails (List[str]): The email address(es) of the user(s).
|
||||
list_id (str): The Brevo waiting list ID, can be None if not configured.
|
||||
|
||||
"""
|
||||
api_key = settings.BREVO_API_KEY
|
||||
if not api_key or not list_id:
|
||||
logger.info("Brevo API key or list ID not configured: skipping adding contact")
|
||||
return
|
||||
|
||||
for email in emails:
|
||||
# Ensure the contact exists before adding to the list
|
||||
# `emails` contains several emails only when used from the admin interface bulk action
|
||||
if not create_contact_in_brevo(email):
|
||||
logger.error("Failed to create contact %s in Brevo, skipping adding to list", email)
|
||||
return
|
||||
|
||||
url = f"https://api.brevo.com/v3/contacts/lists/{list_id}/contacts/add"
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"api-key": api_key,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"emails": emails,
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=5)
|
||||
except requests.RequestException as e:
|
||||
logger.exception(e)
|
||||
return
|
||||
|
||||
if response.status_code != 201:
|
||||
logger.error(
|
||||
"Error adding contacts to Brevo (%s) %s: (%s) %s",
|
||||
list_id,
|
||||
emails,
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
|
||||
|
||||
def remove_user_from_brevo_list(emails: List[str], list_id: Optional[str]) -> None:
|
||||
"""
|
||||
Remove email list from a Brevo list.
|
||||
|
||||
Args:
|
||||
emails (List[str]): The email address(es) of the user(s).
|
||||
list_id (str): The Brevo waiting list ID, can be None if not configured.
|
||||
|
||||
"""
|
||||
api_key = settings.BREVO_API_KEY
|
||||
if not api_key or not list_id:
|
||||
logger.info("Brevo API key or list ID not configured: skipping removing contact")
|
||||
return
|
||||
|
||||
url = f"https://api.brevo.com/v3/contacts/lists/{list_id}/contacts/remove"
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"api-key": api_key,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"emails": emails,
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=5)
|
||||
except requests.RequestException as e:
|
||||
logger.exception(e)
|
||||
return
|
||||
if response.status_code != 201:
|
||||
logger.error(
|
||||
"Error removing contacts from Brevo (%s) %s: (%s) %s",
|
||||
list_id,
|
||||
emails,
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
@@ -9,7 +9,6 @@ User = get_user_model()
|
||||
|
||||
try:
|
||||
import posthog
|
||||
from posthog.contexts import get_tags
|
||||
except ImportError:
|
||||
posthog = None
|
||||
|
||||
@@ -39,8 +38,7 @@ def is_feature_enabled(
|
||||
if posthog is not None:
|
||||
return posthog.feature_enabled(
|
||||
frontend_feature_name(feature_name),
|
||||
user.email,
|
||||
person_properties={"$host": get_tags().get("$host")},
|
||||
user.pk, # same as set by the frontend
|
||||
)
|
||||
|
||||
# No feature flag manager
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Custom middleware(s) for the project."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import MiddlewareNotUsed
|
||||
|
||||
# We are importing posthog here, but it will only be used if the POSTHOG_KEY is set in settings.
|
||||
# The settings are checked before any attempt to use posthog.
|
||||
try:
|
||||
import posthog
|
||||
except ImportError:
|
||||
posthog = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostHogMiddleware:
|
||||
"""
|
||||
This middleware is used to alias the user's distinct_id from the PostHog cookie
|
||||
with their email address when they are authenticated. This allows us to track
|
||||
users across different sessions and devices.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
"""
|
||||
Initialize the middleware and disable it if PostHog is not configured.
|
||||
"""
|
||||
if posthog is None or not settings.POSTHOG_KEY:
|
||||
raise MiddlewareNotUsed("POSTHOG_KEY must be set in settings to use PostHogMiddleware.")
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
"""
|
||||
Process the request to handle the PostHog alias.
|
||||
"""
|
||||
if posthog is not None and settings.POSTHOG_KEY:
|
||||
posthog_cookie = request.COOKIES.get(f"ph_{posthog.project_api_key}_posthog")
|
||||
if posthog_cookie:
|
||||
try:
|
||||
cookie_dict = json.loads(unquote(posthog_cookie))
|
||||
if (
|
||||
cookie_dict.get("distinct_id")
|
||||
and request.user
|
||||
and request.user.is_authenticated
|
||||
):
|
||||
posthog.alias(cookie_dict["distinct_id"], request.user.email)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# If the cookie is malformed or doesn't contain the expected
|
||||
# keys, we can't do anything with it, so we ignore it.
|
||||
logger.warning("Malformed PostHog cookie: %s", posthog_cookie)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
@@ -118,7 +118,7 @@ class Migration(migrations.Migration):
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
# ("de-de", "Deutsch"),
|
||||
# ("nl-nl", "Nederlands"),
|
||||
("nl-nl", "Nederlands"),
|
||||
# ("es-es", "Español"),
|
||||
],
|
||||
default=None,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Asynchronous PostHog context middleware for Django.
|
||||
|
||||
Since https://github.com/PostHog/posthog-python/commit/6af129f41413f4f7d55731763ff42e4c0fb66844
|
||||
The official PostHog Django middleware supports both sync and async requests,
|
||||
but the call to request.user fails in async contexts.
|
||||
|
||||
This is an horrible patch while waiting for an official fix.
|
||||
Follow https://github.com/PostHog/posthog-python/issues/355
|
||||
"""
|
||||
|
||||
from posthog import contexts
|
||||
from posthog.integrations.django import PosthogContextMiddleware
|
||||
|
||||
|
||||
class AsyncPosthogContextMiddleware(PosthogContextMiddleware):
|
||||
"""
|
||||
Asynchronous Django middleware to extract PostHog context from HTTP requests.
|
||||
|
||||
While the original PosthogContextMiddleware is supposed to manage both sync and async requests,
|
||||
the call to request.user fails in async contexts.
|
||||
"""
|
||||
|
||||
async def extract_tags_async(self, request):
|
||||
"""Extract tags from the HTTP request asynchronously."""
|
||||
tags = {}
|
||||
|
||||
(user_id, user_email) = await self.extract_request_user_async(request)
|
||||
|
||||
# Extract session ID from X-POSTHOG-SESSION-ID header
|
||||
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
||||
if session_id:
|
||||
contexts.set_context_session(session_id)
|
||||
|
||||
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
|
||||
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
|
||||
if distinct_id:
|
||||
contexts.identify_context(distinct_id)
|
||||
|
||||
# Extract user email
|
||||
if user_email:
|
||||
tags["email"] = user_email
|
||||
|
||||
# Extract current URL
|
||||
absolute_url = request.build_absolute_uri()
|
||||
if absolute_url:
|
||||
tags["$current_url"] = absolute_url
|
||||
|
||||
# Extract request method
|
||||
if request.method:
|
||||
tags["$request_method"] = request.method
|
||||
|
||||
# Extract request path
|
||||
if request.path:
|
||||
tags["$request_path"] = request.path
|
||||
|
||||
# Extract IP address
|
||||
ip_address = request.headers.get("X-Forwarded-For")
|
||||
if ip_address:
|
||||
tags["$ip_address"] = ip_address
|
||||
|
||||
# Extract user agent
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
if user_agent:
|
||||
tags["$user_agent"] = user_agent
|
||||
|
||||
# Apply extra tags if configured
|
||||
if self.extra_tags:
|
||||
extra = self.extra_tags(request)
|
||||
if extra:
|
||||
tags.update(extra)
|
||||
|
||||
# Apply tag mapping if configured
|
||||
if self.tag_map:
|
||||
tags = self.tag_map(tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def extract_request_user_async(self, request):
|
||||
"""Extract user ID and email from the HTTP request asynchronously."""
|
||||
user_id = None
|
||||
email = None
|
||||
|
||||
user = await request.auser()
|
||||
|
||||
if user and getattr(user, "is_authenticated", False):
|
||||
try:
|
||||
user_id = str(user.pk)
|
||||
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
try:
|
||||
email = str(user.email)
|
||||
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
return user_id, email
|
||||
|
||||
async def __acall__(self, request):
|
||||
"""
|
||||
Asynchronous entry point for async request handling.
|
||||
|
||||
This method is called when the middleware chain is async.
|
||||
"""
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return await self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
tags = await self.extract_tags_async(request)
|
||||
for k, v in tags.items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return await self.get_response(request)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
@@ -57,7 +58,7 @@ def test_authentication_getter_existing_user_via_email(django_assert_num_queries
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(3): # user by sub, user by mail, update sub
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
|
||||
user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert user == db_user
|
||||
@@ -204,7 +205,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(3): # user by sub, unicity check, update sub
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -244,7 +245,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -491,3 +492,82 @@ def test_authentication_session_tokens(django_assert_num_queries, monkeypatch, r
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_authentication_user_added_to_brevo(monkeypatch, rf, settings):
|
||||
"""
|
||||
Test that a user is added to the Brevo follow-up list upon authentication.
|
||||
"""
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
|
||||
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_FOLLOWUP_LIST_ID = "follow-up-list-id"
|
||||
settings.ACTIVATION_REQUIRED = False
|
||||
|
||||
brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
brevo_add_to_list = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/follow-up-list-id/contacts/add",
|
||||
status=400,
|
||||
)
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
|
||||
request.session = {}
|
||||
|
||||
def verify_token_mocked(*args, **kwargs):
|
||||
return {"sub": "123", "email": "test@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||
json={
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(settings.OIDC_OP_USER_ENDPOINT),
|
||||
json={"sub": "123", "email": "test@example.com"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
user = klass.authenticate(
|
||||
request,
|
||||
code="test-code",
|
||||
nonce="test-nonce",
|
||||
code_verifier="test-code-verifier",
|
||||
)
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1
|
||||
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test-api-key"
|
||||
assert json.loads(brevo_create_contact.calls[0].request.body) == {
|
||||
"email": user.email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
|
||||
assert len(brevo_add_to_list.calls) == 1
|
||||
assert brevo_add_to_list.calls[0].request.headers["api-key"] == "test-api-key"
|
||||
assert json.loads(brevo_add_to_list.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
# Now test when activation is required: user should not be added to Brevo list
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
klass.authenticate(
|
||||
request,
|
||||
code="test-code",
|
||||
nonce="test-nonce",
|
||||
code_verifier="test-code-verifier",
|
||||
)
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1 # No new call made
|
||||
assert len(brevo_add_to_list.calls) == 1 # No new call made
|
||||
|
||||
@@ -52,8 +52,7 @@ def test_is_feature_enabled_dynamic_posthog_true(mock_posthog, feature_flags):
|
||||
assert is_feature_enabled(user, "web_search") is True
|
||||
mock_posthog.feature_enabled.assert_called_once_with(
|
||||
"web-search",
|
||||
user.email,
|
||||
person_properties={"$host": None},
|
||||
user.pk,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ Test config API endpoints in the Conversations core app.
|
||||
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
from django.test import AsyncClient, override_settings
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import sync_to_async
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
@@ -53,7 +54,7 @@ def test_api_config(is_authenticated):
|
||||
["en-us", "English"],
|
||||
["fr-fr", "Français"],
|
||||
# ["de-de", "Deutsch"],
|
||||
# ["nl-nl", "Nederlands"],
|
||||
["nl-nl", "Nederlands"],
|
||||
# ["es-es", "Español"],
|
||||
],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
@@ -156,3 +157,52 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
|
||||
theme_customization = json.load(f)
|
||||
|
||||
assert content["theme_customization"] == theme_customization
|
||||
|
||||
|
||||
@override_settings(
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_CSS_URL="http://testcss/",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
SENTRY_DSN="https://sentry.test/123",
|
||||
THEME_CUSTOMIZATION_FILE_PATH="",
|
||||
RAG_FILES_ACCEPTED_FORMATS=[
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
async def test_api_config_async(is_authenticated):
|
||||
"""Anonymous users should be allowed to get the configuration (async client)."""
|
||||
client = AsyncClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = await sync_to_async(factories.UserFactory)()
|
||||
await client.aforce_login(user)
|
||||
|
||||
response = await client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"ACTIVATION_REQUIRED": False,
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
["fr-fr", "Français"],
|
||||
# ["de-de", "Deutsch"],
|
||||
["nl-nl", "Nederlands"],
|
||||
# ["es-es", "Español"],
|
||||
],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
"theme_customization": {},
|
||||
"chat_upload_accept": "application/pdf,text/plain",
|
||||
}
|
||||
|
||||
@@ -229,6 +229,7 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"full_name": user.full_name,
|
||||
"language": user.language,
|
||||
"short_name": user.short_name,
|
||||
"sub": user.sub,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,298 +17,328 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
@@ -324,9 +354,7 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
@@ -334,3 +362,4 @@ msgstr ""
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,298 +17,328 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
@@ -324,9 +354,7 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
@@ -334,3 +362,4 @@ msgstr ""
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,314 +17,328 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Configuration"
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Détails d'utilisation"
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Horodatages"
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Utilisation"
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Description"
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Aucun utilisateur n'a encore utilisé ce code"
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "E-mail"
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Date"
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Utilisateurs qui ont utilisé ce code"
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Recalculer les utilisations actuelles à partir des activations liées"
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
"Tous les codes d'activation sélectionnés ont déjà un nombre correct "
|
||||
"d'utilisations."
|
||||
msgstr "Tous les codes d'activation sélectionnés ont déjà un nombre correct d'utilisations."
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
"Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
|
||||
msgstr "Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Utilisateur"
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "A utilisé le code d'activation"
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Ajouter les utilisateurs sélectionnés à la liste d'attente de Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "%(count)d utilisateur(s) ajoutés à la liste d'attente Brevo."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Aucune adresse e-mail valide trouvée dans les inscriptions sélectionnées."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Supprimer les utilisateurs sélectionnés de la liste d'attente Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Suppression de %(count)d utilisateur(s) de la liste d'attente Brevo."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "code d'activation"
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Le code d'activation que les utilisateurs entreront"
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
"Le code doit être alphanumérique et ne contenir ni espaces ni caractères "
|
||||
"spéciaux"
|
||||
msgstr "Le code doit être alphanumérique et ne contenir ni espaces ni caractères spéciaux"
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "utilisations maximales"
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Nombre maximum d'utilisation de ce code. 0 signifie illimité."
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "utilisations actuelles"
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Nombre de fois où ce code a été utilisé"
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Si ce code peut encore être utilisé"
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "expiration"
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Date et heure d'expiration de ce code"
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Description interne ou notes à propos de ce code"
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "codes d'activation"
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Ce code d'activation n'est plus valide"
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Vous avez déjà activé votre compte"
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "L'utilisateur qui a utilisé le code d'activation"
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Le code d'activation qui a été utilisé"
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "activation d'utilisateur"
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "activations d'utilisateurs"
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "L'utilisateur qui a fait la demande d'enregistrement"
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
"Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
|
||||
msgstr "Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "demande d'inscription d'utilisateur"
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "demandes d'inscription d'utilisateur"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "Le code d'activation à valider"
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Votre compte a été activé avec succès"
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "application de chat"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement en tant que UUID"
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure de création de l'enregistrement"
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
msgstr ""
|
||||
"Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail "
|
||||
"est déjà associé à un utilisateur enregistré."
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
msgstr ""
|
||||
"Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des "
|
||||
"lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_/: uniquement."
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail de l'administrateur"
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
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:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
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:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu "
|
||||
"de supprimer des comptes."
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "autoriser les analyses de conversation"
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
"Si l'utilisateur autorise l'utilisation de ses conversations pour des "
|
||||
"analyses."
|
||||
msgstr "Si l'utilisateur autorise l'utilisation de ses conversations pour des analyses."
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
@@ -340,15 +354,12 @@ msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgstr ""
|
||||
" Docs, votre nouvel outil incontournable pour organiser, partager et "
|
||||
"collaborer sur vos documents en équipe. "
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " Proposé par %(brandname)s "
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,320 +17,349 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
msgstr "Configuratie"
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
msgstr "Gebruiksdetails"
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
msgstr "Tijdstempels"
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
msgstr "Gebruik"
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
msgstr "Beschrijving"
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
msgstr "Er zijn nog geen gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
msgstr "Naam"
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "E-mail"
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
msgstr "Datum"
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
msgstr "Gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
msgstr "Herbereken het huidige gebruik van gerelateerde activeringen"
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
msgstr "Alle geselecteerde activeringscodes hebben al het juiste gebruiksaantal."
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
msgstr "Het gebruik van %(count)d activeringscode(s) is opnieuw berekend."
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
msgstr "Gebruiker"
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
msgstr "Heeft activeringscode gebruikt"
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Voeg geselecteerde gebruikers toe aan de Brevo-wachtlijst"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "%(count)d gebruiker(s) toegevoegd aan de Brevo-wachtlijst."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Er is geen geldig e-mailadres gevonden in de geselecteerde registraties."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Geselecteerde gebruikers van de Brevo-wachtlijst verwijderen"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "%(count)d gebruiker(s) verwijderd van de Brevo-wachtlijst."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
msgstr "activeringscode"
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
msgstr "De activeringscode die gebruikers invoeren"
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
msgstr "De code moet alfanumeriek zijn en mag geen spaties of speciale tekens bevatten"
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
msgstr "maximaal gebruik"
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
msgstr "Maximaal aantal keren dat deze code kan worden gebruikt. 0 betekent onbeperkt."
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
msgstr "huidig gebruik"
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
msgstr "Aantal keren dat deze code is gebruikt"
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgstr "actief"
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
msgstr "Of deze code nog gebruikt kan worden"
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
msgstr "vervalt op"
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
msgstr "Datum en tijd waarop deze code verloopt"
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
msgstr "beschrijving"
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
msgstr "Interne beschrijving of notities over deze code"
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
msgstr "activeringscodes"
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
msgstr "Deze activeringscode is niet meer geldig"
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
msgstr "Je hebt je account al geactiveerd"
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
msgstr "De gebruiker die de activeringscode heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
msgstr "De activeringscode die is gebruikt"
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
msgstr "gebruikers activering"
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
msgstr "gebruikersactivaties"
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
msgstr "De gebruiker die het registratieverzoek heeft gedaan"
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
msgstr "Opslaan of de gebruiker een activeringscode heeft ontvangen en deze heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
msgstr "gebruikersregistratieverzoek"
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
msgstr "gebruikersregistratieverzoeken"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
msgstr "De activeringscode om te valideren"
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
msgstr "Uw account is succesvol geactiveerd"
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
msgstr "chatapplicatie"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Persoonlijke gegevens"
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
msgstr "gemaakt op"
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgstr "datum en tijd waarop een record is aangemaakt"
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "We konden geen gebruiker met dit e-mailadres vinden, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Voer een geldig subsubtype in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/:-tekens bevatten."
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Verplicht. Maximaal 255 tekens. Alleen letters, cijfers en @/./+/-/_/: tekens."
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "identiteits e-mailadres"
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "beheerders e-mailadres"
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
|
||||
|
||||
#: core/models.py:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Of deze gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
msgstr "conversatieanalyse toestaan"
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
msgstr "Of de gebruiker toestaat dat zijn/haar gesprekken voor analyses worden gebruikt."
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
msgstr "Logo e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgstr ""
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, uw nieuwe onmisbare hulpmiddel voor het organiseren, delen en samenwerken aan uw documenten als team. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
msgstr " Aangeboden door %(brandname)s "
|
||||
|
||||
|
||||
@@ -2,328 +2,343 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 "
|
||||
"&& n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 "
|
||||
"&& n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
"X-Crowdin-Project: la-suite-conversations\n"
|
||||
"X-Crowdin-Project-ID: 810876\n"
|
||||
"X-Crowdin-Language: ru\n"
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Настройки"
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Сведения об использовании"
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Временные метки"
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Использование"
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Пока нет пользователей с этим кодом"
|
||||
msgstr "Пока нет пользователей, использовавших этот код"
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Имя"
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "Эл. почта"
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Пользователи, использующие этот код"
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Обновить данные использования связанных активаций"
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
"Все выбранные коды активации уже имеют правильное количество использований."
|
||||
msgstr "Все выбранные коды активации уже имеют правильное количество использований."
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
"Количество использованных кодов активации (%(count)d) успешно пересчитано."
|
||||
msgstr "Количество использованных кодов активации (%(count)d) успешно пересчитано."
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Пользователь"
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Использует код активации"
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Добавить выбранных пользователей в список ожидания Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "В список ожидания Brevo добавлено пользователей: %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "В выбранных регистрациях не найден действительный адрес электронной почты."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Удалить выбранных пользователей из списка ожидания Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Из списка ожидания Brevo удалено пользователей: %(count)d."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "код активации"
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Код активации, который будут вводить пользователи"
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
"Код должен быть буквенно-цифровым и не содержать пробелов или специальных "
|
||||
"символов"
|
||||
msgstr "Код должен быть буквенно-цифровым и не содержать пробелов или специальных символов"
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "максимум использования"
|
||||
msgstr "максимум использований"
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
"Максимальное количество раз, когда этот код может быть использован. 0 "
|
||||
"означает неограниченно."
|
||||
msgstr "Сколько раз можно использовать этот код. 0 означает неограниченно."
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "текущее использование"
|
||||
msgstr "использовано"
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Сколько раз этот код был использован"
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr "активный"
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Можно ли использовать этот код"
|
||||
msgstr "Можно ли ещё использовать этот код"
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "истекает в"
|
||||
msgstr "действителен до"
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Дата и время окончания действия этого кода"
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Внутреннее описание или примечания об этом коде"
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "коды активации"
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Этот код активации больше не действителен"
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Вы уже активировали свою учётную запись"
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr "пользователь"
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "Пользователь, использовавший код активации"
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Использованный код активации"
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "активация пользователя"
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "активации пользователя"
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "Пользователь, который сделал запрос на регистрацию"
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Сохранить, если пользователь получил код активации и использовал его"
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "запрос на регистрацию пользователя"
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "запросы на регистрацию пользователя"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "Код активации для проверки"
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Ваша учётная запись успешно активирована"
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "приложение чата"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Личные данные"
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первичный ключ для записи как UUID"
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "создано"
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата и время создания записи"
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "обновлено"
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата и время последнего обновления записи"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
msgstr ""
|
||||
"Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с "
|
||||
"зарегистрированным пользователем."
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
msgstr ""
|
||||
"Введите правильный префикс. Он может содержать только буквы, цифры и символы "
|
||||
"@/./+/-/_/."
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введите правильный префикс. Он может содержать только буквы, цифры и символы @/./+/-/_/."
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr "префикс"
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr "полное имя"
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr "короткое имя"
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr "личный адрес электронной почты"
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr "e-mail администратора"
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr "язык"
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr "устройство"
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Пользователь является устройством или человеком."
|
||||
|
||||
#: core/models.py:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr "статус сотрудника"
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Может ли пользователь войти на этот административный сайт."
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Должен ли пользователь рассматриваться как активный. Альтернатива удалению "
|
||||
"учётных записей."
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "разрешить аналитику для беседы"
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Разрешает ли пользователь использовать свои беседы для аналитики."
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
@@ -339,15 +354,12 @@ msgstr "Открыть"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgstr ""
|
||||
" Docs, ваш новый инструмент для организации и совместного использования "
|
||||
"документов в вашей команде. "
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " Доступ получен от %(brandname)s "
|
||||
|
||||
|
||||
@@ -2,337 +2,364 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
|
||||
"PO-Revision-Date: 2025-10-19 21:25\n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2025-10-27 08:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 "
|
||||
"&& n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 "
|
||||
"&& n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
"X-Crowdin-Project: la-suite-conversations\n"
|
||||
"X-Crowdin-Project-ID: 810876\n"
|
||||
"X-Crowdin-Language: uk\n"
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:54
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
msgstr "Налаштування"
|
||||
|
||||
#: activation_codes/admin.py:65
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
msgstr "Відомості про використання"
|
||||
|
||||
#: activation_codes/admin.py:69 activation_codes/admin.py:225
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
msgstr "Відмітки часу"
|
||||
|
||||
#: activation_codes/admin.py:108
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
msgstr "Використання"
|
||||
|
||||
#: activation_codes/admin.py:116
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
msgstr "Опис"
|
||||
|
||||
#: activation_codes/admin.py:123
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
msgstr "Користувачі ще не використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:134
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
msgstr "Ім’я"
|
||||
|
||||
#: activation_codes/admin.py:135 activation_codes/admin.py:245
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "Ел. пошта"
|
||||
|
||||
#: activation_codes/admin.py:136
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:160
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
msgstr "Користувачі, що використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:162
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
msgstr "Перерахувати поточні використання пов'язаних ресурсів"
|
||||
|
||||
#: activation_codes/admin.py:176
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
msgstr "Усі обрані коди активації вже мають коректні лічильники використання."
|
||||
|
||||
#: activation_codes/admin.py:181
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
msgstr "Успішно переобчислено використання коду активації %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:239 activation_codes/admin.py:281
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
msgstr "Користувач"
|
||||
|
||||
#: activation_codes/admin.py:288
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
msgstr "Використано код активації"
|
||||
|
||||
#: activation_codes/models.py:36 activation_codes/models.py:83
|
||||
#: activation_codes/models.py:164
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Додати обраних користувачів до списку очікування Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "До списку очікування Brevo додано користувачів: %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Серед обраних реєстрацій не знайдено дійсної адреси електронної пошти."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Видалити обраних користувачів зі списку очікування Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Зі списку очікування Brevo видалено користувачів: %(count)d"
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
msgstr "код активації"
|
||||
|
||||
#: activation_codes/models.py:37
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
msgstr "Код активації, що буде введений користувачами"
|
||||
|
||||
#: activation_codes/models.py:44
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
msgstr "Код має бути буквено-цифровим, без пробілів або спеціальних символів"
|
||||
|
||||
#: activation_codes/models.py:50
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
msgstr "максимум використань"
|
||||
|
||||
#: activation_codes/models.py:51
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
msgstr "Максимальна кількість разів використання для цього коду. 0 - необмежена."
|
||||
|
||||
#: activation_codes/models.py:56
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
msgstr "використано"
|
||||
|
||||
#: activation_codes/models.py:57
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
msgstr "Кількість разів використання цього коду"
|
||||
|
||||
#: activation_codes/models.py:63 core/models.py:151
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgstr "активний"
|
||||
|
||||
#: activation_codes/models.py:64
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
msgstr "Чи цей код все ще може бути використаний"
|
||||
|
||||
#: activation_codes/models.py:69
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
msgstr "дійсний до"
|
||||
|
||||
#: activation_codes/models.py:70
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
msgstr "Дата та час, коли закінчується дія цього коду"
|
||||
|
||||
#: activation_codes/models.py:76
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
msgstr "опис"
|
||||
|
||||
#: activation_codes/models.py:77
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
msgstr "Внутрішній опис або нотатки про цей код"
|
||||
|
||||
#: activation_codes/models.py:84
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
msgstr "коди активації"
|
||||
|
||||
#: activation_codes/models.py:126
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
msgstr "Цей код активації вже не дійсний"
|
||||
|
||||
#: activation_codes/models.py:134
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
msgstr "Ви вже активували свій обліковий запис"
|
||||
|
||||
#: activation_codes/models.py:156 activation_codes/models.py:188
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "користувач"
|
||||
|
||||
#: activation_codes/models.py:157
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
msgstr "Користувач, який користувався кодом активації"
|
||||
|
||||
#: activation_codes/models.py:165
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
msgstr "Використаний код активації"
|
||||
|
||||
#: activation_codes/models.py:172 activation_codes/models.py:196
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
msgstr "активація користувача"
|
||||
|
||||
#: activation_codes/models.py:173
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
msgstr "активації користувача"
|
||||
|
||||
#: activation_codes/models.py:189
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
msgstr "Користувач, що зробив запит на реєстрацію"
|
||||
|
||||
#: activation_codes/models.py:197
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
msgstr "Зберегти, якщо користувач отримав код активації та використав його"
|
||||
|
||||
#: activation_codes/models.py:206
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
msgstr "запит на реєстрацію користувача"
|
||||
|
||||
#: activation_codes/models.py:207
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
msgstr "запити на реєстрацію користувачів"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
msgstr "Код активації для перевірки"
|
||||
|
||||
#: activation_codes/viewsets.py:106
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
msgstr "Ваш обліковий запис успішно активовано"
|
||||
|
||||
#: chat/apps.py:12
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
msgstr "чат-застосунок"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: core/admin.py:40
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: core/admin.py:52
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: core/models.py:39
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:40
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "первинний ключ для запису як UUID"
|
||||
|
||||
#: core/models.py:46
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
msgstr "створено"
|
||||
|
||||
#: core/models.py:47
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgstr "дата і час, коли запис було створено"
|
||||
|
||||
#: core/models.py:52
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
msgstr "оновлено"
|
||||
|
||||
#: core/models.py:53
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "дата і час, коли запис був востаннє оновлений"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid ""
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Ми не змогли знайти користувача з цими даними, але адреса вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
#: core/models.py:99
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_/: characters."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введіть правильний префікс. Це значення може містити лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: core/models.py:105
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "префікс"
|
||||
|
||||
#: core/models.py:107
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обов'язково. 255 символів або менше. Лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: core/models.py:116
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "повне ім'я"
|
||||
|
||||
#: core/models.py:117
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "коротке ім'я"
|
||||
|
||||
#: core/models.py:119
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "адреса електронної пошти особи"
|
||||
|
||||
#: core/models.py:123
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "електронна адреса адміністратора"
|
||||
|
||||
#: core/models.py:129
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
msgstr "мова"
|
||||
|
||||
#: core/models.py:130
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
msgstr "Мова, якою користувач хоче бачити інтерфейс."
|
||||
|
||||
#: core/models.py:138
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "Часовий пояс, в якому користувач хоче бачити час."
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgstr "пристрій"
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
msgstr "Чи є користувач пристроєм чи реальним користувачем."
|
||||
|
||||
#: core/models.py:146
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgstr "статус співробітника"
|
||||
|
||||
#: core/models.py:148
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
msgstr "Чи може користувач увійти на цей сайт адміністратора."
|
||||
|
||||
#: core/models.py:154
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
|
||||
|
||||
#: core/models.py:161
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
msgstr "дозволити аналітику бесіди"
|
||||
|
||||
#: core/models.py:163
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
msgstr "Чи дозволяє користувач використовувати свої розмови для аналітики."
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "користувачі"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
msgstr "Логотип email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Відкрити"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
" Docs, your new essential tool for organizing, sharing and collaborating on "
|
||||
"your documents as a team. "
|
||||
msgstr ""
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
msgstr " Запрошення отримане від %(brandname)s "
|
||||
|
||||
|
||||
+13
-11
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "conversations"
|
||||
version = "0.0.1"
|
||||
version = "0.0.7"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -27,13 +27,13 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"deprecated",
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.51",
|
||||
"boto3==1.40.59",
|
||||
"Brotli==1.1.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.14",
|
||||
"django-lasuite[all]==0.0.16",
|
||||
"django-parler==2.3",
|
||||
"django-pydantic-field==0.3.13",
|
||||
"django-redis==6.0.0",
|
||||
@@ -47,22 +47,23 @@ dependencies = [
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.6.2",
|
||||
"langfuse==3.8.1",
|
||||
"lxml==5.4.0",
|
||||
"markdown==3.9",
|
||||
"markitdown==0.0.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==6.7.7",
|
||||
"pydantic==2.12.1",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.0.18",
|
||||
"psycopg[binary]==3.2.10",
|
||||
"posthog==6.7.10",
|
||||
"pydantic==2.12.3",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.6.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.41.0",
|
||||
"sentry-sdk==2.42.1",
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
]
|
||||
|
||||
@@ -74,6 +75,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"dirty-equals==0.10.0",
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
@@ -82,7 +84,7 @@ dev = [
|
||||
"ipython==9.6.0",
|
||||
"pyfakefs==5.10.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.8",
|
||||
"pylint==3.3.9",
|
||||
"pylint-pydantic==0.4.0",
|
||||
"pytest-asyncio==1.2.0",
|
||||
"pytest-cov==7.0.0",
|
||||
@@ -92,7 +94,7 @@ dev = [
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"respx==0.22.0",
|
||||
"ruff==0.14.0",
|
||||
"ruff==0.14.2",
|
||||
"types-requests==2.32.4.20250913",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
|
||||
* @property {string} sub - The identity provider sub.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -13,4 +14,5 @@ export interface User {
|
||||
short_name: string;
|
||||
language?: string;
|
||||
allow_conversation_analytics: boolean;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@ export const ActivationPage = () => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Verify user is still authenticated before submitting
|
||||
if (!authenticated) {
|
||||
void router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code.trim()) {
|
||||
setError(t('Please enter an activation code'));
|
||||
return;
|
||||
@@ -135,6 +141,12 @@ export const ActivationPage = () => {
|
||||
};
|
||||
|
||||
const handleNotificationRegister = () => {
|
||||
// Verify user is still authenticated before registering
|
||||
if (!authenticated) {
|
||||
void router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
void registerNotification(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast('success', t('You will be notified!'));
|
||||
|
||||
@@ -39,7 +39,7 @@ jest.mock('next/router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const dummyUser = { id: '123', email: 'test@example.com' };
|
||||
const dummyUser = { id: '123', email: 'test@example.com', sub: 'test-sub' };
|
||||
|
||||
describe('useAuth hook - trackEvent effect', () => {
|
||||
beforeEach(() => {
|
||||
@@ -63,6 +63,7 @@ describe('useAuth hook - trackEvent effect', () => {
|
||||
eventName: 'user',
|
||||
id: dummyUser.id,
|
||||
email: dummyUser.email,
|
||||
sub: dummyUser.sub,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const useAuth = () => {
|
||||
eventName: 'user',
|
||||
id: user?.id || '',
|
||||
email: user?.email || '',
|
||||
sub: user?.sub,
|
||||
});
|
||||
setHasTracked(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
|
||||
<svg width="118" height="86" viewBox="0 0 118 86" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_13649_715)">
|
||||
<g filter="url(#filter1_d_13649_715)">
|
||||
<g clip-path="url(#clip0_13649_715)">
|
||||
<rect x="67.1685" y="17.71" width="37.9734" height="30.9413" rx="4.96294" transform="rotate(15.4037 67.1685 17.71)" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7995 26.0047C70.9944 25.2975 71.7257 24.8821 72.4329 25.0769L83.2801 28.0655C83.9874 28.2604 84.4027 28.9917 84.2079 29.6989C84.013 30.4062 83.2817 30.8215 82.5745 30.6267L71.7273 27.6381C71.02 27.4432 70.6047 26.7119 70.7995 26.0047Z" fill="#AE6257"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7995 26.0047C70.9944 25.2975 71.7257 24.8821 72.4329 25.0769L83.2801 28.0655C83.9874 28.2604 84.4027 28.9917 84.2079 29.6989C84.013 30.4062 83.2817 30.8215 82.5745 30.6267L71.7273 27.6381C71.02 27.4432 70.6047 26.7119 70.7995 26.0047Z" fill="#F6F8F9" fill-opacity="0.15"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.3049 31.428C69.4998 30.7208 70.231 30.3054 70.9383 30.5003L93.9886 36.851C94.6958 37.0459 95.1112 37.7772 94.9164 38.4844C94.7215 39.1916 93.9902 39.607 93.283 39.4122L70.2327 33.0614C69.5254 32.8666 69.11 32.1353 69.3049 31.428Z" fill="#AE6257"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.3049 31.428C69.4998 30.7208 70.231 30.3054 70.9383 30.5003L93.9886 36.851C94.6958 37.0459 95.1112 37.7772 94.9164 38.4844C94.7215 39.1916 93.9902 39.607 93.283 39.4122L70.2327 33.0614C69.5254 32.8666 69.11 32.1353 69.3049 31.428Z" fill="#F6F8F9" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<rect x="67.4896" y="18.2754" width="37.0538" height="30.0217" rx="4.50314" transform="rotate(15.4037 67.4896 18.2754)" stroke="#AE6257" stroke-width="0.919604"/>
|
||||
<rect x="67.4896" y="18.2754" width="37.0538" height="30.0217" rx="4.50314" transform="rotate(15.4037 67.4896 18.2754)" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="0.919604"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_13649_715)">
|
||||
<g clip-path="url(#clip1_13649_715)">
|
||||
<rect x="13.6865" y="22.7539" width="30.5799" height="40.7732" rx="4.44697" transform="rotate(-15.6072 13.6865 22.7539)" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5742 30.4375C20.406 29.8351 20.7579 29.2102 21.3604 29.042L31.1778 26.2995C31.7803 26.1312 32.4051 26.4832 32.5734 27.0857C32.7417 27.6881 32.3897 28.3129 31.7873 28.4812L21.9698 31.2236C21.3673 31.3919 20.7425 31.04 20.5742 30.4375Z" fill="#3677CC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5742 30.4375C20.406 29.8351 20.7579 29.2102 21.3604 29.042L31.1778 26.2995C31.7803 26.1312 32.4051 26.4832 32.5734 27.0857C32.7417 27.6881 32.3897 28.3129 31.7873 28.4812L21.9698 31.2236C21.3673 31.3919 20.7425 31.04 20.5742 30.4375Z" fill="#F6F8F9" fill-opacity="0.15"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.6045 34.127C21.4362 33.5245 21.7882 32.8997 22.3906 32.7314L40.8655 27.5706C41.468 27.4023 42.0928 27.7543 42.261 28.3567C42.4293 28.9592 42.0774 29.584 41.4749 29.7523L23.0001 34.9131C22.3976 35.0814 21.7728 34.7294 21.6045 34.127ZM22.5187 37.3995C22.3504 36.797 22.7023 36.1722 23.3048 36.0039L41.7796 30.8431C42.3821 30.6748 43.0069 31.0268 43.1752 31.6292C43.3435 32.2317 42.9915 32.8565 42.3891 33.0248L23.9142 38.1856C23.3118 38.3539 22.6869 38.0019 22.5187 37.3995ZM23.4328 40.6719C23.2645 40.0695 23.6165 39.4447 24.2189 39.2764L42.6938 34.1156C43.2962 33.9473 43.921 34.2993 44.0893 34.9017C44.2576 35.5042 43.9057 36.129 43.3032 36.2973L24.8283 41.4581C24.2259 41.6263 23.6011 41.2744 23.4328 40.6719ZM24.3469 43.9444C24.1787 43.342 24.5306 42.7172 25.1331 42.5489L43.6079 37.3881C44.2104 37.2198 44.8352 37.5718 45.0035 38.1742C45.1718 38.7767 44.8198 39.4015 44.2174 39.5698L25.7425 44.7305C25.14 44.8988 24.5152 44.5469 24.3469 43.9444Z" fill="#3677CC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.6045 34.127C21.4362 33.5245 21.7882 32.8997 22.3906 32.7314L40.8655 27.5706C41.468 27.4023 42.0928 27.7543 42.261 28.3567C42.4293 28.9592 42.0774 29.584 41.4749 29.7523L23.0001 34.9131C22.3976 35.0814 21.7728 34.7294 21.6045 34.127ZM22.5187 37.3995C22.3504 36.797 22.7023 36.1722 23.3048 36.0039L41.7796 30.8431C42.3821 30.6748 43.0069 31.0268 43.1752 31.6292C43.3435 32.2317 42.9915 32.8565 42.3891 33.0248L23.9142 38.1856C23.3118 38.3539 22.6869 38.0019 22.5187 37.3995ZM23.4328 40.6719C23.2645 40.0695 23.6165 39.4447 24.2189 39.2764L42.6938 34.1156C43.2962 33.9473 43.921 34.2993 44.0893 34.9017C44.2576 35.5042 43.9057 36.129 43.3032 36.2973L24.8283 41.4581C24.2259 41.6263 23.6011 41.2744 23.4328 40.6719ZM24.3469 43.9444C24.1787 43.342 24.5306 42.7172 25.1331 42.5489L43.6079 37.3881C44.2104 37.2198 44.8352 37.5718 45.0035 38.1742C45.1718 38.7767 44.8198 39.4015 44.2174 39.5698L25.7425 44.7305C25.14 44.8988 24.5152 44.5469 24.3469 43.9444Z" fill="#F6F8F9" fill-opacity="0.8"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.2719 47.2555C25.1036 46.653 25.4555 46.0282 26.058 45.86L44.5328 40.6992C45.1353 40.5309 45.7601 40.8828 45.9284 41.4853C46.0967 42.0877 45.7447 42.7125 45.1423 42.8808L26.6674 48.0416C26.065 48.2099 25.4401 47.8579 25.2719 47.2555ZM26.186 50.528C26.0177 49.9255 26.3697 49.3007 26.9721 49.1324L45.447 43.9716C46.0494 43.8034 46.6742 44.1553 46.8425 44.7578C47.0108 45.3602 46.6589 45.985 46.0564 46.1533L27.5815 51.3141C26.9791 51.4824 26.3543 51.1304 26.186 50.528ZM27.1001 53.8005C26.9319 53.198 27.2838 52.5732 27.8863 52.4049L46.3611 47.2441C46.9636 47.0759 47.5884 47.4278 47.7567 48.0303C47.925 48.6327 47.573 49.2575 46.9706 49.4258L28.4957 54.5866C27.8932 54.7549 27.2684 54.4029 27.1001 53.8005ZM28.0143 57.073C27.846 56.4705 28.1979 55.8457 28.8004 55.6774L47.2753 50.5166C47.8777 50.3483 48.5025 50.7003 48.6708 51.3027C48.8391 51.9052 48.4871 52.53 47.8847 52.6983L29.4098 57.8591C28.8074 58.0274 28.1826 57.6754 28.0143 57.073Z" fill="#3677CC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.2719 47.2555C25.1036 46.653 25.4555 46.0282 26.058 45.86L44.5328 40.6992C45.1353 40.5309 45.7601 40.8828 45.9284 41.4853C46.0967 42.0877 45.7447 42.7125 45.1423 42.8808L26.6674 48.0416C26.065 48.2099 25.4401 47.8579 25.2719 47.2555ZM26.186 50.528C26.0177 49.9255 26.3697 49.3007 26.9721 49.1324L45.447 43.9716C46.0494 43.8034 46.6742 44.1553 46.8425 44.7578C47.0108 45.3602 46.6589 45.985 46.0564 46.1533L27.5815 51.3141C26.9791 51.4824 26.3543 51.1304 26.186 50.528ZM27.1001 53.8005C26.9319 53.198 27.2838 52.5732 27.8863 52.4049L46.3611 47.2441C46.9636 47.0759 47.5884 47.4278 47.7567 48.0303C47.925 48.6327 47.573 49.2575 46.9706 49.4258L28.4957 54.5866C27.8932 54.7549 27.2684 54.4029 27.1001 53.8005ZM28.0143 57.073C27.846 56.4705 28.1979 55.8457 28.8004 55.6774L47.2753 50.5166C47.8777 50.3483 48.5025 50.7003 48.6708 51.3027C48.8391 51.9052 48.4871 52.53 47.8847 52.6983L29.4098 57.8591C28.8074 58.0274 28.1826 57.6754 28.0143 57.073Z" fill="#F6F8F9" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<rect x="14.2259" y="23.0577" width="29.7044" height="39.8977" rx="4.00923" transform="rotate(-15.6072 14.2259 23.0577)" stroke="#3677CC" stroke-width="0.875498"/>
|
||||
<rect x="14.2259" y="23.0577" width="29.7044" height="39.8977" rx="4.00923" transform="rotate(-15.6072 14.2259 23.0577)" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="0.875498"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_d_13649_715)">
|
||||
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" fill="#6D778C"/>
|
||||
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" fill="#F6F8F9" fill-opacity="0.975"/>
|
||||
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" stroke="#6D778C" stroke-width="1.10324"/>
|
||||
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="1.10324"/>
|
||||
<path d="M50.8763 49.2972C51.4755 49.6412 52.1356 49.8131 52.8568 49.8131C53.5779 49.8131 54.2325 49.6412 54.8206 49.2972C55.4086 48.9422 55.8801 48.4651 56.2351 47.866C56.5902 47.2669 56.7677 46.6067 56.7677 45.8856C56.7677 45.1755 56.5902 44.5209 56.2351 43.9218C55.8801 43.3227 55.4086 42.8456 54.8206 42.4906C54.2325 42.1355 53.5779 41.958 52.8568 41.958C52.1356 41.958 51.4755 42.1355 50.8763 42.4906C50.2883 42.8456 49.8168 43.3227 49.4618 43.9218C49.1067 44.5209 48.9292 45.1755 48.9292 45.8856C48.9292 46.6067 49.1067 47.2669 49.4618 47.866C49.8168 48.4651 50.2883 48.9422 50.8763 49.2972Z" fill="#9B6F02"/>
|
||||
<path d="M50.8763 49.2972C51.4755 49.6412 52.1356 49.8131 52.8568 49.8131C53.5779 49.8131 54.2325 49.6412 54.8206 49.2972C55.4086 48.9422 55.8801 48.4651 56.2351 47.866C56.5902 47.2669 56.7677 46.6067 56.7677 45.8856C56.7677 45.1755 56.5902 44.5209 56.2351 43.9218C55.8801 43.3227 55.4086 42.8456 54.8206 42.4906C54.2325 42.1355 53.5779 41.958 52.8568 41.958C52.1356 41.958 51.4755 42.1355 50.8763 42.4906C50.2883 42.8456 49.8168 43.3227 49.4618 43.9218C49.1067 44.5209 48.9292 45.1755 48.9292 45.8856C48.9292 46.6067 49.1067 47.2669 49.4618 47.866C49.8168 48.4651 50.2883 48.9422 50.8763 49.2972Z" fill="#F6F8F9" fill-opacity="0.15"/>
|
||||
<path d="M47.5929 55.7846C48.3745 54.6792 48.7652 54.1265 49.2447 53.929C49.6647 53.7561 50.1308 53.7561 50.5507 53.929C51.0302 54.1265 51.4209 54.6792 52.2025 55.7846L55.0263 59.7786C56.1326 61.3434 56.6858 62.1258 56.6721 62.7805C56.6602 63.3502 56.407 63.8842 55.9822 64.2353C55.4941 64.6388 54.5699 64.6388 52.7215 64.6388H47.0739C45.2255 64.6388 44.3013 64.6388 43.8132 64.2353C43.3884 63.8842 43.1352 63.3502 43.1233 62.7805C43.1096 62.1258 43.6628 61.3434 44.7691 59.7786L47.5929 55.7846Z" fill="#5A8228"/>
|
||||
<path d="M47.5929 55.7846C48.3745 54.6792 48.7652 54.1265 49.2447 53.929C49.6647 53.7561 50.1308 53.7561 50.5507 53.929C51.0302 54.1265 51.4209 54.6792 52.2025 55.7846L55.0263 59.7786C56.1326 61.3434 56.6858 62.1258 56.6721 62.7805C56.6602 63.3502 56.407 63.8842 55.9822 64.2353C55.4941 64.6388 54.5699 64.6388 52.7215 64.6388H47.0739C45.2255 64.6388 44.3013 64.6388 43.8132 64.2353C43.3884 63.8842 43.1352 63.3502 43.1233 62.7805C43.1096 62.1258 43.6628 61.3434 44.7691 59.7786L47.5929 55.7846Z" fill="#F6F8F9" fill-opacity="0.55"/>
|
||||
<path d="M60.0205 49.0171C60.8418 47.6462 61.2525 46.9607 61.7796 46.7263C62.2398 46.5216 62.7614 46.5216 63.2216 46.7263C63.7487 46.9607 64.1594 47.6462 64.9807 49.0171L71.586 60.0431C72.4852 61.5441 72.9348 62.2945 72.8846 62.9134C72.8409 63.4528 72.5744 63.9468 72.153 64.2689C71.6697 64.6385 70.8151 64.6385 69.106 64.6385H55.8952C54.1861 64.6385 53.3315 64.6385 52.8482 64.2689C52.4268 63.9468 52.1603 63.4528 52.1166 62.9134C52.0664 62.2945 52.516 61.5441 53.4152 60.0431L60.0205 49.0171Z" fill="#5A8228"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_13649_715" x="0.0778322" y="0.618848" width="117.844" height="84.7633" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.35"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.243137 0 0 0 0 0.364706 0 0 0 0 0.905882 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_13649_715" x="48.6245" y="6.69141" width="65.8525" height="65.853" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2.62744"/>
|
||||
<feGaussianBlur stdDeviation="2.62744"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_13649_715" x="3.77499" y="10.6539" width="60.2454" height="60.2454" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2.50142"/>
|
||||
<feGaussianBlur stdDeviation="2.50142"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter3_d_13649_715" x="27.7856" y="25.3547" width="60.7837" height="60.7837" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="3.15211"/>
|
||||
<feGaussianBlur stdDeviation="3.15211"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_13649_715">
|
||||
<rect x="67.1685" y="17.71" width="37.9734" height="30.9413" rx="4.96294" transform="rotate(15.4037 67.1685 17.71)" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_13649_715">
|
||||
<rect x="13.6865" y="22.7539" width="30.5799" height="40.7732" rx="4.44697" transform="rotate(-15.6072 13.6865 22.7539)" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -789,7 +789,7 @@ export const Chat = ({
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
className={`c__button--neutral action-chat-button ${isSourceOpen ? 'action-chat-button--open' : ''}`}
|
||||
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
|
||||
onClick={() => openSources(message.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
|
||||
@@ -8,6 +8,8 @@ import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import FilesIcon from '../assets/files.svg';
|
||||
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { ScrollDown } from './ScrollDown';
|
||||
@@ -52,6 +54,7 @@ export const InputChat = ({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [isDragRejected, setIsDragRejected] = useState(false);
|
||||
const { isDesktop, isMobile } = useResponsiveStore();
|
||||
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
|
||||
const { data: conf } = useConfig();
|
||||
@@ -128,157 +131,328 @@ export const InputChat = ({
|
||||
}
|
||||
}, [status, input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFileAccepted = (file: File): boolean => {
|
||||
if (!conf?.chat_upload_accept) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acceptedTypes = conf.chat_upload_accept
|
||||
.split(',')
|
||||
.map((type) => type.trim());
|
||||
|
||||
return acceptedTypes.some((acceptedType) => {
|
||||
// Extension management
|
||||
if (acceptedType.startsWith('.')) {
|
||||
return file.name.toLowerCase().endsWith(acceptedType.toLowerCase());
|
||||
}
|
||||
// Wildcard MIME type management (ex: image/*)
|
||||
if (acceptedType.endsWith('/*')) {
|
||||
const baseType = acceptedType.slice(0, -2);
|
||||
return file.type.startsWith(baseType);
|
||||
}
|
||||
// Exact MIME type management
|
||||
return file.type === acceptedType;
|
||||
});
|
||||
};
|
||||
|
||||
const areAllFilesAccepted = (fileList: FileList): boolean => {
|
||||
return Array.from(fileList).every((file) => isFileAccepted(file));
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setIsDragActive(true);
|
||||
setIsDragRejected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only hide when leaving the window completely
|
||||
if (!e.relatedTarget) {
|
||||
setIsDragActive(false);
|
||||
setIsDragRejected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check for rejected files during drag over (does not work on Safari)
|
||||
if (e.dataTransfer?.items) {
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
const hasInvalidFile = items.some((item) => {
|
||||
if (item.kind === 'file') {
|
||||
// Check file type
|
||||
const type = item.type;
|
||||
const dummyFile = new File([], '', { type });
|
||||
return !isFileAccepted(dummyFile);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
setIsDragRejected(hasInvalidFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
setIsDragRejected(false);
|
||||
|
||||
if (!fileUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
// Check if all files are accepted
|
||||
if (!areAllFilesAccepted(droppedFiles)) {
|
||||
// Display rejection for 2 seconds (mandatory for Safari)
|
||||
setIsDragActive(true);
|
||||
setIsDragRejected(true);
|
||||
setTimeout(() => {
|
||||
setIsDragActive(false);
|
||||
setIsDragRejected(false);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f) => dt.items.add(f));
|
||||
}
|
||||
Array.from(droppedFiles).forEach((f) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
return dt.files;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', handleDragEnter);
|
||||
window.addEventListener('dragleave', handleDragLeave);
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragEnter);
|
||||
window.removeEventListener('dragleave', handleDragLeave);
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [fileUploadEnabled, setFiles, conf?.chat_upload_accept]);
|
||||
|
||||
const isInputDisabled = status !== 'ready' || isUploadingFiles;
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={`
|
||||
display: block;
|
||||
position: relative;
|
||||
opacity: ${status === 'error' ? '0.5' : '1'};
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
{/* Bouton de scroll vers le bas */}
|
||||
{messagesLength > 1 &&
|
||||
status !== 'streaming' &&
|
||||
status !== 'submitted' &&
|
||||
containerRef &&
|
||||
onScrollToBottom && (
|
||||
<Box
|
||||
$css={`
|
||||
<>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
$position="fixed"
|
||||
$css={`
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
animation: fadeIn 0.3s;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
pointer-events: all;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
$css={`
|
||||
display: block;
|
||||
position: relative;
|
||||
opacity: ${status === 'error' ? '0.5' : '1'};
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
padding: ${isDesktop ? '0' : '0 10px'};
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
{/* Bouton de scroll vers le bas */}
|
||||
{messagesLength > 1 &&
|
||||
status !== 'streaming' &&
|
||||
status !== 'submitted' &&
|
||||
containerRef &&
|
||||
onScrollToBottom && (
|
||||
<Box
|
||||
$css={`
|
||||
position: relative;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
max-width: 750px;
|
||||
`}
|
||||
>
|
||||
<ScrollDown
|
||||
onClick={onScrollToBottom}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Message de bienvenue */}
|
||||
{messagesLength === 0 && (
|
||||
<Box
|
||||
$padding={{ all: 'base', bottom: 'md' }}
|
||||
$align="center"
|
||||
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
|
||||
>
|
||||
<ScrollDown
|
||||
onClick={onScrollToBottom}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* Message de bienvenue */}
|
||||
{messagesLength === 0 && (
|
||||
<Box
|
||||
$padding={{ all: 'base', bottom: 'md' }}
|
||||
$align="center"
|
||||
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
|
||||
>
|
||||
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
|
||||
{t('What is on your mind?')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(fileUploadEnabled);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
if (!fileUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.files?.length) {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f) => dt.items.add(f));
|
||||
}
|
||||
Array.from(e.dataTransfer.files).forEach((f) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
return dt.files;
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
|
||||
<Box
|
||||
$flex={1}
|
||||
$radius="12px"
|
||||
$position="relative"
|
||||
$background="white"
|
||||
$css={`
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(fileUploadEnabled);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
// File handling is now done by global handler
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
|
||||
<Box
|
||||
$flex={1}
|
||||
$radius="12px"
|
||||
$position="relative"
|
||||
$background="white"
|
||||
$css={`
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
border: ${
|
||||
isDragActive
|
||||
? '2px dashed var(--c--theme--colors--primary-400)'
|
||||
: '1px solid var(--c--theme--colors--greyscale-200)'
|
||||
};
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
position: relative;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input ?? ''}
|
||||
name="inputchat-textarea"
|
||||
onChange={(e) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.focus();
|
||||
}}
|
||||
disabled={isInputDisabled}
|
||||
rows={1}
|
||||
style={{
|
||||
padding: '1rem 1.5rem',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '64px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--c--theme--colors--greyscale-800)',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
>
|
||||
{isDragActive && (
|
||||
<Box
|
||||
$position="absolute"
|
||||
$align="center"
|
||||
$direction="row"
|
||||
$justify="center"
|
||||
$gap="1rem"
|
||||
$css={`
|
||||
top: -1px; left: -1px;
|
||||
border-radius: 12px;
|
||||
z-index: 1001;
|
||||
background-color: ${isDragRejected ? '#FFE8E8' : '#EDF0FF'};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: 2px solid ${isDragRejected ? '#FF6B6B' : '#90A7FF'};
|
||||
box-shadow: 0 0 64px 0 ${isDragRejected ? 'rgba(255, 107, 107, 0.25)' : 'rgba(62, 93, 231, 0.25)'};
|
||||
`}
|
||||
>
|
||||
{isDragRejected ? (
|
||||
<>
|
||||
<Text $css="font-size: 48px;">🚫</Text>
|
||||
<Box>
|
||||
<Text $weight="700" $color="#C92A2A">
|
||||
{t('File type not supported (yet)')}
|
||||
</Text>
|
||||
<Text $weight="400" $color="#C92A2A">
|
||||
{t(
|
||||
'We currently support only specific file types...',
|
||||
)}
|
||||
</Text>
|
||||
<Text $weight="400" $color="#C92A2A">
|
||||
{t(
|
||||
'Use the "{{attach_file_btn}}" button to have a better view.',
|
||||
{ attach_file_btn: t('Add attach file') },
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilesIcon />
|
||||
<Box>
|
||||
<Text $weight="700" $color="#223E9E">
|
||||
{t('Add file')}
|
||||
</Text>
|
||||
<Text $weight="400" $color="#223E9E">
|
||||
{t(
|
||||
'To add a file to the conversation, drop it here.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input ?? ''}
|
||||
name="inputchat-textarea"
|
||||
onChange={(e) => {
|
||||
handleInputChange(e);
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
disabled={isInputDisabled}
|
||||
rows={1}
|
||||
style={{
|
||||
padding: '1rem 1.5rem',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '64px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--c--theme--colors--greyscale-800)',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
textarea.style.height = '0';
|
||||
e.currentTarget.form?.requestSubmit?.();
|
||||
textarea.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{!input && (
|
||||
<Box
|
||||
$css={`
|
||||
{!input && (
|
||||
<Box
|
||||
$css={`
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.5rem;
|
||||
@@ -291,130 +465,132 @@ export const InputChat = ({
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$css={`
|
||||
>
|
||||
<Box
|
||||
$css={`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: ${(suggestions.length + 1) * 100}%;
|
||||
transform: translateY(-${currentSuggestionIndex * (100 / (suggestions.length + 1))}%);
|
||||
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
|
||||
`}
|
||||
>
|
||||
{[...suggestions, suggestions[0]].map((suggestion, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
$css={`
|
||||
>
|
||||
{[...suggestions, suggestions[0]].map(
|
||||
(suggestion, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
$css={`
|
||||
height: calc(100% / ${suggestions.length + 1});
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
`}
|
||||
>
|
||||
{suggestion}
|
||||
</Box>
|
||||
))}
|
||||
>
|
||||
{suggestion}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
|
||||
<input
|
||||
accept={conf?.chat_upload_accept}
|
||||
type="file"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f: File) => dt.items.add(f));
|
||||
<input
|
||||
accept={conf?.chat_upload_accept}
|
||||
type="file"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
Array.from(fileList).forEach((f: File) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
return dt.files;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/*Aperçu des fichiers*/}
|
||||
{files && files.length > 0 && (
|
||||
<Box
|
||||
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
|
||||
$padding={{ horizontal: 'base' }}
|
||||
>
|
||||
<AttachmentList
|
||||
attachments={Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
}))}
|
||||
onRemove={(index) => {
|
||||
setFiles((prev) => {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
if (prev) {
|
||||
Array.from(prev).forEach((f: File) => dt.items.add(f));
|
||||
}
|
||||
Array.from(fileList).forEach((f: File) => {
|
||||
if (
|
||||
!Array.from(prev || []).some(
|
||||
(pf) =>
|
||||
pf.name === f.name &&
|
||||
pf.size === f.size &&
|
||||
pf.lastModified === f.lastModified,
|
||||
)
|
||||
) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
}}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="sm"
|
||||
$padding={{ bottom: 'base' }}
|
||||
$align="space-between"
|
||||
>
|
||||
<Box
|
||||
$flex="1"
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!fileUploadEnabled || isUploadingFiles}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label={t('Add attach file')}
|
||||
className="c__button--neutral attach-file-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="attach_file"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size={`${isMobile ? '24px' : '16px'}`}
|
||||
/>
|
||||
}
|
||||
return dt.files;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/*Aperçu des fichiers*/}
|
||||
{files && files.length > 0 && (
|
||||
<Box
|
||||
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
|
||||
$padding={{ horizontal: 'base' }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text $theme="greyscale" $variation="550" $weight="500">
|
||||
{t('Attach file')}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
<AttachmentList
|
||||
attachments={Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
}))}
|
||||
onRemove={(index) => {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files).forEach((f, i) => {
|
||||
if (i !== index) {
|
||||
dt.items.add(f);
|
||||
}
|
||||
});
|
||||
setFiles(dt.files.length > 0 ? dt.files : null);
|
||||
}}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="sm"
|
||||
$padding={{ bottom: 'base' }}
|
||||
$align="space-between"
|
||||
>
|
||||
<Box
|
||||
$flex="1"
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!fileUploadEnabled || isUploadingFiles}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label={t('Add attach file')}
|
||||
className="c__button--neutral attach-file-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="attach_file"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$size={`${isMobile ? '24px' : '16px'}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text $theme="greyscale" $variation="550" $weight="500">
|
||||
{t('Attach file')}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{onToggleWebSearch && (
|
||||
<Box
|
||||
$margin={{ left: '4px' }}
|
||||
$css={`
|
||||
{onToggleWebSearch && (
|
||||
<Box
|
||||
$margin={{ left: '4px' }}
|
||||
$css={`
|
||||
${
|
||||
isMobile
|
||||
? `
|
||||
@@ -434,96 +610,101 @@ export const InputChat = ({
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!webSearchEnabled || isUploadingFiles}
|
||||
onClick={() => {
|
||||
onToggleWebSearch();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t('Research on the web')}
|
||||
className="c__button--neutral research-web-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="language"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$css={`
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!webSearchEnabled || isUploadingFiles}
|
||||
onClick={() => {
|
||||
onToggleWebSearch();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t('Research on the web')}
|
||||
className="c__button--neutral research-web-button"
|
||||
icon={
|
||||
<Icon
|
||||
iconName="language"
|
||||
$theme="greyscale"
|
||||
$variation="550"
|
||||
$css={`
|
||||
color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text
|
||||
$theme={forceWebSearch ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{t('Research on the web')}
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && forceWebSearch && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="space-between"
|
||||
$gap="xs"
|
||||
$css={`
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Text
|
||||
$theme={forceWebSearch ? 'primary' : 'greyscale'}
|
||||
$variation="550"
|
||||
>
|
||||
{t('Research on the web')}
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && forceWebSearch && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="space-between"
|
||||
$gap="xs"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
`}
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="500"
|
||||
$css={`
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="500"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
{t('Web')}
|
||||
</Text>
|
||||
<Icon
|
||||
iconName="close"
|
||||
$variation="text"
|
||||
$theme="primary"
|
||||
$size="md"
|
||||
$css={`
|
||||
>
|
||||
{t('Web')}
|
||||
</Text>
|
||||
<Icon
|
||||
iconName="close"
|
||||
$variation="text"
|
||||
$theme="primary"
|
||||
$size="md"
|
||||
$css={`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding-left: 4px;
|
||||
`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box $direction="row" $padding={{ horizontal: 'base' }} $gap="xs">
|
||||
<Box $padding={{ horizontal: 'xs' }}>
|
||||
{onModelSelect && (
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel || null}
|
||||
onModelSelect={onModelSelect}
|
||||
/>
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
$direction="row"
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$gap="xs"
|
||||
>
|
||||
<Box $padding={{ horizontal: 'xs' }}>
|
||||
{onModelSelect && (
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel || null}
|
||||
onModelSelect={onModelSelect}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<SendButton
|
||||
status={status}
|
||||
disabled={!input || !input.trim() || isUploadingFiles}
|
||||
onClick={onStop}
|
||||
/>
|
||||
<SendButton
|
||||
status={status}
|
||||
disabled={!input || !input.trim() || isUploadingFiles}
|
||||
onClick={onStop}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
|
||||
<Box $direction="row" $gap="4px" $align="center">
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$align="start"
|
||||
$css="font-size: 14px;"
|
||||
$width="100%"
|
||||
>
|
||||
@@ -168,6 +168,9 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
|
||||
padding: 4px;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s;
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Feedback = (_props: { buttonProps?: Partial<ButtonProps> }) => {
|
||||
/>
|
||||
|
||||
<FeedbackButton
|
||||
href="https://www.tchap.gouv.fr/#/room/!eeDqhgOFDpthMyyUjK:agent.dinum.tchap.gouv.fr"
|
||||
href="https://www.tchap.gouv.fr/#/room/!eAHyPLdVHMxNhKAbaC:agent.dinum.tchap.gouv.fr"
|
||||
icon={<TchapIcon />}
|
||||
title={t('Write on Tchap')}
|
||||
description={t('Direct exchange with our team')}
|
||||
|
||||
@@ -106,7 +106,7 @@ export const SettingsModal = ({ onClose, isOpen }: SettingsModalProps) => {
|
||||
display: inline-block;
|
||||
`}
|
||||
target="_blank"
|
||||
href="https://docs.numerique.gouv.fr/docs/f307df3d-275c-4e0b-b53f-3d20052be8be/"
|
||||
href="https://docs.numerique.gouv.fr/docs/53d1dfb9-481d-4a68-b75c-7208c03d4dec/"
|
||||
>
|
||||
<Text
|
||||
$css={`
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"de": { "translation": { "ABC-1234-XY": "ABC-1234-XY" } },
|
||||
"en": { "translation": { "Login": "Login", "Logout": "Logout" } },
|
||||
"nl": { "translation": { "Login": "Inloggen", "Logout": "Uitloggen" } },
|
||||
"uk": { "translation": { "Login": "Увійти", "Logout": "Вийти" } },
|
||||
"ru": { "translation": { "Login": "Войти", "Logout": "Выйти" } },
|
||||
"de": { "translation": { "Login": "Anmelden", "Logout": "Abmelden" } },
|
||||
"fr": {
|
||||
"translation": {
|
||||
"30 sec to tell us what you think or report a bug": "Prenez 30 secondes pour partager votre avis ou signaler un bug",
|
||||
@@ -13,6 +10,7 @@
|
||||
"Access is limited to people who have an invitation code. If you have one, please enter it below.": "L'accès est limité aux personnes qui ont un code d'invitation. Si vous en avez un, veuillez le saisir ci-dessous.",
|
||||
"Account activated successfully!": "Compte activé avec succès !",
|
||||
"Add attach file": "Ajouter une pièce jointe",
|
||||
"Add file": "Ajouter un fichier",
|
||||
"Allow conversation analysis": "Autoriser l'analyse de conversation",
|
||||
"An error occurred. Please try again.": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"Are you sure you want to delete this conversation ?": "Êtes-vous sûr de vouloir supprimer cette conversation ?",
|
||||
@@ -45,8 +43,11 @@
|
||||
"Failed to register for notifications. Please try again.": "Échec de l'inscription aux notifications. Veuillez réessayer.",
|
||||
"Failed to send feedback": "Échec de l’envoi du commentaire",
|
||||
"Failed to update settings": "Impossible de mettre à jour les paramètres",
|
||||
"Failed to upload file": "Impossible de téléverser le fichier",
|
||||
"Failed to upload files. Please try again.": "Le téléversement a échoué. Veuillez réessayer.",
|
||||
"Feedback Négatif": "Retour négatif",
|
||||
"Feedback positif": "Retour positif",
|
||||
"File type not supported (yet)": "Type de fichier non pris en charge (pour l'instant)",
|
||||
"Find recent news about...": "Trouver les dernières actualités concernant...",
|
||||
"Get notified about the Public Beta.": "Soyez informé de la Bêta publique.",
|
||||
"Get notified for the public beta": "Être notifié pour la bêta publique",
|
||||
@@ -64,7 +65,7 @@
|
||||
"Language": "Langue",
|
||||
"Learn more about data usage.": "En savoir plus sur l’usage des données.",
|
||||
"Load more": "Afficher plus",
|
||||
"Log in to access the document.": "Connectez-vous pour accéder au document.",
|
||||
"Log in to access this page.": "Connectez-vous pour accéder à cette page.",
|
||||
"Login": "Connexion",
|
||||
"Logo": "Logo",
|
||||
"Logout": "Se déconnecter",
|
||||
@@ -80,9 +81,9 @@
|
||||
"Proconnect Login": "Connexion Proconnect",
|
||||
"Quick search input": "Saisie de recherche rapide",
|
||||
"Remove attachment": "Supprimer la pièce jointe",
|
||||
"Research on the web": "Recherche sur le web\n",
|
||||
"Research on the web": "Rechercher sur le web",
|
||||
"Search": "Rechercher",
|
||||
"Search for a chat": "Rechercher",
|
||||
"Search for a chat": "Rechercher un chat",
|
||||
"Search results": "Résultats de la recherche",
|
||||
"Search...": "Recherche...",
|
||||
"Select": "Sélectionner",
|
||||
@@ -101,10 +102,15 @@
|
||||
"The conversation has been deleted.": "La conversation a été supprimée.",
|
||||
"The summary feature is not supported yet.": "La fonctionnalité de résumé n'est pas encore prise en charge.",
|
||||
"Thinking...": "Réflexion...",
|
||||
"To add a file to the conversation, drop it here.": "Pour ajouter un fichier à la conversation, déposez-le ici.",
|
||||
"Turn this list into bullet points": "Transformer cette liste en liste à puces...",
|
||||
"Unlock access": "Déverrouiller l'accès",
|
||||
"Unlocking...": "Déverrouillage en cours...",
|
||||
"Untitled conversation": "Conversation sans titre",
|
||||
"Upload Error": "Erreur lors du téléversement",
|
||||
"Uploading files...": "Téléversement des fichiers...",
|
||||
"Use the \"{{attach_file_btn}}\" button to have a better view.": "Utilisez le bouton \"{{attach_file_btn}}\" pour avoir une meilleure vue.",
|
||||
"We currently support only specific file types...": "Nous ne supportons actuellement que des types de fichiers spécifiques...",
|
||||
"We'll email you at {{email}} when the public beta opens.": "Nous vous enverrons un e-mail à {{email}} lorsque la bêta publique sera ouverte.",
|
||||
"We'll email you when the public beta opens.": "Nous vous enverrons un email quand la bêta publique sera ouverte.",
|
||||
"Web": "Web",
|
||||
@@ -112,7 +118,7 @@
|
||||
"Write a short product description": "Écrire une description courte du produit...",
|
||||
"Write on Tchap": "Écrire sur Tchap",
|
||||
"You are on the list": "Vous êtes dans la liste",
|
||||
"You do not have permission to view this document.": "Vous n'avez pas la permission de voir ce contenu.",
|
||||
"You do not have permission to view this page.": "Vous n’avez pas la permission de voir cette page.",
|
||||
"You will be notified!": "Vous serez notifié !",
|
||||
"Your account is already activated.": "Votre compte est déjà activé.",
|
||||
"Your sovereign AI assistant": "Votre assistant IA souverain",
|
||||
@@ -120,5 +126,383 @@
|
||||
"sources": "sources",
|
||||
"{{productName}} Logo": "Logo {{productName}}"
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"translation": {
|
||||
"30 sec to tell us what you think or report a bug": "30 seconden om ons te vertellen wat u ervan vindt of een bug te melden",
|
||||
"A privacy-first assistant built for French public teams. Natively synced with LaSuite apps to help you draft, search, and decide without leaving your workflow. Beta access is available with a referral code.": "Een privacygerichte assistent, speciaal ontwikkeld voor teams. Native gesynchroniseerd met apps om je te helpen bij het opstellen, zoeken en beslissen zonder je workflow te verlaten. Bètatoegang is beschikbaar met een verwijzingscode.",
|
||||
"ABC-1234-XY": "ABC-1234-XY",
|
||||
"Access Denied - Error 403": "Toegang geweigerd - Fout 403",
|
||||
"Access is limited to people who have an invitation code. If you have one, please enter it below.": "Toegang is beperkt tot personen met een uitnodigingscode. Als u die heeft, voer deze dan hieronder in.",
|
||||
"Account activated successfully!": "Account succesvol geactiveerd!",
|
||||
"Add attach file": "Voeg een bijlage toe",
|
||||
"Add file": "Bestand toevoegen",
|
||||
"Allow conversation analysis": "Gespreksanalyse toestaan",
|
||||
"An error occurred. Please try again.": "Er is een fout opgetreden. Probeer het opnieuw.",
|
||||
"Are you sure you want to delete this conversation ?": "Weet u zeker dat u dit gesprek wilt verwijderen?",
|
||||
"Ask a question": "Stel een vraag",
|
||||
"Assistant is already available, log in to use it now.": "Assistent is al beschikbaar, log in om het nu te gebruiken.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Assistent is in ontwikkeling: uw feedback telt! Kies hoe u uw ideeën wilt delen:",
|
||||
"Assistant settings": "Assistentinstellingen",
|
||||
"Attach file": "Bestand bijvoegen",
|
||||
"Attachment summary not supported": "Bijlageoverzicht niet ondersteund",
|
||||
"Available soon": "Binnenkort beschikbaar",
|
||||
"Cancel": "Annuleren",
|
||||
"Clear search": "Zoekopdracht wissen",
|
||||
"Close model selector": "Sluit modelselector",
|
||||
"Close the modal": "Sluit het venster",
|
||||
"Confirm deletion": "Verwijdering bevestigen",
|
||||
"Content modal to delete conversation": "Inhoudsvenster om conversatie te verwijderen",
|
||||
"Conversation actions": "Conversatieacties",
|
||||
"Conversation analysis disabled": "Gespreksanalyse uitgeschakeld",
|
||||
"Conversation analysis enabled": "Gespreksanalyse ingeschakeld",
|
||||
"Copied": "Gekopieerd",
|
||||
"Copy": "Kopiëren",
|
||||
"Default": "Standaard",
|
||||
"Delete": "Verwijderen",
|
||||
"Delete a conversation": "Een gesprek verwijderen",
|
||||
"Delete chat": "Chat verwijderen",
|
||||
"Direct exchange with our team": "Directe uitwisseling met ons team",
|
||||
"Explore other LaSuite apps": "Ontdek andere LaSuite-apps",
|
||||
"Failed to activate account. Please try again.": "Account activeren mislukt. Probeer het opnieuw.",
|
||||
"Failed to copy": "Kopiëren mislukt",
|
||||
"Failed to register for notifications. Please try again.": "Registratie voor meldingen mislukt. Probeer het opnieuw.",
|
||||
"Failed to send feedback": "Het is niet gelukt om feedback te verzenden",
|
||||
"Failed to update settings": "Het is niet gelukt om de instellingen bij te werken",
|
||||
"Failed to upload file": "Het uploaden van het bestand is mislukt",
|
||||
"Failed to upload files. Please try again.": "Bestanden uploaden is mislukt. Probeer het opnieuw.",
|
||||
"Feedback Négatif": "Negatieve feedback",
|
||||
"Feedback positif": "Positieve feedback",
|
||||
"File type not supported (yet)": "Bestandstype wordt (nog) niet ondersteund",
|
||||
"Find recent news about...": "Vind het laatste nieuws over...",
|
||||
"Get notified about the Public Beta.": "Ontvang een melding over de openbare bèta.",
|
||||
"Get notified for the public beta": "Ontvang een melding voor de openbare bètaversie",
|
||||
"Give a quick opinion": "Geef snel een mening",
|
||||
"Give feedback": "Geef feedback",
|
||||
"History": "Geschiedenis",
|
||||
"Home": "Thuis",
|
||||
"If enabled, this allows us to analyse your exchanges to improve the Assistant. If disabled, all conversations remain confidential and are not used in any way. ": "Indien ingeschakeld, kunnen we uw gesprekken analyseren om de Assistent te verbeteren. Indien uitgeschakeld, blijven alle gesprekken vertrouwelijk en worden ze op geen enkele manier gebruikt. ",
|
||||
"Illustration": "Illustratie",
|
||||
"Image 401": "Afbeelding 401",
|
||||
"Image 403": "Afbeelding 403",
|
||||
"Invalid activation code. Please check and try again.": "Ongeldige activeringscode. Controleer en probeer het opnieuw.",
|
||||
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Het lijkt erop dat de pagina die u zoekt niet bestaat of niet correct kan worden weergegeven.",
|
||||
"Know more": "Meer weten",
|
||||
"Language": "Taal",
|
||||
"Learn more about data usage.": "Meer informatie over datagebruik.",
|
||||
"Load more": "Laad Meer",
|
||||
"Log in to access this page.": "Meld u aan om deze pagina te zien.",
|
||||
"Login": "Login",
|
||||
"Logo": "Logo",
|
||||
"Logout": "Uitloggen",
|
||||
"New chat": "Nieuwe chat",
|
||||
"New feedback": "Nieuwe feedback",
|
||||
"No code? ": "Geen code? ",
|
||||
"No conversation found": "Geen gesprek gevonden",
|
||||
"Notify me": "Breng mij op de hoogte",
|
||||
"Open": "Open",
|
||||
"Open the header menu": "Open het hoofdmenu",
|
||||
"Page Not Found - Error 404": "Pagina niet gevonden - Fout 404",
|
||||
"Please enter an activation code": "Voer een activeringscode in",
|
||||
"Proconnect Login": "Login",
|
||||
"Quick search input": "Snelle zoekinvoer",
|
||||
"Remove attachment": "Bijlage verwijderen",
|
||||
"Research on the web": "Onderzoek op het internet",
|
||||
"Search": "Zoek",
|
||||
"Search for a chat": "Zoek naar een chat",
|
||||
"Search results": "Zoekresultaten",
|
||||
"Search...": "Zoek...",
|
||||
"Select": "Selecteer",
|
||||
"Select model": "Selecteer model",
|
||||
"Send": "Verstuur",
|
||||
"Settings": "Instellingen",
|
||||
"Show": "Toon",
|
||||
"Simple chat icon": "Eenvoudig chatpictogram",
|
||||
"Something bad happens, please retry.": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"Sorry, an error occurred. Please try again.": "Sorry, er is een fout opgetreden. Probeer het opnieuw.",
|
||||
"Start a new conversation.": "Begin een nieuw gesprek.",
|
||||
"Start conversation": "Begin een gesprek",
|
||||
"Stop": "Stop",
|
||||
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "De Assistent is een soevereine conversationele AI, ontworpen voor ambtenaren. Het helpt je tijd te besparen bij dagelijkse taken zoals het herformuleren, samenvatten, vertalen of zoeken van informatie. Je gegevens verlaten het land nooit en worden opgeslagen op beveiligde, door de overheid goedgekeurde infrastructuren. Ze worden nooit gebruikt voor commerciële doeleinden.",
|
||||
"The Assistant is in Beta": "De Assistent is in bèta",
|
||||
"The conversation has been deleted.": "Het gesprek is verwijderd.",
|
||||
"The summary feature is not supported yet.": "De samenvattingsfunctie wordt nog niet ondersteund.",
|
||||
"Thinking...": "Denken...",
|
||||
"To add a file to the conversation, drop it here.": "Als u een bestand aan het gesprek wilt toevoegen, sleept u het hierheen.",
|
||||
"Turn this list into bullet points": "Zet deze lijst om in opsommingstekens",
|
||||
"Unlock access": "Toegang ontgrendelen",
|
||||
"Unlocking...": "Ontgrendelen...",
|
||||
"Untitled conversation": "Ongetiteld gesprek",
|
||||
"Upload Error": "Uploadfout",
|
||||
"Uploading files...": "Bestanden uploaden...",
|
||||
"Use the \"{{attach_file_btn}}\" button to have a better view.": "Gebruik de knop \"{{attach_file_btn}}\" voor een beter overzicht.",
|
||||
"We currently support only specific file types...": "Momenteel ondersteunen we alleen specifieke bestandstypen...",
|
||||
"We'll email you at {{email}} when the public beta opens.": "We sturen u een e-mail op {{email}} zodra de openbare bètaversie opengaat.",
|
||||
"We'll email you when the public beta opens.": "We sturen u een e-mail zodra de openbare bètaversie beschikbaar is.",
|
||||
"Web": "Internet",
|
||||
"What is on your mind?": "Waar denk je aan?",
|
||||
"Write a short product description": "Schrijf een korte productbeschrijving",
|
||||
"Write on Tchap": "Schrijf op Tchap",
|
||||
"You are on the list": "Je staat op de lijst",
|
||||
"You do not have permission to view this page.": "U heeft geen toestemming om deze pagina te bekijken.",
|
||||
"You will be notified!": "U wordt op de hoogte gebracht!",
|
||||
"Your account is already activated.": "Uw account is al geactiveerd.",
|
||||
"Your sovereign AI assistant": "Uw soevereine AI-assistent",
|
||||
"source": "bron",
|
||||
"sources": "bronnen",
|
||||
"{{productName}} Logo": "{{productName}} Logo"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"translation": {
|
||||
"30 sec to tell us what you think or report a bug": "В течение 30 секунд расскажите нам, о чём вы думаете или сообщите об ошибке",
|
||||
"A privacy-first assistant built for French public teams. Natively synced with LaSuite apps to help you draft, search, and decide without leaving your workflow. Beta access is available with a referral code.": "Помощник, ориентированный на конфиденциальность, созданный для французских государственных команд. Встроенная синхронизация с приложениями LaSuite помогает составлять черновики, выполнять поиск и принимать решения, не отрываясь от рабочего процесса. Доступ к бета-версии можно получить с помощью реферального кода.",
|
||||
"ABC-1234-XY": "ABC-1234-XY",
|
||||
"Access Denied - Error 403": "Отказано в доступе - Ошибка 403",
|
||||
"Access is limited to people who have an invitation code. If you have one, please enter it below.": "Доступ ограничен людьми с кодом приглашения. Если он у вас есть, введите его ниже.",
|
||||
"Account activated successfully!": "Учётная запись успешно активирована!",
|
||||
"Add attach file": "Добавить вложение",
|
||||
"Add file": "Добавить файл",
|
||||
"Allow conversation analysis": "Разрешить анализ бесед",
|
||||
"An error occurred. Please try again.": "Произошла ошибка. Пожалуйста, повторите попытку.",
|
||||
"Are you sure you want to delete this conversation ?": "Вы действительно хотите удалить эту беседу?",
|
||||
"Ask a question": "Задать вопрос",
|
||||
"Assistant is already available, log in to use it now.": "Помощник уже доступен, просто войдите в систему.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помощник находится в разработке: ваши отзывы важны! Выберите, как поделиться своими идеями:",
|
||||
"Assistant settings": "Настройки помощника",
|
||||
"Attach file": "Прикрепить файл",
|
||||
"Attachment summary not supported": "Сводка о вложениях не поддерживается",
|
||||
"Available soon": "Скоро будет доступно",
|
||||
"Cancel": "Отмена",
|
||||
"Clear search": "Очистить поиск",
|
||||
"Close model selector": "Закрыть выбор модели",
|
||||
"Close the modal": "Закрыть это окно",
|
||||
"Confirm deletion": "Подтвердите удаление",
|
||||
"Content modal to delete conversation": "Подтверждение удаления беседы",
|
||||
"Conversation actions": "Действия в беседе",
|
||||
"Conversation analysis disabled": "Анализ бесед отключён",
|
||||
"Conversation analysis enabled": "Анализ бесед включён",
|
||||
"Copied": "Скопировано",
|
||||
"Copy": "Копировать",
|
||||
"Default": "По-умолчанию",
|
||||
"Delete": "Удалить",
|
||||
"Delete a conversation": "Удалить беседу",
|
||||
"Delete chat": "Удалить беседу",
|
||||
"Direct exchange with our team": "Прямое общение с нашей командой",
|
||||
"Explore other LaSuite apps": "Посмотреть другие приложения LaSuite",
|
||||
"Failed to activate account. Please try again.": "Не удалось активировать учётную запись. Пожалуйста, попробуйте снова.",
|
||||
"Failed to copy": "Не удалось скопировать",
|
||||
"Failed to register for notifications. Please try again.": "Не удалось зарегистрироваться для получения уведомлений. Пожалуйста, попробуйте ещё раз.",
|
||||
"Failed to send feedback": "Не удалось отправить отзыв",
|
||||
"Failed to update settings": "Не удалось обновить настройки",
|
||||
"Failed to upload file": "Не удалось выгрузить файл",
|
||||
"Failed to upload files. Please try again.": "Не удалось выгрузить файлы. Повторите попытку.",
|
||||
"Feedback Négatif": "Отрицательный отзыв",
|
||||
"Feedback positif": "Положительный отзыв",
|
||||
"File type not supported (yet)": "Тип файла не поддерживается (пока ещё)",
|
||||
"Find recent news about...": "Найти последние новости...",
|
||||
"Get notified about the Public Beta.": "Получать уведомления о публичной бета-версии.",
|
||||
"Get notified for the public beta": "Получать уведомления о публичной бета-версии",
|
||||
"Give a quick opinion": "Оставить быстрый отзыв",
|
||||
"Give feedback": "Оставить отзыв",
|
||||
"History": "История",
|
||||
"Home": "Главная",
|
||||
"If enabled, this allows us to analyse your exchanges to improve the Assistant. If disabled, all conversations remain confidential and are not used in any way. ": "Если этот параметр включён, мы можем анализировать ваши беседы для улучшения интеллекта помощника. Если отключён, то все разговоры остаются конфиденциальными и никак не используются. ",
|
||||
"Illustration": "Иллюстрация",
|
||||
"Image 401": "Изображение 401",
|
||||
"Image 403": "Изображение 403",
|
||||
"Invalid activation code. Please check and try again.": "Неверный код активации. Пожалуйста, проверьте его и повторите попытку.",
|
||||
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Похоже, страница, которую вы ищете, не существует или не может быть правильно отображена.",
|
||||
"Know more": "Подробнее",
|
||||
"Language": "Язык",
|
||||
"Learn more about data usage.": "Узнайте больше об использовании данных.",
|
||||
"Load more": "Загрузить ещё",
|
||||
"Log in to access this page.": "Войдите, чтобы получить доступ к этой странице.",
|
||||
"Login": "Войти",
|
||||
"Logo": "Логотип",
|
||||
"Logout": "Выйти",
|
||||
"New chat": "Новая беседа",
|
||||
"New feedback": "Новый отзыв",
|
||||
"No code? ": "Нет кода? ",
|
||||
"No conversation found": "Беседы не найдены",
|
||||
"Notify me": "Уведомите меня",
|
||||
"Open": "Открыть",
|
||||
"Open the header menu": "Открыть меню заголовка",
|
||||
"Page Not Found - Error 404": "Страница не найдена - Ошибка 404",
|
||||
"Please enter an activation code": "Пожалуйста, введите код активации",
|
||||
"Proconnect Login": "Войти через Proconnect",
|
||||
"Quick search input": "Быстрый поиск",
|
||||
"Remove attachment": "Удалить вложение",
|
||||
"Research on the web": "Исследование в Интернете",
|
||||
"Search": "Поиск",
|
||||
"Search for a chat": "Поиск беседы",
|
||||
"Search results": "Результаты поиска",
|
||||
"Search...": "Поиск...",
|
||||
"Select": "Выбрать",
|
||||
"Select model": "Выберите модель",
|
||||
"Send": "Отправить",
|
||||
"Settings": "Настройки",
|
||||
"Show": "Показать",
|
||||
"Simple chat icon": "Простой значок чата",
|
||||
"Something bad happens, please retry.": "Что-то пошло не так, повторите попытку.",
|
||||
"Sorry, an error occurred. Please try again.": "Извините, произошла ошибка. Пожалуйста, попробуйте ещё раз.",
|
||||
"Start a new conversation.": "Начать новую беседу.",
|
||||
"Start conversation": "Начать беседу",
|
||||
"Stop": "Остановить",
|
||||
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "Помощник - собеседник на основе ИИ для государственных служащих. Он поможет вам сэкономить время на ежедневных задачах, таких как перефразирование, обобщение, перевод или поиск информации. Ваши данные никогда не покидают Францию и хранятся в охраняемой государственной инфраструктуре, которая никогда не используется в коммерческих целях.",
|
||||
"The Assistant is in Beta": "Помощник находится на этапе Бета-версии",
|
||||
"The conversation has been deleted.": "Беседа была удалена.",
|
||||
"The summary feature is not supported yet.": "Функция сводки пока не поддерживается.",
|
||||
"Thinking...": "Размышление...",
|
||||
"To add a file to the conversation, drop it here.": "Чтобы добавить файл в беседу, поместите его сюда.",
|
||||
"Turn this list into bullet points": "Преобразовать этот список в маркированный",
|
||||
"Unlock access": "Разблокировать",
|
||||
"Unlocking...": "Разблокировка...",
|
||||
"Untitled conversation": "Беседа без названия",
|
||||
"Upload Error": "Ошибка выгрузки",
|
||||
"Uploading files...": "Выгрузка файлов...",
|
||||
"Use the \"{{attach_file_btn}}\" button to have a better view.": "Используйте кнопку \"{{attach_file_btn}}\".",
|
||||
"We currently support only specific file types...": "В настоящее время мы поддерживаем только определённые типы файлов...",
|
||||
"We'll email you at {{email}} when the public beta opens.": "Когда станет доступна публичная бета-версия, мы отправим вам электронное письмо на адрес {{email}}.",
|
||||
"We'll email you when the public beta opens.": "Мы отправим вам письмо, когда станет доступна публичная бета-версия.",
|
||||
"Web": "Интернет",
|
||||
"What is on your mind?": "О чём вы думаете?",
|
||||
"Write a short product description": "Введите краткое описание продукта",
|
||||
"Write on Tchap": "Написать в Tchap",
|
||||
"You are on the list": "Вы в списке",
|
||||
"You do not have permission to view this page.": "У вас недостаточно прав для просмотра этой страницы.",
|
||||
"You will be notified!": "Вы получите уведомление!",
|
||||
"Your account is already activated.": "Ваша учётная запись уже активирована.",
|
||||
"Your sovereign AI assistant": "Ваш надёжный ИИ-помощник",
|
||||
"source": "источник",
|
||||
"sources": "источники",
|
||||
"{{productName}} Logo": "Логотип {{productName}}"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"translation": {
|
||||
"30 sec to tell us what you think or report a bug": "30 секунд, щоб розповісти нам, що ви думаєте чи повідомити про помилку",
|
||||
"A privacy-first assistant built for French public teams. Natively synced with LaSuite apps to help you draft, search, and decide without leaving your workflow. Beta access is available with a referral code.": "Помічник, що ставить на перше місце конфіденційність, створений для французьких державних команд. Вбудована синхронізація з додатками LaSuite, щоб допомогти вам складати проекти, здійснювати пошук і приймати рішення, не відволікаючись від робочого процесу. Доступ до бета-версії можна отримати за допомогою реферального коду.",
|
||||
"ABC-1234-XY": "ABC-1234-XY",
|
||||
"Access Denied - Error 403": "Доступ заборонений - Помилка 403",
|
||||
"Access is limited to people who have an invitation code. If you have one, please enter it below.": "Доступ обмежено учасниками з кодом запрошення. Якщо у вас є один такий, будь ласка, введіть його нижче.",
|
||||
"Account activated successfully!": "Обліковий запис успішно активовано!",
|
||||
"Add attach file": "Додати файл вкладення",
|
||||
"Add file": "Додати файл",
|
||||
"Allow conversation analysis": "Дозволити аналіз розмови",
|
||||
"An error occurred. Please try again.": "Сталась помилка. Спробуйте ще раз.",
|
||||
"Are you sure you want to delete this conversation ?": "Ви дійсно бажаєте видалити цю розмову?",
|
||||
"Ask a question": "Задати питання",
|
||||
"Assistant is already available, log in to use it now.": "Помічник вже доступний, увійдіть щоб почати використання.",
|
||||
"Assistant is in development: your feedback matters! Choose how to share your ideas:": "Помічник в розробці: ваші відгуки мають значення! Оберіть, як поділитися своїми ідеями:",
|
||||
"Assistant settings": "Налаштування помічника",
|
||||
"Attach file": "Додати файл",
|
||||
"Attachment summary not supported": "Узагальнення вкладень не підтримується",
|
||||
"Available soon": "Незабаром буде доступно",
|
||||
"Cancel": "Скасувати",
|
||||
"Clear search": "Очистити вікно пошуку",
|
||||
"Close model selector": "Закрити вікно вибору моделі",
|
||||
"Close the modal": "Закрити це вікно",
|
||||
"Confirm deletion": "Підтвердження видалення",
|
||||
"Content modal to delete conversation": "Підтвердження видалення розмови",
|
||||
"Conversation actions": "Дії з розмовою",
|
||||
"Conversation analysis disabled": "Аналіз розмови вимкнено",
|
||||
"Conversation analysis enabled": "Аналіз розмов увімкнено",
|
||||
"Copied": "Скопійовано",
|
||||
"Copy": "Копіювати",
|
||||
"Default": "За замовчуванням",
|
||||
"Delete": "Видалити",
|
||||
"Delete a conversation": "Видалити розмову",
|
||||
"Delete chat": "Видалити розмову",
|
||||
"Direct exchange with our team": "Пряме спілкування з нашою командою",
|
||||
"Explore other LaSuite apps": "Ознайомтесь з іншими застосунками LaSuite",
|
||||
"Failed to activate account. Please try again.": "Не вдалося активувати обліковий запис. Спробуйте ще раз.",
|
||||
"Failed to copy": "Не вдалось скопіювати",
|
||||
"Failed to register for notifications. Please try again.": "Не вдалося виконати реєстрацію для повідомлень. Будь ласка, спробуйте ще раз.",
|
||||
"Failed to send feedback": "Не вдалося надіслати відгук",
|
||||
"Failed to update settings": "Не вдалося оновити налаштування",
|
||||
"Failed to upload file": "Не вдалося вивантажити файл",
|
||||
"Failed to upload files. Please try again.": "Не вдалося вивантажити файли. Будь ласка, спробуйте ще раз.",
|
||||
"Feedback Négatif": "Негативний відгук",
|
||||
"Feedback positif": "Позитивний відгук",
|
||||
"File type not supported (yet)": "Тип файлу не підтримується (поки що)",
|
||||
"Find recent news about...": "Знайти останні новини про...",
|
||||
"Get notified about the Public Beta.": "Отримувати повідомлення про публічну бета-версію.",
|
||||
"Get notified for the public beta": "Отримувати повідомлення про публічну бета-версію",
|
||||
"Give a quick opinion": "Дати швидкий відгук",
|
||||
"Give feedback": "Залишити відгук",
|
||||
"History": "Історія",
|
||||
"Home": "Головна",
|
||||
"If enabled, this allows us to analyse your exchanges to improve the Assistant. If disabled, all conversations remain confidential and are not used in any way. ": "Якщо увімкнуто, це дозволить нам аналізувати ваші розмови для покращення помічника. Якщо вимкнено, всі розмови залишаються конфіденційними та не використовуються жодним чином. ",
|
||||
"Illustration": "Ілюстрація",
|
||||
"Image 401": "Зображення 401",
|
||||
"Image 403": "Зображення 403",
|
||||
"Invalid activation code. Please check and try again.": "Невірний код активації. Будь ласка, перевірте та повторіть спробу.",
|
||||
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Схоже, що сторінка, яку ви шукаєте, не існує або не може бути показаною правильно.",
|
||||
"Know more": "Докладніше",
|
||||
"Language": "Мова",
|
||||
"Learn more about data usage.": "Дізнатися більше про використання даних.",
|
||||
"Load more": "Завантажити ще",
|
||||
"Log in to access this page.": "Увійдіть, щоб отримати доступ до цієї сторінки.",
|
||||
"Login": "Увійти",
|
||||
"Logo": "Логотип",
|
||||
"Logout": "Вийти",
|
||||
"New chat": "Нова розмова",
|
||||
"New feedback": "Новий відгук",
|
||||
"No code? ": "Немає коду? ",
|
||||
"No conversation found": "Розмови не знайдено",
|
||||
"Notify me": "Нагадати мені",
|
||||
"Open": "Відкрити",
|
||||
"Open the header menu": "Відкрити меню заголовка",
|
||||
"Page Not Found - Error 404": "Сторінку не знайдено - Помилка 404",
|
||||
"Please enter an activation code": "Будь ласка, введіть код активації",
|
||||
"Proconnect Login": "Увійти через Proconnect",
|
||||
"Quick search input": "Швидкий пошук",
|
||||
"Remove attachment": "Видалити вкладення",
|
||||
"Research on the web": "Дослідження в Інтернеті",
|
||||
"Search": "Пошук",
|
||||
"Search for a chat": "Пошук розмови",
|
||||
"Search results": "Результати пошуку",
|
||||
"Search...": "Пошук...",
|
||||
"Select": "Вибрати",
|
||||
"Select model": "Виберіть модель",
|
||||
"Send": "Відправити",
|
||||
"Settings": "Налаштування",
|
||||
"Show": "Показати",
|
||||
"Simple chat icon": "Проста піктограма розмови",
|
||||
"Something bad happens, please retry.": "Сталася помилка, спробуйте ще раз.",
|
||||
"Sorry, an error occurred. Please try again.": "Вибачте, виникла помилка. Спробуйте ще раз.",
|
||||
"Start a new conversation.": "Розпочати нову розмову.",
|
||||
"Start conversation": "Почати розмову",
|
||||
"Stop": "Зупинити",
|
||||
"The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.": "Помічник - це розмовний ШІ, призначений для державних службовців. Він допоможе вам зберегти час в таких щоденних завданнях, як рефразування, узагальнення, переклад або пошукова інформація. Ваші дані ніколи не покидають Францію та зберігаються на захищеній державній інфраструктурі. Вони ніколи не використовуються для комерційних цілей.",
|
||||
"The Assistant is in Beta": "Помічник у бета-версії",
|
||||
"The conversation has been deleted.": "Розмова була видалена.",
|
||||
"The summary feature is not supported yet.": "Функція узагальнення ще не підтримується.",
|
||||
"Thinking...": "Мислення...",
|
||||
"To add a file to the conversation, drop it here.": "Щоб додати файл до розмови, покладіть його сюди.",
|
||||
"Turn this list into bullet points": "Перетворити цей список на маркований",
|
||||
"Unlock access": "Розблокувати доступ",
|
||||
"Unlocking...": "Розблоковування...",
|
||||
"Untitled conversation": "Розмова без назви",
|
||||
"Upload Error": "Помилка вивантаження",
|
||||
"Uploading files...": "Вивантаження файлів...",
|
||||
"Use the \"{{attach_file_btn}}\" button to have a better view.": "Використовуйте кнопку \"{{attach_file_btn}}\".",
|
||||
"We currently support only specific file types...": "Наразі ми підтримуємо лише конкретні типи файлів...",
|
||||
"We'll email you at {{email}} when the public beta opens.": "Коли стане доступна публічна бета-версія, ми надішлемо вам листа на адресу {{email}}.",
|
||||
"We'll email you when the public beta opens.": "Коли стане доступна публічна бета-версія, ми надішлемо вам листа.",
|
||||
"Web": "Інтернет",
|
||||
"What is on your mind?": "Що у вас на думці?",
|
||||
"Write a short product description": "Напишіть короткий опис продукту",
|
||||
"Write on Tchap": "Написати у Tchap",
|
||||
"You are on the list": "Ви є в списку",
|
||||
"You do not have permission to view this page.": "У вас немає прав для перегляду цієї сторінки.",
|
||||
"You will be notified!": "Ви отримаєте повідомлення!",
|
||||
"Your account is already activated.": "Ваш обліковий запис вже активовано.",
|
||||
"Your sovereign AI assistant": "Ваш надійний помічник з ШІ",
|
||||
"source": "джерело",
|
||||
"sources": "джерела",
|
||||
"{{productName}} Logo": "Логотип {{productName}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type AnalyticEventUser = {
|
||||
eventName: 'user';
|
||||
id: string;
|
||||
email: string;
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
export type AnalyticEvent = AnalyticEventClick | AnalyticEventUser;
|
||||
|
||||
@@ -40,7 +40,7 @@ const Page: NextPageWithLayout = () => {
|
||||
|
||||
<Box $align="center" $gap="0.8rem">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
|
||||
{t('Log in to access the document.')}
|
||||
{t('Log in to access this page.')}
|
||||
</Text>
|
||||
|
||||
<Button onClick={() => gotoLogin(false)} aria-label={t('Login')}>
|
||||
|
||||
@@ -48,7 +48,7 @@ const Page: NextPageWithLayout = () => {
|
||||
|
||||
<Box $align="center" $gap="0.8rem">
|
||||
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
|
||||
{t('You do not have permission to view this document.')}
|
||||
{t('You do not have permission to view this page.')}
|
||||
</Text>
|
||||
|
||||
<StyledLink href="/">
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { ActivationPage } from '@/features/auth';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { ActivationPage, useAuth } from '@/features/auth';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { authenticated, isLoading } = useAuth();
|
||||
const { replace } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !authenticated) {
|
||||
void replace('/');
|
||||
}
|
||||
}, [authenticated, isLoading, replace]);
|
||||
|
||||
if (isLoading || !authenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ActivationPage />;
|
||||
};
|
||||
|
||||
|
||||
@@ -154,13 +154,15 @@ ul a:hover {
|
||||
}
|
||||
|
||||
.mainContent-chat a {
|
||||
color: var(--c--theme--colors--primary-text) !important;
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
transition: box-shadow 0.2s !important;
|
||||
box-shadow: inset 0 -2px 0 0 #dae2ff !important;
|
||||
}
|
||||
|
||||
.mainContent-chat a:hover {
|
||||
color: var(
|
||||
--c--components--button--primary--background--color-hover
|
||||
) !important;
|
||||
color: inherit !important;
|
||||
box-shadow: inset 0 -22px 0 0 #dae2ff !important;
|
||||
}
|
||||
|
||||
.lasuite-gaufre-btn.lasuite--gaufre-opened {
|
||||
|
||||
@@ -21,6 +21,9 @@ export class PostHogAnalytic extends AbstractAnalytic {
|
||||
public trackEvent(evt: AnalyticEvent): void {
|
||||
if (evt.eventName === 'user') {
|
||||
posthog.identify(evt.id, { email: evt.email });
|
||||
if (evt.sub) {
|
||||
posthog.alias(evt.sub, evt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CONFIG = {
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'Français'],
|
||||
// ['de-de', 'Deutsch'],
|
||||
// ['nl-nl', 'Nederlands'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
// ['es-es', 'Español'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "conversations",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-conversations",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:conversations",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user