🎉(all) bootstrap the Messages project

We used the Drive repository as starting point. There are still
some leftovers that will be cleaned up over time.
This commit is contained in:
Sylvain Zimmer
2025-04-13 18:48:04 +02:00
commit 53db5a748d
200 changed files with 27755 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
---
description: Rules for writing Python with Django
globs: src/backend/**/*.py
alwaysApply: false
---
You are an expert in Python, Django, and scalable web application development.
Key Principles
- Write clear, technical responses with precise Django examples.
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance for the most part, with the one exception being 100 characters per line instead of 79).
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
Django/Python
- Use Django REST Framework viewsets for API endpoints.
- Leverage Djangos ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Djangos built-in user model and authentication framework for user management.
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
Error Handling and Validation
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
- Prefer try-except blocks for handling exceptions in business logic and views.
Dependencies
- Django
- Django REST Framework (for API development)
- Celery (for background tasks)
- Redis (for caching and task queues)
- PostgreSQL (preferred databases for production)
- Minio (file storage for production)
- OIDC prodiver (for managing authentication)
Django-Specific Guidelines
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
- Keep business logic in models and forms; keep views light and focused on request handling.
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
- Use Djangos built-in tools for testing (pytest-django) to ensure code quality and reliability.
- Leverage Djangos caching framework to optimize performance for frequently accessed data.
- Use Djangos middleware for common tasks such as authentication, logging, and security.
Performance Optimization
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
- Use Djangos cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Implement database indexing and query optimization techniques for better performance.
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
- Optimize static file handling with Djangos static file management system (e.g., WhiteNoise).
Logging
- As a general rule, we should have logs for every expected and unexpected actions of the application, using the appropriate log level.
- We should also be logging these exceptions to Sentry with the Sentry Python SDK. Python exceptions should almost always be captured automatically without extra instrumentation, but custom ones (such as failed requests to external services, query errors, or Celery task failures) can be tracked using capture_exception().
Log Levels
- A log level or log severity is a piece of information telling how important a given log message is:
- DEBUG: should be used for information that may be needed for diagnosing issues and troubleshooting or when running application in the test environment for the purpose of making sure everything is running correctly
- INFO: should be used as standard log level, indicating that something happened
- WARN: should be used when something unexpected happened but the code can continue the work
- ERROR: should be used when the application hits an issue preventing one or more functionalities from properly functioning
Security
- Dont log sensitive information. Make sure you never log:
- authorization tokens
- passwords
- financial data
- health data
- PII (Personal Identifiable Information)
Testing
- All new packages and most new significant functionality should come with unit tests
Unit tests
- A good unit test should:
- focus on a single use-case at a time
- have a minimal set of assertions per test
- demonstrate every use case. The rule of thumb is: if it can happen, it should be covered
Refer to Django documentation for best practices in views, models, forms, and security considerations.

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
.venv
# System-specific files
.DS_Store
**/.DS_Store
# 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

6
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -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!
-->

28
.github/ISSUE_TEMPLATE/Bug_report.md vendored Normal file
View File

@@ -0,0 +1,28 @@
---
name: 🐛 Bug Report
about: If something is not working as expected 🤔.
---
## 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**
- Drive version:
- Platform:
**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.

View File

@@ -0,0 +1,23 @@
---
name: ✨ Feature Request
about: I have a suggestion (and may want to build it 💪)!
---
## 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 a version the docs (if applicable).
Maybe 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! -->

View File

