Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 402f7a7487 | |||
| 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 | |||
| 8c62f5d933 | |||
| 88bdcc2e60 | |||
| 1c573ad3a7 | |||
| 3106d5f25f | |||
| 23fa1d6b9e | |||
| 8ed72fb305 | |||
| c231e69871 | |||
| fb297b97e6 | |||
| 7858476b84 | |||
| c02254ec93 | |||
| 853305ae74 | |||
| ab2ad0348b | |||
| d752334b5e | |||
| 30f825c337 | |||
| f52a27f218 | |||
| 3e467cacf2 | |||
| 120b204729 | |||
| d9078e75e5 | |||
| 09b003856b | |||
| 0b5317a773 | |||
| abf61a9556 | |||
| 3e8c5c77d5 | |||
| ddfc86a88f | |||
| e7d76e4477 | |||
| fd3399dd66 | |||
| 13c6499c66 | |||
| a0b31e1e61 | |||
| daf90cf110 | |||
| 29f76fe040 | |||
| dc61fdce00 | |||
| aa42a9b4d3 | |||
| 5475bcd04e | |||
| b1533c016a | |||
| 9329ee8c90 | |||
| 39bf8f0c2d | |||
| a1ed561204 | |||
| 6dfb9b7328 | |||
| 38ae97aa31 | |||
| 19cf3b2663 | |||
| 83904d8878 | |||
| d3922b7448 | |||
| cdac7cad3b | |||
| 93ee3cd10d | |||
| 91e1c73ccf | |||
| 0493badb12 | |||
| c6283bd8c8 | |||
| e823d21418 | |||
| 22ce90488c | |||
| 8f5419e6ca | |||
| dcec57719f | |||
| 67bb3536d6 | |||
| a020bfa9bf | |||
| 2df761c9a1 | |||
| fe1a065688 | |||
| a5bc974e5d | |||
| 5a3d20f4a9 | |||
| 78b9f11179 | |||
| b33a3e4987 | |||
| c83c8c7da7 | |||
| ee73c7b9cd | |||
| 78a2393383 | |||
| 392eeece3e | |||
| 1f92187dae | |||
| 9426a7e1ae | |||
| e51620a15c | |||
| f1251a3d09 | |||
| 7bc293b8e3 | |||
| bbac17462a | |||
| 7d7ad0bdcd | |||
| eca8fa5ffe | |||
| 5e497b2ccb | |||
| 55400636b6 | |||
| 1901c4d435 | |||
| 095bcaea1a | |||
| fbe9e039cf | |||
| 33a87c3959 | |||
| 18fc3390f3 | |||
| 1d54114a39 | |||
| 7d8b6fc07c | |||
| 2b96ba0597 | |||
| 34cf348f4c | |||
| 0cce897c69 | |||
| 7c8d8e9de7 | |||
| 14b920466b | |||
| 42017a6180 | |||
| c89ce82a4a | |||
| a738b6cfc3 | |||
| 9c3f8a8541 | |||
| 09e885d7e7 | |||
| 05a1844a0c | |||
| a82e0b4fa0 | |||
| 60b8338ae5 | |||
| bbc8dad9da | |||
| f9e446ec18 | |||
| a013c69ba7 | |||
| 2a79655edb | |||
| ad4b5473aa | |||
| b8e6e32fed | |||
| 0a5f71ef18 | |||
| 151914f55c | |||
| ab45eea736 | |||
| ef7c15507d | |||
| ca0c3a0169 | |||
| 6b2327b0f1 | |||
| 9887d79bd5 | |||
| d1852d210c | |||
| 12a9488f30 | |||
| 15a84132b0 | |||
| 8a52ae8608 | |||
| fcd0e24a16 | |||
| 620575140e | |||
| 1d55f51e15 | |||
| d91cb498e0 | |||
| 7cb21e694e | |||
| 661f678363 | |||
| f3781e0dde | |||
| c510adf832 | |||
| 6641457cdd | |||
| b15aa901f8 | |||
| 161cdfe17c | |||
| c9b724f8da | |||
| ef9bdf9b95 | |||
| daa623d2df | |||
| 14739ba2e6 | |||
| d242328c6d | |||
| aeba1ba869 | |||
| 81c1dcbf81 | |||
| 8dba8b08e3 | |||
| fb92ac9f2b | |||
| 859b2f312d | |||
| 4b70641ac6 | |||
| c460f80cf8 | |||
| c4af7dcc0b | |||
| d12b959812 | |||
| f014de69af | |||
| 4b9a251219 | |||
| 146c9ff19f | |||
| 7c6e3da2d9 | |||
| 605f730b16 | |||
| 04cf9cbc34 | |||
| 5d30f275cf | |||
| ba62bd82d5 | |||
| 5ed92c286e | |||
| feba5509d2 | |||
| 747ca8499d | |||
| f2deeedbc4 | |||
| 1cdccd3d24 | |||
| 79b5be6004 | |||
| a6a5dec0ac | |||
| 592b67ca4c | |||
| 45c346e1e7 | |||
| fe7f7b2730 | |||
| aabb2a0991 | |||
| 48fb064594 | |||
| bc65d646e5 | |||
| 99c4b6ba29 | |||
| c9c10ce19a | |||
| 1b9e814be7 | |||
| 9644b8ccd3 | |||
| ac65ffe7f7 | |||
| bb6ced4728 | |||
| 02ac8af714 | |||
| 8c706fd2d8 | |||
| c9efa978e1 | |||
| d361d0177c | |||
| 934098a1c2 | |||
| 5c4b1ff388 | |||
| 689f2ce672 | |||
| 2515e14942 | |||
| 273332c370 | |||
| 623a951ca2 | |||
| 9e4f0d3310 | |||
| a560ea9e78 | |||
| e04a050486 | |||
| 7e460b55a3 | |||
| dce69eb31c | |||
| 297af78936 | |||
| 7507f1a8db | |||
| 05dceec3e7 | |||
| d486661f32 | |||
| 7bc38c7435 | |||
| b4df44311a | |||
| 9878498a34 | |||
| 517bc1a9d1 | |||
| 81fd0d3400 | |||
| 10f3ebf81f | |||
| d579d9abd0 | |||
| 5bdd2f92f0 | |||
| 633bcf094d | |||
| c491108f9e | |||
| d582f85f59 | |||
| 926ea2975f | |||
| ace27ab7fb | |||
| 9bbc3f160f | |||
| 90d40eba6a | |||
| 8b8b7bd7dd | |||
| b76760025f | |||
| a267b0282b | |||
| c5b9576835 | |||
| 6e5220866a | |||
| 548679698f | |||
| 5e2d77642d | |||
| df3934367a | |||
| 22769af0e5 | |||
| 59af95caf7 | |||
| b6fe71adb0 | |||
| fd06d33d05 | |||
| e921aa9d94 | |||
| f7e3ed19ba | |||
| 4e078d8bb1 | |||
| 2486db8287 | |||
| cc77378d39 | |||
| fbed35f4c4 | |||
| 697b040a40 | |||
| 86bcebadf8 | |||
| 0f45d2e896 | |||
| 475afcb454 | |||
| 095735fb34 | |||
| bd798816a2 | |||
| aafaaeed3e | |||
| a9883a1a31 | |||
| 768aeec91f | |||
| 88760022e0 | |||
| 8cc6a78089 | |||
| 04f15a518a | |||
| 2ef70d15ea | |||
| 8358ca9490 | |||
| f1c8a09378 | |||
| 8c917b90c2 | |||
| 44b50d5085 | |||
| 30ca4a4066 | |||
| 43e012d185 | |||
| 4b89c826c1 | |||
| e9b512400f | |||
| f9d6934205 | |||
| a5ef813840 | |||
| 7d69eb2130 | |||
| f2707bb830 | |||
| b914214818 | |||
| e04f56059d | |||
| c5af3cdcb1 | |||
| 0526c9bbef | |||
| d23434aafe | |||
| 8ec172583a | |||
| 53cfc89b9b | |||
| fe7995a118 |
@@ -0,0 +1,20 @@
|
||||
[codespell]
|
||||
skip =
|
||||
./git/,
|
||||
**/*.pdf,
|
||||
**/*.po,
|
||||
**/*.pot,
|
||||
**/*.json,
|
||||
**/yarn.lock,
|
||||
**/node_modules/**,
|
||||
**/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,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
docker compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
.next
|
||||
@@ -0,0 +1,14 @@
|
||||
DJANGO_CONFIGURATION=Test
|
||||
DJANGO_SETTINGS_MODULE=conversations.settings
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT=/endpoint-for-test-purpose-only
|
||||
DB_HOST=localhost
|
||||
DB_NAME=conversations
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=15432
|
||||
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID=conversations
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
@@ -0,0 +1,23 @@
|
||||
# Set the default behavior for all files
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary files (should not be modified)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.flv binary
|
||||
*.fla binary
|
||||
*.swf binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
@@ -0,0 +1,6 @@
|
||||
<!---
|
||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
||||
|
||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
||||
-->
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
labels: ["bug", "triage"]
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Conversations version:
|
||||
- Instance url:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
labels: ["feature", "triage"]
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
|
||||
Maybe add a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
labels: ["support", "triage"]
|
||||
---
|
||||
## Support request
|
||||
**Checks before filing**
|
||||
Please make sure you have read our [main Readme](https://github.com/suitenumerique/conversations).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/conversations/issues?q=is%3Aissue%20state%3Aopen%20label%3Asupport).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -0,0 +1,22 @@
|
||||
## Purpose
|
||||
|
||||
Describe the purpose of this pull request.
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
- [ ] item 1...
|
||||
- [ ] item 2...
|
||||
|
||||
## External contributions
|
||||
|
||||
Thank you for your contribution! 🎉
|
||||
|
||||
Please ensure the following items are checked before submitting your pull request:
|
||||
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/conversations/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/conversations/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
|
||||
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
|
||||
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
|
||||
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
|
||||
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
|
||||
@@ -0,0 +1,151 @@
|
||||
name: Frontend Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
test-front:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-chromium-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-e2e-chromium
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Start Docker services
|
||||
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-other-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,232 @@
|
||||
name: Main Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
with-build_mails: true
|
||||
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/conversations.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lint-spell-mistakes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install codespell
|
||||
run: pip install --user codespell
|
||||
- name: Check for typos
|
||||
run: codespell
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: conversations
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: conversations.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: conversations
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: conversations
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=conversations" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set conversations http://localhost:9000 conversations password && \
|
||||
mc alias ls && \
|
||||
mc mb conversations/conversations-media-storage && \
|
||||
mc version enable conversations/conversations-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
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
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest -n 2
|
||||
|
||||
security-trivy-critical:
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run Trivy analysis for critical vulnerabilities
|
||||
# We use main branch while we might still iterate on the action
|
||||
uses: numerique-gouv/action-trivy-cache/security-trivy-critical@main
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
|
||||
security-trivy:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run Trivy analysis for vulnerabilities
|
||||
# We use main branch while we might still iterate on the action
|
||||
uses: numerique-gouv/action-trivy-cache/security-trivy@main
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/conversations/
|
||||
touch src/frontend/packages/i18n/locales/conversations/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n,noChangeLog
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
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
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Dependency reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
default: false
|
||||
with-build_mails:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
if: ${{ inputs.with-front-dependencies-installation == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-mails:
|
||||
if: ${{ inputs.with-build_mails == true }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'ci/trivy-fails'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/conversations-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/conversations-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
if: always()
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/conversations-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/conversations-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
if: always()
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:v0.171.0
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Helmfile lint
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
HELMFILE=src/helm/helmfile.yaml.gotmpl
|
||||
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- src/helm/conversations/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.DS_Store
|
||||
.next/
|
||||
|
||||
# Translations # Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# Configuration
|
||||
**/conversations/configuration/llm/dev.json
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
|
||||
# Site media
|
||||
/data/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Docker compose override
|
||||
compose.override.yml
|
||||
@@ -0,0 +1,78 @@
|
||||
# All these sections are optional, edit this file as you like.
|
||||
[general]
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# ignore=title-trailing-punctuation, T3
|
||||
|
||||
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
|
||||
# verbosity = 2
|
||||
|
||||
# By default gitlint will ignore merge commits. Set to 'false' to disable.
|
||||
# ignore-merge-commits=true
|
||||
|
||||
# By default gitlint will ignore fixup commits. Set to 'false' to disable.
|
||||
# ignore-fixup-commits=true
|
||||
|
||||
# By default gitlint will ignore squash commits. Set to 'false' to disable.
|
||||
# ignore-squash-commits=true
|
||||
|
||||
# Enable debug mode (prints more output). Disabled by default.
|
||||
# debug=true
|
||||
|
||||
# Set the extra-path where gitlint will search for user defined rules
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
[title-must-not-contain-word]
|
||||
# Comma-separated list of words that should not occur in the title. Matching is case
|
||||
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
|
||||
# will not cause a violation, but "WIP: my title" will.
|
||||
words=wip
|
||||
|
||||
#[title-match-regex]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit-msg title must be matched to.
|
||||
# Note that the regex can contradict with other rules if not used correctly
|
||||
# (e.g. title-must-not-contain-word).
|
||||
#regex=
|
||||
|
||||
# [B1]
|
||||
# B1 = body-max-line-length
|
||||
# line-length=120
|
||||
# [body-min-length]
|
||||
# min-length=5
|
||||
|
||||
# [body-is-missing]
|
||||
# Whether to ignore this rule on merge commits (which typically only have a title)
|
||||
# default = True
|
||||
# ignore-merge-commits=false
|
||||
|
||||
# [body-changed-file-mention]
|
||||
# List of files that need to be explicitly mentioned in the body when they are changed
|
||||
# This is useful for when developers often erroneously edit certain files or git submodules.
|
||||
# By specifying this rule, developers can only change the file when they explicitly reference
|
||||
# it in the commit message.
|
||||
# files=gitlint/rules.py,README.md
|
||||
|
||||
# [author-valid-email]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit author email address should be matched to
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
[ignore-by-title]
|
||||
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
|
||||
# upgrade dependencies
|
||||
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
|
||||
ignore=B6,UC1
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
# regex=(.*)release(.*)
|
||||
#
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# Use 'all' to ignore all rules
|
||||
# ignore=T1,body-min-length
|
||||
+261
-1
@@ -8,5 +8,265 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/conversations/compare/HEAD...main
|
||||
- ✨(back) add projects with custom LLM instructions
|
||||
- ✨(front) projects management UI
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
|
||||
- ✨(front) rich text copy for Word/Docs paste
|
||||
|
||||
### 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡️(front) performance improvements on chat input
|
||||
- 💄(front) i18n and standardize pdf parsing display
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) consider PDF documents as other kind of documents #234
|
||||
|
||||
## [0.0.11] - 2026-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(e2e) fix test-e2e-chromium
|
||||
- 🐛(back) fix system prompt compatibility with self-hosted models #200
|
||||
- ⚰️(back) remove dead code and unused files
|
||||
- 🐛(back) prevent tool call timeouts
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) remove thinking part from frontend #227
|
||||
|
||||
## [0.0.10] - 2025-12-15
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add retry button
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix long user messages
|
||||
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
|
||||
- 🐛(front) fix parsing documents display
|
||||
- 🐛(front) fix opacity input in error
|
||||
- 🐛(front) resolve React hydration errors
|
||||
- 🚑️(user) allow longer short names #182
|
||||
|
||||
## [0.0.9] - 2025-11-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add code copy button
|
||||
- ✨(RAG) add generic collection RAG tools #159
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔊(langfuse) enable tracing with redacted content #162
|
||||
|
||||
## [0.0.8] - 2025-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🦺(front) Fix send prohibited file types
|
||||
- 🐛(front) fix target blank links in chat #103
|
||||
- 🚑️(posthog) pass str instead of UUID for user PK #134
|
||||
- ⚡️(web-search) keep running when tool call fails #137
|
||||
- ✨(summarize): new summarize tool integration #78
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(posthog) remove posthog middleware for async mode fix #146
|
||||
|
||||
## [0.0.7] - 2025-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(posthog) fix the posthog middleware for async mode #133
|
||||
|
||||
## [0.0.6] - 2025-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(stats) fix tracking id in upload event #130
|
||||
|
||||
## [0.0.5] - 2025-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(drag-drop) fix the rejection display on Safari #127
|
||||
|
||||
## [0.0.4] - 2025-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- ♿️(a11y) improve accessibility #135
|
||||
- 🌐(i18n) add dutch language #117
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(asgi) use `uvicorn` to serve backend #121
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix mobile source
|
||||
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
|
||||
|
||||
## [0.0.3] - 2025-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(web-search) fix missing argument in RAG backend #116
|
||||
|
||||
## [0.0.2] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(front) add drag'n drop file
|
||||
- ✨(activation-codes) register users also on Brevo #98
|
||||
- 📈(posthog) add `sub` field to tracking #95
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔧(front) change links feedback tchap + settings popup
|
||||
- 🐛(front) code activation fix session end #93
|
||||
- 💬(wording) error page wording #102
|
||||
- ⚡️(web-search) allow to override returned chunks #107
|
||||
- 🐛(activation-codes) create contact in brevo before add to list #108
|
||||
- ⚗️(summarization) add system prompt to handle tool #112
|
||||
|
||||
## [0.0.1] - 2025-10-19
|
||||
|
||||
### Changed
|
||||
|
||||
- 🎨(front) activation page footer
|
||||
- 👷(front) change size small modal
|
||||
- 🎨(front) retour ui global
|
||||
- 👷(front) fix button scrollDown
|
||||
- 💥(front) disable input when error occurred
|
||||
- 👷(front) fix scroll
|
||||
- 🐛(front) fix left panel status + fix scroll
|
||||
- 🐛(llm) add is_active field and persist chat preference
|
||||
- ✨(frontend) add LLM selection in chat input #53
|
||||
- 🎨(front) fix width chat container
|
||||
- 🎨(front) fix width chat container #55
|
||||
- 🐛(front) fix button search web on new conversation
|
||||
- 🎨(front) improvement search input scroll
|
||||
- ✨(404) fix front 404 page
|
||||
- ✅(chat) add frontend feature flags #29
|
||||
- 🎨(front) change list attachment in chat
|
||||
- 🎨(front) move emplacement for attachment
|
||||
- 🎨(ui) retour ui sources files
|
||||
- ✨(ui) fix retour global ui
|
||||
- 🐛(fix) broken staging css
|
||||
- 🎨(alpha) adjustment for alpha version
|
||||
- ✨(ui) delete flex message
|
||||
- ✅(front) add enabled/disabled conversation analysis
|
||||
- 🎨(front) amelioration chat ux
|
||||
- 🎨(front) global layout modification
|
||||
- ✨(front) global layout UI
|
||||
- ♻️(chat) rewrite backend using Pydantic AI SDK #4
|
||||
- 🗃️(chat) enforce messages stored JSON format #6
|
||||
- 🐛(chat) UI messages must have a unique identifier #6
|
||||
- ✨(llm) allow configuration from JSON file #22
|
||||
- 💥(agent) replace routing w/ tool calls #40
|
||||
- 🧱(storage) upload the user documents into S3 #86
|
||||
|
||||
### Added
|
||||
|
||||
- 🎉(conversations) bootstrap backend & frontend #1
|
||||
- ✨(web-search) add RAG capability to do web search #7
|
||||
- ✨(chat) add document RAG on document uploaded by user #8
|
||||
- ✨(backend) allow use to stop conversation streaming #14
|
||||
- 🐛(agent) add the current date in the system prompt #18
|
||||
- ✨(backend) add feature flags from posthog #13
|
||||
- ✨(user) allow to use conversation data for analytics #23
|
||||
- ✨(chat) enforce response in user language #24
|
||||
- 📈(langfuse) add light instrumentation #26
|
||||
- 🚑️(agent) allow Mistral w/ vLLM & tools #36
|
||||
- ✨(web-search) add Brave search tool #47
|
||||
- ✨(models) add mistral support & customization #51
|
||||
- 🐛(web-search) add summarization to Brave results #58
|
||||
- ✨(langfuse) allow user to score messages from LLM #6
|
||||
- ✨(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.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
|
||||
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
|
||||
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
|
||||
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
|
||||
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
|
||||
[0.0.5]: https://github.com/suitenumerique/conversations/releases/v0.0.5
|
||||
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.4
|
||||
[0.0.3]: https://github.com/suitenumerique/conversations/releases/v0.0.3
|
||||
[0.0.2]: https://github.com/suitenumerique/conversations/releases/v0.0.2
|
||||
[0.0.1]: https://github.com/suitenumerique/conversations/releases/v0.0.1
|
||||
|
||||
+1
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/conversations/blob/main/README.md) for detailed instructions on how to run Docs locally.
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/conversations/blob/main/README.md) for detailed instructions on how to run Conversations locally.
|
||||
|
||||
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||
|
||||
@@ -92,11 +92,3 @@ Make sure that all new features or fixes have corresponding tests. Run the test
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
|
||||
## Contribute to BlockNote
|
||||
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
|
||||
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
|
||||
|
||||
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
|
||||
|
||||
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
# Django conversations
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||
|
||||
# Install Rust and Cargo using Alpine's package manager
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
libffi-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
rust \
|
||||
cargo
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:24 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base AS link-collector
|
||||
ARG CONVERSATIONS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Copy conversations application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${CONVERSATIONS_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
cairo \
|
||||
file \
|
||||
font-noto \
|
||||
font-noto-emoji \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
libxml2 \
|
||||
libxslt \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
COPY ./docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
# Give the "root" group the same permissions as the "root" user on /etc/passwd
|
||||
# to allow a user belonging to the root group to add new users; typically the
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core AS backend-production
|
||||
|
||||
# Remove apk cache, we don't need it anymore
|
||||
RUN rm -rf /var/cache/apk/*
|
||||
|
||||
ARG CONVERSATIONS_STATIC_ROOT=/data/static
|
||||
|
||||
# Gunicorn - not used by default but configuration file is provided
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/conversations.py /usr/local/etc/gunicorn/conversations.py
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Copy statics
|
||||
COPY --from=link-collector ${CONVERSATIONS_STATIC_ROOT} ${CONVERSATIONS_STATIC_ROOT}
|
||||
|
||||
# Copy conversations mails
|
||||
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
|
||||
|
||||
# The default command runs uvicorn ASGI server in conversations's main module
|
||||
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
|
||||
ENV WEB_CONCURRENCY=4
|
||||
CMD [\
|
||||
"uvicorn",\
|
||||
"--app-dir=/app",\
|
||||
"--host=0.0.0.0",\
|
||||
"--timeout-graceful-shutdown=300",\
|
||||
"--limit-max-requests=20000",\
|
||||
"--lifespan=off",\
|
||||
"conversations.asgi:application"\
|
||||
]
|
||||
|
||||
# To run using gunicorn WSGI server use this instead:
|
||||
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
|
||||
@@ -0,0 +1,383 @@
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# This Makefile is only meant to be used for DEVELOPMENT purpose as we are
|
||||
# changing the user id that will run in the container.
|
||||
#
|
||||
# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER...
|
||||
#
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# Note to developers:
|
||||
#
|
||||
# While editing this file, please respect the following statements:
|
||||
#
|
||||
# 1. Every variable should be defined in the ad hoc VARIABLES section with a
|
||||
# relevant subsection
|
||||
# 2. Every new rule should be defined in the ad hoc RULES section with a
|
||||
# relevant subsection depending on the targeted service
|
||||
# 3. Rules should be sorted alphabetically within their section
|
||||
# 4. When a rule has multiple dependencies, you should:
|
||||
# - duplicate the rule name to add the help string (if required)
|
||||
# - write one dependency per line to increase readability and diffs
|
||||
# 5. .PHONY rule statement should be written after the corresponding rule
|
||||
# ==============================================================================
|
||||
# VARIABLES
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
GREEN := \033[1;32m
|
||||
|
||||
|
||||
# -- Database
|
||||
|
||||
DB_HOST = postgresql
|
||||
DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
PATH_FRONT_CONVERSATIONS = $(PATH_FRONT)/apps/conversations
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
default: help
|
||||
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
.PHONY: post-bootstrap
|
||||
|
||||
|
||||
bootstrap: ## Prepare Docker developmentimages for the project
|
||||
bootstrap: \
|
||||
pre-bootstrap \
|
||||
build \
|
||||
post-bootstrap \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||
bootstrap-e2e: \
|
||||
pre-bootstrap \
|
||||
build-e2e \
|
||||
post-bootstrap \
|
||||
run-e2e
|
||||
.PHONY: bootstrap-e2e
|
||||
|
||||
# -- Docker/compose
|
||||
build: cache ?=
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
build-backend: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev $(cache)
|
||||
.PHONY: build-backend
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-development $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
build-e2e: cache ?=
|
||||
build-e2e: ## build the e2e container
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(COMPOSE_E2E) build frontend openmockllm-mistral $(cache)
|
||||
.PHONY: build-e2e
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE_E2E) down
|
||||
.PHONY: down
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d nginx app-dev
|
||||
.PHONY: run-backend
|
||||
|
||||
run-frontend: ## Start only the frontend application
|
||||
@$(COMPOSE) up --force-recreate -d frontend-development
|
||||
.PHONY: run-frontend
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-frontend
|
||||
.PHONY: run
|
||||
|
||||
create-compose-with-models: ## override the docker-compose file with models
|
||||
cp -n compose.with_model.override.yml compose.override.yml
|
||||
.PHONY: create-compose-with-models
|
||||
|
||||
run-e2e: ## start the e2e server
|
||||
run-e2e:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE_E2E) up --force-recreate -d frontend openmockllm-mistral
|
||||
.PHONY: run-e2e
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE_E2E) ps
|
||||
.PHONY: status
|
||||
|
||||
stop: ## stop the development server using Docker
|
||||
@$(COMPOSE_E2E) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Backend
|
||||
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MAKE) resetdb
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
lint-ruff-format \
|
||||
lint-ruff-check \
|
||||
lint-pylint
|
||||
.PHONY: lint
|
||||
|
||||
lint-ruff-format: ## format back-end python sources with ruff
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_APP) ruff format .
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
bin/pylint --diff-only=origin/main
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args:-${1}}
|
||||
.PHONY: test-back
|
||||
|
||||
test-back-parallel: ## run all back-end tests in parallel
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
makemigrations: ## run django makemigrations for the conversations project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the conversations project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@$(MANAGE) makemessages -a --keep-pot --all
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
shell: ## connect to database shell
|
||||
@$(MANAGE) shell #_plus
|
||||
.PHONY: dbshell
|
||||
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
resetdb: ## flush database and create a superuser "admin"
|
||||
@echo "$(BOLD)Flush database$(RESET)"
|
||||
@$(MANAGE) flush $(FLUSH_ARGS)
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
crowdin-download-sources: ## Download sources from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-download-sources
|
||||
|
||||
crowdin-upload: ## Upload source translations to crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload
|
||||
|
||||
i18n-compile: ## compile all translations
|
||||
i18n-compile: \
|
||||
back-i18n-compile \
|
||||
frontend-i18n-compile
|
||||
.PHONY: i18n-compile
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
i18n-generate: \
|
||||
back-i18n-generate \
|
||||
frontend-i18n-generate
|
||||
.PHONY: i18n-generate
|
||||
|
||||
i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications
|
||||
i18n-download-and-compile: \
|
||||
crowdin-download \
|
||||
i18n-compile
|
||||
.PHONY: i18n-download-and-compile
|
||||
|
||||
i18n-generate-and-upload: ## generate source translations for all applications and upload them to Crowdin
|
||||
i18n-generate-and-upload: \
|
||||
i18n-generate \
|
||||
crowdin-upload
|
||||
.PHONY: i18n-generate-and-upload
|
||||
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
.PHONY: mails-build
|
||||
|
||||
mails-build-html-to-plain-text: ## Convert html files to text
|
||||
@$(MAIL_YARN) build-html-to-plain-text
|
||||
.PHONY: mails-build-html-to-plain-text
|
||||
|
||||
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build-mjml-to-html
|
||||
.PHONY: mails-build-mjml-to-html
|
||||
|
||||
mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
.PHONY: mails-install
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
git clean -idx
|
||||
.PHONY: clean
|
||||
|
||||
help:
|
||||
@echo "$(BOLD)conversations Makefile"
|
||||
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_CONVERSATIONS) && yarn
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
#@$(COMPOSE) stop frontend frontend-development
|
||||
cd $(PATH_FRONT_CONVERSATIONS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
|
||||
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
|
||||
frontend-i18n-generate: \
|
||||
crowdin-download-sources \
|
||||
frontend-i18n-extract
|
||||
.PHONY: frontend-i18n-generate
|
||||
|
||||
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
|
||||
cd $(PATH_FRONT) && yarn i18n:deploy
|
||||
.PHONY: frontend-i18n-compile
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
bump-packages-version: VERSION_TYPE ?= minor
|
||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/conversations/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-config-conversations/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
.PHONY: bump-packages-version
|
||||
@@ -16,6 +16,24 @@
|
||||
</p>
|
||||
|
||||
|
||||
**Warning:** This project is in active development and in a very early stage. Breaking changes may occur at any time.
|
||||
|
||||
|
||||
## Yet another AI chatbot
|
||||
|
||||
Conversations is an open-source AI chatbot designed to be simple, secure and privacy-friendly.
|
||||
|
||||
Why another AI chatbot? Because we want to be able to fully control our data and the way we interact with AI.
|
||||
We want to have a very friendly end-user interface and code, and we want to be able to easily customize the
|
||||
chatbot to our needs.
|
||||
|
||||
We leverage open-source projects such as [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev)
|
||||
and only assemble them in a way that makes sense for us and allows us to focus on the product.
|
||||
|
||||
This assistant's purpose is also to be integrated into the "La Suite numérique" ecosystem of tools for public services.
|
||||
|
||||
Any help to improve the project is very welcome!
|
||||
|
||||
|
||||
### Self-host
|
||||
🚀 Conversations is easy to install on your own servers
|
||||
@@ -30,9 +48,9 @@ In the works: Docker Compose, soon YunoHost
|
||||
|
||||
You can test Conversations on your browser by visiting this => TBD
|
||||
|
||||
### Run Docs locally
|
||||
### Run Conversations locally
|
||||
|
||||
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
|
||||
> ⚠️ The methods described below for running Conversations locally is **for testing purposes only**.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
@@ -97,6 +115,31 @@ To start all the services, except the frontend container, you can use the follow
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
**Setup a basic LLM call**
|
||||
|
||||
To be able to use Conversations, you need to configure at least one Large Language Model (LLM) provider.
|
||||
You can do so by setting the appropriate environment variables in the `env.d/development/common` file:
|
||||
|
||||
```ini
|
||||
AI_BASE_URL=http://host.docker.internal:12434/v1/
|
||||
AI_MODEL=gemma3:4b
|
||||
AI_API_KEY=XXX
|
||||
```
|
||||
|
||||
for a local ollama, or by running a local LLM with docker-compose:
|
||||
|
||||
```shellscript
|
||||
$ make create-compose-with-models
|
||||
```
|
||||
|
||||
which will create a `compose.override.yml` file to start a local models `ai/smollm2`
|
||||
which can be changed later by editing the `compose.override.yml` file.
|
||||
|
||||
You will need to call `make run` after changing the `env.d/development/common`
|
||||
or `compose.override.yml` file.
|
||||
|
||||
You can find more information about configuring LLM providers in the [LLM Configuration](docs/llm-configuration.md) documentation.
|
||||
|
||||
**Adding content**
|
||||
|
||||
You can create a basic demo site by running this command:
|
||||
@@ -123,6 +166,18 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
## Documentation 📚
|
||||
|
||||
Additional documentation is available in the `docs/` directory:
|
||||
|
||||
- [LLM Configuration](docs/llm-configuration.md) - Configure Large Language Models and providers
|
||||
- [Attachments](docs/attachments.md) - How to use attachments in conversations
|
||||
- [Tools for Agents](docs/tools.md) - Available tools and how to add new ones
|
||||
- [Environment Variables](docs/env.md) - All available environment variables
|
||||
- [Installation Guide](docs/installation.md) - Deploy on a Kubernetes cluster
|
||||
- [Theming](docs/theming.md) - Customize the application appearance
|
||||
- [Architecture](docs/architecture.md) - Technical architecture overview
|
||||
|
||||
## Licence 📝
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/conversations/blob/main/LICENSE)).
|
||||
@@ -152,9 +207,7 @@ docs
|
||||
|
||||
### Stack
|
||||
|
||||
Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [OpenAI Agents SDK](https://github.com/openai/openai-agents-python). We thank the contributors of all these projects for their awesome work!
|
||||
|
||||
|
||||
Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work!
|
||||
|
||||
|
||||
### Gov ❤️ open source
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make Docs more secure.
|
||||
We appreciate your effort to make Conversations more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
@@ -20,4 +20,4 @@ and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
effectively and responsibly in our open source software project.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('conversations')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/conversations-backend:latest',
|
||||
context='..',
|
||||
dockerfile='../Dockerfile',
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
target = 'backend-production',
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
]
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/conversations-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'conversations',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_resource('conversations-backend-migrate', resource_deps=['postgres-postgresql'])
|
||||
k8s_resource('conversations-backend-createsuperuser', resource_deps=['conversations-backend-migrate'])
|
||||
k8s_resource('conversations-backend', resource_deps=['conversations-backend-migrate'])
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n conversations -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery conversations-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n conversations exec "$POD_NAME" -- python manage.py makemigrations
|
||||
'''
|
||||
cmd_button('Make migration',
|
||||
argv=['sh', '-c', migration],
|
||||
resource='conversations-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run makemigration',
|
||||
)
|
||||
|
||||
pod_migrate = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery conversations-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n conversations exec "$POD_NAME" -- python manage.py migrate --no-input
|
||||
'''
|
||||
cmd_button('Migrate db',
|
||||
argv=['sh', '-c', pod_migrate],
|
||||
resource='conversations-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
#
|
||||
# usage: _set_user
|
||||
#
|
||||
# You can override default user ID (the current host user ID), by defining the
|
||||
# USER_ID environment variable.
|
||||
#
|
||||
# To avoid running docker commands with a custom user, please set the
|
||||
# $UNSET_USER environment variable to 1.
|
||||
function _set_user() {
|
||||
|
||||
if [ $UNSET_USER -eq 1 ]; then
|
||||
USER_ID=""
|
||||
return
|
||||
fi
|
||||
|
||||
# USER_ID = USER_ID or `id -u` if USER_ID is not set
|
||||
USER_ID=${USER_ID:-$(id -u)}
|
||||
|
||||
echo "🙋(user) ID: ${USER_ID}"
|
||||
}
|
||||
|
||||
# docker_compose: wrap docker compose command
|
||||
#
|
||||
# usage: docker_compose [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose command options
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# _dc_run: wrap docker compose run command
|
||||
#
|
||||
# usage: _dc_run [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose exec command options
|
||||
# ARGS : docker compose exec command arguments
|
||||
function _dc_exec() {
|
||||
_set_user
|
||||
|
||||
echo "🐳(compose) exec command: '\$@'"
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose exec $user_args "$@"
|
||||
}
|
||||
|
||||
# _django_manage: wrap django's manage.py command with docker compose
|
||||
#
|
||||
# usage : _django_manage [ARGS...]
|
||||
#
|
||||
# ARGS : django's manage.py command arguments
|
||||
function _django_manage() {
|
||||
_dc_run "app-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
||||
# terraform directory.
|
||||
#
|
||||
# usage: _set_openstack_project
|
||||
#
|
||||
# If necessary the script will prompt the user to choose a project from those available
|
||||
function _set_openstack_project() {
|
||||
|
||||
declare prompt
|
||||
declare -a projects
|
||||
declare -i default=1
|
||||
declare -i choice=0
|
||||
declare -i n_projects
|
||||
|
||||
# List projects by looking in the "./env.d/terraform" directory
|
||||
# and store them in an array
|
||||
read -r -a projects <<< "$(
|
||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
||||
xargs
|
||||
)"
|
||||
nb_projects=${#projects[@]}
|
||||
|
||||
if [[ ${nb_projects} -le 0 ]]; then
|
||||
echo "There are no OpenStack projects defined..." >&2
|
||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
if [[ ${nb_projects} -gt 1 ]]; then
|
||||
prompt="Select an OpenStack project to target:\\n"
|
||||
for (( i=0; i<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [[ ${choice} -le 0 ]]; then
|
||||
choice=${default}
|
||||
fi
|
||||
fi
|
||||
|
||||
project=${projects[$((choice-1))]}
|
||||
# Check that the openrc.sh file exists for this project
|
||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
||||
exit 12
|
||||
fi
|
||||
|
||||
echo "${project}"
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
|
||||
# directories containing potential secrets
|
||||
DIRS="."
|
||||
|
||||
bold=$(tput bold)
|
||||
normal=$(tput sgr0)
|
||||
|
||||
# allow to read user input, assigns stdin to keyboard
|
||||
exec </dev/tty
|
||||
|
||||
for d in $DIRS; do
|
||||
# find files containing secrets that should be encrypted
|
||||
for f in $(find "${d}" -type f -regex ".*\.enc\..*"); do
|
||||
if ! $(grep -q "unencrypted_suffix" $f); then
|
||||
printf '\xF0\x9F\x92\xA5 '
|
||||
echo "File $f has non encrypted secrets!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
EOF
|
||||
|
||||
chmod +x $PRE_COMMIT_FILE
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_django_manage "$@"
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
declare diff_from
|
||||
declare -a paths
|
||||
declare -a args
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--diff-only=*)
|
||||
diff_from="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$arg")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
paths+=("$arg")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${diff_from}" ]]; then
|
||||
# Run pylint only on modified files located in src/backend
|
||||
# (excluding deleted files and migration files)
|
||||
# shellcheck disable=SC2207
|
||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
||||
fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
app-dev \
|
||||
pytest "$@"
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- conversations
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
terraform-state "$@"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
-e TF_VAR_user_name \
|
||||
terraform "$@"
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
app-dev \
|
||||
python manage.py spectacular \
|
||||
--api-version 'v1.0' \
|
||||
--urlconf 'conversations.urls' \
|
||||
--format openapi-json \
|
||||
--file /app/core/tests/swagger/swagger.json
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
image: conversations:frontend-production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
openmockllm-mistral:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/OpenMockLLM/Dockerfile
|
||||
image: conversations:openmockllm-mistral
|
||||
command:
|
||||
- openmockllm
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8000"
|
||||
- --backend
|
||||
- mistral
|
||||
- --model-name
|
||||
- mistral-mock
|
||||
ports:
|
||||
- "8900:8000"
|
||||
@@ -0,0 +1,12 @@
|
||||
name: conversations
|
||||
|
||||
services:
|
||||
app-dev:
|
||||
models:
|
||||
llm:
|
||||
endpoint_var: AI_BASE_URL
|
||||
model_var: AI_MODEL
|
||||
|
||||
models:
|
||||
llm:
|
||||
model: ai/smollm2
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
name: conversations
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
maildev:
|
||||
image: maildev/maildev:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=conversations
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set conversations http://minio:9000 conversations password && \
|
||||
/usr/bin/mc mb conversations/conversations-media-storage && \
|
||||
/usr/bin/mc version enable conversations/conversations-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
target: backend-development
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: conversations:backend-development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
maildev:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
app-dev:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend-development:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: conversations-dev
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
image: conversations:frontend-development
|
||||
volumes:
|
||||
- ./src/frontend:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/conversations/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:17.5
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.3
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
- --http-enabled=true
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { java.net.URI uri = java.net.URI.create(args[0]); System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)uri.toURL().openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/health/live']
|
||||
start_period: 10s
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: conversations
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
depends_on:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# Your crowdin's credentials
|
||||
#
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
base_path_env: CROWDIN_BASE_PATH
|
||||
|
||||
#
|
||||
# Choose file structure in crowdin
|
||||
# e.g. true or false
|
||||
#
|
||||
preserve_hierarchy: true
|
||||
|
||||
#
|
||||
# Files configuration
|
||||
#
|
||||
files: [
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-conversations.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/conversations/translations-crowdin.json",
|
||||
dest: "/frontend-conversations.json",
|
||||
translation: "/frontend/packages/i18n/locales/conversations/%two_letters_code%/translations.json",
|
||||
skip_untranslated_strings: true,
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /media-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/conversations-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/chats/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Increase proxy buffer size to allow keycloak to send large
|
||||
# header responses when a user is created.
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# The container user (see USER in the Dockerfile) is an un-privileged user that
|
||||
# does not exists and is not created during the build phase (see Dockerfile).
|
||||
# Hence, we use this entrypoint to wrap commands that will be run in the
|
||||
# container to create an entry for this user in the /etc/passwd file.
|
||||
#
|
||||
# The following environment variables may be passed to the container to
|
||||
# customize running user account:
|
||||
#
|
||||
# * USER_NAME: container user name (default: default)
|
||||
# * HOME : container user home directory (default: none)
|
||||
#
|
||||
# To pass environment variables, you can either use the -e option of the docker run command:
|
||||
#
|
||||
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' conversations:latest python manage.py migrate
|
||||
#
|
||||
# or define new variables in an environment file to use with docker or docker compose:
|
||||
#
|
||||
# # env.d/production
|
||||
# USER_NAME=foo
|
||||
# HOME=/home/foo
|
||||
#
|
||||
# docker run --rm --env-file env.d/production conversations:latest python manage.py migrate
|
||||
#
|
||||
|
||||
echo "🐳(entrypoint) creating user running in the container..."
|
||||
if ! whoami > /dev/null 2>&1; then
|
||||
if [ -w /etc/passwd ]; then
|
||||
echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🐳(entrypoint) running your command: ${*}"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Gunicorn-django settings
|
||||
bind = ["0.0.0.0:8000"]
|
||||
name = "conversations"
|
||||
python_path = "/app"
|
||||
|
||||
# Run
|
||||
graceful_timeout = 90
|
||||
timeout = 90
|
||||
workers = 3
|
||||
|
||||
# Logging
|
||||
# Using '-' for the access log file makes gunicorn log accesses to stdout
|
||||
accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
@@ -0,0 +1,14 @@
|
||||
## Architecture
|
||||
|
||||
### Global system architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User -- HTTP --> Front("Frontend (NextJS SPA)")
|
||||
Front -- REST API --> Back("Backend (Django)")
|
||||
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back --> Cache("Cache (Redis)")
|
||||
Back ----> S3("Minio (S3)")
|
||||
Back -- REST API --> LLM("LLM Providers")
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,400 @@
|
||||
# Conversation Attachments
|
||||
|
||||
This document describes how conversation attachments work in the Conversations application, including the upload process, security measures, and how documents are processed for use with Large Language Models (LLMs).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Supported Attachment Types](#supported-attachment-types)
|
||||
- [Architecture & Flow](#architecture--flow)
|
||||
- [High-Level Overview](#high-level-overview)
|
||||
- [Detailed Technical Flow](#detailed-technical-flow)
|
||||
- [Security & Validation](#security--validation)
|
||||
- [MIME Type Validation](#mime-type-validation)
|
||||
- [Malware Detection](#malware-detection)
|
||||
- [Document Processing for LLMs](#document-processing-for-llms)
|
||||
- [Image Attachments](#image-attachments)
|
||||
- [PDF Documents](#pdf-documents)
|
||||
- [Other Document Types](#other-document-types)
|
||||
- [Configuration](#configuration)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Conversations allows users to attach files to their conversations with the AI assistant. These attachments can be:
|
||||
- **Images** (displayed directly to vision-capable LLMs)
|
||||
- **PDF documents** (sent as document URLs to the LLM)
|
||||
- **Other documents** (converted to text and indexed for semantic search)
|
||||
|
||||
The attachment system uses **S3-compatible object storage** (such as MinIO in development) to store files securely.
|
||||
The backend generates **presigned URLs** that allow the frontend to upload files directly to the storage,
|
||||
without routing the file data through the backend server.
|
||||
|
||||
Note about documents: The system uses a tool called **MarkItDown** to convert various document formats
|
||||
(Word, Excel, PowerPoint, text files, etc.) into Markdown text for processing by LLMs. When at least
|
||||
one non-PDF/image document is attached, the system enables:
|
||||
- a **Retrieval-Augmented Generation (RAG)** search tool to allow the LLM to query relevant sections of the documents.
|
||||
- a **summarization tool** to provide document summaries on user request.
|
||||
⚠️ naive implementation at the moment, needs improvement before being used in production.
|
||||
|
||||
## Supported Attachment Types
|
||||
The following attachment types are supported:
|
||||
- **Images**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
|
||||
- **PDF documents**: `application/pdf`
|
||||
- **Other documents**:
|
||||
- Microsoft Word: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
- Microsoft Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- Microsoft PowerPoint: `application/vnd.openxmlformats-officedocument.presentationml.presentation`
|
||||
- Text files: `text/plain`, `text/markdown`, `text/csv`
|
||||
|
||||
**Warning**: The current implementation for PDF expects the LLM to be able to manage them. We need to
|
||||
improve the handling of PDFs in case the LLM cannot process them natively.
|
||||
|
||||
**Todo**:
|
||||
- Add support for more file types and improve document processing workflows.
|
||||
- Allow PDF management via RAG search when the LLM cannot handle them natively.
|
||||
- Allow file type restrictions based on model settings, instead of globally.
|
||||
- Improve the summarization tool to provide better summaries and handle larger documents.
|
||||
- Start file upload right away when the user selects a file, instead of waiting for the user to send the message.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Flow
|
||||
|
||||
### High-Level Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │ │ Backend │ │ S3 Storage │ │ Malware Det.│
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │ │
|
||||
│ 1. Create attachment│ │ │
|
||||
├────────────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ 2. Return presigned │ │ │
|
||||
│ URL for upload │ │ │
|
||||
│<────────────────────┤ │ │
|
||||
│ │ │ │
|
||||
│ 3. Upload file │ │ │
|
||||
│ directly to S3 │ │ │
|
||||
├──────────────────────────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ 4. Notify upload │ │ │
|
||||
│ completed │ │ │
|
||||
├────────────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ │ 5. Detect MIME type │ │
|
||||
│ ├────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ 6. Scan for malware │ │
|
||||
│ ├──────────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ 7. Update status │ │
|
||||
│ 8. Return status │<──────────────────────────────────────────┤
|
||||
│<────────────────────┤ │ │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
### Detailed Technical Flow
|
||||
|
||||
#### Step 1: Attachment Creation Request
|
||||
|
||||
When a user selects a file to upload, the frontend sends a POST request to create an attachment record:
|
||||
|
||||
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/`
|
||||
|
||||
**Request payload**:
|
||||
```json
|
||||
{
|
||||
"file_name": "document.pdf",
|
||||
"size": 1048576,
|
||||
"content_type": "application/pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**Backend processing** (`ChatConversationAttachmentViewSet.perform_create`):
|
||||
1. Verifies the user owns the conversation
|
||||
2. Generates a unique UUID for the file
|
||||
3. Creates a storage key: `{conversation_id}/attachments/{uuid}.{extension}`
|
||||
4. Creates a database record with status `PENDING`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid-of-attachment",
|
||||
"key": "conversation-id/attachments/file-id.pdf",
|
||||
"file_name": "document.pdf",
|
||||
"size": 1048576,
|
||||
"upload_state": "pending",
|
||||
"policy": "https://s3.example.com/bucket/...?presigned-params"
|
||||
}
|
||||
```
|
||||
|
||||
The `policy` field contains a **presigned URL** valid for a limited time (configured by `AWS_S3_UPLOAD_POLICY_EXPIRATION`).
|
||||
|
||||
#### Step 2: Direct Upload to S3
|
||||
|
||||
The frontend uses the presigned URL to upload the file directly to S3 storage using a PUT request.
|
||||
|
||||
**Technical details**:
|
||||
- The presigned URL includes authentication parameters
|
||||
- The upload is done with `Content-Type` header matching the file's MIME type
|
||||
- No backend involvement in the data transfer
|
||||
|
||||
#### Step 3: Upload Completion Notification
|
||||
|
||||
After successful upload, the frontend notifies the backend:
|
||||
|
||||
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/{attachment_id}/upload-ended/`
|
||||
|
||||
**Backend processing** (`ChatConversationAttachmentViewSet.upload_ended`):
|
||||
|
||||
1. **MIME Type Detection** (`chat/views.py`):
|
||||
```python
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
with default_storage.open(attachment.key, "rb") as file:
|
||||
mimetype = mime_detector.from_buffer(file.read(2048))
|
||||
size = file.size
|
||||
```
|
||||
|
||||
Uses `python-magic` to detect the actual MIME type from file content (first 2048 bytes).
|
||||
|
||||
2. **Update attachment status**:
|
||||
- Status: `PENDING` → `ANALYZING`
|
||||
- Store detected MIME type and actual file size
|
||||
|
||||
3. **Trigger Malware Detection**:
|
||||
```python
|
||||
malware_detection.analyse_file(
|
||||
attachment.key,
|
||||
safe_callback="chat.malware_detection.conversation_safe_attachment_callback",
|
||||
unknown_callback="chat.malware_detection.unknown_attachment_callback",
|
||||
unsafe_callback="chat.malware_detection.conversation_unsafe_attachment_callback",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
```
|
||||
|
||||
#### Step 4: Malware Detection Callbacks
|
||||
|
||||
The malware detection service (configurable via `MALWARE_DETECTION_BACKEND`) scans the file and calls one of three callbacks:
|
||||
|
||||
**Safe file** (`conversation_safe_attachment_callback`):
|
||||
- Status: `ANALYZING` → `READY`
|
||||
- File is ready for use
|
||||
|
||||
**Unsafe file** (`conversation_unsafe_attachment_callback`):
|
||||
- Status: `ANALYZING` → `SUSPICIOUS`
|
||||
- File is quarantined and not accessible
|
||||
- Security log entry created
|
||||
|
||||
**Unknown status** (`unknown_attachment_callback`):
|
||||
- Handles special cases (e.g., file too large to analyze)
|
||||
- Status: `ANALYZING` → `FILE_TOO_LARGE_TO_ANALYZE`
|
||||
|
||||
---
|
||||
|
||||
## Security & Validation
|
||||
|
||||
For now, the system is not intended to host user-uploaded files for public download.
|
||||
All files are stored in private S3 buckets with presigned URLs for controlled access and only
|
||||
the owner of the conversation/the uploader can access them, so the risk is quite low around bad use of
|
||||
the attachment system.
|
||||
|
||||
Also, the document content is sent to the LLM and does not prevent any prompt injection attacks, which is not
|
||||
an issue specific to the attachment system but to the overall design of LLM-based applications and should be
|
||||
addressed globally. Also for the moment, the system does not have any action tools that could be used to execute
|
||||
malicious code based on document content.
|
||||
|
||||
### Malware Detection
|
||||
|
||||
The malware detection system is **pluggable** and configurable, allowing different backends to be used.
|
||||
By default, a `DummyBackend` is provided that marks all files as safe.
|
||||
|
||||
⚠️ The current implementation does not disallow any file types or status from being used in conversations.
|
||||
This is a potential security risk and should be addressed in future versions.
|
||||
|
||||
---
|
||||
|
||||
## Document Processing for LLMs
|
||||
|
||||
When a user sends a message with attachments, the system processes them differently based on their type:
|
||||
|
||||
### Image Attachments
|
||||
|
||||
**MIME types**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, etc.
|
||||
|
||||
**Processing flow**:
|
||||
|
||||
1. **URL Conversion**: Local media URLs are converted to presigned S3 URLs before sending to the LLM:
|
||||
```python
|
||||
# From: chat/agents/local_media_url_processors.py
|
||||
content.url = generate_retrieve_policy(key)
|
||||
```
|
||||
|
||||
2. **Sent to LLM**: Images are sent as `ImageUrl` objects in the prompt:
|
||||
```python
|
||||
ImageUrl(
|
||||
url="https://s3.example.com/bucket/key?presigned-params",
|
||||
identifier="file-id.png",
|
||||
)
|
||||
```
|
||||
|
||||
3. **Vision models** can analyze the image content directly.
|
||||
|
||||
4. **Response processing**: After the LLM responds, presigned URLs are converted back to local URLs for storage:
|
||||
```python
|
||||
# Mapping: presigned_url -> /media-key/{conversation_id}/attachments/{file_id}.png
|
||||
```
|
||||
|
||||
### PDF Documents
|
||||
|
||||
**MIME type**: `application/pdf`
|
||||
|
||||
**Processing flow**:
|
||||
|
||||
1. **Direct URL passing**: PDFs are sent as `DocumentUrl` objects :
|
||||
```python
|
||||
DocumentUrl(
|
||||
url="https://s3.example.com/bucket/key?presigned-params",
|
||||
identifier="file-id.pdf",
|
||||
)
|
||||
```
|
||||
|
||||
2. **LLM processing**: Compatible LLMs can:
|
||||
- Extract and read text from PDFs
|
||||
- Understand document structure
|
||||
- Answer questions about the content
|
||||
|
||||
3. **No conversion needed**: PDFs are passed directly without preprocessing.
|
||||
|
||||
### Other Document Types
|
||||
|
||||
**MIME types**: Word documents, Excel spreadsheets, PowerPoint, text files, Markdown, etc.
|
||||
|
||||
**Processing flow**:
|
||||
|
||||
1. **Document parsing**: When a document is uploaded, it's parsed using the `AlbertRagBackend` class.
|
||||
|
||||
2. **Conversion to Markdown**: Documents are converted using **MarkItDown** library or using the "Albert API" for PDFs.
|
||||
|
||||
3. **RAG (Retrieval-Augmented Generation)**:
|
||||
- Converted text is indexed in a vector database
|
||||
- The LLM uses a `document_rag_search` tool to query relevant sections
|
||||
- Only relevant chunks are sent to the LLM to fit context windows
|
||||
|
||||
4. **Summarization tool** if needed.
|
||||
|
||||
### Processing Strategy Decision Tree
|
||||
|
||||
**Decision logic**:
|
||||
- **No documents**: Standard conversation
|
||||
- **Images**: Send as direct (presigned) URLs to the LLM
|
||||
- **Only PDFs**: Send as direct (presigned) URLs to the LLM
|
||||
- **Other documents present**: Enable RAG search tool + convert to Markdown
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------------------------------------------|----------------|------------------------------------------------------------|
|
||||
| `ATTACHMENT_MAX_SIZE` | Configurable | Maximum file size in bytes |
|
||||
| `ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED` | `True` | Enable/disable MIME type validation |
|
||||
| `AWS_S3_UPLOAD_POLICY_EXPIRATION` | 3600 | Presigned URL expiration (seconds) |
|
||||
| `AWS_S3_RETRIEVE_POLICY_EXPIRATION` | 3600 | Presigned retrieval URL expiration (seconds) |
|
||||
| `AWS_S3_DOMAIN_REPLACE` | None | Alternative S3 domain for presigned URLs (for development) |
|
||||
| `MALWARE_DETECTION_BACKEND` | `DummyBackend` | Malware scanning backend class |
|
||||
| `MALWARE_DETECTION_PARAMETERS` | `{}` | Backend-specific configuration |
|
||||
| `RAG_FILES_ACCEPTED_FORMATS` | See below | List of MIME types accepted for file uploads |
|
||||
|
||||
#### RAG_FILES_ACCEPTED_FORMATS
|
||||
|
||||
This environment variable controls which file types users are allowed to upload as attachments to conversations.
|
||||
|
||||
**Configuration**:
|
||||
- **Type**: List of strings (comma-separated MIME types when using environment variable)
|
||||
- **Default value**: Includes a comprehensive list of document and image formats:
|
||||
- Microsoft Office documents (`.docx`, `.pptx`, `.xlsx`, `.xls`)
|
||||
- Text files (`.txt`, `.csv`)
|
||||
- PDF documents (`.pdf`)
|
||||
- HTML files
|
||||
- Markdown files (`.md`)
|
||||
- Outlook messages (`.msg`)
|
||||
- Images (`.jpeg`, `.png`, `.gif`, `.webp`)
|
||||
|
||||
**Example configuration**:
|
||||
```ini
|
||||
# In environment variable (comma-separated)
|
||||
RAG_FILES_ACCEPTED_FORMATS="application/pdf,text/plain,image/png,image/jpeg"
|
||||
```
|
||||
|
||||
```python
|
||||
# In Django settings (as a Python list)
|
||||
RAG_FILES_ACCEPTED_FORMATS = [
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
]
|
||||
```
|
||||
|
||||
**How it's used**:
|
||||
1. **Backend**: The list is exposed via the `/api/v1.0/config/` endpoint as `chat_upload_accept` (MIME types joined with commas)
|
||||
2. **Frontend**: The configuration is used to validate files before upload in the chat interface:
|
||||
- Checks exact MIME type matches
|
||||
- Supports wildcard patterns (e.g., `image/*` for all image types)
|
||||
- Supports file extension patterns (e.g., `.pdf`)
|
||||
3. **User experience**: Files that don't match the accepted formats are rejected with a user-friendly error message
|
||||
|
||||
**Notes**:
|
||||
|
||||
- This setting controls frontend validation only. Backend validation should also be implemented for security.
|
||||
- Future improvements may include per-model file type restrictions.
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
**MinIO (Development)**:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
minio:
|
||||
image: minio/minio
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### LLM Cannot Access Image/PDF
|
||||
|
||||
**Possible causes**:
|
||||
- Presigned URL has expired
|
||||
- S3 storage is not accessible from the LLM provider
|
||||
- CORS configuration issues
|
||||
|
||||
**Solution**: Check `AWS_S3_RETRIEVE_POLICY_EXPIRATION` and S3 access policies.
|
||||
|
||||
### Document Not Appearing in RAG Search
|
||||
|
||||
**Possible causes**:
|
||||
- Document conversion failed
|
||||
- Vector database indexing failed
|
||||
|
||||
**Check logs**: Look for errors in `DocumentConverter` and RAG backend logs.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Installation Guide](installation.md) - S3 storage setup
|
||||
- [LLM Configuration](llm-configuration.md) - Model capabilities for attachments
|
||||
- [Architecture](architecture.md) - System overview
|
||||
- [Tools](tools.md) - Document search and RAG tools
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Conversations variables
|
||||
|
||||
Here we describe all environment variables that can be set for the conversations application.
|
||||
|
||||
## conversations-backend container
|
||||
|
||||
These are the environment variables you can set for the `conversations-backend` container.
|
||||
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------|
|
||||
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
|
||||
| DJANGO_SECRET_KEY | secret key | |
|
||||
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | name of the database | conversations |
|
||||
| DB_USER | user to authenticate with | dinum |
|
||||
| DB_PASSWORD | password to authenticate with | pass |
|
||||
| DB_HOST | host of the database | localhost |
|
||||
| DB_PORT | port of the database | 5432 |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
|
||||
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
|
||||
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | conversations-media-storage |
|
||||
| ATTACHMENT_MAX_SIZE | maximum size of document in bytes | 10485760 |
|
||||
| LANGUAGE_CODE | default language | en-us |
|
||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
|
||||
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
|
||||
| DJANGO_EMAIL_HOST | host name of email | |
|
||||
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | port used to connect to email host | |
|
||||
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
|
||||
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
|
||||
| SENTRY_DSN | sentry host | |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | frontend theme to use | |
|
||||
| POSTHOG_KEY | posthog key for analytics | |
|
||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
||||
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| OIDC_CREATE_USER | create used on OIDC | false |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | client id used for OIDC | conversations |
|
||||
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
|
||||
| LOGOUT_REDIRECT_URL | logout redirect url | |
|
||||
| OIDC_USE_NONCE | use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||
| LLM_CONFIGURATION_FILE_PATH | Path to the LLM configuration JSON file. See [LLM Configuration](llm-configuration.md) for details | <BASE_DIR>/conversations/configuration/llm/default.json |
|
||||
| LLM_DEFAULT_MODEL_HRID | HRID of the model used for conversations | default-model |
|
||||
| LLM_SUMMARIZATION_MODEL_HRID | HRID of the model used for summarization | default-summarization-model |
|
||||
| AI_API_KEY | AI API key to be used for the default provider (used in default LLM configuration, not for production use) | |
|
||||
| AI_BASE_URL | OpenAI compatible AI base URL (used in default LLM configuration, not for production use) | |
|
||||
| AI_MODEL | AI Model name to use (used in default LLM configuration, not for production use) | |
|
||||
| AI_AGENT_INSTRUCTIONS | Base instruction for the AI agent (used in default LLM configuration, not for production use) | You are a helpful assistant. Wrap formulas... |
|
||||
| AI_AGENT_TOOLS | List of enabled tools for the agent (used in default LLM configuration, not for production use) | [] |
|
||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
|
||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||
| REDIS_URL | cache url | redis://redis:6379/1 |
|
||||
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | conversations |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/conversations/configuration/theme/default.json | BASE_DIR/conversations/configuration/theme/default.json |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| FIND_API_KEY | API key of Find | |
|
||||
| FIND_API_URL | URL of Find | `https://app-find/api` |
|
||||
| FIND_API_TIMEOUT | Find API timeout | 30 |
|
||||
|
||||
|
||||
## conversations-frontend image
|
||||
|
||||
These are the environment variables you can set to build the `conversations-frontend` image.
|
||||
|
||||
Depending on how you are building the front-end application, this variable is used in different ways.
|
||||
|
||||
If you want to build the Docker image, this variable is used as an argument in the build command.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg API_ORIGIN=https://mybackend.example.com conversations-frontend:latest
|
||||
```
|
||||
|
||||
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/conversations/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_`.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
cd src/frontend/apps/conversations
|
||||
NODE_ENV=production NEXT_PUBLIC_API_ORIGIN=https://mybackend.example.com yarn build
|
||||
```
|
||||
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|------------------------------------------------------------------------------------| ------------------------------------------------------- |
|
||||
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
|
||||
| PRODUCT_NAME | to change the default product name displayed in frontend | Conversations |
|
||||
@@ -0,0 +1,133 @@
|
||||
image:
|
||||
repository: lasuite/conversations-backend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
backend:
|
||||
replicas: 1
|
||||
envVars:
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: https://conversations.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Feature
|
||||
DJANGO_ALLOWED_HOSTS: conversations.127.0.0.1.nip.io
|
||||
DJANGO_SECRET_KEY: AgoodOrAbadKey
|
||||
DJANGO_SETTINGS_MODULE: conversations.settings
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST: "maildev"
|
||||
DJANGO_EMAIL_LOGO_IMG: https://conversations.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP: INFO
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: conversations
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_VERIFY_SSL: False
|
||||
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://conversations.127.0.0.1.nip.io
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||
LOGIN_REDIRECT_URL: https://conversations.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://conversations.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://conversations.127.0.0.1.nip.io
|
||||
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.conversations.127.0.0.1.nip.io'}"
|
||||
DB_HOST: postgresql
|
||||
DB_NAME: conversations
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: conversations
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
AWS_S3_ENDPOINT_URL: http://minio.conversations.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: root
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: conversations-media-storage
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
|
||||
migrate:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/conversations.py"
|
||||
- "conversations.wsgi:application"
|
||||
- "--reload"
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
frontend:
|
||||
envVars:
|
||||
PORT: 8080
|
||||
NEXT_PUBLIC_API_ORIGIN: https://conversations.127.0.0.1.nip.io
|
||||
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: lasuite/conversations-frontend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: conversations.127.0.0.1.nip.io
|
||||
|
||||
ingressAdmin:
|
||||
enabled: true
|
||||
host: conversations.127.0.0.1.nip.io
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: conversations.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://conversations.127.0.0.1.nip.io/api/v1.0/chats/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.conversations.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /conversations-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.conversations.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
auth:
|
||||
rootUser: root
|
||||
rootPassword: password
|
||||
provisioning:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: conversations-media-storage
|
||||
versioning: true
|
||||
@@ -0,0 +1,7 @@
|
||||
auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: conversations
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
@@ -0,0 +1,4 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# File Upload Modes
|
||||
|
||||
This document describes the different modes for handling file uploads in the Conversations application, and how to configure and use them.
|
||||
|
||||
## Overview
|
||||
|
||||
The application supports two independent configuration points:
|
||||
|
||||
1. **`FILE_UPLOAD_MODE`**: how the frontend uploads files (frontend → storage/backend)
|
||||
2. **`FILE_TO_LLM_MODE`**: how the backend provides files to the LLM (backend → LLM)
|
||||
|
||||
Each mode has different trade-offs in terms of security, performance, and LLM accessibility. The two settings can be combined based on your network constraints.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Frontend upload mode (`FILE_UPLOAD_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL upload (backward compatible)
|
||||
FILE_UPLOAD_MODE=presigned_url
|
||||
|
||||
# Frontend uploads directly to backend
|
||||
FILE_UPLOAD_MODE=backend_to_s3
|
||||
```
|
||||
|
||||
### Backend delivery mode (`FILE_TO_LLM_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL mode (backward compatible)
|
||||
FILE_TO_LLM_MODE=presigned_url
|
||||
|
||||
# Backend provides base64-encoded data URLs
|
||||
FILE_TO_LLM_MODE=backend_base64
|
||||
|
||||
# Backend provides temporary URLs through the backend
|
||||
FILE_TO_LLM_MODE=backend_temporary_url
|
||||
```
|
||||
|
||||
Additional settings for backend temporary URL mode:
|
||||
|
||||
```bash
|
||||
# Base URL to reach backend
|
||||
FILE_BACKEND_URL="http://localhost:8071"
|
||||
|
||||
# Expiration time for temporary URLs (in seconds, default: 180 = 3 minutes)
|
||||
FILE_BACKEND_TEMPORARY_URL_EXPIRATION=180
|
||||
```
|
||||
|
||||
## Mode Details
|
||||
|
||||
### 1. Presigned URL Mode (Default)
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=presigned_url`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=presigned_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend requests a presigned URL from the backend
|
||||
- Frontend uploads the file directly to S3 using the presigned URL
|
||||
- Frontend notifies the backend when upload is complete
|
||||
- Backend initiates malware detection
|
||||
- Backend returns presigned S3 URLs to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- Files don't pass through the backend server (lower bandwidth usage)
|
||||
- Faster uploads for large files (direct to S3)
|
||||
- S3 handles the upload, no backend load
|
||||
- Backward compatible with existing frontend implementations
|
||||
|
||||
**Disadvantages:**
|
||||
- S3 bucket must be accessible from the frontend
|
||||
- Presigned URLs can be leaked if not handled carefully
|
||||
- Frontend needs to handle S3 credentials/configuration
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
- Documents: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When frontend has direct access to S3
|
||||
- When you want to minimize backend load
|
||||
- When S3 is publicly accessible or accessible via VPN
|
||||
|
||||
|
||||
### 2. Backend Base64 Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_base64`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend reads the file, encodes it as base64, and creates a data URL
|
||||
- LLM receives the file as a base64-encoded data URL
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend)
|
||||
- Files always go through the backend for validation
|
||||
- No presigned URLs to manage
|
||||
- Better control over file access
|
||||
- Data URLs work with all LLMs that support file content
|
||||
|
||||
**Disadvantages:**
|
||||
- Backend memory usage increases (entire file loaded for base64 encoding)
|
||||
- Slower for very large files (encoding overhead)
|
||||
- Increased bandwidth on backend
|
||||
- Data URLs can be very large in responses
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Base64-encoded data URLs (format: `data:image/png;base64,...`)
|
||||
- Documents: Base64-encoded data URLs (format: `data:application/pdf;base64,...`)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend
|
||||
- When you want all file uploads to go through the backend
|
||||
- When the LLM supports base64-encoded data URLs
|
||||
- For smaller files (< 50MB)
|
||||
|
||||
|
||||
### 3. Backend Temporary URL Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_temporary_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend generates a secure temporary access token stored in cache (TTL: 3 minutes by default)
|
||||
- Backend returns a temporary URL pointing to the backend's file-stream endpoint
|
||||
- LLM receives the temporary URL and accesses the file through the backend
|
||||
- Backend validates the token and streams the file content from S3 to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend or LLM directly)
|
||||
- Files always go through the backend for validation and access control
|
||||
- LLM doesn't need direct access to S3
|
||||
- Tokens expire quickly (better security than long-lived presigned URLs)
|
||||
- No large data URL strings in memory or responses
|
||||
- Lower backend memory usage than base64 mode
|
||||
- Centralized file access control through the backend
|
||||
- Good balance between security and performance
|
||||
|
||||
**Disadvantages:**
|
||||
- LLM must be able to access the backend server
|
||||
- File streaming goes through the backend (adds some latency)
|
||||
- Time-limited access (token expires)
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
- Documents: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend or LLM
|
||||
- When you want backend control over uploads and file access
|
||||
- When you want time-limited access to files with centralized control
|
||||
- When you want the LLM to access files through the backend gateway
|
||||
- For large files (backend streams directly from S3 without loading entirely into memory)
|
||||
@@ -0,0 +1,228 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Conversations on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we provide an example)
|
||||
- a Redis server (if you don't have one, we provide an example)
|
||||
- a S3 bucket (if you don't have one, we provide an example)
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you need to install:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
|
||||
```
|
||||
./bin/start-kind.sh
|
||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||
Dload Upload Total Spent Left Speed
|
||||
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 24 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
2. Create kind cluster with containerd registry config dir enabled
|
||||
Creating cluster "suite" ...
|
||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-suite"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-suite
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/conversations created
|
||||
Context "kind-suite" modified.
|
||||
secret/mkcert created
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
|
||||
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
||||
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
||||
```
|
||||
|
||||
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What do you use to authenticate your users?
|
||||
|
||||
Conversations uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Conversations) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
```
|
||||
$ kubectl create namespace conversations
|
||||
$ kubectl config set-context --current --namespace=conversations
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/conversations/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: conversations
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connection values
|
||||
|
||||
Conversations needs a redis so we start by deploying one:
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
|
||||
### Find postgresql connection values
|
||||
|
||||
Conversations uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 28m
|
||||
keycloak-postgresql-0 1/1 Running 0 28m
|
||||
postgresql-0 1/1 Running 0 14m
|
||||
redis-master-0 1/1 Running 0 42s
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: conversations
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: conversations
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
### Find s3 bucket connection values
|
||||
|
||||
Conversations uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
||||
|
||||
```
|
||||
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 38m
|
||||
keycloak-postgresql-0 1/1 Running 0 38m
|
||||
minio-84f5c66895-bbhsk 1/1 Running 0 42s
|
||||
minio-provisioning-2b5sq 0/1 Completed 0 42s
|
||||
postgresql-0 1/1 Running 0 24m
|
||||
redis-master-0 1/1 Running 0 10m
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Conversations without AI. AI requires more dependencies (OpenAI API). To deploy Conversations you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add conversations https://suitenumerique.github.io/conversations/
|
||||
$ helm repo update
|
||||
$ helm install conversations conversations/conversations -f examples/conversations.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
conversations-conversations-backend-96558758d-xtkbp 0/1 Running 0 79s
|
||||
conversations-conversations-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
|
||||
conversations-conversations-backend-migrate-c949s 0/1 Completed 0 79s
|
||||
conversations-conversations-frontend-6749f644f7-p5s42 1/1 Running 0 79s
|
||||
keycloak-0 1/1 Running 0 48m
|
||||
keycloak-postgresql-0 1/1 Running 0 48m
|
||||
minio-84f5c66895-bbhsk 1/1 Running 0 10m
|
||||
minio-provisioning-2b5sq 0/1 Completed 0 10m
|
||||
postgresql-0 1/1 Running 0 34m
|
||||
redis-master-0 1/1 Running 0 20m
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
conversations-conversations <none> conversations.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
conversations-conversations-admin <none> conversations.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
conversations-conversations-media <none> conversations.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
conversations-conversations-ws <none> conversations.127.0.0.1.nip.io localhost 80, 443 114s
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
||||
```
|
||||
|
||||
You can use Conversations at https://conversations.127.0.0.1.nip.io. The provisionning user in keycloak is conversations/conversations.
|
||||
@@ -0,0 +1,412 @@
|
||||
# LLM Configuration
|
||||
|
||||
This document describes how to configure Large Language Models (LLMs) in Conversations via the configuration file.
|
||||
|
||||
## Overview
|
||||
|
||||
Conversations uses a JSON configuration file to define LLM models and providers. This approach allows you to:
|
||||
- Configure multiple LLM models from different providers
|
||||
- Switch between models without code changes
|
||||
- Customize model-specific settings like temperature, max tokens, and system prompts
|
||||
- Enable or disable models dynamically
|
||||
|
||||
The overall structure consists of two main sections: `providers` and `models`.
|
||||
Settings for models, provides customization through `settings` and `profile`, which corresponds to the
|
||||
Pydantic AI model settings and profile. While we currently not use those settings extensively,
|
||||
they are available for future use and advanced configurations, please reach us if you face any problem using them.
|
||||
|
||||
## Configuration File Location
|
||||
|
||||
The default LLM configuration file is located at:
|
||||
```
|
||||
src/backend/conversations/configuration/llm/default.json
|
||||
```
|
||||
|
||||
You can override this location by setting the `LLM_CONFIGURATION_FILE_PATH` environment variable, but be careful as
|
||||
this path must be accessible by the backend application _inside the docker image_:
|
||||
``` ini
|
||||
LLM_CONFIGURATION_FILE_PATH=/path/to/your/llm/config.json
|
||||
```
|
||||
|
||||
## Default Behavior
|
||||
|
||||
### Default Configuration
|
||||
|
||||
The default configuration file is useful for local development and running the test, while it can be used
|
||||
in production, we suggest to create a specific one for production and replace the `settings.` values with
|
||||
`environ.` one.
|
||||
|
||||
The default configuration file (`default.json`) includes:
|
||||
|
||||
1. **Two default models**:
|
||||
- `default-model`: The primary conversational model used for chat interactions
|
||||
- `default-summarization-model`: A specialized model for summarizing conversations
|
||||
|
||||
2. **One default provider**:
|
||||
- `default-provider`: An OpenAI-compatible provider that uses environment variables for configuration
|
||||
|
||||
### Environment Variable Integration
|
||||
|
||||
The configuration uses dynamic value resolution with two special prefixes:
|
||||
|
||||
- `settings.VARIABLE_NAME`: Resolves to a Django setting value
|
||||
- `environ.VARIABLE_NAME`: Resolves to an environment variable value
|
||||
|
||||
For example, in the default configuration:
|
||||
```json
|
||||
{
|
||||
"model_name": "settings.AI_MODEL",
|
||||
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
|
||||
"tools": "settings.AI_AGENT_TOOLS"
|
||||
}
|
||||
```
|
||||
|
||||
This allows to configure models in tests using the setting override mechanism from Django/Pytest (but might be replaced
|
||||
later with a simple override of the full configuration like it's done in some tests already).
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
For the default configuration to work, you need to set these environment variables:
|
||||
|
||||
| Variable | Description | Example |
|
||||
|-------------------------------|----------------------------------------|-----------------------------|
|
||||
| `AI_API_KEY` | API key for the default provider | `sk-...` |
|
||||
| `AI_BASE_URL` | Base URL for the OpenAI-compatible API | `https://api.openai.com/v1` |
|
||||
| `AI_MODEL` | Model name to use | `gpt-4o-mini` |
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
If you want to customize the agent behavior and tools, you can set these optional environment variables
|
||||
(defaults are provided in the default configuration):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-------------------------------|----------------------------------------|-------------------|
|
||||
| `AI_AGENT_INSTRUCTIONS` | System prompt for the agent | see `settings.py` |
|
||||
| `AI_AGENT_TOOLS` | List of enabled tools | `[]` |
|
||||
| `SUMMARIZATION_SYSTEM_PROMPT` | Base prompt of the summarization agent | see `settings.py` |
|
||||
|
||||
### Model Selection
|
||||
|
||||
You can configure which models are used for specific tasks via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|--------------------------------|------------------------------------------|-------------------------------|
|
||||
| `LLM_DEFAULT_MODEL_HRID` | HRID of the model used for conversations | `default-model` |
|
||||
| `LLM_SUMMARIZATION_MODEL_HRID` | HRID of the model used for summarization | `default-summarization-model` |
|
||||
|
||||
## Configuration Structure
|
||||
|
||||
The configuration file has two main sections:
|
||||
|
||||
### 1. Providers
|
||||
|
||||
Providers define the API endpoints and authentication for LLM services.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hrid": "unique-provider-id",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": "environ.API_KEY_VAR",
|
||||
"kind": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Provider Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|---------------------------------------------------------|
|
||||
| `hrid` | string | Yes | Unique identifier for the provider |
|
||||
| `base_url` | string | Yes | API base URL (can use `settings.` or `environ.` prefix) |
|
||||
| `api_key` | string | Yes | API authentication key (use `environ.` here) |
|
||||
| `kind` | string | Yes | Provider type: `openai` or `mistral` |
|
||||
|
||||
### 2. Models
|
||||
|
||||
Models define the LLMs available in your application.
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "unique-model-id",
|
||||
"model_name": "gpt-4o-mini",
|
||||
"human_readable_name": "GPT-4o Mini",
|
||||
"provider_name": "unique-provider-id",
|
||||
"profile": null,
|
||||
"settings": {},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "You are a helpful assistant",
|
||||
"tools": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Model Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------------|--------------|----------|-----------------------------------------------------------------------------------------------------|
|
||||
| `hrid` | string | Yes | Unique identifier for the model |
|
||||
| `model_name` | string | Yes | Name of the model as recognized by the provider (can use `settings.` or `environ.` prefix) |
|
||||
| `human_readable_name` | string | Yes | Display name shown to users |
|
||||
| `provider_name` | string | No* | Reference to a provider's `hrid` |
|
||||
| `provider` | object | No* | Inline provider definition (alternative to `provider_name`) |
|
||||
| `profile` | object | No | Model-specific capabilities and settings |
|
||||
| `settings` | object | No | Model inference settings (temperature, max_tokens, etc.) |
|
||||
| `is_active` | boolean | Yes | Whether the model is available for use |
|
||||
| `icon` | string/array | No | Base64-encoded icon or array of icon parts |
|
||||
| `system_prompt` | string | Yes | Default system prompt for the model (can use `settings.` or `environ.` prefix) |
|
||||
| `tools` | array | Yes | List of enabled tools for this model (can use `settings.` or `environ.` prefix for the whole array) |
|
||||
| `supports_streaming` | boolean | No | Whether the model supports streaming responses |
|
||||
|
||||
\* Either `provider_name` or `provider` must be set, unless `model_name` is in the format `<provider>:<model>`.
|
||||
|
||||
## Adding New Models
|
||||
|
||||
### Example 1: Adding a New OpenAI Model
|
||||
|
||||
To add a new OpenAI model using the existing default provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
// ...existing models...
|
||||
{
|
||||
"hrid": "gpt-4-turbo",
|
||||
"model_name": "gpt-4-turbo-preview",
|
||||
"human_readable_name": "GPT-4 Turbo",
|
||||
"provider_name": "default-provider",
|
||||
"profile": null,
|
||||
"settings": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "You are an expert AI assistant.",
|
||||
"tools": ["web_search_brave_with_document_backend"],
|
||||
"supports_streaming": true
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
// ...existing providers...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Adding a Model using Pydantic AI format
|
||||
|
||||
To add a model with a specific provider using the default Pydantic AI format, you don't need to define the provider separately if you use the `model_name` format `<provider>:<model>`.
|
||||
|
||||
1. **Add the model without provider**:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "claude-3-opus",
|
||||
"model_name": "anthropic:claude-3-opus-20240229",
|
||||
"human_readable_name": "Claude 3 Opus",
|
||||
"provider_name": null,
|
||||
"profile": null,
|
||||
"settings": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "You are Claude, a helpful AI assistant.",
|
||||
"tools": []
|
||||
}
|
||||
],
|
||||
"providers": []
|
||||
}
|
||||
```
|
||||
|
||||
2**Set the environment variable**:
|
||||
|
||||
Pydantic AI expects the API key in an environment variable named `ANTHROPIC_API_KEY` is this example, so set it accordingly:
|
||||
|
||||
```ini
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
### Example 3: Adding a Mistral Model
|
||||
|
||||
For Mistral AI models using the Etalab platform:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "mistral-medium",
|
||||
"model_name": "mistral-medium-2508",
|
||||
"human_readable_name": "Mistral Medium (Etalab)",
|
||||
"provider_name": "mistral-etalab",
|
||||
"profile": null,
|
||||
"settings": {
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 8192
|
||||
},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
|
||||
"tools": ["web_search_brave_with_document_backend"]
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"hrid": "mistral-etalab",
|
||||
"base_url": "https://api.mistral.etalab.gouv.fr/",
|
||||
"api_key": "environ.MISTRAL_ETALAB_API_KEY",
|
||||
"kind": "mistral"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Using Inline Provider Definition
|
||||
|
||||
Instead of referencing a provider by name, you can define it inline if you use a unique configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "custom-model",
|
||||
"model_name": "custom-model-v1",
|
||||
"human_readable_name": "Custom Model",
|
||||
"provider": {
|
||||
"hrid": "custom-provider-inline",
|
||||
"base_url": "https://custom-api.example.com/v1",
|
||||
"api_key": "environ.CUSTOM_API_KEY",
|
||||
"kind": "openai"
|
||||
},
|
||||
"settings": {},
|
||||
"is_active": true,
|
||||
"icon": null,
|
||||
"system_prompt": "You are a custom assistant.",
|
||||
"tools": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Model Settings
|
||||
|
||||
The `settings` object supports various inference parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"timeout": 60.0,
|
||||
"parallel_tool_calls": true,
|
||||
"seed": 42,
|
||||
"presence_penalty": 0.0,
|
||||
"frequency_penalty": 0.0,
|
||||
"logit_bias": {},
|
||||
"stop_sequences": [],
|
||||
"extra_headers": {},
|
||||
"extra_body": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Model Profile
|
||||
|
||||
The `profile` object defines model capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
"supports_tools": true,
|
||||
"supports_json_schema_output": true,
|
||||
"supports_json_object_output": true,
|
||||
"default_structured_output_mode": "json_schema",
|
||||
"thinking_tags": ["<thinking>", "</thinking>"],
|
||||
"ignore_streamed_leading_whitespace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
|
||||
Tools can be specified in the `tools` array. Common tools include:
|
||||
- `web_search_brave_with_document_backend`: Web search using Brave API with document processing
|
||||
|
||||
You can also reference the tools list from Django settings:
|
||||
```json
|
||||
{
|
||||
"tools": "settings.AI_AGENT_TOOLS"
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Icons
|
||||
|
||||
Icons can be provided as base64-encoded PNG images. For long strings, you can split them into an array:
|
||||
|
||||
```json
|
||||
{
|
||||
"icon": [
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+m",
|
||||
"AAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAziABUAGIWbpsTwq7HhAAAA"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The configuration is validated when loaded. Common validation errors include:
|
||||
|
||||
- **Provider not found**: A model references a `provider_name` that doesn't exist in the `providers` array
|
||||
- **Missing provider**: Neither `provider_name` nor `provider` is specified, and `model_name` is not in `<provider>:<model>` format
|
||||
- **Environment variable not set**: A value using `environ.` prefix references an undefined environment variable
|
||||
- **Django setting not set**: A value using `settings.` prefix references an undefined Django setting
|
||||
- **Invalid provider kind**: The `kind` field must be either `openai` or `mistral`
|
||||
|
||||
## Testing Your Configuration
|
||||
|
||||
After modifying the configuration file, you can test it by:
|
||||
|
||||
1. **Checking for syntax errors**:
|
||||
```bash
|
||||
python -m json.tool src/backend/conversations/configuration/llm/default.json
|
||||
```
|
||||
|
||||
2. **Starting the application** and checking the logs for validation errors
|
||||
|
||||
3. **Using the Django shell** to load the configuration:
|
||||
```bash
|
||||
./bin/manage shell
|
||||
```
|
||||
```python
|
||||
from django.conf import settings
|
||||
models = settings.LLM_CONFIGURATIONS
|
||||
models.keys() # Should show all model HRIDs
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use environment variables** for sensitive data like API keys (with `environ.` prefix)
|
||||
2. **Use Django settings** for configurable values that may change between environments (with `settings.` prefix)
|
||||
3. **Keep provider definitions separate** from models to avoid duplication when using multiple models from the same provider
|
||||
4. **Set `is_active: false`** for models you want to keep in the configuration but temporarily disable
|
||||
5. **Use descriptive `hrid` values** that clearly identify the model and provider
|
||||
6. **Document custom configurations** in your deployment documentation
|
||||
7. **Test configuration changes** in a development environment before deploying to production
|
||||
|
||||
## See Also
|
||||
|
||||
- [Environment Variables Documentation](env.md) - For configuring environment variables
|
||||
- [Installation Guide](installation.md) - For deployment instructions
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/conversations-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
|
||||
...
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: lasuite/conversations-frontend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1"
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
```text
|
||||
🔖(minor) bump release to 4.18.0
|
||||
```
|
||||
|
||||
5. Open a pull request, wait for an approval from your peers and merge it.
|
||||
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
7. Tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin tag v4.18.1
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
|
||||
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/conversations-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/conversations-frontend/tags) image tags are on Docker Hub.
|
||||
9. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# La Suite Conversations – System & Requirements (2025-06)
|
||||
|
||||
## 1. Quick-Reference Matrix (single VM / laptop)
|
||||
|
||||
| Scenario | RAM | vCPU | SSD | Notes |
|
||||
|---------------------------|-------|------|---------|---------------------------|
|
||||
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
|
||||
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
|
||||
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
|
||||
|
||||
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
|
||||
|
||||
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
|
||||
|
||||
## 2. Development Environment Memory Requirements
|
||||
|
||||
| Service | Typical use | Rationale / source |
|
||||
|------------------|-------------------------------|-----------------------------------------------------------------------------------------|
|
||||
| PostgreSQL | **1 – 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
|
||||
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
|
||||
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
|
||||
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
|
||||
| Django API | **0.8 – 1.5 GB** | Empirical in-house metrics |
|
||||
| Next.js frontend | **0.5 – 1 GB** | Dev build chain |
|
||||
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
|
||||
|
||||
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
|
||||
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
|
||||
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
|
||||
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
|
||||
|
||||
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
|
||||
|
||||
## 3. Production Environment Memory Requirements
|
||||
|
||||
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
|
||||
|
||||
| Service | Typical use | Rationale / notes |
|
||||
|---------------------------|-----------------------------|------------------------------------------------------------------------------------------|
|
||||
| PostgreSQL | **2 – 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
|
||||
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
|
||||
| Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions |
|
||||
| Object Storage (optional) | **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
|
||||
| Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency |
|
||||
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
|
||||
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
|
||||
|
||||
### Production Architecture Notes
|
||||
|
||||
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
|
||||
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
|
||||
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
|
||||
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
|
||||
- **Scaling**: Horizontal scaling is recommended for Django API service
|
||||
|
||||
### Minimal Production Setup (Core Services Only)
|
||||
|
||||
| Service | Memory | Notes |
|
||||
|----------------------------------|------------|----------------------------------------|
|
||||
| PostgreSQL | **2 GB** | Core database |
|
||||
| Django API | **1.5 GB** | Backend services |
|
||||
| Nginx | **100 MB** | Static files + reverse proxy |
|
||||
| Redis | **256 MB** | Session storage |
|
||||
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
|
||||
|
||||
## 4. Recommended Software Versions
|
||||
|
||||
| Tool | Minimum |
|
||||
|-------------------------|---------|
|
||||
| Docker Engine / Desktop | 24.0 |
|
||||
| Docker Compose | v2 |
|
||||
| Git | 2.40 |
|
||||
| **Node.js** | 22+ |
|
||||
| **Python** | 3.13+ |
|
||||
| GNU Make | 4.4 |
|
||||
| Kind | 0.22 |
|
||||
| Helm | 3.14 |
|
||||
| kubectl | 1.29 |
|
||||
| mkcert | 1.4 |
|
||||
|
||||
|
||||
## 5. Ports (dev defaults)
|
||||
|
||||
| Port | Service |
|
||||
|-----------|----------------------------|
|
||||
| 3000 | Next.js |
|
||||
| 8071 | Django |
|
||||
| 8080 | Keycloak |
|
||||
| 8083 | Nginx proxy |
|
||||
| 9000/9001 | MinIO |
|
||||
| 15432 | PostgreSQL (main) |
|
||||
| 5433 | PostgreSQL (Keycloak) |
|
||||
| 1081 | Maildev (currently unused) |
|
||||
|
||||
## 6. Sizing Guidelines
|
||||
|
||||
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||
|
||||
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
|
||||
|
||||
**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate.
|
||||
|
||||
**Disk** – SSD; add 10 GB extra for the Docker layer cache.
|
||||
|
||||
**MinIO** – for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Runtime Theming 🎨
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||
|
||||
```ini
|
||||
FRONTEND_CSS_URL=http://anything/custom-style.css
|
||||
```
|
||||
|
||||
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||
|
||||
### Benefits
|
||||
|
||||
This feature provides several benefits, including:
|
||||
|
||||
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
|
||||
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
|
||||
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
|
||||
|
||||
### Example Use Case
|
||||
|
||||
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
|
||||
|
||||
```css
|
||||
body {
|
||||
background-color: #3498db;
|
||||
}
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
|
||||
|
||||
----
|
||||
|
||||
# **Footer Configuration** 📝
|
||||
|
||||
The footer is configurable from the theme customization file.
|
||||
|
||||
### Settings 🔧
|
||||
|
||||
```ini
|
||||
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
```
|
||||
|
||||
### Example of JSON
|
||||
|
||||
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
`footer.default` is the fallback if the language is not supported.
|
||||
|
||||
|
||||
# **Custom Translations** 📝
|
||||
|
||||
The translations can be partially overridden from the theme customization file.
|
||||
|
||||
### Settings 🔧
|
||||
|
||||
```ini
|
||||
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
```
|
||||
|
||||
### Example of JSON
|
||||
|
||||
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# Tools for the Conversation Agent
|
||||
|
||||
The conversation agent can be extended with various tools that provide additional capabilities such as web search,
|
||||
weather information, and more. We currently only have web search tools, but more tools can be added as needed.
|
||||
This document explains how to configure and use these tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Tools are functions that the LLM can call during a conversation to access external data or perform specific actions.
|
||||
The agent decides when to use these tools based on the user's query and the conversation context.
|
||||
|
||||
## Configuring Tools for a Model
|
||||
|
||||
Tools are configured at the model level in the LLM configuration file.
|
||||
Each model can have its own set of available tools.
|
||||
|
||||
### Configuration File Location
|
||||
|
||||
Read the [LLM Configuration](llm-configuration.md) document to find out where the configuration file is located
|
||||
and how to use it.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "default-model",
|
||||
"model_name": "gpt-4",
|
||||
"human_readable_name": "GPT-4 with Tools",
|
||||
"provider_name": "default-provider",
|
||||
"is_active": true,
|
||||
"system_prompt": "You are a helpful assistant.",
|
||||
"tools": [
|
||||
"web_search_brave",
|
||||
"get_current_weather"
|
||||
]
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"hrid": "default-provider",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "settings.AI_API_KEY",
|
||||
"kind": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `tools` field accepts either:
|
||||
- A list of tool names: `["tool_name_1", "tool_name_2"]`
|
||||
- A reference to a settings variable: `"settings.AI_AGENT_TOOLS"`
|
||||
|
||||
## Available Tools
|
||||
|
||||
To make a tool available to be in a model's configuration, it must be registered in the tool registry located at
|
||||
`src/backend/chat/tools/__init__.py`.
|
||||
|
||||
This is not dynamic - any changes to the tool registry require a code deployment...
|
||||
We want to add dynamic loading in the future.
|
||||
|
||||
| Tool Name | Description | Documentation |
|
||||
|------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||
| `get_current_weather` | Fake weather tool for testing purposes | [Details](tools/get_current_weather.md) |
|
||||
| `web_search_tavily` | Web search using Tavily API | [Details](tools/web_search_tavily.md) |
|
||||
| `web_search_brave` | Web search using Brave Search API with optional summarization | [Details](tools/web_search_brave.md) |
|
||||
| `web_search_brave_with_document_backend` | Web search using Brave with RAG-based document processing | [Details](tools/web_search_brave.md#web_search_brave_with_document_backend) |
|
||||
| `web_search_albert_rag` | ⚠️ **Deprecated** - Web search using Albert API with RAG | [Details](tools/web_search_brave.md#deprecated-web_search_albert_rag) |
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
To add a new tool to the system, follow these steps:
|
||||
|
||||
### 1. Create the Tool Function
|
||||
|
||||
Create a new Python file in `src/backend/chat/tools/` with your tool function. The function should:
|
||||
|
||||
- Have clear type annotations
|
||||
- Include a comprehensive docstring (the LLM uses this to understand when to use the tool)
|
||||
- Accept `RunContext` as the first parameter if it needs access to conversation context
|
||||
- Return appropriate data types
|
||||
|
||||
Example:
|
||||
```python
|
||||
"""My custom tool for the chat agent."""
|
||||
|
||||
from pydantic_ai import RunContext
|
||||
|
||||
def my_custom_tool(ctx: RunContext, param1: str, param2: int) -> dict:
|
||||
"""
|
||||
Brief description of what the tool does.
|
||||
|
||||
The LLM uses this description to decide when to call this tool.
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
param1 (str): Description of parameter 1.
|
||||
param2 (int): Description of parameter 2.
|
||||
|
||||
Returns:
|
||||
dict: Description of the return value.
|
||||
"""
|
||||
# Your implementation here
|
||||
return {"result": "example"}
|
||||
```
|
||||
|
||||
### 2. Register the Tool
|
||||
|
||||
Add your tool to the registry in `src/backend/chat/tools/__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_custom_tool import my_custom_tool
|
||||
|
||||
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=False, prepare=only_if_web_search_enabled
|
||||
),
|
||||
# Add your tool here
|
||||
"my_custom_tool": Tool(
|
||||
my_custom_tool,
|
||||
takes_ctx=True, # Set to True if your tool needs RunContext
|
||||
# prepare=only_if_web_search_enabled # Optional: add conditions
|
||||
),
|
||||
}
|
||||
return tool_dict[name]
|
||||
```
|
||||
|
||||
### 3. Update Imports
|
||||
|
||||
Don't forget to import your tool function at the top of `__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_custom_tool import my_custom_tool
|
||||
```
|
||||
|
||||
### 4. Add to Model Configuration
|
||||
|
||||
Add your tool name to the `tools` list in your LLM configuration file or
|
||||
to the `AI_AGENT_TOOLS` environment variable for local/test purpose.
|
||||
|
||||
## Tool Preparation: Conditional Tool Availability
|
||||
|
||||
Some tools should only be available under certain conditions. The `prepare` parameter in the `Tool` constructor
|
||||
allows you to specify a function that determines whether a tool should be included.
|
||||
|
||||
### The `only_if_web_search_enabled` Prepare Function
|
||||
|
||||
This is a built-in prepare function that checks if web search feature is enabled in the conversation context:
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
All web search tools use this prepare function:
|
||||
|
||||
```python
|
||||
"web_search_brave": Tool(
|
||||
web_search_brave,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled
|
||||
),
|
||||
```
|
||||
|
||||
This ensures that web search tools are only available when the user or conversation settings have enabled web search functionality.
|
||||
|
||||
### Creating Custom Prepare Functions
|
||||
|
||||
You can create your own prepare functions for custom conditions:
|
||||
|
||||
```python
|
||||
async def only_if_feature_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
|
||||
"""Include tool only if a specific feature is enabled."""
|
||||
return tool_def if ctx.deps.feature_enabled else None
|
||||
```
|
||||
|
||||
## Web Search Enable/Disable
|
||||
|
||||
Web search tools can be toggled on or off based on conversation settings. When web search is disabled:
|
||||
- Web search tools are not included in the agent's available tools
|
||||
- The LLM cannot make web search calls even if it tries
|
||||
- This is enforced by the `only_if_web_search_enabled` prepare function
|
||||
|
||||
The `web_search_enabled` flag is typically set:
|
||||
- Per conversation in the conversation settings
|
||||
- Per user preference
|
||||
- Through admin configuration
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep tools focused** - Each tool should do one thing well
|
||||
2. **Clear documentation** - The LLM relies on docstrings to understand when to use tools
|
||||
3. **Error handling** - Tools should handle errors gracefully and return meaningful messages
|
||||
4. **Performance** - Be mindful of API rate limits and timeout values
|
||||
5. **Security** - Never log sensitive data (API keys, user data, etc.)
|
||||
6. **Caching** - Use Django's cache framework for expensive operations when appropriate
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tool Not Being Called
|
||||
|
||||
If the LLM isn't calling your tool:
|
||||
- Check that the tool is registered in `get_pydantic_tools_by_name`
|
||||
- Verify the tool is in the model's `tools` configuration
|
||||
- Review the tool's docstring - make it clearer when the tool should be used
|
||||
- Check if any `prepare` function is preventing the tool from being included
|
||||
|
||||
### Tool Errors
|
||||
|
||||
If a tool is throwing errors:
|
||||
- Check the logs for detailed error messages
|
||||
- Verify all required environment variables are set
|
||||
- Ensure the tool's dependencies are installed
|
||||
- Test the tool function independently
|
||||
|
||||
We recommend wrapping external API calls in try/except blocks to handle potential issues gracefully and use
|
||||
the Pydantic AI `ModelRetry` exception to let the LLM manage the errors.
|
||||
|
||||
### Tool Response Issues
|
||||
|
||||
If the LLM isn't using the tool response correctly:
|
||||
- Ensure the return type is clear and well-structured
|
||||
- Consider returning a `ToolReturn` object with metadata
|
||||
- Check if the response format matches what the LLM expects
|
||||
|
||||
## See Also
|
||||
|
||||
- [Web Search Configuration](llm-configuration.md)
|
||||
- [Architecture](architecture.md)
|
||||
- [Environment Variables](env.md)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# get_current_weather Tool
|
||||
|
||||
## Overview
|
||||
|
||||
The `get_current_weather` tool is a **fake weather tool** designed for testing and demonstration purposes. It does not connect to any real weather API and always returns hardcoded weather data.
|
||||
|
||||
## Purpose
|
||||
|
||||
This tool is useful for:
|
||||
- **Testing** the tool calling functionality of LLMs
|
||||
- **Demonstrating** how tools work without requiring API keys
|
||||
- **Development** and debugging of the agent system
|
||||
- **Example implementation** for creating new tools
|
||||
|
||||
⚠️ **Warning**: This tool should **not** be used in production environments. It always returns fake data regardless of the location or conditions.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Add to Model
|
||||
|
||||
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "my-model",
|
||||
"tools": [
|
||||
"get_current_weather"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Or via environment variable when using local environment settings:
|
||||
```ini
|
||||
AI_AGENT_TOOLS=get_current_weather
|
||||
```
|
||||
|
||||
### No Additional Settings Required
|
||||
|
||||
This tool does not require any API keys, environment variables, or additional configuration.
|
||||
|
||||
## Function Signature
|
||||
|
||||
```python
|
||||
def get_current_weather(location: str, unit: str) -> dict:
|
||||
"""
|
||||
Get the current weather in a given location.
|
||||
|
||||
Args:
|
||||
location (str): The city and state, e.g. San Francisco, CA.
|
||||
unit (str): The unit of temperature, either 'celsius' or 'fahrenheit'.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the location, temperature, and unit.
|
||||
"""
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|------------|------|----------|-----------------------------------------------------------------|
|
||||
| `location` | str | Yes | The city and state (e.g., "San Francisco, CA", "Paris, France") |
|
||||
| `unit` | str | Yes | Temperature unit: either "celsius" or "fahrenheit" |
|
||||
|
||||
## Return Value
|
||||
|
||||
Returns a dictionary with the following structure:
|
||||
|
||||
```python
|
||||
{
|
||||
"location": str, # The location that was queried
|
||||
"temperature": int, # Always 22°C or 72°F
|
||||
"unit": str # The unit that was requested
|
||||
}
|
||||
```
|
||||
|
||||
## How the LLM Uses It
|
||||
|
||||
When a user asks about weather, the LLM will:
|
||||
|
||||
1. **Recognize** the weather-related query
|
||||
2. **Extract** the location from the user's message
|
||||
3. **Determine** the appropriate unit (often from context or user preference)
|
||||
4. **Call** the `get_current_weather` tool
|
||||
5. **Receive** the fake weather data
|
||||
6. **Format** a response to the user
|
||||
|
||||
### Example Conversation
|
||||
|
||||
**User**: "What's the weather like in London?"
|
||||
|
||||
**LLM** (internal): *Calls `get_current_weather("London, UK", "celsius")`*
|
||||
|
||||
**Tool Response**:
|
||||
```json
|
||||
{
|
||||
"location": "London, UK",
|
||||
"temperature": 22,
|
||||
"unit": "celsius"
|
||||
}
|
||||
```
|
||||
|
||||
**LLM** (to user): "The current weather in London, UK is 22°C."
|
||||
|
||||
## See Also
|
||||
|
||||
- [Tools Overview](../tools.md)
|
||||
- [Adding a New Tool](../tools.md#adding-a-new-tool)
|
||||
- [Testing Tools](../tools.md#testing-your-tools)
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
# Brave Web Search Tools
|
||||
|
||||
## Overview
|
||||
|
||||
The Brave web search tools enable the conversation agent to search the web using the [Brave Search API](https://brave.com/search/api/).
|
||||
Brave Search is a privacy-focused search engine that provides comprehensive web search results.
|
||||
|
||||
This documentation covers three related tools:
|
||||
1. **`web_search_brave`** - Standard web search with optional summarization
|
||||
2. **`web_search_brave_with_document_backend`** - Web search with RAG-based document processing
|
||||
3. **`web_search_albert_rag`** - ⚠️ **Deprecated** - Use `web_search_brave_with_document_backend` instead
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Common Configuration](#common-configuration)
|
||||
- [web_search_brave](#web_search_brave)
|
||||
- [web_search_brave_with_document_backend](#web_search_brave_with_document_backend)
|
||||
- [Deprecated: web_search_albert_rag](#deprecated-web_search_albert_rag)
|
||||
- [Comparison](#comparison)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Common Configuration
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Brave Search API Key**: Sign up at [Brave Search API](https://brave.com/search/api/) to get an API key
|
||||
2. **Environment Variables**: Configure the required settings
|
||||
|
||||
### Common Environment Variables
|
||||
|
||||
All Brave tools share these common settings:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---------------------|----------|---------|----------------------------------------------------|
|
||||
| `BRAVE_API_KEY` | **Yes** | None | Your Brave Search API key |
|
||||
| `BRAVE_API_TIMEOUT` | No | 5 | API request timeout in seconds |
|
||||
| `BRAVE_MAX_RESULTS` | No | 8 | Maximum number of search results |
|
||||
| `BRAVE_CACHE_TTL` | No | 1800 | Cache time-to-live in seconds (30 minutes) |
|
||||
|
||||
### Search Parameters
|
||||
|
||||
Check on the Brave API documentation for more details on these parameters:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------------------|----------|------------|---------------------------------------------------|
|
||||
| `BRAVE_SEARCH_COUNTRY` | No | None | Country code for search (e.g., "US", "FR") |
|
||||
| `BRAVE_SEARCH_LANG` | No | None | Language code (e.g., "en", "fr") |
|
||||
| `BRAVE_SEARCH_SAFE_SEARCH` | No | "moderate" | Safe search level: "off", "moderate", or "strict" |
|
||||
| `BRAVE_SEARCH_SPELLCHECK` | No | True | Enable spell checking |
|
||||
| `BRAVE_SEARCH_EXTRA_SNIPPETS` | No | True | Fetch extra snippets from pages |
|
||||
|
||||
|
||||
Note: even if `BRAVE_SEARCH_EXTRA_SNIPPETS` is enabled, the API may not include them if you don't have a plan for this.
|
||||
This is why, in `web_search_brave`, we also fetch the page content ourselves when needed.
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
BRAVE_API_KEY=BSA-your-api-key-here
|
||||
BRAVE_MAX_RESULTS=8
|
||||
BRAVE_MAX_WORKERS=4
|
||||
BRAVE_SEARCH_COUNTRY=US
|
||||
BRAVE_SEARCH_LANG=en
|
||||
BRAVE_SEARCH_SAFE_SEARCH=moderate
|
||||
```
|
||||
|
||||
### Django Settings
|
||||
|
||||
All Brave settings are defined in `src/backend/conversations/brave_settings.py`:
|
||||
|
||||
```python
|
||||
class BraveSettings:
|
||||
"""Brave settings for web_search_brave tool."""
|
||||
|
||||
BRAVE_API_KEY = values.Value(
|
||||
default=None,
|
||||
environ_name="BRAVE_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# ... more settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## web_search_brave
|
||||
|
||||
### Overview
|
||||
|
||||
Standard Brave web search tool with optional LLM-based summarization of page content.
|
||||
|
||||
### Purpose
|
||||
|
||||
- Search the web for up-to-date information
|
||||
- Extract content from web pages
|
||||
- Optionally summarize content using an LLM
|
||||
- Provide structured results with snippets
|
||||
|
||||
### Additional Configuration
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------------------|----------|---------|-------------------------------------------------|
|
||||
| `BRAVE_SUMMARIZATION_ENABLED` | No | False | Enable LLM-based summarization of fetched pages |
|
||||
|
||||
### Function Signature
|
||||
|
||||
```python
|
||||
def web_search_brave(query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
|
||||
Args:
|
||||
query (str): The query to search for.
|
||||
|
||||
Returns:
|
||||
ToolReturn: Formatted search results with metadata
|
||||
"""
|
||||
```
|
||||
|
||||
### Return Value
|
||||
|
||||
Returns a `ToolReturn` object with:
|
||||
|
||||
```python
|
||||
ToolReturn(
|
||||
return_value={
|
||||
"0": {
|
||||
"url": "https://example.com/page1",
|
||||
"title": "Example Page Title",
|
||||
"snippets": ["Extracted or summarized content..."]
|
||||
},
|
||||
"1": {
|
||||
"url": "https://example.com/page2",
|
||||
"title": "Another Page",
|
||||
"snippets": ["More content..."]
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"sources": {
|
||||
"https://example.com/page1",
|
||||
"https://example.com/page2"
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Query API**: Sends search query to Brave Search API
|
||||
2. **Receive Results**: Gets list of matching web pages
|
||||
3. **Fetch Content**: For results without extra_snippets:
|
||||
- Fetches the HTML content using `trafilatura`
|
||||
- Extracts the main text content
|
||||
- Caches the extracted content
|
||||
4. **Summarize (Optional)**: If `BRAVE_SUMMARIZATION_ENABLED=True`:
|
||||
- Sends extracted content to summarization agent
|
||||
- Receives concise summary focused on the query
|
||||
5. **Format Results**: Returns structured data with URLs, titles, and snippets
|
||||
|
||||
### Workflow Diagram
|
||||
|
||||
```
|
||||
User Query
|
||||
↓
|
||||
Brave Search API
|
||||
↓
|
||||
Search Results (URLs, titles, descriptions)
|
||||
↓
|
||||
[For each result without snippets]
|
||||
↓
|
||||
Fetch HTML (trafilatura) → Extract Text → Cache
|
||||
↓
|
||||
[If BRAVE_SUMMARIZATION_ENABLED]
|
||||
↓
|
||||
Summarization Agent (LLM)
|
||||
↓
|
||||
Summary Text
|
||||
↓
|
||||
Format & Return
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Extracted content is cached to avoid repeated fetches:
|
||||
|
||||
```python
|
||||
cache_key = f"web_search_brave:extract:{url}"
|
||||
cache.set(cache_key, document, settings.BRAVE_CACHE_TTL)
|
||||
```
|
||||
|
||||
**Cache Duration**: Controlled by `BRAVE_CACHE_TTL` (default: 30 minutes)
|
||||
|
||||
### Summarization
|
||||
|
||||
When enabled, the tool uses the `SummarizationAgent` to condense page content:
|
||||
|
||||
```python
|
||||
prompt = f"""
|
||||
Based on the following request, summarize the following text in a concise manner,
|
||||
focusing on the key points regarding the user request.
|
||||
The result should be up to 30 lines long.
|
||||
|
||||
<user request>
|
||||
{query}
|
||||
</user request>
|
||||
|
||||
<text to summarize>
|
||||
{text}
|
||||
</text to summarize>
|
||||
"""
|
||||
```
|
||||
|
||||
**Note**: Summarization is costly (additional LLM calls).
|
||||
Use only when necessary, we prefer the document vector search from `web_search_brave_with_document_backend`.
|
||||
|
||||
### Add to Model
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "my-model",
|
||||
"tools": [
|
||||
"web_search_brave"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
**User**: "What are the new features in Django 5.0?"
|
||||
|
||||
**Tool Call**: `web_search_brave("Django 5.0 new features")`
|
||||
|
||||
**Tool Response**:
|
||||
```python
|
||||
{
|
||||
"0": {
|
||||
"url": "https://docs.djangoproject.com/en/5.0/releases/5.0/",
|
||||
"title": "Django 5.0 release notes",
|
||||
"snippets": ["Django 5.0 introduces several new features including..."]
|
||||
},
|
||||
# ... more results
|
||||
}
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
```python
|
||||
"web_search_brave": Tool(
|
||||
web_search_brave,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## web_search_brave_with_document_backend
|
||||
|
||||
### Overview
|
||||
|
||||
Advanced Brave web search tool that uses RAG (Retrieval-Augmented Generation)
|
||||
with a document backend for intelligent content processing and retrieval.
|
||||
|
||||
### Purpose
|
||||
|
||||
- Search the web and process results through a RAG system
|
||||
- Store fetched documents in a temporary vector database
|
||||
- Perform semantic search across fetched content
|
||||
- Return the most relevant chunks based on the query
|
||||
|
||||
### Additional Configuration
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------------------------|----------|------------------|----------------------------------------------|
|
||||
| `BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER` | No | 10 | Number of chunks to retrieve from RAG search |
|
||||
| `RAG_DOCUMENT_SEARCH_BACKEND` | No | AlbertRagBackend | Document backend for RAG processing |
|
||||
|
||||
### Function Signature
|
||||
|
||||
```python
|
||||
def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
query (str): The query to search for.
|
||||
|
||||
Returns:
|
||||
ToolReturn: Formatted search results with RAG-enhanced snippets
|
||||
"""
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Query API**: Sends search query to Brave Search API
|
||||
2. **Receive Results**: Gets list of matching web pages
|
||||
3. **Create Temporary Collection**: Creates a temporary vector database collection
|
||||
4. **Fetch & Store**: For each result:
|
||||
- Fetches the HTML content
|
||||
- Extracts the main text
|
||||
- Stores in the temporary document backend
|
||||
5. **RAG Search**: Performs semantic search across stored documents
|
||||
6. **Map Results**: Maps RAG chunks back to original search results
|
||||
7. **Format & Return**: Returns structured data with enhanced snippets
|
||||
8. **Cleanup**: Temporary collection is automatically deleted
|
||||
|
||||
### Workflow Diagram
|
||||
|
||||
```
|
||||
User Query
|
||||
↓
|
||||
Brave Search API
|
||||
↓
|
||||
Search Results (URLs)
|
||||
↓
|
||||
Create Temporary Vector Collection
|
||||
↓
|
||||
[For each URL]
|
||||
↓
|
||||
Fetch HTML → Extract Text → Store in Vector DB
|
||||
↓
|
||||
RAG Semantic Search
|
||||
↓
|
||||
Retrieve Most Relevant Chunks
|
||||
↓
|
||||
Map Chunks to Original URLs
|
||||
↓
|
||||
Format & Return
|
||||
↓
|
||||
Delete Temporary Collection
|
||||
```
|
||||
|
||||
### Temporary Collection
|
||||
|
||||
The tool creates a temporary collection with a unique ID:
|
||||
|
||||
```python
|
||||
with document_store_backend.temporary_collection(f"tmp-{uuid.uuid4()}") as document_store:
|
||||
# Fetch and store documents
|
||||
# Perform search
|
||||
# Collection is automatically deleted on exit
|
||||
```
|
||||
|
||||
### RAG Search
|
||||
|
||||
The RAG backend performs semantic search to find the most relevant content:
|
||||
|
||||
```python
|
||||
rag_results = document_store.search(
|
||||
query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
**kwargs, # Additional search parameters like session with access_token
|
||||
)
|
||||
```
|
||||
|
||||
Returns chunks ranked by relevance to the query, not just keyword matching.
|
||||
|
||||
### Token Usage Tracking
|
||||
|
||||
The tool tracks LLM tokens used during RAG processing:
|
||||
|
||||
```python
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
output_tokens=rag_results.usage.completion_tokens,
|
||||
)
|
||||
```
|
||||
|
||||
### Document Backend
|
||||
|
||||
The default backend is `AlbertRagBackend`, but you can configure a different one:
|
||||
|
||||
```bash
|
||||
RAG_DOCUMENT_SEARCH_BACKEND=chat.agent_rag.document_rag_backends.custom_backend.CustomBackend
|
||||
```
|
||||
|
||||
### Add to Model
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "my-model",
|
||||
"tools": [
|
||||
"web_search_brave_with_document_backend"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
**User**: "Explain the concept of async views in Django"
|
||||
|
||||
**Tool Call**: `web_search_brave_with_document_backend(ctx, "Django async views explained")`
|
||||
|
||||
**Tool Response**:
|
||||
```python
|
||||
{
|
||||
"0": {
|
||||
"url": "https://docs.djangoproject.com/en/stable/topics/async/",
|
||||
"title": "Asynchronous support",
|
||||
"snippets": [
|
||||
"Django has support for writing asynchronous views...",
|
||||
"Async views are declared using Python's async def syntax..."
|
||||
]
|
||||
},
|
||||
# ... more results with relevant chunks
|
||||
}
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
```python
|
||||
"web_search_brave_with_document_backend": Tool(
|
||||
web_search_brave_with_document_backend,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
)
|
||||
```
|
||||
|
||||
### Advantages Over Standard web_search_brave
|
||||
|
||||
| Feature | web_search_brave | web_search_brave_with_document_backend |
|
||||
|-------------------|--------------------------------|----------------------------------------|
|
||||
| Content Retrieval | Full page or summary | Semantic chunks |
|
||||
| Relevance | Keyword-based | Semantic similarity |
|
||||
| Token Efficiency | May include irrelevant content | Only relevant chunks |
|
||||
| Processing | Simpler, faster | More intelligent, slower |
|
||||
| Cost | Lower | Higher (RAG processing) |
|
||||
| Best For | General search | Deep research, technical queries |
|
||||
|
||||
---
|
||||
|
||||
## Deprecated: web_search_albert_rag
|
||||
|
||||
### ⚠️ Deprecation Notice
|
||||
|
||||
The `web_search_albert_rag` tool is **deprecated** and should not be used in new implementations.
|
||||
|
||||
**Replacement**: Use `web_search_brave_with_document_backend` instead, which provides:
|
||||
- Better performance
|
||||
- More control over the RAG backend
|
||||
- Temporary collections (no cleanup issues)
|
||||
- Token usage tracking
|
||||
- Parallel processing support
|
||||
|
||||
### Why Deprecated?
|
||||
|
||||
- Limited to Albert API only
|
||||
- No control over document backend
|
||||
- Less flexible than the new approach
|
||||
- Maintenance burden
|
||||
|
||||
### Timeline
|
||||
|
||||
- **Current**: Still functional but not recommended
|
||||
- **Future**: Will be removed in a future version
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
### When to Use Which Tool?
|
||||
|
||||
#### Use `web_search_brave`
|
||||
|
||||
✅ **Best for**:
|
||||
- General web search queries
|
||||
- Quick information retrieval
|
||||
- When speed is important
|
||||
- Lower cost requirements
|
||||
- Simple fact-finding
|
||||
|
||||
❌ **Not ideal for**:
|
||||
- Deep research requiring precise context
|
||||
- Technical documentation queries
|
||||
- When semantic relevance is crucial
|
||||
|
||||
#### Use `web_search_brave_with_document_backend`
|
||||
|
||||
✅ **Best for**:
|
||||
- Complex technical queries
|
||||
- Research requiring precise context
|
||||
- When semantic relevance is important
|
||||
- Questions needing deep understanding
|
||||
- Documentation and how-to queries
|
||||
|
||||
❌ **Not ideal for**:
|
||||
- Simple factual queries
|
||||
- When speed is critical
|
||||
- Budget-constrained scenarios
|
||||
- High-volume usage
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Query Formulation
|
||||
|
||||
Help the LLM formulate effective queries:
|
||||
|
||||
```python
|
||||
# Good queries
|
||||
"Python asyncio tutorial 2024"
|
||||
"Django REST framework authentication"
|
||||
"React hooks best practices"
|
||||
|
||||
# Poor queries
|
||||
"tell me about programming" # Too vague
|
||||
"how do I do the thing with the stuff" # Unclear
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
#### 1. Optimize Cache
|
||||
|
||||
```bash
|
||||
# Longer cache for stable content
|
||||
BRAVE_CACHE_TTL=3600 # 1 hour
|
||||
|
||||
# Shorter cache for dynamic content
|
||||
BRAVE_CACHE_TTL=300 # 5 minutes
|
||||
```
|
||||
|
||||
#### 2. Control Result Count
|
||||
|
||||
```bash
|
||||
# Fewer results = faster responses
|
||||
BRAVE_MAX_RESULTS=5
|
||||
|
||||
# More results = more comprehensive
|
||||
BRAVE_MAX_RESULTS=10
|
||||
```
|
||||
|
||||
### Summarization Best Practices
|
||||
|
||||
Only enable summarization when needed:
|
||||
|
||||
```bash
|
||||
# Enable for long-form content
|
||||
BRAVE_SUMMARIZATION_ENABLED=True
|
||||
|
||||
# Disable for speed
|
||||
BRAVE_SUMMARIZATION_ENABLED=False
|
||||
```
|
||||
|
||||
**Cost consideration**: Summarization makes additional LLM calls for each result,
|
||||
significantly increasing costs (and execution time).
|
||||
|
||||
### RAG Configuration
|
||||
|
||||
For `web_search_brave_with_document_backend`:
|
||||
|
||||
```bash
|
||||
# More chunks = more context, higher cost
|
||||
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=10
|
||||
|
||||
# Fewer chunks = faster, less context
|
||||
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=5
|
||||
```
|
||||
|
||||
### Search Parameters
|
||||
|
||||
```bash
|
||||
# Localize results
|
||||
BRAVE_SEARCH_COUNTRY=FR
|
||||
BRAVE_SEARCH_LANG=fr
|
||||
|
||||
# Safe search for public deployments
|
||||
BRAVE_SEARCH_SAFE_SEARCH=strict
|
||||
|
||||
# Enable spell check for better results
|
||||
BRAVE_SEARCH_SPELLCHECK=True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. No Results Returned
|
||||
|
||||
**Symptoms**: Empty results or no snippets
|
||||
|
||||
**Causes**:
|
||||
- Query too specific
|
||||
- Content extraction failed
|
||||
- Trafilatura couldn't parse the pages
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Enable extra snippets
|
||||
BRAVE_SEARCH_EXTRA_SNIPPETS=True
|
||||
|
||||
# Increase result count
|
||||
BRAVE_MAX_RESULTS=10
|
||||
|
||||
# Check logs for extraction errors
|
||||
```
|
||||
|
||||
#### 2. API Errors
|
||||
|
||||
**Symptoms**: HTTP errors, authentication failures
|
||||
|
||||
**Causes**:
|
||||
- Invalid API key
|
||||
- Rate limit exceeded
|
||||
- API service issues
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Verify API key is set
|
||||
echo $BRAVE_API_KEY
|
||||
|
||||
# Check Brave API dashboard for limits
|
||||
# Implement rate limiting in your application
|
||||
```
|
||||
|
||||
#### 3. The tool is not being called
|
||||
**Symptoms**: LLM doesn't use the tool even when appropriate
|
||||
|
||||
**Causes**:
|
||||
- Web search not enabled for the conversation
|
||||
- Tool not in model configuration
|
||||
|
||||
**Solutions**:
|
||||
- Check conversation settings have `web_search_enabled=True`
|
||||
- Verify tool is in the model's `tools` list
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
This tool is quite "raw", so be cautious about:
|
||||
- the results returned by the web search
|
||||
- the context size which might be large when not using summarization or RAG if long results are returned
|
||||
- the query content which might include sensitive information
|
||||
- ...
|
||||
|
||||
### Content Validation
|
||||
|
||||
Be aware that fetched content may contain:
|
||||
- Malicious scripts (mitigated by text extraction)
|
||||
- Inappropriate content
|
||||
- Misinformation
|
||||
- Biased information
|
||||
|
||||
The LLM should evaluate sources critically.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Tools Overview](../tools.md)
|
||||
- [Tavily Web Search Tool](web_search_tavily.md)
|
||||
- [LLM Configuration](../llm-configuration.md)
|
||||
- [Environment Variables](../env.md)
|
||||
- [Brave Search API Documentation](https://brave.com/search/api/)
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# web_search_tavily Tool
|
||||
|
||||
## Overview
|
||||
|
||||
The `web_search_tavily` tool enables the conversation agent to search the web for up-to-date
|
||||
information using the [Tavily Search API](https://tavily.com/).
|
||||
|
||||
## Purpose
|
||||
|
||||
This tool allows the LLM to:
|
||||
- Access current, real-time information beyond its training data
|
||||
- Answer questions about recent events, news, or developments
|
||||
- Provide factual information with sources
|
||||
- Retrieve specific information from the web
|
||||
|
||||
## Configuration
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Tavily API Key**: Sign up at [Tavily](https://tavily.com/) to get an API key
|
||||
2. **Environment Variables**: Configure the required settings
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------------------|----------|---------|--------------------------------------------|
|
||||
| `TAVILY_API_KEY` | **Yes** | None | Your Tavily API key |
|
||||
| `TAVILY_MAX_RESULTS` | No | 5 | Maximum number of search results to return |
|
||||
| `TAVILY_API_TIMEOUT` | No | 10 | API request timeout in seconds |
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
TAVILY_API_KEY=tvly-your-api-key-here
|
||||
TAVILY_MAX_RESULTS=5
|
||||
TAVILY_API_TIMEOUT=10
|
||||
```
|
||||
|
||||
### Add to Model
|
||||
|
||||
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "my-model",
|
||||
"tools": [
|
||||
"web_search_tavily"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Or via environment variable when using local environment settings:
|
||||
|
||||
```ini
|
||||
AI_AGENT_TOOLS=web_search_tavily
|
||||
```
|
||||
|
||||
## Function Signature
|
||||
|
||||
```python
|
||||
def web_search_tavily(query: str) -> list[dict]:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
|
||||
Args:
|
||||
query (str): The query to search for.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of search results, each represented as a dictionary.
|
||||
"""
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------------------|
|
||||
| `query` | str | Yes | The search query string |
|
||||
|
||||
## Return Value
|
||||
|
||||
Returns a list of dictionaries, each containing:
|
||||
|
||||
```python
|
||||
{
|
||||
"link": str, # URL of the result
|
||||
"title": str, # Title of the page
|
||||
"snippet": str # Content snippet from the page
|
||||
}
|
||||
```
|
||||
|
||||
### Example Return Value
|
||||
|
||||
```python
|
||||
[
|
||||
{
|
||||
"link": "https://example.com/article1",
|
||||
"title": "Introduction to Python",
|
||||
"snippet": "Python is a high-level programming language known for its simplicity..."
|
||||
},
|
||||
{
|
||||
"link": "https://example.com/article2",
|
||||
"title": "Python Best Practices",
|
||||
"snippet": "Follow these best practices to write clean and efficient Python code..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## How the LLM Uses It
|
||||
|
||||
When a user asks for current information or specific facts:
|
||||
|
||||
1. **LLM recognizes** the need for external information
|
||||
2. **Formulates** an appropriate search query
|
||||
3. **Calls** `web_search_tavily(query="search terms")`
|
||||
4. **Receives** a list of search results
|
||||
5. **Synthesizes** the information into a response
|
||||
6. **Provides** the answer with source references
|
||||
|
||||
### Example Conversation
|
||||
|
||||
**User**: "What are the latest developments in quantum computing?"
|
||||
|
||||
**LLM** (internal): *Calls `web_search_tavily("latest developments quantum computing 2024")`*
|
||||
|
||||
**Tool Response**:
|
||||
```python
|
||||
[
|
||||
{
|
||||
"link": "https://techcrunch.com/quantum-news",
|
||||
"title": "Major Breakthrough in Quantum Computing",
|
||||
"snippet": "Researchers announced a significant breakthrough..."
|
||||
},
|
||||
# ... more results
|
||||
]
|
||||
```
|
||||
|
||||
**LLM** (to user): "Based on recent sources, there have been several developments in quantum computing.
|
||||
Researchers recently announced a breakthrough in error correction. Additionally, new quantum processors
|
||||
with improved qubit stability have been unveiled..."
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Source Code
|
||||
|
||||
Located at: `src/backend/chat/tools/web_search_tavily.py`
|
||||
|
||||
```python
|
||||
"""Web search tool using Tavily for the chat agent."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def web_search_tavily(query: str) -> list[dict]:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
|
||||
Args:
|
||||
query (str): The query to search for.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of search results, each represented as a dictionary.
|
||||
"""
|
||||
url = "https://api.tavily.com/search"
|
||||
data = {
|
||||
"query": query,
|
||||
"api_key": settings.TAVILY_API_KEY,
|
||||
"max_results": settings.TAVILY_MAX_RESULTS,
|
||||
}
|
||||
response = requests.post(url, json=data, timeout=settings.TAVILY_API_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
json_response = response.json()
|
||||
|
||||
raw_search_results = json_response.get("results", [])
|
||||
|
||||
return [
|
||||
{
|
||||
"link": result["url"],
|
||||
"title": result.get("title", ""),
|
||||
"snippet": result.get("content"),
|
||||
}
|
||||
for result in raw_search_results
|
||||
]
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
The tool is registered in `src/backend/chat/tools/__init__.py`:
|
||||
|
||||
```python
|
||||
"web_search_tavily": Tool(
|
||||
web_search_tavily,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled
|
||||
)
|
||||
```
|
||||
|
||||
Note that:
|
||||
- `takes_ctx=False` - This tool doesn't need the conversation context
|
||||
- `prepare=only_if_web_search_enabled` - Only available when web search is enabled
|
||||
|
||||
## Django Settings
|
||||
|
||||
The tool uses these Django settings from `settings.py`:
|
||||
|
||||
```python
|
||||
# Tavily API
|
||||
TAVILY_API_KEY = values.Value(
|
||||
None, # Tavily API key is not set by default
|
||||
environ_name="TAVILY_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TAVILY_MAX_RESULTS = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="TAVILY_MAX_RESULTS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TAVILY_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=10, # seconds
|
||||
environ_name="TAVILY_API_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The tool may raise exceptions in the following cases:
|
||||
|
||||
### Missing API Key
|
||||
```python
|
||||
# If TAVILY_API_KEY is not set
|
||||
AttributeError: 'Settings' object has no attribute 'TAVILY_API_KEY'
|
||||
```
|
||||
|
||||
**Solution**: Set the `TAVILY_API_KEY` environment variable
|
||||
|
||||
### API Errors
|
||||
```python
|
||||
# If the API request fails
|
||||
requests.exceptions.HTTPError: 401 Unauthorized
|
||||
```
|
||||
|
||||
**Possible causes**:
|
||||
- Invalid API key
|
||||
- Exceeded rate limits
|
||||
- API service unavailable
|
||||
|
||||
### Timeout Errors
|
||||
```python
|
||||
# If the request takes too long
|
||||
requests.exceptions.Timeout
|
||||
```
|
||||
|
||||
**Solution**: Increase `TAVILY_API_TIMEOUT` or check network connectivity
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Query Formulation
|
||||
|
||||
The LLM should formulate queries that are:
|
||||
- **Specific and focused** - Better results with targeted queries
|
||||
- **Up-to-date** - Include year or "latest" when relevant
|
||||
- **Clear** - Avoid ambiguous terms
|
||||
- **Concise** - Remove unnecessary words
|
||||
|
||||
Good query examples:
|
||||
- ✅ "quantum computing breakthroughs 2024"
|
||||
- ✅ "latest Python 3.12 features"
|
||||
- ✅ "climate change COP29 outcomes"
|
||||
|
||||
Poor query examples:
|
||||
- ❌ "tell me about stuff happening" (too vague)
|
||||
- ❌ "what is the weather like today in Paris on November 5th 2024 at 3pm" (too specific/long)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Be aware of Tavily API rate limits:
|
||||
- Free tier: Limited requests per month
|
||||
- Paid tiers: Higher limits
|
||||
|
||||
Monitor your usage and implement caching if needed.
|
||||
|
||||
### Result Count
|
||||
|
||||
The `TAVILY_MAX_RESULTS` setting controls how many results are returned:
|
||||
- **Lower values (3-5)**: Faster responses, less context for LLM
|
||||
- **Higher values (8-10)**: More comprehensive, but slower and more expensive
|
||||
|
||||
Recommended: **5 results** for most use cases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tool Not Being Called
|
||||
|
||||
**Symptoms**: LLM doesn't use web search even when appropriate
|
||||
|
||||
**Possible causes**:
|
||||
1. Web search not enabled for the conversation
|
||||
2. Tool not in model configuration
|
||||
3. API key not set
|
||||
|
||||
**Solutions**:
|
||||
1. Check conversation settings have `web_search_enabled=True`
|
||||
2. Verify tool is in the model's `tools` list
|
||||
3. Confirm `TAVILY_API_KEY` is set
|
||||
|
||||
### No Results Returned
|
||||
|
||||
**Symptoms**: Tool returns empty list
|
||||
|
||||
**Possible causes**:
|
||||
1. Query too specific
|
||||
2. No matching results
|
||||
3. API filtering results
|
||||
|
||||
**Solutions**:
|
||||
1. Try broader query terms
|
||||
2. Check Tavily dashboard for query logs
|
||||
3. Review API response in logs
|
||||
|
||||
### Slow Responses
|
||||
|
||||
**Symptoms**: Tool takes a long time to respond
|
||||
|
||||
**Possible causes**:
|
||||
1. Network latency
|
||||
2. Tavily API slow
|
||||
3. Timeout too high
|
||||
|
||||
**Solutions**:
|
||||
1. Check network connectivity
|
||||
2. Monitor Tavily status page
|
||||
3. Adjust `TAVILY_API_TIMEOUT` if needed
|
||||
|
||||
## Security Considerations
|
||||
|
||||
This tool is quite "raw", and was currently only used for test purpose, so be cautious about:
|
||||
- the results returned by the web search
|
||||
- the context size which might be large if many results are returned
|
||||
- the query content which might include sensitive information
|
||||
- ...
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Query Optimization
|
||||
|
||||
You may want to help the LLM formulate better queries by including something like this in the system prompt:
|
||||
|
||||
```
|
||||
When using web search:
|
||||
- Use specific, focused queries
|
||||
- Include relevant time periods if needed
|
||||
- Avoid unnecessary words
|
||||
- Combine related terms
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Tools Overview](../tools.md)
|
||||
- [Brave Web Search Tool](web_search_brave.md)
|
||||
- [Web Search Configuration](../llm-configuration.md)
|
||||
- [Environment Variables](../env.md)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Line Ending Issues on Windows (LF/CRLF)
|
||||
|
||||
### Problem Description
|
||||
|
||||
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
|
||||
|
||||
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
|
||||
- **This project** uses LF line endings for consistency across all platforms
|
||||
- **Git** may automatically convert line endings, causing conflicts or build failures
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
- Git shows files as modified even when no changes were made
|
||||
- Error messages like "warning: LF will be replaced by CRLF"
|
||||
- Build failures or linting errors due to line ending mismatches
|
||||
|
||||
### Solutions for Windows Users
|
||||
|
||||
#### Configure Git to Preserve LF (Recommended)
|
||||
|
||||
Configure Git to NOT convert line endings and preserve LF:
|
||||
|
||||
```bash
|
||||
git config core.autocrlf false
|
||||
git config core.eol lf
|
||||
```
|
||||
|
||||
This tells Git to:
|
||||
- Never convert line endings automatically
|
||||
- Always use LF for line endings in working directory
|
||||
|
||||
|
||||
#### Fix Existing Repository with Wrong Line Endings
|
||||
|
||||
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
|
||||
|
||||
1. **Configure Git first**:
|
||||
```bash
|
||||
git config --global core.autocrlf false
|
||||
git config --global core.eol lf
|
||||
```
|
||||
|
||||
2. **Clone the project fresh** (recommended):
|
||||
```bash
|
||||
# Navigate to parent directory
|
||||
cd ..
|
||||
|
||||
# Remove current repository (backup your changes first!)
|
||||
rm -rf docs
|
||||
|
||||
# Clone again with correct line endings
|
||||
git clone git@github.com:suitenumerique/conversations.git
|
||||
```
|
||||
|
||||
**Alternative**: If you have uncommitted changes and cannot re-clone:
|
||||
|
||||
1. **Backup your changes**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Save changes before fixing line endings"
|
||||
```
|
||||
|
||||
2. **Remove all files from Git's index**:
|
||||
```bash
|
||||
git rm --cached -r .
|
||||
```
|
||||
|
||||
3. **Reset Git configuration** (if not done globally):
|
||||
```bash
|
||||
git config core.autocrlf false
|
||||
git config core.eol lf
|
||||
```
|
||||
|
||||
4. **Re-add all files** (Git will use LF line endings):
|
||||
```bash
|
||||
git add .
|
||||
```
|
||||
|
||||
5. **Commit the changes**:
|
||||
```bash
|
||||
git commit -m "✏️(project) Fix line endings to LF"
|
||||
```
|
||||
|
||||
## Minio Permission Issues on Windows
|
||||
|
||||
### Problem Description
|
||||
|
||||
On Windows, you may encounter permission-related errors when running Minio in development mode with Docker Compose. This typically happens because:
|
||||
|
||||
- **Windows file permissions** don't map well to Unix-style user IDs used in Docker containers
|
||||
- **Docker Desktop** may have issues with user mapping when using the `DOCKER_USER` environment variable
|
||||
- **Minio container** fails to start or access volumes due to permission conflicts
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
- Minio container fails to start with permission denied errors
|
||||
- Error messages related to file system permissions in Minio logs
|
||||
- Unable to create or access buckets in the development environment
|
||||
- Docker Compose showing Minio service as unhealthy or exited
|
||||
|
||||
### Solution for Windows Users
|
||||
|
||||
If you encounter Minio permission issues on Windows, you can temporarily disable user mapping for the Minio service:
|
||||
|
||||
1. **Open the `compose.yml` file**
|
||||
|
||||
2. **Comment out the user directive** in the `minio` service section:
|
||||
```yaml
|
||||
minio:
|
||||
# user: ${DOCKER_USER:-1000} # Comment this line on Windows if permission issues occur
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=conversations
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
# ... rest of the configuration
|
||||
```
|
||||
|
||||
3. **Restart the services**:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- Commenting out the `user` directive allows the Minio container to run with its default user
|
||||
- This bypasses Windows-specific permission mapping issues
|
||||
- The container will have the necessary permissions to access and manage the mounted volumes
|
||||
|
||||
### Note
|
||||
|
||||
This is a **development-only workaround**. In production environments, proper user mapping and security considerations should be maintained according to your deployment requirements.
|
||||
|
||||
## Frontend File Watching Issues on Windows
|
||||
|
||||
### Problem Description
|
||||
|
||||
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
|
||||
|
||||
- **Docker on Windows** has known limitations with file change detection
|
||||
- **Node.js file watchers** may not detect changes properly on Windows filesystem
|
||||
- **Hot reloading** fails to trigger when files are modified
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
- Changes to frontend code aren't detected automatically
|
||||
- Hot module replacement doesn't work as expected
|
||||
- Need to manually restart the frontend container after code changes
|
||||
- Console shows no reaction when saving files
|
||||
|
||||
### Solution: Enable WATCHPACK_POLLING
|
||||
|
||||
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
|
||||
|
||||
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
|
||||
|
||||
```yaml
|
||||
frontend-development:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: conversations-dev
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
image: conversations:frontend-development
|
||||
environment:
|
||||
- WATCHPACK_POLLING=true # Add this line for Windows users
|
||||
volumes:
|
||||
- ./src/frontend:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/conversations/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
2. **Restart your containers**:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
|
||||
- Polling periodically checks for file changes rather than relying on OS-level file events
|
||||
- This is more reliable on Windows but slightly increases CPU usage
|
||||
- Changes to your frontend code should now be detected properly, enabling hot reloading
|
||||
|
||||
### Note
|
||||
|
||||
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=conversations.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Logging
|
||||
# Set to DEBUG level for dev only
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
|
||||
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# conversations settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST="maildev"
|
||||
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
CONVERSATIONS_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=conversations
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/conversations/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/conversations/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/conversations/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/conversations/protocol/openid-connect/userinfo
|
||||
|
||||
OIDC_RP_CLIENT_ID=conversations
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# AI
|
||||
# AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
# AI_MODEL=llama
|
||||
@@ -0,0 +1,12 @@
|
||||
# For the CI job test-e2e
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
|
||||
# LLM
|
||||
LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.json
|
||||
|
||||
# Features
|
||||
FEATURE_FLAG_WEB_SEARCH=ENABLED
|
||||
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES=3
|
||||
@@ -0,0 +1,3 @@
|
||||
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
|
||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||
CROWDIN_BASE_PATH=/app/src
|
||||
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=conversations
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=conversations
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=conversations
|
||||
POSTGRES_USER=dinum
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=conversations
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
|
||||
class GitmojiTitle(LineRule):
|
||||
"""
|
||||
This rule will enforce that each commit title is of the form "<gitmoji>(<scope>) <subject>"
|
||||
where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and
|
||||
subject should be all lowercase
|
||||
"""
|
||||
|
||||
id = "UC1"
|
||||
name = "title-should-have-gitmoji-and-scope"
|
||||
target = CommitMessageTitle
|
||||
|
||||
def validate(self, title, _commit):
|
||||
"""
|
||||
Download the list possible gitmojis from the project's github repository and check that
|
||||
title contains one of them.
|
||||
"""
|
||||
gitmojis = requests.get(
|
||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
).json()["gitmojis"]
|
||||
emojis = [item["emoji"] for item in gitmojis]
|
||||
pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis))
|
||||
if not re.search(pattern, title):
|
||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||
return [RuleViolation(self.id, violation_msg, title)]
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": []
|
||||
},
|
||||
{
|
||||
"groupName": "allowed redis versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "ignore recent markitdown versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["markitdown"],
|
||||
"allowedVersions": "==0.0.2"
|
||||
},
|
||||
{
|
||||
"groupName": "ignore recent lxml versions not supported by htmldate==1.9.3",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["lxml"],
|
||||
"allowedVersions": "<6"
|
||||
},
|
||||
{
|
||||
"groupName": "ignore recent pylint versions not supported by pylint-django",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"eslint",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "ignore Vercel SDK >= 5.0.0",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["@ai-sdk/react", "@ai-sdk/ui-utils"],
|
||||
"allowedVersions": "^1.2.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.13.3-alpine
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools lorem-text
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Install the package
|
||||
RUN pip install git+https://github.com/etalab-ia/openmockllm.git
|
||||
|
||||
# Expose the default port
|
||||
EXPOSE 8000
|
||||
|
||||
# Set default command
|
||||
CMD ["openmockllm", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,19 @@
|
||||
[OpenMockLLM](https://github.com/etalab-ia/OpenMockLLM) is a FastAPI-based mock LLM API server that simulates
|
||||
several Large Language Model API providers.
|
||||
|
||||
This is a simple docker image to run the server for testing and development purposes (E2E tests mainly).
|
||||
|
||||
It's a bit overkill to have a dedicated image for that, but it allows simple E2E stack with docker-compose since
|
||||
our code is also run in Docker containers.
|
||||
|
||||
## Build and Run manually
|
||||
|
||||
```bash
|
||||
docker build -t openmockllm .
|
||||
docker run -p 8000:8000 openmockllm
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- Add more chat completion behaviors (specific text streaming, function calling, etc.)
|
||||
- Pin a specific OpenMockLLM version in the Dockerfile
|
||||
@@ -0,0 +1,474 @@
|
||||
[MASTER]
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Add files or directories to the blacklist. They should be base names, not
|
||||
# paths.
|
||||
ignore=migrations
|
||||
|
||||
# Add files or directories matching the regex patterns to the blacklist. The
|
||||
# regex matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use.
|
||||
jobs=0
|
||||
|
||||
# List of plugins (as comma separated values of python modules names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=pylint_django,
|
||||
pylint.extensions.no_self_use,
|
||||
pylint_pydantic,
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once).You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=bad-inline-option,
|
||||
deprecated-pragma,
|
||||
django-not-configured,
|
||||
file-ignored,
|
||||
locally-disabled,
|
||||
no-self-use,
|
||||
raw-checker-failed,
|
||||
suppressed-message,
|
||||
useless-suppression
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a note less than 10 (10 is the highest
|
||||
# note). You have access to the variables errors warning, statement which
|
||||
# respectively contain the number of errors / warnings messages and the total
|
||||
# number of statements analyzed. This is used by the global evaluation report
|
||||
# (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio).You can also give a reporter class, eg
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=optparse.Values,sys.exit
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it working
|
||||
# install python-enchant package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to indicated private dictionary in
|
||||
# --spelling-private-dict-file option instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
|
||||
Template,Contact
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis. It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid to define new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||
# not used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Ignore comments when computing similarities.
|
||||
ignore-comments=yes
|
||||
|
||||
# Ignore docstrings when computing similarities.
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Ignore imports when computing similarities.
|
||||
ignore-imports=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
# First implementations of CMS wizards have common fields we do not want to factorize for now
|
||||
min-similarity-lines=35
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Naming style matching correct class attribute names
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class names
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-style
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
cm,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style
|
||||
method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
|
||||
|
||||
# Naming style matching correct module names
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming style matching correct variable names
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma
|
||||
deprecated-modules=optparse,tkinter.tix
|
||||
|
||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
ext-import-graph=
|
||||
|
||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||
# given file (report RP0402 must not be disabled)
|
||||
import-graph=
|
||||
|
||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in a if statement
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=20
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=10
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=0
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "Exception"
|
||||
overgeneral-exceptions=builtins.Exception
|
||||
@@ -0,0 +1,3 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
recursive-include src/backend/conversations *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Admin classes for activation codes application."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.ActivationCode)
|
||||
class ActivationCodeAdmin(admin.ModelAdmin):
|
||||
"""Admin class for ActivationCode model"""
|
||||
|
||||
list_display = (
|
||||
"code",
|
||||
"usage_display",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
"description_short",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"is_active",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"code",
|
||||
"description",
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"current_uses",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"usage_details",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"code",
|
||||
"description",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Configuration"),
|
||||
{
|
||||
"fields": (
|
||||
"max_uses",
|
||||
"current_uses",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Usage details"),
|
||||
{"fields": ("usage_details",)},
|
||||
),
|
||||
(
|
||||
_("Timestamps"),
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
actions = ["recompute_current_uses"]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Make `code` readonly when editing an existing ActivationCode.
|
||||
|
||||
When obj is None (creation form), `code` remains editable. When obj is
|
||||
provided (editing), add `code` to readonly fields so it cannot be
|
||||
changed after creation.
|
||||
"""
|
||||
# Start from the configured readonly_fields to preserve other read-only fields
|
||||
ro_fields = list(self.readonly_fields)
|
||||
if obj is not None:
|
||||
ro_fields.append("code")
|
||||
return tuple(ro_fields)
|
||||
|
||||
def usage_display(self, obj):
|
||||
"""Display usage statistics."""
|
||||
max_uses = obj.max_uses if obj.max_uses > 0 else "∞"
|
||||
if obj.current_uses >= obj.max_uses and obj.max_uses > 0:
|
||||
color = "red"
|
||||
elif obj.current_uses > 0:
|
||||
color = "orange"
|
||||
else:
|
||||
color = "green"
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {};">{} / {}</span>', color, obj.current_uses, max_uses
|
||||
)
|
||||
|
||||
usage_display.short_description = _("Usage")
|
||||
|
||||
def description_short(self, obj):
|
||||
"""Display truncated description."""
|
||||
if obj.description:
|
||||
return obj.description[:50] + "..." if len(obj.description) > 50 else obj.description
|
||||
return "-"
|
||||
|
||||
description_short.short_description = _("Description")
|
||||
|
||||
def usage_details(self, obj):
|
||||
"""Display detailed usage information."""
|
||||
usages = obj.usages.select_related("user").all()
|
||||
|
||||
if not usages:
|
||||
return _("No users have used this code yet")
|
||||
|
||||
table_head = format_html(
|
||||
(
|
||||
"<table style='width: 100%; border-collapse: collapse;'>"
|
||||
"<tr style='background-color: #f0f0f0;'>"
|
||||
"<th style='padding: 8px; text-align: left;'>{name}</th>"
|
||||
"<th style='padding: 8px; text-align: left;'>{title}</th>"
|
||||
"<th style='padding: 8px; text-align: left;'>{date}</th>"
|
||||
"</tr>"
|
||||
),
|
||||
name=_("Name"),
|
||||
title=_("Email"),
|
||||
date=_("Date"),
|
||||
)
|
||||
|
||||
rows = format_html_join(
|
||||
"",
|
||||
(
|
||||
"<tr style='border-bottom: 1px solid #ddd;'>"
|
||||
"<td style='padding: 8px;'>{name}</td>"
|
||||
"<td style='padding: 8px;'>{email}</td>"
|
||||
"<td style='padding: 8px;'>{created_at}</td>"
|
||||
"</tr>"
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": usage.user.full_name or "-",
|
||||
"email": usage.user.email or "-",
|
||||
"created_at": usage.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||
}
|
||||
for usage in usages
|
||||
),
|
||||
)
|
||||
|
||||
return format_html("{table_head}{rows}</table>", table_head=table_head, rows=rows)
|
||||
|
||||
usage_details.short_description = _("Users who used this code")
|
||||
|
||||
@admin.action(description=_("Recompute current uses from related activations"))
|
||||
def recompute_current_uses(self, request, queryset):
|
||||
"""Recompute the current_uses field by counting related UserActivation objects."""
|
||||
updated_count = 0
|
||||
for activation_code in queryset:
|
||||
actual_uses = activation_code.usages.count()
|
||||
if activation_code.current_uses != actual_uses:
|
||||
activation_code.current_uses = actual_uses
|
||||
activation_code.save(update_fields=["current_uses", "updated_at"])
|
||||
updated_count += 1
|
||||
|
||||
if updated_count == 0:
|
||||
self.message_user(
|
||||
request,
|
||||
_("All selected activation codes already have correct usage counts."),
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
_("Successfully recomputed usage counts for %(count)d activation code(s).")
|
||||
% {"count": updated_count},
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.UserActivation)
|
||||
class UserActivationAdmin(admin.ModelAdmin):
|
||||
"""Admin class for UserActivation model"""
|
||||
|
||||
list_display = (
|
||||
"user_display",
|
||||
"user_email",
|
||||
"activation_code",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
list_filter = ("created_at",)
|
||||
|
||||
search_fields = (
|
||||
"user__email",
|
||||
"user__full_name",
|
||||
"activation_code__code",
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"user",
|
||||
"activation_code",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"user",
|
||||
"activation_code",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Timestamps"),
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user's full name."""
|
||||
return obj.user.full_name or str(obj.user.id)
|
||||
|
||||
user_display.short_description = _("User")
|
||||
|
||||
def user_email(self, obj):
|
||||
"""Display user's email."""
|
||||
return obj.user.email or "-"
|
||||
|
||||
user_email.short_description = _("Email")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of user activations."""
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(models.UserRegistrationRequest)
|
||||
class UserRegistrationRequestAdmin(admin.ModelAdmin):
|
||||
"""Admin class for UserRegistrationRequest model"""
|
||||
|
||||
list_display = (
|
||||
"user_display",
|
||||
"created_at",
|
||||
"has_user_activation",
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"user",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user_activation",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"user__email",
|
||||
"user__full_name",
|
||||
)
|
||||
|
||||
list_filter = ("created_at",)
|
||||
|
||||
actions = ["add_to_brevo_waiting_list", "remove_from_brevo_waiting_list"]
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user's full name."""
|
||||
return obj.user.email or str(obj.user.pk)
|
||||
|
||||
user_display.short_description = _("User")
|
||||
|
||||
def has_user_activation(self, obj):
|
||||
"""Indicate if the user has used an activation code."""
|
||||
return obj.user_activation_id is not None
|
||||
|
||||
has_user_activation.boolean = True
|
||||
has_user_activation.short_description = _("Has used activation code")
|
||||
|
||||
@admin.action(description=_("Add selected users to Brevo waiting list"))
|
||||
def add_to_brevo_waiting_list(self, request, queryset):
|
||||
"""Add selected users to Brevo waiting list."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from core.brevo import add_user_to_brevo_list # noqa: PLC0415
|
||||
|
||||
registration_to_send = queryset.filter(
|
||||
user_activation__isnull=True,
|
||||
)
|
||||
|
||||
_total_emails = 0
|
||||
for i in range(0, registration_to_send.count(), 150):
|
||||
batch = registration_to_send[i : i + 150]
|
||||
emails = [reg.user.email for reg in batch if reg.user.email]
|
||||
if emails:
|
||||
add_user_to_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
|
||||
_total_emails += len(emails)
|
||||
|
||||
if _total_emails:
|
||||
self.message_user(
|
||||
request,
|
||||
_("Added %(count)d user(s) to Brevo waiting list.") % {"count": _total_emails},
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
_("No valid email address found in selected registrations."),
|
||||
level="warning",
|
||||
)
|
||||
|
||||
@admin.action(description=_("Remove selected users from Brevo waiting list"))
|
||||
def remove_from_brevo_waiting_list(self, request, queryset):
|
||||
"""Remove selected users from Brevo waiting list."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from core.brevo import remove_user_from_brevo_list # noqa: PLC0415
|
||||
|
||||
registration_to_send = queryset.filter(
|
||||
user_activation__isnull=False,
|
||||
)
|
||||
_total_emails = 0
|
||||
for i in range(0, registration_to_send.count(), 150):
|
||||
batch = registration_to_send[i : i + 150]
|
||||
emails = [reg.user.email for reg in batch if reg.user.email]
|
||||
if emails:
|
||||
remove_user_from_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
|
||||
_total_emails += len(emails)
|
||||
if _total_emails:
|
||||
self.message_user(
|
||||
request,
|
||||
_("Removed %(count)d user(s) from Brevo waiting list.") % {"count": _total_emails},
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
_("No valid email address found in selected registrations."),
|
||||
level="warning",
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Exceptions for activation code handling."""
|
||||
|
||||
|
||||
class InvalidCodeError(ValueError):
|
||||
"""Raised when an activation code is invalid or cannot be used."""
|
||||
|
||||
|
||||
class UserAlreadyActivatedError(ValueError):
|
||||
"""Raised when a user tries to activate but is already activated."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Factories for creating activation code and user activation instances for testing."""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import factory.django
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ActivationCodeFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create activation codes for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.ActivationCode
|
||||
|
||||
code = factory.LazyAttribute(lambda x: models.generate_activation_code())
|
||||
created_at = factory.LazyAttribute(lambda obj: timezone.now())
|
||||
|
||||
|
||||
class UserActivationFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create user activations for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.UserActivation
|
||||
|
||||
user = factory.SubFactory(UserFactory)
|
||||
activation_code = factory.SubFactory(ActivationCodeFactory)
|
||||
@@ -0,0 +1,234 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-09 08:30
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import activation_codes.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ActivationCode",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"code",
|
||||
models.CharField(
|
||||
default=activation_codes.models.generate_activation_code,
|
||||
help_text="The activation code that users will enter",
|
||||
max_length=50,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Code must be alphanumeric and contain no spaces or special characters",
|
||||
regex="^[A-Z0-9]+$",
|
||||
)
|
||||
],
|
||||
verbose_name="activation code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_uses",
|
||||
models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Maximum number of times this code can be used. 0 means unlimited.",
|
||||
verbose_name="maximum uses",
|
||||
),
|
||||
),
|
||||
(
|
||||
"current_uses",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
help_text="Number of times this code has been used",
|
||||
verbose_name="current uses",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this code can still be used",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Date and time when this code expires",
|
||||
null=True,
|
||||
verbose_name="expires at",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Internal description or notes about this code",
|
||||
verbose_name="description",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "activation code",
|
||||
"verbose_name_plural": "activation codes",
|
||||
"db_table": "activation_code",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserActivation",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"activation_code",
|
||||
models.ForeignKey(
|
||||
help_text="The activation code that was used",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="usages",
|
||||
to="activation_codes.activationcode",
|
||||
verbose_name="activation code",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
help_text="The user who used the activation code",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="activation",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user activation",
|
||||
"verbose_name_plural": "user activations",
|
||||
"db_table": "user_activation",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserRegistrationRequest",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
help_text="The user who made the registration request",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="registration_request",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_activation",
|
||||
models.OneToOneField(
|
||||
blank=True,
|
||||
help_text="Store if the user received an activation code and used it",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="registration_request",
|
||||
to="activation_codes.useractivation",
|
||||
verbose_name="user activation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user registration request",
|
||||
"verbose_name_plural": "user registration requests",
|
||||
"db_table": "user_registration_request",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Models for the activation codes application
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.brevo import add_user_to_brevo_list, remove_user_from_brevo_list
|
||||
from core.models import BaseModel, User
|
||||
|
||||
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_activation_code():
|
||||
"""Generate a random 16-character activation code."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
# Remove ambiguous characters
|
||||
alphabet = alphabet.replace("O", "").replace("0", "").replace("I", "").replace("1", "")
|
||||
return "".join(secrets.choice(alphabet) for _ in range(16))
|
||||
|
||||
|
||||
class ActivationCode(BaseModel):
|
||||
"""
|
||||
Represents an activation code that can be used to activate user accounts.
|
||||
"""
|
||||
|
||||
code = models.CharField(
|
||||
verbose_name=_("activation code"),
|
||||
help_text=_("The activation code that users will enter"),
|
||||
max_length=50,
|
||||
unique=True,
|
||||
default=generate_activation_code,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[A-Z0-9]+$",
|
||||
message=_("Code must be alphanumeric and contain no spaces or special characters"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
max_uses = models.PositiveIntegerField(
|
||||
verbose_name=_("maximum uses"),
|
||||
help_text=_("Maximum number of times this code can be used. 0 means unlimited."),
|
||||
default=1,
|
||||
)
|
||||
|
||||
current_uses = models.PositiveIntegerField(
|
||||
verbose_name=_("current uses"),
|
||||
help_text=_("Number of times this code has been used"),
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
verbose_name=_("active"),
|
||||
help_text=_("Whether this code can still be used"),
|
||||
default=True,
|
||||
)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
verbose_name=_("expires at"),
|
||||
help_text=_("Date and time when this code expires"),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_("description"),
|
||||
help_text=_("Internal description or notes about this code"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "activation_code"
|
||||
verbose_name = _("activation code")
|
||||
verbose_name_plural = _("activation codes")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the activation code."""
|
||||
return f"{self.code} ({self.current_uses}/{self.max_uses if self.max_uses > 0 else '∞'})"
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if the code is still valid and can be used."""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
if self.expires_at and self.expires_at < timezone.now():
|
||||
return False
|
||||
|
||||
if self.max_uses > 0 and self.current_uses >= self.max_uses:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def can_be_used(self):
|
||||
"""Alias for is_valid() for better readability."""
|
||||
return self.is_valid()
|
||||
|
||||
def use(self, user):
|
||||
"""
|
||||
Mark this code as used by a user.
|
||||
|
||||
Args:
|
||||
user: The User instance using this code
|
||||
|
||||
Returns:
|
||||
UserActivation instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If the code cannot be used
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Lock the activation code row to prevent concurrent overuse.
|
||||
locked_code = ActivationCode.objects.select_for_update().get(pk=self.pk)
|
||||
|
||||
if not locked_code.is_valid():
|
||||
raise InvalidCodeError(_("This activation code is no longer valid"))
|
||||
|
||||
# Create activation record; rely on DB uniqueness for concurrent duplicate attempts.
|
||||
try:
|
||||
activation = UserActivation.objects.create(user=user, activation_code=locked_code)
|
||||
except (IntegrityError, ValidationError) as exc:
|
||||
# User already has an activation in a concurrent or prior transaction.
|
||||
raise UserAlreadyActivatedError(
|
||||
_("You have already activated your account")
|
||||
) from exc
|
||||
|
||||
existing_registration = bool(
|
||||
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
|
||||
)
|
||||
if existing_registration:
|
||||
transaction.on_commit(
|
||||
lambda: remove_user_from_brevo_list(
|
||||
[user.email], settings.BREVO_WAITING_LIST_ID
|
||||
)
|
||||
)
|
||||
|
||||
# Increment usage counter safely under the same lock.
|
||||
locked_code.current_uses += 1
|
||||
locked_code.save(update_fields=["current_uses", "updated_at"])
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
|
||||
)
|
||||
|
||||
if locked_code.max_uses > 0 and locked_code.current_uses >= locked_code.max_uses:
|
||||
logger.warning("Activation code %s has reached its maximum uses", locked_code.code)
|
||||
|
||||
return activation
|
||||
|
||||
|
||||
class UserActivation(BaseModel):
|
||||
"""
|
||||
Records with user used which activation code and when.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
help_text=_("The user who used the activation code"),
|
||||
on_delete=models.CASCADE,
|
||||
related_name="activation",
|
||||
)
|
||||
|
||||
activation_code = models.ForeignKey(
|
||||
ActivationCode,
|
||||
verbose_name=_("activation code"),
|
||||
help_text=_("The activation code that was used"),
|
||||
on_delete=models.PROTECT,
|
||||
related_name="usages",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "user_activation"
|
||||
verbose_name = _("user activation")
|
||||
verbose_name_plural = _("user activations")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the user activation."""
|
||||
return f"{self.user} - {self.activation_code.code}"
|
||||
|
||||
|
||||
class UserRegistrationRequest(BaseModel):
|
||||
"""
|
||||
Records of user registration requests.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
help_text=_("The user who made the registration request"),
|
||||
on_delete=models.CASCADE,
|
||||
related_name="registration_request",
|
||||
)
|
||||
|
||||
user_activation = models.OneToOneField(
|
||||
UserActivation,
|
||||
verbose_name=_("user activation"),
|
||||
help_text=_("Store if the user received an activation code and used it"),
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="registration_request",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "user_registration_request"
|
||||
verbose_name = _("user registration request")
|
||||
verbose_name_plural = _("user registration requests")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the user registration request."""
|
||||
return f"Registration request by {self.user}"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Permission classes for activation codes."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class IsActivatedUser(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that checks if user has activated their account.
|
||||
|
||||
This permission is only enforced if ACTIVATION_REQUIRED is True in settings.
|
||||
Staff users and users without authentication requirement are always allowed.
|
||||
"""
|
||||
|
||||
message = "activation-required" # Custom message to indicate activation is required to frontend
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if user has activated their account."""
|
||||
# If activation is not required, allow access
|
||||
if not settings.ACTIVATION_REQUIRED:
|
||||
return True
|
||||
|
||||
# Staff users can always access
|
||||
if request.user and request.user.is_staff:
|
||||
return True
|
||||
|
||||
# Anonymous users are handled by other permission classes
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return True
|
||||
|
||||
# Check if user has an activation record
|
||||
return models.UserActivation.objects.filter(user=request.user).exists()
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check object-level permission."""
|
||||
return self.has_permission(request, view)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Serializers for the activation codes application."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ActivationCodeValidationSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||
"""Serializer for validating an activation code."""
|
||||
|
||||
code = serializers.CharField(
|
||||
max_length=50, required=True, help_text=_("The activation code to validate")
|
||||
)
|
||||
|
||||
def validate_code(self, value):
|
||||
"""Validate that the code exists and is valid."""
|
||||
# Normalize the code (remove spaces, convert to uppercase)
|
||||
return value.strip().upper().replace(" ", "").replace("-", "")
|
||||
|
||||
|
||||
class UserActivationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user activation records."""
|
||||
|
||||
code = serializers.CharField(source="activation_code.code", read_only=True)
|
||||
activated_at = serializers.DateTimeField(source="created_at", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.UserActivation
|
||||
fields = ["id", "code", "activated_at"]
|
||||
read_only_fields = ["id", "code", "activated_at"]
|
||||
|
||||
|
||||
class ActivationStatusSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||
"""Serializer for activation status response."""
|
||||
|
||||
is_activated = serializers.BooleanField(read_only=True)
|
||||
activation = UserActivationSerializer(read_only=True, allow_null=True)
|
||||
requires_activation = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
class UserRegistrationRequestSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for registering a user for activation notifications."""
|
||||
|
||||
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
|
||||
class Meta:
|
||||
model = models.UserRegistrationRequest
|
||||
fields = ["user"]
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Integration tests for activation_codes application."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from activation_codes.factories import ActivationCodeFactory
|
||||
from activation_codes.models import ActivationCode, UserActivation
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_complete_activation_flow(api_client, settings):
|
||||
"""Test complete user activation flow from registration to usage."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
# Create a user (simulating registration)
|
||||
user = UserFactory(email="newuser@example.com", password="password123")
|
||||
|
||||
# Create an activation code (simulating admin creating codes)
|
||||
activation_code = ActivationCode.objects.create(
|
||||
code="WELCOME123456789", max_uses=10, description="Welcome batch for new users"
|
||||
)
|
||||
|
||||
# User logs in
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# Step 1: Check activation status (should not be activated)
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["is_activated"] is False
|
||||
assert response.data["requires_activation"] is True
|
||||
|
||||
# Step 2: User enters activation code
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "WELCOME123456789"})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "successfully activated" in response.data["detail"]
|
||||
|
||||
# Step 3: Check activation status again (should now be activated)
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["is_activated"] is True
|
||||
assert response.data["activation"]["code"] == "WELCOME123456789"
|
||||
|
||||
# Step 4: Verify in database
|
||||
assert UserActivation.objects.filter(user=user).exists()
|
||||
activation_code.refresh_from_db()
|
||||
assert activation_code.current_uses == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_not_required_flow(api_client, settings):
|
||||
"""Test that when activation is not required, users can access without codes."""
|
||||
settings.ACTIVATION_REQUIRED = False
|
||||
|
||||
user = UserFactory(email="freeuser@example.com", password="password123")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# Check status
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["requires_activation"] is False
|
||||
assert response.data["is_activated"] is False # Not activated but not required
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiple_users_same_code(api_client, settings):
|
||||
"""Test multiple users using the same multi-use code."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
# Create a multi-use code
|
||||
code = ActivationCode.objects.create(
|
||||
code="TEAMCODE12345678", max_uses=3, description="Team activation code"
|
||||
)
|
||||
|
||||
# Create 3 users
|
||||
users = []
|
||||
for i in range(3):
|
||||
user = UserFactory(email=f"teamuser{i}@example.com", password="password123")
|
||||
users.append(user)
|
||||
|
||||
# Each user activates
|
||||
for i, user in enumerate(users):
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEAMCODE12345678"})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
code.refresh_from_db()
|
||||
assert code.current_uses == i + 1
|
||||
|
||||
# Code should now be exhausted
|
||||
code.refresh_from_db()
|
||||
assert code.is_valid() is False
|
||||
|
||||
# Try with a 4th user (should fail)
|
||||
user4 = UserFactory(email="teamuser4@example.com", password="password123")
|
||||
api_client.force_authenticate(user=user4)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEAMCODE12345678"})
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_code_expiration_scenario(api_client, settings):
|
||||
"""Test code expiration over time."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
# Create a code that expires in 1 day
|
||||
future_time = timezone.now() + timedelta(days=1)
|
||||
_code = ActivationCode.objects.create(code="EXPIRES123456789", expires_at=future_time)
|
||||
|
||||
user = UserFactory(email="timeduser@example.com", password="password123")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# Should work now
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "EXPIRES123456789"})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_user_bypass(api_client, settings):
|
||||
"""Test that staff users bypass activation requirement."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
staff_user = UserFactory(email="staff@example.com", password="password123", is_staff=True)
|
||||
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
|
||||
# Staff should be able to check status even without activation
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_activate_twice(api_client, settings):
|
||||
"""Test that a user cannot activate their account twice."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
user = UserFactory(email="onceuser@example.com", password="password123")
|
||||
|
||||
_code1 = ActivationCodeFactory(code="FIRST12345678901")
|
||||
_code2 = ActivationCodeFactory(code="SECOND1234567890")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# First activation
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "FIRST12345678901"})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Try second activation
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "SECOND1234567890"})
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data == {"code": "account-already-activated"}
|
||||
|
||||
# Verify only one activation exists
|
||||
assert UserActivation.objects.filter(user=user).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_code_variations_normalized(api_client, settings):
|
||||
"""Test that different code input formats are normalized correctly."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
code = ActivationCodeFactory(code="TESTCODE12345678")
|
||||
|
||||
test_cases = [
|
||||
"testcode12345678", # lowercase
|
||||
"TESTCODE12345678", # uppercase
|
||||
"test code 1234 5678", # with spaces
|
||||
"TEST-CODE-1234-5678", # with dashes
|
||||
" test-code 1234-5678 ", # mixed with leading/trailing spaces
|
||||
]
|
||||
|
||||
for i, code_variation in enumerate(test_cases):
|
||||
user = UserFactory(email=f"varuser{i}@example.com", password="password123")
|
||||
|
||||
# Update code to allow multiple uses
|
||||
code.max_uses = 0
|
||||
code.save()
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": code_variation})
|
||||
assert response.status_code == status.HTTP_201_CREATED, (
|
||||
f"Failed for variation: {code_variation}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inactive_code_cannot_be_used(api_client, settings):
|
||||
"""Test that inactive codes cannot be used even if valid otherwise."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
_code = ActivationCodeFactory(
|
||||
code="INACTIVE123VALID",
|
||||
is_active=False,
|
||||
max_uses=10,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
|
||||
user = UserFactory(email="blockeduser@example.com", password="password123")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "INACTIVE123VALID"})
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_concurrent_activations_multi_use_code(api_client, settings):
|
||||
"""Test that concurrent activations don't exceed max_uses."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
code = ActivationCodeFactory(code="CONCURRENT123456", max_uses=2)
|
||||
|
||||
# Create 3 users
|
||||
users = [
|
||||
UserFactory(email=f"concurrent{i}@example.com", password="password123") for i in range(3)
|
||||
]
|
||||
|
||||
# First two should succeed
|
||||
for i in range(2):
|
||||
api_client.force_authenticate(user=users[i])
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "CONCURRENT123456"})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Third should fail
|
||||
api_client.force_authenticate(user=users[2])
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "CONCURRENT123456"})
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Verify only 2 activations
|
||||
code.refresh_from_db()
|
||||
assert code.current_uses == 2
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Tests for activation_codes models."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import ProtectedError
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
|
||||
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
|
||||
from activation_codes.models import (
|
||||
ActivationCode,
|
||||
UserActivation,
|
||||
UserRegistrationRequest,
|
||||
generate_activation_code,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_generate_activation_code():
|
||||
"""Test that generate_activation_code creates a valid code."""
|
||||
code = generate_activation_code()
|
||||
|
||||
assert len(code) == 16
|
||||
assert code.isupper()
|
||||
assert all(c.isalnum() for c in code)
|
||||
# Check that ambiguous characters are not present
|
||||
assert "O" not in code
|
||||
assert "0" not in code
|
||||
assert "I" not in code
|
||||
assert "1" not in code
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_generate_activation_code_uniqueness():
|
||||
"""Test that generated codes are unique."""
|
||||
codes = [generate_activation_code() for _ in range(100)]
|
||||
assert len(codes) == len(set(codes))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_creation():
|
||||
"""Test creating an activation code."""
|
||||
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
assert activation_code.code == "TEST1234ABCD5678"
|
||||
assert activation_code.max_uses == 1
|
||||
assert activation_code.current_uses == 0
|
||||
assert activation_code.is_active is True
|
||||
assert activation_code.expires_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_auto_generated_code():
|
||||
"""Test that activation code is auto-generated if not provided."""
|
||||
code = ActivationCodeFactory()
|
||||
assert len(code.code) == 16
|
||||
assert code.code.isupper()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_str_representation():
|
||||
"""Test string representation of activation code."""
|
||||
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
assert str(activation_code) == "TEST1234ABCD5678 (0/1)"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_str_representation_unlimited():
|
||||
"""Test string representation of unlimited activation code."""
|
||||
unlimited_activation_code = ActivationCodeFactory(code="UNLIMITED123CODE", max_uses=0)
|
||||
|
||||
assert str(unlimited_activation_code) == "UNLIMITED123CODE (0/∞)"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_is_valid_active():
|
||||
"""Test that an active, non-expired code is valid."""
|
||||
activation_code = ActivationCodeFactory()
|
||||
assert activation_code.is_valid() is True
|
||||
assert activation_code.can_be_used() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_is_valid_inactive():
|
||||
"""Test that an inactive code is not valid."""
|
||||
inactive_activation_code = ActivationCodeFactory(is_active=False)
|
||||
assert inactive_activation_code.is_valid() is False
|
||||
assert inactive_activation_code.can_be_used() is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_is_valid_expired():
|
||||
"""Test that an expired code is not valid."""
|
||||
expired_activation_code = ActivationCodeFactory(
|
||||
created_at=timezone.now() - timedelta(days=10),
|
||||
expires_at=timezone.now() - timedelta(days=1),
|
||||
)
|
||||
assert expired_activation_code.is_valid() is False
|
||||
assert expired_activation_code.can_be_used() is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_is_valid_max_uses_reached():
|
||||
"""Test that a code with max uses reached is not valid."""
|
||||
activation_code = ActivationCodeFactory(max_uses=1)
|
||||
activation_code.current_uses = 1
|
||||
activation_code.save()
|
||||
assert activation_code.is_valid() is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_is_valid_unlimited_uses():
|
||||
"""Test that unlimited code is always valid regardless of current uses."""
|
||||
unlimited_activation_code = ActivationCodeFactory(max_uses=0)
|
||||
unlimited_activation_code.current_uses = 100
|
||||
unlimited_activation_code.save()
|
||||
assert unlimited_activation_code.is_valid() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_use_success():
|
||||
"""Test successfully using an activation code."""
|
||||
user = UserFactory()
|
||||
activation_code = ActivationCodeFactory()
|
||||
activation = activation_code.use(user)
|
||||
|
||||
assert isinstance(activation, UserActivation)
|
||||
assert activation.user == user
|
||||
assert activation.activation_code == activation_code
|
||||
|
||||
# Check that usage counter was incremented
|
||||
activation_code.refresh_from_db()
|
||||
assert activation_code.current_uses == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_use_invalid_code():
|
||||
"""Test using an invalid activation code raises error."""
|
||||
inactive_activation_code = ActivationCodeFactory(is_active=False)
|
||||
user = UserFactory()
|
||||
with pytest.raises(InvalidCodeError):
|
||||
inactive_activation_code.use(user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_use_already_activated():
|
||||
"""Test using a code when user is already activated raises error."""
|
||||
user = UserFactory()
|
||||
activation_code = ActivationCodeFactory()
|
||||
|
||||
# First activation
|
||||
activation_code.use(user)
|
||||
|
||||
# Try to activate again with a different code
|
||||
another_code = ActivationCodeFactory(code="ANOTHER123456789")
|
||||
with pytest.raises(UserAlreadyActivatedError):
|
||||
another_code.use(user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_use_multi_use():
|
||||
"""Test using a multi-use activation code."""
|
||||
multi_use_activation_code = ActivationCodeFactory(max_uses=4)
|
||||
users = [UserFactory(email=f"user{i}@example.com") for i in range(3)]
|
||||
|
||||
for i, user in enumerate(users):
|
||||
activation = multi_use_activation_code.use(user)
|
||||
assert activation.user == user
|
||||
|
||||
multi_use_activation_code.refresh_from_db()
|
||||
assert multi_use_activation_code.current_uses == i + 1
|
||||
|
||||
# Code should still be valid
|
||||
assert multi_use_activation_code.is_valid() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_use_max_uses_exceeded():
|
||||
"""Test that code cannot be used when max uses is reached."""
|
||||
user = UserFactory()
|
||||
activation_code = ActivationCodeFactory(max_uses=1)
|
||||
|
||||
# Use the code
|
||||
activation_code.use(user)
|
||||
|
||||
# Try to use it again with another user
|
||||
another_user = UserFactory(email="another@example.com")
|
||||
with pytest.raises(InvalidCodeError):
|
||||
activation_code.use(another_user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_expiration():
|
||||
"""Test that code expires correctly."""
|
||||
future_expiry = timezone.now() + timedelta(days=1)
|
||||
code = ActivationCodeFactory(code="FUTURE123456789", expires_at=future_expiry)
|
||||
|
||||
assert code.is_valid() is True
|
||||
|
||||
# Manually set to past
|
||||
code.expires_at = timezone.now() - timedelta(seconds=1)
|
||||
code.save()
|
||||
|
||||
assert code.is_valid() is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_str_representation():
|
||||
"""Test string representation of user activation."""
|
||||
user_activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
|
||||
|
||||
expected = f"{user_activation.user} - TEST1234ABCD5678"
|
||||
assert str(user_activation) == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_one_to_one_relationship():
|
||||
"""Test that a user can only have one activation."""
|
||||
user_activation = UserActivationFactory()
|
||||
|
||||
# Try to create another activation for the same user
|
||||
with pytest.raises(ValidationError): # should be IntegrityError
|
||||
UserActivationFactory(user=user_activation.user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_protect_on_delete():
|
||||
"""Test that activation code is protected from deletion when used."""
|
||||
user_activation = UserActivationFactory()
|
||||
|
||||
# Try to delete the activation code
|
||||
with pytest.raises(ProtectedError):
|
||||
user_activation.activation_code.delete()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_cascade_on_user_delete():
|
||||
"""Test that activation is deleted when user is deleted."""
|
||||
activation = UserActivationFactory()
|
||||
activation_id = activation.pk
|
||||
|
||||
activation.user.delete()
|
||||
|
||||
# Activation should be deleted
|
||||
assert not UserActivation.objects.filter(id=activation_id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_ordering():
|
||||
"""Test that activation codes are ordered by created_at descending."""
|
||||
code1 = ActivationCodeFactory(code="CODE1")
|
||||
code2 = ActivationCodeFactory(code="CODE2")
|
||||
code3 = ActivationCodeFactory(code="CODE3")
|
||||
|
||||
codes = list(ActivationCode.objects.all())
|
||||
assert codes == [code3, code2, code1]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_ordering():
|
||||
"""Test that user activations are ordered by created_at descending."""
|
||||
code1 = ActivationCodeFactory(code="CODE1", max_uses=3)
|
||||
code2 = ActivationCodeFactory(code="CODE2", max_uses=3)
|
||||
|
||||
user1 = UserFactory(email="user1@example.com")
|
||||
user2 = UserFactory(email="user2@example.com")
|
||||
|
||||
activation1 = UserActivationFactory(user=user1, activation_code=code1)
|
||||
activation2 = UserActivationFactory(user=user2, activation_code=code2)
|
||||
|
||||
activations = list(UserActivation.objects.all())
|
||||
assert activations == [activation2, activation1]
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_activation_code_use_success_notify_brevo(settings):
|
||||
"""Test successfully using an activation code and notify Brevo."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
settings.BREVO_FOLLOWUP_LIST_ID = "test_followup_list_name"
|
||||
|
||||
brevo_remove_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/remove",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_add_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_followup_list_name/contacts/add",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
registration = UserRegistrationRequest.objects.create(user=user)
|
||||
activation_code = ActivationCodeFactory()
|
||||
activation = activation_code.use(user)
|
||||
|
||||
registration.refresh_from_db()
|
||||
assert registration.user_activation == activation
|
||||
|
||||
assert len(brevo_remove_mock.calls) == 1
|
||||
assert brevo_remove_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_remove_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1
|
||||
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_create_contact.calls[0].request.body) == {
|
||||
"email": user.email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
|
||||
assert len(brevo_add_mock.calls) == 1
|
||||
assert brevo_add_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_add_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for activation_codes permissions."""
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from activation_codes.factories import UserActivationFactory
|
||||
from activation_codes.permissions import IsActivatedUser
|
||||
|
||||
|
||||
@pytest.fixture(name="request_factory")
|
||||
def request_factory_fixture():
|
||||
"""Fixture to provide a request factory."""
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture(name="view")
|
||||
def view_fixture():
|
||||
"""Fixture to provide a basic view instance."""
|
||||
return APIView()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_activation_not_required(request_factory, view, settings):
|
||||
"""Test that permission allows access when activation is not required."""
|
||||
settings.ACTIVATION_REQUIRED = False
|
||||
user = UserFactory()
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_staff_user(request_factory, view, settings):
|
||||
"""Test that staff users always have permission."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
staff_user = UserFactory(email="staff@example.com", password="password123", is_staff=True)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = staff_user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_anonymous_user(request_factory, view, settings):
|
||||
"""Test that anonymous users are allowed (handled by other permissions)."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = None
|
||||
|
||||
permission = IsActivatedUser()
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_activated_user(request_factory, view, settings):
|
||||
"""Test that activated users have permission."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
# Activate the user
|
||||
activation = UserActivationFactory()
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = activation.user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_not_activated_user(request_factory, view, settings):
|
||||
"""Test that non-activated users do not have permission."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
assert permission.has_permission(request, view) is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_permission_custom_message():
|
||||
"""Test that permission has custom message for frontend."""
|
||||
permission = IsActivatedUser()
|
||||
assert permission.message == "activation-required"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_object_permission(request_factory, view, settings):
|
||||
"""Test object-level permission delegates to has_permission."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
_user = UserFactory()
|
||||
|
||||
# Activate the user
|
||||
activation = UserActivationFactory()
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = activation.user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
obj = object() # Any object
|
||||
|
||||
# Object permission should delegate to has_permission
|
||||
assert permission.has_object_permission(request, view, obj) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_activated_user_object_permission_not_activated(request_factory, view, settings):
|
||||
"""Test object-level permission when user is not activated."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.user = user
|
||||
|
||||
permission = IsActivatedUser()
|
||||
obj = object()
|
||||
|
||||
assert permission.has_object_permission(request, view, obj) is False
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for activation_codes serializers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
|
||||
from activation_codes.serializers import (
|
||||
ActivationCodeValidationSerializer,
|
||||
ActivationStatusSerializer,
|
||||
UserActivationSerializer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_valid_code():
|
||||
"""Test validating a valid activation code."""
|
||||
# Create a valid activation code
|
||||
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
serializer = ActivationCodeValidationSerializer(data={"code": "TEST1234ABCD5678"})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_normalize_lowercase():
|
||||
"""Test that code is normalized to uppercase."""
|
||||
# Create a valid activation code
|
||||
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
serializer = ActivationCodeValidationSerializer(data={"code": "test1234abcd5678"})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_normalize_with_spaces():
|
||||
"""Test that spaces are removed from code."""
|
||||
# Create a valid activation code
|
||||
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
serializer = ActivationCodeValidationSerializer(data={"code": "TEST 1234 ABCD 5678"})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_normalize_with_dashes():
|
||||
"""Test that dashes are removed from code."""
|
||||
# Create a valid activation code
|
||||
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
serializer = ActivationCodeValidationSerializer(data={"code": "TEST-1234-ABCD-5678"})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_normalize_mixed():
|
||||
"""Test that code with spaces, dashes and lowercase is normalized."""
|
||||
# Create a valid activation code
|
||||
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
serializer = ActivationCodeValidationSerializer(data={"code": " test-1234 abcd-5678 "})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_code_validation_serializer_missing_code():
|
||||
"""Test that code field is required."""
|
||||
serializer = ActivationCodeValidationSerializer(data={})
|
||||
assert not serializer.is_valid()
|
||||
assert "code" in serializer.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_serializer():
|
||||
"""Test serializing a user activation."""
|
||||
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
|
||||
|
||||
serializer = UserActivationSerializer(activation)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert data["code"] == "TEST1234ABCD5678"
|
||||
assert "activated_at" in data
|
||||
assert data["activated_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_activation_serializer_read_only_fields():
|
||||
"""Test that all fields are read-only."""
|
||||
activation = UserActivationFactory()
|
||||
|
||||
serializer = UserActivationSerializer(activation)
|
||||
|
||||
# All fields should be in read_only_fields
|
||||
meta = serializer.Meta
|
||||
assert set(meta.read_only_fields) == set(meta.fields)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_serializer_activated():
|
||||
"""Test serializing activation status for activated user."""
|
||||
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
|
||||
|
||||
data = {"is_activated": True, "activation": activation, "requires_activation": True}
|
||||
|
||||
serializer = ActivationStatusSerializer(data)
|
||||
serialized_data = serializer.data
|
||||
|
||||
assert serialized_data["is_activated"] is True
|
||||
assert serialized_data["activation"] is not None
|
||||
assert serialized_data["activation"]["code"] == "TEST1234ABCD5678"
|
||||
assert serialized_data["requires_activation"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_serializer_not_activated():
|
||||
"""Test serializing activation status for non-activated user."""
|
||||
data = {"is_activated": False, "activation": None, "requires_activation": True}
|
||||
|
||||
serializer = ActivationStatusSerializer(data)
|
||||
serialized_data = serializer.data
|
||||
|
||||
assert serialized_data["is_activated"] is False
|
||||
assert serialized_data["activation"] is None
|
||||
assert serialized_data["requires_activation"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_serializer_activation_not_required():
|
||||
"""Test serializing activation status when activation is not required."""
|
||||
data = {"is_activated": False, "activation": None, "requires_activation": False}
|
||||
|
||||
serializer = ActivationStatusSerializer(data)
|
||||
serialized_data = serializer.data
|
||||
|
||||
assert serialized_data["is_activated"] is False
|
||||
assert serialized_data["activation"] is None
|
||||
assert serialized_data["requires_activation"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_serializer_all_fields_read_only():
|
||||
"""Test that all fields in ActivationStatusSerializer are read-only."""
|
||||
serializer = ActivationStatusSerializer()
|
||||
|
||||
for field_name, field in serializer.fields.items():
|
||||
assert field.read_only is True, f"Field {field_name} should be read-only"
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Tests for activation_codes viewsets."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
|
||||
from activation_codes.models import ActivationCode, UserActivation, UserRegistrationRequest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_unauthenticated(api_client):
|
||||
"""Test that unauthenticated users cannot access status endpoint."""
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_authenticated_not_activated(api_client, settings):
|
||||
"""Test activation status for authenticated but not activated user."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["is_activated"] is False
|
||||
assert response.data["activation"] is None
|
||||
assert response.data["requires_activation"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_authenticated_activated(api_client, settings):
|
||||
"""Test activation status for activated user."""
|
||||
settings.ACTIVATION_REQUIRED = True
|
||||
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
|
||||
api_client.force_authenticate(user=activation.user)
|
||||
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["is_activated"] is True
|
||||
assert response.data["activation"] is not None
|
||||
assert response.data["activation"]["code"] == "TEST1234ABCD5678"
|
||||
assert "activated_at" in response.data["activation"]
|
||||
assert response.data["requires_activation"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_activation_status_activation_not_required(api_client, settings):
|
||||
"""Test activation status when activation is not required."""
|
||||
settings.ACTIVATION_REQUIRED = False
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.get("/api/v1.0/activation/status/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["requires_activation"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_unauthenticated(api_client):
|
||||
"""Test that unauthenticated users cannot validate codes."""
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_success(api_client):
|
||||
"""Test successfully validating and using an activation code."""
|
||||
user = UserFactory()
|
||||
activation_code = ActivationCode.objects.create(code="TEST1234ABCD5678")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
with patch("activation_codes.viewsets.logger") as mock_logger:
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "Your account has been successfully activated" in response.data["detail"]
|
||||
assert "activation" in response.data
|
||||
assert response.data["activation"]["code"] == "TEST1234ABCD5678"
|
||||
|
||||
# Verify user is now activated
|
||||
assert UserActivation.objects.filter(user=user).exists()
|
||||
|
||||
# Verify activation code was used
|
||||
activation_code.refresh_from_db()
|
||||
assert activation_code.current_uses == 1
|
||||
|
||||
# Verify logging
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_with_spaces_and_lowercase(api_client):
|
||||
"""Test validating code with spaces and lowercase."""
|
||||
user = UserFactory()
|
||||
ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "test 1234 abcd 5678"})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert UserActivation.objects.filter(user=user).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_already_activated(api_client):
|
||||
"""Test validating code when user is already activated."""
|
||||
# First activation
|
||||
activation = UserActivationFactory()
|
||||
api_client.force_authenticate(user=activation.user)
|
||||
|
||||
# Try to activate again with different code
|
||||
_another_code = ActivationCodeFactory(code="ANOTHER123456789")
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "ANOTHER123456789"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data == {"code": "account-already-activated"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_nonexistent(api_client):
|
||||
"""Test validating a non-existent code."""
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "NONEXISTENT12345"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data == {"code": "invalid-code"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_invalid_serializer(api_client):
|
||||
"""Test validating with invalid data."""
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/validate/",
|
||||
{}, # Missing code
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "code" in response.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_inactive(api_client):
|
||||
"""Test validating an inactive code."""
|
||||
user = UserFactory()
|
||||
ActivationCodeFactory(code="INACTIVE12345678", is_active=False)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "INACTIVE12345678"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.data == {"code": "invalid-code"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_expired(api_client):
|
||||
"""Test validating an expired code."""
|
||||
user = UserFactory()
|
||||
ActivationCodeFactory(code="EXPIRED123456789", expires_at=timezone.now() - timedelta(days=1))
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "EXPIRED123456789"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_max_uses_reached(api_client):
|
||||
"""Test validating a code that has reached max uses."""
|
||||
user = UserFactory()
|
||||
ActivationCodeFactory(code="MAXUSED123456789", max_uses=1, current_uses=1)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "MAXUSED123456789"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_multi_use(api_client):
|
||||
"""Test using a multi-use code with multiple users."""
|
||||
multi_use_activation_code = ActivationCodeFactory(
|
||||
code="MULTIUSE12345678",
|
||||
max_uses=3,
|
||||
)
|
||||
users = []
|
||||
for i in range(3):
|
||||
user = UserFactory(email=f"user{i}@example.com")
|
||||
users.append(user)
|
||||
|
||||
for i, user in enumerate(users):
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "MULTIUSE12345678"})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
multi_use_activation_code.refresh_from_db()
|
||||
assert multi_use_activation_code.current_uses == i + 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_unlimited_use(api_client):
|
||||
"""Test using an unlimited code with multiple users."""
|
||||
unlimited_activation_code = ActivationCodeFactory(
|
||||
code="UNLIMITED123CODE",
|
||||
max_uses=0, # Unlimited uses
|
||||
)
|
||||
for i in range(10):
|
||||
user = UserFactory(email=f"user{i}@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "UNLIMITED123CODE"})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Code should still be valid
|
||||
unlimited_activation_code.refresh_from_db()
|
||||
assert unlimited_activation_code.is_valid() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_logging_on_validation_error(api_client):
|
||||
"""Test that validation errors are logged."""
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# Create a code that will cause validation error
|
||||
code = ActivationCodeFactory(
|
||||
code="WILLEXPIRE123456", expires_at=timezone.now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
# Make it expire
|
||||
code.expires_at = timezone.now() - timedelta(seconds=1)
|
||||
code.save()
|
||||
|
||||
with patch("activation_codes.viewsets.logger"):
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "WILLEXPIRE123456"})
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Note: In this case the serializer will catch it first
|
||||
# so the warning might not be called, but this tests the flow
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unauthenticated_register_email(api_client):
|
||||
"""Test that unauthenticated users cannot register email."""
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{
|
||||
"email": "test@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_register_email_success(api_client):
|
||||
"""Test successfully registering an email."""
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
registration = UserRegistrationRequest.objects.get(user=user)
|
||||
assert registration.user == user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_register_already_created(api_client):
|
||||
"""Test successfully registering an email."""
|
||||
user = UserFactory()
|
||||
_registration = UserRegistrationRequest.objects.create(
|
||||
user=user,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"code": "registration-successful"}
|
||||
|
||||
assert UserRegistrationRequest.objects.filter(user=user).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_code_registered_user(api_client):
|
||||
"""Test validating a code for a user with a pre-existing registration."""
|
||||
user = UserFactory()
|
||||
_registration = UserRegistrationRequest.objects.create(
|
||||
user=user,
|
||||
)
|
||||
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
_registration.refresh_from_db()
|
||||
assert _registration.user_activation.activation_code == activation_code
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_register_email_success_brevo(api_client, settings):
|
||||
"""Test successfully registering an email and notify Brevo."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
|
||||
brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
|
||||
json={"message": "Contacts added successfully"},
|
||||
status=201,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
registration = UserRegistrationRequest.objects.get(user=user)
|
||||
assert registration.user == user
|
||||
|
||||
assert len(brevo_create_contact.calls) == 1
|
||||
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_create_contact.calls[0].request.body) == {
|
||||
"email": user.email,
|
||||
"updateEnabled": True,
|
||||
}
|
||||
|
||||
assert len(brevo_mock.calls) == 1
|
||||
assert brevo_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
|
||||
assert json.loads(brevo_mock.calls[0].request.body) == {"emails": [user.email]}
|
||||
|
||||
# Register again to test idempotency
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
assert len(brevo_mock.calls) == 1 # No new call made
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_register_email_success_brevo_fails(api_client, settings):
|
||||
"""Test successfully registering an email, even if Brevo fails."""
|
||||
settings.BREVO_API_KEY = "test_brevo_api_key"
|
||||
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
|
||||
|
||||
_brevo_create_contact = responses.post(
|
||||
"https://api.brevo.com/v3/contacts",
|
||||
status=200,
|
||||
)
|
||||
|
||||
brevo_mock = responses.post(
|
||||
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
|
||||
status=400,
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1.0/activation/register/",
|
||||
{},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["code"] == "registration-successful"
|
||||
|
||||
registration = UserRegistrationRequest.objects.get(user=user)
|
||||
assert registration.user == user
|
||||
|
||||
assert len(brevo_mock.calls) == 1
|
||||
@@ -0,0 +1,153 @@
|
||||
"""API ViewSets for activation codes."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.brevo import add_user_to_brevo_list
|
||||
from core.permissions import IsAuthenticated
|
||||
|
||||
from . import models, serializers
|
||||
from .exceptions import InvalidCodeError, UserAlreadyActivatedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActivationViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
ViewSet for handling user activation with codes.
|
||||
|
||||
Endpoints:
|
||||
- GET /activation/status/ - Check if current user is activated
|
||||
- POST /activation/validate/ - Validate and use an activation code
|
||||
- POST /activation/register/ - Register an email to be notified later
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = serializers.ActivationCodeValidationSerializer
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="status")
|
||||
def status(self, request):
|
||||
"""
|
||||
Get the activation status of the current user.
|
||||
|
||||
Returns:
|
||||
- is_activated: Whether the user has activated their account
|
||||
- activation: Details of the activation (if exists)
|
||||
- requires_activation: Whether activation is required by the system
|
||||
"""
|
||||
requires_activation = getattr(settings, "ACTIVATION_REQUIRED", False)
|
||||
|
||||
try:
|
||||
activation = models.UserActivation.objects.select_related("activation_code").get(
|
||||
user=request.user
|
||||
)
|
||||
is_activated = True
|
||||
except models.UserActivation.DoesNotExist:
|
||||
activation = None
|
||||
is_activated = False
|
||||
|
||||
response_data = {
|
||||
"is_activated": is_activated,
|
||||
"activation": activation,
|
||||
"requires_activation": requires_activation,
|
||||
}
|
||||
|
||||
return Response(
|
||||
serializers.ActivationStatusSerializer(response_data).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="validate")
|
||||
def validate_code(self, request):
|
||||
"""
|
||||
Validate an activation code and activate the user's account.
|
||||
|
||||
Request body:
|
||||
- code: The activation code to validate
|
||||
|
||||
Returns:
|
||||
- Success: Activation details
|
||||
- Error: Validation error message
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code_value = serializer.validated_data["code"]
|
||||
|
||||
# Get the activation code
|
||||
try:
|
||||
activation_code = models.ActivationCode.objects.get(code=code_value)
|
||||
except models.ActivationCode.DoesNotExist:
|
||||
logger.info("Activation code %s does not exist", code_value)
|
||||
return Response({"code": "invalid-code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Use the code
|
||||
try:
|
||||
activation = activation_code.use(request.user)
|
||||
except InvalidCodeError as exc:
|
||||
logger.warning(exc)
|
||||
return Response({"code": "invalid-code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except UserAlreadyActivatedError as exc:
|
||||
logger.info(exc)
|
||||
return Response(
|
||||
{"code": "account-already-activated"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
logger.info("User %s activated account with code %s", request.user.id, activation_code.code)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": "activation-successful",
|
||||
"detail": _("Your account has been successfully activated"),
|
||||
"activation": serializers.UserActivationSerializer(activation).data,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="register")
|
||||
def register_email(self, request):
|
||||
"""
|
||||
Register an email to be notified when activation codes are available.
|
||||
|
||||
Request body:
|
||||
- email: The email address to register
|
||||
|
||||
Returns:
|
||||
- Success: Confirmation message
|
||||
- Error: Validation error message
|
||||
"""
|
||||
serializer = serializers.UserRegistrationRequestSerializer(
|
||||
data={},
|
||||
context={"request": request},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Create the registration
|
||||
try:
|
||||
serializer.save()
|
||||
except ValidationError:
|
||||
# user is already registered, it's OK
|
||||
return Response(
|
||||
{"code": "registration-successful"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
add_user_to_brevo_list(
|
||||
[serializer.validated_data["user"].email], settings.BREVO_WAITING_LIST_ID
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Registered email %s for activation notifications",
|
||||
serializer.validated_data["user"].email,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": "registration-successful"},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Admin classes and registrations for chat application."""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.ChatConversation)
|
||||
class ChatConversationAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the ChatConversation model"""
|
||||
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Constants and schemas for the Albert RAG agent from Albert API codebase."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Dict, List, Literal, Optional, Self
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints, model_validator
|
||||
|
||||
|
||||
# - app/schemas/chunks.py
|
||||
class Chunk(BaseModel):
|
||||
"""Model representing a chunk of text with metadata."""
|
||||
|
||||
object: Literal["chunk"] = "chunk"
|
||||
id: int
|
||||
metadata: Dict[str, Any]
|
||||
content: str
|
||||
|
||||
|
||||
class Chunks(BaseModel):
|
||||
"""Model representing a list of chunks."""
|
||||
|
||||
object: Literal["list"] = "list"
|
||||
data: List[Chunk]
|
||||
|
||||
|
||||
# - app/schemas/usage.py
|
||||
class CarbonFootprintUsageKWh(BaseModel):
|
||||
"""Model representing the carbon footprint usage in kWh (kilowatt-hours)."""
|
||||
|
||||
min: Optional[float] = Field(default=None, description="Minimum carbon footprint in kWh.")
|
||||
max: Optional[float] = Field(default=None, description="Maximum carbon footprint in kWh.")
|
||||
|
||||
|
||||
class CarbonFootprintUsageKgCO2eq(BaseModel):
|
||||
"""Model representing the carbon footprint usage in kgCO2eq (kilograms of CO2 equivalent)."""
|
||||
|
||||
min: Optional[float] = Field(
|
||||
default=None, description="Minimum carbon footprint in kgCO2eq (global warming potential)."
|
||||
)
|
||||
max: Optional[float] = Field(
|
||||
default=None, description="Maximum carbon footprint in kgCO2eq (global warming potential)."
|
||||
)
|
||||
|
||||
|
||||
class CarbonFootprintUsage(BaseModel):
|
||||
"""Model representing the carbon footprint usage in kWh and kgCO2eq."""
|
||||
|
||||
kWh: CarbonFootprintUsageKWh = Field(default_factory=CarbonFootprintUsageKWh)
|
||||
kgCO2eq: CarbonFootprintUsageKgCO2eq = Field(default_factory=CarbonFootprintUsageKgCO2eq)
|
||||
|
||||
|
||||
class BaseUsage(BaseModel):
|
||||
"""Base model for usage statistics in the Albert API."""
|
||||
|
||||
prompt_tokens: int = Field(
|
||||
default=0, description="Number of prompt tokens (e.g. input tokens)."
|
||||
)
|
||||
completion_tokens: int = Field(
|
||||
default=0, description="Number of completion tokens (e.g. output tokens)."
|
||||
)
|
||||
total_tokens: int = Field(
|
||||
default=0, description="Total number of tokens (e.g. input and output tokens)."
|
||||
)
|
||||
cost: float = Field(default=0.0, description="Total cost of the request.")
|
||||
carbon: CarbonFootprintUsage = Field(default_factory=CarbonFootprintUsage)
|
||||
|
||||
|
||||
# - app/schemas/usage.py
|
||||
class Detail(BaseModel):
|
||||
"""Model representing a detail in the usage statistics."""
|
||||
|
||||
id: str
|
||||
model: str
|
||||
usage: BaseUsage = Field(default_factory=BaseUsage)
|
||||
|
||||
|
||||
class Usage(BaseUsage):
|
||||
"""Model representing the usage statistics for the Albert API."""
|
||||
|
||||
details: List[Detail] = []
|
||||
|
||||
|
||||
class SearchMethod(str, Enum):
|
||||
"""
|
||||
Enum representing the search methods available (will be displayed in this order in playground).
|
||||
"""
|
||||
|
||||
MULTIAGENT = "multiagent"
|
||||
HYBRID = "hybrid"
|
||||
SEMANTIC = "semantic"
|
||||
LEXICAL = "lexical"
|
||||
|
||||
|
||||
class SearchArgs(BaseModel):
|
||||
"""Model representing the arguments for a search request in the Albert API."""
|
||||
|
||||
collections: List[Any] = Field(default=[], description="List of collections ID")
|
||||
rff_k: int = Field(default=20, description="k constant in RFF algorithm")
|
||||
k: int = Field(gt=0, default=4, description="Number of results to return")
|
||||
method: SearchMethod = Field(default=SearchMethod.SEMANTIC)
|
||||
score_threshold: Optional[float] = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Score of cosine similarity threshold for filtering results, "
|
||||
"only available for semantic search method."
|
||||
),
|
||||
)
|
||||
web_search: bool = Field(
|
||||
default=False, description="Whether add internet search to the results."
|
||||
)
|
||||
web_search_k: int = Field(default=5, description="Number of results to return for web search.")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def score_threshold_filter(self) -> Self:
|
||||
"""Validate the score threshold based on the search method."""
|
||||
if self.score_threshold and self.method not in (
|
||||
SearchMethod.SEMANTIC,
|
||||
SearchMethod.MULTIAGENT,
|
||||
):
|
||||
raise ValueError(
|
||||
"Score threshold is only available for semantic and multiagent search methods."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class SearchRequest(SearchArgs):
|
||||
"""Model representing a search request in the Albert API."""
|
||||
|
||||
prompt: Annotated[
|
||||
str,
|
||||
StringConstraints(strip_whitespace=True, min_length=1),
|
||||
] = Field(description="Prompt related to the search")
|
||||
|
||||
|
||||
class Search(BaseModel):
|
||||
"""Model representing a search result in the Albert API."""
|
||||
|
||||
method: SearchMethod
|
||||
score: float
|
||||
chunk: Chunk
|
||||
|
||||
|
||||
class Searches(BaseModel):
|
||||
"""Model representing a list of search results in the Albert API."""
|
||||
|
||||
object: Literal["list"] = "list"
|
||||
data: List[Search]
|
||||
usage: Usage = Field(default_factory=Usage, description="Usage information for the request.")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Constants for RAG (Retrieval-Augmented Generation) results."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RAGWebUsage(BaseModel):
|
||||
"""
|
||||
Model representing the usage statistics for web results in RAG (Retrieval-Augmented Generation).
|
||||
"""
|
||||
|
||||
prompt_tokens: int = Field(default=0, description="Number of prompt tokens used.")
|
||||
completion_tokens: int = Field(default=0, description="Number of completion tokens generated.")
|
||||
|
||||
|
||||
class RAGWebResult(BaseModel):
|
||||
"""Model representing a single web result in RAG (Retrieval-Augmented Generation)."""
|
||||
|
||||
url: str = Field(..., description="URL of the web result.")
|
||||
content: str = Field(..., description="Content of the web result chunk.")
|
||||
score: float = Field(
|
||||
..., description="Relevance score of the web result, typically between 0 and 1."
|
||||
)
|
||||
|
||||
|
||||
class RAGWebResults(BaseModel):
|
||||
"""Model representing a list of web results in RAG (Retrieval-Augmented Generation)."""
|
||||
|
||||
data: List[RAGWebResult]
|
||||
usage: RAGWebUsage = Field(..., description="RAG usage statistics.")
|
||||
|
||||
def to_prompt(self) -> str:
|
||||
"""Convert the web results to a prompt string."""
|
||||
_format = " - From: {url}:\n content: {content}\n\n"
|
||||
return (
|
||||
"\n\n".join(
|
||||
_format.format(url=result.url, content=result.content) for result in self.data
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Document Converter using MarkItDown"""
|
||||
|
||||
import os.path
|
||||
from io import BytesIO
|
||||
|
||||
from markitdown import MarkItDown
|
||||
|
||||
|
||||
class DocumentConverter:
|
||||
"""Simple document converter that uses MarkItDown to convert documents to Markdown format."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the DocumentConverter with MarkItDown."""
|
||||
self.converter = MarkItDown()
|
||||
|
||||
def convert_raw( # pylint: disable=unused-argument
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> str:
|
||||
"""
|
||||
Convert a document to Markdown format.
|
||||
The name, content_type, and content parameters comes from the user input
|
||||
(vercel SDK Attachment, or BinaryContent).
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (bytes): The content of the document as bytes.
|
||||
"""
|
||||
return self._convert(BytesIO(content), file_extension=os.path.splitext(name)[1])
|
||||
|
||||
def _convert(self, document: BytesIO, file_extension: str) -> str:
|
||||
"""
|
||||
Convert the given document using the underlying DocumentConverter.
|
||||
"""
|
||||
conversion = self.converter.convert_stream(
|
||||
document, file_extension=file_extension or ".txt"
|
||||
)
|
||||
document_markdown = conversion.text_content
|
||||
return document_markdown
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user