Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1401a1bc1b | |||
| cb2bfd53cc | |||
| f5a1b72913 | |||
| 9e37f71818 | |||
| 6dd41e827e | |||
| f6edb14e1f | |||
| bcf55c6c0c | |||
| 2f74543d41 | |||
| 995ad407a6 | |||
| 0746d23a1e | |||
| c699d7503d | |||
| bc7568ccf1 | |||
| c17a8ae9b9 | |||
| 4545083210 | |||
| 1088d88aba | |||
| 99353357ff | |||
| 8feed1e3ac | |||
| 61f86e18df | |||
| 2b8123462a | |||
| 3c3eaf3eeb | |||
| e925bff1a2 | |||
| ba712af899 | |||
| e60c938d44 | |||
| 5d895d15f9 | |||
| 96a2963adf | |||
| 224e6f83fd | |||
| af6facf4a3 | |||
| 9d16460777 | |||
| 09dceb508d | |||
| 63e0e6c383 | |||
| a919d9a63b | |||
| 9506df341a | |||
| c87c734e98 | |||
| 41ade65a49 | |||
| fd372e1c56 | |||
| 3bbe7fb125 | |||
| 082937e107 | |||
| 54d2d3e807 | |||
| 23964cbf12 | |||
| ff9e8335f2 |
@@ -1,6 +1,7 @@
|
||||
[codespell]
|
||||
skip =
|
||||
./git/,
|
||||
**/*.pdf,
|
||||
**/*.po,
|
||||
**/*.pot,
|
||||
**/*.json,
|
||||
@@ -9,9 +10,11 @@ skip =
|
||||
**/e2e/report/**,
|
||||
*.tsbuildinfo,
|
||||
**/uv.lock,
|
||||
./docker/files/etc/mime.types,
|
||||
check-filenames = true
|
||||
ignore-words-list =
|
||||
afterAll,
|
||||
statics,
|
||||
exclude-file =
|
||||
./src/backend/chat/agent_rag/web_search/mocked.py,
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
sudo cp $GITHUB_WORKSPACE/docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
@@ -22,15 +22,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -10,13 +10,67 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add ui kit #240
|
||||
- 🧱(files) allow to use S3 storage without external access #849
|
||||
- ✨(backend) add FindRagBackend #209
|
||||
- ✨(back) add projects with custom LLM instructions
|
||||
- ✨(front) projects management UI
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) add missing color option for project colors
|
||||
|
||||
## [0.0.14] - 2026-03-11
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(user) allow disabling automatic internet search
|
||||
- ✨(search) add searchModal and modify leftPanel
|
||||
- ✨(waffle) hide the waffle if not fr theme
|
||||
- ✨(front) allow pasting an attachment from clipboard
|
||||
- ✨(array) temporarily adjust array
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(front) optimize streaming markdown rendering performance
|
||||
- ⬆️(back) update pydantic-ai
|
||||
- ♻️(chat) refactor AIAgentService for readability and maintainability
|
||||
- 🚸(oidc) ignore case when fallback on email #281
|
||||
- ⬆️(back) update pillow, django-pydantic-field, pypdf
|
||||
- ♻️(front) migrate from ESLint 8 to ESLint 9 flat config
|
||||
- ⬆️(back) update django and pypdf
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
|
||||
- 🚑️(back) fix mime type for pptx
|
||||
- 🐛(front) fix math formulas and carousel translations
|
||||
- 🐛(helm) reverse liveness and readiness for backend deployment
|
||||
- 🐛(front) fix dark mode styling on chat messages
|
||||
- 🐛(front) fixed inverted toast for setting changes
|
||||
|
||||
## [0.0.13] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- 💄(front) ui fix : update ui-kit
|
||||
- ✨(front) add persistent darkmode
|
||||
- ✨(front) add ui kit #240
|
||||
- 🧱(files) allow to use S3 storage without external access #849
|
||||
- ✨(backend) add FindRagBackend #209
|
||||
- ⬆️(back) update dependencies
|
||||
- ✨(back) use adaptive parsing for pdf documents
|
||||
|
||||
### Changed
|
||||
|
||||
- 💄(darkmode) change color feedback button
|
||||
- 🏗️(back) migrate to uv
|
||||
- ♻️(front) optimize syntax highlighting bundle size
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) cast collection Ids to API expected types
|
||||
|
||||
## [0.0.12] - 2026-01-27
|
||||
|
||||
@@ -34,8 +88,7 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
|
||||
- ✨(chat) generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -201,8 +254,9 @@ and this project adheres to
|
||||
- ✨(onboarding) add activation code logic for launch #62
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.12...main
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.14...main
|
||||
[0.0.14]: https://github.com/suitenumerique/conversations/releases/v0.0.14
|
||||
[0.0.13]: https://github.com/suitenumerique/conversations/releases/v0.0.13
|
||||
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
|
||||
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
|
||||
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
|
||||
|
||||
@@ -93,7 +93,7 @@ RUN apk add \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
COPY ./docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
@@ -9,11 +9,28 @@ from . import models
|
||||
class ChatConversationAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the ChatConversation model"""
|
||||
|
||||
autocomplete_fields = ("owner",)
|
||||
autocomplete_fields = ("owner", "project")
|
||||
list_select_related = ("project",)
|
||||
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.ChatProject)
|
||||
class ChatProjectAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the ChatProject model"""
|
||||
|
||||
search_fields = ("title",)
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"icon",
|
||||
"color",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Document parsers for RAG backends."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from io import BytesIO
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
|
||||
@@ -63,3 +67,214 @@ class AlbertParser(BaseParser):
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
|
||||
METHOD_TEXT_EXTRACTION = "text_extraction"
|
||||
METHOD_OCR = "ocr"
|
||||
|
||||
|
||||
def analyze_pdf(pdf_data: bytes) -> dict:
|
||||
"""
|
||||
Analyze a PDF to determine if it needs OCR or can use direct text extraction.
|
||||
"""
|
||||
reader = PdfReader(BytesIO(pdf_data))
|
||||
total_pages = len(reader.pages)
|
||||
if total_pages == 0:
|
||||
logger.info("No page found in pdf")
|
||||
return {
|
||||
"total_pages": 0,
|
||||
"pages_with_text": 0,
|
||||
"avg_chars_per_page": 0,
|
||||
"text_coverage": 0,
|
||||
"recommended_method": METHOD_TEXT_EXTRACTION,
|
||||
}
|
||||
|
||||
total_chars = 0
|
||||
pages_with_text = 0
|
||||
for page in reader.pages:
|
||||
text = (page.extract_text() or "").strip()
|
||||
char_count = len(text)
|
||||
total_chars += char_count
|
||||
|
||||
if char_count > 50:
|
||||
pages_with_text += 1
|
||||
|
||||
avg_chars = total_chars / total_pages
|
||||
text_coverage = pages_with_text / total_pages
|
||||
|
||||
# Decision logic
|
||||
if (
|
||||
avg_chars > settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
and text_coverage > settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION
|
||||
):
|
||||
method = METHOD_TEXT_EXTRACTION
|
||||
|
||||
else:
|
||||
method = METHOD_OCR
|
||||
|
||||
return {
|
||||
"total_pages": total_pages,
|
||||
"pages_with_text": pages_with_text,
|
||||
"avg_chars_per_page": round(avg_chars),
|
||||
"text_coverage": round(text_coverage, 2),
|
||||
"recommended_method": method,
|
||||
}
|
||||
|
||||
|
||||
class AdaptiveParserMixin:
|
||||
"""
|
||||
Mixin that adds adaptive PDF parsing behavior.
|
||||
|
||||
Analyzes PDF content to choose between direct text extraction (fast) and OCR
|
||||
(for scanned/image PDFs). Subclasses must implement `parse_pdf_document_with_ocr`.
|
||||
"""
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Analyze PDF and route to text extraction or OCR based on content."""
|
||||
analysis = analyze_pdf(content)
|
||||
|
||||
logger.info(
|
||||
"Pdf analysis - pages: %s, pages with text: %s, text_coverage: %s, "
|
||||
"recommended method: %s",
|
||||
analysis["total_pages"],
|
||||
analysis["pages_with_text"],
|
||||
analysis["text_coverage"],
|
||||
analysis["recommended_method"],
|
||||
)
|
||||
|
||||
method = analysis["recommended_method"]
|
||||
if method == METHOD_TEXT_EXTRACTION:
|
||||
return self.extract_text_from_pdf(name=name, content_type=content_type, content=content)
|
||||
return self.parse_pdf_document_with_ocr(name=name, content=content)
|
||||
|
||||
def extract_text_from_pdf(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Extract text directly from PDF without OCR (for text-based PDFs)."""
|
||||
logger.info("Parsing pdf with text extraction")
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
|
||||
"""Process PDF through OCR. Must be implemented by subclass."""
|
||||
raise NotImplementedError("Subclass must implement parse_pdf_document_with_ocr")
|
||||
|
||||
|
||||
class AdaptivePdfParser(AdaptiveParserMixin, BaseParser):
|
||||
"""
|
||||
PDF parser with adaptive text extraction / OCR routing.
|
||||
|
||||
Uses Mistral OCR API for scanned/image PDFs, processed in batches with retry logic.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.endpoint = urljoin(
|
||||
settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.base_url, "/v1/ocr"
|
||||
)
|
||||
self.max_retries = settings.OCR_MAX_RETRIES
|
||||
self.retry_delay = settings.OCR_RETRY_DELAY
|
||||
api_key = settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.api_key
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def extract_page_batch(self, reader: PdfReader, start_index: int, end_index: int) -> bytes:
|
||||
"""Extract a range of pages from PDF as a new PDF bytes object."""
|
||||
writer = PdfWriter()
|
||||
for i in range(start_index, end_index):
|
||||
writer.add_page(reader.pages[i])
|
||||
output = BytesIO()
|
||||
writer.write(output)
|
||||
return output.getvalue()
|
||||
|
||||
def ocr_page_batch(
|
||||
self,
|
||||
name: str,
|
||||
page_content: bytes,
|
||||
start_index: int,
|
||||
end_index: int,
|
||||
) -> list[str]:
|
||||
"""Send page batch to Mistral OCR API with static delay retry."""
|
||||
file_data = base64.standard_b64encode(page_content).decode("utf-8")
|
||||
payload = {
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_name": f"{name}_pages_{start_index + 1}_to_{end_index}",
|
||||
"document_url": f"data:application/pdf;base64,{file_data}",
|
||||
},
|
||||
"model": settings.OCR_MODEL,
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=settings.OCR_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
pages = response.json().get("pages", [])
|
||||
return [page.get("markdown", "") for page in pages]
|
||||
|
||||
except (requests.Timeout, requests.RequestException) as e:
|
||||
last_exception = e
|
||||
if attempt < self.max_retries - 1:
|
||||
logger.warning(
|
||||
"OCR attempt %d/%d failed for pages %d-%d: %s. Retrying in %.1fs...",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
start_index + 1,
|
||||
end_index,
|
||||
str(e),
|
||||
self.retry_delay,
|
||||
)
|
||||
time.sleep(self.retry_delay)
|
||||
|
||||
logger.error(
|
||||
"OCR failed for pages %d-%d after %d attempts: %s",
|
||||
start_index + 1,
|
||||
end_index,
|
||||
self.max_retries,
|
||||
str(last_exception),
|
||||
)
|
||||
raise last_exception
|
||||
|
||||
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
|
||||
"""Process PDF through OCR in batches, returning concatenated markdown."""
|
||||
reader = PdfReader(BytesIO(content))
|
||||
total_pages = len(reader.pages)
|
||||
batch_size = settings.OCR_BATCH_PAGES
|
||||
|
||||
logger.info("Parsing pdf with OCR (%d pages, batch size %d)", total_pages, batch_size)
|
||||
|
||||
results = []
|
||||
for start_index in range(0, total_pages, batch_size):
|
||||
end_index = min(start_index + batch_size, total_pages)
|
||||
batch_content = self.extract_page_batch(reader, start_index, end_index)
|
||||
try:
|
||||
batch_results = self.ocr_page_batch(name, batch_content, start_index, end_index)
|
||||
results.extend(batch_results)
|
||||
logger.debug(
|
||||
"Completed OCR for pages %d-%d/%d", start_index + 1, end_index, total_pages
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except #noqa: BLE001
|
||||
logger.error("Failed to OCR pages %d-%d: %s", start_index + 1, end_index, str(e))
|
||||
# Preserve page count with empty placeholders to maintain correct ordering
|
||||
results.extend([""] * (end_index - start_index))
|
||||
|
||||
return "\n\n".join(results)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Route to PDF parser or DocumentConverter based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
@@ -7,13 +7,13 @@ from typing import List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import AlbertParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +45,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
|
||||
self._search_endpoint = urljoin(self._base_url, "/v1/search")
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser = AlbertParser()
|
||||
parser_class = import_string(settings.RAG_DOCUMENT_PARSER)
|
||||
self.parser = parser_class()
|
||||
|
||||
@staticmethod
|
||||
def cast_collection_id(collection_id):
|
||||
"""Albert API expects int Ids."""
|
||||
return int(collection_id)
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
|
||||
@@ -41,6 +41,11 @@ class BaseRagBackend(ABC):
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser: BaseParser = BaseParser()
|
||||
|
||||
@staticmethod
|
||||
def cast_collection_id(collection_id):
|
||||
"""Dummy method to be overridden when needed."""
|
||||
return collection_id
|
||||
|
||||
def get_all_collection_ids(self) -> List[str]:
|
||||
"""
|
||||
Get all collection IDs, including the main collection ID and read-only collection IDs.
|
||||
@@ -55,10 +60,13 @@ class BaseRagBackend(ABC):
|
||||
|
||||
collection_ids = []
|
||||
if self.collection_id:
|
||||
collection_ids.append(self.collection_id)
|
||||
collection_ids.append(self.cast_collection_id(self.collection_id))
|
||||
if self.read_only_collection_id:
|
||||
collection_ids.extend(
|
||||
[int(collection_id) for collection_id in self.read_only_collection_id]
|
||||
[
|
||||
self.cast_collection_id(collection_id)
|
||||
for collection_id in self.read_only_collection_id
|
||||
]
|
||||
)
|
||||
return collection_ids
|
||||
|
||||
|
||||
@@ -117,20 +117,14 @@ class ConversationAgent(BaseAgent):
|
||||
"""Dynamic instruction function to set the expected language to use."""
|
||||
return f"Answer in {get_language_name(language).lower()}." if language else ""
|
||||
|
||||
def get_web_search_tool_name(self) -> str | None:
|
||||
def is_web_search_configured(self) -> bool:
|
||||
"""
|
||||
Get the name of the web search tool if available.
|
||||
Return True when a web search backend is configured on this model.
|
||||
|
||||
If several are available, return the first one found.
|
||||
|
||||
Warning, this says the tool is available, not that
|
||||
it (the tool/feature) is enabled for the current conversation.
|
||||
This does not mean web search is enabled for the current conversation
|
||||
(feature flags and runtime deps still apply).
|
||||
"""
|
||||
for toolset in self.toolsets:
|
||||
for tool in toolset.tools.values():
|
||||
if tool.name.startswith("web_search_"):
|
||||
return tool.name
|
||||
return None
|
||||
return bool(getattr(self.configuration, "web_search", None))
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
|
||||
@@ -9,6 +9,29 @@ from core.factories import UserFactory
|
||||
from . import models
|
||||
|
||||
|
||||
class ChatProjectFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Project instances."""
|
||||
|
||||
title = factory.Sequence(lambda n: f"title {n}")
|
||||
owner = factory.SubFactory(UserFactory)
|
||||
icon = factory.fuzzy.FuzzyChoice(models.ChatProjectIcon)
|
||||
color = factory.fuzzy.FuzzyChoice(models.ChatProjectColor)
|
||||
|
||||
class Meta:
|
||||
model = models.ChatProject
|
||||
skip_postgeneration_save = True
|
||||
|
||||
@factory.post_generation
|
||||
def number_of_conversations(self, create, extracted, **kwargs):
|
||||
"""Create attached conversations for the project."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
|
||||
if not isinstance(extracted, int):
|
||||
raise TypeError("number_of_conversations must be an integer")
|
||||
ChatConversationFactory.create_batch(extracted, project=self, owner=self.owner)
|
||||
|
||||
|
||||
class ChatConversationFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating ChatConversation instances."""
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ class LLModel(BaseModel):
|
||||
supports_streaming: bool | None = None
|
||||
system_prompt: SettingEnvValue
|
||||
tools: list[str]
|
||||
web_search: SettingEnvValue | None = None
|
||||
|
||||
@field_validator("tools", mode="before")
|
||||
@classmethod
|
||||
@@ -134,6 +135,14 @@ class LLModel(BaseModel):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@field_validator("web_search", mode="before")
|
||||
@classmethod
|
||||
def validate_web_search(cls, value: str | None) -> str | None:
|
||||
"""Convert web_search path if it's a setting or environment variable."""
|
||||
if isinstance(value, str):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_provider_or_provider_name(self) -> Self:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-25 16:20
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("chat", "0005_chatconversation_title_set_by_user_at"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChatProject",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(help_text="Title of the chat project", max_length=100)),
|
||||
(
|
||||
"icon",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("folder", "Folder icon"),
|
||||
("file", "File icon"),
|
||||
("perso", "Perso icon"),
|
||||
("gear", "Gear icon"),
|
||||
("megaphone", "Megaphone icon"),
|
||||
("star", "Star icon"),
|
||||
("bookmark", "Bookmark icon"),
|
||||
("chart", "Chart icon"),
|
||||
("photo", "Photo icon"),
|
||||
("euro", "Euro icon"),
|
||||
("key", "Key icon"),
|
||||
("justice", "Justice icon"),
|
||||
("book", "Book icon"),
|
||||
("puzzle", "Puzzle icon"),
|
||||
("palette", "Palette icon"),
|
||||
("terminal", "Terminal icon"),
|
||||
("car", "Car icon"),
|
||||
("music", "Music icon"),
|
||||
("checkmark", "Checkmark icon"),
|
||||
("la_suite", "La Suite icon"),
|
||||
],
|
||||
help_text="Project icon",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"color",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("color_1", "Color 1"),
|
||||
("color_2", "Color 2"),
|
||||
("color_3", "Color 3"),
|
||||
("color_4", "Color 4"),
|
||||
("color_5", "Color 5"),
|
||||
("color_6", "Color 6"),
|
||||
("color_7", "Color 7"),
|
||||
("color_8", "Color 8"),
|
||||
("color_9", "Color 9"),
|
||||
("color_10", "Color 10"),
|
||||
],
|
||||
help_text="Project icon color",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"llm_instructions",
|
||||
models.TextField(
|
||||
blank=True, help_text="Custom user instructions to be sent to the llm"
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="projects",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="chatconversation",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="conversations",
|
||||
to="chat.chatproject",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatconversation",
|
||||
index=models.Index(
|
||||
fields=["owner", "-created_at"], name="chat_chatco_owner_i_48266a_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatconversation",
|
||||
index=models.Index(fields=["owner", "project"], name="chat_chatco_owner_i_38d719_idx"),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,74 @@ from chat.ai_sdk_types import UIMessage
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ChatProjectIcon(models.TextChoices):
|
||||
"""Project icon text choices."""
|
||||
|
||||
FOLDER = "folder", "Folder icon"
|
||||
FILE = "file", "File icon"
|
||||
PERSO = "perso", "Perso icon"
|
||||
GEAR = "gear", "Gear icon"
|
||||
MEGAPHONE = "megaphone", "Megaphone icon"
|
||||
STAR = "star", "Star icon"
|
||||
BOOKMARK = "bookmark", "Bookmark icon"
|
||||
CHART = "chart", "Chart icon"
|
||||
PHOTO = "photo", "Photo icon"
|
||||
EURO = "euro", "Euro icon"
|
||||
KEY = "key", "Key icon"
|
||||
JUSTICE = "justice", "Justice icon"
|
||||
BOOK = "book", "Book icon"
|
||||
PUZZLE = "puzzle", "Puzzle icon"
|
||||
PALETTE = "palette", "Palette icon"
|
||||
TERMINAL = "terminal", "Terminal icon"
|
||||
CAR = "car", "Car icon"
|
||||
MUSIC = "music", "Music icon"
|
||||
CHECKMARK = "checkmark", "Checkmark icon"
|
||||
LA_SUITE = "la_suite", "La Suite icon"
|
||||
|
||||
|
||||
class ChatProjectColor(models.TextChoices):
|
||||
"""Project icon color choices. We keep it generic to ease frontend compatibility."""
|
||||
|
||||
COLOR_1 = "color_1", "Color 1"
|
||||
COLOR_2 = "color_2", "Color 2"
|
||||
COLOR_3 = "color_3", "Color 3"
|
||||
COLOR_4 = "color_4", "Color 4"
|
||||
COLOR_5 = "color_5", "Color 5"
|
||||
COLOR_6 = "color_6", "Color 6"
|
||||
COLOR_7 = "color_7", "Color 7"
|
||||
COLOR_8 = "color_8", "Color 8"
|
||||
COLOR_9 = "color_9", "Color 9"
|
||||
COLOR_10 = "color_10", "Color 10"
|
||||
|
||||
|
||||
class ChatProject(BaseModel):
|
||||
"""Model representing a project that groups conversations together."""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
related_name="projects",
|
||||
on_delete=models.CASCADE,
|
||||
null=False,
|
||||
blank=False,
|
||||
)
|
||||
title = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Title of the chat project",
|
||||
)
|
||||
icon = models.CharField(max_length=20, choices=ChatProjectIcon, help_text="Project icon")
|
||||
color = models.CharField(
|
||||
max_length=20, choices=ChatProjectColor, help_text="Project icon color"
|
||||
)
|
||||
|
||||
llm_instructions = models.TextField(
|
||||
blank=True,
|
||||
help_text="Custom user instructions to be sent to the llm",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ChatConversation(BaseModel):
|
||||
"""
|
||||
Model representing a chat conversation.
|
||||
@@ -79,6 +147,23 @@ class ChatConversation(BaseModel):
|
||||
help_text="Collection ID for the conversation, used for RAG document search",
|
||||
)
|
||||
|
||||
project = models.ForeignKey(
|
||||
ChatProject,
|
||||
related_name="conversations",
|
||||
on_delete=models.SET_NULL, # explicitly avoid Cascade here
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
indexes = [
|
||||
models.Index(fields=["owner", "-created_at"]),
|
||||
models.Index(fields=["owner", "project"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or str(self.pk)
|
||||
|
||||
|
||||
class ChatConversationAttachment(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -25,10 +25,21 @@ class ChatConversationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner", "project"]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "messages"]
|
||||
|
||||
def validate_project(self, project):
|
||||
"""Ensure the project belongs to the current user."""
|
||||
if project and project.owner != self.context["request"].user:
|
||||
raise serializers.ValidationError("The project must belong to the current user.")
|
||||
return project
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Project is immutable after creation — no moving or detaching
|
||||
if "project" in validated_data:
|
||||
raise serializers.ValidationError(
|
||||
{"project": "This field can only be set at creation time."}
|
||||
)
|
||||
# If title is being changed, mark it as user-set
|
||||
if "title" in validated_data and validated_data["title"] != instance.title:
|
||||
instance.title_set_by_user_at = timezone.now()
|
||||
@@ -216,3 +227,68 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
return size
|
||||
|
||||
|
||||
class ChatProjectNestedSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight read-only serializer for nested project info in search results."""
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatProject
|
||||
fields = ["id", "title", "icon"]
|
||||
read_only_fields = ["id", "title", "icon"]
|
||||
|
||||
|
||||
class ChatConversationSearchSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for conversation search results with nested project info."""
|
||||
|
||||
project = ChatProjectNestedSerializer(read_only=True)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = ["id", "title", "created_at", "updated_at", "project"]
|
||||
read_only_fields = ["id", "title", "created_at", "updated_at", "project"]
|
||||
|
||||
|
||||
class ChatConversationNestedSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for chat conversations."""
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
]
|
||||
read_only_fields = ["id", "title"]
|
||||
|
||||
|
||||
class ChatProjectSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for projects."""
|
||||
|
||||
LLM_INSTRUCTIONS_MAX_LENGTH = 4000 # prevent too large prompts, easier to handle here
|
||||
|
||||
owner = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
# Unbounded: the sidebar needs all conversations per project.
|
||||
# Projects are paginated at the view level, keeping payloads reasonable.
|
||||
conversations = ChatConversationNestedSerializer(many=True, read_only=True)
|
||||
llm_instructions = serializers.CharField(
|
||||
max_length=LLM_INSTRUCTIONS_MAX_LENGTH, required=False, allow_blank=True
|
||||
)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatProject
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"icon",
|
||||
"color",
|
||||
"llm_instructions",
|
||||
"owner",
|
||||
"conversations",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer (pypdf)
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 10
|
||||
/Kids [ 4 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Length 132
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000054 00000 n
|
||||
0000000174 00000 n
|
||||
0000000223 00000 n
|
||||
0000000351 00000 n
|
||||
0000000534 00000 n
|
||||
0000000631 00000 n
|
||||
0000000759 00000 n
|
||||
0000000853 00000 n
|
||||
0000000947 00000 n
|
||||
0000001042 00000 n
|
||||
0000001137 00000 n
|
||||
0000001232 00000 n
|
||||
0000001327 00000 n
|
||||
0000001422 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 16
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
1517
|
||||
%%EOF
|
||||
@@ -0,0 +1,355 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer (pypdf)
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 10
|
||||
/Kids [ 4 0 R 7 0 R 10 0 R 13 0 R 16 0 R 19 0 R 22 0 R 25 0 R 28 0 R 31 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 8 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 9 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 11 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 12 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 14 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 15 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 17 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 18 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 20 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 21 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 23 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 24 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 26 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 27 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 29 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 30 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 32 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 33 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 34
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000054 00000 n
|
||||
0000000176 00000 n
|
||||
0000000225 00000 n
|
||||
0000000353 00000 n
|
||||
0000000736 00000 n
|
||||
0000000833 00000 n
|
||||
0000000961 00000 n
|
||||
0000001344 00000 n
|
||||
0000001441 00000 n
|
||||
0000001572 00000 n
|
||||
0000001956 00000 n
|
||||
0000002054 00000 n
|
||||
0000002185 00000 n
|
||||
0000002569 00000 n
|
||||
0000002667 00000 n
|
||||
0000002798 00000 n
|
||||
0000003182 00000 n
|
||||
0000003280 00000 n
|
||||
0000003411 00000 n
|
||||
0000003795 00000 n
|
||||
0000003893 00000 n
|
||||
0000004024 00000 n
|
||||
0000004408 00000 n
|
||||
0000004506 00000 n
|
||||
0000004637 00000 n
|
||||
0000005021 00000 n
|
||||
0000005119 00000 n
|
||||
0000005250 00000 n
|
||||
0000005634 00000 n
|
||||
0000005732 00000 n
|
||||
0000005863 00000 n
|
||||
0000006247 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 34
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
6345
|
||||
%%EOF
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Tests for AdaptivePdfParser and AdaptiveParserMixin."""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
|
||||
from chat.agent_rag.document_converter.parser import (
|
||||
METHOD_OCR,
|
||||
METHOD_TEXT_EXTRACTION,
|
||||
AdaptivePdfParser,
|
||||
analyze_pdf,
|
||||
)
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture(name="text_pdf_1_page")
|
||||
def provide_text_pdf_1_page():
|
||||
"""Load a 1 page PDF with extractable text."""
|
||||
return (FIXTURES_DIR / "text_pdf_1_page.pdf").read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(name="text_pdf_10_pages")
|
||||
def provide_text_pdf_10_pages():
|
||||
"""Load a 10-page PDF with extractable text (~300 chars per page)."""
|
||||
return (FIXTURES_DIR / "text_10_pages.pdf").read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(name="mixed_pdf_10_pages")
|
||||
def provide_mixed_pdf_10_pages():
|
||||
"""Load a 10-page PDF with 2 pages of text and 8 blank pages."""
|
||||
return (FIXTURES_DIR / "mixed_10_pages.pdf").read_bytes()
|
||||
|
||||
|
||||
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = 200
|
||||
OCR_RETRY_DELAY = 1
|
||||
OCR_MAX_RETRIES = 3
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Mock Django settings for OCR configuration."""
|
||||
settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = 0.7
|
||||
settings.OCR_HRID = "test-ocr-hrid"
|
||||
settings.OCR_MODEL = "test-ocr-model"
|
||||
settings.OCR_TIMEOUT = 60
|
||||
settings.OCR_MAX_RETRIES = OCR_MAX_RETRIES
|
||||
settings.OCR_RETRY_DELAY = OCR_RETRY_DELAY
|
||||
settings.OCR_BATCH_PAGES = 10
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"test-ocr-hrid": MagicMock(
|
||||
provider=MagicMock(
|
||||
base_url="https://ocr.example.com",
|
||||
api_key="test-api-key",
|
||||
)
|
||||
)
|
||||
}
|
||||
return settings
|
||||
|
||||
|
||||
def test_analyze_pdf_returns_correct_structure(text_pdf_10_pages):
|
||||
"""analyze_pdf should return dict with expected keys."""
|
||||
result = analyze_pdf(text_pdf_10_pages)
|
||||
|
||||
assert "total_pages" in result
|
||||
assert "pages_with_text" in result
|
||||
assert "avg_chars_per_page" in result
|
||||
assert "text_coverage" in result
|
||||
assert "recommended_method" in result
|
||||
|
||||
|
||||
def test_analyze_pdf_with_text_recommends_extraction(text_pdf_1_page):
|
||||
"""PDF with sufficient text should recommend text extraction."""
|
||||
result = analyze_pdf(text_pdf_1_page)
|
||||
|
||||
assert result["total_pages"] == 1
|
||||
assert result["pages_with_text"] == 1
|
||||
assert result["text_coverage"] == pytest.approx(1.0)
|
||||
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
|
||||
|
||||
|
||||
def test_analyze_multi_page_pdf_with_text_recommends_extraction(text_pdf_10_pages):
|
||||
"""PDF with sufficient text should recommend text extraction."""
|
||||
result = analyze_pdf(text_pdf_10_pages)
|
||||
|
||||
assert result["total_pages"] == 10
|
||||
assert result["pages_with_text"] == 10
|
||||
assert result["text_coverage"] == pytest.approx(1.0)
|
||||
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
|
||||
|
||||
|
||||
def test_analyze_pdf_mixed_content_recommends_ocr(mixed_pdf_10_pages):
|
||||
"""PDF with low text coverage should recommend OCR."""
|
||||
result = analyze_pdf(mixed_pdf_10_pages)
|
||||
|
||||
assert result["total_pages"] == 10
|
||||
assert result["pages_with_text"] == 2
|
||||
assert result["text_coverage"] == pytest.approx(0.2)
|
||||
assert result["recommended_method"] == METHOD_OCR
|
||||
|
||||
|
||||
def test_extract_page_batch_single_page(text_pdf_10_pages):
|
||||
"""Should extract a single page correctly."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 0, 1)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 1
|
||||
|
||||
|
||||
def test_extract_page_batch_multiple_pages(text_pdf_10_pages):
|
||||
"""Should extract multiple pages correctly."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 2, 7)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 5
|
||||
|
||||
|
||||
def test_extract_page_batch_last_batch(text_pdf_10_pages):
|
||||
"""Should handle last batch with fewer pages."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 7, 10)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 3
|
||||
|
||||
|
||||
def test_ocr_page_batch_success(text_pdf_1_page):
|
||||
"""Should return markdown content on successful OCR."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.return_value = {
|
||||
"pages": [
|
||||
{"markdown": "# Page 1 content"},
|
||||
]
|
||||
}
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Page 1 content"]
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
def test_ocr_page_batch_retry_on_timeout(text_pdf_1_page):
|
||||
"""Should retry on timeout with static delay."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep") as mock_sleep:
|
||||
mock_post.side_effect = [
|
||||
requests.Timeout("Connection timed out"),
|
||||
MagicMock(
|
||||
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
|
||||
raise_for_status=MagicMock(),
|
||||
),
|
||||
]
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Content"]
|
||||
assert mock_post.call_count == 2
|
||||
mock_sleep.assert_called_once_with(OCR_RETRY_DELAY)
|
||||
|
||||
|
||||
def test_ocr_page_batch_fails_after_max_retries(text_pdf_1_page):
|
||||
"""Should raise exception after max retries exceeded."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
mock_post.side_effect = requests.Timeout("Connection timed out")
|
||||
|
||||
with pytest.raises(requests.Timeout):
|
||||
parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert mock_post.call_count == OCR_MAX_RETRIES
|
||||
|
||||
|
||||
def test_ocr_page_batch_retry_on_request_exception(text_pdf_1_page):
|
||||
"""Should retry on general request exceptions."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
mock_post.side_effect = [
|
||||
requests.RequestException("Network error"),
|
||||
requests.RequestException("Network error"),
|
||||
MagicMock(
|
||||
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
|
||||
raise_for_status=MagicMock(),
|
||||
),
|
||||
]
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Content"]
|
||||
assert mock_post.call_count == 3
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_single_batch(text_pdf_10_pages):
|
||||
"""Should process PDF in single batch when pages <= batch size."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.return_value = {
|
||||
"pages": [{"markdown": f"Page {i}"} for i in range(1, 11)]
|
||||
}
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
assert "Page 1" in result
|
||||
assert "Page 10" in result
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_multiple_batches(text_pdf_10_pages, settings):
|
||||
"""Should process PDF in multiple batches when pages > batch size."""
|
||||
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.side_effect = [
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]},
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(5, 9)]},
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(9, 11)]},
|
||||
]
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
assert mock_post.call_count == 3
|
||||
assert "Page 1" in result
|
||||
assert "Page 10" in result
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_partial_failure(text_pdf_10_pages, settings):
|
||||
"""Should insert empty placeholders for failed batches."""
|
||||
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
success_response = MagicMock()
|
||||
success_response.json.return_value = {"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
# First batch succeeds, then all retries fail for remaining batches
|
||||
mock_post.side_effect = [
|
||||
success_response,
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
]
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
parts = result.split("\n\n")
|
||||
# First batch succeeded (4 pages), remaining batches failed (6 pages as placeholders)
|
||||
assert len(parts) == 10
|
||||
assert parts[0] == "Page 1"
|
||||
assert parts[3] == "Page 4"
|
||||
assert parts[4] == "" # Failed batch placeholder
|
||||
|
||||
|
||||
def test_parse_document_pdf_routed_correctly(text_pdf_1_page):
|
||||
"""Should route PDF content type to PDF parser."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch.object(parser, "parse_pdf_document", return_value="pdf content") as mock_parse:
|
||||
result = parser.parse_document("test.pdf", "application/pdf", text_pdf_1_page)
|
||||
|
||||
assert result == "pdf content"
|
||||
mock_parse.assert_called_once_with(
|
||||
name="test.pdf",
|
||||
content_type="application/pdf",
|
||||
content=text_pdf_1_page,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_document_non_pdf_uses_document_converter():
|
||||
"""Should route non-PDF content to DocumentConverter."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.DocumentConverter") as mock_converter:
|
||||
mock_converter.return_value.convert_raw.return_value = "docx content"
|
||||
|
||||
result = parser.parse_document("test.docx", "application/vnd.openxmlformats", b"content")
|
||||
|
||||
assert result == "docx content"
|
||||
mock_converter.return_value.convert_raw.assert_called_once()
|
||||
@@ -3,14 +3,12 @@
|
||||
# pylint:disable=protected-access
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.clients.pydantic_ai import ContextDeps
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -87,47 +85,30 @@ def test_add_dynamic_system_prompt():
|
||||
assert agent._instructions[2]() == "Answer in french."
|
||||
|
||||
|
||||
def test_agent_get_web_search_tool_name(settings):
|
||||
"""Test the web_search_available method."""
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_albert_rag"]
|
||||
def test_agent_is_web_search_configured():
|
||||
"""Test whether web search backend is configured on the model."""
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() == "web_search_albert_rag"
|
||||
assert agent.is_web_search_configured() is False
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather"]
|
||||
|
||||
def test_agent_is_web_search_configured_when_defined_in_model_config(settings):
|
||||
"""Web search is configured when LLModel.web_search is set."""
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"default-model": LLModel(
|
||||
hrid="default-model",
|
||||
model_name="model-123",
|
||||
human_readable_name="Default Model",
|
||||
is_active=True,
|
||||
icon=None,
|
||||
system_prompt="You are a helpful assistant",
|
||||
tools=[],
|
||||
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
provider=LLMProvider(
|
||||
hrid="default-provider",
|
||||
base_url="https://api.llm.com/v1/",
|
||||
api_key="test-key",
|
||||
),
|
||||
),
|
||||
}
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() is None
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_tavily", "web_search_albert_rag"]
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() == "web_search_tavily"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_web_search_tool_avalability(settings):
|
||||
"""Test the web search tool availability according to context."""
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://api.tavily.com/search",
|
||||
json={"results": []},
|
||||
status=200,
|
||||
)
|
||||
context_deps = ContextDeps(conversation=None, user=None, web_search_enabled=True)
|
||||
|
||||
# No tools (context allows web search, but no tool configured)
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
# Tool configured, context allows web search
|
||||
settings.AI_AGENT_TOOLS = ["web_search_tavily"]
|
||||
agent = ConversationAgent(model_hrid="default-model") # re-init to pick up new settings
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == '{"web_search_tavily":[]}'
|
||||
|
||||
# Tool configured, context disables web search
|
||||
context_deps.web_search_enabled = False
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
assert agent.is_web_search_configured() is True
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Unit tests for project-level LLM instructions injection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def base_settings(settings):
|
||||
"""Set up base settings for the tests."""
|
||||
settings.AI_BASE_URL = "https://api.llm.com/v1/"
|
||||
settings.AI_API_KEY = "test-key"
|
||||
settings.AI_MODEL = "model-123"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
|
||||
settings.AI_AGENT_TOOLS = []
|
||||
|
||||
|
||||
def _get_instruction_names(service):
|
||||
"""Return the names of dynamic (callable) instructions registered on the conversation agent."""
|
||||
# pylint: disable=protected-access
|
||||
return [fn.__name__ for fn in service.conversation_agent._instructions if callable(fn)]
|
||||
|
||||
|
||||
def test_project_instructions_injected_when_present():
|
||||
"""Test that project LLM instructions are injected as a dynamic agent instruction."""
|
||||
project = ChatProjectFactory(llm_instructions="Always answer in bullet points.")
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" in _get_instruction_names(service)
|
||||
# Verify the instruction returns the correct content
|
||||
instruction_fn = next(
|
||||
fn
|
||||
for fn in service.conversation_agent._instructions # pylint: disable=protected-access
|
||||
if callable(fn) and fn.__name__ == "project_instructions"
|
||||
)
|
||||
assert instruction_fn() == "Always answer in bullet points."
|
||||
|
||||
|
||||
def test_project_instructions_not_injected_when_empty():
|
||||
"""Test that empty project instructions are not injected."""
|
||||
project = ChatProjectFactory(llm_instructions="")
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" not in _get_instruction_names(service)
|
||||
|
||||
|
||||
def test_project_instructions_not_injected_when_no_project():
|
||||
"""Test that no project instruction is injected for standalone conversations."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" not in _get_instruction_names(service)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Unit tests for add_document_rag_search_tool_from_setting integration with AIAgentService."""
|
||||
|
||||
# pylint: disable=redefined-outer-name, protected-access
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.factories import ChatConversationFactory, UserFactory
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
|
||||
pytestmark = pytest.mark.django_db()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _llm_config_with_websearch(settings):
|
||||
"""Configure a single active model that includes the web search tool."""
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"default-model": LLModel(
|
||||
hrid="default-model",
|
||||
model_name="amazing-llm",
|
||||
human_readable_name="Amazing LLM",
|
||||
is_active=True,
|
||||
icon=None,
|
||||
system_prompt="You are an amazing assistant.",
|
||||
tools=[],
|
||||
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
provider=LLMProvider(
|
||||
hrid="unused",
|
||||
base_url="https://example.com",
|
||||
api_key="key",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_smart_search_disabled_suppresses_tool_at_runtime(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is off, the tool must be suppressed at runtime.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=False)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is False
|
||||
assert service._is_web_search_enabled is True
|
||||
|
||||
# Replicate what _run_agent does before calling the model
|
||||
if not service._is_smart_search_enabled and service._is_web_search_enabled:
|
||||
service._context_deps.web_search_enabled = False
|
||||
|
||||
service._setup_web_search_tool()
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
|
||||
def test_smart_search_enabled_tool_is_called(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is on, the tool must be invoked.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=True)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is True
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
|
||||
service._setup_web_search_tool()
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
assert "web_search" in response.output
|
||||
|
||||
|
||||
def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is off, the tool must be enabled via force_web_search.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=False)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is False
|
||||
assert service._context_deps.web_search_enabled is False
|
||||
|
||||
# Match _run_agent: register the tool first, then enable deps + forced prompt.
|
||||
service._setup_web_search_tool()
|
||||
service._setup_web_search(force_web_search=True)
|
||||
|
||||
assert service.conversation_agent.is_web_search_configured() is True
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
assert any(
|
||||
callable(instr) and "web_search" in instr()
|
||||
for instr in service.conversation_agent._instructions
|
||||
)
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
assert "web_search" in response.output
|
||||
@@ -20,7 +20,7 @@ from chat.clients.pydantic_ai import AIAgentService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(name="today_promt_date")
|
||||
@pytest.fixture(name="today_prompt_date")
|
||||
def today_prompt_date_fixture():
|
||||
"""Fixture to mock date the system prompt when useless to test it."""
|
||||
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for chat factories."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Unit tests for the ChatConversationFactory."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_conversation_factory():
|
||||
"""Test that the factory creates a valid conversation with default values."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
assert conversation.owner is not None
|
||||
assert conversation.title is None
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_conversation_factory_with_project():
|
||||
"""Test that the factory accepts a project."""
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(project=project, owner=project.owner)
|
||||
|
||||
assert conversation.project == project
|
||||
assert conversation.owner == project.owner
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Unit tests for the ChatProjectFactory."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_project_factory():
|
||||
"""Test that the factory creates a valid project with default values."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
assert project.title.startswith("title ")
|
||||
assert project.icon
|
||||
assert project.color
|
||||
assert project.owner is not None
|
||||
assert project.conversations.count() == 0
|
||||
|
||||
|
||||
def test_project_factory_number_of_conversations():
|
||||
"""Test that number_of_conversations creates attached conversations."""
|
||||
project = ChatProjectFactory(number_of_conversations=3)
|
||||
|
||||
assert project.conversations.count() == 3
|
||||
assert all(c.owner == project.owner for c in project.conversations.all())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for the ChatProjectSerializer."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat import serializers
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="request_context")
|
||||
def request_context_fixture():
|
||||
"""Return a serializer context with an authenticated request."""
|
||||
user = UserFactory()
|
||||
request = APIRequestFactory().post("/")
|
||||
request.user = user
|
||||
return {"request": request}
|
||||
|
||||
|
||||
def test_serialize_project():
|
||||
"""Test serializing a project returns expected fields."""
|
||||
project = ChatProjectFactory(
|
||||
title="My project",
|
||||
llm_instructions="My custom instructions",
|
||||
color=ChatProjectColor.COLOR_2,
|
||||
icon=ChatProjectIcon.JUSTICE,
|
||||
)
|
||||
serializer = serializers.ChatProjectSerializer(project)
|
||||
data = serializer.data
|
||||
|
||||
assert data["title"] == "My project"
|
||||
assert data["id"] == str(project.pk)
|
||||
assert data["icon"] == "justice"
|
||||
assert data["color"] == "color_2"
|
||||
assert data["llm_instructions"] == "My custom instructions"
|
||||
assert data["conversations"] == []
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
|
||||
def test_serialize_project_with_conversations():
|
||||
"""Test serializing a project includes nested conversations."""
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation"
|
||||
)
|
||||
serializer = serializers.ChatProjectSerializer(project)
|
||||
data = serializer.data
|
||||
|
||||
assert len(data["conversations"]) == 1
|
||||
assert data["conversations"][0] == {
|
||||
"id": str(conversation.pk),
|
||||
"title": "My conversation",
|
||||
}
|
||||
|
||||
|
||||
def test_deserialize_valid_project(request_context):
|
||||
"""Test deserializing valid project data."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": "star",
|
||||
"color": "color_2",
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["title"] == "My Project"
|
||||
assert serializer.validated_data["icon"] == ChatProjectIcon.STAR
|
||||
assert serializer.validated_data["color"] == ChatProjectColor.COLOR_2
|
||||
|
||||
|
||||
def test_deserialize_missing_title(request_context):
|
||||
"""Test that title is required."""
|
||||
data = {
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "title" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_invalid_icon(request_context):
|
||||
"""Test that an invalid icon value is rejected."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": "invalid_icon",
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "icon" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_invalid_color(request_context):
|
||||
"""Test that an invalid color value is rejected."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": "invalid_color",
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "color" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_title_max_length(request_context):
|
||||
"""Test that a title exceeding 100 characters is rejected."""
|
||||
data = {
|
||||
"title": "X" * 101,
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "title" in serializer.errors
|
||||
|
||||
|
||||
def test_conversations_field_is_read_only(request_context):
|
||||
"""Test that conversations cannot be set via input data."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"conversations": [{"id": "fake", "title": "fake"}],
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
assert "conversations" not in serializer.validated_data
|
||||
|
||||
|
||||
def test_owner_is_set_from_request(request_context):
|
||||
"""Test that the owner is automatically set from the request user."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
project = serializer.save()
|
||||
assert project.owner == request_context["request"].user
|
||||
@@ -21,13 +21,14 @@ from chat.tools.web_search_brave import (
|
||||
_extract_and_summarize_snippets_async,
|
||||
_fetch_and_extract_async,
|
||||
_fetch_and_store_async,
|
||||
_query_brave_api_async,
|
||||
_query_brave_llm_context_api_async,
|
||||
format_tool_return,
|
||||
web_search_brave,
|
||||
web_search_brave_with_document_backend,
|
||||
)
|
||||
|
||||
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_LLM_CONTEXT_URL = "https://api.search.brave.com/res/v1/llm/context"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -67,7 +68,7 @@ def fixture_mocked_context():
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results with extra_snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -122,7 +123,7 @@ async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_without_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results without extra_snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -168,7 +169,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
"""Test when the Brave search returns results without extra_snippets with summarization."""
|
||||
settings.BRAVE_SUMMARIZATION_ENABLED = True
|
||||
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -216,7 +217,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
"""Test when the Brave search returns no results."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -233,7 +234,7 @@ async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_http_error(mocked_context):
|
||||
"""Test handling of HTTP errors from Brave API."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
@@ -256,7 +257,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
settings.BRAVE_SEARCH_COUNTRY = None
|
||||
settings.BRAVE_SEARCH_LANG = None
|
||||
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -290,7 +291,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_concurrent_processing(mocked_context):
|
||||
"""Test concurrent processing with asyncio.gather."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -401,7 +402,7 @@ async def test_extract_and_summarize_snippets_summarization_failure(settings):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend with successful RAG search."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -452,7 +453,7 @@ async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_fetch_error(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document fetching fails."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -495,7 +496,7 @@ async def test_web_search_brave_with_document_backend_no_matching_rag_results(mo
|
||||
|
||||
This is actually a problematic scenario, but we want to ensure graceful handling.
|
||||
"""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -565,37 +566,37 @@ async def test_fetch_and_store_empty_document():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_web_key():
|
||||
"""Test _query_brave_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"error": "no web results"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await _query_brave_api_async("query")
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_results_key():
|
||||
"""Test _query_brave_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {}},
|
||||
)
|
||||
)
|
||||
result = await _query_brave_api_async("query")
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_rate_limit():
|
||||
"""Test _query_brave_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=429,
|
||||
json={"error": "Rate limit exceeded"},
|
||||
@@ -603,7 +604,7 @@ async def test_query_brave_api_rate_limit():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "rate limited" in str(exc.value).lower()
|
||||
|
||||
@@ -611,8 +612,8 @@ async def test_query_brave_api_rate_limit():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_client_error():
|
||||
"""Test _query_brave_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=400,
|
||||
json={"error": "Bad request"},
|
||||
@@ -620,7 +621,7 @@ async def test_query_brave_api_client_error():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "client error" in str(exc.value).lower()
|
||||
|
||||
@@ -628,11 +629,11 @@ async def test_query_brave_api_client_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_timeout():
|
||||
"""Test _query_brave_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
"""Test _query_brave_llm_context_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "timed out" in str(exc.value).lower()
|
||||
|
||||
@@ -640,11 +641,11 @@ async def test_query_brave_api_timeout():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_generic_http_error():
|
||||
"""Test _query_brave_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
"""Test _query_brave_llm_context_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "connection error" in str(exc.value).lower()
|
||||
|
||||
@@ -652,11 +653,11 @@ async def test_query_brave_api_generic_http_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_unexpected_error():
|
||||
"""Test _query_brave_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
"""Test _query_brave_llm_context_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "unexpected error" in str(exc.value).lower()
|
||||
|
||||
@@ -768,7 +769,7 @@ async def test_fetch_and_store_extraction_error():
|
||||
@respx.mock
|
||||
async def test_web_search_brave_mixed_results(mocked_context):
|
||||
"""Test web_search_brave with mixed results (some with snippets, some without)."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -812,7 +813,7 @@ async def test_web_search_brave_mixed_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
"""Test web_search_brave when extraction fails for all results without snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -845,7 +846,7 @@ async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
"""Test web_search_brave handling of ModelCannotRetry from API."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=403,
|
||||
json={"error": "Forbidden"},
|
||||
@@ -863,7 +864,7 @@ async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -894,7 +895,7 @@ async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when RAG search returns empty results."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -929,7 +930,7 @@ async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_c
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_store_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document store raises exception."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -958,7 +959,7 @@ async def test_web_search_brave_with_document_backend_store_exception(mocked_con
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
|
||||
result = await web_search_brave_with_document_backend(mocked_context, "error query")
|
||||
assert result == (
|
||||
@@ -971,7 +972,7 @@ async def test_web_search_brave_with_document_backend_unexpected_exception(mocke
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of ModelCannotRetry."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=401,
|
||||
json={"error": "Unauthorized"},
|
||||
@@ -989,7 +990,7 @@ async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_rag_search_params(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend passes correct parameters to RAG search."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
|
||||
@@ -190,6 +190,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -198,15 +199,29 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -317,6 +332,7 @@ def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
"Answer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -325,15 +341,29 @@ def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -438,6 +468,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -446,15 +477,29 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -624,6 +669,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -644,15 +690,29 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "I see a cat in the picture.", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "I see a cat in the picture.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -790,6 +850,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -798,23 +859,31 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -834,6 +903,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
|
||||
@@ -845,17 +915,29 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The current weather in Paris is nice",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -992,6 +1074,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -1000,23 +1083,31 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1036,6 +1127,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": "Unknown tool name: 'get_current_weather'. No tools available.",
|
||||
@@ -1046,17 +1138,29 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "I cannot give you an answer to that.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "I cannot give you an answer to that.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1302,6 +1406,7 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Why the sky is blue?"],
|
||||
@@ -1310,10 +1415,12 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1321,12 +1428,18 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"Rayleigh scattering.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-92c413bb5a45426299335d0621324654",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
"provider_url": "https://www.external-ai-service.com",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
@@ -1442,6 +1555,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -1450,15 +1564,29 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1582,6 +1710,7 @@ async def test_post_conversation_async_triggers_keepalive(
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -1590,15 +1719,29 @@ async def test_post_conversation_async_triggers_keepalive(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": ANY,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": ANY,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -273,7 +273,7 @@ def test_post_conversation_with_document_upload(
|
||||
api_client,
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -409,7 +409,7 @@ def test_post_conversation_with_document_upload(
|
||||
|
||||
assert chat_conversation.pydantic_messages[0] == {
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -424,6 +424,7 @@ def test_post_conversation_with_document_upload(
|
||||
"Do not request re-upload of documents; consider them already "
|
||||
"available via the internal store.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What does the document say?"],
|
||||
@@ -432,10 +433,12 @@ def test_post_conversation_with_document_upload(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[1] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -444,11 +447,14 @@ def test_post_conversation_with_document_upload(
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "document_search_rag",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -465,7 +471,7 @@ def test_post_conversation_with_document_upload(
|
||||
assert chat_conversation.pydantic_messages[2] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -481,6 +487,7 @@ def test_post_conversation_with_document_upload(
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -498,21 +505,26 @@ def test_post_conversation_with_document_upload(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[3] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "From the document, I can see that it says 'Hello PDF'.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -602,7 +614,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
api_client,
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
mock_summarization_agent, # pylint: disable=unused-argument
|
||||
):
|
||||
@@ -739,7 +751,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
assert chat_conversation.pydantic_messages[0] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -755,6 +767,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Make a summary of this document."],
|
||||
@@ -763,10 +776,12 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[1] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -775,11 +790,14 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "summarize",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -796,7 +814,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
assert chat_conversation.pydantic_messages[2] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -812,6 +830,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": "The document discusses various topics.",
|
||||
@@ -823,17 +842,26 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[3] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{"content": "The document discusses various topics.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The document discusses various topics.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -73,12 +73,13 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
sample_document_content,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
Test POST to /api/v1/chats/{pk}/conversation/ with a document URL.
|
||||
"""
|
||||
|
||||
responses.post(
|
||||
"https://albert.api.etalab.gouv.fr/v1/collections",
|
||||
json={"id": 123, "object": "collection"},
|
||||
@@ -140,7 +141,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
],
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from attached "
|
||||
"documents. Do NOT use it to summarize; for summaries, call the summarize "
|
||||
@@ -156,6 +157,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
"via the internal store."
|
||||
),
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is a document about a single pixel."
|
||||
@@ -229,7 +231,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n"
|
||||
"\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages "
|
||||
@@ -249,6 +251,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
"conversation. Do not request re-upload of documents; "
|
||||
"consider them already available via the internal store.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -259,21 +262,26 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document about a single pixel.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timestamp,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -545,6 +553,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
assert presigned_url.find("X-Amz-Signature=") != -1
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
@@ -558,7 +567,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
@@ -570,7 +579,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
parts=[TextPart(content="This is a document about a single pixel.")],
|
||||
usage=RequestUsage(input_tokens=50, output_tokens=9),
|
||||
model_name="function::agent_model",
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
run_id=messages[1].run_id,
|
||||
),
|
||||
ModelRequest(
|
||||
@@ -579,9 +588,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
content=[
|
||||
"Give more details about this document.",
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
)
|
||||
],
|
||||
timestamp=timestamp_now,
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\n"
|
||||
"Answer in english.",
|
||||
@@ -738,6 +748,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\n"
|
||||
"Answer in english.",
|
||||
"metadata": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
@@ -747,21 +758,26 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document of square, very small and nice.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -791,7 +807,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
def test_post_conversation_with_local_not_pdf_document_url(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
file_name,
|
||||
content_type,
|
||||
@@ -853,6 +869,8 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
)
|
||||
|
||||
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -861,12 +879,13 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
"What is in this document?",
|
||||
# No presigned URL for non-PDF documents (not supporter by LLM)
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
timestamp=timestamp_now,
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -957,7 +976,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from "
|
||||
"attached documents. Do NOT use it to summarize; for summaries, "
|
||||
@@ -974,6 +993,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
"consider them already available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -984,21 +1004,26 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document about you.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timestamp,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -29,6 +29,10 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
PYAI_CURRENT = "current"
|
||||
PYAI_V1_17 = "v1.17"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
@@ -41,17 +45,9 @@ def ai_settings(settings):
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture(name="history_conversation")
|
||||
def history_conversation_fixture():
|
||||
"""Create a conversation with existing message history."""
|
||||
# Create a timestamp for the first message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
|
||||
# Create a conversation with pre-existing messages
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Add previous user and assistant messages
|
||||
conversation.messages = [
|
||||
def build__history_conversation_ui_messages(history_timestamp):
|
||||
"""Build ui messages list for fixtures."""
|
||||
return [
|
||||
UIMessage(
|
||||
id="prev-user-msg-1",
|
||||
createdAt=history_timestamp,
|
||||
@@ -120,94 +116,205 @@ def history_conversation_fixture():
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="history_conversation")
|
||||
def history_conversation_fixture(request):
|
||||
"""Create a conversation with existing message history according to pydantic ai version."""
|
||||
|
||||
# Create a timestamp for the first message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
|
||||
# Create a conversation with pre-existing messages
|
||||
conversation = ChatConversationFactory()
|
||||
pyai_version = getattr(request, "param", PYAI_CURRENT)
|
||||
# Add previous user and assistant messages
|
||||
if pyai_version == PYAI_V1_17:
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
]
|
||||
else:
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "some model",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"provider_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"finish_reason": "stop",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "test-model",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"provider_details": {
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
"provider_name": "test-model",
|
||||
"provider_response_id": "xyz",
|
||||
},
|
||||
]
|
||||
|
||||
# Set up the OpenAI message format as well
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
]
|
||||
conversation.messages = build__history_conversation_ui_messages(history_timestamp)
|
||||
|
||||
conversation.save()
|
||||
return conversation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_conversation", [PYAI_CURRENT, PYAI_V1_17], indirect=True)
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_data_protocol_with_history(
|
||||
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"
|
||||
data = {
|
||||
"messages": [
|
||||
@@ -1028,6 +1135,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1069,6 +1177,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1102,6 +1211,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1148,6 +1258,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1384,6 +1495,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in dutch.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["How about Paris weather?"],
|
||||
@@ -1392,24 +1504,32 @@ def test_post_conversation_with_existing_tool_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
}
|
||||
|
||||
assert history_conversation_with_tool.pydantic_messages[9] == {
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1429,6 +1549,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in dutch.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
|
||||
@@ -1440,18 +1561,30 @@ def test_post_conversation_with_existing_tool_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
}
|
||||
|
||||
assert history_conversation_with_tool.pydantic_messages[11] == {
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The current weather in Paris is nice",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -92,6 +92,7 @@ def test_post_conversation_with_local_image_url(
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -110,6 +111,7 @@ def test_post_conversation_with_local_image_url(
|
||||
instructions="You are a helpful test assistant :)\n\nToday is "
|
||||
f"{formatted_date}.\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -181,6 +183,7 @@ def test_post_conversation_with_local_image_url(
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -199,17 +202,26 @@ def test_post_conversation_with_local_image_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{"content": "This is an image of a single pixel.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "This is an image of a single pixel.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -229,7 +241,7 @@ def test_post_conversation_with_local_image_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_local_image_wrong_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -275,7 +287,8 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
],
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
timestamp=timezone.now(),
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
@@ -314,7 +327,7 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_remote_image_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -361,8 +374,9 @@ def test_post_conversation_with_remote_image_url(
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -432,7 +446,7 @@ def test_post_conversation_with_remote_image_url(
|
||||
@freeze_time("2025-10-18T20:48:20.286204Z")
|
||||
def test_post_conversation_with_local_image_url_in_history(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -475,7 +489,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
],
|
||||
pydantic_messages=[
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -547,6 +561,8 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -559,11 +575,11 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
identifier="sample.png",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
),
|
||||
ModelResponse(
|
||||
parts=[TextPart(content="This is an image of a single pixel.")],
|
||||
@@ -577,12 +593,13 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
content=[
|
||||
"Give more details about this image.",
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
)
|
||||
],
|
||||
run_id=messages[2].run_id,
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
]
|
||||
yield "This is an image of square, very small and nice."
|
||||
@@ -681,7 +698,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -728,6 +745,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Give more details about this image."],
|
||||
@@ -736,21 +754,26 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is an image of square, very small and nice.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for conversation with project LLM instructions."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_includes_project_llm_instructions(api_client, mock_openai_stream):
|
||||
"""Test that project LLM instructions are sent to the LLM as part of the system prompt."""
|
||||
project = ChatProjectFactory(llm_instructions="Always reply in bullet points.")
|
||||
conversation = ChatConversationFactory(
|
||||
owner=project.owner, project=project, owner__language="en-us"
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
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'
|
||||
)
|
||||
|
||||
last_request_payload = json.loads(respx.calls.last.request.content)
|
||||
system_message = last_request_payload["messages"][0]
|
||||
assert system_message["role"] == "system"
|
||||
assert "Always reply in bullet points." in system_message["content"]
|
||||
assert mock_openai_stream.called
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_without_project_has_no_project_instructions(
|
||||
api_client, mock_openai_stream
|
||||
):
|
||||
"""Test that conversations without a project do not include project instructions."""
|
||||
conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
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'
|
||||
)
|
||||
|
||||
last_request_payload = json.loads(respx.calls.last.request.content)
|
||||
system_message = last_request_payload["messages"][0]
|
||||
assert system_message["role"] == "system"
|
||||
assert system_message["content"] == (
|
||||
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
|
||||
)
|
||||
assert mock_openai_stream.called
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -52,6 +54,60 @@ def test_create_conversation_other_owner(api_client):
|
||||
assert conversation.title == "New Conversation"
|
||||
|
||||
|
||||
def test_create_conversation_with_project(api_client):
|
||||
"""Test creating a conversation attached to a project."""
|
||||
project = ChatProjectFactory()
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {
|
||||
"title": "New Conversation",
|
||||
"project": str(project.pk),
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert str(response.data["project"]) == str(project.pk)
|
||||
|
||||
conversation = ChatConversation.objects.get(id=response.data["id"])
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_create_conversation_with_other_user_project_fails(api_client):
|
||||
"""Test that creating a conversation with another user's project is rejected."""
|
||||
user = UserFactory()
|
||||
other_project = ChatProjectFactory() # owned by another user
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {
|
||||
"title": "New Conversation",
|
||||
"project": str(other_project.pk),
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
assert response.data == {
|
||||
"project": [
|
||||
ErrorDetail(
|
||||
string="The project must belong to the current user.",
|
||||
code="invalid",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_create_conversation_without_project(api_client):
|
||||
"""Test creating a conversation without a project."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {"title": "New Conversation"}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["project"] is None
|
||||
|
||||
|
||||
def test_create_conversation_anonymous(api_client):
|
||||
"""Test creating a conversation as an anonymous user returns a 401 error."""
|
||||
url = "/api/v1.0/chats/"
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -79,3 +79,145 @@ def test_ordering_conversations(api_client):
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"][0]["id"] == str(conv1.id)
|
||||
assert response.data["results"][1]["id"] == str(conv2.id)
|
||||
|
||||
|
||||
def test_list_conversations_no_project_filter_returns_all(api_client):
|
||||
"""Test that without project filter, all conversations are returned."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
conv_no_project = ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 2
|
||||
result_ids = {r["id"] for r in response.data["results"]}
|
||||
assert result_ids == {str(conv_in_project.pk), str(conv_no_project.pk)}
|
||||
|
||||
|
||||
def test_filter_conversations_by_project(api_client):
|
||||
"""Test filtering conversations by a specific project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/chats/?project={project.pk}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_invalid_uuid(api_client):
|
||||
"""Test that an invalid UUID for project filter returns empty results."""
|
||||
user = UserFactory()
|
||||
ChatConversationFactory(owner=user, title="Some chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=notauuid")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_none(api_client):
|
||||
"""Test filtering conversations not linked to any project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
conv_no_project = ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=none")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_no_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_any(api_client):
|
||||
"""Test filtering conversations linked to any project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=any")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_title_and_project(api_client):
|
||||
"""Test filtering conversations by both title and project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_match = ChatConversationFactory(owner=user, project=project, title="Design review")
|
||||
ChatConversationFactory(owner=user, project=project, title="Budget plan")
|
||||
ChatConversationFactory(owner=user, title="Design ideas")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/chats/?title=Design&project={project.pk}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_match.pk)
|
||||
|
||||
|
||||
def test_search_by_title_returns_nested_project_info(api_client):
|
||||
"""Test that searching by title returns nested project info (id, title, icon)."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user, title="My Project", icon="folder")
|
||||
conv = ChatConversationFactory(owner=user, project=project, title="Hello world")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?title=Hello")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
assert result["id"] == str(conv.pk)
|
||||
assert result["project"] == {
|
||||
"id": str(project.pk),
|
||||
"title": "My Project",
|
||||
"icon": "folder",
|
||||
}
|
||||
assert "messages" not in result
|
||||
|
||||
|
||||
def test_search_by_title_returns_null_project_when_none(api_client):
|
||||
"""Test that searching by title for a conversation without a project returns null."""
|
||||
user = UserFactory()
|
||||
conv = ChatConversationFactory(owner=user, title="Standalone chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?title=Standalone")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
assert result["id"] == str(conv.pk)
|
||||
assert result["project"] is None
|
||||
|
||||
|
||||
def test_list_without_title_filter_does_not_nest_project(api_client):
|
||||
"""Test that listing without title filter returns project as a UUID, not nested."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
ChatConversationFactory(owner=user, project=project, title="Some chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
# project should be a flat UUID, not a nested dict
|
||||
assert not isinstance(result["project"], dict)
|
||||
assert str(result["project"]) == str(project.pk)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit tests for partially updating chat conversations in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_partial_update_conversation_title(api_client):
|
||||
"""Test partially updating a chat conversation title as the owner."""
|
||||
chat_conversation = ChatConversationFactory(title="Original Title")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated Title"}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Updated Title"
|
||||
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Updated Title"
|
||||
assert conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_partial_update_conversation_anonymous(api_client):
|
||||
"""Test partially updating a conversation as an anonymous user returns a 401 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated Title"}
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_partial_update_conversation_project_fails(api_client):
|
||||
"""Test that partially updating a conversation's project is rejected."""
|
||||
conversation = ChatConversationFactory()
|
||||
project = ChatProjectFactory(owner=conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"project": str(project.pk)}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_partial_update_conversation_remove_project_fails(api_client):
|
||||
"""Test that trying to remove a conversation from a project fails."""
|
||||
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"project": None}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_partial_update_other_user_conversation_fails(api_client):
|
||||
"""Test that partially updating another user's conversation returns a 404 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated By Other User"}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -6,7 +6,7 @@ from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -68,6 +68,42 @@ def test_update_conversation_anonymous(api_client):
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_update_conversation_project_fails(api_client):
|
||||
"""Test that updating a conversation's project is rejected."""
|
||||
conversation = ChatConversationFactory()
|
||||
project = ChatProjectFactory(owner=conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"title": "Updated Title", "project": str(project.pk)}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_update_conversation_remove_project_fails(api_client):
|
||||
"""Test that trying to remove a conversation from a project fails."""
|
||||
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
|
||||
data = {"title": "Updated Title", "project": None}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_update_other_user_conversation_fails(api_client):
|
||||
"""Test that updating another user's conversation returns a 404 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Unit tests for creating projects."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.models import ChatProject, ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_project(api_client):
|
||||
"""Test creating a new project as an authenticated user."""
|
||||
user = UserFactory(sub="testuser", email="test@example.com")
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "New Project"
|
||||
|
||||
# Verify in database
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.owner == user
|
||||
assert project.title == "New Project"
|
||||
assert project.icon == ChatProjectIcon.FOLDER
|
||||
assert project.color == ChatProjectColor.COLOR_1
|
||||
|
||||
|
||||
def test_create_project_other_owner(api_client):
|
||||
"""Test that a user cannot assign another user as the owner of a project."""
|
||||
other_user = UserFactory()
|
||||
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"owner": str(other_user.pk), # Attempt to set another user as owner
|
||||
}
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify in database
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.owner == user
|
||||
assert project.title == "New Project"
|
||||
|
||||
|
||||
def test_create_project_with_llm_instructions(api_client):
|
||||
"""Test creating a project with custom llm instructions."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"llm_instructions": "Always answer in French.",
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["llm_instructions"] == "Always answer in French."
|
||||
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.llm_instructions == "Always answer in French."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ChatProjectColor)
|
||||
def test_create_project_with_each_color(api_client, color):
|
||||
"""Test creating a project with each available color."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "Color Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": color,
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.color == color
|
||||
|
||||
|
||||
def test_create_project_anonymous(api_client):
|
||||
"""Test creating a project as an anonymous user returns a 401 error."""
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Unit tests for project deletion in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatConversation, ChatProject
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_delete_project(api_client):
|
||||
"""Test deleting a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# Verify deletion in database
|
||||
assert not ChatProject.objects.filter(id=project.pk).exists()
|
||||
|
||||
|
||||
def test_delete_project_deletes_related_conversations(api_client):
|
||||
"""Test that deleting a project also deletes its conversations."""
|
||||
project = ChatProjectFactory(number_of_conversations=2)
|
||||
conversation_pks = list(project.conversations.values_list("pk", flat=True))
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not ChatProject.objects.filter(id=project.pk).exists()
|
||||
assert not ChatConversation.objects.filter(pk__in=conversation_pks).exists()
|
||||
|
||||
|
||||
def test_delete_project_does_not_affect_other_conversations(api_client):
|
||||
"""Test that deleting a project does not delete conversations from other projects."""
|
||||
project = ChatProjectFactory(number_of_conversations=1)
|
||||
other_project = ChatProjectFactory(owner=project.owner, number_of_conversations=1)
|
||||
other_conversation = other_project.conversations.get()
|
||||
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert ChatConversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_delete_other_user_project_fails(api_client):
|
||||
"""Test that deleting another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
other_user = UserFactory()
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify project still exists
|
||||
assert ChatProject.objects.filter(id=project.pk).exists()
|
||||
|
||||
|
||||
def test_delete_project_user_anonymous(api_client):
|
||||
"""Test deleting a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# Verify project still exists
|
||||
assert ChatProject.objects.filter(id=project.pk).exists()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Unit tests for listing projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_list_projects(api_client, django_assert_num_queries):
|
||||
"""Test retrieving the list of projects for an authenticated user."""
|
||||
project = ChatProjectFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(project.owner)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(project.pk)
|
||||
assert results[0]["title"] == project.title
|
||||
assert results[0]["conversations"] == []
|
||||
|
||||
|
||||
def test_filter_projects_by_title(api_client):
|
||||
"""Test filtering projects by title substring."""
|
||||
user = UserFactory(sub="testuser", email="test@example.com")
|
||||
|
||||
ChatProjectFactory(owner=user, title="Test Project")
|
||||
|
||||
ChatProjectFactory(owner=user, title="Other Project")
|
||||
|
||||
url = "/api/v1.0/projects/?title=Test"
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["title"] == "Test Project"
|
||||
|
||||
|
||||
def test_list_projects_with_conversations(api_client, django_assert_num_queries):
|
||||
"""Test retrieving projects with associated conversations ordered by -created_at."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
with freeze_time("2026-01-01"):
|
||||
conversation_1 = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation 1"
|
||||
)
|
||||
with freeze_time("2026-01-02"):
|
||||
conversation_2 = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation 2"
|
||||
)
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(project.owner)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(project.pk)
|
||||
assert results[0]["title"] == project.title
|
||||
assert results[0]["conversations"] == [
|
||||
{
|
||||
"id": str(conversation_2.id),
|
||||
"title": conversation_2.title,
|
||||
},
|
||||
{
|
||||
"id": str(conversation_1.id),
|
||||
"title": conversation_1.title,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_projects_no_n_plus_one(api_client, django_assert_num_queries):
|
||||
"""Test that query count stays constant regardless of project/conversation count."""
|
||||
user = UserFactory()
|
||||
for _ in range(3):
|
||||
ChatProjectFactory(owner=user, number_of_conversations=2)
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 3
|
||||
|
||||
|
||||
def test_list_projects_ordered_by_title(api_client):
|
||||
"""Test that projects are returned in alphabetical order by title."""
|
||||
user = UserFactory()
|
||||
ChatProjectFactory(owner=user, title="Zeta")
|
||||
ChatProjectFactory(owner=user, title="Alpha")
|
||||
ChatProjectFactory(owner=user, title="Mu")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/projects/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == ["Alpha", "Mu", "Zeta"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ordering,expected_titles",
|
||||
[
|
||||
("created_at", ["First", "Second", "Third"]),
|
||||
("-created_at", ["Third", "Second", "First"]),
|
||||
("title", ["First", "Second", "Third"]),
|
||||
("-title", ["Third", "Second", "First"]),
|
||||
],
|
||||
)
|
||||
def test_list_projects_ordering(api_client, ordering, expected_titles):
|
||||
"""Test ordering projects by the allowed ordering fields."""
|
||||
user = UserFactory()
|
||||
with freeze_time("2026-01-01"):
|
||||
ChatProjectFactory(owner=user, title="First")
|
||||
with freeze_time("2026-01-02"):
|
||||
ChatProjectFactory(owner=user, title="Second")
|
||||
with freeze_time("2026-01-03"):
|
||||
ChatProjectFactory(owner=user, title="Third")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/projects/?ordering={ordering}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == expected_titles
|
||||
|
||||
|
||||
def test_list_projects_ordering_by_updated_at(api_client):
|
||||
"""Test ordering projects by updated_at."""
|
||||
user = UserFactory()
|
||||
with freeze_time("2026-01-01"):
|
||||
project_a = ChatProjectFactory(owner=user, title="A")
|
||||
ChatProjectFactory(owner=user, title="B")
|
||||
with freeze_time("2026-01-02"):
|
||||
project_a.title = "A updated"
|
||||
project_a.save()
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/projects/?ordering=updated_at")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == ["B", "A updated"]
|
||||
|
||||
|
||||
def test_list_projects_empty(api_client):
|
||||
"""Test retrieving the list of projects for an authenticated user."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
def test_list_projects_anonymous(api_client):
|
||||
"""Test listing projects as an anonymous user returns a 401 error."""
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for partial update of projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("title", "Updated Title"),
|
||||
("icon", ChatProjectIcon.STAR),
|
||||
("color", ChatProjectColor.COLOR_3),
|
||||
("llm_instructions", "Always answer in French."),
|
||||
],
|
||||
)
|
||||
def test_partial_update_project(api_client, field, value):
|
||||
"""Test updating a project field as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {field: value}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data[field] == value
|
||||
|
||||
# Verify in database
|
||||
project.refresh_from_db()
|
||||
assert getattr(project, field) == value
|
||||
|
||||
|
||||
def test_partial_update_other_user_project_fails(api_client):
|
||||
"""Test that updating another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
|
||||
data = {
|
||||
"title": "Updated By Other User",
|
||||
}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_partial_update_project_anonymous(api_client):
|
||||
"""Test updating a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
}
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for retrieving projects."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_retrieve_project(api_client):
|
||||
"""Test retrieving a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == str(project.pk)
|
||||
assert response.data["title"] == project.title
|
||||
|
||||
|
||||
def test_retrieve_other_user_project_fails(api_client):
|
||||
"""Test that retrieving another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_retrieve_project_anonymous(api_client):
|
||||
"""Test retrieving a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Unit tests for updating projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_project(api_client):
|
||||
"""Test updating a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Updated Title"
|
||||
|
||||
# Verify in database
|
||||
project.refresh_from_db()
|
||||
assert project.title == "Updated Title"
|
||||
|
||||
|
||||
def test_update_project_llm_instructions(api_client):
|
||||
"""Test updating a project's LLM instructions via PUT."""
|
||||
project = ChatProjectFactory(llm_instructions="Old instructions")
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": project.title,
|
||||
"icon": project.icon,
|
||||
"color": project.color,
|
||||
"llm_instructions": "New instructions",
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["llm_instructions"] == "New instructions"
|
||||
|
||||
project.refresh_from_db()
|
||||
assert project.llm_instructions == "New instructions"
|
||||
|
||||
|
||||
def test_update_other_user_project_fails(api_client):
|
||||
"""Test that updating another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
|
||||
data = {
|
||||
"title": "Updated By Other User",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_update_project_anonymous(api_client):
|
||||
"""Test updating a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -1,46 +1,13 @@
|
||||
"""Tools for the chat agent."""
|
||||
|
||||
from pydantic_ai import Tool, ToolDefinition
|
||||
|
||||
from pydantic_ai import Tool # noqa: I001
|
||||
from .fake_current_weather import get_current_weather
|
||||
from .web_seach_albert_rag import web_search_albert_rag
|
||||
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
|
||||
from .web_search_tavily import web_search_tavily
|
||||
|
||||
|
||||
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
|
||||
"""Prepare function to include a tool only if web search is enabled in the context."""
|
||||
return tool_def if ctx.deps.web_search_enabled else None
|
||||
|
||||
|
||||
def get_pydantic_tools_by_name(name: str) -> Tool:
|
||||
"""Get a tool by its name."""
|
||||
tool_dict = {
|
||||
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
|
||||
"web_search_brave": Tool(
|
||||
web_search_brave,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_brave_with_document_backend": Tool(
|
||||
web_search_brave_with_document_backend,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_tavily": Tool(
|
||||
web_search_tavily,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_albert_rag": Tool(
|
||||
web_search_albert_rag,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
}
|
||||
|
||||
return tool_dict[name] # will raise on purpose if name is not found
|
||||
|
||||
@@ -142,23 +142,33 @@ async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
|
||||
# Continue with other documents
|
||||
|
||||
|
||||
async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
"""Query the Brave Search API and return the raw results."""
|
||||
url = "https://api.search.brave.com/res/v1/web/search"
|
||||
def _normalize_llm_context_results(json_response: dict) -> List[dict]:
|
||||
"""Normalize Brave LLM context payload into our common result shape."""
|
||||
generic_results = json_response.get("grounding", {}).get("generic", []) or []
|
||||
normalized_results: List[dict] = []
|
||||
for item in generic_results:
|
||||
item_url = item.get("url")
|
||||
if not item_url:
|
||||
continue
|
||||
|
||||
normalized_results.append(
|
||||
{
|
||||
"url": item_url,
|
||||
# Fallback to URL if no title is provided
|
||||
"title": item.get("title") or item_url,
|
||||
# `snippets` is already a list
|
||||
"snippets": item.get("snippets") or [],
|
||||
}
|
||||
)
|
||||
return normalized_results
|
||||
|
||||
|
||||
async def _query_brave_api_with_endpoint_async(url: str, data: dict) -> List[dict]:
|
||||
"""Query a Brave endpoint and return raw results normalized to our schema."""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Subscription-Token": settings.BRAVE_API_KEY,
|
||||
}
|
||||
data = {
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
}
|
||||
params = {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
try:
|
||||
@@ -167,6 +177,12 @@ async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
response.raise_for_status()
|
||||
json_response = response.json()
|
||||
|
||||
# LLM context API: results are under `grounding.generic`
|
||||
# See: https://api-dashboard.search.brave.com/documentation/services/llm-context
|
||||
if "grounding" in json_response:
|
||||
return _normalize_llm_context_results(json_response)
|
||||
|
||||
# Fallback for classic web search JSON shape
|
||||
# https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
|
||||
return json_response.get("web", {}).get("results", [])
|
||||
|
||||
@@ -209,44 +225,99 @@ async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
) from e
|
||||
|
||||
|
||||
async def _query_brave_llm_context_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave LLM context endpoint and return normalized results."""
|
||||
logger.debug("Using LLM context endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/llm/context",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
"maximum_number_of_urls": settings.BRAVE_MAX_RESULTS,
|
||||
"maximum_number_of_tokens": settings.BRAVE_MAX_TOKENS,
|
||||
"maximum_number_of_snippets": settings.BRAVE_MAX_SNIPPETS,
|
||||
"maximum_number_of_snippets_per_url": settings.BRAVE_MAX_SNIPPETS_PER_URL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _query_brave_web_search_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave classic web search endpoint and return normalized results."""
|
||||
logger.debug("Using classic web search endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
|
||||
"""Format the raw search results into a ToolReturn object."""
|
||||
"""Build the tool payload from Brave results.
|
||||
|
||||
Keep only sources that have non-empty snippets and prefer `snippets` over
|
||||
`extra_snippets` when both are present.
|
||||
"""
|
||||
formatted_results = {}
|
||||
sources = set()
|
||||
|
||||
for idx, result in enumerate(raw_search_results):
|
||||
logger.debug("Formatting result: %s", result)
|
||||
snippets = result.get("snippets") or result.get("extra_snippets") or []
|
||||
if not snippets:
|
||||
continue
|
||||
|
||||
formatted_results[str(idx)] = {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": snippets,
|
||||
}
|
||||
sources.add(result["url"])
|
||||
|
||||
return ToolReturn(
|
||||
# Format return value "mistral-like": https://docs.mistral.ai/capabilities/citations/
|
||||
return_value={
|
||||
str(idx): {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": result.get("extra_snippets", []),
|
||||
}
|
||||
for idx, result in enumerate(raw_search_results)
|
||||
if result.get("extra_snippets", [])
|
||||
},
|
||||
metadata={
|
||||
"sources": {
|
||||
result["url"] for result in raw_search_results if result.get("extra_snippets", [])
|
||||
}
|
||||
},
|
||||
return_value=formatted_results,
|
||||
metadata={"sources": sources},
|
||||
)
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
Search the web for up-to-date information.
|
||||
This function use the classic websearch endpoint of the Brave API.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then summarized using the LLM summarization agent.
|
||||
The results are then formatted and returned.
|
||||
|
||||
Args:
|
||||
_ctx (RunContext): The run context, used by the wrapper.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.debug("Starting classic web search without RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
|
||||
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
|
||||
|
||||
# Parallelize fetch/extract for results that don't include extra_snippets
|
||||
# Parallelize fetch/extract only for results that don't already include any snippets
|
||||
# (neither Brave `snippets` nor `extra_snippets`).
|
||||
to_process = [
|
||||
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
|
||||
(idx, r)
|
||||
for idx, r in enumerate(raw_search_results)
|
||||
if not r.get("extra_snippets") and not r.get("snippets")
|
||||
]
|
||||
|
||||
if to_process:
|
||||
@@ -283,18 +354,45 @@ async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_llm_context(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web using Brave LLM context endpoint (no RAG post-processing).
|
||||
This function use the LLM context endpoint of the Brave API.
|
||||
The results are then formatted and returned.
|
||||
"""
|
||||
logger.debug("Starting web search with LLM context endpoint for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_llm_context_api_async(query)
|
||||
formatted_result = format_tool_return(raw_search_results)
|
||||
if not formatted_result.return_value:
|
||||
raise ModelRetry("No valid search results were extracted from Brave LLM context.")
|
||||
return formatted_result
|
||||
except (ModelCannotRetry, ModelRetry):
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error in web_search_brave_llm_context: %s", exc)
|
||||
raise ModelCannotRetry(
|
||||
f"An unexpected error occurred during web search: {type(exc).__name__}. "
|
||||
"You must explain this to the user and not try to answer based on your knowledge."
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information using RAG backend
|
||||
Search the web for up-to-date information using RAG backend.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then stored in a temporary document store for RAG search.
|
||||
The RAG search is then performed and the results are returned.
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.info("Starting web search with RAG backend for query: %s", query)
|
||||
logger.debug("Starting web search with RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
|
||||
# Clear trafilatura caches in thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -328,7 +426,7 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
|
||||
session=ctx.deps.session,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
)
|
||||
logger.info("RAG search returned: %s", rag_results)
|
||||
logger.debug("RAG search returned: %s", rag_results)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import Prefetch
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
import langfuse
|
||||
import magic
|
||||
import posthog
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
|
||||
@@ -50,8 +52,8 @@ def conditional_refresh_oidc_token(func):
|
||||
return func
|
||||
|
||||
|
||||
class ChatConversationFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversation."""
|
||||
class TitleSearchFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversation by title (accent-insensitive)."""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""Filter conversation by title."""
|
||||
@@ -59,6 +61,58 @@ class ChatConversationFilter(filters.BaseFilterBackend):
|
||||
queryset = queryset.filter(title__unaccent__icontains=remove_accents(title))
|
||||
return queryset
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
"""Return the schema for the ``title`` query parameter (drf-spectacular)."""
|
||||
return [
|
||||
{
|
||||
"name": "title",
|
||||
"required": False,
|
||||
"in": "query",
|
||||
"description": "Search conversations by title (accent-insensitive). "
|
||||
"When provided, the response uses a search-specific serializer "
|
||||
"with nested project info.",
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ProjectFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversations by project.
|
||||
|
||||
Accepts a `project` query parameter:
|
||||
- a UUID: conversations belonging to that specific project
|
||||
- "none": conversations not linked to any project
|
||||
- "any": conversations linked to any project
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""Filter conversations by project."""
|
||||
project_id = request.GET.get("project")
|
||||
if project_id is None:
|
||||
return queryset
|
||||
if project_id == "none":
|
||||
return queryset.filter(project__isnull=True)
|
||||
if project_id == "any":
|
||||
return queryset.filter(project__isnull=False)
|
||||
try:
|
||||
UUID(project_id)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
return queryset.filter(project_id=project_id)
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
"""Return the schema for the ``project`` query parameter (drf-spectacular)."""
|
||||
return [
|
||||
{
|
||||
"name": "project",
|
||||
"required": False,
|
||||
"in": "query",
|
||||
"description": "Filter by project. Pass a UUID for a specific project, "
|
||||
'"none" for standalone conversations, or "any" for all project conversations.',
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ChatAttachmentMixin(AttachmentMixin): # pylint: disable=abstract-method
|
||||
"""Mixin to handle attachment authorization for chat conversations."""
|
||||
@@ -121,18 +175,42 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
]
|
||||
serializer_class = serializers.ChatConversationSerializer
|
||||
post_conversation_serializer_class = serializers.ChatConversationInputSerializer
|
||||
filter_backends = [filters.OrderingFilter, ChatConversationFilter]
|
||||
filter_backends = [filters.OrderingFilter, TitleSearchFilter, ProjectFilter]
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at"]
|
||||
queryset = models.ChatConversation.objects # defined to be used in AttachmentMixin
|
||||
|
||||
@extend_schema(
|
||||
responses=serializers.ChatConversationSearchSerializer(many=True),
|
||||
description=(
|
||||
"When the `title` query parameter is provided, returns search results "
|
||||
"with nested project info (id, title, icon) and no messages. "
|
||||
"Without `title`, returns the default conversation list."
|
||||
),
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return search serializer when filtering by title on list action."""
|
||||
|
||||
# Search results only include nested project info
|
||||
if self.action == "list" and self.request.query_params.get("title"):
|
||||
return serializers.ChatConversationSearchSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the chat conversations."""
|
||||
return (
|
||||
self.queryset.filter(owner=self.request.user)
|
||||
if self.request.user.is_authenticated
|
||||
else self.queryset.none()
|
||||
)
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.queryset.none()
|
||||
|
||||
qs = self.queryset.filter(owner=self.request.user)
|
||||
|
||||
# Search results use nested project info; post_conversation needs
|
||||
# project.llm_instructions — prefetch to avoid extra queries
|
||||
if self.request.query_params.get("title") or self.action == "post_conversation":
|
||||
qs = qs.select_related("project")
|
||||
return qs
|
||||
|
||||
def get_permissions(self):
|
||||
"""Return the permissions for the viewset."""
|
||||
@@ -624,3 +702,42 @@ class FileStreamView(APIView):
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to serve file via temporary key: %s", temporary_key)
|
||||
raise Http404("Failed to retrieve file") from exc
|
||||
|
||||
|
||||
class ChatProjectViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
"""ViewSet for managing projects."""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
IsActivatedUser, # see activation_codes application
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
ordering = ["title"]
|
||||
ordering_fields = ["title", "created_at", "updated_at"]
|
||||
queryset = models.ChatProject.objects
|
||||
serializer_class = serializers.ChatProjectSerializer
|
||||
filter_backends = [filters.OrderingFilter, TitleSearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the projects."""
|
||||
|
||||
# Prefetch conversations ordered by most recent first
|
||||
conversations_prefetch = Prefetch(
|
||||
"conversations",
|
||||
queryset=models.ChatConversation.objects.order_by("-created_at"),
|
||||
)
|
||||
|
||||
return (
|
||||
self.queryset.filter(owner=self.request.user).prefetch_related(conversations_prefetch)
|
||||
if self.request.user.is_authenticated
|
||||
else self.queryset.none()
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete a project and its related conversations.
|
||||
|
||||
ChatConversation.project uses on_delete=SET_NULL (to avoid accidental
|
||||
cascade), so we explicitly delete conversations here.
|
||||
"""
|
||||
instance.conversations.all().delete()
|
||||
instance.delete()
|
||||
|
||||
@@ -74,3 +74,20 @@ class BraveSettings:
|
||||
environ_name="BRAVE_SEARCH_EXTRA_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# LLM context endpoint limits
|
||||
BRAVE_MAX_TOKENS = values.IntegerValue(
|
||||
default=8192,
|
||||
environ_name="BRAVE_MAX_TOKENS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS = values.IntegerValue(
|
||||
default=50,
|
||||
environ_name="BRAVE_MAX_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS_PER_URL = values.IntegerValue(
|
||||
default=10,
|
||||
environ_name="BRAVE_MAX_SNIPPETS_PER_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -368,6 +368,7 @@ class Base(BraveSettings, Configuration):
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
]
|
||||
|
||||
# Cache
|
||||
@@ -672,6 +673,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="DEFAULT_ALLOW_CONVERSATION_ANALYTICS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DEFAULT_ALLOW_SMART_WEB_SEARCH = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="DEFAULT_ALLOW_SMART_WEB_SEARCH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# These settings are default values used in the default LLM_CONFIGURATIONS
|
||||
# They allow a deployment with only one model without a specific configuration file
|
||||
@@ -702,7 +708,7 @@ class Base(BraveSettings, Configuration):
|
||||
# docx files
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
# pptx files
|
||||
"application/vnd.openxmlformats-officedocument.presentationml",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
# xlsx and xls files
|
||||
"application/vnd.ms-excel",
|
||||
"application/excel",
|
||||
@@ -743,6 +749,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="RAG_DOCUMENT_SEARCH_BACKEND",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RAG_DOCUMENT_PARSER = values.Value(
|
||||
"chat.agent_rag.document_converter.parser.AlbertParser",
|
||||
environ_name="RAG_DOCUMENT_PARSER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = values.DictValue(
|
||||
default={},
|
||||
environ_name="SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS",
|
||||
@@ -808,6 +819,51 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OCR settings for AdaptivePdfParser
|
||||
OCR_HRID = values.Value(
|
||||
default="etalab-plateform-mistral-medium-2508",
|
||||
environ_name="OCR_HRID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Specific Mistral OCR model - Designates which Mistral vision model to use for OCR
|
||||
OCR_MODEL = values.Value(
|
||||
default="mistral-ocr-2512",
|
||||
environ_name="OCR_MODEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OCR_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=240,
|
||||
environ_name="OCR_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OCR_MAX_RETRIES = values.PositiveIntegerValue(
|
||||
default=3,
|
||||
environ_name="OCR_MAX_RETRIES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OCR_RETRY_DELAY = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="OCR_RETRY_DELAY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OCR_BATCH_PAGES = values.PositiveIntegerValue(
|
||||
default=10,
|
||||
environ_name="OCR_BATCH_PAGES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = values.PositiveIntegerValue(
|
||||
default=200,
|
||||
environ_name="MIN_AVG_CHARS_FOR_TEXT_EXTRACTION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = values.FloatValue(
|
||||
default=0.7,
|
||||
environ_name="MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Summarization
|
||||
SUMMARIZATION_SYSTEM_PROMPT = values.Value(
|
||||
(
|
||||
@@ -1114,6 +1170,17 @@ USER QUESTION:
|
||||
"OIDC_STORE_ACCESS_TOKEN and OIDC_STORE_REFRESH_TOKEN to be set."
|
||||
)
|
||||
|
||||
# OCR configuration validation
|
||||
# Note: we call load_llm_configuration directly because LLM_CONFIGURATIONS is a
|
||||
# @property returning a lazy object, which cannot be accessed via cls in a classmethod.
|
||||
if cls.RAG_DOCUMENT_PARSER == "chat.agent_rag.document_converter.parser.AdaptivePdfParser":
|
||||
llm_configs = load_llm_configuration(cls._llm_configuration_file_path)
|
||||
if cls.OCR_HRID not in llm_configs:
|
||||
raise ValueError(
|
||||
f"OCR_HRID '{cls.OCR_HRID}' not found in LLM_CONFIGURATIONS. "
|
||||
"Please add a matching provider entry or set OCR_HRID to an existing key."
|
||||
)
|
||||
|
||||
# Langfuse initialization
|
||||
if cls.LANGFUSE_ENABLED:
|
||||
if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED:
|
||||
|
||||
@@ -13,6 +13,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
|
||||
@@ -55,7 +55,11 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
more information about the usage of the application.
|
||||
"""
|
||||
return super().create_user(
|
||||
claims | {"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS}
|
||||
claims
|
||||
| {
|
||||
"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS,
|
||||
"allow_smart_web_search": settings.DEFAULT_ALLOW_SMART_WEB_SEARCH,
|
||||
}
|
||||
)
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
|
||||
@@ -22,6 +22,7 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
skip_postgeneration_save = True
|
||||
|
||||
allow_conversation_analytics = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
allow_smart_web_search = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0003_alter_user_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_smart_web_search",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether the user allows to use smart web search features.",
|
||||
verbose_name="allow smart web search",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -77,10 +77,13 @@ class UserManager(auth_models.UserManager):
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
return self.get(email__iexact=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif self.filter(email=email).exists() and not settings.OIDC_ALLOW_DUPLICATE_EMAILS:
|
||||
elif (
|
||||
self.filter(email__iexact=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
@@ -163,6 +166,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
help_text=_("Whether the user allows to use their conversations for analytics."),
|
||||
)
|
||||
|
||||
allow_smart_web_search = models.BooleanField(
|
||||
_("allow smart web search"),
|
||||
default=True,
|
||||
help_text=_("Whether the user allows to use smart web search features."),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
|
||||
@@ -64,6 +64,28 @@ def test_authentication_getter_existing_user_via_email(django_assert_num_queries
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_case_insensitive(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email with different case,
|
||||
the user should be returned (case-insensitive email matching).
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
@@ -149,6 +171,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate_case_insensitive(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should detect duplicate emails even with different case.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
_db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(django_assert_num_queries, monkeypatch):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
|
||||
@@ -225,6 +225,7 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"allow_conversation_analytics": user.allow_conversation_analytics,
|
||||
"allow_smart_web_search": user.allow_smart_web_search,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"language": user.language,
|
||||
@@ -338,7 +339,7 @@ def test_api_users_update_anonymous():
|
||||
def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to update their own user but only "language",
|
||||
"allow_conversation_analytics" and "timezone" fields.
|
||||
"allow_conversation_analytics", "allow_smart_web_search" and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -350,6 +351,7 @@ def test_api_users_update_authenticated_self():
|
||||
serializers.UserSerializer(
|
||||
instance=factories.UserFactory(
|
||||
allow_conversation_analytics=not user.allow_conversation_analytics,
|
||||
allow_smart_web_search=not user.allow_smart_web_search,
|
||||
)
|
||||
).data
|
||||
)
|
||||
@@ -364,7 +366,12 @@ def test_api_users_update_authenticated_self():
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["allow_conversation_analytics", "language", "timezone"]:
|
||||
if key in [
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"language",
|
||||
"timezone",
|
||||
]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
@@ -419,7 +426,7 @@ def test_api_users_patch_anonymous():
|
||||
def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to patch their own user but only "language",
|
||||
"allow_conversation_analytics" and "timezone" fields.
|
||||
"allow_conversation_analytics", "allow_smart_web_search" and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -431,6 +438,7 @@ def test_api_users_patch_authenticated_self():
|
||||
serializers.UserSerializer(
|
||||
instance=factories.UserFactory(
|
||||
allow_conversation_analytics=not user.allow_conversation_analytics,
|
||||
allow_smart_web_search=not user.allow_smart_web_search,
|
||||
)
|
||||
).data
|
||||
)
|
||||
@@ -446,7 +454,12 @@ def test_api_users_patch_authenticated_self():
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["allow_conversation_analytics", "language", "timezone"]:
|
||||
if key in [
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"language",
|
||||
"timezone",
|
||||
]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
@@ -12,6 +12,7 @@ from core.file_upload.enums import FileToLLMMode
|
||||
from activation_codes import viewsets as activation_viewsets
|
||||
from chat.views import (
|
||||
ChatConversationAttachmentViewSet,
|
||||
ChatProjectViewSet,
|
||||
ChatViewSet,
|
||||
FileStreamView,
|
||||
LLMConfigurationView,
|
||||
@@ -21,6 +22,7 @@ from chat.views import (
|
||||
router = DefaultRouter()
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("chats", ChatViewSet, basename="chats")
|
||||
router.register("projects", ChatProjectViewSet, basename="projects")
|
||||
router.register("activation", activation_viewsets.ActivationViewSet, basename="activation")
|
||||
|
||||
conversation_router = DefaultRouter()
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: 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:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: 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:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: 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:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: 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:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: 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:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: 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:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: 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:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: 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:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Configuration"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Détails d'utilisation"
|
||||
|
||||
#: 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:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Utilisation"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Description"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: 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:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Date"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Utilisateurs qui ont utilisé ce code"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: 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:177 build/lib/activation_codes/admin.py:177
|
||||
#: 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."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: 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."
|
||||
|
||||
#: 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:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "A utilisé le code d'activation"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Ajouter les utilisateurs sélectionnés à la liste d'attente Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "code d'activation"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: 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:46 build/lib/activation_codes/models.py:46
|
||||
#: 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"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "utilisations maximales"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: 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:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "utilisations actuelles"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: 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:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: 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:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "expiration"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: 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:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: 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:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "codes d'activation"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: 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:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Vous avez déjà activé votre compte"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: 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:179 build/lib/activation_codes/models.py:179
|
||||
#: 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: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:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "activations d'utilisateurs"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: 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:211 build/lib/activation_codes/models.py:211
|
||||
#: 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é"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "demande d'inscription d'utilisateur"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: 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:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Votre compte a été activé avec succès"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "application de chat"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 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"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: build/lib/core/models.py:47 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"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: build/lib/core/models.py:53 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"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Saisissez un 'sub' valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
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:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail de l'administrateur"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
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:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "autoriser les analyses de conversation"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Si l'utilisateur autorise l'utilisation de ses conversations pour des analyses."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "autoriser la recherche intelligente sur le web"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Si l'utilisateur autorise l'utilisation de fonctions de recherche intelligente sur le Web."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Configuratie"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Gebruiksdetails"
|
||||
|
||||
#: 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 "Tijdstempels"
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Gebruik"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Beschrijving"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Er zijn nog geen gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Naam"
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Datum"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Herbereken het huidige gebruik van gerelateerde activeringen"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Alle geselecteerde activeringscodes hebben al het juiste gebruiksaantal."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Het gebruik van %(count)d activeringscode(s) is opnieuw berekend."
|
||||
|
||||
#: 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 "Gebruiker"
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Heeft activeringscode gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "activeringscode"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "De activeringscode die gebruikers invoeren"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "De code moet alfanumeriek zijn en mag geen spaties of speciale tekens bevatten"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "maximaal gebruik"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Maximaal aantal keren dat deze code kan worden gebruikt. 0 betekent onbeperkt."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "huidig gebruik"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Aantal keren dat deze code is gebruikt"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Of deze code nog gebruikt kan worden"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "vervalt op"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Datum en tijd waarop deze code verloopt"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "beschrijving"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Interne beschrijving of notities over deze code"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "activeringscodes"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Deze activeringscode is niet meer geldig"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Je hebt je account al geactiveerd"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "De gebruiker die de activeringscode heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "De activeringscode die is gebruikt"
|
||||
|
||||
#: 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 "gebruikers activering"
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "gebruikersactivaties"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "De gebruiker die het registratieverzoek heeft gedaan"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Opslaan of de gebruiker een activeringscode heeft ontvangen en deze heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "gebruikersregistratieverzoek"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "gebruikersregistratieverzoeken"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "De activeringscode om te valideren"
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Uw account is succesvol geactiveerd"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "chatapplicatie"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke gegevens"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "gemaakt op"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record is aangemaakt"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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."
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
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."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Verplicht. Maximaal 255 tekens. Alleen letters, cijfers en @/./+/-/_/: tekens."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "identiteits e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "beheerders e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
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."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "conversatieanalyse toestaan"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Of de gebruiker toestaat dat zijn/haar gesprekken voor analyses worden gebruikt."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "smart web zoeken toestaan"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Of de gebruiker toestemming geeft om smart web zoekfuncties te gebruiken."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Настройки"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Сведения об использовании"
|
||||
|
||||
#: 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:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Использование"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Пока нет пользователей, использовавших этот код"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Имя"
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Пользователи, использующие этот код"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Обновить данные использования связанных активаций"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Все выбранные коды активации уже имеют правильное количество использований."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Количество использованных кодов активации (%(count)d) успешно пересчитано."
|
||||
|
||||
#: 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:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Использует код активации"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "код активации"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Код активации, который будут вводить пользователи"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "Код должен быть буквенно-цифровым и не содержать пробелов или специальных символов"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "максимум использований"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Сколько раз можно использовать этот код. 0 означает неограниченно."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "использовано"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Сколько раз этот код был использован"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "активный"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Можно ли ещё использовать этот код"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "действителен до"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Дата и время окончания действия этого кода"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Внутреннее описание или примечания об этом коде"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "коды активации"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Этот код активации больше не действителен"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Вы уже активировали свою учётную запись"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "пользователь"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "Пользователь, использовавший код активации"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Использованный код активации"
|
||||
|
||||
#: 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:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "активации пользователя"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "Пользователь, который сделал запрос на регистрацию"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Сохранить, если пользователь получил код активации и использовал его"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "запрос на регистрацию пользователя"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: 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:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Ваша учётная запись успешно активирована"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "приложение чата"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Личные данные"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первичный ключ для записи как UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "создано"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата и время создания записи"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "обновлено"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата и время последнего обновления записи"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введите правильный префикс. Он может содержать только буквы, цифры и символы @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "префикс"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "полное имя"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "короткое имя"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "личный адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "e-mail администратора"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "язык"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "устройство"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Пользователь является устройством или человеком."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "статус сотрудника"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Может ли пользователь войти на этот административный сайт."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "разрешить аналитику для беседы"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Разрешает ли пользователь использовать свои беседы для аналитики."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "разрешить умный поиск в Интернете"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Разрешает ли пользователь использовать умный поиск в Интернете."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-27 15:38\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Налаштування"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Відомості про використання"
|
||||
|
||||
#: 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:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Використання"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Опис"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Користувачі ще не використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Ім’я"
|
||||
|
||||
#: 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:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Користувачі, що використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Перерахувати поточні використання пов'язаних ресурсів"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Усі обрані коди активації вже мають коректні лічильники використання."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Успішно переобчислено використання коду активації %(count)d."
|
||||
|
||||
#: 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:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Використано код активації"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: 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
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "код активації"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Код активації, що буде введений користувачами"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "Код має бути буквено-цифровим, без пробілів або спеціальних символів"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "максимум використань"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Максимальна кількість разів використання для цього коду. 0 - необмежена."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "використано"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Кількість разів використання цього коду"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "активний"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Чи цей код все ще може бути використаний"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "дійсний до"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Дата та час, коли закінчується дія цього коду"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Внутрішній опис або нотатки про цей код"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "коди активації"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Цей код активації вже не дійсний"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Ви вже активували свій обліковий запис"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "користувач"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "Користувач, який користувався кодом активації"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Використаний код активації"
|
||||
|
||||
#: 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:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "активації користувача"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "Користувач, що зробив запит на реєстрацію"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Зберегти, якщо користувач отримав код активації та використав його"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "запит на реєстрацію користувача"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: 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:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Ваш обліковий запис успішно активовано"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "чат-застосунок"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первинний ключ для запису як UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "створено"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата і час, коли запис було створено"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "оновлено"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата і час, коли запис був востаннє оновлений"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
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:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введіть правильний префікс. Це значення може містити лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "префікс"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обов'язково. 255 символів або менше. Лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "повне ім'я"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "коротке ім'я"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "адреса електронної пошти особи"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "електронна адреса адміністратора"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "мова"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Мова, якою користувач хоче бачити інтерфейс."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовий пояс, в якому користувач хоче бачити час."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "пристрій"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Чи є користувач пристроєм чи реальним користувачем."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "статус співробітника"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Чи може користувач увійти на цей сайт адміністратора."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "дозволити аналітику бесіди"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Чи дозволяє користувач використовувати свої розмови для аналітики."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "дозволити розумний пошук в Інтернеті"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Чи дозволяє користувач використовувати розумні пошукові функції, пов'язані з Інтернетом."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "conversations"
|
||||
version = "0.0.12"
|
||||
version = "0.0.14"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -33,13 +33,13 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.18",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-parler==2.3",
|
||||
"django-pydantic-field==0.4.0",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.9",
|
||||
"django==5.2.12",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -56,17 +56,18 @@ dependencies = [
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.0.0",
|
||||
"pydantic==2.12.4",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.17.0",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.62.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.10.1",
|
||||
"PyJWT==2.12.0",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"requests==2.33.0",
|
||||
"semchunk==3.2.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"pypdf==6.9.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -107,6 +108,17 @@ zip-safe = true
|
||||
[tool.distutils.bdist_wheel]
|
||||
universal = true
|
||||
|
||||
[tool.uv]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"sys_platform == 'darwin'",
|
||||
]
|
||||
override-dependencies = [
|
||||
"cryptography>=46.0.5", # CVE-2026-26007
|
||||
"joserfc>=1.6.3", # CVE-2026-27932
|
||||
"pillow>=12.1.1", #CVE-2026-25990
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-root = ""
|
||||
source-exclude = [
|
||||
@@ -146,8 +158,8 @@ select = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","conversations","first-party","local-folder"]
|
||||
sections = { conversations=["core"], django=["django"] }
|
||||
section-order = ["future", "standard-library", "django", "third-party", "conversations", "first-party", "local-folder"]
|
||||
sections = { conversations = ["core"], django = ["django"] }
|
||||
extra-standard-library = ["tomllib"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
||||
@@ -3,8 +3,18 @@ revision = 3
|
||||
requires-python = "==3.13.*"
|
||||
resolution-markers = [
|
||||
"sys_platform == 'win32'",
|
||||
"sys_platform == 'emscripten'",
|
||||
"sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"sys_platform != 'win32'",
|
||||
]
|
||||
required-markers = [
|
||||
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"sys_platform == 'darwin'",
|
||||
]
|
||||
|
||||
[manifest]
|
||||
overrides = [
|
||||
{ name = "cryptography", specifier = ">=46.0.5" },
|
||||
{ name = "joserfc", specifier = ">=1.6.3" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -132,20 +142,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "azure-core"
|
||||
version = "1.38.0"
|
||||
version = "1.38.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-identity"
|
||||
version = "1.25.1"
|
||||
version = "1.25.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-core" },
|
||||
@@ -154,9 +164,9 @@ dependencies = [
|
||||
{ name = "msal-extensions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -391,7 +401,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "conversations"
|
||||
version = "0.0.12"
|
||||
version = "0.0.14"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
@@ -428,6 +438,7 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim", extra = ["evals", "logfire", "mcp", "mistral", "openai"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-magic" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
@@ -470,15 +481,15 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "deprecated" },
|
||||
{ name = "dirty-equals", marker = "extra == 'dev'", specifier = "==0.10.0" },
|
||||
{ name = "django", specifier = "==5.2.9" },
|
||||
{ name = "django", specifier = "==5.2.12" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.1.0" },
|
||||
{ name = "django-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
|
||||
{ name = "django-filter", specifier = "==25.2" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.18" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
|
||||
{ name = "django-parler", specifier = "==2.3" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.4.0" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.5.4" },
|
||||
{ name = "django-redis", specifier = "==6.0.0" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-test-migrations", marker = "extra == 'dev'", specifier = "==1.5.0" },
|
||||
@@ -504,12 +515,13 @@ requires-dist = [
|
||||
{ name = "posthog", specifier = "==7.0.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.12" },
|
||||
{ name = "pydantic", specifier = "==2.12.4" },
|
||||
{ name = "pydantic-ai-slim", extras = ["openai", "mistral", "mcp", "evals", "logfire"], specifier = "==1.17.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["openai", "mistral", "mcp", "evals", "logfire"], specifier = "==1.62.0" },
|
||||
{ name = "pyfakefs", marker = "extra == 'dev'", specifier = "==5.10.2" },
|
||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||
{ name = "pyjwt", specifier = "==2.12.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.9" },
|
||||
{ name = "pylint-django", marker = "extra == 'dev'", specifier = "==2.6.1" },
|
||||
{ name = "pylint-pydantic", marker = "extra == 'dev'", specifier = "==0.4.1" },
|
||||
{ name = "pypdf", specifier = "==6.9.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.1" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" },
|
||||
@@ -518,7 +530,7 @@ requires-dist = [
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
|
||||
{ name = "python-magic", specifier = "==0.4.27" },
|
||||
{ name = "redis", specifier = "<6.0.0" },
|
||||
{ name = "requests", specifier = "==2.32.5" },
|
||||
{ name = "requests", specifier = "==2.33.0" },
|
||||
{ name = "responses", marker = "extra == 'dev'", specifier = "==0.25.8" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = "==0.22.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.5" },
|
||||
@@ -582,43 +594,41 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -695,16 +705,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.9"
|
||||
version = "5.2.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -771,19 +781,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-lasuite"
|
||||
version = "0.0.18"
|
||||
version = "0.0.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "joserfc" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/af/e42d4307dc2505792b2e8e73c86f26fb24d2e674a95b148d0eb9618e193c/django_lasuite-0.0.18.tar.gz", hash = "sha256:be0c0b234025d9d2a76eb66189abb8bd8a581d5b1f5f66715751e6849c42fbe4", size = 29719, upload-time = "2025-11-12T09:02:01.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/05/60f0114a3d7cf95eb1dd0d1c3234dd4c8b612264b4774733d2188b010036/django_lasuite-0.0.18-py3-none-any.whl", hash = "sha256:30754c0ff532174546355997c3cf143cb123a457c7e4ecbf0fc5404504aca0e9", size = 43422, upload-time = "2025-11-12T09:01:59.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -806,16 +817,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-pydantic-field"
|
||||
version = "0.4.0"
|
||||
version = "0.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/d6/dca6645f87345a7962ed0cd19e358368b02e83d5d172e30cd583a04d9f5e/django_pydantic_field-0.4.0.tar.gz", hash = "sha256:4fc35a45d9e511b9e9320e68569ec22fe11d8cbf40a88609d7bc1a3294e4ca9d", size = 37344, upload-time = "2025-11-06T10:31:05.84Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/3d/d2390f1383eb60b25c7b68b3493af566831b0db540e47ed85a395727bae2/django_pydantic_field-0.5.4.tar.gz", hash = "sha256:3c884299ea777e8221e4c0ff222e078960ba3576a3b6970636629524e5d6cf39", size = 22013, upload-time = "2026-02-22T13:47:30.633Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/50/03465856af47160cbca1d6b0eb5bba827db7276cb12ed01b6acb28a6c956/django_pydantic_field-0.4.0-py3-none-any.whl", hash = "sha256:081bf08ef18d1b9118988e36cad8faef915b935bec371a094828f6981fb2863d", size = 41726, upload-time = "2025-11-06T10:31:04.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/88/1acbef013f172f9f03836d3c03639ae5604a46e9f1a921c6e623e60f8594/django_pydantic_field-0.5.4-py3-none-any.whl", hash = "sha256:5c080b5f0ea17ae11b5305d649778a87437c61a403894933337fe3e295f627a9", size = 36963, upload-time = "2026-02-22T13:47:29.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1033,15 +1044,12 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "1.15.0"
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1309,14 +1317,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.1"
|
||||
version = "1.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/3d/82375487dcc2bcdf136a68e1a8543165feccbbc8833dfc451f87a5f83b81/joserfc-1.6.1.tar.gz", hash = "sha256:7759a14d732d93503317468c0dd258510c4f64b30759cf42e96016c97b38c4b7", size = 226277, upload-time = "2025-12-30T08:45:07.289Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/90/b8cc8635c4ce2e5e8104bf26ef147f6e599478f6329107283cdc53aae97f/joserfc-1.6.3.tar.gz", hash = "sha256:c00c2830db969b836cba197e830e738dd9dda0955f1794e55d3c636f17f5c9a6", size = 229090, upload-time = "2026-02-25T15:33:38.167Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/01/9674cc6d478406ae61d910cb16ca8b5699a8a9e6a2019987ebe5a5957d1d/joserfc-1.6.1-py3-none-any.whl", hash = "sha256:74d158c9d56be54c710cdcb2a0741372254b682ad2101a0f72e5bd0e925695f0", size = 70349, upload-time = "2025-12-30T08:45:05.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4f/124b3301067b752f44f292f0b9a74e837dd75ff863ee39500a082fc4c733/joserfc-1.6.3-py3-none-any.whl", hash = "sha256:6beab3635358cbc565cb94fb4c53d0557e6d10a15b933e2134939351590bda9a", size = 70465, upload-time = "2026-02-25T15:33:36.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1964,7 +1972,7 @@ name = "pexpect"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||
wheels = [
|
||||
@@ -1973,35 +1981,35 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.0"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2161,20 +2169,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "1.17.0"
|
||||
version = "1.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffe" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/bc/39f1dca02883372ccccd82e55b21a0bf4d248e69f40a22a6e177a285781a/pydantic_ai_slim-1.17.0.tar.gz", hash = "sha256:7c6a10b0842819cd1328dc6d0b64faba4ae59b78d9a97b53910aff1a28108e0a", size = 301616, upload-time = "2025-11-14T00:40:17.329Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/8d/6350a49f2e4b636efbcfc233221420ab576e4ba4edba38254cb84ae4a1e6/pydantic_ai_slim-1.62.0.tar.gz", hash = "sha256:00d84f659107bbbd88823a3d3dbe7348385935a9870b9d7d4ba799256f6b6983", size = 422452, upload-time = "2026-02-19T05:07:10.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/16/be20a655d6a9323165306b0889b2f04b985025a7f195c486e4292fd51f74/pydantic_ai_slim-1.17.0-py3-none-any.whl", hash = "sha256:2fc64bad8b6396a2af32c1ff04d73f4cde62ba15c28329cc98dbae47651cad90", size = 401934, upload-time = "2025-11-14T00:40:03.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/67/21e9b3b0944568662e3790c936226bd48a9f27c6b5f27b5916f5857bc4d8/pydantic_ai_slim-1.62.0-py3-none-any.whl", hash = "sha256:5210073fadd46f65859a67da67845093c487f025fa430ed027151f22ec684ab2", size = 549296, upload-time = "2026-02-19T05:07:01.624Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2192,6 +2200,7 @@ mistral = [
|
||||
]
|
||||
openai = [
|
||||
{ name = "openai" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2221,7 +2230,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-evals"
|
||||
version = "1.17.0"
|
||||
version = "1.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2231,14 +2240,14 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/f5/4126cedb4c65e54c13d44b03a9551ed8cf6d4b1c0551e61a2ed9f700c73c/pydantic_evals-1.17.0.tar.gz", hash = "sha256:54e24324fb99b453b27817a8c51510a56282a41bc7968e1e5585355cf0c1aea1", size = 46978, upload-time = "2025-11-14T00:40:18.719Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/90/080f6722412263395d1d6d066ee90fa8bc2722ce097844220c2d9c946877/pydantic_evals-1.62.0.tar.gz", hash = "sha256:198c4bee936718a4acf6f504056b113e60b34eb49021df8889a394e14c803693", size = 56434, upload-time = "2026-02-19T05:07:11.793Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/04/2ddffbd5b45388e7e0f6019670e4cd6cf524fef07597938107f0e6aeb431/pydantic_evals-1.17.0-py3-none-any.whl", hash = "sha256:a7447c99ca86bf68880c3078f1b751c418145a725185bce99b604dab9479bf1a", size = 56134, upload-time = "2025-11-14T00:40:06.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b9/dc8dba744ec02b16c6fd1abe3fd8ef1b00fd05c72feef5069851b811952f/pydantic_evals-1.62.0-py3-none-any.whl", hash = "sha256:0ca7e10037ed90393c54b6cff41370d6d4bac63f8c878715599c58863c303db1", size = 67341, upload-time = "2026-02-19T05:07:03.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "1.17.0"
|
||||
version = "1.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -2246,9 +2255,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/c8/7d41e6f07d2e81851036552a6a4cd62b100509bfeafceb2e46051beea7b0/pydantic_graph-1.17.0.tar.gz", hash = "sha256:0e673f049fa5e86443ea90f0b79d8e24696b2d49545d31556933a08b9363633b", size = 57983, upload-time = "2025-11-14T00:40:19.876Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/b6/0b084c847ecd99624f4fbc5c8ecd3f67a2388a282a32612b2a68c3b3595f/pydantic_graph-1.62.0.tar.gz", hash = "sha256:efe56bee3a8ca35b11a3be6a5f7352419fe182ef1e1323a3267ee12dec95f3c7", size = 58529, upload-time = "2026-02-19T05:07:12.947Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/6c/a3ccda382ad69d5549da186fe349b17efd1f4ad7bf871584186381a1bc31/pydantic_graph-1.17.0-py3-none-any.whl", hash = "sha256:436e11e8ca5bd6a99e5e2ba50a42f958bfff65aeb8a40aef83ae5da1bb8b5891", size = 72003, upload-time = "2025-11-14T00:40:08.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/12/1a9cbcd59fd070ba72b0fe544caa6ca97758518643523ec2bf1162084e0d/pydantic_graph-1.62.0-py3-none-any.whl", hash = "sha256:abe0e7b356b4d3202b069ec020d8dd1f647f55e9a0e85cd272dab48250bde87d", size = 72350, upload-time = "2026-02-19T05:07:05.305Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2294,11 +2303,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2362,6 +2371,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ac/5de3c91c7f9354444af251f053cc9953c89cce1defa74b907f67be4f770a/pylint_pydantic-0.4.1-py3-none-any.whl", hash = "sha256:d1b937abe5c346d38de69ee1ada80c93d38ee2356addbabb687e2eb44036ac93", size = 16161, upload-time = "2025-10-27T08:03:33.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.1"
|
||||
@@ -2601,7 +2619,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -2609,9 +2627,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2877,6 +2895,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tld"
|
||||
version = "0.13.1"
|
||||
@@ -3085,15 +3129,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "youtube-transcript-api"
|
||||
version = "1.2.3"
|
||||
version = "1.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "defusedxml" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/03/68c69b2d3e282d45cb3c07e5836a9146ff9574cde720570ffc7eb124e56b/youtube_transcript_api-1.2.3.tar.gz", hash = "sha256:76016b71b410b124892c74df24b07b052702cf3c53afb300d0a2c547c0b71b68", size = 469757, upload-time = "2025-10-13T15:57:17.532Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/32/f60d87a99c05a53604c58f20f670c7ea6262b55e0bbeb836ffe4550b248b/youtube_transcript_api-1.0.3.tar.gz", hash = "sha256:902baf90e7840a42e1e148335e09fe5575dbff64c81414957aea7038e8a4db46", size = 2153252, upload-time = "2025-03-25T18:14:21.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/75/a861661b73d862e323c12af96ecfb237fb4d1433e551183d4172d39d5275/youtube_transcript_api-1.2.3-py3-none-any.whl", hash = "sha256:0c1b32ea5e739f9efde8c42e3d43e67df475185af6f820109607577b83768375", size = 485140, upload-time = "2025-10-13T15:57:16.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/44/40c03bb0f8bddfb9d2beff2ed31641f52d96c287ba881d20e0c074784ac2/youtube_transcript_api-1.0.3-py3-none-any.whl", hash = "sha256:d1874e57de65cf14c9d7d09b2b37c814d6287fa0e770d4922c4cd32a5b3f6c47", size = 2169911, upload-time = "2025-03-25T18:14:19.416Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['conversations/next'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
|
||||
};
|
||||
@@ -145,10 +145,8 @@ export const commonGlobals = {
|
||||
|
||||
export const whiteLabelGlobals = {
|
||||
colors: {
|
||||
'logo-1-light': '#4844AD',
|
||||
'logo-2-light': '#4844AD',
|
||||
'logo-1-dark': '#BEC5F0',
|
||||
'logo-2-dark': '#BEC5F0',
|
||||
'logo-1': '#4844AD',
|
||||
'logo-2': '#4844AD',
|
||||
'brand-050': '#EEF1FA',
|
||||
'brand-100': '#DDE2F5',
|
||||
'brand-150': '#CED3F1',
|
||||
@@ -841,13 +839,13 @@ const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
|
||||
overrides: commonTokenOverrides,
|
||||
});
|
||||
|
||||
if (dsfrThemes.dark?.globals?.colors) {
|
||||
if (dsfrThemes.dark) {
|
||||
dsfrThemes.dark.globals.components.logo.src =
|
||||
'/assets/logo-gouv-darkmode.svg';
|
||||
dsfrThemes.dark.globals.colors['logo-1'] = '#95ABFF';
|
||||
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
|
||||
}
|
||||
|
||||
console.log(dsfrThemes);
|
||||
|
||||
const themes = {
|
||||
themes: {
|
||||
default: whiteLabelThemes.light,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { nextConfig } from 'eslint-config-conversations/next.mjs';
|
||||
|
||||
export default nextConfig({
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
nextRootDir: import.meta.dirname,
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
|
||||
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -12,44 +10,22 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
},
|
||||
generateBuildId: () => buildId,
|
||||
env: {
|
||||
NEXT_PUBLIC_BUILD_ID: buildId,
|
||||
},
|
||||
webpack(config, { isServer }) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
);
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
turbopack: {
|
||||
rules: {
|
||||
'*.svg': {
|
||||
loaders: ['@svgr/webpack'],
|
||||
as: '*.js',
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
);
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
// Formula rendering in markdown, replace dollar-sign with \(...\) and \[...\]
|
||||
// https://github.com/remarkjs/remark-math/issues/39#issuecomment-2636184992
|
||||
config.resolve.alias['micromark-extension-math'] =
|
||||
'micromark-extension-llm-math';
|
||||
|
||||
return config;
|
||||
},
|
||||
resolveAlias: {
|
||||
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -8,53 +8,47 @@
|
||||
"build:ci": "cp .env.development .env.local && yarn build",
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
"lint": "tsc --noEmit && next lint",
|
||||
"lint:fix": "tsc --noEmit && next lint --fix",
|
||||
"lint": "tsc --noEmit && eslint src/",
|
||||
"lint:fix": "tsc --noEmit && eslint src/ --fix",
|
||||
"prettier": "prettier --write .",
|
||||
"stylelint": "stylelint \"**/*.css\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@ai-sdk/react": "1.2.12",
|
||||
"@ai-sdk/ui-utils": "1.2.11",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/cunningham-react": "^4.2.0",
|
||||
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.4",
|
||||
"@openfun/cunningham-react": "4.0.0",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@sentry/nextjs": "9.26.0",
|
||||
"@shikijs/rehype": "^3.21.0",
|
||||
"@tanstack/react-query": "5.80.5",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"emoji-mart": "5.6.0",
|
||||
"i18next": "25.2.1",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"lottie-react": "^2.4.1",
|
||||
"luxon": "3.6.1",
|
||||
"micromark-extension-llm-math": "3.1.1-20250610",
|
||||
"next": "15.3.9",
|
||||
"next": "16.2.0",
|
||||
"posthog-js": "1.249.3",
|
||||
"react": "19.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria-components": "1.9.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "15.5.2",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-select": "5.10.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"shiki": "^3.13.0",
|
||||
"shiki": "^3.21.0",
|
||||
"styled-components": "6.1.18",
|
||||
"use-debounce": "10.0.4",
|
||||
"zod": "^3.25.67",
|
||||
@@ -71,21 +65,23 @@
|
||||
"@types/lodash": "4.17.17",
|
||||
"@types/luxon": "3.6.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "16.5.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-conversations": "*",
|
||||
"eslint-config-next": "16.2.0",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "16.20.0",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"webpack": "5.99.9",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
"typescript": "*"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0234 1.74219C18.0366 1.74222 18.8012 2.0067 19.3174 2.53516C19.8336 3.05763 20.0918 3.82638 20.0918 4.83984V15.3203C20.0918 15.7987 20.0103 16.1737 19.8467 16.4443C19.683 16.715 19.4848 16.9544 19.252 17.1621C18.8556 17.5396 18.6101 17.9483 18.5156 18.3887C18.4275 18.823 18.4652 19.2263 18.6289 19.5977C18.7925 19.969 19.0688 20.2402 19.459 20.4102C19.6352 20.4857 19.7862 20.599 19.9121 20.75C20.038 20.9074 20.1016 21.0901 20.1016 21.2979C20.1015 21.5431 20.0165 21.751 19.8467 21.9209C19.6831 22.0907 19.4752 22.1757 19.2236 22.1758H6.94824C5.94744 22.1758 5.189 21.914 4.67285 21.3916C4.15673 20.8754 3.89844 20.1136 3.89844 19.1064V4.83984C3.89844 3.82013 4.15672 3.04884 4.67285 2.52637C5.18903 2.0039 5.95431 1.74219 6.96777 1.74219H17.0234ZM16.9951 17.4922C16.907 17.5048 16.8057 17.5107 16.6924 17.5107H7.25977C6.76271 17.5108 6.36957 17.6465 6.08008 17.917C5.79681 18.1877 5.65527 18.5343 5.65527 18.9561C5.65534 19.4028 5.78403 19.7582 6.04199 20.0225C6.30008 20.2868 6.68098 20.4189 7.18457 20.4189H17.0898C16.8381 19.9909 16.7025 19.5191 16.6836 19.0029C16.6647 18.493 16.7685 17.9895 16.9951 17.4922ZM6.81641 3.31934C6.6654 3.31934 6.53319 3.37606 6.41992 3.48926C6.30662 3.59627 6.25 3.73184 6.25 3.89551V15.415C6.25004 15.5723 6.30666 15.708 6.41992 15.8213C6.53315 15.9344 6.66549 15.9912 6.81641 15.9912C6.97369 15.9912 7.1094 15.9345 7.22266 15.8213C7.33592 15.708 7.39253 15.5723 7.39258 15.415V3.89551C7.39258 3.73194 7.33583 3.59624 7.22266 3.48926C7.1094 3.376 6.97369 3.31938 6.81641 3.31934Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.6259 1.87573C16.5271 1.87574 17.2093 2.11081 17.6721 2.57969C18.1347 3.04853 18.3658 3.7364 18.3659 4.64344V20.5735C18.3658 20.9814 18.2598 21.3014 18.0467 21.5328C17.8397 21.7703 17.5559 21.8889 17.1966 21.8889C16.9471 21.8889 16.719 21.8125 16.5121 21.6604C16.305 21.5143 16.0032 21.2495 15.6074 20.8658L12.0728 17.3766C12.0241 17.3218 11.9722 17.3218 11.9174 17.3766L8.3921 20.8658C7.99629 21.2495 7.69129 21.5143 7.47816 21.6604C7.27115 21.8126 7.04551 21.8889 6.80196 21.8889C6.43678 21.8888 6.14733 21.7702 5.93428 21.5328C5.72726 21.3014 5.6244 20.9815 5.62439 20.5735V4.64344C5.62442 3.73636 5.85554 3.04853 6.31817 2.57969C6.78096 2.1108 7.46312 1.87573 8.36435 1.87573H15.6259Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 3.97363C12.791 3.97363 13.5222 3.97923 14.1934 3.99121C14.8707 4.0032 15.4464 4.01815 15.9199 4.03613C16.3994 4.05411 16.7471 4.07843 16.9629 4.1084C17.5802 4.19232 18.1224 4.39331 18.5898 4.71094C19.0633 5.02261 19.426 5.44228 19.6777 5.96973C19.7796 6.19149 19.9059 6.51222 20.0557 6.93164C20.2114 7.35103 20.3639 7.82439 20.5137 8.35156C20.6695 8.87897 20.8013 9.41276 20.9092 9.95215L21.71 10.9502C21.9916 11.3157 22.2132 11.6692 22.375 12.0107C22.5428 12.3524 22.6625 12.7216 22.7344 13.1172C22.8063 13.5126 22.8428 13.9738 22.8428 14.501V19.8242C22.8427 20.1656 22.7257 20.4529 22.4922 20.6865C22.2584 20.9203 21.9705 21.0371 21.6289 21.0371H20.541C20.1994 21.0371 19.9115 20.9203 19.6777 20.6865C19.4503 20.4529 19.336 20.1655 19.3359 19.8242V18.2637C19.2742 18.2677 19.2114 18.2734 19.1475 18.2773C18.4703 18.3193 17.7242 18.3586 16.9092 18.3945C16.1002 18.4245 15.2699 18.4478 14.4189 18.4658C13.5738 18.4838 12.7672 18.4932 12 18.4932C11.2328 18.4932 10.4234 18.4838 9.57227 18.4658C8.72725 18.4478 7.89709 18.4245 7.08203 18.3945C6.27294 18.3586 5.52979 18.3193 4.85254 18.2773C4.79159 18.2736 4.73176 18.2675 4.67285 18.2637V19.8242C4.67277 20.1655 4.55285 20.4529 4.31348 20.6865C4.07978 20.9202 3.79173 21.0371 3.4502 21.0371H2.37988C2.03233 21.0371 1.74153 20.9202 1.50781 20.6865C1.27434 20.4529 1.15731 20.1656 1.15723 19.8242V14.501C1.15725 13.9738 1.19375 13.5126 1.26562 13.1172C1.33755 12.7216 1.45438 12.3524 1.61621 12.0107C1.784 11.6692 2.00847 11.3156 2.29004 10.9502L3.08105 9.95215C3.19494 9.41271 3.33049 8.87901 3.48633 8.35156C3.64209 7.82439 3.79456 7.35103 3.94434 6.93164C4.09411 6.51226 4.2232 6.19148 4.33105 5.96973C4.5768 5.44228 4.93386 5.01982 5.40137 4.70215C5.86883 4.38454 6.41385 4.18632 7.03711 4.1084C7.24689 4.08442 7.59179 4.0629 8.07129 4.04492C8.55065 4.02096 9.12585 4.0032 9.79688 3.99121C10.4682 3.97922 11.2028 3.97363 12 3.97363ZM5.41895 12.9824C4.98747 12.9824 4.62752 13.1255 4.33984 13.4131C4.05815 13.7008 3.917 14.0579 3.91699 14.4834C3.91699 14.909 4.05814 15.2688 4.33984 15.5625C4.62754 15.8502 4.9874 15.9941 5.41895 15.9941C5.8503 15.9941 6.20664 15.85 6.48828 15.5625C6.77598 15.2688 6.91992 14.909 6.91992 14.4834C6.91991 14.0579 6.77597 13.7008 6.48828 13.4131C6.20666 13.1257 5.85022 12.9824 5.41895 12.9824ZM18.5811 12.9824C18.1555 12.9824 17.7984 13.1254 17.5107 13.4131C17.2231 13.7008 17.0801 14.0579 17.0801 14.4834C17.0801 14.9089 17.2231 15.2688 17.5107 15.5625C17.7984 15.8502 18.1555 15.9941 18.5811 15.9941C19.0066 15.9941 19.3637 15.8502 19.6514 15.5625C19.939 15.2688 20.083 14.9089 20.083 14.4834C20.083 14.0579 19.9391 13.7008 19.6514 13.4131C19.3637 13.1254 19.0066 12.9824 18.5811 12.9824ZM9.57227 13.4404C9.24873 13.4405 8.98498 13.5424 8.78125 13.7461C8.58347 13.9439 8.48439 14.2047 8.48438 14.5283C8.48438 14.8459 8.5835 15.1068 8.78125 15.3105C8.98498 15.5083 9.24873 15.6074 9.57227 15.6074H14.4277C14.7452 15.6074 15.0025 15.5081 15.2002 15.3105C15.404 15.1068 15.5059 14.846 15.5059 14.5283C15.5058 14.2047 15.404 13.9439 15.2002 13.7461C15.0025 13.5426 14.7451 13.4405 14.4277 13.4404H9.57227ZM12 5.67285C10.6214 5.67285 9.53349 5.68501 8.73633 5.70898C7.94543 5.73295 7.38814 5.76568 7.06445 5.80762C6.79493 5.84954 6.57598 5.92727 6.4082 6.04102C6.24038 6.1489 6.08987 6.31996 5.95801 6.55371C5.87411 6.71554 5.77597 6.94367 5.66211 7.2373C5.55427 7.52489 5.44573 7.83918 5.33789 8.18066C5.23601 8.51626 5.15564 8.83135 5.0957 9.125C5.04775 9.3168 5.06271 9.45795 5.14062 9.54785C5.22455 9.63748 5.36507 9.67599 5.5625 9.66406C6.02997 9.64009 6.55162 9.61675 7.12695 9.59277C7.70235 9.5628 8.38279 9.53849 9.16797 9.52051C9.95314 9.50253 10.8972 9.49414 12 9.49414C13.0968 9.49414 14.0381 9.50253 14.8232 9.52051C15.6143 9.53849 16.2977 9.56281 16.873 9.59277C17.4544 9.61675 17.976 9.64009 18.4375 9.66406C18.6409 9.67599 18.7814 9.63747 18.8594 9.54785C18.9433 9.45795 18.962 9.31679 18.9141 9.125C18.8421 8.83131 18.7552 8.51352 18.6533 8.17188C18.5515 7.83037 18.4457 7.51612 18.3379 7.22852C18.23 6.93482 18.1281 6.70955 18.0322 6.55371C17.9004 6.32007 17.7536 6.14887 17.5918 6.04102C17.43 5.92727 17.2111 5.84954 16.9355 5.80762C16.6119 5.75969 16.0517 5.72696 15.2549 5.70898C14.4577 5.68501 13.3726 5.67285 12 5.67285Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.39578 8.98665C6.02586 8.98669 6.5045 9.14711 6.83081 9.46779C7.16257 9.78285 7.32866 10.2415 7.3287 10.8433V19.9997C7.32864 20.5959 7.16261 21.0518 6.83081 21.3668C6.5045 21.6875 6.02586 21.8479 5.39578 21.848H2.99847C2.36832 21.848 1.88702 21.6875 1.55506 21.3668C1.22881 21.0518 1.06561 20.596 1.06555 19.9997L1.06555 10.8433C1.06559 10.2414 1.22886 9.78285 1.55506 9.46779C1.88702 9.14708 2.36832 8.98667 2.99847 8.98665H5.39578Z" fill="currentColor"/>
|
||||
<path d="M13.1945 5.561C13.8245 5.56105 14.3023 5.7215 14.6286 6.04214C14.9605 6.36286 15.1274 6.82393 15.1274 7.42599V19.9997C15.1273 20.596 14.9605 21.0518 14.6286 21.3668C14.3023 21.6873 13.8243 21.8479 13.1945 21.848H10.7972C10.167 21.848 9.68572 21.6876 9.35375 21.3668C9.02748 21.0518 8.8643 20.596 8.86424 19.9997V7.42599C8.86424 6.82409 9.02758 6.36285 9.35375 6.04214C9.68572 5.72142 10.167 5.56101 10.7972 5.561H13.1945Z" fill="currentColor"/>
|
||||
<path d="M20.9932 2.1521C21.6288 2.15216 22.1093 2.31257 22.4356 2.63324C22.7676 2.95396 22.9344 3.41228 22.9344 4.00871V19.9997C22.9344 20.596 22.7675 21.0518 22.4356 21.3668C22.1093 21.6873 21.6286 21.8479 20.9932 21.848H18.5958C17.9713 21.848 17.4928 21.6876 17.1608 21.3668C16.8346 21.0518 16.6714 20.596 16.6713 19.9997V4.00871C16.6713 3.41248 16.8347 2.95394 17.1608 2.63324C17.4928 2.31251 17.9713 2.1521 18.5958 2.1521H20.9932Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.44933 20.0391C5.61068 20.0391 4.98629 19.834 4.57615 19.4239C4.16601 19.0198 3.96094 18.3954 3.96094 17.5507V15.5857C3.96094 15.4143 3.90584 15.2704 3.79566 15.1541L2.39995 13.7584C1.80005 13.1646 1.50009 12.58 1.50009 12.0046C1.50009 11.4292 1.80005 10.8415 2.39995 10.2416L3.79566 8.84589C3.90584 8.72959 3.96094 8.58879 3.96094 8.42351V6.44933C3.96094 5.59844 4.16295 4.97098 4.56696 4.56696C4.97711 4.16295 5.60456 3.96094 6.44933 3.96094H8.42351C8.59491 3.96094 8.73877 3.90278 8.85508 3.78647L10.2508 2.39995C10.8446 1.80005 11.4292 1.50009 12.0046 1.50009C12.58 1.49397 13.1646 1.79392 13.7584 2.39995L15.1541 3.78647C15.2704 3.90278 15.4143 3.96094 15.5857 3.96094H17.5507C18.4016 3.96094 19.029 4.16601 19.433 4.57615C19.8371 4.98629 20.0391 5.61068 20.0391 6.44933V8.42351C20.0391 8.58879 20.1003 8.72959 20.2227 8.84589L21.6092 10.2416C22.203 10.8415 22.4999 11.4292 22.4999 12.0046C22.4999 12.58 22.203 13.1646 21.6092 13.7584L20.2227 15.1541C20.1003 15.2704 20.0391 15.4143 20.0391 15.5857V17.5507C20.0391 18.4016 19.834 19.029 19.4239 19.433C19.0198 19.8371 18.3954 20.0391 17.5507 20.0391H15.5857C15.4143 20.0391 15.2704 20.0972 15.1541 20.2135L13.7584 21.6092C13.1646 22.203 12.58 22.4999 12.0046 22.4999C11.4292 22.506 10.8446 22.2091 10.2508 21.6092L8.85508 20.2135C8.73877 20.0972 8.59491 20.0391 8.42351 20.0391H6.44933ZM10.9854 16.5223C11.1568 16.5223 11.3129 16.4825 11.4537 16.4029C11.6006 16.3233 11.7291 16.2039 11.8393 16.0448L16.1366 9.34174C16.1978 9.24379 16.2529 9.13973 16.3019 9.02954C16.3509 8.91323 16.3753 8.80304 16.3753 8.69898C16.3753 8.46024 16.2835 8.26741 16.0999 8.1205C15.9224 7.97358 15.7203 7.90012 15.4938 7.90012C15.1939 7.90012 14.946 8.05928 14.7501 8.3776L10.9486 14.4563L9.18564 12.2158C9.06321 12.0627 8.94384 11.9556 8.82753 11.8944C8.71122 11.8332 8.57961 11.8026 8.43269 11.8026C8.20008 11.8026 8.00113 11.8883 7.83585 12.0597C7.67669 12.225 7.59711 12.4239 7.59711 12.6565C7.59711 12.7728 7.61853 12.8861 7.66138 12.9963C7.70423 13.1065 7.76545 13.2136 7.84503 13.3177L10.0947 16.054C10.2294 16.2192 10.3671 16.3386 10.5079 16.4121C10.6487 16.4855 10.8078 16.5223 10.9854 16.5223Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.54004C13.4245 1.54005 14.7604 1.80664 16.0068 2.34082C17.2532 2.875 18.348 3.61778 19.291 4.56738C20.2339 5.51037 20.9727 6.60529 21.5068 7.85156C22.0475 9.09783 22.3174 10.4331 22.3174 11.8574C22.3174 13.2819 22.0476 14.6178 21.5068 15.8643C20.9727 17.1041 20.234 18.1956 19.291 19.1387C18.348 20.0882 17.2531 20.8301 16.0068 21.3643C14.7604 21.905 13.4245 22.1758 12 22.1758C10.5755 22.1758 9.23961 21.905 7.99316 21.3643C6.74686 20.8301 5.64881 20.0883 4.69922 19.1387C3.75617 18.1956 3.01759 17.1041 2.4834 15.8643C1.94921 14.6178 1.68164 13.2819 1.68164 11.8574C1.68167 10.4331 1.94932 9.09786 2.4834 7.85156C3.01754 6.60524 3.75627 5.5104 4.69922 4.56738C5.64885 3.61775 6.74679 2.87501 7.99316 2.34082C9.23961 1.80663 10.5755 1.54004 12 1.54004ZM13.2861 7.28711C12.5212 7.28711 11.8121 7.40933 11.1592 7.65332C10.5129 7.89733 9.96506 8.2671 9.5166 8.76172C9.06839 9.25622 8.7647 9.88571 8.60645 10.6504H7.58789C7.50217 10.6504 7.42947 10.6809 7.37012 10.7402C7.31095 10.7995 7.28125 10.8756 7.28125 10.9678C7.28135 11.0532 7.31105 11.1254 7.37012 11.1846C7.42947 11.2439 7.50217 11.2744 7.58789 11.2744H8.51758C8.511 11.36 8.50463 11.4489 8.49805 11.541V12.1055C8.50463 12.1976 8.511 12.2865 8.51758 12.3721H7.58789C7.50217 12.3721 7.42947 12.4016 7.37012 12.4609C7.3108 12.5203 7.28128 12.5962 7.28125 12.6885C7.28125 12.7676 7.31082 12.8403 7.37012 12.9062C7.42947 12.9656 7.50217 12.9951 7.58789 12.9951H8.61719C8.84141 14.0767 9.36513 14.9079 10.1895 15.4883C11.0138 16.062 12.0399 16.3486 13.2666 16.3486C13.4378 16.3486 13.6123 16.3391 13.79 16.3193C13.9746 16.2996 14.1465 16.2668 14.3047 16.2207C14.4695 16.1679 14.6086 16.0921 14.7207 15.9932C14.8328 15.8877 14.8886 15.7422 14.8887 15.5576C14.8887 15.3466 14.8192 15.1949 14.6807 15.1025C14.5422 15.0036 14.3673 14.9541 14.1562 14.9541C14.0377 14.9541 13.9385 14.9605 13.8594 14.9736C13.7868 14.9868 13.6749 14.9932 13.5234 14.9932C12.6727 14.9932 11.97 14.8256 11.416 14.4893C10.8621 14.1463 10.4859 13.648 10.2881 12.9951H13.2559C13.3415 12.9951 13.4143 12.9655 13.4736 12.9062C13.5396 12.8403 13.5732 12.7676 13.5732 12.6885C13.5732 12.5962 13.5395 12.5203 13.4736 12.4609C13.4143 12.4017 13.3415 12.3721 13.2559 12.3721H10.1504C10.1438 12.2865 10.1365 12.1976 10.1299 12.1055V11.541C10.1365 11.4488 10.1438 11.36 10.1504 11.2744H13.2559C13.3416 11.2744 13.4143 11.2439 13.4736 11.1846C13.5394 11.1253 13.5731 11.0533 13.5732 10.9678C13.5732 10.8755 13.5396 10.7996 13.4736 10.7402C13.4143 10.6809 13.3416 10.6504 13.2559 10.6504H10.2881C10.4794 10.0042 10.846 9.50989 11.3867 9.16699C11.9341 8.82407 12.6463 8.65234 13.5234 8.65234C13.6487 8.65235 13.7678 8.65869 13.8799 8.67188C13.9917 8.67845 14.087 8.68261 14.166 8.68262C14.3769 8.68262 14.5488 8.63297 14.6807 8.53418C14.8191 8.43533 14.8886 8.28014 14.8887 8.06934C14.8887 7.89791 14.8359 7.75883 14.7305 7.65332C14.6315 7.54121 14.4893 7.45584 14.3047 7.39648C14.1596 7.35694 14.0015 7.33057 13.8301 7.31738C13.6587 7.2976 13.4773 7.28712 13.2861 7.28711Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.10682 21.8756C6.10841 21.8756 5.35495 21.6182 4.84644 21.1035C4.33793 20.5888 4.08368 19.8292 4.08368 18.8245V4.78786C4.08368 3.78945 4.33793 3.03289 4.84644 2.51818C5.35495 1.99727 6.10841 1.73682 7.10682 1.73682H11.3485V8.76911C11.3485 9.92256 11.9252 10.4993 13.0787 10.4993H20.0366V18.8245C20.0366 19.823 19.7823 20.5795 19.2738 21.0942C18.7653 21.6151 18.0118 21.8756 17.0134 21.8756H7.10682ZM13.2461 9.14119C12.8927 9.14119 12.7159 8.96445 12.7159 8.61098V1.82984C12.9268 1.85464 13.1376 1.94146 13.3484 2.09029C13.5655 2.23912 13.7887 2.43136 14.0182 2.66701L19.0971 7.82031C19.3389 8.06836 19.5312 8.29781 19.6738 8.50866C19.8226 8.7195 19.9094 8.93035 19.9342 9.14119H13.2461Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 801 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.80179 17.4336V10.0689H22.1983V17.4336C22.1983 18.3616 21.9737 19.0562 21.5247 19.5172C21.0756 19.9843 20.4499 20.2178 19.6476 20.2178H4.62191C3.68187 20.2178 2.97534 19.9843 2.50233 19.5172C2.0353 19.0562 1.80179 18.3616 1.80179 17.4336ZM1.80179 8.89239V6.48541C1.80179 5.58129 2.01734 4.9047 2.44844 4.45564C2.87954 4.00658 3.49027 3.78204 4.28062 3.78204H6.84028C7.13367 3.78204 7.38814 3.803 7.60369 3.84491C7.82523 3.88682 8.02581 3.95568 8.20543 4.05148C8.39105 4.14728 8.58265 4.27901 8.78024 4.44666L9.32809 4.89572C9.55562 5.08732 9.77416 5.22503 9.98373 5.30886C10.1933 5.3867 10.4508 5.42562 10.7561 5.42562H19.3781C20.3122 5.42562 21.0157 5.65913 21.4887 6.12615C21.9617 6.59318 22.1983 7.28773 22.1983 8.20981V8.89239H1.80179Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 854 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.6875 11.1467C17.2283 11.3776 17.8056 11.4931 18.4193 11.4931C19.0208 11.4931 19.592 11.3776 20.1328 11.1467C20.6736 10.9097 21.1476 10.5847 21.5547 10.1715C21.9679 9.75827 22.2899 9.28432 22.5208 8.74959C22.7578 8.2088 22.8763 7.63458 22.8763 7.02694C22.8763 6.20662 22.6758 5.45923 22.2747 4.78475C21.8737 4.11027 21.3329 3.57251 20.6523 3.17147C19.9779 2.76435 19.2335 2.56079 18.4193 2.56079C17.599 2.56079 16.8516 2.76435 16.1771 3.17147C15.5026 3.57251 14.9618 4.11027 14.5547 4.78475C14.1536 5.45923 13.9531 6.20662 13.9531 7.02694C13.9531 7.63458 14.0686 8.2088 14.2995 8.74959C14.5365 9.29039 14.8585 9.76739 15.2656 10.1806C15.6788 10.5877 16.1528 10.9097 16.6875 11.1467ZM18.8659 9.70662C18.7565 9.816 18.6107 9.87069 18.4284 9.87069C18.24 9.87069 18.0881 9.816 17.9727 9.70662C17.8633 9.59117 17.8086 9.4423 17.8086 9.26001V7.63761H16.1953C16.013 7.63761 15.8642 7.58293 15.7487 7.47355C15.6393 7.3581 15.5846 7.20923 15.5846 7.02694C15.5846 6.83857 15.6393 6.6897 15.7487 6.58032C15.8642 6.47095 16.013 6.41626 16.1953 6.41626H17.8086V4.80298C17.8086 4.62069 17.8633 4.47485 17.9727 4.36548C18.0881 4.25003 18.24 4.1923 18.4284 4.1923C18.6107 4.1923 18.7565 4.25003 18.8659 4.36548C18.9813 4.47485 19.0391 4.62069 19.0391 4.80298V6.41626H20.6523C20.8346 6.41626 20.9805 6.47095 21.0898 6.58032C21.2053 6.6897 21.263 6.83857 21.263 7.02694C21.263 7.20923 21.2053 7.3581 21.0898 7.47355C20.9805 7.58293 20.8346 7.63761 20.6523 7.63761H19.0391V9.26001C19.0391 9.4423 18.9813 9.59117 18.8659 9.70662Z" fill="#222631"/>
|
||||
<path d="M11.0156 5.00098H13.2079C12.9971 5.54224 12.8683 6.12447 12.8367 6.73242H10.5586C10.2539 6.73242 9.9873 6.71191 9.75879 6.6709C9.53613 6.62988 9.32812 6.5625 9.13477 6.46875C8.94727 6.36914 8.75391 6.2373 8.55469 6.07324L8.05371 5.66016C7.80762 5.46094 7.57617 5.32031 7.35938 5.23828C7.14844 5.15039 6.8877 5.10645 6.57715 5.10645H4.88086C4.48828 5.10645 4.18652 5.21191 3.97559 5.42285C3.76465 5.62793 3.65918 5.93262 3.65918 6.33691V8.52539H13.0324C13.1837 9.07028 13.4156 9.58166 13.7144 10.0459H3.65918V16.752C3.65918 17.1797 3.76465 17.499 3.97559 17.71C4.19238 17.9209 4.50586 18.0264 4.91602 18.0264H19.0752C19.4795 18.0264 19.793 17.9209 20.0156 17.71C20.2383 17.499 20.3496 17.1826 20.3496 16.7607V12.2683C20.9865 12.0331 21.5688 11.6851 22.0723 11.2485V16.9014C22.0723 17.8389 21.8379 18.5479 21.3691 19.0283C20.9004 19.5146 20.2471 19.7578 19.4092 19.7578H4.81055C3.86133 19.7578 3.14355 19.5146 2.65723 19.0283C2.1709 18.5479 1.92773 17.8389 1.92773 16.9014V6.1875C1.92773 5.26172 2.15625 4.56445 2.61328 4.0957C3.07617 3.62109 3.71777 3.38379 4.53809 3.38379H7.02539C7.33008 3.38379 7.59375 3.4043 7.81641 3.44531C8.03906 3.48633 8.24414 3.55664 8.43164 3.65625C8.61914 3.75 8.81543 3.88184 9.02051 4.05176L9.52148 4.46484C9.76758 4.6582 9.99902 4.7959 10.2158 4.87793C10.4326 4.95996 10.6992 5.00098 11.0156 5.00098Z" fill="#222631"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.1604 21.75C10.8912 21.75 10.6618 21.6735 10.4721 21.5206C10.2825 21.3738 10.154 21.1658 10.0867 20.8966L9.68925 19.2034C9.65105 19.0407 9.5318 18.9096 9.37524 18.851C9.30279 18.8239 9.23101 18.7961 9.15988 18.7676C9.09695 18.744 9.03514 18.7199 8.97446 18.6952C8.82514 18.6343 8.65532 18.6434 8.51752 18.7271L7.03094 19.6302C6.80459 19.7709 6.56906 19.8291 6.32435 19.8046C6.07965 19.7862 5.86247 19.6822 5.67282 19.4926L4.50741 18.3272C4.31776 18.1375 4.21071 17.9173 4.18624 17.6665C4.16176 17.4095 4.22294 17.1679 4.36976 16.9415L5.27375 15.4696C5.35707 15.3339 5.36889 15.1661 5.3054 15.0201C5.27799 14.957 5.25058 14.894 5.22318 14.8309C5.19937 14.7738 5.17695 14.7167 5.15592 14.6595C5.09541 14.4951 4.96195 14.3654 4.79153 14.3248L3.10341 13.9225C2.83424 13.8613 2.62318 13.7359 2.47024 13.5462C2.32341 13.3566 2.25 13.1241 2.25 12.8488V11.2062C2.25 10.9371 2.32341 10.7076 2.47024 10.518C2.62318 10.3284 2.83424 10.2029 3.10341 10.1418L4.77534 9.73956C4.94459 9.69884 5.07731 9.57024 5.13624 9.40645C5.16096 9.33775 5.18688 9.27086 5.214 9.20576C5.24006 9.14322 5.26557 9.08234 5.29052 9.02313C5.35151 8.87837 5.34018 8.7131 5.25894 8.57866L4.35141 7.07682C4.21071 6.84435 4.14953 6.60882 4.16788 6.37024C4.18624 6.12553 4.29329 5.90835 4.48906 5.71871L5.67282 4.54412C5.86247 4.35447 6.07353 4.24741 6.306 4.22294C6.53847 4.19235 6.76788 4.24435 6.99424 4.37894L8.49778 5.30175C8.64132 5.38985 8.81959 5.39626 8.97383 5.32862C9.03426 5.30212 9.09628 5.27615 9.15988 5.25071C9.2309 5.2223 9.30259 5.19455 9.37493 5.16746C9.53166 5.10877 9.65098 4.97751 9.68905 4.81454L10.0867 3.11259C10.154 2.84953 10.2825 2.64153 10.4721 2.48859C10.6618 2.32953 10.8912 2.25 11.1604 2.25H12.8396C13.1149 2.25 13.3444 2.32953 13.5279 2.48859C13.7175 2.64153 13.846 2.84953 13.9133 3.11259L14.3105 4.82855C14.3488 4.99389 14.4708 5.12615 14.6297 5.18582C14.7018 5.2129 14.7719 5.24065 14.8401 5.26906C14.8989 5.29022 14.9559 5.31229 15.011 5.33526C15.1698 5.40135 15.352 5.39733 15.4984 5.30717L17.0058 4.37894C17.2321 4.24435 17.4585 4.19541 17.6848 4.23212C17.9173 4.26271 18.1284 4.36365 18.318 4.53494L19.5201 5.71871C19.7159 5.90835 19.8199 6.12553 19.8321 6.37024C19.8505 6.60882 19.7893 6.84435 19.6486 7.07682L18.7334 8.57814C18.6512 8.71283 18.6395 8.87886 18.7008 9.02424C18.7256 9.0831 18.7509 9.14361 18.7768 9.20576C18.8055 9.27465 18.8336 9.34556 18.8609 9.41849C18.9201 9.57616 19.0507 9.6976 19.2145 9.73682L20.9058 10.1418C21.1688 10.2029 21.3738 10.3284 21.5206 10.518C21.6735 10.7076 21.75 10.9371 21.75 11.2062V12.8488C21.75 13.1241 21.6735 13.3566 21.5206 13.5462C21.3738 13.7359 21.1688 13.8613 20.9058 13.9225L19.1969 14.3278C19.0327 14.3668 18.901 14.4881 18.8442 14.647C18.8223 14.7083 18.7998 14.7696 18.7768 14.8309C18.7544 14.8883 18.7304 14.9457 18.7049 15.0031C18.635 15.1604 18.6397 15.3428 18.7304 15.4891L19.6302 16.9415C19.7771 17.1679 19.8382 17.4095 19.8138 17.6665C19.7954 17.9173 19.6914 18.1375 19.5018 18.3272L18.3272 19.4926C18.1375 19.6822 17.9204 19.7862 17.6756 19.8046C17.4309 19.8291 17.1954 19.7709 16.9691 19.6302L15.4825 18.7271C15.3447 18.6434 15.1749 18.6345 15.0251 18.6941C14.9621 18.7192 14.8974 18.7437 14.8309 18.7676C14.7634 18.7958 14.6946 18.8233 14.6244 18.8501C14.4683 18.9099 14.349 19.0407 14.3107 19.2034L13.9133 20.8966C13.846 21.1658 13.7175 21.3738 13.5279 21.5206C13.3444 21.6735 13.1149 21.75 12.8396 21.75H11.1604ZM12.0046 15.2164C12.5919 15.2164 13.1272 15.0726 13.6105 14.7851C14.0999 14.4975 14.4884 14.1091 14.7759 13.6196C15.0634 13.1302 15.2072 12.5919 15.2072 12.0046C15.2072 11.4173 15.0634 10.8789 14.7759 10.3895C14.4884 9.90012 14.0999 9.51165 13.6105 9.22412C13.1272 8.93659 12.5919 8.79282 12.0046 8.79282C11.4112 8.79282 10.8698 8.93659 10.3804 9.22412C9.89706 9.51165 9.51165 9.90012 9.22412 10.3895C8.93659 10.8789 8.79282 11.4173 8.79282 12.0046C8.79282 12.5919 8.93659 13.1302 9.22412 13.6196C9.51165 14.1091 9.89706 14.4975 10.3804 14.7851C10.8698 15.0726 11.4112 15.2164 12.0046 15.2164Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.8196 3.27502C12.6849 3.20798 13.6964 3.68687 14.0579 4.51721C14.0768 4.56013 14.1142 4.58756 14.1526 4.59142C14.8334 4.65904 15.3735 4.76098 15.8938 4.94592C16.414 5.13089 16.9109 5.39715 17.5061 5.78869C17.7397 5.94233 18.0965 6.18728 18.4631 6.36389C18.6461 6.45197 18.8276 6.5207 18.9934 6.55334C19.16 6.58606 19.3027 6.58097 19.4143 6.53088L19.4153 6.5299C19.6268 6.43761 19.7781 6.25157 19.9504 6.04357C20.116 5.8438 20.3026 5.62243 20.5784 5.52502C20.9848 5.3817 21.3823 5.555 21.6213 5.8424C21.8598 6.12954 21.9569 6.54997 21.7444 6.9176C21.4197 7.47932 20.7654 8.09265 20.1252 8.36974C20.0592 8.39859 20.0302 8.47454 20.0549 8.53283C20.1459 8.74738 20.2438 9.00351 20.3196 9.1881L20.9485 10.6988L22.9026 15.3326L22.9036 15.3336C22.9308 15.3995 22.9918 15.5488 23.0461 15.6969C23.0732 15.7707 23.099 15.8458 23.1184 15.9108C23.1366 15.9719 23.153 16.0369 23.1536 16.0836L23.1438 16.4108C23.091 17.1792 22.823 17.9715 22.3977 18.6578C21.9117 19.442 21.2157 20.0959 20.3889 20.4166C19.312 20.8343 17.9294 20.828 16.886 20.4C15.2308 19.7206 14.2216 17.8826 14.1331 16.1715V16.1686C14.1324 16.1406 14.1381 16.1077 14.1448 16.0778C14.1519 16.0459 14.1624 16.0086 14.1751 15.9674C14.2006 15.8844 14.2367 15.7799 14.2805 15.6627C14.3683 15.4281 14.4872 15.1351 14.6106 14.8395C14.8558 14.252 15.1268 13.6368 15.2014 13.4596L17.4397 8.13537C17.459 8.08941 17.4414 8.03478 17.3977 8.00842C16.8619 7.68751 16.355 7.35033 15.8245 7.08068C15.2967 6.81248 14.755 6.61682 14.1614 6.58264C14.1284 6.58074 14.0935 6.59878 14.0725 6.63342C14.0007 6.75264 13.9221 6.86816 13.8381 6.97912C13.3992 7.55026 12.8809 7.78055 12.1965 7.87267L12.1858 7.87365C11.6562 7.89164 11.2267 7.79582 10.8626 7.58361C10.4993 7.37182 10.2092 7.04801 9.94751 6.62658C9.92625 6.59251 9.88908 6.57499 9.85376 6.57775C9.11593 6.63917 8.69868 6.80402 8.16431 7.1051L7.57447 7.45275C7.26287 7.63733 6.94962 7.82036 6.63599 8.00158C6.59074 8.02791 6.57064 8.08757 6.59302 8.14025C6.64148 8.25434 6.69548 8.38995 6.74634 8.51818C6.79738 8.64689 6.84513 8.76886 6.88306 8.859L7.74048 10.8922C8.40108 12.459 9.06497 14.0318 9.72095 15.6012C9.74364 15.6555 9.78376 15.7647 9.81861 15.8727C9.83608 15.9269 9.85315 15.9827 9.86548 16.0319C9.87714 16.0784 9.8875 16.128 9.88794 16.1666V16.1686C9.88022 16.809 9.66181 17.4977 9.42017 18.0631C8.55729 20.0815 6.63448 21.0118 4.50904 20.6578C4.13575 20.5956 3.87503 20.527 3.52076 20.3707C2.37858 19.8667 1.57118 18.858 1.16138 17.7067C0.997368 17.2482 0.823369 16.6608 0.849857 16.1363V16.1276L0.882083 15.9733C0.925715 15.805 1.00191 15.5995 1.08228 15.401C1.19031 15.1341 1.313 14.8621 1.38599 14.6901L2.01392 13.1988C2.64244 11.7048 3.30964 10.0233 3.97681 8.53478C4.00042 8.48207 3.97534 8.41174 3.91333 8.38244C3.48923 8.18314 2.92063 7.7873 2.54908 7.32971C2.36342 7.10094 2.22092 6.84879 2.17603 6.5924C2.13038 6.33098 2.18753 6.06977 2.39087 5.83557C2.5753 5.62278 2.83915 5.49497 3.12036 5.48107H3.20728C3.40362 5.4918 3.56612 5.57065 3.7063 5.67834C3.8631 5.79888 3.99772 5.96033 4.12232 6.10607C4.38331 6.41132 4.60168 6.64005 4.95044 6.57287C5.26374 6.51234 5.6192 6.33846 5.97681 6.12951C6.15466 6.02559 6.33044 5.91378 6.50025 5.80627C6.66932 5.69922 6.83331 5.59579 6.98365 5.50744C7.30179 5.31868 7.63196 5.15044 7.97193 5.00451L8.23072 4.90197C8.80917 4.68761 9.23285 4.6409 9.8606 4.60217C9.89592 4.59991 9.92977 4.5779 9.94849 4.54357C10.3432 3.81016 10.9662 3.36697 11.8167 3.27502H11.8196ZM2.93384 16.109L7.79029 16.1119C7.4586 15.3141 5.79821 11.326 5.38306 10.3639L2.93384 16.109ZM18.6184 10.3707C17.8845 12.2332 16.5414 15.4101 16.2444 16.111L21.0842 16.1051L18.6184 10.3707Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0046 0.599976C12.8309 0.599976 13.6052 0.756058 14.3275 1.06822C15.0559 1.37427 15.6955 1.80273 16.2464 2.35361C16.7972 2.89837 17.2257 3.53494 17.5318 4.26333C17.8439 4.98559 18 5.75988 18 6.5862C18 7.82874 17.6297 8.95499 16.8891 9.96493C16.1546 10.9688 15.0987 11.7614 13.7215 12.3429L15.9985 14.6291C16.127 14.7637 16.1913 14.9137 16.1913 15.0789C16.1974 15.2442 16.1362 15.3911 16.0077 15.5196L13.6664 17.8517L15.3466 19.5135C15.469 19.6421 15.5302 19.789 15.5302 19.9542C15.5363 20.1256 15.4751 20.2756 15.3466 20.4041L12.4453 23.3054C12.3229 23.4278 12.179 23.4829 12.0138 23.4707C11.8485 23.4584 11.7169 23.4033 11.619 23.3054L10.0306 21.7538C9.93267 21.6558 9.85922 21.5487 9.81025 21.4324C9.76741 21.3222 9.74598 21.1998 9.74598 21.0652V12.1593C8.98087 11.8471 8.31676 11.4125 7.75363 10.8555C7.19663 10.2985 6.76511 9.65277 6.45907 8.91826C6.15302 8.18375 6 7.4064 6 6.5862C6 5.75376 6.15302 4.97641 6.45907 4.25415C6.77123 3.52576 7.19969 2.88919 7.74445 2.34443C8.29533 1.79355 8.93191 1.36509 9.65417 1.05904C10.3826 0.752998 11.166 0.599976 12.0046 0.599976ZM12.0046 6.02614C12.4269 6.02614 12.7881 5.87618 13.088 5.57626C13.3879 5.27633 13.5379 4.9152 13.5379 4.49286C13.5379 4.0644 13.3879 3.70327 13.088 3.40946C12.7881 3.10954 12.4269 2.95958 12.0046 2.95958C11.57 2.95958 11.2058 3.10954 10.912 3.40946C10.6182 3.70327 10.4713 4.0644 10.4713 4.49286C10.4713 4.9152 10.6182 5.27633 10.912 5.57626C11.2119 5.87618 11.5761 6.02614 12.0046 6.02614Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.3995 9.1791C14.6959 9.20399 15.0612 9.41527 15.7918 9.83705L17.5101 10.829C18.091 11.1644 18.3817 11.3324 18.6134 11.5454C19.0415 11.9392 19.3392 12.4542 19.4661 13.0219C19.5348 13.3291 19.5347 13.6649 19.5347 14.3357V17.2622C19.5347 17.9327 19.5348 18.2681 19.4661 18.5752C19.3393 19.1427 19.0421 19.658 18.6141 20.0517C18.3825 20.2647 18.0922 20.4327 17.5116 20.7681L14.9754 22.2325C14.3945 22.5681 14.104 22.7362 13.8036 22.8305C13.2483 23.0046 12.6527 23.0046 12.0974 22.8305C11.7969 22.7363 11.5061 22.5682 10.9249 22.2325L9.20085 21.237C8.47009 20.8148 8.1046 20.6036 7.93491 20.3592C7.61775 19.9025 7.61799 19.2964 7.93562 18.84C8.10555 18.5958 8.47179 18.3851 9.203 17.9636L10.9306 16.9674C11.5123 16.6322 11.8033 16.4648 12.0353 16.2517C12.4639 15.858 12.7617 15.3425 12.8888 14.7745C12.9576 14.4671 12.958 14.1313 12.958 13.4601V11.4734C12.958 10.6298 12.9577 10.2075 13.0843 9.93838C13.3211 9.43551 13.8456 9.13264 14.3995 9.1791Z" fill="currentColor"/>
|
||||
<path d="M10.1963 1.16953C10.7517 0.995411 11.3472 0.99541 11.9026 1.16953C12.203 1.26374 12.4934 1.43192 13.0743 1.76754L14.7984 2.76302C15.5291 3.18509 15.8946 3.3965 16.0644 3.64076C16.3816 4.09751 16.3813 4.7036 16.0637 5.16004C15.8937 5.40412 15.528 5.61504 14.797 6.03636L13.0686 7.03256C12.4873 7.36764 12.1966 7.53528 11.9647 7.74831C11.536 8.14207 11.2376 8.65743 11.1105 9.22548C11.0417 9.53287 11.042 9.8687 11.042 10.54V12.5266C11.042 13.3702 11.0423 13.7925 10.9157 14.0616C10.679 14.5645 10.1543 14.8673 9.60048 14.8209C9.30412 14.796 8.9387 14.5847 8.20822 14.163L6.48913 13.171C5.90836 12.8357 5.61828 12.6676 5.3866 12.4546C4.95845 12.0609 4.66076 11.5457 4.53384 10.9781C4.46517 10.6709 4.46533 10.3351 4.46533 9.66436V6.73784C4.46533 6.06733 4.46521 5.7319 4.53384 5.42479C4.6607 4.85729 4.95791 4.34203 5.38589 3.94833C5.61749 3.7353 5.9078 3.56727 6.48842 3.23187L9.02459 1.76754C9.60556 1.43193 9.89591 1.26375 10.1963 1.16953Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.28613 16.5527C6.39893 16.5584 6.49204 16.5605 6.56543 16.5605C7.65036 16.5831 8.49273 16.62 9.0918 16.6709C9.69654 16.7161 10.1887 16.7783 10.5674 16.8574L11.0508 19.0449C11.1525 19.4857 11.1409 19.8673 11.0166 20.1895C10.8923 20.5115 10.6887 20.7572 10.4062 20.9268C10.1237 21.102 9.80117 21.1895 9.43945 21.1895C8.91966 21.1894 8.51042 21.0566 8.21094 20.791C7.91139 20.5254 7.64795 20.1554 7.42188 19.6807L5.93848 16.5439C6.05715 16.5439 6.17311 16.5471 6.28613 16.5527Z" fill="currentColor"/>
|
||||
<path d="M19.8252 2.81055C20.1811 2.81059 20.4944 2.88968 20.7656 3.04785C21.0424 3.20038 21.2576 3.41466 21.4102 3.69141C21.5684 3.96834 21.6475 4.28554 21.6475 4.6416V17.7646C21.6475 18.1151 21.5684 18.4291 21.4102 18.7061C21.2576 18.9829 21.0425 19.198 20.7656 19.3506C20.4945 19.503 20.181 19.5791 19.8252 19.5791C19.4691 19.5791 19.1519 19.5032 18.875 19.3506C18.6039 19.198 18.3897 18.9828 18.2314 18.7061C18.0788 18.4291 18.002 18.1151 18.002 17.7646V4.6416C18.002 4.28554 18.0788 3.96834 18.2314 3.69141C18.3896 3.41485 18.6041 3.20035 18.875 3.04785C19.1519 2.8896 19.4691 2.81055 19.8252 2.81055Z" fill="currentColor"/>
|
||||
<path d="M16.7988 5.05664V17.417C16.8045 17.4679 16.8066 17.5163 16.8066 17.5615C15.9985 17.1659 15.1394 16.8212 14.2295 16.5273C13.3252 16.2278 12.4177 15.9822 11.5078 15.79C10.598 15.5923 9.73877 15.4478 8.93066 15.3574V7.1084C9.6767 7.02927 10.5082 6.89333 11.4238 6.70117C12.3392 6.50339 13.263 6.25455 14.1953 5.95508C15.1334 5.64991 16.0041 5.29988 16.8066 4.9043C16.801 4.95507 16.7988 5.00587 16.7988 5.05664Z" fill="currentColor"/>
|
||||
<path d="M7.70996 15.3486H5.98926C4.80817 15.3486 3.90684 15.0492 3.28516 14.4502C2.66346 13.8455 2.35254 12.9749 2.35254 11.8389V10.627C2.35254 9.49094 2.66346 8.62037 3.28516 8.01562C3.90685 7.41093 4.80809 7.1084 5.98926 7.1084H7.70996V15.3486Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.1143 1.80515C17.3978 1.7458 17.6454 1.80166 17.8564 1.97312C18.0674 2.14456 18.1728 2.3786 18.1729 2.67527V6.38523C18.1728 6.57628 18.1168 6.74098 18.0049 6.87937C17.8928 7.01126 17.741 7.09761 17.5498 7.13718L12.999 8.13621C12.8014 8.18234 12.6695 8.24467 12.6035 8.32371C12.5442 8.40284 12.5147 8.55143 12.5146 8.76902V17.8305C12.5146 18.5362 12.3956 19.1462 12.1582 19.6606C11.9274 20.175 11.624 20.5943 11.248 20.9175C10.8722 21.2405 10.4632 21.4809 10.0215 21.6391C9.58623 21.7974 9.16376 21.8764 8.75488 21.8764C7.89773 21.8764 7.19573 21.6256 6.64844 21.1245C6.10114 20.6233 5.82722 19.9741 5.82715 19.1762C5.82715 18.398 6.07472 17.7512 6.56934 17.2368C7.06396 16.7225 7.82268 16.3567 8.84473 16.1391L10.4268 15.813C10.829 15.7272 11.0112 15.4596 10.9717 15.0112L10.9512 4.06003C10.9513 3.45352 11.3075 3.07403 12.0195 2.92234L17.1143 1.80515Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8428 3.02246C15.2607 3.02246 16.5498 3.16895 17.71 3.46191C18.8701 3.74902 19.8662 4.16211 20.6982 4.70117C21.5361 5.24023 22.1807 5.88477 22.6318 6.63477C23.083 7.38477 23.3086 8.21973 23.3086 9.13965C23.3086 9.76074 23.1855 10.2939 22.9395 10.7393C22.6992 11.1787 22.3857 11.5566 21.999 11.873C21.6123 12.1836 21.2021 12.4531 20.7686 12.6816C20.3408 12.9043 19.9336 13.1094 19.5469 13.2969C19.166 13.4785 18.8525 13.666 18.6064 13.8594C18.3662 14.0469 18.2461 14.2578 18.2461 14.4922C18.2461 14.75 18.3193 14.9814 18.4658 15.1865C18.6123 15.3857 18.7793 15.5938 18.9668 15.8105C19.1602 16.0273 19.3301 16.2822 19.4766 16.5752C19.623 16.8682 19.6963 17.2402 19.6963 17.6914C19.6963 18.377 19.4238 18.9775 18.8789 19.4932C18.334 20.0146 17.5752 20.4189 16.6025 20.7061C15.6357 20.9932 14.502 21.1367 13.2012 21.1367C11.4668 21.1367 9.8877 20.9229 8.46387 20.4951C7.04004 20.0732 5.8125 19.4668 4.78125 18.6758C3.75586 17.8906 2.96484 16.9531 2.4082 15.8633C1.85156 14.7676 1.57324 13.5547 1.57324 12.2246C1.57324 10.8594 1.86621 9.61426 2.45215 8.48926C3.03809 7.3584 3.87305 6.38867 4.95703 5.58008C6.04102 4.76562 7.33594 4.13574 8.8418 3.69043C10.3477 3.24512 12.0146 3.02246 13.8428 3.02246ZM13.0781 12.7344C12.627 12.7344 12.2139 12.8486 11.8389 13.0771C11.4639 13.2998 11.1621 13.5986 10.9336 13.9736C10.7109 14.3486 10.5996 14.7646 10.5996 15.2217C10.5996 15.6787 10.7109 16.0947 10.9336 16.4697C11.1621 16.8447 11.4639 17.1465 11.8389 17.375C12.2139 17.5977 12.627 17.709 13.0781 17.709C13.5527 17.709 13.9775 17.5977 14.3525 17.375C14.7275 17.1523 15.0234 16.8535 15.2402 16.4785C15.4629 16.1035 15.5742 15.6846 15.5742 15.2217C15.5742 14.7646 15.46 14.3486 15.2314 13.9736C15.0088 13.5986 14.71 13.2998 14.335 13.0771C13.96 12.8486 13.541 12.7344 13.0781 12.7344ZM5.80957 10.3789C5.41113 10.3789 5.0625 10.5254 4.76367 10.8184C4.4707 11.1113 4.32422 11.457 4.32422 11.8555C4.32422 12.2656 4.4707 12.6201 4.76367 12.9189C5.05664 13.2119 5.40527 13.3584 5.80957 13.3584C6.21973 13.3584 6.56836 13.2119 6.85547 12.9189C7.14844 12.6201 7.29492 12.2656 7.29492 11.8555C7.29492 11.4453 7.14844 11.0967 6.85547 10.8096C6.56836 10.5225 6.21973 10.3789 5.80957 10.3789ZM9.29883 6.47656C8.7832 6.47656 8.34082 6.65527 7.97168 7.0127C7.6084 7.37012 7.42676 7.80371 7.42676 8.31348C7.42676 8.83496 7.6084 9.27441 7.97168 9.63184C8.34082 9.98926 8.7832 10.168 9.29883 10.168C9.80859 10.168 10.2451 9.98926 10.6084 9.63184C10.9717 9.27441 11.1533 8.83496 11.1533 8.31348C11.1533 7.80371 10.9717 7.37012 10.6084 7.0127C10.2451 6.65527 9.80859 6.47656 9.29883 6.47656ZM18.4043 7.04785C18.0645 7.04785 17.7744 7.16797 17.5342 7.4082C17.2939 7.64258 17.1738 7.92969 17.1738 8.26953C17.1738 8.60938 17.2939 8.89648 17.5342 9.13086C17.7744 9.36523 18.0645 9.48242 18.4043 9.48242C18.7383 9.48242 19.0225 9.3623 19.2568 9.12207C19.4912 8.88184 19.6084 8.59766 19.6084 8.26953C19.6084 7.94141 19.4912 7.65723 19.2568 7.41699C19.0225 7.1709 18.7383 7.04785 18.4043 7.04785ZM14.1152 5.73828C13.7051 5.73828 13.3506 5.88477 13.0518 6.17773C12.7529 6.46484 12.6035 6.81641 12.6035 7.23242C12.6035 7.64844 12.7529 8.00293 13.0518 8.2959C13.3506 8.58301 13.7051 8.72656 14.1152 8.72656C14.5312 8.72656 14.8828 8.58301 15.1699 8.2959C15.4629 8.00293 15.6094 7.64844 15.6094 7.23242C15.6094 6.81641 15.4629 6.46484 15.1699 6.17773C14.8828 5.88477 14.5312 5.73828 14.1152 5.73828Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.9902 13.4937C13.276 13.4937 14.4333 13.6845 15.4619 14.0669C16.4907 14.4494 17.3683 14.9477 18.0938 15.561C18.8258 16.1744 19.3863 16.8307 19.7754 17.5298C20.1644 18.2287 20.3593 18.898 20.3594 19.5376C20.3594 19.9729 20.2003 20.3163 19.8838 20.5669C19.5673 20.8173 19.1319 20.9429 18.5781 20.9429H5.42188C4.8679 20.9429 4.43179 20.8175 4.11523 20.5669C3.79878 20.3163 3.64062 19.9728 3.64062 19.5376C3.64067 18.898 3.83238 18.2288 4.21484 17.5298C4.60395 16.8307 5.16127 16.1744 5.88672 15.561C6.61868 14.9478 7.4987 14.4494 8.52734 14.0669C9.55616 13.6844 10.7108 13.4937 11.9902 13.4937Z" fill="currentColor"/>
|
||||
<path d="M12 3.05713C12.7188 3.05713 13.3816 3.24477 13.9883 3.62061C14.595 3.99652 15.0797 4.51165 15.4424 5.16455C15.8116 5.81076 15.9961 6.5359 15.9961 7.34033C15.9961 8.16459 15.8116 8.90966 15.4424 9.57568C15.0797 10.2352 14.595 10.7598 13.9883 11.1489C13.3816 11.538 12.7188 11.7329 12 11.7329C11.2812 11.7329 10.6184 11.538 10.0117 11.1489C9.41158 10.7598 8.92694 10.2352 8.55762 9.57568C8.19499 8.91625 8.01367 8.17753 8.01367 7.35986C8.01373 6.55545 8.19497 5.83028 8.55762 5.18408C8.92694 4.53118 9.41475 4.01288 10.0215 3.63037C10.6282 3.24794 11.2878 3.05713 12 3.05713Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8135 3.79102C20.8064 3.79105 21.5559 4.04418 22.0615 4.5498C22.5669 5.04934 22.8193 5.78624 22.8193 6.76074V17.8916C22.8193 18.8661 22.567 19.603 22.0615 20.1025C21.5559 20.6021 20.8064 20.8525 19.8135 20.8525H4.16895C3.18201 20.8525 2.43534 20.6021 1.92969 20.1025C1.42414 19.603 1.17095 18.8662 1.1709 17.8916V6.76074C1.17092 5.78616 1.42417 5.04935 1.92969 4.5498C2.43534 4.04415 3.18201 3.79102 4.16895 3.79102H19.8135ZM15.6006 11.8965C15.0645 11.8965 14.58 12.0888 14.1475 12.4727L9.8252 16.3193L8.08887 14.7568C7.68683 14.3913 7.25126 14.209 6.78223 14.209C6.57522 14.209 6.36814 14.2544 6.16113 14.3457C5.96016 14.431 5.75859 14.5617 5.55762 14.7383L2.97168 16.9961V17.7178C2.9717 18.1381 3.09055 18.4675 3.32812 18.7051C3.56569 18.9424 3.89163 19.0605 4.30566 19.0605H19.6943C20.0964 19.0605 20.4167 18.9454 20.6543 18.7139C20.8979 18.4763 21.0195 18.1504 21.0195 17.7363V16.1729L17.0264 12.4814C16.8193 12.2865 16.5906 12.1404 16.3408 12.043C16.0911 11.9455 15.8443 11.8965 15.6006 11.8965ZM8.29883 7.94922C7.87854 7.94925 7.49466 8.05266 7.14746 8.25977C6.80647 8.46684 6.53227 8.74374 6.3252 9.09082C6.11806 9.43198 6.01465 9.81034 6.01465 10.2246C6.01471 10.6448 6.11812 11.0288 6.3252 11.376C6.5323 11.717 6.8064 11.9912 7.14746 12.1982C7.49466 12.4053 7.87854 12.5088 8.29883 12.5088C8.70693 12.5088 9.08174 12.4053 9.42285 12.1982C9.77008 11.9911 10.0478 11.7171 10.2549 11.376C10.462 11.0288 10.5654 10.6448 10.5654 10.2246C10.5654 9.81034 10.462 9.43198 10.2549 9.09082C10.0478 8.74361 9.77007 8.46688 9.42285 8.25977C9.0817 8.05264 8.70699 7.94922 8.29883 7.94922Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.94798 3.30664C8.01975 3.30672 7.31901 3.54355 6.84599 4.01658C6.36709 4.48377 6.12781 5.17293 6.12781 6.08354V9.69403C6.12772 9.78818 6.09795 9.84989 6.03919 9.8795C5.9742 9.90313 5.89698 9.88595 5.80838 9.82695C5.65462 9.71459 5.49148 9.61375 5.31998 9.52505C5.14264 9.4364 4.9532 9.36495 4.75223 9.31176C4.55132 9.25862 4.33558 9.23243 4.10515 9.23242C3.5729 9.23242 3.09924 9.35368 2.68527 9.59614C2.26556 9.83859 1.93437 10.173 1.69197 10.5987C1.44962 11.0185 1.32825 11.4947 1.32825 12.0268C1.32828 12.5531 1.44954 13.0292 1.69197 13.455C1.93441 13.8807 2.26548 14.2151 2.68527 14.4575C3.09924 14.7 3.5729 14.8213 4.10515 14.8213C4.33563 14.8212 4.55128 14.794 4.75223 14.7409C4.95314 14.6877 5.14269 14.6172 5.31998 14.5286C5.49148 14.4399 5.65462 14.3414 5.80838 14.235C5.89695 14.1701 5.97422 14.1529 6.03919 14.1824C6.09809 14.2062 6.12781 14.2652 6.12781 14.3596V17.9083C6.12787 18.8247 6.36713 19.5164 6.84599 19.9835C7.31901 20.4565 8.01975 20.6934 8.94798 20.6934H19.8773C20.7938 20.6934 21.4887 20.4541 21.9618 19.9753C22.4349 19.5022 22.6717 18.804 22.6718 17.8815V14.1206C22.6718 13.8072 22.5676 13.5579 22.3606 13.3746C22.1538 13.1916 21.9117 13.0996 21.6341 13.0995C21.4746 13.0995 21.3146 13.1357 21.155 13.2066C20.9894 13.2776 20.8353 13.3841 20.6934 13.5261C20.5634 13.6561 20.4124 13.7946 20.2411 13.9423C20.0696 14.0842 19.8598 14.2089 19.6115 14.3153C19.3631 14.4159 19.064 14.4658 18.7151 14.4658C18.2717 14.4657 17.8668 14.3568 17.5002 14.1381C17.1336 13.9193 16.8435 13.6258 16.6306 13.2592C16.4119 12.8926 16.3019 12.4844 16.3019 12.0351C16.3079 11.5799 16.4211 11.1693 16.6398 10.8027C16.8527 10.4302 17.1418 10.1367 17.5085 9.92381C17.8692 9.71093 18.2716 9.60441 18.7151 9.60439C19.064 9.60439 19.3631 9.65532 19.6115 9.75585C19.8596 9.85633 20.0697 9.98011 20.2411 10.1278C20.4125 10.2697 20.5633 10.4058 20.6934 10.5359C20.8353 10.6837 20.9894 10.7936 21.155 10.8646C21.3146 10.9295 21.4746 10.9624 21.6341 10.9624C21.9119 10.9623 22.1537 10.8728 22.3606 10.6956C22.5675 10.5123 22.6717 10.2615 22.6718 9.94235V6.11858C22.6717 5.19021 22.4348 4.48967 21.9618 4.01658C21.4887 3.54348 20.7939 3.30665 19.8773 3.30664H8.94798Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.39681 21.5464C5.17464 21.3813 5.03817 21.1592 4.98738 20.8799C4.94295 20.6069 4.98421 20.2832 5.11116 19.9087L7.07259 14.0911L2.07381 10.5015C1.75008 10.2729 1.52474 10.0349 1.39778 9.78735C1.27083 9.53345 1.25179 9.27319 1.34066 9.00659C1.42952 8.74634 1.59774 8.55273 1.84529 8.42578C2.09285 8.29883 2.41658 8.23535 2.81649 8.23535H8.9388L10.805 2.42725C10.9256 2.04639 11.0843 1.75757 11.2811 1.56079C11.4842 1.35767 11.7222 1.2561 11.9952 1.2561C12.2745 1.2561 12.5125 1.35767 12.7093 1.56079C12.9124 1.75757 13.0743 2.04639 13.1949 2.42725L15.0611 8.23535H21.1834C21.5833 8.23535 21.9071 8.29883 22.1546 8.42578C22.4022 8.55273 22.5704 8.74634 22.6593 9.00659C22.7481 9.27319 22.7291 9.53345 22.6021 9.78735C22.4752 10.0349 22.2498 10.2729 21.9261 10.5015L16.9273 14.0911L18.8888 19.9087C19.0157 20.2832 19.0538 20.6069 19.003 20.8799C18.9586 21.1592 18.8253 21.3813 18.6031 21.5464C18.3809 21.7241 18.1302 21.7876 17.8509 21.7368C17.5716 21.686 17.2733 21.5464 16.9559 21.3179L11.9952 17.6711L7.04402 21.3179C6.72664 21.5464 6.4283 21.686 6.149 21.7368C5.86971 21.7876 5.61898 21.7241 5.39681 21.5464Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6562 3.50635C20.6529 3.50638 21.4094 3.76446 21.9258 4.28076C22.4422 4.79717 22.7002 5.54793 22.7002 6.53271V17.4761C22.7002 18.4549 22.4422 19.2028 21.9258 19.7192C21.4094 20.2356 20.6529 20.4936 19.6562 20.4937H4.35254C3.35596 20.4936 2.59657 20.2355 2.07422 19.7192C1.55781 19.2028 1.2998 18.4549 1.2998 17.4761V6.53271C1.2998 5.54192 1.55781 4.79117 2.07422 4.28076C2.59657 3.76451 3.35601 3.50641 4.35254 3.50635H19.6562ZM10.0186 11.1987C9.8204 11.1987 9.649 11.2704 9.50488 11.4146C9.36679 11.5527 9.29785 11.7241 9.29785 11.9282C9.2979 12.1323 9.36682 12.3066 9.50488 12.4507C9.64298 12.5947 9.81443 12.6665 10.0186 12.6665H13.5137C13.7175 12.6664 13.8884 12.5945 14.0264 12.4507C14.1704 12.3066 14.2431 12.1323 14.2432 11.9282C14.2432 11.7241 14.1705 11.5527 14.0264 11.4146C13.8884 11.2706 13.7176 11.1988 13.5137 11.1987H10.0186ZM6.17188 7.07275C5.93188 6.92288 5.71546 6.87798 5.52344 6.93799C5.33144 6.99806 5.18127 7.11272 5.07324 7.28076C4.97141 7.4487 4.9359 7.6345 4.96582 7.83838C5.00185 8.04254 5.12758 8.20837 5.34375 8.33447L7.35254 9.5415L5.35254 10.7661C5.13055 10.8981 5.00192 11.0632 4.96582 11.2612C4.93581 11.4593 4.97123 11.6457 5.07324 11.8198C5.17531 11.9939 5.32254 12.1113 5.51465 12.1714C5.71277 12.2314 5.93173 12.1857 6.17188 12.0356L8.79297 10.3882C9.00307 10.2561 9.13914 10.0818 9.19922 9.86572C9.26526 9.64959 9.26147 9.43621 9.18945 9.22607C9.1234 9.0099 8.99113 8.8385 8.79297 8.7124L6.17188 7.07275Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, VariantType } from '@openfun/cunningham-react';
|
||||
import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -92,6 +92,7 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
|
||||
{isMounted &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.body &&
|
||||
toasts.length > 0 &&
|
||||
createPortal(
|
||||
<Box
|
||||
aria-live="polite"
|
||||
|
||||