@@ -0,0 +1,22 @@
---
name: 🤗 Support Question
about: If you have a question 💬, or something was not clear from the docs!
---
<!-- ^ Click "Preview" for a nicer view! ^
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
---
Please make sure you have read our [main Readme](https://github.com/suitenumerique/st-messages).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/st-messages/issues).
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 🙏

11
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...

76
.github/workflows/crowdin_download.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
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/impress/
touch src/frontend/packages/i18n/locales/impress/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: 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

67
.github/workflows/crowdin_upload.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
synchronize-with-crowdin:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- 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 python manage.py makemessages -a --keep-pot
# frontend i18n
- 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/"

View File

@@ -0,0 +1,36 @@
name: Install frontend installation reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
jobs:
front-dependencies-installation:
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 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') }}

81
.gitignore vendored Normal file
View File

@@ -0,0 +1,81 @@
# 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
# 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
# Various
.turbo

139
Dockerfile Normal file
View File

@@ -0,0 +1,139 @@
# Django messages
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
apk add git
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
# Copy required python dependencies
COPY ./src/backend /builder
RUN mkdir /install && \
pip install --prefix=/install .
# ---- static link collector ----
FROM base AS link-collector
ARG MESSAGES_STATIC_ROOT=/data/static
# Install pango & rdfind
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy messages application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /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 ${MESSAGES_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 \
pandoc \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /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 installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy messages application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
# 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
# Uninstall messages and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y messages
RUN pip install -e .[dev]
# 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
ARG MESSAGES_STATIC_ROOT=/data/static
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/messages.py /usr/local/etc/gunicorn/messages.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy statics
COPY --from=link-collector ${MESSAGES_STATIC_ROOT} ${MESSAGES_STATIC_ROOT}
# The default command runs gunicorn WSGI server in messages's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/messages.py", "messages.wsgi:application"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Direction Interministérielle du Numérique - Gouvernement Français
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

306
Makefile Normal file
View File

@@ -0,0 +1,306 @@
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ 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_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
# ==============================================================================
# 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
bootstrap: ## Prepare Docker images for the project
bootstrap: \
data/media \
data/static \
create-env-files \
build \
migrate \
back-i18n-compile
.PHONY: bootstrap
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-frontend-dev cache=$(cache)
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-frontend-dev: cache ?=
build-frontend-dev: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend-dev
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
run-all-fg:
@$(COMPOSE) up --force-recreate --build nginx frontend-dev
.PHONY: run-all-fg
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
stop: ## stop the development server using Docker
@$(COMPOSE) 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 messages project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the messages 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 django 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
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
.PHONY: clean
clean-media: ## remove all media files
rm -rf data/media/*
.PHONY: clean-media
help:
@echo "$(BOLD)messages 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-install: ## install the frontend locally
@$(COMPOSE) run --rm frontend-install
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
@$(COMPOSE) run --rm frontend-dev npm run lint
.PHONY: frontend-lint
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
@$(COMPOSE) run --rm frontend-dev npm run 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
@$(COMPOSE) run --rm frontend-dev npm run i18n:deploy
.PHONY: frontend-i18n-compile

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Messages
Messages is the all-in-one collaborative inbox for [La Suite territoriale](https://suiteterritoriale.anct.gouv.fr/).
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
## Getting started
### Prerequisite
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your machine:
```bash
$ docker -v
Docker version 27.5.1, build 9f9e405
$ docker compose version
Docker Compose version v2.32.4
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Bootstrap project
The easiest way to start working on the project is to use GNU Make:
```bash
$ make bootstrap
```
This command builds the `app-dev` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running! 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
```bash
$ make run
```
You can check all available Make rules using:
```bash
$ make help
```
### Django admin
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```bash
$ make superuser
```
You can then login with email `admin@example.com` and password `admin`.
### Run frontend
Run the front with:
```bash
$ make run-with-frontend
```
Then access [http://localhost:3000](http://localhost:3000) with :
user: user{1,2,3}
password: user{1,2,3}
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
## License
This work is released under the MIT License (see [LICENSE](./LICENSE)).

93
bin/_config.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
if [ -z ${COMPOSE_FILE+x} ]; then
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
fi
# _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) project, 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 "$@"
}

6
bin/compose Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"

6
bin/manage Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_django_manage "$@"

38
bin/pylint Executable file
View File

@@ -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[@]}"

8
bin/pytest Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \
pytest "$@"

17
bin/update_app_cacert.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -o errexit
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
# The script is executed with the following command:
# $ bin/update_app_cacert.sh docs-production-backend-1
CONTAINER_NAME=${1:-"st-messages-production-backend-1"}
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
echo "end patching cacert.pem in ${CONTAINER_NAME}"

12
bin/update_openapi_schema Executable file
View File

@@ -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 'impress.api_urls' \
--format openapi-json \
--file /app/core/tests/swagger/swagger.json

209
compose.yaml Normal file
View File

@@ -0,0 +1,209 @@
name: st-messages
services:
postgresql:
image: postgres:16.6
ports:
- "6434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
redis:
image: redis:5
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
# minio:
# user: ${DOCKER_USER:-1000}
# image: minio/minio
# environment:
# - MINIO_ROOT_USER=st-messages
# - 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 st-messages http://minio:9000 st-messages password && \
# /usr/bin/mc mb st-messages/st-messages-media-storage && \
# /usr/bin/mc version enable st-messages/st-messages-media-storage && \
# exit 0;"
app-dev:
build:
context: .
target: backend-development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: st-messages:backend-development
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
# createbuckets:
# condition: service_started
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: st-messages:backend-production
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
#minio:
# condition: service_started
nginx:
image: nginx:1.25
ports:
- "8083:8083"
volumes:
- ./docker/files/development/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: st-messages-dev
args:
API_ORIGIN: "http://localhost:8071"
S3_DOMAIN_REPLACE: "http://localhost:9000"
image: st-messages:frontend-development
volumes:
- ./src/frontend/:/home/frontend/
ports:
- "3000:3000"
frontend-install:
profiles:
- frontend-install
build:
dockerfile: ./src/frontend/Dockerfile.install
volumes:
- ./src/frontend/:/home/frontend/
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
S3_DOMAIN_REPLACE: "http://localhost:9000"
image: st-messages:frontend-production
ports:
- "3001: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:16.6
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "6433:5432"
env_file:
- env.d/development/kc_postgresql
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL_HOST: kc_postgresql
KC_DB_URL_DATABASE: keycloak
KC_DB_PASSWORD: pass
KC_DB_USERNAME: user
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: "true"
ports:
- "8080:8080"
depends_on:
- kc_postgresql

2309
docker/auth/realm.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
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/messages-media-storage/;
# proxy_set_header Host minio:9000;
# }
# location /media-auth {
# proxy_pass http://app-dev:8000/api/v1.0/items/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;
}
}

View File

@@ -0,0 +1,13 @@
server {
listen 8083;
server_name localhost;
charset utf-8;
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;
}
}

View File

@@ -0,0 +1,80 @@
upstream docs_backend {
server backend:8000 fail_timeout=0;
}
upstream docs_frontend {
server frontend:8080 fail_timeout=0;
}
server {
listen 8083 ssl;
server_name localhost;
# Disables server version feedback on pages and in headers
server_tokens off;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location @proxy_to_docs_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
# 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/docs-media-storage/;
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
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;
}
}

View File

@@ -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' st-messages-backend: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 st-messages-backend: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 "$@"

View File

@@ -0,0 +1,16 @@
# Gunicorn-django settings
bind = ["0.0.0.0:8000"]
name = "messages"
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"

View File

@@ -0,0 +1,56 @@
# Django
DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=messages.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
# Drive settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite territoriale"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=messages
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/messages/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/messages/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/messages/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/messages/protocol/openid-connect/userinfo
OIDC_RP_CLIENT_ID=messages
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"}
# Collaboration
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -0,0 +1,3 @@
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

View File

@@ -0,0 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=user
POSTGRES_PASSWORD=pass
# App database configuration
DB_HOST=kc_postgresql
DB_NAME=keycloak
DB_USER=user
DB_PASSWORD=pass
DB_PORT=5433

View File

@@ -0,0 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=messages
POSTGRES_USER=user
POSTGRES_PASSWORD=pass
# App database configuration
DB_HOST=postgresql
DB_NAME=messages
DB_USER=user
DB_PASSWORD=pass
DB_PORT=5432

19
renovate.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"enabled": false,
"groupName": "ignored python dependencies",
"matchManagers": ["pep621"],
"matchPackageNames": []
},
{
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": []
}
]
}

472
src/backend/.pylintrc Normal file
View File

@@ -0,0 +1,472 @@
[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
# 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*(#\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

3
src/backend/MANIFEST.in Normal file
View File

@@ -0,0 +1,3 @@
include LICENSE
include README.md
recursive-include src/backend/messages *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2

0
src/backend/__init__.py Normal file
View File

View File

93
src/backend/core/admin.py Normal file
View File

@@ -0,0 +1,93 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from . import models
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
fieldsets = (
(
None,
{
"fields": (
"id",
"admin_email",
"password",
)
},
),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_device",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = (
"id",
"sub",
"full_name",
"admin_email",
"email",
"is_active",
"is_staff",
"is_superuser",
"is_device",
"created_at",
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")

View File

@@ -0,0 +1,41 @@
"""Messages core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework import exceptions as drf_exceptions
from rest_framework import views as drf_views
from rest_framework.decorators import api_view
from rest_framework.response import Response
def exception_handler(exc, context):
"""Handle Django ValidationError as an accepted exception.
For the parameters, see ``exception_handler``
This code comes from twidi's gist:
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
detail = None
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
exc = drf_exceptions.ValidationError(detail=detail)
return drf_views.exception_handler(exc, context)
# pylint: disable=unused-argument
@api_view(["GET"])
def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)

View File

@@ -0,0 +1,25 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers
class JSONField(serializers.Field):
"""
A custom field for handling JSON data.
"""
def to_representation(self, value):
"""
Convert the JSON string to a Python dictionary for serialization.
"""
return value
def to_internal_value(self, data):
"""
Convert the Python dictionary to a JSON string for deserialization.
"""
if data is None:
return None
return json.dumps(data)

View File

@@ -0,0 +1,7 @@
"""API filters for messages' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from core import models

View File

@@ -0,0 +1,82 @@
"""Permission handlers for the messages core app."""
from django.core import exceptions
from django.db.models import Q
from django.http import Http404
from rest_framework import permissions
from core.models import RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
class IsAuthenticated(permissions.BasePermission):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
"""
def has_permission(self, request, view):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
"""
def has_object_permission(self, request, view, obj):
"""Write permissions are only allowed to the user itself."""
return obj == request.user
class IsOwnedOrPublic(IsAuthenticated):
"""
Allows access to authenticated users only for objects that are owned or not related
to any user via the "owner" field.
"""
def has_object_permission(self, request, view, obj):
"""Unsafe permissions are only allowed for the owner of the object."""
if obj.owner == request.user:
return True
if request.method in permissions.SAFE_METHODS and obj.owner is None:
return True
try:
return obj.user == request.user
except exceptions.ObjectDoesNotExist:
return False
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)

View File

@@ -0,0 +1,92 @@
"""Client serializers for the messages core app."""
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, serializers
from core import models
from core.api import utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs

View File

@@ -0,0 +1,110 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
def flat_to_nested(items):
"""
Create a nested tree structure from a flat list of items.
"""
# Create a dictionary to hold nodes by their path
node_dict = {}
roots = []
# Sort the flat list by path to ensure parent nodes are processed first
items.sort(key=lambda x: x["path"])
for item in items:
item["children"] = [] # Initialize children list
node_dict[item["path"]] = item
# Determine parent path
parent_path = ".".join(item["path"].split(".")[:-1])
if parent_path in node_dict:
node_dict[parent_path]["children"].append(item)
else:
roots.append(item) # Collect root nodes
if len(roots) > 1:
raise ValueError("More than one root element detected")
return roots[0] if roots else {}
def filter_root_paths(paths, skip_sorting=False):
"""
Filters root paths from a list of paths representing a tree structure.
A root path is defined as a path that is not a prefix of any other path.
Args:
paths (list of PathValue): The list of paths.
Returns:
list of str: The filtered list of root paths.
"""
if not skip_sorting:
paths.sort()
root_paths = []
for path in paths:
# If the current path is not a prefix of the last added root path, add it
if not root_paths or not str(path).startswith(str(root_paths[-1])):
root_paths.append(path)
return root_paths
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our items' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
def generate_upload_policy(item):
"""
Generate a S3 upload policy for a given item.
"""
# Generate a unique key for the item
key = f"{item.key_base}/{item.filename}"
# Generate the policy
s3_client = default_storage.connection.meta.client
policy = s3_client.generate_presigned_post(
default_storage.bucket_name,
key,
Fields={"acl": "private"},
Conditions=[
{"acl": "private"},
["content-length-range", 0, settings.ITEM_FILE_MAX_SIZE],
],
ExpiresIn=settings.AWS_S3_UPLOAD_POLICY_EXPIRATION,
)
return policy

View File

@@ -0,0 +1,352 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db import transaction
from django.db.models.expressions import RawSQL
import magic
import rest_framework as drf
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import enums, models
from . import permissions, serializers, utils
logger = logging.getLogger(__name__)
ITEM_FOLDER = "item"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"(\.[a-zA-Z0-9]+)?$"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
f"(?P<key>{ITEM_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/.*{FILE_EXT_REGEX:s})$"
)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 200
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
pagination_class = None
throttle_classes = []
def get_throttles(self):
self.throttle_classes = []
if self.action == "list":
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
return super().get_throttles()
def get_queryset(self):
"""
Limit listed users by querying the email field with a trigram similarity
search if a query is provided.
Limit listed users by excluding users already in the item if a item_id
is provided.
"""
queryset = self.queryset
if self.action != "list":
return queryset
# Exclude all users already in the given item
if item_id := self.request.query_params.get("item_id", ""):
queryset = queryset.exclude(itemaccess__item_id=item_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
)
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
permission_classes=[permissions.IsAuthenticated],
)
def get_me(self, request):
"""
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
self.serializer_class(request.user, context=context).data
)
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def get_permissions(self):
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
resource = getattr(instance, self.resource_field_name)
# Check if the access being deleted is the last owner access for the resource
if (
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
resource = getattr(instance, self.resource_field_name)
# Check if the access being updated is the last owner access for the resource
if (
instance.role == models.RoleChoices.OWNER
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class ItemMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/items/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)

11
src/backend/core/apps.py Normal file
View File

@@ -0,0 +1,11 @@
"""Messages Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the messages core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("messages core application")

View File

@@ -0,0 +1,52 @@
"""Custom authentication classes for the messages core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create item server to server'"

View File

@@ -0,0 +1,130 @@
"""Authentication Backends for the messages core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
}
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(id=user.id).update(**updated_claims)

View File

@@ -0,0 +1,18 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]

View File

@@ -0,0 +1,137 @@
"""Authentication Views for the People core app."""
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)

12
src/backend/core/enums.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Core application enums declaration
"""
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}

View File

@@ -0,0 +1,46 @@
# ruff: noqa: S311
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
import factory.fuzzy
from faker import Faker
from core import models
fake = Faker()
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node."""
def generate(self, step, params):
"""
Generate a parent node for the factory.
This method is invoked during the factory's build process to determine the parent
node of the current object being created. If `params` is provided, it uses the factory's
metadata to recursively create or fetch the parent node. Otherwise, it returns `None`.
"""
if not params:
return None
subfactory = step.builder.factory_meta.factory
return step.recurse(subfactory, params)

View File

@@ -0,0 +1,47 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
UserModel = get_user_model()
class Command(BaseCommand):
"""Management command to create a superuser from an email and password."""
help = "Create a superuser with an email and a password"
def add_arguments(self, parser):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help=("Email for the user."),
)
parser.add_argument(
"--password",
help="Password for the user.",
)
def handle(self, *args, **options):
"""
Given an email and a password, create a superuser or upgrade the existing
user to superuser status.
"""
email = options.get("email")
try:
user = UserModel.objects.get(admin_email=email)
except UserModel.DoesNotExist:
user = UserModel(admin_email=email)
message = "Superuser created successfully."
else:
if user.is_superuser and user.is_staff:
message = "Superuser already exists."
else:
message = "User already existed and was upgraded to superuser."
user.is_superuser = True
user.is_staff = True
user.set_password(options["password"])
user.save()
self.stdout.write(self.style.SUCCESS(message))

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.1.8 on 2025-04-13 15:05
import core.models
import django.core.validators
import timezone_field.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=[('fr-fr', 'French'), ('en-us', 'English'), ('de-de', 'German')], default='fr-fr', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'messages_user',
},
managers=[
('objects', core.models.UserManager()),
],
),
]

View File

338
src/backend/core/models.py Normal file
View File

@@ -0,0 +1,338 @@
"""
Declare and configure the models for the messages core application
"""
# pylint: disable=too-many-lines
import smtplib
import uuid
from collections import defaultdict
from datetime import timedelta
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.indexes import GistIndex
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models.expressions import RawSQL
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django_ltree.managers import TreeManager, TreeQuerySet
from django_ltree.models import TreeModel
from django_ltree.paths import PathGenerator
from timezone_field import TimeZoneField
logger = getLogger(__name__)
def get_trashbin_cutoff():
"""
Calculate the cutoff datetime for soft-deleted items based on the retention policy.
The function returns the current datetime minus the number of days specified in
the TRASHBIN_CUTOFF_DAYS setting, indicating the oldest date for items that can
remain in the trash bin.
Returns:
datetime: The cutoff datetime for soft-deleted items.
"""
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a item."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the item
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the item
PUBLIC = "public", _("Public") # Even anonymous users can access the item
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
is_device = models.BooleanField(
_("device"),
default=False,
help_text=_("Whether the user is a device or a real user."),
)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "messages_user"
verbose_name = _("user")
verbose_name_plural = _("users")
def __str__(self):
return self.email or self.admin_email or str(self.id)
@cached_property
def teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
"""
return []
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class ItemQuerySet(TreeQuerySet):
"""Custom queryset for Item model with additional methods."""
def readable_per_se(self, user):
"""
Filters the queryset to return documents that the given user has
permission to read.
:param user: The user for whom readable documents are to be fetched.
:return: A queryset of documents readable by the user.
"""
if user.is_authenticated:
return self.filter(
models.Q(accesses__user=user)
| models.Q(accesses__team__in=user.teams)
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
)
return self.filter(models.Q(link_reach=LinkReachChoices.PUBLIC))

View File

@@ -0,0 +1,58 @@
"""Custom template tags for the core application."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""

View File

View File

@@ -0,0 +1,551 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the user's info sub, the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_sub(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_email(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the name fields on the user when they change
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": user.email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
"""
klass = OIDCAuthenticationBackend()
email = "drive@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
"""Test get_userinfo method with a token response."""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not match the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
# Essential claims
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
"""The sub claim should be mandatory by default."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
KeyError,
match="sub",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@pytest.mark.parametrize(
"essential_claims, missing_claims",
[
(["email", "sub"], ["email"]),
(["Email", "sub"], ["Email"]), # Case sensitivity
],
)
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@mock.patch.object(Logger, "error")
def test_authentication_verify_claims_essential_missing(
mock_logger,
essential_claims,
missing_claims,
django_assert_num_queries,
monkeypatch,
):
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"sub": "123",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
),
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
@override_settings(
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
)
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
"""Ensure user is authenticated when all essential claims are present."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"email": "john.doe@example.com",
"last_name": "Doe",
"sub": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(
6 + 15
): # 6 for user creation, 15 for workspace creation
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.filter(id=user.id).exists()
assert user.sub == "123"
assert user.full_name == "Doe"
assert user.short_name is None
assert user.email == "john.doe@example.com"

View File

@@ -0,0 +1,10 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")

View File

@@ -0,0 +1,231 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"

View File

@@ -0,0 +1,18 @@
"""Fixtures for tests in the messages core application"""
from unittest import mock
import pytest
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams

View File

@@ -0,0 +1,42 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO
from django.core.management import call_command
from django.test import Client
import pytest
pytestmark = pytest.mark.django_db
def test_openapi_client_schema():
"""
Generated and served OpenAPI client schema should be correct.
"""
# Start by generating the swagger.json file
output = StringIO()
call_command(
"spectacular",
"--api-version",
"v1.0",
"--urlconf",
"core.urls",
"--format",
"openapi-json",
"--file",
"core/tests/swagger/swagger.json",
stdout=output,
)
assert output.getvalue() == ""
response = Client().get("/v1.0/swagger.json")
assert response.status_code == 200
with open(
"core/tests/swagger/swagger.json", "r", encoding="utf-8"
) as expected_schema:
assert response.json() == json.load(expected_schema)

View File

@@ -0,0 +1,45 @@
"""
Test config API endpoints in the messages core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -0,0 +1,573 @@
"""
Test users API endpoints in the messages core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_users_list_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory()
client = APIClient()
response = client.get("/api/v1.0/users/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_list_authenticated():
"""
Authenticated users should not be able to list users without a query.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == 200
assert response.json() == []
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(lennon.id)]
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 5
# if the limit is changed, all users should be returned
settings.API_USERS_LIST_LIMIT = 100
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 15
def test_api_users_list_throttling_authenticated(settings):
"""
Authenticated users should be throttled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
for _i in range(3):
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 429
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users and filter by email.
Only results with a Levenstein distance less than 3 with the query should be returned.
We want to match by Levenstein distance because we want to prevent typing errors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr")
user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users while filtering by email
and excluding users who have access to a item.
"""
user = factories.UserFactory()
item = factories.ItemFactory()
client = APIClient()
client.force_login(user)
nicole_fool = factories.UserFactory(email="nicole_fool@work.com")
nicole_pool = factories.UserFactory(email="nicole_pool@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
factories.UserItemAccessFactory(item=item, user=nicole_pool)
response = client.get(
"/api/v1.0/users/?q=nicole_fool@work.com&item_id=" + str(item.id)
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(nicole_fool.id)]
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
user = factories.UserFactory()
response = client.get(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_authenticated_self():
"""
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "GET" not allowed.'}
def test_api_users_retrieve_authenticated_other():
"""
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "GET" not allowed.'}
def test_api_users_create_anonymous():
"""Anonymous users should not be able to create users via the API."""
response = APIClient().post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.User.objects.exists() is False
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
format="json",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert models.User.objects.exclude(id=user.id).exists() is False
def test_api_users_update_anonymous():
"""Anonymous users should not be able to update users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = APIClient().put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_update_authenticated_self():
"""
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_anonymous():
"""Anonymous users should not be able to patch users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = APIClient().patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_authenticated_self():
"""
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == 401
assert models.User.objects.count() == 2
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
)
assert response.status_code == 405
assert models.User.objects.count() == 3
def test_api_users_delete_anonymous():
"""Anonymous users should not be allowed to delete a user."""
user = factories.UserFactory()
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == 401
assert models.User.objects.count() == 1
def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
response = client.delete(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == 405
assert models.User.objects.count() == 2
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == 405
assert models.User.objects.count() == 1

View File

@@ -0,0 +1,96 @@
"""
Unit tests for the filter_root_paths utility function.
"""
from django_ltree.fields import PathValue
from core.api.utils import filter_root_paths
def test_api_utils_filter_root_paths_success():
"""
The `filter_root_paths` function should correctly identify root paths
from a given list of paths.
This test uses a list of paths with missing intermediate paths to ensure that
only the minimal set of root paths is returned.
"""
paths = [
PathValue("0001"),
PathValue("0001.0001"),
PathValue("0001.0001.0001"),
PathValue("0001.0001.0002"),
# missing 00010002
PathValue("0001.0002.0001"),
PathValue("0001.0002.0002"),
PathValue("0002"),
PathValue("0002.0001"),
PathValue("0002.0002"),
# missing 0003
PathValue("0003.0001"),
PathValue("0003.0001.0001"),
PathValue("0003.0002"),
# missing 0004
# missing 00040001
# missing 000400010001
# missing 000400010002
PathValue("0004.0001.0003"),
PathValue("0004.0001.0003.0001"),
PathValue("0004.0001.0004"),
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
PathValue("0001"),
PathValue("0002"),
PathValue("0003.0001"),
PathValue("0003.0002"),
PathValue("0004.0001.0003"),
PathValue("0004.0001.0004"),
]
def test_api_utils_filter_root_paths_sorting():
"""
The `filter_root_paths` function should fail is sorting is skipped and paths are not sorted.
This test verifies that when sorting is skipped, the function respects the input order, and
when sorting is enabled, the result is correctly ordered and minimal.
"""
paths = [
PathValue("0001"),
PathValue("0001.0001"),
PathValue("0001.0001.0001"),
PathValue("0001.0002.0002"),
PathValue("0001.0001.0002"),
PathValue("0001.0002.0001"),
PathValue("0002.0001"),
PathValue("0002"),
PathValue("0002.0002"),
PathValue("0003.0001.0001"),
PathValue("0003.0001"),
PathValue("0003.0002"),
PathValue("0004.0001.0003.0001"),
PathValue("0004.0001.0003"),
PathValue("0004.0001.0004"),
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
PathValue("0001"),
PathValue("0002.0001"),
PathValue("0002"),
PathValue("0003.0001.0001"),
PathValue("0003.0001"),
PathValue("0003.0002"),
PathValue("0004.0001.0003.0001"),
PathValue("0004.0001.0003"),
PathValue("0004.0001.0004"),
]
filtered_paths = filter_root_paths(paths)
assert filtered_paths == [
PathValue("0001"),
PathValue("0002"),
PathValue("0003.0001"),
PathValue("0003.0002"),
PathValue("0004.0001.0003"),
PathValue("0004.0001.0004"),
]

View File

@@ -0,0 +1,183 @@
"""Test the utils function flat_to_nested."""
import pytest
from core.api.utils import flat_to_nested
def test_flat_to_nested_with_ordered_list():
"""Test the function flat_to_nested."""
flat_items_list = [
{"depth": 1, "path": "0000000", "title": "root"},
{"depth": 2, "path": "0000000.0000000", "title": "level1_1"},
{"depth": 3, "path": "0000000.0000000.0000003", "title": "level2_1"},
{"depth": 3, "path": "0000000.0000000.0000004", "title": "level2_2"},
{"depth": 4, "path": "0000000.0000000.0000004.0000004", "title": "level3_1"},
{"depth": 2, "path": "0000000.0000001", "title": "level1_2"},
{"depth": 2, "path": "0000000.0000002", "title": "level1_3"},
]
assert flat_to_nested(flat_items_list) == {
"depth": 1,
"path": "0000000",
"title": "root",
"children": [
{
"depth": 2,
"path": "0000000.0000000",
"title": "level1_1",
"children": [
{
"depth": 3,
"path": "0000000.0000000.0000003",
"title": "level2_1",
"children": [],
},
{
"depth": 3,
"path": "0000000.0000000.0000004",
"title": "level2_2",
"children": [
{
"depth": 4,
"path": "0000000.0000000.0000004.0000004",
"title": "level3_1",
"children": [],
}
],
},
],
},
{
"depth": 2,
"path": "0000000.0000001",
"title": "level1_2",
"children": [],
},
{
"depth": 2,
"path": "0000000.0000002",
"title": "level1_3",
"children": [],
},
],
}
def test_utils_flat_to_nested_with_not_ordered_list():
"""Test the function flat_to_nested."""
flat_items_list = [
{"depth": 2, "path": "0000000.0000000", "title": "level1_1"},
{"depth": 3, "path": "0000000.0000000.0000003", "title": "level2_1"},
{"depth": 2, "path": "0000000.0000001", "title": "level1_2"},
{"depth": 1, "path": "0000000", "title": "root"},
{"depth": 2, "path": "0000000.0000002", "title": "level1_3"},
{"depth": 3, "path": "0000000.0000000.0000004", "title": "level2_2"},
{"depth": 4, "path": "0000000.0000000.0000004.0000004", "title": "level3_1"},
]
assert flat_to_nested(flat_items_list) == {
"depth": 1,
"path": "0000000",
"title": "root",
"children": [
{
"depth": 2,
"path": "0000000.0000000",
"title": "level1_1",
"children": [
{
"depth": 3,
"path": "0000000.0000000.0000003",
"title": "level2_1",
"children": [],
},
{
"depth": 3,
"path": "0000000.0000000.0000004",
"title": "level2_2",
"children": [
{
"depth": 4,
"path": "0000000.0000000.0000004.0000004",
"title": "level3_1",
"children": [],
}
],
},
],
},
{
"depth": 2,
"path": "0000000.0000001",
"title": "level1_2",
"children": [],
},
{
"depth": 2,
"path": "0000000.0000002",
"title": "level1_3",
"children": [],
},
],
}
def test_utils_flat_to_nested_not_started_with_a_depth_1():
"""Test the function flat_to_nested."""
flat_items_list = [
{"depth": 2, "path": "0000000.0000000", "title": "level1_1"},
{"depth": 3, "path": "0000000.0000000.0000000", "title": "level2_1"},
{"depth": 3, "path": "0000000.0000000.0000001", "title": "level2_2"},
]
assert flat_to_nested(flat_items_list) == {
"depth": 2,
"path": "0000000.0000000",
"title": "level1_1",
"children": [
{
"depth": 3,
"path": "0000000.0000000.0000000",
"title": "level2_1",
"children": [],
},
{
"depth": 3,
"path": "0000000.0000000.0000001",
"title": "level2_2",
"children": [],
},
],
}
def test_utils_flat_to_nested_with_two_root_elements():
"""
Test the function flat_to_nested with multiple root elements. Should raise a ValueError
if it's the case.
"""
flat_items_list = [
{"depth": 1, "path": "0000000", "title": "root1"},
{"depth": 1, "path": "0000001", "title": "root2"},
{"depth": 2, "path": "0000000.0000000", "title": "level1_1"},
{"depth": 3, "path": "0000000.0000000.0000000", "title": "level2_1"},
{"depth": 3, "path": "0000000.0000000.0000001", "title": "level2_2"},
{"depth": 2, "path": "0000001.0000001", "title": "level1_2"},
{"depth": 3, "path": "0000001.0000001.0000001", "title": "level2_3"},
{"depth": 3, "path": "0000001.0000001.0000002", "title": "level2_4"},
]
with pytest.raises(ValueError):
flat_to_nested(flat_items_list)
def test_utils_flat_to_nested_with_empty_list():
"""Test the function flat_to_nested with an empty list. Should return an empty dict."""
# pylint: disable=use-implicit-booleaness-not-comparison
assert flat_to_nested([]) == {}

View File

@@ -0,0 +1,55 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_users_str():
"""The str representation should be the email."""
user = factories.UserFactory()
assert str(user) == user.email
def test_models_users_id_unique():
"""The "id" field should be unique."""
user = factories.UserFactory()
with pytest.raises(ValidationError, match="User with this Id already exists."):
factories.UserFactory(id=user.id)
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
def test_models_users_save_create_main_workspace():
"""The "save' method should create a main workspace for the user."""
user = factories.UserFactory()
item = models.Item.objects.get(creator=user, main_workspace=True)
assert models.ItemAccess.objects.filter(
user=user, role=models.RoleChoices.OWNER, item=item
).exists()

View File

@@ -0,0 +1,30 @@
"""
Unit tests for the User model
"""
import pytest
from messages.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)

27
src/backend/core/urls.py Normal file
View File

@@ -0,0 +1,27 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
include(
[
*router.urls,
*oidc_urls
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]

View File

@@ -0,0 +1,463 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-05 15:02+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-drive.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:42 core/admin.py:122
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:54
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:132
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/filters.py:19
msgid "Favorite"
msgstr "Favorit"
#: core/api/filters.py:22
msgid "Title"
msgstr "Titel"
#: core/api/serializers.py:319
msgid "This field is required for files."
msgstr ""
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "Benutzerkonto ist deaktiviert"
#: core/enums.py:19
msgid "First child"
msgstr ""
#: core/enums.py:20
msgid "Last child"
msgstr ""
#: core/enums.py:21
msgid "First sibling"
msgstr ""
#: core/enums.py:22
msgid "Last sibling"
msgstr ""
#: core/enums.py:23
msgid "Left"
msgstr ""
#: core/enums.py:24
msgid "Right"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Reader"
msgstr "Lesen"
#: core/models.py:55 core/models.py:62
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:64
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:75
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:79
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:81
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:87
msgid "Folder"
msgstr ""
#: core/models.py:88
msgid "Link"
msgstr ""
#: core/models.py:89
msgid "File"
msgstr ""
#: core/models.py:95
msgid "Pending"
msgstr ""
#: core/models.py:96
msgid "Uploading"
msgstr ""
#: core/models.py:97
msgid "Uploaded"
msgstr ""
#: core/models.py:119
msgid "id"
msgstr ""
#: core/models.py:120
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:126
msgid "created on"
msgstr "Erstellt"
#: core/models.py:127
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:132
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:133
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:169
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:182
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, "
"Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:188
msgid "sub"
msgstr "unter"
#: core/models.py:190
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen "
"@/./+/-/_/:"
#: core/models.py:199
msgid "full name"
msgstr "Name"
#: core/models.py:200
msgid "short name"
msgstr "Kurzbezeichnung"
#: core/models.py:202
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:207
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: core/models.py:214
msgid "language"
msgstr "Sprache"
#: core/models.py:215
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:221
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:224
msgid "device"
msgstr "Gerät"
#: core/models.py:226
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:229
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: core/models.py:231
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:234
msgid "active"
msgstr "aktiviert"
#: core/models.py:237
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie "
"diese Option, anstatt Konten zu löschen."
#: core/models.py:249
msgid "user"
msgstr "Benutzer"
#: core/models.py:250
msgid "users"
msgstr "Benutzer"
#: core/models.py:385
msgid "title"
msgstr "Titel"
#: core/models.py:427
msgid "Item"
msgstr ""
#: core/models.py:428
#, fuzzy
#| msgid "items"
msgid "Items"
msgstr "Dokumente"
#: core/models.py:440
#, fuzzy
#| msgid "Untitled item"
msgid "Untitled Item"
msgstr "Unbenanntes Dokument"
#: core/models.py:626
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you!"
msgid "{name} shared an item with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:628
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
"{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:631
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:665
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is not deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:671
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:707
msgid "Only folders can have children."
msgstr ""
#: core/models.py:714
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:736
#, fuzzy
#| msgid "item/user link trace"
msgid "Item/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:737
#, fuzzy
#| msgid "item/user link traces"
msgid "Item/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:743
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:766
#, fuzzy
#| msgid "item favorite"
msgid "Item favorite"
msgstr "Dokumentenfavorit"
#: core/models.py:767
#, fuzzy
#| msgid "item favorites"
msgid "Item favorites"
msgstr "Dokumentfavoriten"
#: core/models.py:773
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
"Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:795
#, fuzzy
#| msgid "item/user relation"
msgid "Item/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:796
#, fuzzy
#| msgid "item/user relations"
msgid "Item/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:802
msgid "This user is already in this item."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:808
msgid "This team is already in this item."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:814
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:841
msgid "email address"
msgstr "E-Mail-Adresse"
#: core/models.py:860
#, fuzzy
#| msgid "item invitation"
msgid "Item invitation"
msgstr "Einladung zum Dokument"
#: core/models.py:861
#, fuzzy
#| msgid "item invitations"
msgid "Item invitations"
msgstr "Dokumenteinladungen"
#: core/models.py:881
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Drive, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: drive/settings.py:239
msgid "English"
msgstr "Englisch"
#: drive/settings.py:240
msgid "French"
msgstr "Französisch"
#: drive/settings.py:241
msgid "German"
msgstr "Deutsch"
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Sie sind Besitzer eines neuen Dokuments:"
#~ msgid "Body"
#~ msgstr "Inhalt"
#~ msgid "Body type"
#~ msgstr "Typ"
#~ msgid "item"
#~ msgstr "Dokument"
#~ msgid "description"
#~ msgstr "Beschreibung"
#~ msgid "code"
#~ msgstr "Code"
#~ msgid "css"
#~ msgstr "CSS"
#~ msgid "public"
#~ msgstr "öffentlich"
#~ msgid "Whether this template is public for anyone to use."
#~ msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#~ msgid "Template"
#~ msgstr "Vorlage"
#~ msgid "Templates"
#~ msgstr "Vorlagen"
#~ msgid "Template/user relation"
#~ msgstr "Vorlage/Benutzer-Beziehung"
#~ msgid "Template/user relations"
#~ msgstr "Vorlage/Benutzerbeziehungen"
#~ msgid "This user is already in this template."
#~ msgstr "Dieser Benutzer ist bereits in dieser Vorlage."

View File

@@ -0,0 +1,386 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-05 15:02+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-drive.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:29
msgid "Personal info"
msgstr ""
#: core/admin.py:42 core/admin.py:122
msgid "Permissions"
msgstr ""
#: core/admin.py:54
msgid "Important dates"
msgstr ""
#: core/admin.py:132
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:319
msgid "This field is required for files."
msgstr ""
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/enums.py:19
msgid "First child"
msgstr ""
#: core/enums.py:20
msgid "Last child"
msgstr ""
#: core/enums.py:21
msgid "First sibling"
msgstr ""
#: core/enums.py:22
msgid "Last sibling"
msgstr ""
#: core/enums.py:23
msgid "Left"
msgstr ""
#: core/enums.py:24
msgid "Right"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:55 core/models.py:62
msgid "Editor"
msgstr ""
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:75
msgid "Restricted"
msgstr ""
#: core/models.py:79
msgid "Authenticated"
msgstr ""
#: core/models.py:81
msgid "Public"
msgstr ""
#: core/models.py:87
msgid "Folder"
msgstr ""
#: core/models.py:88
msgid "Link"
msgstr ""
#: core/models.py:89
msgid "File"
msgstr ""
#: core/models.py:95
msgid "Pending"
msgstr ""
#: core/models.py:96
msgid "Uploading"
msgstr ""
#: core/models.py:97
msgid "Uploaded"
msgstr ""
#: core/models.py:119
msgid "id"
msgstr ""
#: core/models.py:120
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:126
msgid "created on"
msgstr ""
#: core/models.py:127
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:132
msgid "updated on"
msgstr ""
#: core/models.py:133
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:169
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:182
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:188
msgid "sub"
msgstr ""
#: core/models.py:190
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:199
msgid "full name"
msgstr ""
#: core/models.py:200
msgid "short name"
msgstr ""
#: core/models.py:202
msgid "identity email address"
msgstr ""
#: core/models.py:207
msgid "admin email address"
msgstr ""
#: core/models.py:214
msgid "language"
msgstr ""
#: core/models.py:215
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:221
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:224
msgid "device"
msgstr ""
#: core/models.py:226
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:229
msgid "staff status"
msgstr ""
#: core/models.py:231
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:234
msgid "active"
msgstr ""
#: core/models.py:237
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:249
msgid "user"
msgstr ""
#: core/models.py:250
msgid "users"
msgstr ""
#: core/models.py:385
msgid "title"
msgstr ""
#: core/models.py:427
msgid "Item"
msgstr ""
#: core/models.py:428
msgid "Items"
msgstr ""
#: core/models.py:440
msgid "Untitled Item"
msgstr ""
#: core/models.py:626
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr ""
#: core/models.py:628
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
#: core/models.py:631
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr ""
#: core/models.py:665
msgid "This item is not deleted."
msgstr ""
#: core/models.py:671
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:707
msgid "Only folders can have children."
msgstr ""
#: core/models.py:714
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:736
msgid "Item/user link trace"
msgstr ""
#: core/models.py:737
msgid "Item/user link traces"
msgstr ""
#: core/models.py:743
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:766
msgid "Item favorite"
msgstr ""
#: core/models.py:767
msgid "Item favorites"
msgstr ""
#: core/models.py:773
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:795
msgid "Item/user relation"
msgstr ""
#: core/models.py:796
msgid "Item/user relations"
msgstr ""
#: core/models.py:802
msgid "This user is already in this item."
msgstr ""
#: core/models.py:808
msgid "This team is already in this item."
msgstr ""
#: core/models.py:814
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:841
msgid "email address"
msgstr ""
#: core/models.py:860
msgid "Item invitation"
msgstr ""
#: core/models.py:861
msgid "Item invitations"
msgstr ""
#: core/models.py:881
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Drive, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: drive/settings.py:239
msgid "English"
msgstr ""
#: drive/settings.py:240
msgid "French"
msgstr ""
#: drive/settings.py:241
msgid "German"
msgstr ""

View File

@@ -0,0 +1,393 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-05 15:02+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-drive.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:29
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:42 core/admin.py:122
msgid "Permissions"
msgstr ""
#: core/admin.py:54
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:132
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:319
msgid "This field is required for files."
msgstr ""
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/enums.py:19
msgid "First child"
msgstr ""
#: core/enums.py:20
msgid "Last child"
msgstr ""
#: core/enums.py:21
msgid "First sibling"
msgstr ""
#: core/enums.py:22
msgid "Last sibling"
msgstr ""
#: core/enums.py:23
msgid "Left"
msgstr ""
#: core/enums.py:24
msgid "Right"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:55 core/models.py:62
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:63
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:64
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:75
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:79
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:81
msgid "Public"
msgstr ""
#: core/models.py:87
msgid "Folder"
msgstr ""
#: core/models.py:88
msgid "Link"
msgstr ""
#: core/models.py:89
msgid "File"
msgstr ""
#: core/models.py:95
msgid "Pending"
msgstr ""
#: core/models.py:96
msgid "Uploading"
msgstr ""
#: core/models.py:97
msgid "Uploaded"
msgstr ""
#: core/models.py:119
msgid "id"
msgstr ""
#: core/models.py:120
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:126
msgid "created on"
msgstr ""
#: core/models.py:127
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:132
msgid "updated on"
msgstr ""
#: core/models.py:133
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:169
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:182
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:188
msgid "sub"
msgstr ""
#: core/models.py:190
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:199
msgid "full name"
msgstr ""
#: core/models.py:200
msgid "short name"
msgstr ""
#: core/models.py:202
msgid "identity email address"
msgstr ""
#: core/models.py:207
msgid "admin email address"
msgstr ""
#: core/models.py:214
msgid "language"
msgstr ""
#: core/models.py:215
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:221
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:224
msgid "device"
msgstr ""
#: core/models.py:226
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:229
msgid "staff status"
msgstr ""
#: core/models.py:231
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:234
msgid "active"
msgstr ""
#: core/models.py:237
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:249
msgid "user"
msgstr ""
#: core/models.py:250
msgid "users"
msgstr ""
#: core/models.py:385
msgid "title"
msgstr ""
#: core/models.py:427
msgid "Item"
msgstr ""
#: core/models.py:428
msgid "Items"
msgstr ""
#: core/models.py:440
msgid "Untitled Item"
msgstr ""
#: core/models.py:626
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} a partagé un item avec vous!"
#: core/models.py:628
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le item suivant:"
#: core/models.py:631
#, fuzzy, python-brace-format
#| msgid "{name} shared an item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} a partagé un item avec vous: {title}"
#: core/models.py:665
msgid "This item is not deleted."
msgstr ""
#: core/models.py:671
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:707
msgid "Only folders can have children."
msgstr ""
#: core/models.py:714
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:736
msgid "Item/user link trace"
msgstr ""
#: core/models.py:737
msgid "Item/user link traces"
msgstr ""
#: core/models.py:743
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:766
msgid "Item favorite"
msgstr ""
#: core/models.py:767
msgid "Item favorites"
msgstr ""
#: core/models.py:773
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:795
msgid "Item/user relation"
msgstr ""
#: core/models.py:796
msgid "Item/user relations"
msgstr ""
#: core/models.py:802
msgid "This user is already in this item."
msgstr ""
#: core/models.py:808
msgid "This team is already in this item."
msgstr ""
#: core/models.py:814
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:841
msgid "email address"
msgstr ""
#: core/models.py:860
msgid "Item invitation"
msgstr ""
#: core/models.py:861
msgid "Item invitations"
msgstr ""
#: core/models.py:881
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Drive, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: drive/settings.py:239
msgid "English"
msgstr ""
#: drive/settings.py:240
msgid "French"
msgstr ""
#: drive/settings.py:241
msgid "German"
msgstr ""
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Un nouveau item a été créé pour vous !"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Vous avez été déclaré propriétaire d'un nouveau item :"

View File

@@ -0,0 +1,386 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-05 15:02+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-drive.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:29
msgid "Personal info"
msgstr ""
#: core/admin.py:42 core/admin.py:122
msgid "Permissions"
msgstr ""
#: core/admin.py:54
msgid "Important dates"
msgstr ""
#: core/admin.py:132
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:319
msgid "This field is required for files."
msgstr ""
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/enums.py:19
msgid "First child"
msgstr ""
#: core/enums.py:20
msgid "Last child"
msgstr ""
#: core/enums.py:21
msgid "First sibling"
msgstr ""
#: core/enums.py:22
msgid "Last sibling"
msgstr ""
#: core/enums.py:23
msgid "Left"
msgstr ""
#: core/enums.py:24
msgid "Right"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:55 core/models.py:62
msgid "Editor"
msgstr ""
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:75
msgid "Restricted"
msgstr ""
#: core/models.py:79
msgid "Authenticated"
msgstr ""
#: core/models.py:81
msgid "Public"
msgstr ""
#: core/models.py:87
msgid "Folder"
msgstr ""
#: core/models.py:88
msgid "Link"
msgstr ""
#: core/models.py:89
msgid "File"
msgstr ""
#: core/models.py:95
msgid "Pending"
msgstr ""
#: core/models.py:96
msgid "Uploading"
msgstr ""
#: core/models.py:97
msgid "Uploaded"
msgstr ""
#: core/models.py:119
msgid "id"
msgstr ""
#: core/models.py:120
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:126
msgid "created on"
msgstr ""
#: core/models.py:127
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:132
msgid "updated on"
msgstr ""
#: core/models.py:133
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:169
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:182
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:188
msgid "sub"
msgstr ""
#: core/models.py:190
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:199
msgid "full name"
msgstr ""
#: core/models.py:200
msgid "short name"
msgstr ""
#: core/models.py:202
msgid "identity email address"
msgstr ""
#: core/models.py:207
msgid "admin email address"
msgstr ""
#: core/models.py:214
msgid "language"
msgstr ""
#: core/models.py:215
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:221
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:224
msgid "device"
msgstr ""
#: core/models.py:226
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:229
msgid "staff status"
msgstr ""
#: core/models.py:231
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:234
msgid "active"
msgstr ""
#: core/models.py:237
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:249
msgid "user"
msgstr ""
#: core/models.py:250
msgid "users"
msgstr ""
#: core/models.py:385
msgid "title"
msgstr ""
#: core/models.py:427
msgid "Item"
msgstr ""
#: core/models.py:428
msgid "Items"
msgstr ""
#: core/models.py:440
msgid "Untitled Item"
msgstr ""
#: core/models.py:626
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr ""
#: core/models.py:628
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
#: core/models.py:631
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr ""
#: core/models.py:665
msgid "This item is not deleted."
msgstr ""
#: core/models.py:671
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:707
msgid "Only folders can have children."
msgstr ""
#: core/models.py:714
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:736
msgid "Item/user link trace"
msgstr ""
#: core/models.py:737
msgid "Item/user link traces"
msgstr ""
#: core/models.py:743
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:766
msgid "Item favorite"
msgstr ""
#: core/models.py:767
msgid "Item favorites"
msgstr ""
#: core/models.py:773
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:795
msgid "Item/user relation"
msgstr ""
#: core/models.py:796
msgid "Item/user relations"
msgstr ""
#: core/models.py:802
msgid "This user is already in this item."
msgstr ""
#: core/models.py:808
msgid "This team is already in this item."
msgstr ""
#: core/models.py:814
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:841
msgid "email address"
msgstr ""
#: core/models.py:860
msgid "Item invitation"
msgstr ""
#: core/models.py:861
msgid "Item invitations"
msgstr ""
#: core/models.py:881
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Drive, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: drive/settings.py:239
msgid "English"
msgstr ""
#: drive/settings.py:240
msgid "French"
msgstr ""
#: drive/settings.py:241
msgid "German"
msgstr ""

15
src/backend/manage.py Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python
"""
messages management script.
"""
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "messages.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
from configurations.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

700
src/backend/messages/settings.py Executable file
View File

@@ -0,0 +1,700 @@
"""
Django settings for messages project.
Generated by 'django-admin startproject' using Django 3.1.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
"""
try:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
is recommended to configure third-party applications by creating a configuration mixins in
./configurations and compose the Base configuration with those mixins.
It depends on an environment variable that SHOULD be defined:
* DJANGO_SECRET_KEY
You may also want to override default configuration by setting the following environment
variables:
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
* DB_USER
"""
DEBUG = False
USE_SWAGGER = False
API_VERSION = "v1.0"
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
ROOT_URLCONF = "messages.urls"
WSGI_APPLICATION = "messages.wsgi.application"
# Database
DATABASES = {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
environ_prefix=None,
),
"NAME": values.Value("messages", environ_name="DB_NAME", environ_prefix=None),
"USER": values.Value("dbuser", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"dbpass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
"localhost", environ_name="DB_HOST", environ_prefix=None
),
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
},
"staticfiles": {
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
),
},
}
# Media
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"st-messages-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_UPLOAD_POLICY_EXPIRATION = values.Value(
24 * 60 * 60, # 24h
environ_name="AWS_S3_UPLOAD_POLICY_EXPIRATION",
environ_prefix=None,
)
# item images
ITEM_FILE_MAX_SIZE = values.PositiveIntegerValue(
5 * (2**30), # 5GB
environ_name="ITEM_FILE_MAX_SIZE",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Languages
LANGUAGE_CODE = values.Value("fr-fr")
LANGUAGE_COOKIE_NAME = "messages_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
# if is set to False, dict python is returned
"querydict": False,
}
# Careful! Languages should be ordered by priority, as this tuple is used to get
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("fr-fr", _("French")),
("en-us", _("English")),
("de-de", _("German")),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Templates
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.csrf",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.request",
"django.template.context_processors.tz",
],
"loaders": [
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
},
},
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"core.authentication.backends.OIDCAuthenticationBackend",
]
# Django applications from the highest priority to the lowest
INSTALLED_APPS = [
"core",
"drf_spectacular",
# Third party apps
"corsheaders",
"django_filters",
"dockerflow.django",
"rest_framework",
"parler",
"easy_thumbnails",
# Django
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.postgres",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
]
# Cache
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value(
default="180/hour",
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
environ_prefix=None,
),
"user_list_burst": values.Value(
default="30/minute",
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
"TITLE": "messages API",
"DESCRIPTION": "This is the messages API schema.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue(
default=False,
environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK",
),
"COMPONENT_SPLIT_REQUEST": True,
# OTHER SETTINGS
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.Value(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Posthog
POSTHOG_KEY = values.DictValue(
None, environ_name="POSTHOG_KEY", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
OIDC_RP_CLIENT_ID = values.Value(
"st_messages", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
)
OIDC_OP_TOKEN_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
LOGIN_REDIRECT_URL_FAILURE = values.Value(
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
)
LOGOUT_REDIRECT_URL = values.Value(
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
)
OIDC_USE_NONCE = values.BooleanValue(
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
)
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
)
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
"""Environment in which the application is launched."""
return self.__class__.__name__.lower()
# pylint: disable=invalid-name
@property
def RELEASE(self):
"""
Return the release information.
Delegate to the module function to enable easier testing.
"""
return get_release()
# pylint: disable=invalid-name
@property
def PARLER_LANGUAGES(self):
"""
Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings.
"""
return {
self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES),
"default": {
"fallbacks": [self.LANGUAGE_CODE],
"hide_untranslated": False,
},
}
@classmethod
def post_setup(cls):
"""Post setup configuration.
This is the place where you can configure settings that require other
settings to be loaded.
"""
super().post_setup()
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):
"""Settings used when the application is built.
This environment should not be used to run the application. Just to build it with non-blocking
settings.
"""
SECRET_KEY = values.Value("DummyKey")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
),
},
}
class Development(Base):
"""
Development environment settings
We set DEBUG to True and configure the server to respond from all hosts.
"""
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
SESSION_COOKIE_NAME = "st_messages_sessionid"
USE_SWAGGER = True
SESSION_CACHE_ALIAS = "session"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/2",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_DEFAULT_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
class Test(Base):
"""Test environment settings"""
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
class ContinuousIntegration(Test):
"""
Continuous Integration environment settings
nota bene: it should inherit from the Test environment.
"""
class Production(Base):
"""
Production environment settings
You must define the ALLOWED_HOSTS environment variable in Production
configuration (and derived configurations):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
# Security
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
# object when your application is behind a reverse proxy.
#
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
# - your Django app is behind a proxy.
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
#
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_DEFAULT_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
class Feature(Production):
"""
Feature environment settings
nota bene: it should inherit from the Production environment.
"""
class Staging(Production):
"""
Staging environment settings
nota bene: it should inherit from the Production environment.
"""
class PreProduction(Production):
"""
Pre-production environment settings
nota bene: it should inherit from the Production environment.
"""

View File

@@ -0,0 +1,48 @@
"""URL configuration for the messages project"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularJSONAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
]
if settings.DEBUG:
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, item_root=settings.MEDIA_ROOT)
)
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"{settings.API_VERSION}/swagger.json",
SpectacularJSONAPIView.as_view(
api_version=settings.API_VERSION,
urlconf="core.urls",
),
name="client-api-schema",
),
path(
f"{settings.API_VERSION}/swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"{settings.API_VERSION}/redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),
]

View File

@@ -0,0 +1,17 @@
"""
WSGI config for the messages project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "messages.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
application = get_wsgi_application()

143
src/backend/pyproject.toml Normal file
View File

@@ -0,0 +1,143 @@
#
# messages package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "messages"
version = "0.0.1"
authors = [{ "name" = "ANCT", "email" = "suiteterritoriale@anct.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Framework :: Django :: 5",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
description = "A Django MDA"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.36.7",
"Brotli==1.1.0",
"django==5.1.8",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-ltree@git+https://github.com/mariocesar/django-ltree@5d955bc82021a50c522ee524106f6709e3b414e4",
"django-parler==2.3",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"djangorestframework==3.15.2",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis==5.2.1",
"requests==2.32.3",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"whitenoise==6.8.2",
]
[project.urls]
"Bug Tracker" = "https://github.com/suitenumerique/st-messages/issues/new"
"Changelog" = "https://github.com/suitenumerique/st-messages/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/st-messages"
"Repository" = "https://github.com/suitenumerique/st-messages"
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.ruff]
exclude = [
".git",
".venv",
"build",
"venv",
"__pycache__",
"*/migrations/*",
]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"DJ", # flake8-django
"I", # isort
"PLC", # pylint-convention
"PLE", # pylint-error
"PLR", # pylint-refactoring
"PLW", # pylint-warning
"RUF100", # Ruff unused-noqa
"RUF200", # Ruff check pyproject.toml
"S", # flake8-bandit
"SLF", # flake8-self
"T20", # flake8-print
]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","messages","first-party","local-folder"]
sections = { messages=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
addopts = [
"-v",
"--cov-report",
"term-missing",
# Allow test files to have the same name in different directories.
"--import-mode=importlib",
]
python_files = [
"test_*.py",
"tests.py",
]

7
src/backend/setup.py Normal file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python
"""Setup file for the messages module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()

58
src/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
FROM node:22-alpine AS frontend-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
# COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
#COPY ./src/frontend/packages/eslint-config-messages ./packages/eslint-config-messages
COPY ./src/frontend ./apps/messages
### ---- Front-end builder image ----
FROM frontend-deps AS messages
WORKDIR /home/frontend/apps/messages
FROM frontend-deps AS st-messages-dev
WORKDIR /home/frontend/apps/messages
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
EXPOSE 3000
CMD [ "npm", "run", "dev"]
# Tilt will rebuild messages target so, we dissociate messages and messages-builder
# to avoid rebuilding the app at every changes.
FROM messages AS messages-builder
WORKDIR /home/frontend/apps/messages
ARG S3_DOMAIN_REPLACE
ENV NEXT_PUBLIC_S3_DOMAIN_REPLACE=${S3_DOMAIN_REPLACE}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=messages-builder \
/home/frontend/apps/messages/out \
/usr/share/nginx/html
COPY ./src/frontend/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,6 @@
FROM node:22-alpine AS frontend-deps
WORKDIR /home/frontend/
# This will bootstrap a new package-lock.json file
CMD [ "npm", "install"]

40
src/frontend/README.md Normal file
View File

@@ -0,0 +1,40 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,24 @@
server {
listen 8080;
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
}
location /explorer/items/ {
error_page 404 /explorer/items/[id].html;
}
location /sdk/explorer/items/ {
error_page 404 /sdk/explorer/items/[id].html;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}

View File

@@ -0,0 +1,5 @@
import { cunninghamConfig } from "@gouvfr-lasuite/ui-kit";
export default {
...cunninghamConfig,
};

View File

@@ -0,0 +1,21 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"react-hooks/exhaustive-deps": "off",
},
},
];
export default eslintConfig;

5
src/frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
debug: process.env.NODE_ENV === "development",
reactStrictMode: false,
};
export default nextConfig;

10150
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
src/frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "st-messages",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"build-theme": "cunningham -g css -o src/styles"
},
"dependencies": {
"@gouvfr-lasuite/ui-kit": "0.1.9",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.66.9",
"@tanstack/react-table": "8.20.6",
"@viselect/react": "3.9.0",
"clsx": "2.1.1",
"i18next": "24.2.2",
"next": "15.2.3",
"next-i18next": "15.4.2",
"pretty-bytes": "6.1.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-dropzone": "14.3.8",
"react-hook-form": "7.54.2",
"react-i18next": "15.4.1",
"react-toastify": "11.0.5",
"sass": "1.85.0"
},
"devDependencies": {
"@eslint/eslintrc": "3.2.0",
"@tanstack/eslint-plugin-query": "5.66.1",
"@types/node": "20.14.2",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"eslint": "9.20.1",
"eslint-config-next": "15.1.7",
"typescript": "5.4.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -0,0 +1,68 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_3239_3207)">
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="#FAFAFA"/>
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="url(#paint0_linear_3239_3207)"/>
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="url(#paint1_linear_3239_3207)" fill-opacity="0.04"/>
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="#CECECE" stroke-width="0.7"/>
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="url(#paint2_linear_3239_3207)" stroke-opacity="0.5" stroke-width="0.7"/>
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="url(#paint3_linear_3239_3207)" stroke-opacity="0.2" stroke-width="0.7"/>
<path d="M13.2866 19.2658V13.7304C13.2866 13.487 13.3513 13.3043 13.4806 13.1826C13.61 13.0609 13.7647 13 13.9448 13C14.102 13 14.2567 13.0431 14.4089 13.1293L19.0122 15.8152C19.1872 15.9167 19.3178 16.0194 19.404 16.1234C19.4902 16.2248 19.5334 16.3504 19.5334 16.5C19.5334 16.6446 19.4902 16.7701 19.404 16.8766C19.3178 16.9806 19.1872 17.0821 19.0122 17.181L14.4089 19.8668C14.2567 19.9556 14.102 20 13.9448 20C13.7647 20 13.61 19.9379 13.4806 19.8136C13.3513 19.6918 13.2866 19.5092 13.2866 19.2658Z" fill="#CECECE"/>
<path d="M13.2866 19.2658V13.7304C13.2866 13.487 13.3513 13.3043 13.4806 13.1826C13.61 13.0609 13.7647 13 13.9448 13C14.102 13 14.2567 13.0431 14.4089 13.1293L19.0122 15.8152C19.1872 15.9167 19.3178 16.0194 19.404 16.1234C19.4902 16.2248 19.5334 16.3504 19.5334 16.5C19.5334 16.6446 19.4902 16.7701 19.404 16.8766C19.3178 16.9806 19.1872 17.0821 19.0122 17.181L14.4089 19.8668C14.2567 19.9556 14.102 20 13.9448 20C13.7647 20 13.61 19.9379 13.4806 19.8136C13.3513 19.6918 13.2866 19.5092 13.2866 19.2658Z" fill="url(#paint4_linear_3239_3207)" fill-opacity="0.77"/>
<g opacity="0.52">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.86977C5 8.38941 5.38941 8 5.86977 8H7.71165C8.19201 8 8.58142 8.38941 8.58142 8.86977C8.58142 9.35014 8.19201 9.73955 7.71165 9.73955H5.86977C5.38941 9.73955 5 9.35014 5 8.86977Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.86977C5 8.38941 5.38941 8 5.86977 8H7.71165C8.19201 8 8.58142 8.38941 8.58142 8.86977C8.58142 9.35014 8.19201 9.73955 7.71165 9.73955H5.86977C5.38941 9.73955 5 9.35014 5 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 8.86977C9.60449 8.38941 9.9939 8 10.4743 8H12.3161C12.7965 8 13.1859 8.38941 13.1859 8.86977C13.1859 9.35014 12.7965 9.73955 12.3161 9.73955H10.4743C9.9939 9.73955 9.60449 9.35014 9.60449 8.86977Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 8.86977C9.60449 8.38941 9.9939 8 10.4743 8H12.3161C12.7965 8 13.1859 8.38941 13.1859 8.86977C13.1859 9.35014 12.7965 9.73955 12.3161 9.73955H10.4743C9.9939 9.73955 9.60449 9.35014 9.60449 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 8.86977C14.2095 8.38941 14.5989 8 15.0792 8H16.9211C17.4015 8 17.7909 8.38941 17.7909 8.86977C17.7909 9.35014 17.4015 9.73955 16.9211 9.73955H15.0792C14.5989 9.73955 14.2095 9.35014 14.2095 8.86977Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 8.86977C14.2095 8.38941 14.5989 8 15.0792 8H16.9211C17.4015 8 17.7909 8.38941 17.7909 8.86977C17.7909 9.35014 17.4015 9.73955 16.9211 9.73955H15.0792C14.5989 9.73955 14.2095 9.35014 14.2095 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 8.86977C18.814 8.38941 19.2034 8 19.6837 8H21.5256C22.006 8 22.3954 8.38941 22.3954 8.86977C22.3954 9.35014 22.006 9.73955 21.5256 9.73955H19.6837C19.2034 9.73955 18.814 9.35014 18.814 8.86977Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 8.86977C18.814 8.38941 19.2034 8 19.6837 8H21.5256C22.006 8 22.3954 8.38941 22.3954 8.86977C22.3954 9.35014 22.006 9.73955 21.5256 9.73955H19.6837C19.2034 9.73955 18.814 9.35014 18.814 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 8.86977C23.4185 8.38941 23.8079 8 24.2882 8H26.1301C26.6105 8 26.9999 8.38941 26.9999 8.86977C26.9999 9.35014 26.6105 9.73955 26.1301 9.73955H24.2882C23.8079 9.73955 23.4185 9.35014 23.4185 8.86977Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 8.86977C23.4185 8.38941 23.8079 8 24.2882 8H26.1301C26.6105 8 26.9999 8.38941 26.9999 8.86977C26.9999 9.35014 26.6105 9.73955 26.1301 9.73955H24.2882C23.8079 9.73955 23.4185 9.35014 23.4185 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
</g>
<g opacity="0.38">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 24.1298C5 23.6494 5.38941 23.26 5.86977 23.26H7.71165C8.19201 23.26 8.58142 23.6494 8.58142 24.1298C8.58142 24.6101 8.19201 24.9996 7.71165 24.9996H5.86977C5.38941 24.9996 5 24.6101 5 24.1298Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 24.1298C5 23.6494 5.38941 23.26 5.86977 23.26H7.71165C8.19201 23.26 8.58142 23.6494 8.58142 24.1298C8.58142 24.6101 8.19201 24.9996 7.71165 24.9996H5.86977C5.38941 24.9996 5 24.6101 5 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 24.1298C9.60449 23.6494 9.9939 23.26 10.4743 23.26H12.3161C12.7965 23.26 13.1859 23.6494 13.1859 24.1298C13.1859 24.6101 12.7965 24.9996 12.3161 24.9996H10.4743C9.9939 24.9996 9.60449 24.6101 9.60449 24.1298Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 24.1298C9.60449 23.6494 9.9939 23.26 10.4743 23.26H12.3161C12.7965 23.26 13.1859 23.6494 13.1859 24.1298C13.1859 24.6101 12.7965 24.9996 12.3161 24.9996H10.4743C9.9939 24.9996 9.60449 24.6101 9.60449 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 24.1298C14.2095 23.6494 14.5989 23.26 15.0792 23.26H16.9211C17.4015 23.26 17.7909 23.6494 17.7909 24.1298C17.7909 24.6101 17.4015 24.9996 16.9211 24.9996H15.0792C14.5989 24.9996 14.2095 24.6101 14.2095 24.1298Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 24.1298C14.2095 23.6494 14.5989 23.26 15.0792 23.26H16.9211C17.4015 23.26 17.7909 23.6494 17.7909 24.1298C17.7909 24.6101 17.4015 24.9996 16.9211 24.9996H15.0792C14.5989 24.9996 14.2095 24.6101 14.2095 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 24.1298C18.814 23.6494 19.2034 23.26 19.6837 23.26H21.5256C22.006 23.26 22.3954 23.6494 22.3954 24.1298C22.3954 24.6101 22.006 24.9996 21.5256 24.9996H19.6837C19.2034 24.9996 18.814 24.6101 18.814 24.1298Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 24.1298C18.814 23.6494 19.2034 23.26 19.6837 23.26H21.5256C22.006 23.26 22.3954 23.6494 22.3954 24.1298C22.3954 24.6101 22.006 24.9996 21.5256 24.9996H19.6837C19.2034 24.9996 18.814 24.6101 18.814 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 24.1298C23.4185 23.6494 23.8079 23.26 24.2882 23.26H26.1301C26.6105 23.26 26.9999 23.6494 26.9999 24.1298C26.9999 24.6101 26.6105 24.9996 26.1301 24.9996H24.2882C23.8079 24.9996 23.4185 24.6101 23.4185 24.1298Z" fill="#CECECE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 24.1298C23.4185 23.6494 23.8079 23.26 24.2882 23.26H26.1301C26.6105 23.26 26.9999 23.6494 26.9999 24.1298C26.9999 24.6101 26.6105 24.9996 26.1301 24.9996H24.2882C23.8079 24.9996 23.4185 24.6101 23.4185 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
</g>
</g>
<defs>
<filter id="filter0_d_3239_3207" x="-1.5" y="0.5" width="36" height="36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3239_3207"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3239_3207" result="shape"/>
</filter>
<linearGradient id="paint0_linear_3239_3207" x1="16" y1="5.5" x2="26" y2="23.5" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_3239_3207" x1="9.75363" y1="1.56622" x2="15.1333" y2="29.9727" gradientUnits="userSpaceOnUse">
<stop stop-color="#6A6AF4"/>
<stop offset="1" stop-color="#6A6AF4"/>
</linearGradient>
<linearGradient id="paint2_linear_3239_3207" x1="10" y1="-1" x2="18.5564" y2="36.5113" gradientUnits="userSpaceOnUse">
<stop stop-color="#6A6AF4" stop-opacity="0"/>
<stop offset="1" stop-color="#6A6AF4"/>
</linearGradient>
<linearGradient id="paint3_linear_3239_3207" x1="16" y1="5.5" x2="16" y2="27.5" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint4_linear_3239_3207" x1="13" y1="9.5" x2="17.459" y2="24.5122" gradientUnits="userSpaceOnUse">
<stop stop-color="#6A6AF4" stop-opacity="0"/>
<stop offset="1" stop-color="#6A6AF4"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

Some files were not shown because too many files have changed in this diff Show More