(all) add organizations, resources, channels, and infra migration (#34)

Add multi-tenant organization model populated from OIDC claims with
org-scoped user discovery, CalDAV principal filtering, and cross-org
isolation at the SabreDAV layer.

Add bookable resource principals (rooms, equipment) with CalDAV
auto-scheduling that handles conflict detection, auto-accept/decline,
and org-scoped booking enforcement. Fixes #14.

Replace CalendarSubscriptionToken with a unified Channel model
supporting CalDAV integration tokens and iCal feed URLs, with
encrypted token storage and role-based access control. Fixes #16.

Migrate task queue from Celery to Dramatiq with async ICS import,
progress tracking, and task status polling endpoint.

Replace nginx with Caddy for both the reverse proxy and frontend
static serving. Switch frontend package manager from yarn/pnpm to
npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9.

Harden security with fail-closed entitlements, RSVP rate limiting
and token expiry, CalDAV proxy path validation blocking internal
API routes, channel path scope enforcement, and ETag-based
conflict prevention.

Add frontend pages for resource management and integration channel
CRUD, with resource booking in the event modal.

Restructure CalDAV paths to /calendars/users/ and
/calendars/resources/ with nested principal collections in SabreDAV.
This commit is contained in:
Sylvain Zimmer
2026-03-09 09:09:34 +01:00
committed by GitHub
parent cd2b15b3b5
commit 9c18f96090
176 changed files with 26903 additions and 12108 deletions

View File

@@ -24,16 +24,16 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22.x"
node-version: "24.x"
- name: Restore the frontend cache
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
key: front-node_modules-${{ hashFiles('src/frontend/**/package-lock.json') }}
fail-on-cache-miss: true
- name: Check linting
run: cd src/frontend/ && yarn lint
run: cd src/frontend/ && npm run lint
test-unit:
runs-on: ubuntu-latest
@@ -45,16 +45,16 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22.x"
node-version: "24.x"
- name: Restore the frontend cache
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
key: front-node_modules-${{ hashFiles('src/frontend/**/package-lock.json') }}
fail-on-cache-miss: true
- name: Run unit tests
run: |
cd src/frontend/apps/calendars
TZ=Europe/Paris yarn test
TZ=Europe/Paris npm test

View File

@@ -65,6 +65,7 @@ jobs:
CALDAV_URL: http://localhost:80
CALDAV_OUTBOUND_API_KEY: test-outbound-key
CALDAV_INBOUND_API_KEY: test-inbound-key
CALDAV_INTERNAL_API_KEY: test-internal-key
CALDAV_CALLBACK_HOST: localhost
TRANSLATIONS_JSON_PATH: ${{ github.workspace }}/src/frontend/apps/calendars/src/features/i18n/translations.json
@@ -80,7 +81,7 @@ jobs:
- name: Build and start CalDAV server
working-directory: .
run: |
docker build -t caldav-test docker/sabredav
docker build -t caldav-test src/caldav
docker run -d --name caldav-test \
--network host \
-e PGHOST=localhost \
@@ -88,9 +89,10 @@ jobs:
-e PGDATABASE=calendars \
-e PGUSER=pgroot \
-e PGPASSWORD=pass \
-e CALDAV_BASE_URI=/api/v1.0/caldav/ \
-e CALDAV_BASE_URI=/caldav/ \
-e CALDAV_INBOUND_API_KEY=test-inbound-key \
-e CALDAV_OUTBOUND_API_KEY=test-outbound-key \
-e CALDAV_INTERNAL_API_KEY=test-internal-key \
caldav-test \
sh -c "/usr/local/bin/init-database.sh && apache2-foreground"
@@ -108,13 +110,10 @@ jobs:
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages) and MIME support
- name: Install MIME support
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info media-types
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
sudo apt-get install -y pandoc shared-mime-info media-types
- name: Run tests
run: uv run pytest -n 2

View File

@@ -1,74 +0,0 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '22.x'
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
# 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@v5
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

View File

@@ -1,68 +0,0 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '22.x'
synchronize-with-crowdin:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
# Backend i18n
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13.9"
cache: 'pip'
- 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@v5
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

@@ -49,7 +49,7 @@ jobs:
context: ./src/backend
target: backend-production
platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
build-args: DOCKER_USER=${{ env.DOCKER_USER }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -81,7 +81,7 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build SSG assets (platform-independent, amd64 only)
name: Build Next.js static output (amd64 only, avoids QEMU for Node)
uses: docker/build-push-action@v6
with:
context: ./src/frontend
@@ -90,19 +90,18 @@ jobs:
load: true
tags: calendars-builder:local
-
name: Extract SSG build output
name: Extract static output
run: |
docker create --name extract calendars-builder:local
docker cp extract:/home/frontend/apps/calendars/out ./src/frontend/out
docker rm extract
-
name: Build and push nginx image (multi-arch)
name: Build and push frontend image (multi-arch)
uses: docker/build-push-action@v6
with:
context: ./src/frontend
file: ./src/frontend/Dockerfile.nginx
file: ./src/frontend/Dockerfile.caddy
platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '22.x'
default: '24.x'
type: string
jobs:
@@ -19,7 +19,7 @@ jobs:
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
key: front-node_modules-${{ hashFiles('src/frontend/**/package-lock.json') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
@@ -27,10 +27,10 @@ jobs:
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
run: cd src/frontend/ && npm ci
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
key: front-node_modules-${{ hashFiles('src/frontend/**/package-lock.json') }}

3
.gitignore vendored
View File

@@ -47,8 +47,9 @@ env.d/terraform
compose.override.yml
docker/auth/*.local
# npm
# npm / pnpm
node_modules
.pnpm-store
# Mails
src/backend/core/templates/mail/

View File

@@ -18,44 +18,39 @@ In this project, you can create events, invite people to events, create calendar
### Development Setup
```bash
make bootstrap # Initial setup: builds containers, runs migrations, starts services
make run # Start all services (backend + frontend containers)
make run-backend # Start backend services only (for local frontend development)
make start # Start all services (backend + frontend containers)
make start-back # Start backend services only (for local frontend development)
make stop # Stop all containers
make down # Stop and remove containers, networks, volumes
make update # Update project after pulling changes
```
### Backend Development
```bash
make test-back -- path/to/test.py::TestClass::test_method # Run specific test
make test-back-parallel # Run all tests in parallel
make lint # Run ruff + pylint
make lint # Run all linters (back + front)
make lint-back # Run back-end linters only
make migrate # Run Django migrations
make makemigrations # Create new migrations
make shell # Django shell
make dbshell # PostgreSQL shell
make shell-back-django # Django shell
make shell-db # PostgreSQL shell
```
### Frontend Development
```bash
make frontend-development-install # Install frontend dependencies locally
make run-frontend-development # Run frontend locally (after run-backend)
make frontend-lint # Run ESLint on frontend
cd src/frontend/apps/calendars && yarn test # Run frontend tests
cd src/frontend/apps/calendars && yarn test:watch # Watch mode
make install-front # Install frontend dependencies
make lint-front # Run ESLint on frontend
make typecheck-front # Run TypeScript type checker
make test-front # Run frontend tests
cd src/frontend/apps/calendars && npm test # Run frontend tests (local)
cd src/frontend/apps/calendars && npm run test:watch # Watch mode (local)
```
### E2E Tests
```bash
make run-tests-e2e # Run all e2e tests
make run-tests-e2e -- --project chromium --headed # Run with specific browser
```
### Internationalization
```bash
make i18n-generate # Generate translation files
make i18n-compile # Compile translations
make crowdin-upload # Upload sources to Crowdin
make crowdin-download # Download translations from Crowdin
make test-e2e # Run all e2e tests
make test-e2e -- --project chromium --headed # Run with specific browser
```
## Architecture
@@ -70,14 +65,14 @@ make crowdin-download # Download translations from Crowdin
- `tests/` - pytest test files
### Frontend Structure (`src/frontend/`)
Yarn workspaces monorepo:
npm workspaces:
- `apps/calendars/` - Main Next.js application
- `src/features/` - Feature modules (calendar, auth, api, i18n, etc.)
- `src/pages/` - Next.js pages
- `src/hooks/` - Custom React hooks
- `apps/e2e/` - Playwright end-to-end tests
### CalDAV Server (`docker/sabredav/`)
### CalDAV Server (`src/caldav/`)
PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database.
**IMPORTANT: Never query the SabreDAV database tables directly from Django.** Always interact with CalDAV through the SabreDAV HTTP API (PROPFIND, REPORT, PUT, etc.).
@@ -86,14 +81,13 @@ PHP SabreDAV server providing CalDAV protocol support, running against the share
| Service | URL / Port | Description |
|---------|------------|-------------|
| **Frontend** | [http://localhost:8920](http://localhost:8920) | Next.js Calendar frontend |
| **Backend API** | [http://localhost:8921](http://localhost:8921) | Django REST API |
| **CalDAV** | [http://localhost:8922](http://localhost:8922) | SabreDAV CalDAV server |
| **Nginx** | [http://localhost:8923](http://localhost:8923) | Reverse proxy (frontend + API) |
| **Redis** | 8924 | Cache and Celery broker |
| **Keycloak** | [http://localhost:8925](http://localhost:8925) | OIDC identity provider |
| **PostgreSQL** | 8926 | Database server |
| **Mailcatcher** | [http://localhost:8927](http://localhost:8927) | Email testing interface |
| **Frontend** | [http://localhost:8930](http://localhost:8930) | Next.js Calendar frontend |
| **Backend API** | [http://localhost:8931](http://localhost:8931) | Django REST API |
| **CalDAV** | [http://localhost:8932](http://localhost:8932) | SabreDAV CalDAV server |
| **Redis** | 8934 | Cache and Celery broker |
| **Keycloak** | [http://localhost:8935](http://localhost:8935) | OIDC identity provider |
| **PostgreSQL** | 8936 | Database server |
| **Mailcatcher** | [http://localhost:8937](http://localhost:8937) | Email testing interface |
## Key Technologies

349
Makefile
View File

@@ -1,12 +1,3 @@
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ 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:
@@ -26,12 +17,7 @@
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
# -- Database
DB_HOST = postgresql
DB_PORT = 5432
BLUE := \033[1;34m
# -- Docker
# Get the current user ID to use for docker run and docker exec commands
@@ -45,17 +31,11 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) backend-dev
COMPOSE_RUN_APP_NO_DEPS = $(COMPOSE_RUN) --no-deps backend-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MANAGE_EXEC = $(COMPOSE_EXEC_APP) python manage.py
PSQL_E2E = ./bin/postgres_e2e
# -- Frontend
PATH_FRONT = ./src/frontend
PATH_FRONT_CALENDARS = $(PATH_FRONT)/apps/calendars
# ==============================================================================
# RULES
@@ -71,7 +51,6 @@ data/static:
create-env-files: ## Create empty .local env files for local development
create-env-files: \
env.d/development/crowdin.local \
env.d/development/postgresql.local \
env.d/development/keycloak.local \
env.d/development/backend.local \
@@ -84,13 +63,12 @@ env.d/development/%.local:
@echo "# Add your local-specific environment variables below:" >> $@
@echo "# Example: DJANGO_DEBUG=True" >> $@
@echo "" >> $@
.PHONY: env.d/development/%.local
create-docker-network: ## create the docker network if it doesn't exist
@docker network create lasuite-network || true
.PHONY: create-docker-network
bootstrap: ## Prepare Docker images for the project
bootstrap: ## Prepare the project for local development
bootstrap: \
data/media \
data/static \
@@ -99,84 +77,56 @@ bootstrap: \
create-docker-network \
migrate \
migrate-caldav \
back-i18n-compile \
run
start
.PHONY: bootstrap
update: ## Update the project with latest changes
@$(MAKE) data/media
@$(MAKE) data/static
@$(MAKE) create-env-files
@$(MAKE) build
@$(MAKE) migrate
@$(MAKE) migrate-caldav
@$(MAKE) install-frozen-front
.PHONY: update
# -- Docker/compose
build: cache ?= # --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
@$(MAKE) build-caldav cache=$(cache)
@$(COMPOSE) build $(cache)
.PHONY: build
build-backend: cache ?=
build-backend: ## build the backend-dev container
@$(COMPOSE) build backend-dev $(cache)
.PHONY: build-backend
build-caldav: cache ?=
build-caldav: ## build the caldav container
@$(COMPOSE) build caldav $(cache)
.PHONY: build-caldav
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend-development
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
rm -rf data/postgresql.*
.PHONY: down
logs: ## display backend-dev logs (follow mode)
@$(COMPOSE) logs -f backend-dev
logs: ## display all services logs (follow mode)
@$(COMPOSE) logs -f
.PHONY: logs
run-backend: ## start the backend container
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
start: ## start all development services
@$(COMPOSE) up --force-recreate -d worker-dev frontend-dev
.PHONY: start
bootstrap-e2e: ## bootstrap the backend container for e2e tests, without frontend
bootstrap-e2e: \
data/media \
data/static \
create-env-local-files \
build-backend \
create-docker-network \
back-i18n-compile \
run-backend-e2e
.PHONY: bootstrap-e2e
start-back: ## start backend services only (for local frontend development)
@$(COMPOSE) up --force-recreate -d worker-dev
.PHONY: start-back
clear-db-e2e: ## quickly clears the database for e2e tests, used in the e2e tests
$(PSQL_E2E) -c "$$(cat bin/clear_db_e2e.sql)"
.PHONY: clear-db-e2e
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
run-backend-e2e: ## start the backend container for e2e tests, always remove the postgresql.e2e volume first
@$(MAKE) stop
rm -rf data/postgresql.e2e
@ENV_OVERRIDE=e2e $(MAKE) run-backend
@ENV_OVERRIDE=e2e $(MAKE) migrate
.PHONY: run-backend-e2e
stop: ## stop all development services
@$(COMPOSE) stop
.PHONY: stop
run-tests-e2e: ## run the e2e tests, example: make run-tests-e2e -- --project chromium --headed
@$(MAKE) run-backend-e2e
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
cd src/frontend/apps/e2e && yarn test $${args:-${1}}
.PHONY: run-tests-e2e
backend-exec-command: ## execute a command in the backend container
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(MANAGE_EXEC) $${args}
.PHONY: backend-exec-command
run: ## start the development server and frontend development
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend-dev
restart: ## restart all development services
restart: \
stop \
start
.PHONY: restart
migrate-caldav: ## Initialize CalDAV server database schema
@echo "$(BOLD)Initializing CalDAV server database schema...$(RESET)"
@@ -184,57 +134,50 @@ migrate-caldav: ## Initialize CalDAV server database schema
@echo "$(GREEN)CalDAV server initialized$(RESET)"
.PHONY: migrate-caldav
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
# -- Linters
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
index: ## index all files to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: ## run all linters
lint: \
lint-ruff-format \
lint-ruff-check \
lint-pylint
lint-back \
lint-front
.PHONY: lint
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
lint-back: ## run back-end linters (with auto-fix)
lint-back: \
format-back \
check-back \
analyze-back
.PHONY: lint-back
format-back: ## format back-end python sources with ruff
@$(COMPOSE_RUN_APP_NO_DEPS) ruff format .
.PHONY: lint-ruff-format
.PHONY: format-back
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
check-back: ## check back-end python sources with ruff
@$(COMPOSE_RUN_APP_NO_DEPS) ruff check . --fix
.PHONY: lint-ruff-check
.PHONY: check-back
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
analyze-back: ## lint all back-end python sources with pylint
@$(COMPOSE_RUN_APP_NO_DEPS) pylint .
.PHONY: analyze-back
test: ## run project tests
@$(MAKE) test-back-parallel
lint-front: ## run the frontend linter
@$(COMPOSE) run --rm frontend-dev sh -c "cd apps/calendars && npm run lint"
.PHONY: lint-front
typecheck-front: ## run the frontend type checker
@$(COMPOSE) run --rm frontend-dev sh -c "cd apps/calendars && npx tsc --noEmit"
.PHONY: typecheck-front
# -- Tests
test: ## run all tests
test: \
test-back-parallel \
test-front
.PHONY: test
test-back: ## run back-end tests (rebuilds and recreates containers)
@echo "$(BOLD)Rebuilding containers (using Docker cache)...$(RESET)"
@$(MAKE) build-caldav
@echo "$(BOLD)Recreating containers...$(RESET)"
@$(COMPOSE) up -d --force-recreate postgresql caldav
test-back: ## run back-end tests
@echo "$(BOLD)Running tests...$(RESET)"
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args:-${1}}
@@ -245,13 +188,49 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
makemigrations: ## run django makemigrations for the calendar project.
test-front: ## run the frontend tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
$(COMPOSE) run --rm frontend-dev sh -c "cd apps/calendars && npm test -- $${args:-${1}}"
.PHONY: test-front
# -- E2E Tests
bootstrap-e2e: ## bootstrap the backend for e2e tests, without frontend
bootstrap-e2e: \
data/media \
data/static \
create-env-files \
build \
create-docker-network \
start-back-e2e
.PHONY: bootstrap-e2e
clear-db-e2e: ## quickly clears the database for e2e tests
$(PSQL_E2E) -c "$$(cat bin/clear_db_e2e.sql)"
.PHONY: clear-db-e2e
start-back-e2e: ## start the backend for e2e tests
@$(MAKE) stop
rm -rf data/postgresql.e2e
@ENV_OVERRIDE=e2e $(MAKE) start-back
@ENV_OVERRIDE=e2e $(MAKE) migrate
.PHONY: start-back-e2e
test-e2e: ## run the e2e tests, example: make test-e2e -- --project chromium --headed
@$(MAKE) start-back-e2e
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
cd src/frontend/apps/e2e && npm test $${args:-${1}}
.PHONY: test-e2e
# -- Backend
makemigrations: ## run django makemigrations
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the calendar project.
migrate: ## run django migrations
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(MANAGE) migrate
@@ -262,81 +241,57 @@ superuser: ## Create an admin superuser with password "admin"
@$(MANAGE) createsuperuser --email admin@example.com --password admin
.PHONY: superuser
shell-back: ## open a shell in the backend container
@$(COMPOSE) run --rm --build backend-dev /bin/sh
.PHONY: shell-back
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
exec-back: ## open a shell in the running backend-dev container
@$(COMPOSE) exec backend-dev /bin/sh
.PHONY: exec-back
back-lock: ## regenerate the uv.lock file (uses temporary container)
shell-back-django: ## connect to django shell
@$(MANAGE) shell
.PHONY: shell-back-django
back-lock: ## regenerate the uv.lock file
@echo "$(BOLD)Regenerating uv.lock$(RESET)"
@docker run --rm -v $(PWD)/src/backend:/app -w /app ghcr.io/astral-sh/uv:python3.13-alpine uv lock
.PHONY: back-lock
back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot --all
.PHONY: back-i18n-generate
back-shell: ## open a shell in the backend container
@$(COMPOSE) run --rm --build backend-dev /bin/sh
.PHONY: back-shell
shell: ## connect to django shell
@$(MANAGE) shell #_plus
.PHONY: shell
# -- Database
dbshell: ## connect to database shell
docker compose exec backend-dev python manage.py dbshell
.PHONY: dbshell
shell-db: ## connect to database shell
@$(COMPOSE) exec backend-dev python manage.py dbshell
.PHONY: shell-db
resetdb: FLUSH_ARGS ?=
resetdb: ## flush database and create a superuser "admin"
reset-db: FLUSH_ARGS ?=
reset-db: ## flush database
@echo "$(BOLD)Flush database$(RESET)"
@$(MANAGE) flush $(FLUSH_ARGS)
@${MAKE} superuser
.PHONY: resetdb
.PHONY: reset-db
# -- Internationalization
demo: ## flush db then create a demo
@$(MAKE) reset-db
@$(MANAGE) create_demo
.PHONY: demo
crowdin-download: ## Download translated message from crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
# -- Frontend
crowdin-download-sources: ## Download sources from Crowdin
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
.PHONY: crowdin-download-sources
install-front: ## install the frontend dependencies
@$(COMPOSE) run --rm frontend-dev sh -c "npm install"
.PHONY: install-front
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
install-frozen-front: ## install frontend dependencies from lockfile
@echo "Installing frontend dependencies..."
@$(COMPOSE) run --rm frontend-dev sh -c "npm ci"
.PHONY: install-frozen-front
shell-front: ## open a shell in the frontend container
@$(COMPOSE) run --rm frontend-dev /bin/sh
.PHONY: shell-front
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
.PHONY: clean
@@ -350,31 +305,3 @@ help:
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
cd $(PATH_FRONT_CALENDARS) && yarn
.PHONY: frontend-development-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_CALENDARS) && yarn dev
.PHONY: run-frontend-development
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
frontend-i18n-generate: \
crowdin-download-sources \
frontend-i18n-extract
.PHONY: frontend-i18n-generate
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile

View File

@@ -1,3 +1,3 @@
web: bin/scalingo_run_web
worker: celery -A calendars.celery_app worker --task-events --beat -l INFO -c $DJANGO_CELERY_CONCURRENCY -Q celery,default
worker: python worker.py
postdeploy: source bin/export_pg_vars.sh && python manage.py migrate && SQL_DIR=/app/sabredav/sql bash sabredav/init-database.sh

View File

@@ -56,10 +56,10 @@ Compose](https://docs.docker.com/compose/install) installed on your laptop:
```bash
$ docker -v
Docker version 27.5.1, build 9f9e405
Docker version 27.x
$ docker compose version
Docker Compose version v2.32.4
Docker Compose version v2.x
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
@@ -73,14 +73,14 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap
```
This command builds the `backend-dev` and `frontend-dev` containers, 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.
This command builds the containers, installs dependencies, and runs database
migrations. 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! 🎉
You can access the project by going to <http://localhost:8920>.
You can access the project by going to <http://localhost:8930>.
You will be prompted to log in. The default credentials are:
@@ -92,7 +92,7 @@ password: calendars
Note that if you need to run them afterward, you can use the eponym Make rule:
```bash
$ make run
$ make start
```
You can check all available Make rules using:
@@ -101,30 +101,30 @@ You can check all available Make rules using:
$ make help
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
⚠️ For frontend developers, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
First, install the frontend dependencies:
```shellscript
$ make frontend-development-install
```bash
$ make install-front
```
And run the frontend locally in development mode with the following command:
Then start the backend services:
```shellscript
$ make run-frontend-development
```bash
$ make start-back
```
To start all the services, except the frontend container, you can use the following command:
And run the frontend locally in development mode:
```shellscript
$ make run-backend
```bash
$ cd src/frontend/apps/calendars && npm run dev
```
### Django admin
You can access the Django admin site at
[http://localhost:8921/admin](http://localhost:8921/admin).
[http://localhost:8931/admin](http://localhost:8931/admin).
You first need to create a superuser account:

View File

@@ -1,38 +0,0 @@
#!/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 --no-deps backend-dev pylint "${paths[@]}" "${args[@]}"

View File

@@ -5,14 +5,27 @@ set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
# Move the frontend build to the app root and clean up
mkdir -p build/
mv src/frontend/apps/calendars/out build/frontend-out
cp src/frontend/apps/calendars/src/features/i18n/translations.json translations.json
mv src/backend/* ./
mv src/nginx/* ./
# Download Caddy binary with checksum verification
CADDY_VERSION="2.11.2"
CADDY_SHA256="94391dfefe1f278ac8f387ab86162f0e88d87ff97df367f360e51e3cda3df56f"
CADDY_TAR="/tmp/caddy.tar.gz"
curl -fsSL -o "$CADDY_TAR" \
"https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz"
echo "${CADDY_SHA256} ${CADDY_TAR}" | sha256sum -c -
tar -xz -C bin/ caddy < "$CADDY_TAR"
rm "$CADDY_TAR"
chmod +x bin/caddy
# Copy Caddyfile (uses {$ENV} vars natively, no ERB needed)
cp src/proxy/Caddyfile ./Caddyfile
echo "3.13" > .python-version
@@ -25,22 +38,25 @@ mkdir -p "$DEB_DIR" "$PHP_PREFIX"
# Hardcoded Launchpad URLs for PHP 8.3.6-0maysync1 (Ubuntu Noble amd64)
# Source: https://launchpad.net/ubuntu/noble/amd64/php8.3-fpm/8.3.6-0maysync1
declare -A PHP_DEBS=(
[php8.3-cli]="http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb"
[php8.3-fpm]="http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb"
[php8.3-common]="http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb"
[php8.3-opcache]="http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb"
[php8.3-readline]="http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb"
[php8.3-pgsql]="http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb"
[php8.3-xml]="http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb"
[php8.3-mbstring]="http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb"
[php8.3-curl]="http://launchpadlibrarian.net/724872607/php8.3-curl_8.3.6-0maysync1_amd64.deb"
[php-common]="http://launchpadlibrarian.net/710804987/php-common_93ubuntu2_all.deb"
# Format: "package_name url sha256"
PHP_DEBS=(
"php8.3-cli http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb 8cb7461dd06fb214b30c060b80b1c6f95d1ff5e2656fdadf215e50b9f299f196"
"php8.3-fpm http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb b3a9435025766bcbf6c16199c06481c5196098c084933dfabf8867c982edc2b2"
"php8.3-common http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb 0e0d0ad9c17add5fb2afcc14c6fffb81c2beb99114108b8ebd0461d910a79dfc"
"php8.3-opcache http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb 13b2662201c57904c1eda9b048b1349acaf3609c7d9e8df5b2d93833a059bdb0"
"php8.3-readline http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb 380f8ed79196914ee2eebb68bf518a752204826af1fdb8a0d5c9609c76086b90"
"php8.3-pgsql http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb b1ed204c980c348d1870cfa88c1b40257621ae5696a2a7f44f861a9d00eb7477"
"php8.3-xml http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb 6c6ded219d1966a50108d032b7a522e641765a8a6aa48747483313fa7dafd533"
"php8.3-mbstring http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb 42c89945eb105c2232ab208b893ef65e9abc8af5c95aa10c507498655ef812c4"
"php8.3-curl http://launchpadlibrarian.net/724872607/php8.3-curl_8.3.6-0maysync1_amd64.deb 95d46a22e6b493ba0b6256cf036a2a37d4b9b5f438968073709845af1c17df4c"
"php-common http://launchpadlibrarian.net/710804987/php-common_93ubuntu2_all.deb 39b15c407700e81ddd62580736feba31b187ffff56f6835dac5fa8f847c42529"
)
for pkg in "${!PHP_DEBS[@]}"; do
for entry in "${PHP_DEBS[@]}"; do
read -r pkg url sha256 <<< "$entry"
echo " Downloading ${pkg}"
curl -fsSL -o "$DEB_DIR/${pkg}.deb" "${PHP_DEBS[$pkg]}"
curl -fsSL -o "$DEB_DIR/${pkg}.deb" "$url"
echo "${sha256} ${DEB_DIR}/${pkg}.deb" | sha256sum -c -
done
for deb in "$DEB_DIR"/*.deb; do
@@ -90,11 +106,14 @@ chmod +x bin/php
echo "-----> PHP version: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -v | head -1)"
echo "-----> PHP modules: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -m | tr '\n' ' ')"
# Download Composer and install SabreDAV dependencies
# Download Composer with integrity verification and install SabreDAV dependencies
echo "-----> Installing SabreDAV dependencies"
COMPOSER_VERSION="2.9.5"
COMPOSER_SHA256="c86ce603fe836bf0861a38c93ac566c8f1e69ac44b2445d9b7a6a17ea2e9972a"
curl -fsSL -o bin/composer.phar \
https://getcomposer.org/download/latest-stable/composer.phar
cp -r docker/sabredav sabredav
"https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar"
echo "${COMPOSER_SHA256} bin/composer.phar" | sha256sum -c -
cp -r src/caldav sabredav
cd sabredav
"../$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" ../bin/composer.phar install \
--no-dev --optimize-autoloader --no-interaction

View File

@@ -3,6 +3,11 @@
# Parse DATABASE_URL into PG* vars for PHP and psql
source bin/export_pg_vars.sh
# Set defaults for Caddy env vars
export CALENDARS_FRONTEND_ROOT="${CALENDARS_FRONTEND_ROOT:-/app/build/frontend-out}"
export CALENDARS_FRONTEND_BACKEND_SERVER="${CALENDARS_FRONTEND_BACKEND_SERVER:-localhost:8000}"
export DJANGO_ADMIN_URL="${DJANGO_ADMIN_URL:-admin}"
# Start PHP-FPM for SabreDAV (CalDAV server)
.php/usr/sbin/php-fpm8.3 \
-n -c /app/.php/php.ini \
@@ -12,11 +17,11 @@ source bin/export_pg_vars.sh
# Start the Django backend
gunicorn -b :8000 calendars.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# Start the Caddy server
bin/caddy run --config Caddyfile --adapter caddyfile &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
trap "pkill -SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n

View File

@@ -5,7 +5,7 @@ services:
postgresql:
image: postgres:15
ports:
- "8926:5432"
- "8936:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 1s
@@ -18,12 +18,12 @@ services:
redis:
image: redis:5
ports:
- "8924:6379"
- "8934:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "8927:1080"
- "8937:1080"
backend-dev:
build:
@@ -40,7 +40,7 @@ services:
- env.d/development/backend.defaults
- env.d/development/backend.local
ports:
- "8921:8000"
- "8931:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -57,18 +57,18 @@ services:
condition: service_started
redis:
condition: service_started
celery-dev:
worker-dev:
condition: service_started
caldav:
condition: service_started
celery-dev:
worker-dev:
user: ${DOCKER_USER:-1000}
image: calendars:backend-development
networks:
- default
- lasuite
command: [ "celery", "-A", "calendars.celery_app", "worker", "-l", "DEBUG" ]
command: [ "python", "worker.py", "-v", "2" ]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
@@ -80,28 +80,13 @@ services:
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
- /app/.venv
nginx:
image: nginx:1.25
ports:
- "8923:8083"
networks:
default: {}
lasuite:
aliases:
- nginx
volumes:
- ./docker/files/development/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- backend-dev
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: src/frontend
target: calendars-dev
args:
API_ORIGIN: "http://localhost:8921"
API_ORIGIN: "http://localhost:8931"
image: calendars:frontend-development
env_file:
- env.d/development/frontend.defaults
@@ -111,20 +96,10 @@ services:
- /home/frontend/node_modules
- /home/frontend/apps/calendars/node_modules
ports:
- "8920:3000"
crowdin:
image: crowdin/cli:3.16.0
volumes:
- ".:/app"
env_file:
- env.d/development/crowdin.defaults
- env.d/development/crowdin.local
user: "${DOCKER_USER:-1000}"
working_dir: /app
- "8930:3000"
node:
image: node:22
image: node:24
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -134,17 +109,17 @@ services:
# CalDAV Server
caldav:
build:
context: docker/sabredav
context: src/caldav
ports:
- "8922:80"
- "8932:80"
env_file:
- env.d/development/caldav.defaults
- env.d/development/caldav.local
volumes:
- ./docker/sabredav/server.php:/var/www/sabredav/server.php
- ./docker/sabredav/src:/var/www/sabredav/src
- ./docker/sabredav/sql:/var/www/sabredav/sql
- ./docker/sabredav/init-database.sh:/usr/local/bin/init-database.sh
- ./src/caldav/server.php:/var/www/sabredav/server.php
- ./src/caldav/src:/var/www/sabredav/src
- ./src/caldav/sql:/var/www/sabredav/sql
- ./src/caldav/init-database.sh:/usr/local/bin/init-database.sh
networks:
- default
- lasuite
@@ -166,13 +141,13 @@ services:
- start-dev
- --features=preview
- --import-realm
- --hostname=http://localhost:8925
- --hostname=http://localhost:8935
- --hostname-strict=false
env_file:
- env.d/development/keycloak.defaults
- env.d/development/keycloak.local
ports:
- "8925:8080"
- "8935:8080"
depends_on:
postgresql:
condition: service_healthy

View File

@@ -694,16 +694,16 @@
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:8920/*",
"http://localhost:8921/*",
"http://localhost:8922/*",
"http://localhost:8923/*"
"http://localhost:8930/*",
"http://localhost:8931/*",
"http://localhost:8932/*",
"http://localhost:8933/*"
],
"webOrigins": [
"http://localhost:8920",
"http://localhost:8921",
"http://localhost:8922",
"http://localhost:8923"
"http://localhost:8930",
"http://localhost:8931",
"http://localhost:8932",
"http://localhost:8933"
],
"notBefore": 0,
"bearerOnly": false,
@@ -719,7 +719,7 @@
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8920/*##http://localhost:8921/*",
"post.logout.redirect.uris": "http://localhost:8930/*##http://localhost:8931/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
@@ -765,16 +765,16 @@
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:8920/*",
"http://localhost:8921/*",
"http://localhost:8922/*",
"http://localhost:8923/*"
"http://localhost:8930/*",
"http://localhost:8931/*",
"http://localhost:8932/*",
"http://localhost:8933/*"
],
"webOrigins": [
"http://localhost:8920",
"http://localhost:8921",
"http://localhost:8922",
"http://localhost:8923"
"http://localhost:8930",
"http://localhost:8931",
"http://localhost:8932",
"http://localhost:8933"
],
"notBefore": 0,
"bearerOnly": false,
@@ -790,7 +790,7 @@
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8920/*##http://localhost:8921/*",
"post.logout.redirect.uris": "http://localhost:8930/*##http://localhost:8931/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",

View File

@@ -1,45 +0,0 @@
server {
listen 8083;
server_name localhost;
charset utf-8;
# API routes - proxy to Django backend
location /api/ {
proxy_pass http://backend-dev:8000;
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-Forwarded-Proto $scheme;
}
# RSVP and iCal routes - proxy to Django backend
location /rsvp/ {
proxy_pass http://backend-dev:8000;
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-Forwarded-Proto $scheme;
}
location /ical/ {
proxy_pass http://backend-dev:8000;
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-Forwarded-Proto $scheme;
}
# Frontend - proxy to Next.js dev server
location / {
proxy_pass http://frontend-dev:3000;
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-Forwarded-Proto $scheme;
# WebSocket support for HMR
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,15 +0,0 @@
server {
listen 8923;
server_name localhost;
charset utf-8;
# Keycloak - all auth-related paths
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -1,30 +0,0 @@
CREATE TABLE principals (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
email VARCHAR(80),
displayname VARCHAR(80)
);
ALTER TABLE ONLY principals
ADD CONSTRAINT principals_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX principals_ukey
ON principals USING btree (uri);
CREATE TABLE groupmembers (
id SERIAL NOT NULL,
principal_id INTEGER NOT NULL,
member_id INTEGER NOT NULL
);
ALTER TABLE ONLY groupmembers
ADD CONSTRAINT groupmembers_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX groupmembers_ukey
ON groupmembers USING btree (principal_id, member_id);
INSERT INTO principals (uri,email,displayname) VALUES
('principals/admin', 'admin@example.org','Administrator'),
('principals/admin/calendar-proxy-read', null, null),
('principals/admin/calendar-proxy-write', null, null);

View File

@@ -1,53 +0,0 @@
<?php
/**
* Custom principal backend that auto-creates principals when they don't exist.
* This allows Apache authentication to work without pre-creating principals.
*/
namespace Calendars\SabreDav;
use Sabre\DAVACL\PrincipalBackend\PDO as BasePDO;
use Sabre\DAV\MkCol;
class AutoCreatePrincipalBackend extends BasePDO
{
/**
* Returns a specific principal, specified by it's path.
* Auto-creates the principal if it doesn't exist.
*
* @param string $path
*
* @return array|null
*/
public function getPrincipalByPath($path)
{
$principal = parent::getPrincipalByPath($path);
// If principal doesn't exist, create it automatically
if (!$principal && strpos($path, 'principals/') === 0) {
// Extract username from path (e.g., "principals/user@example.com" -> "user@example.com")
$username = substr($path, strlen('principals/'));
// Create principal directly in database
// Access protected pdo property from parent
$pdo = $this->pdo;
$tableName = $this->tableName;
try {
$stmt = $pdo->prepare(
'INSERT INTO ' . $tableName . ' (uri, email, displayname) VALUES (?, ?, ?) ON CONFLICT (uri) DO NOTHING'
);
$stmt->execute([$path, $username, $username]);
// Retry getting the principal
$principal = parent::getPrincipalByPath($path);
} catch (\Exception $e) {
// If creation fails, return null
error_log("Failed to auto-create principal: " . $e->getMessage());
return null;
}
}
return $principal;
}
}

View File

@@ -1,278 +0,0 @@
<?php
/**
* ICSImportPlugin - Bulk import events from a multi-event ICS file.
*
* Accepts a single POST with raw ICS data and splits it into individual
* calendar objects using Sabre\VObject\Splitter\ICalendar. Each split
* VCALENDAR is validated/repaired and inserted directly via the CalDAV
* PDO backend, avoiding N HTTP round-trips from Python.
*
* The endpoint is gated by a dedicated X-Calendars-Import header so that
* only the Python backend can call it (not future proxied CalDAV clients).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
use Sabre\VObject;
class ICSImportPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var CalDAVBackend */
private $caldavBackend;
/** @var string */
private $importApiKey;
public function __construct(CalDAVBackend $caldavBackend, string $importApiKey)
{
$this->caldavBackend = $caldavBackend;
$this->importApiKey = $importApiKey;
}
public function getPluginName()
{
return 'ics-import';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 90: runs before the debug logger (50)
$server->on('method:POST', [$this, 'httpPost'], 90);
}
/**
* Handle POST requests with ?import query parameter.
*
* @return bool|null false to stop event propagation, null to let
* other handlers proceed.
*/
public function httpPost($request, $response)
{
// Only handle requests with ?import in the query string
$queryParams = $request->getQueryParameters();
if (!array_key_exists('import', $queryParams)) {
return;
}
// Verify the dedicated import header
$headerValue = $request->getHeader('X-Calendars-Import');
if (!$headerValue || $headerValue !== $this->importApiKey) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Forbidden: missing or invalid X-Calendars-Import header',
]));
return false;
}
// Resolve the calendar from the request path.
// getPath() returns a path relative to the base URI, e.g.
// "calendars/user@example.com/cal-uuid"
$path = $request->getPath();
$parts = explode('/', trim($path, '/'));
// Expect exactly: [calendars, <user>, <calendar-uri>]
if (count($parts) < 3 || $parts[0] !== 'calendars') {
error_log("[ICSImportPlugin] Invalid calendar path: " . $path);
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Invalid calendar path',
]));
return false;
}
$principalUser = urldecode($parts[1]);
$calendarUri = $parts[2];
$principalUri = 'principals/' . $principalUser;
// Look up calendarId by iterating the user's calendars
$calendarId = $this->resolveCalendarId($principalUri, $calendarUri);
if ($calendarId === null) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Calendar not found',
]));
return false;
}
// Read and parse the raw ICS body
$icsBody = $request->getBodyAsString();
if (empty($icsBody)) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Empty request body',
]));
return false;
}
try {
$vcal = VObject\Reader::read($icsBody);
} catch (\Exception $e) {
error_log("[ICSImportPlugin] Failed to parse ICS: " . $e->getMessage());
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Failed to parse ICS file',
]));
return false;
}
// Validate and auto-repair (fixes missing VALARM ACTION, etc.)
$vcal->validate(VObject\Component::REPAIR);
// Split by UID using the stream-based splitter
// The splitter expects a stream, so we wrap the serialized data
$stream = fopen('php://temp', 'r+');
fwrite($stream, $vcal->serialize());
rewind($stream);
$splitter = new VObject\Splitter\ICalendar($stream);
$totalEvents = 0;
$importedCount = 0;
$duplicateCount = 0;
$skippedCount = 0;
$errors = [];
while ($splitVcal = $splitter->getNext()) {
$totalEvents++;
try {
// Extract UID from the first VEVENT
$uid = null;
foreach ($splitVcal->VEVENT as $vevent) {
if (isset($vevent->UID)) {
$uid = (string)$vevent->UID;
break;
}
}
if (!$uid) {
$uid = \Sabre\DAV\UUIDUtil::getUUID();
}
// Sanitize event data (strip attachments, truncate descriptions)
// and enforce max resource size
$this->sanitizeAndCheckSize($splitVcal);
$objectUri = $uid . '.ics';
$data = $splitVcal->serialize();
$this->caldavBackend->createCalendarObject(
$calendarId,
$objectUri,
$data
);
$importedCount++;
} catch (\Exception $e) {
$msg = $e->getMessage();
$summary = '';
if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) {
$summary = (string)$splitVcal->VEVENT->SUMMARY;
}
// Duplicate key (SQLSTATE 23505) = event already exists
// "no valid instances" = dead recurring event (all occurrences excluded)
// Neither is actionable by the user, skip silently.
if (strpos($msg, '23505') !== false) {
$duplicateCount++;
} elseif (strpos($msg, 'valid instances') !== false) {
$skippedCount++;
} else {
$skippedCount++;
if (count($errors) < 10) {
$errors[] = [
'uid' => $uid ?? 'unknown',
'summary' => $summary,
'error' => $msg,
];
}
error_log(
"[ICSImportPlugin] Failed to import event "
. "uid=" . ($uid ?? 'unknown')
. " summary={$summary}: {$msg}"
);
}
}
}
fclose($stream);
error_log(
"[ICSImportPlugin] Import complete: "
. "{$importedCount} imported, "
. "{$duplicateCount} duplicates, "
. "{$skippedCount} failed "
. "out of {$totalEvents} total"
);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'total_events' => $totalEvents,
'imported_count' => $importedCount,
'duplicate_count' => $duplicateCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
]));
return false;
}
/**
* Sanitize a split VCALENDAR before import and enforce max resource size.
*
* Delegates to CalendarSanitizerPlugin (if registered). Import bypasses
* the HTTP layer (uses createCalendarObject directly), so beforeCreateFile
* hooks don't fire — we must call the sanitizer explicitly.
*
* @throws \Exception if the sanitized object exceeds the max resource size.
*/
private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal)
{
$sanitizer = $this->server->getPlugin('calendar-sanitizer');
if ($sanitizer) {
$sanitizer->sanitizeVCalendar($vcal);
$sanitizer->checkResourceSize($vcal);
}
}
/**
* Resolve the internal calendar ID (the [calendarId, instanceId] pair)
* from a principal URI and calendar URI.
*
* @param string $principalUri e.g. "principals/user@example.com"
* @param string $calendarUri e.g. "a1b2c3d4-..."
* @return array|null The calendarId pair, or null if not found.
*/
private function resolveCalendarId(string $principalUri, string $calendarUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar['uri'] === $calendarUri) {
return $calendar['id'];
}
}
return null;
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Bulk import events from a multi-event ICS file',
];
}
}

View File

@@ -5,8 +5,10 @@ checking whether a user is allowed to access the application. It
integrates with the DeployCenter API in production and uses a local
backend for development.
Unlike La Suite Messages, Calendars only checks `can_access` — there
is no admin permission sync.
Calendars checks two entitlements:
- `can_access`: whether the user can use the app at all
- `can_admin`: whether the user is an admin of their organization
(e.g. can create/delete resources)
## Architecture
@@ -18,7 +20,7 @@ is no admin permission sync.
┌──────────────▼──────────────────────────────┐
│ UserMeSerializer │
GET /users/me/ → { can_access: bool }
│ GET /users/me/ → { can_access, can_admin }
└──────────────┬──────────────────────────────┘
┌──────────────▼──────────────────────────────┐
@@ -97,6 +99,10 @@ is no admin permission sync.
is denied (returns 403).
- **Import events is fail-closed**: if the entitlements service is
unavailable, ICS import is denied (returns 403).
- **Resource provisioning is fail-closed**: if the entitlements
service is unavailable, resource creation/deletion is denied
(returns 403). The `can_admin` check follows the same pattern
as `can_access` fail-closed checks.
- The DeployCenter backend falls back to stale cached data when the
API is unavailable.
- `EntitlementsUnavailableError` is only raised when the API fails
@@ -157,7 +163,7 @@ class MyBackend(EntitlementsBackend):
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
# Return: {"can_access": bool}
# Return: {"can_access": bool, "can_admin": bool, ...}
# Raise EntitlementsUnavailableError on failure.
pass
```
@@ -175,7 +181,8 @@ Headers: `X-Service-Auth: Bearer {api_key}`
Query parameters include any configured `oidc_claims` extracted from
the OIDC user_info response (e.g. `siret`).
Expected response: `{"entitlements": {"can_access": true}}`
Expected response:
`{"entitlements": {"can_access": true, "can_admin": false}}`
## Access control flow

View File

@@ -171,9 +171,9 @@ When an event with attendees is deleted:
| Email service | `src/backend/core/services/calendar_invitation_service.py` |
| ICS parser | `src/backend/core/services/calendar_invitation_service.py` (`ICalendarParser`) |
| Email templates | `src/backend/core/templates/emails/calendar_invitation*.html` |
| SabreDAV sanitizer | `docker/sabredav/src/CalendarSanitizerPlugin.php` |
| SabreDAV attendee dedup | `docker/sabredav/src/AttendeeNormalizerPlugin.php` |
| SabreDAV callback plugin | `docker/sabredav/src/HttpCallbackIMipPlugin.php` |
| SabreDAV sanitizer | `src/caldav/src/CalendarSanitizerPlugin.php` |
| SabreDAV attendee dedup | `src/caldav/src/AttendeeNormalizerPlugin.php` |
| SabreDAV callback plugin | `src/caldav/src/HttpCallbackIMipPlugin.php` |
## Future: Messages mail client integration

503
docs/organizations.md Normal file
View File

@@ -0,0 +1,503 @@
# Organizations
How organizations and multi-tenancy work in La Suite Calendars:
scoping users, calendars, resources, and permissions by
organization.
## Table of Contents
- [Overview](#overview)
- [What Is an Organization in Calendars?](#what-is-an-organization-in-calendars)
- [Where Organization Context Comes From](#where-organization-context-comes-from)
- [OIDC Claims](#oidc-claims)
- [DeployCenter and Entitlements](#deploycenter-and-entitlements)
- [What Is Org-Scoped](#what-is-org-scoped)
- [Data Model](#data-model)
- [Auto-Population on Login](#auto-population-on-login)
- [Why a Local Model?](#why-a-local-model)
- [CalDAV and Multi-Tenancy](#caldav-and-multi-tenancy)
- [The Core Problem](#the-core-problem)
- [SabreDAV Principal Backend Filtering](#sabredav-principal-backend-filtering)
- [User Discovery and Sharing](#user-discovery-and-sharing)
- [Resource Scoping](#resource-scoping)
- [Entitlements Integration](#entitlements-integration)
- [Frontend Considerations](#frontend-considerations)
- [Implementation Plan](#implementation-plan)
- [Phase 1: Org Context Propagation](#phase-1-org-context-propagation)
- [Phase 2: User Discovery Scoping](#phase-2-user-discovery-scoping)
- [Phase 3: CalDAV Scoping](#phase-3-caldav-scoping)
- [Phase 4: Resource Scoping](#phase-4-resource-scoping)
- [Key Files](#key-files)
---
## Overview
La Suite Calendars scopes users, calendars, and resources by
organization. Every user belongs to exactly one org, determined by
their email domain (default) or a configurable OIDC claim. Orgs
are created automatically on first login.
The overarching constraint is that **CalDAV has no native concept of
organizations or tenants**. The protocol operates on principals,
calendars, and scheduling. Org scoping is layered on top via
SabreDAV backend filtering.
---
## What Is an Organization in Calendars?
An organization is a **boundary for user and resource visibility**.
Within an organization:
- Users discover and share calendars with other members
- Resources (meeting rooms, equipment) are visible and bookable
- Admins manage resources and org-level settings
Across organizations:
- Users cannot discover each other (unless explicitly shared with
by email)
- Resources are invisible
- Scheduling still works via email (iTIP), just like scheduling
with external users
An organization maps to a real-world entity: a company, a government
agency, a university department. It is identified by a **unique ID**
-- either a specific OIDC claim value (like a SIRET number in
France) or an **email domain** (the default).
---
## Where Organization Context Comes From
### OIDC Claims
The user's organization is identified at authentication time via an
OIDC claim. The identity provider (Keycloak) includes an org
identifier in the user info response:
```json
{
"sub": "abc-123",
"email": "alice@ministry.gouv.fr",
"siret": "13002526500013"
}
```
The claim used to identify the org (e.g. `siret`) is configured via
`OIDC_USERINFO_ORGANIZATION_CLAIM`. When no claim is configured, the email
domain is used as the org identifier.
The claim names and their meaning depend on the Keycloak
configuration and the identity federation in use (AgentConnect for
French public sector, ProConnect, etc.).
### DeployCenter and Entitlements
The [entitlements system](entitlements.md) already forwards
OIDC claims to DeployCenter. The `oidc_claims` parameter in the
DeployCenter backend config specifies which claims to include:
```json
{
"oidc_claims": ["siret"]
}
```
DeployCenter uses the `siret` claim to determine which organization
the user belongs to and whether they have access to the Calendars
service. It also knows the **organization name** (e.g. "Ministere
X") and returns it in the entitlements response. This means the
entitlements system is the **source of truth for org names** --
Calendars does not need a separate OIDC claim for the org name.
---
## What Is Org-Scoped
| Feature | Behavior |
|---------|----------|
| **User discovery** (search when sharing) | Same-org users only |
| **Calendar sharing suggestions** | Same-org users; cross-org by typing full email |
| **Resource discovery** | Same-org resources only |
| **Resource creation** | Org admins only (`can_admin` entitlement) |
| **Resource booking** | Same-org users only |
| **Free/busy lookup** | Same-org principals |
Things that are **not** org-scoped:
- **Event scheduling via email**: iTIP works across orgs (same as
external users)
- **Calendar sharing by email**: A user can share a calendar with
anyone by typing their email address
- **CalDAV protocol operations**: PUT, GET, PROPFIND on a user's
own calendars
- **Subscription tokens**: Public iCal URLs
---
## Data Model
A lightweight `Organization` model stores just enough to scope
data. It is auto-populated on login from the OIDC claim (or email
domain) and the entitlements response.
```python
class Organization(BaseModel):
"""Organization model, populated from OIDC claims and entitlements."""
name = models.CharField(max_length=200, blank=True)
external_id = models.CharField(
max_length=128, unique=True, db_index=True
)
class Meta:
db_table = "calendars_organization"
```
A FK on User links each user to their org:
```python
class User(AbstractBaseUser, ...):
organization = models.ForeignKey(
Organization, on_delete=models.PROTECT, related_name="members"
)
```
### Auto-Population on Login
On OIDC login, `post_get_or_create_user()` resolves the org. The
org identifier (`external_id`) comes from the OIDC claim or
email domain. The org **name** comes from the entitlements response.
```python
# 1. Determine the org identifier
claim_key = settings.OIDC_USERINFO_ORGANIZATION_CLAIM # e.g. "siret"
if claim_key:
reg_id = user_info.get(claim_key)
else:
# Default: derive org from email domain
reg_id = user.email.split("@")[-1] if user.email and "@" in user.email else None
# 2. Get org name from entitlements (looked up from DeployCenter)
org_name = entitlements.get("organization_name", "")
# 3. Create or update the org
if reg_id:
org, created = Organization.objects.get_or_create(
external_id=reg_id,
defaults={"name": org_name}
)
if not created and org_name and org.name != org_name:
org.name = org_name
org.save(update_fields=["name"])
if user.organization_id != org.id:
user.organization = org
user.save(update_fields=["organization"])
```
By default, the org is derived from the **user's email domain**
(e.g. `alice@ministry.gouv.fr` → org `ministry.gouv.fr`). Orgs
are always created automatically on first login.
`OIDC_USERINFO_ORGANIZATION_CLAIM` can override this with a specific OIDC
claim (e.g. `"siret"` for French public sector, `"organization_id"`
for other identity providers).
The org name is kept in sync: each login updates it from the
entitlements response if it has changed. If entitlements are
unavailable on login (fail-open), the org is still created from
the OIDC claim or email domain, but the name is left empty until
a subsequent login succeeds.
### Why a Local Model?
- **Efficient queries**: `User.objects.filter(organization=org)`
for user search scoping, instead of JSONField queries on claims
- **Org-level settings**: Place to attach resource creation policy,
default timezone, branding, etc.
- **SabreDAV integration**: The org's Django UUID is forwarded to
SabreDAV as `X-CalDAV-Organization` for principal scoping
- **Claim-agnostic**: The claim name is a setting, not hardcoded
---
## CalDAV and Multi-Tenancy
### The Core Problem
CalDAV principals live in a flat namespace: `principals/{username}`.
When a frontend does a `PROPFIND` on `principals/` or a
`principal-property-search`, SabreDAV returns **all** principals.
There is no built-in way to scope results by organization.
The same applies to scheduling: `calendar-free-busy-set` returns
free/busy for any principal the server knows about.
A design principle is that **Django should not inspect or filter
CalDAV traffic**. The `CalDAVProxyView` is a pass-through proxy --
it sets authentication headers and forwards requests, but never
parses CalDAV XML. Org scoping must happen either in SabreDAV
itself or in the frontend.
### SabreDAV Principal Backend Filtering
Org scoping is enforced server-side in SabreDAV by filtering
principal queries by `org_id`. Django never inspects CalDAV
traffic -- it only sets the `X-CalDAV-Organization` header.
```php
class OrgAwarePrincipalBackend extends AutoCreatePrincipalBackend
{
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof')
{
$orgId = $this->server->httpRequest->getHeader('X-CalDAV-Organization');
// Add WHERE org_id = $orgId to the query
return parent::searchPrincipals(...) + org filter;
}
}
```
**Implementation:**
1. Add `org_id` column to `principals` table
2. Set it when auto-creating principals (from `X-CalDAV-Organization`)
3. Filter discovery and listing methods by org (see below)
4. `CalDAVProxyView` always sets `X-CalDAV-Organization` from the
authenticated user's org
**Which backend methods are org-filtered:**
| Method | Filtered? | Why |
|--------|-----------|-----|
| `searchPrincipals()` | Yes | Used for user/resource discovery |
| `getPrincipalsByPrefix()` | Yes | Used for listing principals |
| `getPrincipalByPath()` | **No** | Used for sharing and scheduling with a specific principal — must work cross-org |
| Schedule outbox free/busy | Yes | Aggregates all calendars for a principal — scoped to same-org |
| `free-busy-query` on a specific calendar | **No** | If a user has access to a shared calendar, they can query its free/busy regardless of org |
This keeps principal paths stable (`principals/{username}` -- no
org baked into the URI), enforces scoping at the CalDAV level for
both web and external clients (Apple Calendar, Thunderbird), and
allows cross-org sharing to work when explicitly granted.
---
## User Discovery and Sharing
When a user types an email to share a calendar, the frontend
currently searches all users. With orgs:
### Same-Org Discovery
The user search endpoint (`GET /api/v1.0/users/?q=alice`) should
return only users in the same organization by default. This is a
Django-side filter:
```python
# In UserViewSet.get_queryset():
queryset = queryset.filter(organization=request.user.organization)
```
### Cross-Org Sharing
Typing a full email address that doesn't match any same-org user
should still work. The frontend sends the sharing request to CalDAV
with the email address. SabreDAV resolves the recipient via
`getPrincipalByPath()`, which is **not org-filtered** -- so
cross-org sharing works. If the recipient is external (not on this
server), an iTIP email is sent.
Once shared, the recipient can see that calendar's events and
query its free/busy (via `free-busy-query` on the specific
calendar collection), regardless of org. They still cannot
discover the sharer's other calendars or query their aggregate
free/busy via the scheduling outbox.
The UI should make this distinction clear:
- Autocomplete results: same-org users
- Manual email entry: "This user is outside your organization"
### CalDAV User Search
The CalDAV `principal-property-search` REPORT is how external
CalDAV clients discover users. SabreDAV only returns principals
from the user's org.
---
## Resource Scoping
Resource discovery and booking scoping follows the same pattern as
user scoping. See [docs/resources.md](resources.md) for the full
resource design.
Key points for org scoping of resources:
1. **Resource principals** get an org association (same `org_id`
column on the `principals` table as user principals)
2. **Resource discovery** is scoped to the user's org
3. **Resource creation** requires an org-admin permission
4. **Resource email addresses** follow the convention
`{opaque-id}@resource.calendar.{APP_DOMAIN}` -- the org is
**not** encoded in the email address (see resources.md for
rationale)
5. **No cross-org resource booking** -- the auto-schedule plugin
rejects invitations from users outside the resource's org
The resource creation permission gate checks the user's
`can_admin` entitlement, returned by the entitlements system
alongside `can_access`.
---
## Entitlements Integration
The [entitlements system](entitlements.md) controls whether a user
can access Calendars at all. Organizations add a layer on top:
```
User authenticates
→ Entitlements check: can_access? (DeployCenter, per-user)
→ Org resolution: which org? (OIDC claim or email domain)
→ Org name: from entitlements response
→ Scoping: show only org's users/resources
```
The entitlements backend already receives OIDC claims (including
`siret`). DeployCenter resolves the organization and returns the
org name alongside the access decision:
```json
{
"can_access": true,
"can_admin": false,
"organization_name": "Ministere X"
}
```
On login, `post_get_or_create_user()` uses the entitlements
response to populate the organization name. The org's
`external_id` is determined locally (from the OIDC claim or
email domain), but the **display name comes from DeployCenter**.
This avoids requiring a separate OIDC claim for the org name and
keeps DeployCenter as the single source of truth for org metadata.
---
## Frontend Considerations
### Org-Aware UI Elements
- **User search/autocomplete:** Filter results to same-org users by
default; show a "search all users" or "invite by email" option for
cross-org
- **Resource picker:** Only show resources from the user's org
- **Calendar list:** No change (users only see calendars they own or
are shared on)
- **No-access page:** Already exists (from entitlements). Could show
org-specific messaging
- **Org switcher:** Not needed (a user belongs to exactly one org)
### Org Context in Frontend State
The frontend needs to know the user's org ID to:
- Scope user search API calls
- Scope resource PROPFIND requests
- Display org name in the UI
This can come from the `GET /users/me/` response:
```json
{
"id": "user-uuid",
"email": "alice@ministry.gouv.fr",
"organization": {
"id": "org-uuid",
"name": "Ministere X"
}
}
```
---
## Implementation Plan
### Phase 1: Org Context Propagation
**Goal:** Every request knows the user's org.
1. Add `OIDC_USERINFO_ORGANIZATION_CLAIM` setting (default: `""`, uses email
domain)
2. Add `Organization` model (id, name, external_id)
3. Add `organization` FK on `User` (non-nullable -- every user has
an org)
4. In `post_get_or_create_user()`, resolve org from email domain or
OIDC claim and set `user.organization`
5. Expose `organization` in `GET /users/me/` response
6. Frontend stores org context from `/users/me/`
### Phase 2: User Discovery Scoping
**Goal:** User search returns same-org users by default.
1. Scope `UserViewSet` queryset by org when org is set
2. Frontend user search autocomplete uses scoped endpoint
3. Cross-org sharing still works via explicit email entry
4. Add `X-CalDAV-Organization` header to `CalDAVProxyView` requests
### Phase 3: CalDAV Scoping
**Goal:** CalDAV operations are org-scoped.
1. Add `org_id` column to SabreDAV `principals` table
2. Set `org_id` when auto-creating principals (from
`X-CalDAV-Organization`)
3. Extend `AutoCreatePrincipalBackend` to filter
`searchPrincipals()`, `getPrincipalsByPrefix()`, and free/busy
by org
4. Test with external CalDAV clients (Apple Calendar, Thunderbird)
### Phase 4: Resource Scoping
**Goal:** Resources are org-scoped (depends on resources being
implemented -- see [docs/resources.md](resources.md)).
1. Resource creation endpoint requires org-admin permission
2. Resource principals get org association
3. Resource discovery is scoped by org
4. Resource booking respects org boundaries
---
## Key Files
| Area | Path |
|------|------|
| User model (claims) | `src/backend/core/models.py` |
| OIDC auth backend | `src/backend/core/authentication/backends.py` |
| OIDC settings | `src/backend/calendars/settings.py` |
| CalDAV proxy | `src/backend/core/api/viewsets_caldav.py` |
| Entitlements system | `src/backend/core/entitlements/` |
| User serializer | `src/backend/core/api/serializers.py` |
| SabreDAV principal backend | `src/caldav/src/AutoCreatePrincipalBackend.php` |
| SabreDAV server config | `src/caldav/server.php` |
| Resource scoping details | `docs/resources.md` |
| Entitlements details | `docs/entitlements.md` |
---
## Design Decisions
1. **A user belongs to exactly one org**, determined by the OIDC
claim at login.
2. **Cross-org calendar sharing is allowed** -- a user can share by
email with anyone. Autocomplete only shows same-org users;
cross-org sharing requires typing the full email.
3. **Cross-org resource booking is not allowed** -- the
auto-schedule plugin rejects invitations from users outside the
resource's org.
4. **Org scoping is enforced in SabreDAV**, not in Django. Django
only sets `X-CalDAV-Organization` on proxied requests.
5. **Org is derived from email domain by default**. A specific OIDC
claim can be configured via `OIDC_USERINFO_ORGANIZATION_CLAIM` (e.g.
`siret` for French public sector).

1002
docs/resources.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,11 @@ PYTHONPATH=/app
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
MEDIA_BASE_URL=http://localhost:8923
MEDIA_BASE_URL=http://localhost:8933
# OIDC - Keycloak on dedicated port 8925
# OIDC - Keycloak on dedicated port 8935
OIDC_OP_JWKS_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8925/realms/calendars/protocol/openid-connect/auth
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8935/realms/calendars/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/userinfo
@@ -30,15 +30,15 @@ OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=http://localhost:8920
LOGIN_REDIRECT_URL_FAILURE=http://localhost:8920
LOGOUT_REDIRECT_URL=http://localhost:8920
LOGIN_REDIRECT_URL=http://localhost:8930
LOGIN_REDIRECT_URL_FAILURE=http://localhost:8930
LOGOUT_REDIRECT_URL=http://localhost:8930
OIDC_REDIRECT_ALLOWED_HOSTS="http://localhost:8923,http://localhost:8920"
OIDC_REDIRECT_ALLOWED_HOSTS="http://localhost:8933,http://localhost:8930"
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Resource Server Backend
OIDC_OP_URL=http://localhost:8925/realms/calendars
OIDC_OP_URL=http://localhost:8935/realms/calendars
OIDC_OP_INTROSPECTION_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/token/introspect
OIDC_RESOURCE_SERVER_ENABLED=False
OIDC_RS_CLIENT_ID=calendars
@@ -50,6 +50,7 @@ OIDC_RS_ALLOWED_AUDIENCES=""
CALDAV_URL=http://caldav:80
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
CALDAV_INTERNAL_API_KEY=changeme-internal-in-production
# Internal URL for CalDAV scheduling callbacks (accessible from CalDAV container)
CALDAV_CALLBACK_BASE_URL=http://backend-dev:8000

View File

@@ -3,9 +3,10 @@ PGPORT=5432
PGDATABASE=calendars
PGUSER=pgroot
PGPASSWORD=pass
CALDAV_BASE_URI=/api/v1.0/caldav/
CALDAV_BASE_URI=/caldav/
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
CALDAV_INTERNAL_API_KEY=changeme-internal-in-production
# Default callback URL for sending scheduling notifications (emails)
# Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header
CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback

View File

@@ -1,2 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921
NEXT_PUBLIC_API_ORIGIN=http://localhost:8931
NEXT_TELEMETRY_DISABLED=1

View File

@@ -94,9 +94,6 @@ WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that

View File

@@ -1,5 +1 @@
"""Calendars package. Import the celery app early to load shared task form dependencies."""
from .celery_app import app as celery_app
__all__ = ["celery_app"]
"""Calendars Django project package."""

View File

@@ -1,26 +0,0 @@
"""Calendars celery configuration file."""
import os
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
# Can be loaded only after install call.
from django.conf import settings # pylint: disable=wrong-import-position
app = Celery("calendars")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

View File

@@ -14,8 +14,6 @@ import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
@@ -74,13 +72,24 @@ class Base(Configuration):
# CalDAV API keys for bidirectional authentication
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
CALDAV_INBOUND_API_KEY = values.Value(
CALDAV_INBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
)
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
CALDAV_OUTBOUND_API_KEY = values.Value(
CALDAV_OUTBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None
)
# INTERNAL: API key for Django → CalDAV internal API (resource provisioning, import)
CALDAV_INTERNAL_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None
)
# Salt for django-fernet-encrypted-fields (Channel tokens, etc.)
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
SALT_KEY = values.Value(
"calendars-default-salt-change-in-production",
environ_name="SALT_KEY",
environ_prefix=None,
)
# Base URL for CalDAV scheduling callbacks (must be accessible from CalDAV container)
# In Docker environments, use the internal Docker network URL (e.g., http://backend:8000)
CALDAV_CALLBACK_BASE_URL = values.Value(
@@ -117,7 +126,7 @@ class Base(Configuration):
CALENDAR_INVITATION_FROM_EMAIL = values.Value(
None, environ_name="CALENDAR_INVITATION_FROM_EMAIL", environ_prefix=None
)
APP_NAME = values.Value("Calendrier", environ_name="APP_NAME", environ_prefix=None)
APP_NAME = values.Value("Calendars", environ_name="APP_NAME", environ_prefix=None)
APP_URL = values.Value("", environ_name="APP_URL", environ_prefix=None)
CALENDAR_ITIP_ENABLED = values.BooleanValue(
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
@@ -133,6 +142,18 @@ class Base(Configuration):
environ_prefix=None,
)
# Organizations
OIDC_USERINFO_ORGANIZATION_CLAIM = values.Value(
"",
environ_name="OIDC_USERINFO_ORGANIZATION_CLAIM",
environ_prefix=None,
)
RESOURCE_EMAIL_DOMAIN = values.Value(
"",
environ_name="RESOURCE_EMAIL_DOMAIN",
environ_prefix=None,
)
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -212,7 +233,7 @@ class Base(Configuration):
# This is used to limit the size of the request body in memory.
# This also limits the size of the file that can be uploaded to the server.
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
2 * (2**30), # 2GB
20 * (2**20), # 20MB
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
environ_prefix=None,
)
@@ -234,15 +255,13 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("nl-nl", _("Dutch")),
("en-us", "English"),
("fr-fr", "French"),
("de-de", "German"),
("nl-nl", "Dutch"),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
@@ -275,7 +294,6 @@ class Base(Configuration):
"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",
@@ -296,7 +314,7 @@ class Base(Configuration):
"drf_standardized_errors",
# Third party apps
"corsheaders",
"django_celery_beat",
"django_dramatiq",
"django_filters",
"rest_framework",
"rest_framework_api_key",
@@ -415,7 +433,11 @@ class Base(Configuration):
)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
RSVP_TOKEN_MAX_AGE_RECURRING = values.PositiveIntegerValue(
7776000, # 90 days
environ_name="RSVP_TOKEN_MAX_AGE_RECURRING",
environ_prefix=None,
)
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -514,10 +536,40 @@ class Base(Configuration):
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Dramatiq
DRAMATIQ_BROKER = {
"BROKER": "dramatiq.brokers.redis.RedisBroker",
"OPTIONS": {
"url": values.Value(
"redis://redis:6379/0",
environ_name="DRAMATIQ_BROKER_URL",
environ_prefix=None,
),
},
"MIDDLEWARE": [
"dramatiq.middleware.AgeLimit",
"dramatiq.middleware.TimeLimit",
"dramatiq.middleware.Callbacks",
"dramatiq.middleware.Retries",
"dramatiq.middleware.CurrentMessage",
"django_dramatiq.middleware.DbConnectionsMiddleware",
"django_dramatiq.middleware.AdminMiddleware",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.redis.RedisBackend",
"BACKEND_OPTIONS": {
"url": values.Value(
"redis://redis:6379/1",
environ_name="DRAMATIQ_RESULT_BACKEND_URL",
environ_prefix=None,
),
},
"MIDDLEWARE_OPTIONS": {
"result_ttl": 1000 * 60 * 60 * 24 * 30, # 30 days
},
}
DRAMATIQ_AUTODISCOVER_MODULES = ["tasks"]
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -635,12 +687,6 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
@@ -870,7 +916,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8920",
"http://localhost:8930",
"http://localhost:3000",
]
DEBUG = True
@@ -887,8 +933,8 @@ class Development(Base):
EMAIL_USE_SSL = False
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
APP_NAME = "Calendrier (Dev)"
APP_URL = "http://localhost:8921"
APP_NAME = "Calendars (dev)"
APP_URL = "http://localhost:8931"
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
@@ -919,7 +965,18 @@ class Test(Base):
]
USE_SWAGGER = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
DRAMATIQ_BROKER = {
"BROKER": "core.task_utils.EagerBroker",
"OPTIONS": {},
"MIDDLEWARE": [
"dramatiq.middleware.CurrentMessage",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.stub.StubBackend",
"BACKEND_OPTIONS": {},
"MIDDLEWARE_OPTIONS": {"result_ttl": 1000 * 60 * 10},
}
OIDC_STORE_ACCESS_TOKEN = False
OIDC_STORE_REFRESH_TOKEN = False
@@ -977,6 +1034,7 @@ class Production(Base):
"^__lbheartbeat__",
"^__heartbeat__",
r"^api/v1\.0/caldav-scheduling-callback/",
r"^caldav/",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`

View File

@@ -2,7 +2,6 @@
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
@@ -23,20 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
(
_("Personal info"),
"Personal info",
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
"Permissions",
{
"fields": (
"is_active",
@@ -48,7 +46,7 @@ class UserAdmin(auth_admin.UserAdmin):
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
("Important dates", {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
@@ -86,27 +84,27 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.CalendarSubscriptionToken)
class CalendarSubscriptionTokenAdmin(admin.ModelAdmin):
"""Admin class for CalendarSubscriptionToken model."""
@admin.register(models.Channel)
class ChannelAdmin(admin.ModelAdmin):
"""Admin class for Channel model."""
list_display = (
"calendar_name",
"owner",
"name",
"type",
"organization",
"user",
"caldav_path",
"token",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
)
list_filter = ("is_active",)
search_fields = ("calendar_name", "owner__email", "caldav_path", "token")
readonly_fields = ("id", "token", "created_at", "last_accessed_at")
raw_id_fields = ("owner",)
list_filter = ("type", "is_active")
search_fields = ("name", "user__email", "caldav_path")
readonly_fields = ("id", "created_at", "updated_at", "last_used_at")
raw_id_fields = ("user", "organization")

View File

@@ -2,19 +2,12 @@
import logging
from django.core import exceptions
from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
logger = logging.getLogger(__name__)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -26,15 +19,6 @@ class IsAuthenticated(permissions.BasePermission):
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
@@ -46,27 +30,7 @@ class IsSelf(IsAuthenticated):
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 IsEntitled(IsAuthenticated):
class IsEntitledToAccess(IsAuthenticated):
"""Allows access only to users with can_access entitlement.
Fail-closed: denies access when the entitlements service is
@@ -78,25 +42,31 @@ class IsEntitled(IsAuthenticated):
return False
try:
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_access", True)
return entitlements.get("can_access", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying access for user %s",
request.user.pk,
)
return False
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
class IsOrgAdmin(IsAuthenticated):
"""Allows access only to users with can_admin entitlement.
Fail-closed: denies access when the entitlements service is
unavailable and no cached value exists.
"""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action not in [
"create",
]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
if not super().has_permission(request, view):
return False
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_admin", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying admin for user %s",
request.user.pk,
)
return False

View File

@@ -1,13 +1,22 @@
"""Client serializers for the calendars core app."""
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from rest_framework import exceptions, serializers
from rest_framework import serializers
from core import models
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import uuid_to_urlsafe
class OrganizationSerializer(serializers.ModelSerializer):
"""Serialize organizations."""
class Meta:
model = models.Organization
fields = ["id", "name"]
read_only_fields = ["id", "name"]
class UserLiteSerializer(serializers.ModelSerializer):
@@ -15,171 +24,174 @@ class UserLiteSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "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
fields = ["id", "full_name"]
read_only_fields = ["id", "full_name"]
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"language",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name"]
def get_email(self, user) -> str | None:
"""Return OIDC email, falling back to admin_email for staff users."""
return user.email or user.admin_email
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_access = serializers.SerializerMethodField(read_only=True)
can_admin = serializers.SerializerMethodField(read_only=True)
organization = OrganizationSerializer(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._entitlements_cache = {}
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_access"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_access"]
fields = [
*UserSerializer.Meta.fields,
"can_access",
"can_admin",
"organization",
]
read_only_fields = [
*UserSerializer.Meta.read_only_fields,
"can_access",
"can_admin",
"organization",
]
def _get_entitlements(self, user):
"""Get cached entitlements for the user, keyed by user.sub.
Cache is per-serializer-instance (request-scoped) to avoid
duplicate calls when both can_access and can_admin are serialized.
"""
if user.sub not in self._entitlements_cache:
try:
self._entitlements_cache[user.sub] = get_user_entitlements(
user.sub, user.email
)
except EntitlementsUnavailableError:
self._entitlements_cache[user.sub] = None
return self._entitlements_cache[user.sub]
def get_can_access(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_access", True)
except EntitlementsUnavailableError:
return True # fail-open
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_access", False)
def get_can_admin(self, user) -> bool:
"""Check admin entitlement for the current user."""
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_admin", False)
class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):
"""Serializer for CalendarSubscriptionToken model."""
class ChannelSerializer(serializers.ModelSerializer):
"""Read serializer for Channel model."""
role = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
fields = [
"token",
"url",
"id",
"name",
"type",
"organization",
"user",
"caldav_path",
"calendar_name",
"role",
"is_active",
"last_accessed_at",
"created_at",
]
read_only_fields = [
"token",
"settings",
"url",
"caldav_path",
"calendar_name",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_role(self, obj):
"""Get role from settings."""
return obj.role
def get_url(self, obj) -> str | None:
"""Build iCal subscription URL for ical-feed channels, None otherwise."""
if obj.type != "ical-feed":
return None
token = obj.encrypted_settings.get("token", "")
if not token:
return None
short_id = uuid_to_urlsafe(obj.pk)
calendar_name = obj.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
ical_path = f"/ical/{short_id}/{token}/{filename}.ics"
def get_url(self, obj) -> str:
"""Build the full subscription URL, enforcing HTTPS in production."""
request = self.context.get("request")
if request:
url = request.build_absolute_uri(f"/ical/{obj.token}.ics")
url = request.build_absolute_uri(ical_path)
else:
# Fallback to APP_URL if no request context
app_url = getattr(settings, "APP_URL", "")
url = f"{app_url.rstrip('/')}/ical/{obj.token}.ics"
app_url = settings.APP_URL
url = f"{app_url.rstrip('/')}{ical_path}"
# Force HTTPS in production to protect the token in transit
if not settings.DEBUG and url.startswith("http://"):
url = url.replace("http://", "https://", 1)
return url
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for creating a CalendarSubscriptionToken."""
class ChannelCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Write serializer for creating a Channel."""
caldav_path = serializers.CharField(max_length=512)
name = serializers.CharField(max_length=255)
type = serializers.CharField(max_length=255, default="caldav")
caldav_path = serializers.CharField(max_length=512, required=False, default="")
calendar_name = serializers.CharField(max_length=255, required=False, default="")
role = serializers.ChoiceField(
choices=[(r, r) for r in models.Channel.VALID_ROLES],
default=models.Channel.ROLE_READER,
)
def validate_caldav_path(self, value):
"""Validate and normalize the caldav_path."""
# Normalize path to always have trailing slash
"""Normalize caldav_path if provided."""
if value:
if not value.endswith("/"):
value = value + "/"
# Normalize path to always start with /
if not value.startswith("/"):
value = "/" + value
return value
def validate_type(self, value):
"""Validate channel type."""
if value == "ical-feed":
return value
return "caldav"
class ChannelWithTokenSerializer(ChannelSerializer):
"""Serializer that includes the plaintext token (used only on creation)."""
token = serializers.CharField(read_only=True)
class Meta(ChannelSerializer.Meta):
fields = [*ChannelSerializer.Meta.fields, "token"]

View File

@@ -1,5 +1,4 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
@@ -8,9 +7,7 @@ from django.conf import settings
from django.core.cache import cache
from django.utils.text import slugify
import rest_framework as drf
from rest_framework import response as drf_response
from rest_framework import status, viewsets
from rest_framework import mixins, pagination, response, status, views, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -21,7 +18,8 @@ from core.services.caldav_service import (
normalize_caldav_path,
verify_caldav_access,
)
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
from core.services.import_service import MAX_FILE_SIZE
from core.services.resource_service import ResourceProvisioningError, ResourceService
from . import permissions, serializers
@@ -31,60 +29,6 @@ logger = logging.getLogger(__name__)
# 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.
@@ -110,10 +54,10 @@ class SerializerPerActionMixin:
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
ordering = "-created_at"
max_page_size = settings.MAX_PAGE_SIZE
page_size_query_param = "page_size"
@@ -132,9 +76,9 @@ class UserListThrottleSustained(UserRateThrottle):
class UserViewSet(
SerializerPerActionMixin,
drf.mixins.UpdateModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.ListModelMixin,
mixins.ListModelMixin,
):
"""User ViewSet"""
@@ -142,7 +86,7 @@ class UserViewSet(
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
pagination_class = None
pagination_class = Pagination
throttle_classes = []
def get_throttles(self):
@@ -155,6 +99,7 @@ class UserViewSet(
def get_queryset(self):
"""
Limit listed users by querying the email field.
Scoped to the requesting user's organization.
If query contains "@", search exactly. Otherwise return empty.
"""
queryset = self.queryset
@@ -162,19 +107,22 @@ class UserViewSet(
if self.action != "list":
return queryset
# Scope to same organization; users without an org see no results
if not self.request.user.organization_id:
return queryset.none()
queryset = queryset.filter(organization_id=self.request.user.organization_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match exactly
if "@" in query:
return queryset.filter(email__iexact=query).order_by("email")[
: settings.API_USERS_LIST_LIMIT
]
return queryset.filter(email__iexact=query).order_by("email")
# For non-email queries, return empty (no fuzzy search)
return queryset.none()
@drf.decorators.action(
@action(
detail=False,
methods=["get"],
url_name="me",
@@ -185,12 +133,12 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
return response.Response(
self.get_serializer(request.user, context=context).data
)
class ConfigView(drf.views.APIView):
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
@@ -224,7 +172,7 @@ class ConfigView(drf.views.APIView):
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
return response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
@@ -272,7 +220,7 @@ class CalendarViewSet(viewsets.GenericViewSet):
def get_permissions(self):
if self.action == "import_events":
return [permissions.IsEntitled()]
return [permissions.IsEntitledToAccess()]
return super().get_permissions()
@action(
@@ -286,12 +234,18 @@ class CalendarViewSet(viewsets.GenericViewSet):
"""Import events from an ICS file into a calendar.
POST /api/v1.0/calendars/import-events/
Body (multipart): file=<ics>, caldav_path=/calendars/user@.../uuid/
Body (multipart): file=<ics>, caldav_path=/calendars/users/user@.../uuid/
Returns a task_id that can be polled at GET /api/v1.0/tasks/{task_id}/
"""
from core.tasks import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
import_events_task,
)
caldav_path = request.data.get("caldav_path", "")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path is required"},
return response.Response(
{"detail": "caldav_path is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -299,15 +253,15 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Verify user access
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
return response.Response(
{"detail": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Validate file presence
if "file" not in request.FILES:
return drf_response.Response(
{"error": "No file provided"},
return response.Response(
{"detail": "No file provided"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -315,124 +269,81 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Validate file size
if uploaded_file.size > MAX_FILE_SIZE:
return drf_response.Response(
{"error": "File too large. Maximum size is 10 MB."},
return response.Response(
{"detail": "File too large. Maximum size is 10 MB."},
status=status.HTTP_400_BAD_REQUEST,
)
ics_data = uploaded_file.read()
service = ICSImportService()
result = service.import_events(request.user, caldav_path, ics_data)
response_data = {
"total_events": result.total_events,
"imported_count": result.imported_count,
"duplicate_count": result.duplicate_count,
"skipped_count": result.skipped_count,
}
if result.errors:
response_data["errors"] = result.errors
# Queue the import task
task = import_events_task.delay(
str(request.user.id),
caldav_path,
ics_data.hex(),
)
task.track_owner(request.user.id)
return drf_response.Response(response_data, status=status.HTTP_200_OK)
return response.Response(
{"task_id": task.id},
status=status.HTTP_202_ACCEPTED,
)
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
"""
ViewSet for managing subscription tokens independently of Django Calendar model.
class ResourceViewSet(viewsets.ViewSet):
"""ViewSet for resource provisioning (create/delete).
This viewset operates directly with CalDAV paths, without requiring a Django
Calendar record. The backend verifies that the user has access to the calendar
by checking that their email is in the CalDAV path.
Endpoints:
- POST /api/v1.0/subscription-tokens/ - Create or get existing token
- GET /api/v1.0/subscription-tokens/by-path/ - Get token by CalDAV path
- DELETE /api/v1.0/subscription-tokens/by-path/ - Delete token by CalDAV path
Resources are CalDAV principals — this endpoint only handles
provisioning. All metadata, sharing, and discovery goes through CalDAV.
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSubscriptionTokenSerializer
permission_classes = [permissions.IsOrgAdmin]
def create(self, request):
"""Create a resource principal and its default calendar.
POST /api/v1.0/resources/
Body: {"name": "Room 101", "resource_type": "ROOM"}
"""
Create or get existing subscription token.
name = request.data.get("name", "").strip()
resource_type = request.data.get("resource_type", "ROOM").strip().upper()
POST body:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
- calendar_name: Display name of the calendar (optional)
"""
create_serializer = serializers.CalendarSubscriptionTokenCreateSerializer(
data=request.data
)
create_serializer.is_valid(raise_exception=True)
caldav_path = create_serializer.validated_data["caldav_path"]
calendar_name = create_serializer.validated_data.get("calendar_name", "")
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Get or create token
token, created = models.CalendarSubscriptionToken.objects.get_or_create(
owner=request.user,
caldav_path=caldav_path,
defaults={"calendar_name": calendar_name},
)
# Update calendar_name if provided and different
if not created and calendar_name and token.calendar_name != calendar_name:
token.calendar_name = calendar_name
token.save(update_fields=["calendar_name"])
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(
serializer.data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(detail=False, methods=["get", "delete"], url_path="by-path")
def by_path(self, request):
"""
Get or delete subscription token by CalDAV path.
Query parameter:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
"""
caldav_path = request.query_params.get("caldav_path")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path query parameter is required"},
if not name:
return response.Response(
{"detail": "name is required."},
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = normalize_caldav_path(caldav_path)
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
service = ResourceService()
try:
token = models.CalendarSubscriptionToken.objects.get(
owner=request.user,
caldav_path=caldav_path,
)
except models.CalendarSubscriptionToken.DoesNotExist:
return drf_response.Response(
{"error": "No subscription token exists for this calendar"},
status=status.HTTP_404_NOT_FOUND,
result = service.create_resource(request.user, name, resource_type)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
if request.method == "GET":
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(serializer.data)
return response.Response(result, status=status.HTTP_201_CREATED)
# DELETE
token.delete()
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, pk=None):
"""Delete a resource principal and its calendar.
DELETE /api/v1.0/resources/{resource_id}/
"""
resource_id = pk
if not resource_id:
return response.Response(
{"detail": "Resource ID is required."},
status=status.HTTP_400_BAD_REQUEST,
)
service = ResourceService()
try:
service.delete_resource(request.user, resource_id)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
return response.Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,11 +1,14 @@
"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
import re
import secrets
from django.conf import settings
from django.core.validators import validate_email
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@@ -13,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import Channel
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.calendar_invitation_service import calendar_invitation_service
@@ -30,6 +34,73 @@ class CalDAVProxyView(View):
Authentication is handled via session cookies instead.
"""
# HTTP methods allowed per Channel role
READER_METHODS = frozenset({"GET", "PROPFIND", "REPORT", "OPTIONS"})
EDITOR_METHODS = READER_METHODS | frozenset({"PUT", "POST", "DELETE", "PROPPATCH"})
ADMIN_METHODS = EDITOR_METHODS | frozenset({"MKCALENDAR", "MKCOL"})
ROLE_METHODS = {
Channel.ROLE_READER: READER_METHODS,
Channel.ROLE_EDITOR: EDITOR_METHODS,
Channel.ROLE_ADMIN: ADMIN_METHODS,
}
@staticmethod
def _authenticate_channel_token(request):
"""Try to authenticate via X-Channel-Id + X-Channel-Token headers.
Returns (channel, user) on success, (None, None) on failure.
"""
channel_id = request.headers.get("X-Channel-Id", "").strip()
token = request.headers.get("X-Channel-Token", "").strip()
if not channel_id or not token:
return None, None
try:
channel = Channel.objects.get(pk=channel_id, is_active=True, type="caldav")
except (ValueError, Channel.DoesNotExist):
return None, None
if not channel.verify_token(token):
return None, None
user = channel.user
if not user:
logger.warning("Channel %s has no user", channel.id)
return None, None
# Update last_used_at (fire-and-forget, no extra query on critical path)
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
return channel, user
@staticmethod
def _check_channel_path_access(channel, path):
"""Check that the CalDAV path is within the channel's scope.
Returns True if allowed, False if denied.
"""
# Ensure path starts with /
full_path = "/" + path.lstrip("/") if path else "/"
# caldav_path scope: request must be within the scoped calendar
# The trailing slash on caldav_path (enforced by serializer) ensures
# /cal1/ won't match /cal1-secret/
if channel.caldav_path:
if not channel.caldav_path.endswith("/"):
logger.error(
"caldav_path %r missing trailing slash", channel.caldav_path
)
return False
return full_path.startswith(channel.caldav_path)
# user scope: request must be under the user's calendars
if channel.user:
user_prefix = f"/calendars/users/{channel.user.email}/"
return full_path.startswith(user_prefix)
return False
@staticmethod
def _check_entitlements_for_creation(user):
"""Check if user is entitled to create calendars.
@@ -39,7 +110,7 @@ class CalDAVProxyView(View):
"""
try:
entitlements = get_user_entitlements(user.sub, user.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
return HttpResponse(
status=403,
content="Calendar creation not allowed",
@@ -51,27 +122,42 @@ class CalDAVProxyView(View):
)
return None
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements,too-many-locals
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT,"
" MKCOL, MKCALENDAR, PUT, DELETE, POST"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
"Content-Type, depth, x-channel-id, x-channel-token,"
" if-match, if-none-match, prefer"
)
return response
# Try channel token auth first (for external services like Messages)
channel = None
effective_user = None
if not request.user.is_authenticated:
channel, effective_user = self._authenticate_channel_token(request)
if not channel:
return HttpResponse(status=401)
else:
effective_user = request.user
# Block calendar creation (MKCALENDAR/MKCOL) for non-entitled users.
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
# so that users invited to shared calendars can still use them.
if channel:
# Enforce role-based method restrictions
allowed = self.ROLE_METHODS.get(channel.role, self.READER_METHODS)
if request.method not in allowed:
return HttpResponse(
status=403, content="Method not allowed for this role"
)
# Check entitlements for calendar creation (all auth methods)
if request.method in ("MKCALENDAR", "MKCOL"):
if denied := self._check_entitlements_for_creation(request.user):
if denied := self._check_entitlements_for_creation(effective_user):
return denied
# Build the CalDAV server URL
@@ -81,8 +167,9 @@ class CalDAVProxyView(View):
if not validate_caldav_proxy_path(path):
return HttpResponse(status=400, content="Invalid path")
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# Enforce channel path scope
if channel and not self._check_channel_path_access(channel, path):
return HttpResponse(status=403, content="Path not allowed for this channel")
http = CalDAVHTTPClient()
@@ -95,7 +182,7 @@ class CalDAVProxyView(View):
# Prepare headers — start with shared auth headers, add proxy-specific ones
try:
headers = CalDAVHTTPClient.build_base_headers(user_principal)
headers = CalDAVHTTPClient.build_base_headers(effective_user)
except ValueError:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
@@ -112,7 +199,7 @@ class CalDAVProxyView(View):
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
# the CalDAV container needs to reach Django via internal network)
callback_path = reverse("caldav-scheduling-callback")
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
callback_base_url = settings.CALDAV_CALLBACK_BASE_URL
if callback_base_url:
# Use configured internal URL (e.g., http://backend:8000)
headers["X-CalDAV-Callback-URL"] = (
@@ -145,7 +232,7 @@ class CalDAVProxyView(View):
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
target_url,
user_principal,
effective_user.email,
)
response = requests.request(
method=request.method,
@@ -161,7 +248,7 @@ class CalDAVProxyView(View):
if response.status_code == 401:
logger.warning(
"CalDAV server returned 401 for user %s at %s",
user_principal,
effective_user.email,
target_url,
)
@@ -182,7 +269,7 @@ class CalDAVProxyView(View):
except requests.exceptions.RequestException as e:
logger.error("CalDAV server proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
content="CalDAV server is unavailable",
status=502,
content_type="text/plain",
)
@@ -216,9 +303,8 @@ class CalDAVDiscoveryView(View):
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
response["Location"] = "/caldav/"
return response
@@ -239,24 +325,25 @@ class CalDAVSchedulingCallbackView(View):
See: https://sabre.io/dav/scheduling/
"""
def dispatch(self, request, *args, **kwargs):
http_method_names = ["post"]
def post(self, request, *args, **kwargs): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Handle scheduling messages from CalDAV server."""
# Authenticate via API key
api_key = request.headers.get("X-Api-Key", "").strip()
expected_key = settings.CALDAV_INBOUND_API_KEY
if not expected_key or not secrets.compare_digest(api_key, expected_key):
logger.warning(
"CalDAV scheduling callback request with invalid API key. "
"Expected: %s..., Got: %s...",
expected_key[:10] if expected_key else "None",
api_key[:10] if api_key else "None",
)
logger.warning("CalDAV scheduling callback request with invalid API key.")
return HttpResponse(status=401)
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
# Extract and validate sender/recipient emails
sender = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Sender", "")
).strip()
recipient = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Recipient", "")
).strip()
method = request.headers.get("X-CalDAV-Method", "").upper()
# Validate required fields
@@ -275,6 +362,22 @@ class CalDAVSchedulingCallbackView(View):
content_type="text/plain",
)
# Validate email format
try:
validate_email(sender)
validate_email(recipient)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
logger.warning(
"CalDAV scheduling callback with invalid email: sender=%s, recipient=%s",
sender,
recipient,
)
return HttpResponse(
status=400,
content="Invalid sender or recipient email",
content_type="text/plain",
)
# Get iCalendar data from request body
icalendar_data = (
request.body.decode("utf-8", errors="replace") if request.body else ""
@@ -332,6 +435,6 @@ class CalDAVSchedulingCallbackView(View):
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,
content=f"Internal error: {str(e)}",
content="Internal server error",
content_type="text/plain",
)

View File

@@ -0,0 +1,148 @@
"""Channel API for managing integration tokens."""
import logging
import secrets
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from core import models
from core.api import serializers
from core.services.caldav_service import verify_caldav_access
logger = logging.getLogger(__name__)
class ChannelViewSet(viewsets.GenericViewSet):
"""CRUD for integration channels.
Endpoints:
GET /api/v1.0/channels/ — list (filterable by ?type=)
POST /api/v1.0/channels/ — create (returns token once)
GET /api/v1.0/channels/{id}/ — retrieve
DELETE /api/v1.0/channels/{id}/ — delete
POST /api/v1.0/channels/{id}/regenerate-token/ — regenerate token
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.ChannelSerializer
def get_queryset(self):
return models.Channel.objects.filter(user=self.request.user).select_related(
"organization", "user"
)
def list(self, request):
"""List channels created by the current user, optionally filtered by type."""
queryset = self.get_queryset()
channel_type = request.query_params.get("type")
if channel_type:
queryset = queryset.filter(type=channel_type)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def create(self, request):
"""Create a new channel and return the token (once).
For type="ical-feed", returns an existing channel if one already
exists for the same user + caldav_path (get-or-create semantics).
"""
create_serializer = serializers.ChannelCreateSerializer(data=request.data)
create_serializer.is_valid(raise_exception=True)
data = create_serializer.validated_data
caldav_path = data.get("caldav_path", "")
channel_type = data.get("type", "caldav")
calendar_name = data.get("calendar_name", "")
# If a caldav_path is specified, verify the user has access
if caldav_path and not verify_caldav_access(request.user, caldav_path):
return Response(
{"detail": "You don't have access to this calendar."},
status=status.HTTP_403_FORBIDDEN,
)
# For ical-feed, return existing channel if one exists
if channel_type == "ical-feed" and caldav_path:
existing = (
self.get_queryset()
.filter(caldav_path=caldav_path, type="ical-feed")
.first()
)
if existing:
# Update calendar_name if provided and different
current_name = existing.settings.get("calendar_name", "")
if calendar_name and current_name != calendar_name:
existing.settings["calendar_name"] = calendar_name
existing.name = calendar_name
existing.save(update_fields=["settings", "name", "updated_at"])
serializer = self.get_serializer(existing, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
token = secrets.token_urlsafe(16)
channel_settings = {"role": data.get("role", models.Channel.ROLE_READER)}
if calendar_name:
channel_settings["calendar_name"] = calendar_name
channel = models.Channel(
name=data.get("name") or calendar_name or caldav_path or "Channel",
type=channel_type,
user=request.user,
caldav_path=caldav_path,
organization=request.user.organization,
settings=channel_settings,
encrypted_settings={"token": token},
)
channel.save()
# Attach plaintext token for the response (not persisted)
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def retrieve(self, request, pk=None):
"""Retrieve a channel (without token)."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(channel, context={"request": request})
return Response(serializer.data)
def destroy(self, request, pk=None):
"""Delete a channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
channel.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="regenerate-token")
def regenerate_token(self, request, pk=None):
"""Regenerate the token for an existing channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
token = secrets.token_urlsafe(16)
channel.encrypted_settings = {
**channel.encrypted_settings,
"token": token,
}
channel.save(update_fields=["encrypted_settings", "updated_at"])
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data)
def _get_owned_channel(self, pk):
"""Get a channel owned by the current user, or None."""
try:
return self.get_queryset().get(pk=pk)
except models.Channel.DoesNotExist:
return None

View File

@@ -2,19 +2,24 @@
import logging
from django.core.cache import cache
from django.http import Http404, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.text import slugify
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.models import CalendarSubscriptionToken
from core.models import Channel, urlsafe_to_uuid
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
ICAL_RATE_LIMIT = 5 # requests per minute per channel
ICAL_RATE_WINDOW = 60 # seconds
@method_decorator(csrf_exempt, name="dispatch")
class ICalExportView(View):
@@ -22,40 +27,49 @@ class ICalExportView(View):
Public endpoint for iCal calendar exports.
This view serves calendar data in iCal format without requiring authentication.
The token in the URL path acts as the authentication mechanism.
The channel_id in the URL is used for lookup, and the token for authentication.
URL format: /ical/<uuid:token>.ics
URL format: /ical/<short_id>/<token>/<slug>.ics
The view proxies the request to SabreDAV's ICSExportPlugin, which generates
RFC 5545 compliant iCal data.
Looks up a Channel by base64url-encoded ID, verifies the token, then
proxies the request to SabreDAV's ICSExportPlugin.
"""
def get(self, request, token):
def get(self, request, short_id, token):
"""Handle GET requests for iCal export."""
# Lookup token
subscription = (
CalendarSubscriptionToken.objects.filter(token=token, is_active=True)
.select_related("owner")
.first()
)
try:
channel_id = urlsafe_to_uuid(short_id)
channel = Channel.objects.get(pk=channel_id, is_active=True)
except (ValueError, Channel.DoesNotExist) as exc:
raise Http404("Calendar not found") from exc
if not subscription:
logger.warning("Invalid or inactive subscription token: %s", token)
if channel.type != "ical-feed":
raise Http404("Calendar not found")
# Update last_accessed_at atomically to avoid race conditions
# when multiple calendar clients poll simultaneously
CalendarSubscriptionToken.objects.filter(token=token, is_active=True).update(
last_accessed_at=timezone.now()
)
if not channel.verify_token(token):
raise Http404("Calendar not found")
if not channel.user:
logger.warning("ical-feed channel %s has no user", channel.id)
raise Http404("Calendar not found")
# Rate limit: 5 requests per minute per channel
rate_key = f"ical_rate:{channel_id}"
hits = cache.get(rate_key, 0)
if hits >= ICAL_RATE_LIMIT:
return HttpResponse(status=429, content="Too many requests")
cache.set(rate_key, hits + 1, ICAL_RATE_WINDOW)
# Update last_used_at
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
# Proxy to SabreDAV
http = CalDAVHTTPClient()
try:
caldav_path = subscription.caldav_path.lstrip("/")
caldav_path = channel.caldav_path.lstrip("/")
response = http.request(
"GET",
subscription.owner.email,
channel.user,
caldav_path,
query="export",
)
@@ -88,15 +102,12 @@ class ICalExportView(View):
status=200,
content_type="text/calendar; charset=utf-8",
)
# Set filename for download (use calendar_name or fallback to "calendar")
display_name = subscription.calendar_name or "calendar"
safe_name = display_name.replace('"', '\\"')
calendar_name = channel.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
django_response["Content-Disposition"] = (
f'attachment; filename="{safe_name}.ics"'
f'attachment; filename="{filename}.ics"'
)
# Prevent caching of potentially sensitive data
django_response["Cache-Control"] = "no-store, private"
# Prevent token leakage via referrer
django_response["Referrer-Policy"] = "no-referrer"
return django_response

View File

@@ -1,16 +1,28 @@
"""RSVP view for handling invitation responses from email links."""
"""RSVP view for handling invitation responses from email links.
GET /rsvp/?token=...&action=accepted -> renders a confirmation page that
auto-submits via JavaScript (no extra click for the user).
POST /api/v1.0/rsvp/ -> processes the RSVP and returns a
result page. Link previewers / prefetchers only issue GET, so the
state-changing work is safely behind POST.
"""
import logging
import re
from datetime import timezone as dt_timezone
from django.core.signing import BadSignature, Signer
from django.conf import settings
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.shortcuts import render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView
from core.models import User
from core.services.caldav_service import CalDAVHTTPClient
from core.services.translation_service import TranslationService
@@ -35,7 +47,7 @@ PARTSTAT_VALUES = {
}
def _render_error(request, message, lang="fr"):
def _render_error(request, message, lang="en"):
"""Render the RSVP error page."""
t = TranslationService.t
return render(
@@ -85,69 +97,169 @@ def _is_event_past(icalendar_data):
return False
@method_decorator(csrf_exempt, name="dispatch")
class RSVPView(View):
"""Handle RSVP responses from invitation email links."""
def _validate_token(token, max_age=None):
"""Unsign and validate an RSVP token.
def get(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Process an RSVP response."""
token = request.GET.get("token", "")
action = request.GET.get("action", "")
lang = TranslationService.resolve_language(request=request)
t = TranslationService.t
# Validate action
if action not in PARTSTAT_VALUES:
return _render_error(request, t("rsvp.error.invalidAction", lang), lang)
# Unsign token — tokens don't have a built-in expiry,
# but RSVPs are rejected once the event has ended (_is_event_past).
signer = Signer(salt="rsvp")
Returns (payload, error_key). On success error_key is None.
"""
ts_signer = TimestampSigner(salt="rsvp")
try:
payload = signer.unsign_object(token)
payload = ts_signer.unsign_object(token, max_age=max_age)
except SignatureExpired:
return None, "token_expired"
except BadSignature:
return _render_error(request, t("rsvp.error.invalidToken", lang), lang)
return None, "invalid_token"
uid = payload.get("uid")
recipient_email = payload.get("email")
organizer_email = payload.get("organizer", "")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(
r"^mailto:", "", payload.get("organizer", ""), flags=re.IGNORECASE
)
organizer_email = re.sub(r"^mailto:", "", organizer_email, flags=re.IGNORECASE)
if not uid or not recipient_email or not organizer_email:
return _render_error(request, t("rsvp.error.invalidPayload", lang), lang)
return None, "invalid_payload"
payload["organizer"] = organizer_email
return payload, None
_TOKEN_ERROR_KEYS = {
"token_expired": "rsvp.error.tokenExpired",
"invalid_token": "rsvp.error.invalidToken",
"invalid_payload": "rsvp.error.invalidPayload",
}
def _validate_and_render_error(request, token, action, lang):
"""Validate action + token; return (payload, error_response).
On success error_response is None.
"""
t = TranslationService.t
if action not in PARTSTAT_VALUES:
return None, _render_error(request, t("rsvp.error.invalidAction", lang), lang)
payload, error = _validate_token(
token, max_age=settings.RSVP_TOKEN_MAX_AGE_RECURRING
)
if error:
return None, _render_error(request, t(_TOKEN_ERROR_KEYS[error], lang), lang)
return payload, None
@method_decorator(csrf_exempt, name="dispatch")
class RSVPConfirmView(View):
"""GET handler: render auto-submitting confirmation page.
This page is safe for link previewers / prefetchers because it
doesn't change any state — only the POST endpoint does.
"""
def get(self, request):
"""Render a page that auto-submits the RSVP via POST."""
token = request.GET.get("token", "")
action = request.GET.get("action", "")
lang = TranslationService.resolve_language(request=request)
_, error_response = _validate_and_render_error(request, token, action, lang)
if error_response:
return error_response
# Render auto-submit page
label = TranslationService.t(f"rsvp.{action}", lang)
return render(
request,
"rsvp/confirm.html",
{
"page_title": label,
"token": token,
"action": action,
"lang": lang,
"heading": label,
"status_icon": PARTSTAT_ICONS[action],
"header_color": PARTSTAT_COLORS[action],
"submit_label": label,
"post_url": f"/api/{settings.API_VERSION}/rsvp/",
},
)
class RSVPThrottle(AnonRateThrottle):
"""Throttle RSVP POST requests: 30/min per IP."""
rate = "30/minute"
def _process_rsvp(request, payload, action, lang):
"""Execute the RSVP: find event, update PARTSTAT, PUT back.
Returns an error response on failure, or the updated calendar data
string on success.
"""
t = TranslationService.t
http = CalDAVHTTPClient()
# Find the event in the organizer's CalDAV calendars
calendar_data, href = http.find_event_by_uid(organizer_email, uid)
try:
organizer = User.objects.get(email=payload["organizer"])
except User.DoesNotExist:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
calendar_data, href, etag = http.find_event_by_uid(organizer, payload["uid"])
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
# Check if the event is already over
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
# Update the attendee's PARTSTAT
partstat = PARTSTAT_VALUES[action]
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, recipient_email, partstat
calendar_data, payload["email"], PARTSTAT_VALUES[action]
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
# PUT the updated event back to CalDAV
success = http.put_event(organizer_email, href, updated_data)
if not success:
if not http.put_event(organizer, href, updated_data, etag=etag):
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
# Extract event summary for display
return calendar_data
class RSVPProcessView(APIView):
"""POST handler: actually process the RSVP.
Uses DRF's AnonRateThrottle for rate limiting. No authentication
required — the signed token acts as authorization.
"""
authentication_classes = []
permission_classes = []
throttle_classes = [RSVPThrottle]
def post(self, request):
"""Process the RSVP response."""
token = request.data.get("token", "")
action = request.data.get("action", "")
lang = TranslationService.resolve_language(request=request)
t = TranslationService.t
payload, error_response = _validate_and_render_error(
request, token, action, lang
)
if error_response:
return error_response
result = _process_rsvp(request, payload, action, lang)
# result is either an error HttpResponse or calendar data string
if not isinstance(result, str):
return result
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ICalendarParser,
)
summary = ICalendarParser.extract_property(calendar_data, "SUMMARY") or ""
summary = ICalendarParser.extract_property(result, "SUMMARY") or ""
label = t(f"rsvp.{action}", lang)
return render(

View File

@@ -0,0 +1,104 @@
"""API endpoint for polling async task status."""
import logging
import uuid
import dramatiq
from dramatiq.results import ResultFailure, ResultMissing
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from core.task_utils import get_task_progress, get_task_tracking
logger = logging.getLogger(__name__)
class TaskDetailView(APIView):
"""View to retrieve the status of an async task."""
permission_classes = [IsAuthenticated]
def get(self, request, task_id): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Get the status of a task."""
try:
uuid.UUID(task_id)
except ValueError:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
tracking = get_task_tracking(task_id)
if tracking is None:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
if str(request.user.id) != tracking["owner"]:
return Response(
{"status": "FAILURE", "result": None, "error": "Forbidden"},
status=403,
)
# Try to fetch the result from dramatiq's result backend
message = dramatiq.Message(
queue_name=tracking["queue_name"],
actor_name=tracking["actor_name"],
args=(),
kwargs={},
options={},
message_id=task_id,
)
try:
result_data = message.get_result(block=False)
except ResultMissing:
result_data = None
except ResultFailure as exc:
logger.error("Task %s failed: %s", task_id, exc)
return Response(
{
"status": "FAILURE",
"result": None,
"error": "Task failed",
}
)
if result_data is not None:
resp = {
"status": "SUCCESS",
"result": result_data,
"error": None,
}
# Unpack {status, result, error} convention
if (
isinstance(result_data, dict)
and {"status", "result", "error"} <= result_data.keys()
):
resp["status"] = result_data["status"]
resp["result"] = result_data["result"]
resp["error"] = result_data["error"]
return Response(resp)
# Check for progress data
progress_data = get_task_progress(task_id)
if progress_data:
return Response(
{
"status": "PROGRESS",
"result": None,
"error": None,
"progress": progress_data.get("progress"),
"message": progress_data.get("metadata", {}).get("message"),
"timestamp": progress_data.get("timestamp"),
}
)
# Default to pending
return Response(
{
"status": "PENDING",
"result": None,
"error": None,
}
)

View File

@@ -1,7 +1,6 @@
"""Calendars Core application"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
@@ -9,7 +8,7 @@ class CoreConfig(AppConfig):
name = "core"
app_label = "core"
verbose_name = _("calendars core application")
verbose_name = "calendars core application"
def ready(self):
"""

View File

@@ -10,11 +10,51 @@ from lasuite.oidc_login.backends import (
)
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import DuplicateEmailError
from core.models import DuplicateEmailError, Organization
logger = logging.getLogger(__name__)
def _resolve_org_external_id(claims, email=None):
"""Extract the organization external_id from OIDC claims or email domain."""
claim_key = settings.OIDC_USERINFO_ORGANIZATION_CLAIM
if claim_key:
return claims.get(claim_key)
email = email or claims.get("email")
return email.split("@")[-1] if email and "@" in email else None
def resolve_organization(user, claims, entitlements=None):
"""Resolve and assign the user's organization.
The org identifier (external_id) comes from the OIDC claim configured via
OIDC_USERINFO_ORGANIZATION_CLAIM, or falls back to the email domain.
The org name comes from the entitlements response.
"""
entitlements = entitlements or {}
external_id = _resolve_org_external_id(claims, email=user.email)
if not external_id:
logger.error(
"Cannot resolve organization for user %s: no org claim or email domain",
user.email,
)
return
org_name = entitlements.get("organization_name", "") or external_id
org, created = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": org_name},
)
if not created and org_name and org.name != org_name:
org.name = org_name
org.save(update_fields=["name"])
if user.organization_id != org.id:
user.organization = org
user.save(update_fields=["organization"])
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -23,39 +63,46 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Args:
user_info (dict): The user information dictionary.
Returns:
dict: A dictionary of extra claims.
"""
# We need to add the claims that we want to store so that they are
# available in the post_get_or_create_user method.
"""Return extra claims from user_info."""
claims_to_store = {
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
}
return {
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
"claims": claims_to_store,
}
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
def create_user(self, claims):
"""Create a new user, resolving their organization first.
Organization is NOT NULL, so we must resolve it before the initial save.
"""
external_id = _resolve_org_external_id(claims)
if not external_id:
raise SuspiciousOperation(
"Cannot create user without an organization "
"(no org claim and no email domain)"
)
org, _ = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": external_id},
)
claims["organization"] = org
return super().create_user(claims)
def post_get_or_create_user(self, user, claims, is_new_user):
"""Warm the entitlements cache on login (force_refresh)."""
"""Warm the entitlements cache and resolve organization on login."""
entitlements = {}
try:
get_user_entitlements(
entitlements = get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
@@ -66,3 +113,5 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Entitlements unavailable for %s during login",
user.email,
)
resolve_organization(user, claims, entitlements)

View File

@@ -17,7 +17,7 @@ def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=Fa
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {"can_access": bool, "can_admin": bool, ...}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached

View File

@@ -20,7 +20,11 @@ class EntitlementsBackend(ABC):
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {
"can_access": bool,
"can_admin": bool,
"organization_name": str, # optional, extracted from response
}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.

View File

@@ -114,7 +114,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend):
entitlements = data.get("entitlements", {})
result = {
"can_access": entitlements.get("can_access", False),
"can_admin": entitlements.get("can_admin", False),
}
# Organization name from DeployCenter response (if present)
org = data.get("organization") or {}
org_name = org.get("name", "")
if org_name:
result["organization_name"] = org_name
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result

View File

@@ -4,9 +4,9 @@ from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
"""Local backend that always grants access and admin."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_access": True}
return {"can_access": True, "can_admin": True}

View File

@@ -1,12 +1,3 @@
"""
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

@@ -4,7 +4,7 @@ from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import AccessPermission, IsSelf
from core.api.permissions import IsSelf
from core.api.viewsets import UserViewSet
from core.external_api.permissions import ResourceServerClientPermission

View File

@@ -2,6 +2,8 @@
Core application factories
"""
import secrets
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -13,6 +15,16 @@ from core import models
fake = Faker()
class OrganizationFactory(factory.django.DjangoModelFactory):
"""A factory to create organizations for testing purposes."""
class Meta:
model = models.Organization
name = factory.Faker("company")
external_id = factory.Sequence(lambda n: f"org-{n}")
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
@@ -23,20 +35,32 @@ class UserFactory(factory.django.DjangoModelFactory):
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")
organization = factory.SubFactory(OrganizationFactory)
class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory):
"""A factory to create calendar subscription tokens for testing purposes."""
class ChannelFactory(factory.django.DjangoModelFactory):
"""A factory to create channels for testing purposes."""
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
owner = factory.SubFactory(UserFactory)
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}/"
name = factory.Faker("sentence", nb_words=3)
user = factory.SubFactory(UserFactory)
settings = factory.LazyFunction(lambda: {"role": "reader"})
encrypted_settings = factory.LazyFunction(
lambda: {"token": secrets.token_urlsafe(16)}
)
class ICalFeedChannelFactory(ChannelFactory):
"""A factory to create ical-feed channels."""
type = "ical-feed"
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/users/{obj.user.email}/{fake.uuid4()}/"
)
settings = factory.LazyAttribute(
lambda obj: {"role": "reader", "calendar_name": fake.sentence(nb_words=3)}
)
calendar_name = factory.Faker("sentence", nb_words=3)
is_active = True

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.9 on 2026-01-11 00:45
# Generated by Django 5.2.9 on 2026-03-08 21:40
import core.models
import django.core.validators
import django.db.models.deletion
import encrypted_fields.fields
import timezone_field.fields
import uuid
from django.conf import settings
@@ -18,6 +19,21 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(blank=True, default='', max_length=200)),
('external_id', models.CharField(db_index=True, help_text='Organization identifier from OIDC claim or email domain.', max_length=128, unique=True)),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'db_table': 'calendars_organization',
},
),
migrations.CreateModel(
name='User',
fields=[
@@ -29,8 +45,7 @@ class Migration(migrations.Migration):
('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')),
('email', models.EmailField(blank=True, db_index=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(blank=True, choices=[('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'), ('nl-nl', 'Dutch')], default=None, help_text='The language in which the user wants to see the interface.', max_length=10, null=True, 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)),
@@ -40,6 +55,7 @@ class Migration(migrations.Migration):
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
('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')),
('organization', models.ForeignKey(help_text='The organization this user belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='members', to='core.organization')),
],
options={
'verbose_name': 'user',
@@ -51,40 +67,26 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='Calendar',
name='Channel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('color', models.CharField(default='#3174ad', max_length=7)),
('description', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('is_visible', models.BooleanField(default=True)),
('caldav_path', models.CharField(max_length=512, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
('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')),
('name', models.CharField(help_text='Human-readable name for this channel.', max_length=255)),
('type', models.CharField(default='caldav', help_text='Type of channel.', max_length=255)),
('caldav_path', models.CharField(blank=True, default='', help_text='CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).', max_length=512)),
('is_active', models.BooleanField(default=True)),
('settings', models.JSONField(blank=True, default=dict, help_text='Channel-specific configuration settings (e.g. role).', verbose_name='settings')),
('encrypted_settings', encrypted_fields.fields.EncryptedJSONField(blank=True, default=dict, help_text='Encrypted channel settings (e.g. token).', verbose_name='encrypted settings')),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, help_text='User who created this channel (used for permissions and auditing).', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to='core.organization')),
],
options={
'ordering': ['-is_default', 'name'],
'verbose_name': 'channel',
'verbose_name_plural': 'channels',
'db_table': 'calendars_channel',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CalendarShare',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('permission', models.CharField(choices=[('read', 'Read only'), ('write', 'Read and write')], default='read', max_length=10)),
('is_visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.calendar')),
('shared_with', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_calendars', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='calendar',
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('owner',), name='unique_default_calendar_per_user'),
),
migrations.AlterUniqueTogether(
name='calendarshare',
unique_together={('calendar', 'shared_with')},
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 14:21
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CalendarSubscriptionToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('caldav_path', models.CharField(help_text='CalDAV path of the calendar', max_length=512)),
('calendar_name', models.CharField(blank=True, default='', help_text='Display name of the calendar', max_length=255)),
('token', models.UUIDField(db_index=True, default=uuid.uuid4, help_text='Secret token used in the subscription URL', unique=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this subscription token is active')),
('last_accessed_at', models.DateTimeField(blank=True, help_text='Last time this subscription URL was accessed', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscription_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'calendar subscription token',
'verbose_name_plural': 'calendar subscription tokens',
'constraints': [models.UniqueConstraint(fields=('owner', 'caldav_path'), name='unique_token_per_owner_calendar')],
},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_calendarsubscriptiontoken'),
]
operations = [
migrations.AddIndex(
model_name='calendarsubscriptiontoken',
index=models.Index(fields=['token', 'is_active'], name='token_active_idx'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.9 on 2026-02-09 18:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0003_calendarsubscriptiontoken_token_active_idx'),
]
operations = [
migrations.DeleteModel(
name='CalendarShare',
),
migrations.DeleteModel(
name='Calendar',
),
]

View File

@@ -2,6 +2,8 @@
Declare and configure the models for the calendars core application
"""
import base64
import secrets
import uuid
from logging import getLogger
@@ -10,47 +12,13 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from timezone_field import TimeZoneField
logger = getLogger(__name__)
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."""
@@ -70,21 +38,21 @@ class BaseModel(models.Model):
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
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"),
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"),
verbose_name="updated on",
help_text="date and time at which a record was last updated",
auto_now=True,
editable=False,
)
@@ -98,6 +66,46 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class Organization(BaseModel):
"""Organization model, populated from OIDC claims and entitlements.
Every user belongs to exactly one organization, determined by their
email domain (default) or a configurable OIDC claim. Orgs are
created automatically on first login.
"""
name = models.CharField(max_length=200, blank=True, default="")
external_id = models.CharField(
max_length=128,
unique=True,
db_index=True,
help_text="Organization identifier from OIDC claim or email domain.",
)
class Meta:
db_table = "calendars_organization"
verbose_name = "organization"
verbose_name_plural = "organizations"
def __str__(self):
return self.name or self.external_id
def delete(self, *args, **kwargs):
"""Delete org after cleaning up members' CalDAV data.
Must run before super().delete() because the User FK uses
on_delete=PROTECT, which blocks deletion while members exist.
The pre_delete signal would never fire with PROTECT, so the
cleanup logic lives here instead.
"""
from core.services.caldav_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
cleanup_organization_caldav_data,
)
cleanup_organization_caldav_data(self)
super().delete(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
@@ -119,10 +127,8 @@ class UserManager(auth_models.UserManager):
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
@@ -132,16 +138,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
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."
"sub",
help_text=(
"Required. 255 characters or fewer."
" Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -150,23 +157,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
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)
full_name = models.CharField("full name", max_length=100, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
email = models.EmailField(
"identity email address", blank=True, null=True, db_index=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
"admin email address", unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
verbose_name="language",
help_text="The language in which the user wants to see the interface.",
null=True,
blank=True,
)
@@ -174,31 +182,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
help_text="The timezone in which the user wants to see times.",
)
is_device = models.BooleanField(
_("device"),
"device",
default=False,
help_text=_("Whether the user is a device or a real user."),
help_text="Whether the user is a device or a real user.",
)
is_staff = models.BooleanField(
_("staff status"),
"staff status",
default=False,
help_text=_("Whether the user can log into this admin site."),
help_text="Whether the user can log into this admin site.",
)
is_active = models.BooleanField(
_("active"),
"active",
default=True,
help_text=_(
help_text=(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="members",
help_text="The organization this user belongs to.",
)
claims = models.JSONField(
blank=True,
default=dict,
help_text=_("Claims from the OIDC token."),
help_text="Claims from the OIDC token.",
)
objects = UserManager()
@@ -208,8 +223,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "calendars_user"
verbose_name = _("user")
verbose_name_plural = _("users")
verbose_name = "user"
verbose_name_plural = "users"
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -220,152 +235,123 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@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 []
def uuid_to_urlsafe(u):
"""Encode a UUID as unpadded base64url (22 chars)."""
return base64.urlsafe_b64encode(u.bytes).rstrip(b"=").decode()
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
def urlsafe_to_uuid(s):
"""Decode an unpadded base64url string back to a UUID."""
padded = s + "=" * (-len(s) % 4)
return uuid.UUID(bytes=base64.urlsafe_b64decode(padded))
class Channel(BaseModel):
"""Integration channel for external service access to calendars.
Follows the same pattern as the Messages Channel model. Allows external
services (e.g. Messages) to access CalDAV on behalf of a user via a
bearer token.
Configuration is split between ``settings`` (public, non-sensitive) and
``encrypted_settings`` (sensitive data like tokens). The ``role`` for
CalDAV access control lives in ``settings``.
For iCal feeds, the URL contains the base64url-encoded channel ID (for
lookup) and a base64url token (for authentication):
``/ical/<short_id>/<token>/<slug>.ics``.
"""
ROLE_READER = "reader"
ROLE_EDITOR = "editor"
ROLE_ADMIN = "admin"
VALID_ROLES = {ROLE_READER, ROLE_EDITOR, ROLE_ADMIN}
name = models.CharField(
max_length=255,
help_text="Human-readable name for this channel.",
)
type = models.CharField(
max_length=255,
help_text="Type of channel.",
default="caldav",
)
user = models.ForeignKey(
User,
"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
related_name="channels",
help_text="User who created this channel (used for permissions and auditing).",
)
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 CalendarSubscriptionToken(models.Model):
"""
Stores subscription tokens for iCal export.
Each calendar can have one token that allows unauthenticated read-only access
via a public URL for use in external calendar applications.
This model is standalone and stores the CalDAV path directly,
without requiring a foreign key to the Calendar model.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Owner of the calendar (for permission verification)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="subscription_tokens",
null=True,
blank=True,
related_name="channels",
)
# CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/)
caldav_path = models.CharField(
max_length=512,
help_text=_("CalDAV path of the calendar"),
)
# Calendar display name (for UI display)
calendar_name = models.CharField(
max_length=255,
blank=True,
default="",
help_text=_("Display name of the calendar"),
help_text="CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).",
)
token = models.UUIDField(
unique=True,
db_index=True,
default=uuid.uuid4,
help_text=_("Secret token used in the subscription URL"),
)
is_active = models.BooleanField(
default=True,
help_text=_("Whether this subscription token is active"),
)
last_accessed_at = models.DateTimeField(
null=True,
is_active = models.BooleanField(default=True)
settings = models.JSONField(
"settings",
default=dict,
blank=True,
help_text=_("Last time this subscription URL was accessed"),
help_text="Channel-specific configuration settings (e.g. role).",
)
created_at = models.DateTimeField(auto_now_add=True)
encrypted_settings = EncryptedJSONField(
"encrypted settings",
default=dict,
blank=True,
help_text="Encrypted channel settings (e.g. token).",
)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("calendar subscription token")
verbose_name_plural = _("calendar subscription tokens")
constraints = [
models.UniqueConstraint(
fields=["owner", "caldav_path"],
name="unique_token_per_owner_calendar",
)
]
indexes = [
# Composite index for the public iCal endpoint query:
# CalendarSubscriptionToken.objects.filter(token=..., is_active=True)
models.Index(fields=["token", "is_active"], name="token_active_idx"),
]
db_table = "calendars_channel"
verbose_name = "channel"
verbose_name_plural = "channels"
ordering = ["-created_at"]
def __str__(self):
return f"Subscription token for {self.calendar_name or self.caldav_path}"
return self.name
@property
def role(self):
"""Get the role from settings, defaulting to reader."""
return self.settings.get("role", self.ROLE_READER)
@role.setter
def role(self, value):
"""Set the role in settings."""
self.settings["role"] = value
def clean(self):
"""Validate that at least one scope is set."""
from django.core.exceptions import ValidationError # noqa: PLC0415, I001 # pylint: disable=C0415
if not self.organization and not self.user and not self.caldav_path:
raise ValidationError(
"At least one scope must be set: organization, user, or caldav_path."
)
def verify_token(self, token):
"""Check that *token* matches the stored encrypted token."""
stored = self.encrypted_settings.get("token", "")
if not token or not stored:
return False
return secrets.compare_digest(token, stored)

View File

@@ -1,8 +1,10 @@
"""Services for CalDAV integration."""
import json
import logging
import re
from datetime import date, datetime, timedelta
from datetime import timezone as dt_timezone
from typing import Optional
from urllib.parse import unquote
from uuid import uuid4
@@ -30,7 +32,7 @@ class CalDAVHTTPClient:
and HTTP requests. All higher-level CalDAV consumers delegate to this.
"""
BASE_URI_PATH = "/api/v1.0/caldav"
BASE_URI_PATH = "/caldav"
DEFAULT_TIMEOUT = 30
def __init__(self):
@@ -45,11 +47,21 @@ class CalDAVHTTPClient:
return key
@classmethod
def build_base_headers(cls, email: str) -> dict:
"""Build authentication headers for CalDAV requests."""
def build_base_headers(cls, user) -> dict:
"""Build authentication headers for CalDAV requests.
Args:
user: Object with .email and .organization_id attributes.
Raises:
ValueError: If user.email is not set.
"""
if not user.email:
raise ValueError("User has no email address")
return {
"X-Api-Key": cls.get_api_key(),
"X-Forwarded-User": email,
"X-Forwarded-User": user.email,
"X-CalDAV-Organization": str(user.organization_id),
}
def build_url(self, path: str, query: str = "") -> str:
@@ -70,7 +82,7 @@ class CalDAVHTTPClient:
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
method: str,
email: str,
user,
path: str,
*,
query: str = "",
@@ -80,7 +92,7 @@ class CalDAVHTTPClient:
content_type: str | None = None,
) -> requests.Response:
"""Make an authenticated HTTP request to the CalDAV server."""
headers = self.build_base_headers(email)
headers = self.build_base_headers(user)
if content_type:
headers["Content-Type"] = content_type
if extra_headers:
@@ -95,9 +107,13 @@ class CalDAVHTTPClient:
timeout=timeout or self.DEFAULT_TIMEOUT,
)
def get_dav_client(self, email: str) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user email."""
headers = self.build_base_headers(email)
def get_dav_client(self, user) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user.
Args:
user: Object with .email and .organization_id attributes.
"""
headers = self.build_base_headers(user)
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
return DAVClient(
url=caldav_url,
@@ -107,38 +123,58 @@ class CalDAVHTTPClient:
headers=headers,
)
def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]:
def find_event_by_uid(
self, user, uid: str
) -> tuple[str | None, str | None, str | None]:
"""Find an event by UID across all of the user's calendars.
Returns (ical_data, href) or (None, None).
Returns (ical_data, href, etag) or (None, None, None).
"""
client = self.get_dav_client(email)
client = self.get_dav_client(user)
try:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.object_by_uid(uid)
return event.data, str(event.url.path)
etag = getattr(event, "props", {}).get("{DAV:}getetag") or getattr(
event, "etag", None
)
return event.data, str(event.url.path), etag
except caldav_lib.error.NotFoundError:
continue
logger.warning("Event UID %s not found in user %s calendars", uid, email)
return None, None
logger.warning(
"Event UID %s not found in user %s calendars", uid, user.email
)
return None, None, None
except Exception: # pylint: disable=broad-exception-caught
logger.exception("CalDAV error looking up event %s", uid)
return None, None
return None, None, None
def put_event(self, email: str, href: str, ical_data: str) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
def put_event(
self, user, href: str, ical_data: str, etag: str | None = None
) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success.
If *etag* is provided, the request includes an If-Match header to
prevent lost updates from concurrent modifications.
"""
try:
extra_headers = {}
if etag:
extra_headers["If-Match"] = etag
response = self.request(
"PUT",
email,
user,
href,
data=ical_data.encode("utf-8"),
content_type="text/calendar; charset=utf-8",
extra_headers=extra_headers or None,
)
if response.status_code in (200, 201, 204):
return True
if response.status_code == 412:
logger.warning("CalDAV PUT conflict (ETag mismatch) for %s", href)
return False
logger.error(
"CalDAV PUT failed: %s %s",
response.status_code,
@@ -160,10 +196,10 @@ class CalDAVHTTPClient:
cal = icalendar.Calendar.from_ical(ical_data)
updated = False
target = f"mailto:{email.lower()}"
for component in cal.walk("VEVENT"):
for _name, attendee in component.property_items("ATTENDEE"):
attendee_val = str(attendee).lower()
if email.lower() in attendee_val:
if str(attendee).lower().strip() == target:
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
updated = True
@@ -182,14 +218,19 @@ class CalDAVClient:
self._http = CalDAVHTTPClient()
self.base_url = self._http.base_url
def _calendar_url(self, calendar_path: str) -> str:
"""Build a full URL for a calendar path, including the BASE_URI_PATH."""
return f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}{calendar_path}"
def _get_client(self, user) -> DAVClient:
"""
Get a CalDAV client for the given user.
The CalDAV server requires API key authentication via Authorization header
and X-Forwarded-User header for user identification.
Includes X-CalDAV-Organization when the user has an org.
"""
return self._http.get_dav_client(user.email)
return self._http.get_dav_client(user)
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
"""
@@ -197,7 +238,7 @@ class CalDAVClient:
Returns dict with name, color, description or None if not found.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
try:
calendar = client.calendar(url=calendar_url)
@@ -227,37 +268,53 @@ class CalDAVClient:
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
return None
def create_calendar(
self, user, calendar_name: str, calendar_id: str, color: str = ""
def create_calendar( # pylint: disable=too-many-arguments
self,
user,
calendar_name: str = "",
calendar_id: str = "",
color: str = "",
*,
name: str = "",
) -> str:
"""
Create a new calendar in CalDAV server for the given user.
Returns the CalDAV server path for the calendar.
"""
calendar_name = calendar_name or name
if not calendar_id:
calendar_id = str(uuid4())
if not color:
color = settings.DEFAULT_CALENDAR_COLOR
client = self._get_client(user)
principal = client.principal()
try:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# Pass cal_id so the library uses our UUID for the path.
calendar = principal.make_calendar(name=calendar_name, cal_id=calendar_id)
# Set calendar color if provided
if color:
calendar.set_properties([CalendarColor(color)])
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
# Extract CalDAV-relative path from the calendar URL
calendar_url = str(calendar.url)
# Extract path from full URL
if calendar_url.startswith(self.base_url):
path = calendar_url[len(self.base_url) :]
else:
# Fallback: construct path manually based on standard CalDAV structure
# CalDAV servers typically create calendars under /calendars/{principal}/
path = f"/calendars/{user.email}/{calendar_id}/"
path = f"/calendars/users/{user.email}/{calendar_id}/"
base_prefix = CalDAVHTTPClient.BASE_URI_PATH
if path.startswith(base_prefix):
path = path[len(base_prefix) :]
if not path.startswith("/"):
path = "/" + path
path = unquote(path)
logger.info(
"Created calendar in CalDAV server: %s at %s", calendar_name, path
"Created calendar in CalDAV server: %s at %s",
calendar_name,
path,
)
return path
except Exception as e:
@@ -285,7 +342,7 @@ class CalDAVClient:
client = self._get_client(user)
# Get calendar by URL
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -323,7 +380,7 @@ class CalDAVClient:
Returns the event UID.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -342,7 +399,7 @@ class CalDAVClient:
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
# Extract event data
@@ -385,27 +442,11 @@ class CalDAVClient:
"""Update an existing event in CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
target_event = calendar.object_by_uid(event_uid)
# Update event properties
dtstart = event_data.get("start")
@@ -432,6 +473,8 @@ class CalDAVClient:
target_event.save()
logger.info("Updated event in CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to update event in CalDAV server: %s", str(e))
raise
@@ -440,36 +483,44 @@ class CalDAVClient:
"""Delete an event from CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
# Delete the event
target_event = calendar.object_by_uid(event_uid)
target_event.delete()
logger.info("Deleted event from CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to delete event from CalDAV server: %s", str(e))
raise
def get_user_calendar_paths(self, user) -> list[str]:
"""Return a list of CalDAV-relative calendar paths for the user."""
client = self._get_client(user)
principal = client.principal()
paths = []
base = f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}"
for cal in principal.calendars():
url = str(cal.url)
if url.startswith(base):
paths.append(unquote(url[len(base) :]))
return paths
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def _parse_event(self, event) -> Optional[dict]:
"""
Parse a caldav Event object and return event data as dictionary.
@@ -491,13 +542,15 @@ class CalDAVClient:
# Convert datetime to string format for consistency
if event_data["start"]:
if isinstance(event_data["start"], datetime):
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
utc_start = event_data["start"].astimezone(dt_timezone.utc)
event_data["start"] = utc_start.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["start"], date):
event_data["start"] = event_data["start"].strftime("%Y%m%d")
if event_data["end"]:
if isinstance(event_data["end"], datetime):
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
utc_end = event_data["end"].astimezone(dt_timezone.utc)
event_data["end"] = utc_end.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["end"], date):
event_data["end"] = event_data["end"].strftime("%Y%m%d")
@@ -507,60 +560,19 @@ class CalDAVClient:
return None
class CalendarService:
"""
High-level service for managing calendars and events.
"""
def __init__(self):
self.caldav = CalDAVClient()
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.caldav.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def create_calendar(self, user, name: str, color: str = "") -> str:
"""Create a new calendar for a user. Returns the caldav_path."""
calendar_id = str(uuid4())
return self.caldav.create_calendar(
user, name, calendar_id, color=color or settings.DEFAULT_CALENDAR_COLOR
)
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
"""Get events from a calendar. Returns parsed event data."""
return self.caldav.get_events(user, caldav_path, start, end)
def create_event(self, user, caldav_path: str, event_data: dict) -> str:
"""Create a new event."""
return self.caldav.create_event(user, caldav_path, event_data)
def update_event(
self, user, caldav_path: str, event_uid: str, event_data: dict
) -> None:
"""Update an existing event."""
self.caldav.update_event(user, caldav_path, event_uid, event_data)
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
"""Delete an event."""
self.caldav.delete_event(user, caldav_path, event_uid)
# CalendarService is kept as an alias for backwards compatibility
# with tests and signals that reference it.
CalendarService = CalDAVClient
# ---------------------------------------------------------------------------
# CalDAV path utilities
# ---------------------------------------------------------------------------
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
# Pattern: /calendars/users/<email-or-encoded>/<calendar-id>/
# or /calendars/resources/<resource-id>/<calendar-id>/
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
r"^/calendars/(users|resources)/[^/]+/[a-zA-Z0-9-]+/$",
)
@@ -568,8 +580,8 @@ def normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
so that paths like /api/v1.0/caldav/calendars/users/user@ex.com/uuid/
become /calendars/users/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
@@ -582,19 +594,60 @@ def normalize_caldav_path(caldav_path):
return caldav_path
def _resource_belongs_to_org(resource_id: str, org_id: str) -> bool:
"""Check whether a resource principal belongs to the given organization.
Queries the CalDAV internal API. Returns False on any error (fail-closed).
"""
api_key = settings.CALDAV_INTERNAL_API_KEY
caldav_url = settings.CALDAV_URL
if not api_key or not caldav_url:
return False
try:
resp = requests.get(
f"{caldav_url.rstrip('/')}/caldav/internal-api/resources/{resource_id}",
headers={"X-Internal-Api-Key": api_key},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
return data.get("org_id") == org_id
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to verify resource org for %s", resource_id)
return False
def verify_caldav_access(user, caldav_path):
"""Verify that the user has access to the CalDAV calendar.
Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
2. For user calendars: the user's email matches the email in the path
3. For resource calendars: the user has an organization
Note: Fine-grained org-to-resource authorization is enforced by SabreDAV
itself (via X-CalDAV-Organization header). This check only gates access
for Django-level features (subscription tokens, imports).
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
if len(parts) < 3 or parts[0] != "calendars":
return False
# User calendars: calendars/users/<email>/<calendar-id>
if parts[1] == "users":
if not user.email:
return False
path_email = unquote(parts[2])
return path_email.lower() == user.email.lower()
# Resource calendars: calendars/resources/<resource-id>/<calendar-id>
# Org membership is required. Fine-grained org-to-resource authorization
# is enforced by SabreDAV via the X-CalDAV-Organization header on every
# proxied request. For subscription tokens / imports, callers should
# additionally use _resource_belongs_to_org() to verify ownership.
if parts[1] == "resources":
return bool(getattr(user, "organization_id", None))
return False
@@ -605,10 +658,16 @@ def validate_caldav_proxy_path(path):
- Directory traversal sequences (../)
- Null bytes
- Paths that don't start with expected prefixes
URL-decodes the path first so that encoded payloads like
``%2e%2e`` or ``%00`` cannot bypass the checks.
"""
if not path:
return True # Empty path is fine (root request)
# Decode percent-encoded characters before validation
path = unquote(path)
# Block directory traversal
if ".." in path:
return False
@@ -617,10 +676,60 @@ def validate_caldav_proxy_path(path):
if "\x00" in path:
return False
clean = path.lstrip("/")
# Explicitly block internal-api/ paths — these must never be proxied.
# The allowlist below already rejects them, but an explicit block makes
# the intent clear and survives future allowlist additions.
blocked_prefixes = ("internal-api/",)
if clean and any(clean.startswith(prefix) for prefix in blocked_prefixes):
return False
# Path must start with a known CalDAV resource prefix
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
clean = path.lstrip("/")
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
return False
return True
def cleanup_organization_caldav_data(org):
"""Clean up CalDAV data for all members of an organization.
Deletes each member's CalDAV data via the SabreDAV internal API,
then deletes the Django User objects so the PROTECT foreign key
on User.organization doesn't block org deletion.
Called from Organization.delete() — NOT a signal, because the
PROTECT FK raises ProtectedError before pre_delete fires.
"""
if not settings.CALDAV_INTERNAL_API_KEY:
return
http = CalDAVHTTPClient()
members = list(org.members.all())
for user in members:
if not user.email:
continue
try:
http.request(
"POST",
user,
"internal-api/users/delete",
data=json.dumps({"email": user.email}).encode("utf-8"),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s (org %s)",
user.email,
org.external_id,
)
# Delete all members so the PROTECT FK doesn't block org deletion.
# CalDAV cleanup is best-effort; orphaned CalDAV data is acceptable.
org.members.all().delete()

View File

@@ -20,7 +20,7 @@ from urllib.parse import urlencode
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.signing import Signer
from django.core.signing import TimestampSigner
from django.template.loader import render_to_string
from core.services.translation_service import TranslationService
@@ -424,8 +424,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"time_str": time_str,
"is_update": event.sequence > 0,
"is_cancel": method == self.METHOD_CANCEL,
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
"app_url": getattr(settings, "APP_URL", ""),
"app_name": settings.APP_NAME,
"app_url": settings.APP_URL,
# Translated content blocks
"content": {
"title": t(f"email.{type_key}.title", lang),
@@ -457,13 +457,13 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"footer": t(
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
lang,
appName=getattr(settings, "APP_NAME", "Calendrier"),
appName=settings.APP_NAME,
),
}
# Add RSVP links for REQUEST method (invitations and updates)
if method == self.METHOD_REQUEST:
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
# Strip mailto: prefix (case-insensitive) for shorter tokens
organizer = re.sub(
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
@@ -475,7 +475,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"organizer": organizer,
}
)
app_url = getattr(settings, "APP_URL", "")
app_url = settings.APP_URL
base = f"{app_url}/rsvp/"
for action in ("accept", "tentative", "decline"):
partstat = {
@@ -498,7 +498,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
When False (default), strips METHOD so the ICS is treated as a plain
calendar object — our own RSVP web links handle responses instead.
"""
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
if itip_enabled:
if "METHOD:" not in icalendar_data.upper():
@@ -549,10 +549,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"""
try:
# Get email settings
from_addr = getattr(
settings,
"CALENDAR_INVITATION_FROM_EMAIL",
getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@example.com"),
from_addr = (
settings.CALENDAR_INVITATION_FROM_EMAIL or settings.DEFAULT_FROM_EMAIL
)
# Create the email message
@@ -571,7 +569,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
ics_attachment = MIMEBase("text", "calendar")
ics_attachment.set_payload(ics_content.encode("utf-8"))
encoders.encode_base64(ics_attachment)
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
content_type = "text/calendar; charset=utf-8"
if itip_enabled:
content_type += f"; method={ics_method}"

View File

@@ -3,6 +3,8 @@
import logging
from dataclasses import dataclass, field
from django.conf import settings
import requests
from core.services.caldav_service import CalDAVHTTPClient
@@ -41,37 +43,43 @@ class ICSImportService:
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar.
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
handles all ICS parsing, splitting by UID, VALARM repair, and
per-event insertion.
Sends the raw ICS bytes to the SabreDAV internal API import
endpoint which handles all ICS parsing, splitting by UID,
VALARM repair, and per-event insertion.
Args:
user: The authenticated user performing the import.
caldav_path: CalDAV path of the calendar
(e.g. /calendars/user@example.com/uuid/).
(e.g. /calendars/users/user@example.com/uuid/).
ics_data: Raw ICS file content.
"""
result = ImportResult()
try:
api_key = CalDAVHTTPClient.get_api_key()
except ValueError:
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
api_key = settings.CALDAV_INTERNAL_API_KEY
if not api_key:
result.errors.append("CALDAV_INTERNAL_API_KEY is not configured")
return result
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
# Extract calendar URI from caldav_path
# Path format: /calendars/users/<email>/<calendar-uri>/
parts = caldav_path.strip("/").split("/")
if len(parts) == 4 and parts[0] == "calendars" and parts[1] == "users":
principal_user = parts[2]
calendar_uri = parts[3]
else:
result.errors.append("Invalid calendar path")
return result
# import runs in a background task so we can wait a decent amount of time
timeout = 1200 # 20 minutes
try:
response = self._http.request(
"POST",
user.email,
caldav_path,
query="import",
user,
f"internal-api/import/{principal_user}/{calendar_uri}",
data=ics_data,
content_type="text/calendar",
extra_headers={"X-Calendars-Import": api_key},
extra_headers={"X-Internal-Api-Key": api_key},
timeout=timeout,
)
except requests.RequestException as exc:

View File

@@ -0,0 +1,182 @@
"""Service for managing calendar resource provisioning via CalDAV."""
import json
import logging
from uuid import UUID, uuid4
from django.conf import settings
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
class ResourceProvisioningError(Exception):
"""Raised when resource provisioning fails."""
class ResourceService:
"""Provisions and deletes resource principals in SabreDAV.
Resources are CalDAV principals — this service creates them by
making HTTP requests to the SabreDAV internal API. No Django model
is created; the CalDAV principal IS the resource.
"""
def __init__(self):
self._http = CalDAVHTTPClient()
def _resource_email(self, resource_id):
"""Generate a resource scheduling address."""
domain = settings.RESOURCE_EMAIL_DOMAIN
if not domain:
domain = "resource.invalid"
return f"{resource_id}@{domain}"
def create_resource(self, user, name, resource_type="ROOM"):
"""Provision a resource principal and its default calendar.
Args:
user: The admin user creating the resource (provides auth context).
name: Display name for the resource.
resource_type: "ROOM" or "RESOURCE".
Returns:
dict with resource info: id, email, principal_uri, calendar_uri.
Raises:
ResourceProvisioningError on failure.
"""
if resource_type not in ("ROOM", "RESOURCE"):
raise ResourceProvisioningError(
"resource_type must be 'ROOM' or 'RESOURCE'."
)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
resource_id = str(uuid4())
email = self._resource_email(resource_id)
org_id = str(user.organization_id)
try:
response = self._http.request(
"POST",
user,
"internal-api/resources/",
data=self._json_bytes(
{
"resource_id": resource_id,
"name": name,
"email": email,
"resource_type": resource_type,
"org_id": org_id,
}
),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to create resource principal: %s", e)
raise ResourceProvisioningError(
"Failed to create resource principal."
) from e
if response.status_code == 409:
raise ResourceProvisioningError(f"Resource '{resource_id}' already exists.")
if response.status_code != 201:
logger.error(
"InternalApi create resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to create resource principal.")
principal_uri = f"principals/resources/{resource_id}"
calendar_uri = f"calendars/resources/{resource_id}/default/"
return {
"id": resource_id,
"email": email,
"name": name,
"resource_type": resource_type,
"principal_uri": principal_uri,
"calendar_uri": calendar_uri,
}
@staticmethod
def _validate_resource_id(resource_id):
"""Validate that resource_id is a proper UUID.
Raises ResourceProvisioningError if the ID is not a valid UUID,
preventing path traversal via crafted IDs.
"""
try:
UUID(str(resource_id))
except (ValueError, AttributeError) as e:
raise ResourceProvisioningError(
"Invalid resource ID: must be a valid UUID."
) from e
def delete_resource(self, user, resource_id):
"""Delete a resource principal and its calendar.
Events in user calendars that reference this resource are left
as-is — the resource address becomes unresolvable.
Args:
user: The admin user requesting deletion.
resource_id: The resource UUID.
Raises:
ResourceProvisioningError on failure.
"""
self._validate_resource_id(resource_id)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
try:
response = self._http.request(
"DELETE",
user,
f"internal-api/resources/{resource_id}",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to delete resource: %s", e)
raise ResourceProvisioningError("Failed to delete resource.") from e
if response.status_code == 404:
raise ResourceProvisioningError(f"Resource '{resource_id}' not found.")
if response.status_code == 403:
try:
error_msg = response.json().get("error", "")
except ValueError:
error_msg = ""
raise ResourceProvisioningError(
error_msg or "Cannot delete a resource from a different organization."
)
if response.status_code not in (200, 204):
logger.error(
"InternalApi delete resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to delete resource.")
@staticmethod
def _json_bytes(data):
"""Serialize a dict to JSON bytes."""
return json.dumps(data).encode("utf-8")

View File

@@ -2,6 +2,7 @@
import json
import logging
import threading
from datetime import datetime
from typing import Optional
@@ -40,6 +41,7 @@ class TranslationService:
"""Lightweight translation service backed by translations.json."""
_translations = None
_load_lock = threading.Lock()
@classmethod
def _load(cls):
@@ -47,7 +49,12 @@ class TranslationService:
if cls._translations is not None:
return
path = getattr(settings, "TRANSLATIONS_JSON_PATH", "")
with cls._load_lock:
# Double-check after acquiring lock
if cls._translations is not None:
return
path = settings.TRANSLATIONS_JSON_PATH
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
@@ -104,15 +111,15 @@ class TranslationService:
if email:
try:
from core.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
User,
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
user = User.objects.filter(email=email).first()
user = get_user_model().objects.filter(email=email).first()
if user and user.language:
return cls.normalize_lang(user.language)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to resolve language for email %s", email)
logger.exception("Failed to resolve language for recipient")
return "fr"

View File

@@ -2,15 +2,17 @@
Declare and configure the signals for the calendars core application
"""
import json
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.db import transaction
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.services.caldav_service import CalendarService
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -32,7 +34,7 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
# never create a calendar if we can't confirm access.
try:
entitlements = get_user_entitlements(instance.sub, instance.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
logger.info(
"Skipped calendar creation for %s (not entitled)",
instance.email,
@@ -48,22 +50,45 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
try:
service = CalendarService()
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# In tests, CalDAV server may not be available, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()
if "does not exist" in error_str or "relation" in error_str:
# Likely in test environment, fail silently
logger.debug(
"Skipped calendar creation for user %s (likely test environment): %s",
instance.email,
str(e),
logger.info("Created default calendar for user %s", instance.pk)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to create default calendar for user %s",
instance.pk,
)
else:
# Real error, log it
logger.error(
"Failed to create default calendar for user %s: %s",
instance.email,
str(e),
@receiver(pre_delete, sender=User)
def delete_user_caldav_data(sender, instance, **kwargs): # pylint: disable=unused-argument
"""Schedule CalDAV data cleanup when a user is deleted.
Uses on_commit so the external CalDAV call only fires after
the DB transaction commits — avoids orphaned state on rollback.
"""
email = instance.email
if not email:
return
if not settings.CALDAV_INTERNAL_API_KEY:
return
api_key = settings.CALDAV_INTERNAL_API_KEY
def _cleanup():
try:
http = CalDAVHTTPClient()
http.request(
"POST",
instance,
"internal-api/users/delete",
data=json.dumps({"email": email}).encode("utf-8"),
content_type="application/json",
extra_headers={"X-Internal-Api-Key": api_key},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s",
email,
)
transaction.on_commit(_cleanup)

View File

@@ -0,0 +1,170 @@
"""Task queue utilities.
Provides decorators and helpers that abstract away the underlying task
queue library (currently Dramatiq). Application code should import from
here instead of importing dramatiq directly.
"""
import json
import logging
from typing import Any, Optional
from django.core.cache import cache
from django.utils import timezone
import dramatiq
from dramatiq.brokers.stub import StubBroker
from dramatiq.middleware import CurrentMessage
logger = logging.getLogger(__name__)
TASK_PROGRESS_CACHE_TIMEOUT = 86400 # 24 hours
TASK_TRACKING_CACHE_TTL = 86400 * 30 # 30 days
# ---------------------------------------------------------------------------
# Task wrapper (Celery-compatible API)
# ---------------------------------------------------------------------------
class Task:
"""Wrapper around a Dramatiq Message with a Celery-like API."""
def __init__(self, message):
self._message = message
@property
def id(self):
"""Celery-compatible task ID (maps to message_id)."""
return self._message.message_id
def track_owner(self, user_id):
"""Register tracking metadata for permission checks."""
cache.set(
f"task_tracking:{self.id}",
json.dumps(
{
"owner": str(user_id),
"actor_name": self._message.actor_name,
"queue_name": self._message.queue_name,
}
),
timeout=TASK_TRACKING_CACHE_TTL,
)
def __getattr__(self, name):
return getattr(self._message, name)
class CeleryCompatActor(dramatiq.Actor):
"""Actor subclass that adds a .delay() method returning a Task."""
def delay(self, *args, **kwargs):
"""Dispatch the task asynchronously, returning a Task wrapper."""
message = self.send(*args, **kwargs)
return Task(message)
# ---------------------------------------------------------------------------
# Decorators
# ---------------------------------------------------------------------------
def register_task(*args, **kwargs):
"""Decorator to register a task (wraps dramatiq.actor).
Usage::
@register_task(queue="import")
def my_task(arg):
...
"""
kwargs.setdefault("store_results", True)
if "queue" in kwargs:
kwargs.setdefault("queue_name", kwargs.pop("queue"))
kwargs.setdefault("actor_class", CeleryCompatActor)
def decorator(fn):
return dramatiq.actor(fn, **kwargs)
if args and callable(args[0]):
return decorator(args[0])
return decorator
# ---------------------------------------------------------------------------
# Task tracking & progress
# ---------------------------------------------------------------------------
def get_task_tracking(task_id: str) -> Optional[dict]:
"""Get tracking metadata for a task, or None if not found."""
raw = cache.get(f"task_tracking:{task_id}")
if raw is None:
return None
return json.loads(raw)
def set_task_progress(progress: int, metadata: Optional[dict[str, Any]] = None) -> None:
"""Set the progress of the currently executing task."""
current_message = CurrentMessage.get_current_message()
if not current_message:
logger.warning("set_task_progress called outside of a task")
return
task_id = current_message.message_id
try:
progress = max(0, min(100, int(progress)))
except (TypeError, ValueError):
progress = 0
cache.set(
f"task_progress:{task_id}",
{
"progress": progress,
"timestamp": timezone.now().timestamp(),
"metadata": metadata or {},
},
timeout=TASK_PROGRESS_CACHE_TIMEOUT,
)
def get_task_progress(task_id: str) -> Optional[dict[str, Any]]:
"""Get the progress of a task by ID."""
return cache.get(f"task_progress:{task_id}")
# ---------------------------------------------------------------------------
# EagerBroker for tests
# ---------------------------------------------------------------------------
class EagerBroker(StubBroker):
"""Broker that executes tasks synchronously (for tests).
Equivalent to Celery's CELERY_TASK_ALWAYS_EAGER mode.
Only runs CurrentMessage and Results middleware.
"""
def enqueue(self, message, *, delay=None):
from dramatiq.results import Results # noqa: PLC0415 # pylint: disable=C0415
actor = self.get_actor(message.actor_name)
cm = next(
(m for m in self.middleware if isinstance(m, CurrentMessage)),
None,
)
rm = next((m for m in self.middleware if isinstance(m, Results)), None)
prev = CurrentMessage.get_current_message() if cm else None
if cm:
cm.before_process_message(self, message)
try:
result = actor.fn(*message.args, **message.kwargs)
if rm:
rm.after_process_message(self, message, result=result)
finally:
if cm:
cm.after_process_message(self, message)
if prev is not None:
cm.before_process_message(self, prev)
return message

50
src/backend/core/tasks.py Normal file
View File

@@ -0,0 +1,50 @@
"""Background tasks for the calendars core application."""
# pylint: disable=import-outside-toplevel
import logging
from dataclasses import asdict
from core.services.import_service import ICSImportService
from core.task_utils import register_task, set_task_progress
logger = logging.getLogger(__name__)
@register_task(queue="import")
def import_events_task(user_id, caldav_path, ics_data_hex):
"""Import events from ICS data in the background.
Parameters are kept JSON-serialisable:
- user_id: pk of the User who triggered the import
- caldav_path: target CalDAV calendar path
- ics_data_hex: ICS bytes encoded as hex string
"""
from core.models import User # noqa: PLC0415
set_task_progress(0, {"message": "Starting import..."})
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.error("import_events_task: user %s not found", user_id)
return {
"status": "FAILURE",
"result": None,
"error": "User not found",
}
ics_data = bytes.fromhex(ics_data_hex)
set_task_progress(10, {"message": "Sending to CalDAV server..."})
service = ICSImportService()
result = service.import_events(user, caldav_path, ics_data)
set_task_progress(100, {"message": "Import complete"})
result_dict = asdict(result)
return {
"status": "SUCCESS",
"result": result_dict,
"error": None,
}

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="{{ lang|default:'fr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
color: {{ header_color }};
font-size: 24px;
margin: 0 0 16px;
}
.message {
font-size: 16px;
color: #555;
margin-bottom: 24px;
}
.submit-btn {
display: inline-block;
padding: 12px 32px;
background-color: {{ header_color }};
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
.submit-btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">{{ status_icon|safe }}</div>
<h1>{{ heading }}</h1>
<form id="rsvp-form" method="post" action="{{ post_url }}">
<input type="hidden" name="token" value="{{ token }}">
<input type="hidden" name="action" value="{{ action }}">
<noscript>
<button type="submit" class="submit-btn">{{ submit_label }}</button>
</noscript>
<p class="message" id="loading-msg">...</p>
</form>
</div>
<script>
document.getElementById('rsvp-form').submit();
</script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -17,7 +17,14 @@ from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# Patch org resolution out by default in this module.
# Tests for org resolution are in test_organizations.py.
_no_org_resolve = mock.patch(
"core.authentication.backends.resolve_organization", lambda *a, **kw: None
)
@_no_org_resolve
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
@@ -41,6 +48,7 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
@_no_org_resolve
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
@@ -57,7 +65,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
with django_assert_num_queries(5): # user by sub, user by mail, update sub, org
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -65,30 +73,24 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
def test_authentication_getter_email_none_rejected(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
If no user is found with the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
UserFactory() # existing user with different sub
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
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
)
# 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"
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
@@ -154,6 +156,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
@_no_org_resolve
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -161,7 +164,7 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
user = UserFactory(full_name="John Doe")
def get_userinfo_mocked(*args):
return {
@@ -182,6 +185,7 @@ def test_authentication_getter_existing_user_with_email(
assert user == authenticated_user
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -199,9 +203,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -213,8 +215,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -223,9 +224,9 @@ def test_authentication_getter_existing_user_change_fields_sub(
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
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -241,9 +242,7 @@ def test_authentication_getter_existing_user_change_fields_email(
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"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -255,8 +254,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
with django_assert_num_queries(5):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -265,13 +263,12 @@ def test_authentication_getter_existing_user_change_fields_email(
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):
def test_authentication_getter_new_user_no_email_rejected(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.
If no user matches the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
@@ -280,16 +277,10 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
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.has_usable_password() is False
assert models.User.objects.count() == 1
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
@@ -314,7 +305,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -458,6 +449,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
assert models.User.objects.count() == 1
@_no_org_resolve
@responses.activate
def test_authentication_session_tokens(
django_assert_num_queries, monkeypatch, rf, settings
@@ -498,7 +490,7 @@ def test_authentication_session_tokens(
status=200,
)
with django_assert_num_queries(6):
with django_assert_num_queries(12):
user = klass.authenticate(
request,
code="test-code",
@@ -538,7 +530,7 @@ def test_authentication_store_claims_new_user(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert user.claims == {"iss": "https://example.com"}
assert models.User.objects.count() == 1

View File

@@ -1,10 +1,9 @@
"""Fixtures for tests in the calendars core application"""
import base64
from unittest import mock
from django.conf import settings
from django.core.cache import cache
from django.db import connection
import pytest
import responses
@@ -13,41 +12,84 @@ from core import factories
from core.tests.utils.urls import reload_urls
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
def _has_caldav_marker(request):
"""Check if the test has the xdist_group('caldav') marker."""
marker = request.node.get_closest_marker("xdist_group")
return marker is not None and marker.args and marker.args[0] == "caldav"
@pytest.fixture(autouse=True)
def truncate_caldav_tables(django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Fixture to truncate CalDAV server tables at the start of each test.
def truncate_caldav_tables(request, django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Truncate CalDAV tables before each CalDAV E2E test.
CalDAV server tables are created by the CalDAV server container migrations, not Django.
We just truncate them to ensure clean state for each test.
Only runs for tests marked with @pytest.mark.xdist_group("caldav").
Non-CalDAV tests don't touch the SabreDAV database, so truncating
from their worker would corrupt state for CalDAV tests running
concurrently on another xdist worker.
"""
with django_db_blocker.unblock():
with connection.cursor() as cursor:
# Truncate CalDAV server tables if they exist (created by CalDAV server container)
cursor.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN
TRUNCATE TABLE principals CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN
TRUNCATE TABLE users CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN
TRUNCATE TABLE calendars CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN
TRUNCATE TABLE calendarinstances CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN
TRUNCATE TABLE calendarobjects CASCADE;
END IF;
END $$;
""")
if not _has_caldav_marker(request):
yield
return
import psycopg # noqa: PLC0415 # pylint: disable=import-outside-toplevel
db_settings = settings.DATABASES["default"]
conn = psycopg.connect(
host=db_settings["HOST"],
port=db_settings["PORT"],
dbname="calendars", # SabreDAV always uses this DB
user=db_settings["USER"],
password=db_settings["PASSWORD"],
)
conn.autocommit = True
try:
with conn.cursor() as cur: # pylint: disable=no-member
for table in [
"calendarobjects",
"calendarinstances",
"calendars",
"principals",
]:
cur.execute(f"TRUNCATE TABLE {table} CASCADE")
finally:
conn.close() # pylint: disable=no-member
yield
@pytest.fixture(autouse=True)
def disconnect_caldav_signals_for_unit_tests(request):
"""Disconnect CalDAV signal handlers for non-CalDAV tests.
Prevents non-CalDAV tests from hitting the real SabreDAV server
(e.g. via post_save signal when UserFactory creates a user),
which would interfere with CalDAV E2E tests running concurrently
on another xdist worker.
"""
if _has_caldav_marker(request):
yield
return
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
from django.db.models.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
post_save,
pre_delete,
)
from core.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
delete_user_caldav_data,
provision_default_calendar,
)
user_model = get_user_model()
post_save.disconnect(provision_default_calendar, sender=user_model)
pre_delete.disconnect(delete_user_caldav_data, sender=user_model)
yield
post_save.connect(provision_default_calendar, sender=user_model)
pre_delete.connect(delete_user_caldav_data, sender=user_model)
@pytest.fixture(autouse=True)
@@ -58,16 +100,7 @@ def clear_cache():
# Clear functools.cache for functions decorated with @functools.cache
@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
def resource_server_backend_setup(settings):
def resource_server_backend_setup(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -91,7 +124,7 @@ def resource_server_backend_setup(settings):
@pytest.fixture
def resource_server_backend_conf(settings):
def resource_server_backend_conf(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -100,7 +133,7 @@ def resource_server_backend_conf(settings):
@pytest.fixture
def resource_server_backend(settings):
def resource_server_backend(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.

View File

@@ -43,29 +43,32 @@ def test_api_users_list_authenticated():
"/api/v1.0/users/",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(
email="john.doe@example.com", is_active=False, organization=org
)
lennon = factories.UserFactory(email="john.lennon@example.com", organization=org)
# Use email query to get exact match
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(lennon.id)]
# Inactive user should not be returned even with exact match
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
@@ -83,16 +86,16 @@ def test_api_users_list_query_short_queries():
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Non-email queries (without @) return empty
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_limit(settings):
@@ -101,6 +104,7 @@ def test_api_users_list_limit(settings):
should be limited to 10.
"""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
@@ -108,14 +112,14 @@ def test_api_users_list_limit(settings):
# 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")
factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org)
# Non-email queries (without @) return empty
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Email queries require exact match
settings.API_USERS_LIST_LIMIT = 100
@@ -123,7 +127,7 @@ def test_api_users_list_limit(settings):
"/api/v1.0/users/?q=alice.0@example.com",
)
assert response.status_code == 200
assert len(response.json()) == 1
assert len(response.json()["results"]) == 1
def test_api_users_list_throttling_authenticated(settings):
@@ -157,19 +161,20 @@ def test_api_users_list_query_email(settings):
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
factories.UserFactory(email="nicole.bowman@work.com", organization=org)
# Exact match works
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()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Case-insensitive match works
@@ -177,7 +182,7 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=David.Bowman@Work.COM",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Typos don't match (exact match only)
@@ -185,43 +190,48 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
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()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""Email queries return exact matches only (case-insensitive)."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="alice.johnnson@example.gouv.fr")
factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
user1 = factories.UserFactory(
email="alice.johnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org)
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
user4 = factories.UserFactory(
email="alicia.johnnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alicia.johnnson@example.gov.uk", organization=org)
factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org)
# Exact match returns only that user
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()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user1.id)]
# Different email returns different user
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()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user4.id)]
@@ -260,9 +270,13 @@ def test_api_users_retrieve_me_authenticated():
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"can_access": True,
"can_admin": True,
"organization": {
"id": str(user.organization.id),
"name": user.organization.name,
},
}

View File

@@ -27,7 +27,7 @@ class TestCalDAVProxy:
def test_proxy_requires_authentication(self):
"""Test that unauthenticated requests return 401."""
client = APIClient()
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_401_UNAUTHORIZED
@responses.activate
@@ -49,7 +49,7 @@ class TestCalDAVProxy:
)
)
client.generic("PROPFIND", "/api/v1.0/caldav/")
client.generic("PROPFIND", "/caldav/")
# Verify request was made to CalDAV server
assert len(responses.calls) == 1
@@ -77,7 +77,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -88,7 +88,7 @@ class TestCalDAVProxy:
malicious_email = "attacker@example.com"
client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
HTTP_X_FORWARDED_USER=malicious_email,
)
@@ -107,10 +107,6 @@ class TestCalDAVProxy:
"X-Forwarded-User should NOT use client-sent header value"
)
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_proxy_propfind_response_contains_prefixed_urls(self):
"""PROPFIND responses should contain URLs with proxy prefix.
@@ -130,7 +126,7 @@ class TestCalDAVProxy:
)
response = client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
data=propfind_body,
content_type="application/xml",
)
@@ -154,8 +150,8 @@ class TestCalDAVProxy:
if href and (
href.startswith("/principals/") or href.startswith("/calendars/")
):
assert href.startswith("/api/v1.0/caldav/"), (
f"Expected URL to start with /api/v1.0/caldav/, "
assert href.startswith("/caldav/"), (
f"Expected URL to start with /caldav/, "
f"got {href}. BaseUriPlugin is not using "
f"X-Forwarded-Prefix correctly. Full response: "
f"{response.content.decode('utf-8', errors='ignore')}"
@@ -178,7 +174,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:">
<response>
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
<href>/caldav/calendars/users/test@example.com/calendar-id/</href>
<propstat>
<prop>
<resourcetype>
@@ -193,14 +189,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -213,7 +209,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/calendars/test@example.com/calendar-id/", (
assert href == "/caldav/calendars/users/test@example.com/calendar-id/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -234,7 +230,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:D="DAV:">
<response>
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
<D:href>/caldav/principals/users/test@example.com/</D:href>
<propstat>
<prop>
<resourcetype>
@@ -248,14 +244,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -268,7 +264,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/principals/test@example.com/", (
assert href == "/caldav/principals/users/test@example.com/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -283,7 +279,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/",
url=f"{caldav_url}/caldav/principals/users/test@example.com/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -291,14 +287,12 @@ class TestCalDAVProxy:
)
# Request a specific path
client.generic("PROPFIND", "/api/v1.0/caldav/principals/test@example.com/")
client.generic("PROPFIND", "/caldav/principals/users/test@example.com/")
# Verify the request was made to the correct URL
assert len(responses.calls) == 1
request = responses.calls[0].request
assert (
request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/"
)
assert request.url == f"{caldav_url}/caldav/principals/users/test@example.com/"
@responses.activate
def test_proxy_handles_options_request(self):
@@ -307,7 +301,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.options("/api/v1.0/caldav/")
response = client.options("/caldav/")
assert response.status_code == HTTP_200_OK
assert "Access-Control-Allow-Methods" in response
@@ -319,9 +313,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic(
"PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd"
)
response = client.generic("PROPFIND", "/caldav/calendars/../../etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_non_caldav_path(self):
@@ -330,7 +322,16 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic("PROPFIND", "/api/v1.0/caldav/etc/passwd")
response = client.generic("PROPFIND", "/caldav/etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_internal_api_path(self):
"""Test that proxy explicitly blocks /internal-api/ paths."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.generic("POST", "/caldav/internal-api/resources/")
assert response.status_code == HTTP_400_BAD_REQUEST
@@ -343,11 +344,11 @@ class TestValidateCaldavProxyPath:
def test_calendars_path_is_valid(self):
"""Standard calendars path should be valid."""
assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("calendars/users/user@ex.com/uuid/") is True
def test_principals_path_is_valid(self):
"""Standard principals path should be valid."""
assert validate_caldav_proxy_path("principals/user@ex.com/") is True
assert validate_caldav_proxy_path("principals/users/user@ex.com/") is True
def test_traversal_is_rejected(self):
"""Directory traversal attempts should be rejected."""
@@ -363,4 +364,24 @@ class TestValidateCaldavProxyPath:
def test_leading_slash_calendars_is_valid(self):
"""Paths with leading slash should still be valid."""
assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("/calendars/users/user@ex.com/uuid/") is True
def test_internal_api_is_rejected(self):
"""Internal API paths should be explicitly blocked."""
assert validate_caldav_proxy_path("internal-api/resources/") is False
def test_internal_api_with_leading_slash_is_rejected(self):
"""Internal API paths with leading slash should be blocked."""
assert validate_caldav_proxy_path("/internal-api/import/user/cal") is False
def test_encoded_traversal_is_rejected(self):
"""URL-encoded directory traversal should be rejected."""
assert validate_caldav_proxy_path("calendars/%2e%2e/%2e%2e/etc/passwd") is False
def test_encoded_internal_api_is_rejected(self):
"""URL-encoded internal-api path should be blocked."""
assert validate_caldav_proxy_path("%69nternal-api/resources/") is False
def test_encoded_null_byte_is_rejected(self):
"""URL-encoded null byte should be rejected."""
assert validate_caldav_proxy_path("calendars/user%00/") is False

View File

@@ -72,13 +72,10 @@ def create_test_server() -> tuple:
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVScheduling:
"""Tests for CalDAV scheduling callback when creating events with attendees."""
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
self,
):
@@ -125,8 +122,8 @@ class TestCalDAVScheduling:
try:
# Create an event with an attendee
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
calendar_url = f"{settings.CALDAV_URL}{caldav_path}"
client = service._get_client(organizer) # pylint: disable=protected-access
calendar_url = service._calendar_url(caldav_path) # pylint: disable=protected-access
# Add custom callback URL header to the client
# The CalDAV server will use this URL for the callback

View File

@@ -1,7 +1,5 @@
"""Tests for CalDAV service integration."""
from django.conf import settings
import pytest
from core import factories
@@ -9,6 +7,7 @@ from core.services.caldav_service import CalDAVClient, CalendarService
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVClient:
"""Tests for CalDAVClient authentication and communication."""
@@ -30,10 +29,6 @@ class TestCalDAVClient:
assert "X-Forwarded-User" in dav_client.headers
assert dav_client.headers["X-Forwarded-User"] == user.email
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_authenticates_with_caldav_server(self):
"""Test that calendar creation authenticates successfully with CalDAV server."""
user = factories.UserFactory(email="test@example.com")
@@ -65,10 +60,6 @@ class TestCalDAVClient:
assert isinstance(caldav_path, str)
assert "calendars/" in caldav_path
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_with_color_persists(self):
"""Test that creating a calendar with a color saves it in CalDAV."""
user = factories.UserFactory(email="color-test@example.com")
@@ -79,7 +70,7 @@ class TestCalDAVClient:
caldav_path = service.create_calendar(user, name="Red Calendar", color=color)
# Fetch the calendar info and verify the color was persisted
info = service.caldav.get_calendar_info(user, caldav_path)
info = service.get_calendar_info(user, caldav_path)
assert info is not None
assert info["color"] == color
assert info["name"] == "Red Calendar"

View File

@@ -1,40 +1,37 @@
"""Tests for calendar subscription token API."""
from urllib.parse import quote
from django.urls import reverse
"""Tests for iCal feed channel creation via the channels API."""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core import factories
from core.models import CalendarSubscriptionToken
from core.models import Channel
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.mark.django_db
class TestSubscriptionTokenViewSet:
"""Tests for the new standalone SubscriptionTokenViewSet."""
class TestICalFeedChannels:
"""Tests for ical-feed channel creation via ChannelViewSet."""
def test_create_subscription_token(self):
"""Test creating a subscription token for a calendar."""
def test_create_ical_feed_channel(self):
"""Test creating an ical-feed channel for a calendar."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar-uuid/"
caldav_path = f"/calendars/users/{user.email}/test-calendar-uuid/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"name": "My Test Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Test Calendar",
},
@@ -47,233 +44,210 @@ class TestSubscriptionTokenViewSet:
assert "/ical/" in response.data["url"]
assert ".ics" in response.data["url"]
assert response.data["caldav_path"] == caldav_path
assert response.data["calendar_name"] == "My Test Calendar"
assert response.data["type"] == "ical-feed"
# Verify token was created in database
assert CalendarSubscriptionToken.objects.filter(
owner=user, caldav_path=caldav_path
# Verify channel was created in database
assert Channel.objects.filter(
user=user, caldav_path=caldav_path, type="ical-feed"
).exists()
def test_create_subscription_token_normalizes_path(self):
def test_create_ical_feed_normalizes_path(self):
"""Test that caldav_path is normalized to have leading/trailing slashes."""
user = factories.UserFactory()
caldav_path = f"calendars/{user.email}/test-uuid" # No leading/trailing slash
caldav_path = f"calendars/users/{user.email}/test-uuid"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{"name": "Cal", "type": "ical-feed", "caldav_path": caldav_path},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# Path should be normalized
assert response.data["caldav_path"] == f"/calendars/{user.email}/test-uuid/"
assert (
response.data["caldav_path"] == f"/calendars/users/{user.email}/test-uuid/"
)
def test_create_subscription_token_returns_existing(self):
"""Test that creating a token when one exists returns the existing one."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_create_ical_feed_returns_existing(self):
"""Test that creating an ical-feed channel when one exists returns it."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"caldav_path": subscription.caldav_path,
"name": "Updated Name",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
"calendar_name": "Updated Name",
},
format="json",
)
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
# Name should be updated
subscription.refresh_from_db()
assert subscription.calendar_name == "Updated Name"
channel.refresh_from_db()
assert channel.settings["calendar_name"] == "Updated Name"
def test_get_subscription_token_by_path(self):
"""Test retrieving an existing subscription token by CalDAV path."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_list_ical_feed_channels(self):
"""Test filtering channels by type=ical-feed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
# Create one ical-feed and one caldav channel
client.post(
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": f"/calendars/users/{user.email}/cal1/",
},
format="json",
)
client.post(
CHANNELS_URL,
{"name": "CalDAV Channel"},
format="json",
)
# Filter by type
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
assert "url" in response.data
assert len(response.data) == 1
assert response.data[0]["type"] == "ical-feed"
def test_get_subscription_token_not_found(self):
"""Test retrieving token when none exists."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/nonexistent/"
# Without filter, both show up
response = client.get(CHANNELS_URL)
assert len(response.data) == 2
def test_delete_ical_feed_channel(self):
"""Test deleting an ical-feed channel."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(user)
client.force_login(channel.user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": caldav_path})
assert response.status_code == HTTP_404_NOT_FOUND
def test_get_subscription_token_missing_path(self):
"""Test that missing caldav_path query param returns 400."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-by-path")
response = client.get(url)
assert response.status_code == HTTP_400_BAD_REQUEST
def test_delete_subscription_token(self):
"""Test revoking a subscription token."""
subscription = factories.CalendarSubscriptionTokenFactory()
client = APIClient()
client.force_login(subscription.owner)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
response = client.delete(url)
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
assert not CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists()
assert not Channel.objects.filter(pk=channel.pk).exists()
def test_delete_subscription_token_not_found(self):
"""Test deleting token when none exists."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/nonexistent/"
client = APIClient()
client.force_login(user)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(caldav_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_404_NOT_FOUND
def test_non_owner_cannot_create_token(self):
"""Test that users cannot create tokens for other users' calendars."""
def test_non_owner_cannot_create_ical_feed(self):
"""Test that users cannot create ical-feed channels for others' calendars."""
user = factories.UserFactory()
other_user = factories.UserFactory()
caldav_path = f"/calendars/{other_user.email}/test-calendar/"
caldav_path = f"/calendars/users/{other_user.email}/test-calendar/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Stolen",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_non_owner_cannot_get_token(self):
"""Test that users cannot get tokens for other users' calendars."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_non_owner_cannot_list_others_channels(self):
"""Test that users only see their own channels."""
factories.ICalFeedChannelFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(other_user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert len(response.data) == 0
assert response.status_code == HTTP_403_FORBIDDEN
def test_non_owner_cannot_delete_token(self):
"""Test that users cannot delete tokens for other users' calendars."""
subscription = factories.CalendarSubscriptionTokenFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(other_user)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_403_FORBIDDEN
# Token should still exist
assert CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists()
def test_unauthenticated_cannot_create_token(self):
"""Test that unauthenticated users cannot create tokens."""
def test_unauthenticated_cannot_create(self):
"""Test that unauthenticated users cannot create channels."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar/"
caldav_path = f"/calendars/users/{user.email}/test-calendar/"
client = APIClient()
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_unauthenticated_cannot_get_token(self):
"""Test that unauthenticated users cannot get tokens."""
subscription = factories.CalendarSubscriptionTokenFactory()
client = APIClient()
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_regenerate_token(self):
"""Test regenerating a token by delete + create."""
subscription = factories.CalendarSubscriptionTokenFactory()
old_token = subscription.token
channel = factories.ICalFeedChannelFactory()
old_token = channel.encrypted_settings["token"]
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
base_by_path_url = reverse("subscription-tokens-by-path")
by_path_url = (
f"{base_by_path_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
)
create_url = reverse("subscription-tokens-list")
# Delete old token
response = client.delete(by_path_url)
# Delete old channel
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Create new token
# Create new one for the same path
response = client.post(
create_url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert response.data["token"] != str(old_token)
assert response.data["token"] != old_token
def test_unique_constraint_per_owner_calendar(self):
"""Test that only one token can exist per owner+caldav_path."""
subscription = factories.CalendarSubscriptionTokenFactory()
# Try to create another token for the same path - should return existing
"""Test that only one ical-feed channel exists per owner+caldav_path."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
# Try to create another - should return existing
response = client.post(
url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Duplicate",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
},
format="json",
)
# Should return the existing token, not create a new one
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
assert (
CalendarSubscriptionToken.objects.filter(owner=subscription.owner).count()
== 1
assert Channel.objects.filter(user=channel.user, type="ical-feed").count() == 1
def test_url_contains_slugified_calendar_name(self):
"""Test that the URL contains the slugified calendar name."""
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/cal/"
client = APIClient()
client.force_login(user)
response = client.post(
CHANNELS_URL,
{
"name": "My Awesome Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Awesome Calendar",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert "my-awesome-calendar.ics" in response.data["url"]
@pytest.mark.django_db
class TestPathInjectionProtection:
@@ -290,45 +264,40 @@ class TestPathInjectionProtection:
@pytest.mark.parametrize(
"malicious_suffix",
[
# Path traversal attacks
"../other-calendar/",
"../../etc/passwd/",
"..%2F..%2Fetc%2Fpasswd/", # URL-encoded traversal
# Query parameter injection
"..%2F..%2Fetc%2Fpasswd/",
"uuid?export=true/",
"uuid?admin=true/",
# Fragment injection
"uuid#malicious/",
# Special characters that shouldn't be in calendar IDs
"uuid;rm -rf/",
"uuid|cat /etc/passwd/",
"uuid$(whoami)/",
"uuid`whoami`/",
# Double slashes
"uuid//",
"/uuid/",
# Spaces and other whitespace
"uuid with spaces/",
"uuid\ttab/",
# Unicode tricks
"uuid\u002e\u002e/", # Unicode dots
"uuid\u002e\u002e/",
],
)
def test_create_token_rejects_malicious_calendar_id(self, malicious_suffix):
def test_create_rejects_malicious_calendar_id(self, malicious_suffix):
"""Test that malicious calendar IDs in paths are rejected."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/{malicious_suffix}"
caldav_path = f"/calendars/users/{user.email}/{malicious_suffix}"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
# Should be rejected - either 403 (invalid format) or path doesn't normalize
assert response.status_code == HTTP_403_FORBIDDEN, (
f"Path '{caldav_path}' should be rejected but got {response.status_code}"
)
@@ -336,31 +305,30 @@ class TestPathInjectionProtection:
@pytest.mark.parametrize(
"malicious_path",
[
# Completely wrong structure
"/etc/passwd/",
"/admin/calendars/user@test.com/uuid/",
"/../calendars/user@test.com/uuid/",
# Missing segments
"/calendars/",
"/calendars/user@test.com/",
# Path traversal to access another user's calendar
"/calendars/victim@test.com/../attacker@test.com/uuid/",
],
)
def test_create_token_rejects_malformed_paths(self, malicious_path):
def test_create_rejects_malformed_paths(self, malicious_path):
"""Test that malformed CalDAV paths are rejected."""
user = factories.UserFactory(email="attacker@test.com")
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": malicious_path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": malicious_path,
},
format="json",
)
# Should be rejected
assert response.status_code == HTTP_403_FORBIDDEN, (
f"Path '{malicious_path}' should be rejected but got {response.status_code}"
)
@@ -368,21 +336,23 @@ class TestPathInjectionProtection:
def test_path_traversal_to_other_user_calendar_rejected(self):
"""Test that path traversal to access another user's calendar is blocked."""
attacker = factories.UserFactory(email="attacker@example.com")
victim = factories.UserFactory(email="victim@example.com")
factories.UserFactory(email="victim@example.com")
client = APIClient()
client.force_login(attacker)
# Try to access victim's calendar via path traversal
malicious_paths = [
f"/calendars/{attacker.email}/../{victim.email}/secret-calendar/",
f"/calendars/{victim.email}/secret-calendar/", # Direct access
f"/calendars/{attacker.email}/../victim@example.com/secret-calendar/",
"/calendars/victim@example.com/secret-calendar/",
]
url = reverse("subscription-tokens-list")
for path in malicious_paths:
response = client.post(
url,
{"caldav_path": path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": path,
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN, (
@@ -392,15 +362,19 @@ class TestPathInjectionProtection:
def test_valid_uuid_path_accepted(self):
"""Test that valid UUID-style calendar IDs are accepted."""
user = factories.UserFactory()
# Standard UUID format
caldav_path = f"/calendars/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
caldav_path = (
f"/calendars/users/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
)
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
@@ -409,43 +383,18 @@ class TestPathInjectionProtection:
def test_valid_alphanumeric_path_accepted(self):
"""Test that valid alphanumeric calendar IDs are accepted."""
user = factories.UserFactory()
# Alphanumeric with hyphens (allowed by regex)
caldav_path = f"/calendars/{user.email}/my-calendar-2024/"
caldav_path = f"/calendars/users/{user.email}/my-calendar-2024/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
def test_get_token_with_malicious_path_rejected(self):
"""Test that GET requests with malicious paths are rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
malicious_path = f"/calendars/{user.email}/../../../etc/passwd/"
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": malicious_path})
assert response.status_code == HTTP_403_FORBIDDEN
def test_delete_token_with_malicious_path_rejected(self):
"""Test that DELETE requests with malicious paths are rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
malicious_path = f"/calendars/{user.email}/../../../etc/passwd/"
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(malicious_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_403_FORBIDDEN

View File

@@ -0,0 +1,456 @@
"""Tests for the Channel model and API."""
# pylint: disable=redefined-outer-name,missing-function-docstring,no-member
import uuid
from unittest.mock import patch
from django.core.exceptions import ValidationError
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.fixture
def authenticated_client():
"""Return an (APIClient, User) pair with forced authentication."""
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
return client, user
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestChannelModel:
"""Tests for the Channel model."""
def test_verify_token(self):
channel = factories.ChannelFactory()
token = channel.encrypted_settings["token"]
assert channel.verify_token(token)
assert not channel.verify_token("wrong-token")
def test_scope_validation_requires_at_least_one(self):
"""Channel with no scope should fail validation."""
channel = models.Channel(name="no-scope")
with pytest.raises(ValidationError):
channel.full_clean()
def test_role_property(self):
"""Role is stored in settings and accessible via property."""
user = factories.UserFactory()
channel = models.Channel(
name="test",
user=user,
settings={"role": "editor"},
)
assert channel.role == "editor"
channel.role = "admin"
assert channel.settings["role"] == "admin"
def test_role_default(self):
"""Role defaults to reader when not set."""
user = factories.UserFactory()
channel = models.Channel(name="test", user=user)
assert channel.role == "reader"
# ---------------------------------------------------------------------------
# API tests
# ---------------------------------------------------------------------------
class TestChannelAPI:
"""Tests for the Channel CRUD API."""
def test_create_channel(self, authenticated_client):
client, user = authenticated_client
response = client.post(
CHANNELS_URL,
{"name": "My Channel"},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Channel"
assert "token" in data # token revealed on creation
assert len(data["token"]) >= 20
assert data["role"] == "reader"
assert data["user"] == str(user.pk)
def test_create_channel_with_caldav_path(self, authenticated_client):
client, user = authenticated_client
caldav_path = f"/calendars/users/{user.email}/my-cal/"
response = client.post(
CHANNELS_URL,
{"name": "Cal Channel", "caldav_path": caldav_path},
format="json",
)
assert response.status_code == 201
assert response.json()["caldav_path"] == caldav_path
def test_create_channel_wrong_caldav_path(self, authenticated_client):
client, _user = authenticated_client
response = client.post(
CHANNELS_URL,
{
"name": "Bad",
"caldav_path": "/calendars/users/other@example.com/cal/",
},
format="json",
)
assert response.status_code == 403
def test_list_channels(self, authenticated_client):
client, _user = authenticated_client
# Create 2 channels
for i in range(2):
client.post(
CHANNELS_URL,
{"name": f"Channel {i}"},
format="json",
)
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 2
def test_list_channels_only_own(self, authenticated_client):
"""Users should only see their own channels."""
client, _user = authenticated_client
# Create a channel for another user
factories.ChannelFactory()
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 0
def test_retrieve_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Retrieve Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.get(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 200
assert response.json()["name"] == "Retrieve Me"
assert "token" not in response.json() # token NOT in retrieve
def test_delete_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Delete Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.delete(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 204
assert not models.Channel.objects.filter(pk=channel_id).exists()
def test_regenerate_token(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Regen"},
format="json",
)
old_token = create_resp.json()["token"]
channel_id = create_resp.json()["id"]
response = client.post(f"{CHANNELS_URL}{channel_id}/regenerate-token/")
assert response.status_code == 200
new_token = response.json()["token"]
assert new_token != old_token
assert len(new_token) >= 20
def test_unauthenticated(self):
client = APIClient()
response = client.get(CHANNELS_URL)
assert response.status_code in (401, 403)
# ---------------------------------------------------------------------------
# CalDAV proxy channel auth tests
# ---------------------------------------------------------------------------
class TestCalDAVProxyChannelAuth:
"""Tests for channel token authentication in the CalDAV proxy."""
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_auth_propfind(self, mock_http_cls):
"""A reader channel token should allow PROPFIND."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"<xml/>",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_reader_cannot_put(self, _mock_http_cls):
"""A reader channel should NOT allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_editor_can_put(self, mock_http_cls):
"""An editor channel should allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "editor"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 201,
"content": b"",
"headers": {"Content-Type": "text/plain"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 201
def test_channel_token_wrong_path(self):
"""Channel should not access paths outside its user scope."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/users/other@example.com/cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_invalid_token(self):
"""Invalid token should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN="invalid-token-12345",
)
assert response.status_code == 401
def test_missing_channel_id(self):
"""Token without channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_nonexistent_channel_id(self):
"""Non-existent channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(uuid.uuid4()),
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_inactive_channel_id(self):
"""Inactive channel should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
is_active=False,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 401
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_caldav_path_scoped_channel(self, mock_http_cls):
"""Channel with caldav_path scope restricts to that path."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/specific-cal/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"<xml/>",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
# Allowed: within scoped path
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav{scoped_path}",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
# Denied: different calendar
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/other-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_caldav_path_boundary_no_prefix_leak(self):
"""Scoped path /cal1/ must NOT match /cal1-secret/ (trailing slash boundary)."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/cal1/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/cal1-secret/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.get_user_entitlements")
def test_channel_mkcalendar_checks_entitlements(self, mock_entitlements):
"""MKCALENDAR via channel token must still check entitlements."""
mock_entitlements.return_value = {"can_access": False}
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "admin"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user.email}/new-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
mock_entitlements.assert_called_once_with(user.sub, user.email)

View File

@@ -0,0 +1,709 @@
"""End-to-end cross-organization isolation tests against real SabreDAV.
These tests verify that org-scoped resources, calendars, and operations
are properly isolated between organizations. They hit the real SabreDAV
server (no mocks) to validate the full stack: Django -> SabreDAV -> DB.
Requires: CalDAV server running (skipped otherwise).
"""
# pylint: disable=no-member,broad-exception-caught,unused-variable
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_207_MULTI_STATUS,
HTTP_400_BAD_REQUEST,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization, User
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
from core.services.resource_service import ResourceProvisioningError, ResourceService
pytestmark = [
pytest.mark.django_db,
pytest.mark.xdist_group("caldav"),
]
@pytest.fixture(autouse=True)
def _local_entitlements(settings):
"""Use local entitlements backend for all tests in this module."""
settings.ENTITLEMENTS_BACKEND = (
"core.entitlements.backends.local.LocalEntitlementsBackend"
)
settings.ENTITLEMENTS_BACKEND_PARAMETERS = {}
get_entitlements_backend.cache_clear()
yield
get_entitlements_backend.cache_clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_org_admin(org):
"""Create a user in the given org and return (user, api_client).
Uses force_login (not force_authenticate) so that session-based auth
works for the CalDAV proxy view, which checks request.user.is_authenticated
via Django's session middleware rather than DRF's token auth.
"""
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_login(user)
return user, client
def _create_resource_via_internal_api(user, name="Room 1", resource_type="ROOM"):
"""Create a resource using ResourceService (hits real SabreDAV)."""
service = ResourceService()
return service.create_resource(user, name, resource_type)
def _get_dav_client_with_org(user):
"""Get a DAVClient with org header via CalDAVHTTPClient."""
http = CalDAVHTTPClient()
return http.get_dav_client(user)
def _propfind_resource_principals(api_client):
"""PROPFIND /caldav/principals/resources/ and return parsed XML root."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<prop>"
"<displayname/>"
"<C:calendar-user-type/>"
"</prop>"
"</propfind>"
)
response = api_client.generic(
"PROPFIND",
"/caldav/principals/resources/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _propfind_resource_calendar(api_client, resource_id):
"""PROPFIND a specific resource's calendar collection."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
)
response = api_client.generic(
"PROPFIND",
f"/caldav/calendars/resources/{resource_id}/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _put_event_on_resource(api_client, resource_id, event_uid, organizer_email):
"""PUT an event directly onto a resource's default calendar."""
dtstart = datetime.now() + timedelta(days=1)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{event_uid}\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Cross-org test event\r\n"
f"ORGANIZER:mailto:{organizer_email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return api_client.generic(
"PUT",
f"/caldav/calendars/resources/{resource_id}/default/{event_uid}.ics",
data=ical,
content_type="text/calendar",
)
# ---------------------------------------------------------------------------
# 1. Resource provisioning — cross-org isolation via real SabreDAV
# ---------------------------------------------------------------------------
class TestResourceProvisioningE2E:
"""Resource creation and deletion hit real SabreDAV internal API."""
def test_create_resource_e2e(self):
"""POST /resources/ creates a principal in SabreDAV."""
org = factories.OrganizationFactory(external_id="res-e2e-org")
admin, client = _create_org_admin(org)
response = client.post(
"/api/v1.0/resources/",
{"name": "Meeting Room A", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED, response.json()
data = response.json()
assert data["name"] == "Meeting Room A"
assert data["resource_type"] == "ROOM"
resource_id = data["id"]
# Verify the principal actually exists in SabreDAV via PROPFIND
propfind = _propfind_resource_calendar(client, resource_id)
assert propfind.status_code == HTTP_207_MULTI_STATUS, (
f"Resource calendar not found in SabreDAV: {propfind.status_code}"
)
def test_delete_resource_e2e_same_org(self):
"""Admin can delete a resource belonging to their own org."""
org = factories.OrganizationFactory(external_id="del-same-org")
admin, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(admin, "Doomed Room")
resource_id = resource["id"]
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the principal is gone from SabreDAV
propfind = _propfind_resource_calendar(client, resource_id)
# Should be 404 or 207 with empty result — the principal was deleted
assert propfind.status_code != HTTP_207_MULTI_STATUS or (
b"<response>" not in propfind.content
)
def test_delete_resource_e2e_cross_org_blocked(self):
"""Admin from org A CANNOT delete a resource belonging to org B.
This is enforced by SabreDAV's InternalApiPlugin, not Django.
"""
org_a = factories.OrganizationFactory(external_id="del-org-a")
org_b = factories.OrganizationFactory(external_id="del-org-b")
# Create resource in org B
admin_b = factories.UserFactory(organization=org_b)
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
resource_id = resource["id"]
# Admin from org A tries to delete it
_, client_a = _create_org_admin(org_a)
response = client_a.delete(f"/api/v1.0/resources/{resource_id}/")
# Django returns 400 because SabreDAV returned 403
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# 2. CalDAV proxy — org header forwarding verified E2E
# ---------------------------------------------------------------------------
class TestCalDAVProxyOrgHeaderE2E:
"""Verify org header reaches SabreDAV and affects responses."""
def test_user_can_propfind_own_calendar(self):
"""User can PROPFIND their own calendar home."""
org = factories.OrganizationFactory(external_id="proxy-own")
user, client = _create_org_admin(org)
# Create a calendar for the user
service = CalendarService()
service.create_calendar(user, name="My Cal")
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
data='<?xml version="1.0"?><propfind xmlns="DAV:"><prop>'
"<displayname/></prop></propfind>",
content_type="application/xml",
HTTP_DEPTH="1",
)
assert response.status_code == HTTP_207_MULTI_STATUS
def test_user_cannot_read_other_users_calendar_objects(self):
"""User from org A cannot read events from user B's calendar.
SabreDAV allows PROPFIND on calendar homes (auto-creates principals),
but blocks reading actual calendar objects via ACLs. The key isolation
is that event data (REPORT/GET on .ics) is protected.
"""
org_a = factories.OrganizationFactory(external_id="proxy-org-a")
org_b = factories.OrganizationFactory(external_id="proxy-org-b")
user_a, client_a = _create_org_admin(org_a)
user_b = factories.UserFactory(organization=org_b)
# Create a calendar with an event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Calendar")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Add an event to user B's calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
dtstart = datetime.now() + timedelta(days=10)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:private-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Private Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to GET user B's event
response = client_a.generic(
"GET",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/private-event-uid.ics",
)
# SabreDAV should block with 403 (ACL) or 404
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user GET, got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 3. Resource calendar access — cross-org isolation
# ---------------------------------------------------------------------------
class TestResourceCalendarAccessE2E:
"""Verify that resource calendar data is org-scoped in SabreDAV.
Users cannot PUT events directly on resource calendars — only
SabreDAV's scheduling plugin writes there via iTIP. These tests
verify that direct PUT is blocked by ACLs for both same-org and
cross-org users.
"""
def test_direct_put_on_resource_calendar_blocked(self):
"""Users cannot PUT events directly on resource calendars.
Resource calendars are managed by the auto-schedule plugin.
Direct writes are blocked by SabreDAV ACLs.
"""
org = factories.OrganizationFactory(external_id="put-direct-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "ACL Room")
response = _put_event_on_resource(
client, resource["id"], "direct-put-uid", user.email
)
# Direct PUT on resource calendar is blocked by ACLs
assert response.status_code in (403, 404), (
f"Expected 403/404 for direct PUT on resource calendar, "
f"got {response.status_code}: "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
def test_cross_org_put_on_resource_calendar_blocked(self):
"""Cross-org direct PUT on resource calendar is also blocked."""
org_a = factories.OrganizationFactory(external_id="put-org-a")
org_b = factories.OrganizationFactory(external_id="put-org-b")
_, client_a = _create_org_admin(org_a)
admin_b = factories.UserFactory(organization=org_b)
resource_b = _create_resource_via_internal_api(admin_b, "Org B Room")
response = _put_event_on_resource(
client_a, resource_b["id"], "cross-org-event-uid", "attacker@test.com"
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-org PUT on resource calendar, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 4. Resource auto-scheduling — cross-org booking rejection
# ---------------------------------------------------------------------------
class TestResourceAutoScheduleCrossOrgE2E:
"""Verify that cross-org resource bookings are declined by SabreDAV.
The ResourceAutoSchedulePlugin checks X-CalDAV-Organization against
the resource's org_id and declines cross-org booking requests.
NOTE: SabreDAV's scheduling plugin resolves attendees via the principal
backend. Resource principals live under principals/resources/, and the
scheduling plugin must find them by email for iTIP delivery to work.
If SCHEDULE-STATUS=5.x appears, it means the principal wasn't resolved,
which blocks both auto-accept and auto-decline. These tests verify the
org-scoping logic in InternalApiPlugin (create/delete) rather than
iTIP scheduling, since iTIP requires searchPrincipals() support for
the resources collection.
"""
def test_resource_create_stores_org_id(self):
"""Resource creation stores org_id in SabreDAV for later scoping."""
org = factories.OrganizationFactory(external_id="sched-orgid")
user, _ = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Org Room")
# Verify the resource exists by PROPFIND on its calendar
propfind = _propfind_resource_calendar(
_create_org_admin(org)[1], resource["id"]
)
assert propfind.status_code == HTTP_207_MULTI_STATUS
def test_resource_delete_cross_org_rejected_by_sabredav(self):
"""SabreDAV's InternalApiPlugin rejects cross-org resource deletion.
This is the core org-scoping enforcement: the org_id stored on creation
is checked on deletion.
"""
org_a = factories.OrganizationFactory(external_id="sched-del-a")
org_b = factories.OrganizationFactory(external_id="sched-del-b")
admin_a = factories.UserFactory(organization=org_a)
admin_b = factories.UserFactory(organization=org_b)
# Create resource in org B
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
# Attempt delete from org A — should fail
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="different organization"):
service.delete_resource(admin_a, resource["id"])
# Verify resource still exists
_, client_b = _create_org_admin(org_b)
propfind = _propfind_resource_calendar(client_b, resource["id"])
assert propfind.status_code == HTTP_207_MULTI_STATUS
# ---------------------------------------------------------------------------
# 5. Resource principal discovery — all orgs see resource list
# but data is org-scoped
# ---------------------------------------------------------------------------
class TestResourceDiscoveryE2E:
"""Verify resource principal discovery behavior across orgs."""
def test_resource_principals_visible_to_authenticated_users(self):
"""Any authenticated user can PROPFIND /principals/resources/.
ResourcePrincipal grants {DAV:}read to {DAV:}authenticated.
This allows resource discovery for scheduling.
"""
org = factories.OrganizationFactory(external_id="disc-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Discoverable Room")
response = _propfind_resource_principals(client)
assert response.status_code == HTTP_207_MULTI_STATUS
# The response should contain the resource we just created
content = response.content.decode("utf-8", errors="ignore")
assert resource["id"] in content or "Discoverable Room" in content
# ---------------------------------------------------------------------------
# 6. User deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestUserDeletionCleanupE2E:
"""Verify user deletion cleans up CalDAV data in SabreDAV."""
def test_deleting_user_removes_caldav_principal(self):
"""When a Django user is deleted, their SabreDAV principal is cleaned up."""
user = factories.UserFactory(email="doomed-user@test-e2e.com")
# Create a calendar for the user (creates principal in SabreDAV)
service = CalendarService()
service.create_calendar(user, name="Soon Deleted")
# Verify calendar exists
dav = CalDAVHTTPClient().get_dav_client(user)
principal = dav.principal()
assert len(principal.calendars()) > 0
# Capture org before delete (Python obj persists but be explicit)
org_id = user.organization_id
# Delete the user (signal triggers CalDAV cleanup)
user.delete()
# Verify the principal's calendars are gone
# After deletion, the principal shouldn't exist, but due to
# auto-create behavior, just check calendars are empty
ghost = SimpleNamespace(
email="doomed-user@test-e2e.com", organization_id=org_id
)
dav2 = CalDAVHTTPClient().get_dav_client(ghost)
try:
principal2 = dav2.principal()
cals = principal2.calendars()
# Either no calendars or the principal doesn't exist
assert len(cals) == 0, (
f"Expected 0 calendars after deletion, found {len(cals)}"
)
except Exception: # noqa: BLE001
# Principal not found — expected
pass
# ---------------------------------------------------------------------------
# 7. Organization deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestOrgDeletionCleanupE2E:
"""Verify org deletion cleans up all member CalDAV data."""
def test_deleting_org_removes_all_member_caldav_data(self):
"""Deleting an org cleans up CalDAV data for all its members."""
org = factories.OrganizationFactory(external_id="doomed-org-e2e")
alice = factories.UserFactory(
email="alice-doomed@test-e2e.com", organization=org
)
bob = factories.UserFactory(email="bob-doomed@test-e2e.com", organization=org)
# Create calendars for both users
service = CalendarService()
service.create_calendar(alice, name="Alice Cal")
service.create_calendar(bob, name="Bob Cal")
# Verify calendars exist
for user in [alice, bob]:
dav = CalDAVHTTPClient().get_dav_client(user)
assert len(dav.principal().calendars()) > 0
# Capture org_id before deletion
org_id = org.id
# Delete the org (cascades to member cleanup + user deletion)
org.delete()
# Verify both users' calendars are gone
emails = ["alice-doomed@test-e2e.com", "bob-doomed@test-e2e.com"]
for email in emails:
ghost = SimpleNamespace(email=email, organization_id=org_id)
dav = CalDAVHTTPClient().get_dav_client(ghost)
try:
cals = dav.principal().calendars()
assert len(cals) == 0
except Exception: # noqa: BLE001
pass # Principal not found — expected
assert not User.objects.filter(
email__in=[
"alice-doomed@test-e2e.com",
"bob-doomed@test-e2e.com",
]
).exists()
assert not Organization.objects.filter(external_id="doomed-org-e2e").exists()
# ---------------------------------------------------------------------------
# 8. Calendar creation isolation
# ---------------------------------------------------------------------------
class TestCalendarCreationIsolationE2E:
"""Verify calendar creation is scoped to the authenticated user."""
def test_mkcalendar_creates_for_authenticated_user_only(self):
"""MKCALENDAR via proxy creates calendar under the authenticated user's principal."""
org = factories.OrganizationFactory(external_id="mkcal-org")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(organization=org)
# User A creates a calendar via proxy
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_a.email}/new-cal-e2e/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>E2E Test Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
assert response.status_code == 201, (
f"MKCALENDAR failed: {response.status_code} "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
# Verify user A has the calendar via CalDAV PROPFIND
dav_a = CalDAVHTTPClient().get_dav_client(user_a)
cal_names_a = [c.name for c in dav_a.principal().calendars()]
assert "E2E Test Calendar" in cal_names_a, (
f"Calendar 'E2E Test Calendar' not found for user A. Found: {cal_names_a}"
)
# Verify user B does NOT have this calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
try:
cal_names_b = [c.name for c in dav_b.principal().calendars()]
assert "E2E Test Calendar" not in cal_names_b
except Exception: # noqa: BLE001
pass # User B has no principal — that's fine
def test_cannot_create_calendar_under_other_user(self):
"""User A cannot MKCALENDAR under user B's principal."""
org = factories.OrganizationFactory(external_id="mkcal-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="mkcal-victim@test-e2e.com", organization=org
)
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_b.email}/hijacked-cal/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>Hijacked Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
# SabreDAV should block this with 403 (ACL violation)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for MKCALENDAR under another user, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 9. Event CRUD isolation
# ---------------------------------------------------------------------------
class TestEventCRUDIsolationE2E:
"""Verify event CRUD is scoped to the calendar owner."""
def test_user_cannot_put_event_in_other_users_calendar(self):
"""User A cannot PUT an event into user B's calendar."""
org = factories.OrganizationFactory(external_id="event-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-victim@test-e2e.com", organization=org
)
# Create a calendar for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Private Cal")
# Extract calendar ID from path
# Path format: calendars/users/email/cal-id/
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
dtstart = datetime.now() + timedelta(days=5)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:malicious-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Malicious Event\r\n"
f"ORGANIZER:mailto:{user_a.email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
response = client_a.generic(
"PUT",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/malicious.ics",
data=ical,
content_type="text/calendar",
)
# SabreDAV should block with 403 (ACL)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for cross-user PUT, got {response.status_code}"
)
def test_user_cannot_delete_other_users_event(self):
"""User A cannot DELETE an event from user B's calendar."""
org = factories.OrganizationFactory(external_id="event-del-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-del-victim@test-e2e.com", organization=org
)
# Create a calendar and event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Cal")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Create event as user B
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
assert len(cals_b) > 0
dtstart = datetime.now() + timedelta(days=6)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:victim-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Victim's Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to DELETE user B's event
response = client_a.generic(
"DELETE",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/victim-event-uid.ics",
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user DELETE, got {response.status_code}"
)
# Verify event still exists via CalDAV API
http = CalDAVHTTPClient()
ical_data, _, _ = http.find_event_by_uid(user_b, "victim-event-uid")
assert ical_data is not None, (
"Victim's event should still exist after blocked deletion attempt"
)

View File

@@ -27,10 +27,10 @@ from core.entitlements.factory import get_entitlements_backend
def test_local_backend_always_grants_access():
"""The local backend should always return can_access=True."""
"""The local backend should always return can_access=True and can_admin=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
def test_local_backend_ignores_parameters():
@@ -42,7 +42,7 @@ def test_local_backend_ignores_parameters():
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# -- Factory --
@@ -99,7 +99,7 @@ def test_deploycenter_backend_grants_access():
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": True}},
json={"entitlements": {"can_access": True, "can_admin": True}},
status=200,
)
@@ -109,7 +109,7 @@ def test_deploycenter_backend_grants_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
@@ -135,7 +135,7 @@ def test_deploycenter_backend_denies_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": False}
assert result == {"can_access": False, "can_admin": False}
@responses.activate
@@ -157,12 +157,12 @@ def test_deploycenter_backend_uses_cache():
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_access": True}
assert result1["can_access"] is True
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_access": True}
assert result2["can_access"] is True
assert len(responses.calls) == 1 # No additional API call
@@ -230,7 +230,7 @@ def test_deploycenter_backend_fallback_to_stale_cache():
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_access": True}
assert result["can_access"] is True
@responses.activate
@@ -355,20 +355,21 @@ def test_user_me_serializer_includes_can_access_false():
assert data["can_access"] is False
def test_user_me_serializer_can_access_fail_open():
"""UserMeSerializer should return can_access=True when entitlements unavailable."""
def test_user_me_serializer_can_access_fail_closed():
"""UserMeSerializer should return can_access=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_access"] is True
assert data["can_access"] is False
# -- Signals integration --
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -392,6 +393,7 @@ def test_signal_skips_calendar_when_not_entitled():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -414,6 +416,7 @@ def test_signal_skips_calendar_when_entitlements_unavailable():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -456,7 +459,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -474,7 +477,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCOL",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -493,7 +496,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -509,7 +512,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="MKCALENDAR",
url=f"{caldav_url}/api/v1.0/caldav/calendars/test@example.com/new-cal/",
url=f"{caldav_url}/caldav/calendars/users/test@example.com/new-cal/",
status=201,
body="",
)
@@ -521,7 +524,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == 201
@@ -538,7 +541,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -546,7 +549,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
)
# No entitlements mock needed — PROPFIND should not check entitlements
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -561,7 +564,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="REPORT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -570,7 +573,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"REPORT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/",
"/caldav/calendars/users/other@example.com/cal-id/",
)
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -587,7 +590,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PUT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/event.ics",
status=HTTP_200_OK,
body="",
)
@@ -595,7 +598,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"PUT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
"/caldav/calendars/users/other@example.com/cal-id/event.ics",
data=b"BEGIN:VCALENDAR\nEND:VCALENDAR",
content_type="text/calendar",
)

View File

@@ -1,9 +1,6 @@
"""Tests for iCal export endpoint."""
import uuid
"""Tests for iCal export endpoint (using Channel type=ical-feed)."""
from django.conf import settings
from django.urls import reverse
import pytest
import responses
@@ -11,22 +8,28 @@ from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_502_BAD_
from rest_framework.test import APIClient
from core import factories
from core.models import uuid_to_urlsafe
@pytest.mark.django_db
class TestICalExport:
"""Tests for ICalExportView."""
def _ical_url(self, channel):
"""Build the iCal export URL for a channel."""
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
return f"/ical/{short_id}/{token}/calendar.ics"
def test_export_with_valid_token_returns_ics(self):
"""Test that a valid token returns iCal data."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
# Mock CalDAV server response
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
ics_content = b"""BEGIN:VCALENDAR
VERSION:2.0
@@ -47,8 +50,7 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_200_OK
assert response["Content-Type"] == "text/calendar; charset=utf-8"
@@ -58,35 +60,38 @@ END:VCALENDAR"""
def test_export_with_invalid_token_returns_404(self):
"""Test that an invalid token returns 404."""
channel = factories.ICalFeedChannelFactory()
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
invalid_token = uuid.uuid4()
url = reverse("ical-export", kwargs={"token": invalid_token})
response = client.get(url)
response = client.get(f"/ical/{short_id}/WrongTokenHere123/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_with_invalid_channel_id_returns_404(self):
"""Test that a nonexistent channel ID returns 404."""
client = APIClient()
# base62-encoded zero UUID
response = client.get("/ical/0/SomeToken123/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_with_inactive_token_returns_404(self):
"""Test that an inactive token returns 404."""
subscription = factories.CalendarSubscriptionTokenFactory(is_active=False)
channel = factories.ICalFeedChannelFactory(is_active=False)
client = APIClient()
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_updates_last_accessed_at(self):
"""Test that accessing the export updates last_accessed_at."""
subscription = factories.CalendarSubscriptionTokenFactory()
assert subscription.last_accessed_at is None
def test_export_updates_last_used_at(self):
"""Test that accessing the export updates last_used_at."""
channel = factories.ICalFeedChannelFactory()
assert channel.last_used_at is None
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -96,22 +101,20 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
subscription.refresh_from_db()
assert subscription.last_accessed_at is not None
channel.refresh_from_db()
assert channel.last_used_at is not None
def test_export_does_not_require_authentication(self):
"""Test that the endpoint is accessible without authentication."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
# Not logging in - should still work
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -121,20 +124,18 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_200_OK
def test_export_sends_correct_headers_to_caldav(self):
"""Test that the proxy sends correct authentication headers to CalDAV."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -144,24 +145,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
# Verify headers sent to CalDAV
assert len(rsps.calls) == 1
request = rsps.calls[0].request
assert request.headers["X-Forwarded-User"] == subscription.owner.email
assert request.headers["X-Forwarded-User"] == channel.user.email
assert request.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
def test_export_handles_caldav_error(self):
"""Test that CalDAV server errors are handled gracefully."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -170,20 +169,18 @@ END:VCALENDAR"""
status=500,
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_502_BAD_GATEWAY
def test_export_sets_security_headers(self):
"""Test that security headers are set correctly."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -193,24 +190,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
# Verify security headers
assert response["Cache-Control"] == "no-store, private"
assert response["Referrer-Policy"] == "no-referrer"
def test_export_uses_calendar_name_in_filename(self):
"""Test that the export filename uses the calendar_name."""
subscription = factories.CalendarSubscriptionTokenFactory(
calendar_name="My Test Calendar"
"""Test that the export filename uses the calendar_name from settings."""
channel = factories.ICalFeedChannelFactory(
settings={"role": "reader", "calendar_name": "My Test Calendar"}
)
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -220,7 +215,15 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert "my-test-calendar.ics" in response["Content-Disposition"]
assert "My Test Calendar.ics" in response["Content-Disposition"]
def test_non_ical_feed_channel_returns_404(self):
"""Test that a valid token for a non-ical-feed channel returns 404."""
channel = factories.ChannelFactory() # type="caldav" (default)
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
response = client.get(f"/ical/{short_id}/{token}/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND

View File

@@ -247,7 +247,7 @@ END:VCALENDAR"""
def _make_caldav_path(user):
"""Build a caldav_path string for a user (test helper)."""
return f"/calendars/{user.email}/{uuid.uuid4()}/"
return f"/calendars/users/{user.email}/{uuid.uuid4()}/"
def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -482,7 +482,7 @@ class TestICSImportService:
@patch("core.services.caldav_service.requests.request")
def test_import_passes_calendar_path(self, mock_post):
"""The import URL should include the caldav_path."""
"""The import URL should use the internal-api/import/ endpoint."""
mock_post.return_value = _make_sabredav_response(
total_events=1, imported_count=1
)
@@ -495,8 +495,11 @@ class TestICSImportService:
call_args = mock_post.call_args
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
assert caldav_path in url
assert "?import" in url
assert "internal-api/import/" in url
# URL should contain the user email and calendar URI from the path
parts = caldav_path.strip("/").split("/")
assert parts[2] in url # user email
assert parts[3] in url # calendar URI
@patch("core.services.caldav_service.requests.request")
def test_import_sends_auth_headers(self, mock_post):
@@ -515,7 +518,7 @@ class TestICSImportService:
headers = call_kwargs["headers"]
assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Forwarded-User"] == user.email
assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Internal-Api-Key"] == settings.CALDAV_INTERNAL_API_KEY
assert headers["Content-Type"] == "text/calendar"
@patch("core.services.caldav_service.requests.request")
@@ -571,7 +574,7 @@ class TestImportEventsAPI:
"""Users cannot import to a calendar they don't own."""
owner = factories.UserFactory(email="owner@example.com")
other_user = factories.UserFactory(email="other@example.com")
caldav_path = f"/calendars/{owner.email}/some-uuid/"
caldav_path = f"/calendars/users/{owner.email}/some-uuid/"
client = APIClient()
client.force_login(other_user)
@@ -597,12 +600,12 @@ class TestImportEventsAPI:
)
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
assert response.status_code == 400
assert "caldav_path" in response.json()["error"]
assert "caldav_path" in response.json()["detail"]
def test_import_events_missing_file(self):
"""Request without a file should return 400."""
user = factories.UserFactory(email="nofile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -613,12 +616,12 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "No file provided" in response.json()["error"]
assert "No file provided" in response.json()["detail"]
def test_import_events_file_too_large(self):
"""Files exceeding MAX_FILE_SIZE should be rejected."""
user = factories.UserFactory(email="largefile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -634,11 +637,11 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "too large" in response.json()["error"]
assert "too large" in response.json()["detail"]
@patch.object(ICSImportService, "import_events")
def test_import_events_success(self, mock_import):
"""Successful import should return result data."""
def test_import_events_returns_task_id(self, mock_import):
"""Successful import should return a task_id for polling."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=3,
@@ -648,7 +651,7 @@ class TestImportEventsAPI:
)
user = factories.UserFactory(email="success@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -662,51 +665,20 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 200
assert response.status_code == 202
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert "errors" not in data
assert "task_id" in data
@patch.object(ICSImportService, "import_events")
def test_import_events_partial_success(self, mock_import):
"""Partial success should include errors in response."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=2,
duplicate_count=0,
skipped_count=1,
errors=["Planning session"],
)
user = factories.UserFactory(email="partial@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
ics_file = SimpleUploadedFile(
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 2
assert data["skipped_count"] == 1
assert len(data["errors"]) == 1
# With EagerBroker, the task runs synchronously — poll for result
task_response = client.get(f"/api/v1.0/tasks/{data['task_id']}/")
assert task_response.status_code == 200
task_data = task_response.json()
assert task_data["status"] == "SUCCESS"
assert task_data["result"]["total_events"] == 3
assert task_data["result"]["imported_count"] == 3
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestImportEventsE2E:
"""End-to-end tests that import ICS events through the real SabreDAV server."""
@@ -827,11 +799,16 @@ class TestImportEventsE2E:
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert response.status_code == 202
task_id = response.json()["task_id"]
# With EagerBroker, poll for the synchronous result
task_response = client.get(f"/api/v1.0/tasks/{task_id}/")
assert task_response.status_code == 200
data = task_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 3
assert data["result"]["imported_count"] == 3
# Verify events actually exist in SabreDAV
caldav = CalDAVClient()
@@ -955,7 +932,7 @@ class TestImportEventsE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data
@@ -1022,10 +999,7 @@ class TestImportEventsE2E:
assert "..." in raw
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestCalendarSanitizerE2E:
"""E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations."""
@@ -1038,7 +1012,7 @@ class TestCalendarSanitizerE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data

View File

@@ -0,0 +1,196 @@
"""Tests for the organizations feature."""
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
from core.authentication.backends import resolve_organization
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization
# -- Organization model --
@pytest.mark.django_db
def test_organization_str_with_name():
"""Organization.__str__ returns the name when set."""
org = factories.OrganizationFactory(name="Acme Corp", external_id="acme")
assert str(org) == "Acme Corp"
@pytest.mark.django_db
def test_organization_str_without_name():
"""Organization.__str__ falls back to external_id when name is empty."""
org = factories.OrganizationFactory(name="", external_id="acme")
assert str(org) == "acme"
@pytest.mark.django_db
def test_organization_unique_external_id():
"""external_id must be unique."""
factories.OrganizationFactory(external_id="org-1")
with pytest.raises(Exception): # noqa: B017
factories.OrganizationFactory(external_id="org-1")
# -- Org resolution on login --
@pytest.mark.django_db
def test_resolve_org_from_email_domain():
"""Without OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from email domain."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "ministry.gouv.fr"
assert user.organization.name == "ministry.gouv.fr"
@pytest.mark.django_db
@override_settings(OIDC_USERINFO_ORGANIZATION_CLAIM="siret")
def test_resolve_org_from_oidc_claim():
"""With OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from the claim."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(
user,
claims={"siret": "13002526500013"},
entitlements={"organization_name": "Ministere X"},
)
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "13002526500013"
assert user.organization.name == "Ministere X"
@pytest.mark.django_db
def test_resolve_org_updates_name():
"""Org name is updated from entitlements on subsequent logins."""
org = factories.OrganizationFactory(external_id="example.com", name="Old Name")
user = factories.UserFactory(email="alice@example.com", organization=org)
resolve_organization(
user,
claims={},
entitlements={"organization_name": "New Name"},
)
org.refresh_from_db()
assert org.name == "New Name"
@pytest.mark.django_db
def test_resolve_org_reuses_existing():
"""Existing org is reused, not duplicated."""
org = factories.OrganizationFactory(external_id="example.com")
user = factories.UserFactory(email="bob@example.com")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization_id == org.id
assert Organization.objects.filter(external_id="example.com").count() == 1
# -- User API: /users/me/ --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_organization():
"""GET /users/me/ includes the user's organization."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(name="Test Org", external_id="test")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["organization"]["id"] == str(org.id)
assert data["organization"]["name"] == "Test Org"
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_can_admin():
"""GET /users/me/ includes can_admin from entitlements."""
get_entitlements_backend.cache_clear()
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert "can_admin" in data
# Local backend returns True for can_admin
assert data["can_admin"] is True
get_entitlements_backend.cache_clear()
# -- User list scoping --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_scoped_by_org():
"""User list only returns users from the same org."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
alice = factories.UserFactory(email="alice@example.com", organization=org_a)
factories.UserFactory(email="bob@other.com", organization=org_b)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=bob@other.com")
assert response.status_code == HTTP_200_OK
# Bob should NOT be visible (different org)
assert len(response.json()["results"]) == 0
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_same_org_visible():
"""User list returns users from the same org."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-a")
alice = factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="carol@example.com", organization=org)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=carol@example.com")
assert response.status_code == HTTP_200_OK
data = response.json()["results"]
assert len(data) == 1
assert data[0]["email"] == "carol@example.com"
get_entitlements_backend.cache_clear()

View File

@@ -0,0 +1,382 @@
"""Tests for permissions, access control, and security edge cases."""
from unittest import mock
from django.conf import settings
from django.test import override_settings
import pytest
import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_207_MULTI_STATUS,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.factory import get_entitlements_backend
from core.services.caldav_service import (
CALDAV_PATH_PATTERN,
normalize_caldav_path,
verify_caldav_access,
)
# ---------------------------------------------------------------------------
# verify_caldav_access — resource paths
# ---------------------------------------------------------------------------
class TestVerifyCaldavAccessResourcePaths:
"""Tests for verify_caldav_access() with resource calendar paths."""
def _make_user(self, email="alice@example.com", org_id=None):
"""Create a mock user object."""
user = mock.Mock()
user.email = email
user.organization_id = org_id
return user
def test_resource_path_allowed_when_user_has_org(self):
"""Users with an organization can access resource calendars.
Fine-grained org-to-resource authorization is enforced by SabreDAV
via the X-CalDAV-Organization header, not here.
"""
user = self._make_user(org_id="some-org-uuid")
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is True
def test_user_path_allowed_for_own_email(self):
"""Users can access their own calendar paths."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
def test_user_path_denied_for_other_email(self):
"""Users cannot access another user's calendar path."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/bob@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is False
def test_invalid_path_denied(self):
"""Paths that don't match the expected pattern are rejected."""
user = self._make_user(org_id="some-org")
assert verify_caldav_access(user, "/etc/passwd") is False
assert verify_caldav_access(user, "/calendars/") is False
assert verify_caldav_access(user, "/calendars/unknown/x/y/") is False
def test_path_traversal_denied(self):
"""Path traversal attempts are rejected."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/../../../etc/passwd/"
assert verify_caldav_access(user, path) is False
def test_resource_path_pattern_matches(self):
"""The CALDAV_PATH_PATTERN regex matches resource paths."""
assert CALDAV_PATH_PATTERN.match("/calendars/resources/abc-123/default/")
assert CALDAV_PATH_PATTERN.match(
"/calendars/resources/a1b2c3d4-e5f6-7890-abcd-ef1234567890/default/"
)
def test_resource_path_denied_when_user_has_no_org(self):
"""Users without an organization cannot access resource calendars."""
user = self._make_user(org_id=None)
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is False
def test_user_path_case_insensitive(self):
"""Email comparison in user paths should be case-insensitive."""
user = self._make_user(email="Alice@Example.COM")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
# ---------------------------------------------------------------------------
# IsOrgAdmin permission
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsOrgAdminPermission:
"""Tests for the IsOrgAdmin permission class on the ResourceViewSet."""
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_admin_user_can_create_resource(self):
"""Users with can_admin=True can create resources."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
get_entitlements_backend.cache_clear()
def test_non_admin_user_denied_resource_creation(self):
"""Users with can_admin=False are denied by IsOrgAdmin."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
# Patch where IsOrgAdmin actually looks up get_user_entitlements
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_entitlements_unavailable_denies_access(self):
"""IsOrgAdmin is fail-closed: denies when entitlements service is down."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_unauthenticated_user_denied(self):
"""Unauthenticated users are denied by IsOrgAdmin (inherits IsAuthenticated)."""
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
# 401 or 403 depending on DRF config
assert response.status_code in (401, 403)
def test_non_admin_user_denied_resource_deletion(self):
"""Users with can_admin=False cannot delete resources either."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.delete("/api/v1.0/resources/some-uuid/")
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# CalDAV proxy — X-CalDAV-Organization header forwarding
# ---------------------------------------------------------------------------
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVProxyOrgHeader:
"""Tests that the CalDAV proxy forwards the org header correctly."""
@responses.activate
def test_proxy_sends_org_header(self):
"""CalDAV proxy sends X-CalDAV-Organization for users with an org."""
org = factories.OrganizationFactory(external_id="org-alpha")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
client.generic("PROPFIND", "/caldav/principals/resources/")
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.headers["X-CalDAV-Organization"] == str(org.id)
@responses.activate
def test_proxy_cannot_spoof_org_header(self):
"""Client-sent X-CalDAV-Organization is overwritten by the proxy."""
org = factories.OrganizationFactory(external_id="real-org")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# Attempt to spoof the org header
client.generic(
"PROPFIND",
"/caldav/principals/resources/",
HTTP_X_CALDAV_ORGANIZATION="spoofed-org-id",
)
assert len(responses.calls) == 1
request = responses.calls[0].request
# The proxy should use the user's real org ID, not the spoofed one
assert request.headers["X-CalDAV-Organization"] == str(org.id)
assert request.headers["X-CalDAV-Organization"] != "spoofed-org-id"
# ---------------------------------------------------------------------------
# IsEntitledToAccess permission — fail-closed
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsEntitledToAccessFailClosed:
"""Tests that IsEntitledToAccess permission is fail-closed."""
def test_import_denied_when_entitlements_unavailable(self):
"""ICS import should be denied when entitlements service is down."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_import_denied_when_can_access_false(self):
"""ICS import should be denied when can_access=False."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": False, "can_admin": False},
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# normalize_caldav_path
# ---------------------------------------------------------------------------
class TestNormalizeCaldavPath:
"""Tests for normalize_caldav_path helper."""
def test_strips_old_api_prefix(self):
"""Should strip any prefix before /calendars/."""
result = normalize_caldav_path(
"/api/v1.0/caldav/calendars/users/user@ex.com/uuid/"
)
assert result == "/calendars/users/user@ex.com/uuid/"
def test_strips_new_prefix(self):
"""Should strip /caldav prefix."""
result = normalize_caldav_path("/caldav/calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_leading_slash(self):
"""Should add a leading slash if missing."""
result = normalize_caldav_path("calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_trailing_slash(self):
"""Should add a trailing slash if missing."""
result = normalize_caldav_path("/calendars/users/user@ex.com/uuid")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_resource_path_unchanged(self):
"""Resource paths should pass through unchanged."""
result = normalize_caldav_path("/calendars/resources/abc-123/default/")
assert result == "/calendars/resources/abc-123/default/"
# ---------------------------------------------------------------------------
# UserViewSet — user without org gets empty results
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestUserViewSetCrossOrg:
"""Tests that users from a different org see no users in the list."""
def test_user_from_other_org_gets_empty_list(self):
"""A user from a different org should get an empty user list."""
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
factories.UserFactory(email="orguser@example.com", organization=org_a)
other_org_user = factories.UserFactory(
email="other@example.com", organization=org_b
)
client = APIClient()
client.force_login(other_org_user)
response = client.get("/api/v1.0/users/", {"q": "orguser@example.com"})
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 0

View File

@@ -0,0 +1,309 @@
"""Tests for the resource provisioning API."""
import json
from unittest import mock
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.services.resource_service import ResourceProvisioningError, ResourceService
# -- Permission checks --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_create_resource_requires_auth():
"""POST /resources/ requires authentication."""
get_entitlements_backend.cache_clear()
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_success():
"""POST /resources/ creates a resource principal via the internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
# Mock the internal API response for resource creation
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"principal_uri": "principals/resources/some-uuid",
"email": "c_test@resource.calendar.localhost",
}
mock_response.text = '{"principal_uri": "principals/resources/some-uuid"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 101", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
data = response.json()
assert data["name"] == "Room 101"
assert data["resource_type"] == "ROOM"
assert "email" in data
assert "id" in data
# Principal URI uses the opaque UUID, not the slug
assert data["principal_uri"].startswith("principals/resources/")
assert data["principal_uri"] == f"principals/resources/{data['id']}"
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert "internal-api/resources" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource():
"""DELETE /resources/{resource_id}/ deletes the resource via internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"deleted": True}
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert f"internal-api/resources/{resource_id}" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_cross_org_blocked():
"""Cannot delete a resource from another organization."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
admin = factories.UserFactory(organization=org_a)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 403
mock_response.json.return_value = {
"error": "Cannot delete a resource from a different organization."
}
mock_response.text = (
'{"error": "Cannot delete a resource from a different organization."}'
)
mock_request.return_value = mock_response
response = client.delete(
"/api/v1.0/resources/b1b2c3d4-e5f6-7890-abcd-ef1234567890/"
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"]
get_entitlements_backend.cache_clear()
# -- Lateral access tests --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_sends_user_org_id():
"""Create resource always sends the authenticated user's org_id, not a caller-supplied one."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-alpha")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# Verify the JSON body sent to internal API contains the user's org
call_kwargs = mock_request.call_args
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["org_id"] == str(org.id)
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_sends_user_org_id():
"""Delete resource sends the authenticated user's org_id in the header."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-beta")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-0000-0000-0000-000000000001"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
call_kwargs = mock_request.call_args
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-CalDAV-Organization") == str(org.id)
get_entitlements_backend.cache_clear()
# -- Path traversal tests --
class TestResourceIdValidation:
"""Tests that resource_id is validated as a UUID to prevent path traversal."""
@pytest.fixture(autouse=True)
def _internal_api_key(self, settings):
settings.CALDAV_INTERNAL_API_KEY = "test-internal-key"
def test_delete_rejects_path_traversal(self):
"""A malicious resource_id like ../../users/victim is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "../../users/victim@example.com")
def test_delete_rejects_non_uuid_string(self):
"""A non-UUID resource_id is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "not-a-uuid")
def test_delete_accepts_valid_uuid(self):
"""A valid UUID resource_id passes validation."""
user = mock.Mock()
user.email = "admin@example.com"
user.organization_id = "some-org"
service = ResourceService()
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
# Should not raise
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
def test_create_resource_rejects_missing_api_key(self, settings):
"""create_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.create_resource(user, "Room 1", "ROOM")
def test_delete_resource_rejects_missing_api_key(self, settings):
"""delete_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")

View File

@@ -8,7 +8,7 @@ from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
from django.core import mail
from django.core.signing import BadSignature, Signer
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
@@ -16,7 +16,8 @@ from django.utils import timezone
import icalendar
import pytest
from core.api.viewsets_rsvp import RSVPView
from core import factories
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.services.caldav_service import CalDAVHTTPClient
from core.services.calendar_invitation_service import (
CalendarInvitationService,
@@ -56,10 +57,10 @@ SAMPLE_CALDAV_RESPONSE = """\
<?xml version="1.0" encoding="UTF-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:href>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:propstat>
<d:prop>
<d:gethref>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<d:gethref>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<cal:calendar-data>{ics_data}</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
@@ -71,8 +72,8 @@ SAMPLE_CALDAV_RESPONSE = """\
def _make_token(
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
):
"""Create a valid signed RSVP token."""
signer = Signer(salt="rsvp")
"""Create a valid signed RSVP token using TimestampSigner."""
signer = TimestampSigner(salt="rsvp")
return signer.sign_object(
{
"uid": uid,
@@ -88,7 +89,7 @@ class TestRSVPTokenGeneration:
def test_token_roundtrip(self):
"""A generated token can be unsigned to recover the payload."""
token = _make_token()
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
payload = signer.unsign_object(token)
assert payload["uid"] == "test-uid-123"
assert payload["email"] == "bob@example.com"
@@ -97,7 +98,7 @@ class TestRSVPTokenGeneration:
def test_tampered_token_fails(self):
"""A tampered token raises BadSignature."""
token = _make_token() + "tampered"
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
with pytest.raises(BadSignature):
signer.unsign_object(token)
@@ -194,7 +195,7 @@ class TestRSVPEmailTemplateRendering:
class TestUpdateAttendeePartstat:
"""Tests for the _update_attendee_partstat function."""
"""Tests for the update_attendee_partstat function."""
def test_update_existing_partstat(self):
result = CalDAVHTTPClient.update_attendee_partstat(
@@ -232,18 +233,50 @@ class TestUpdateAttendeePartstat:
assert "CN=Bob" in result
assert "mailto:bob@example.com" in result
def test_substring_email_does_not_match(self):
"""Emails that are substrings of the target should NOT match."""
# Create ICS with a similar-but-different email
ics = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:test-substring\r\n"
"DTSTART:20260401T100000Z\r\n"
"DTEND:20260401T110000Z\r\n"
"SUMMARY:Test\r\n"
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notbob@example.com\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
# "bob@example.com" should NOT match "notbob@example.com"
result = CalDAVHTTPClient.update_attendee_partstat(
ics, "bob@example.com", "ACCEPTED"
)
assert result is None
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPView(TestCase):
"""Tests for the RSVPView."""
class TestRSVPConfirmView(TestCase):
"""Tests for the RSVPConfirmView (GET handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPView.as_view()
self.view = RSVPConfirmView.as_view()
def test_valid_token_renders_confirm_page(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 200
content = response.content.decode()
assert 'method="post"' in content
assert token in content
def test_invalid_action_returns_400(self):
token = _make_token()
@@ -251,12 +284,6 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
request = self.factory.get(
"/rsvp/", {"token": "bad-token", "action": "accepted"}
@@ -264,8 +291,46 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPProcessView(TestCase):
"""Tests for the RSVPProcessView (POST handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
# RSVP view looks up organizer from DB
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
def test_invalid_action_returns_400(self):
token = _make_token()
response = self._post(token, "invalid")
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.post("/api/v1.0/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
response = self._post("bad-token", "accepted")
assert response.status_code == 400
def test_missing_token_returns_400(self):
request = self.factory.get("/rsvp/", {"action": "accepted"})
request = self.factory.post("/api/v1.0/rsvp/", {"action": "accepted"})
response = self.view(request)
assert response.status_code == 400
@@ -275,33 +340,37 @@ class TestRSVPView(TestCase):
"""Full accept flow: find event, update partstat, put back."""
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-123"',
)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 200
assert "accepted the invitation" in response.content.decode()
# Verify CalDAV calls
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
# Check the updated data contains ACCEPTED
put_args = mock_put.call_args
assert "PARTSTAT=ACCEPTED" in put_args[0][2]
# Check ETag is passed
assert put_args[1]["etag"] == '"etag-123"'
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_decline_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "declined"})
response = self.view(request)
response = self._post(token, "declined")
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -311,12 +380,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_tentative_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"})
response = self.view(request)
response = self._post(token, "tentative")
assert response.status_code == 200
content = response.content.decode()
@@ -326,11 +394,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_event_not_found_returns_400(self, mock_find):
mock_find.return_value = (None, None)
mock_find.return_value = (None, None, None)
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not found" in response.content.decode().lower()
@@ -338,12 +405,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_put_failure_returns_400(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = False
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "error occurred" in response.content.decode().lower()
@@ -351,12 +417,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_attendee_not_in_event_returns_400(self, mock_find):
"""If the attendee email is not in the event, return error."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
# Token with an email that's not in the event
token = _make_token(email="stranger@example.com")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not listed" in response.content.decode().lower()
@@ -364,11 +429,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_past_event_returns_400(self, mock_find):
"""Cannot RSVP to an event that has already ended."""
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
token = _make_token(uid="test-uid-past")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
@@ -430,28 +494,29 @@ class TestItipSetting:
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
CALDAV_INBOUND_API_KEY="test-inbound-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
class TestRSVPEndToEndFlow(TestCase):
"""
Integration test: scheduling callback sends email extract RSVP links
follow link verify event is updated.
This tests the full flow from CalDAV scheduling callback to RSVP response,
using Django's in-memory email backend to intercept sent emails.
Integration test: scheduling callback sends email -> extract RSVP links
-> follow link (GET confirm -> POST process) -> verify event is updated.
"""
def setUp(self):
self.factory = RequestFactory()
self.rsvp_view = RSVPView.as_view()
self.confirm_view = RSVPConfirmView.as_view()
self.process_view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def test_email_to_rsvp_accept_flow(self):
"""
1. CalDAV scheduling callback sends an invitation email
2. Extract RSVP accept link from the email HTML
3. Follow the RSVP link
4. Verify the event PARTSTAT is updated to ACCEPTED
3. GET the RSVP link (renders auto-submit form)
4. POST to process the RSVP
5. Verify the event PARTSTAT is updated to ACCEPTED
"""
# Step 1: Send invitation via the CalendarInvitationService
service = CalendarInvitationService()
@@ -488,22 +553,32 @@ class TestRSVPEndToEndFlow(TestCase):
assert "token" in params
assert params["action"] == ["accepted"]
# Step 4: Follow the RSVP link (mock CalDAV interactions)
# Step 3b: GET the confirm page
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.confirm_view(request)
assert response.status_code == 200
assert 'method="post"' in response.content.decode()
# Step 4: POST to process the RSVP (mock CalDAV interactions)
with (
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-abc"',
)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
# Step 5: Verify success
assert response.status_code == 200
@@ -511,7 +586,10 @@ class TestRSVPEndToEndFlow(TestCase):
assert "accepted the invitation" in content
# Verify CalDAV was called with the right data
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
put_data = mock_put.call_args[0][2]
assert "PARTSTAT=ACCEPTED" in put_data
@@ -542,14 +620,14 @@ class TestRSVPEndToEndFlow(TestCase):
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "declined"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -612,13 +690,134 @@ class TestRSVPEndToEndFlow(TestCase):
params = parse_qs(parsed.query)
# The event is in the past
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
def _make_recurring_ics(
uid="recurring-uid-1", summary="Weekly Standup", days_from_now=30
):
"""Build a recurring ICS string with an RRULE."""
dt = timezone.now() + timedelta(days=days_from_now)
dtstart = dt.strftime("%Y%m%dT%H%M%SZ")
dtend = (dt + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ")
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTART:{dtstart}\r\n"
f"DTEND:{dtend}\r\n"
f"SUMMARY:{summary}\r\n"
"RRULE:FREQ=WEEKLY;COUNT=52\r\n"
"ORGANIZER;CN=Alice:mailto:alice@example.com\r\n"
"ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n"
"SEQUENCE:0\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
RSVP_TOKEN_MAX_AGE_RECURRING=7776000, # 90 days
)
class TestRSVPRecurringTokenExpiry(TestCase):
"""Tests for RSVP token max_age enforcement on recurring events.
Tokens are signed with TimestampSigner so that the max_age check
works correctly for recurring events.
"""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_fresh_token_succeeds(self, mock_find, mock_put):
"""A fresh token for a recurring event should be accepted."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token(uid="recurring-uid-1")
response = self._post(token, "accepted")
assert response.status_code == 200
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_expired_token_rejected(self, mock_find):
"""An expired token for a recurring event should be rejected."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Simulate time passing beyond max_age by patching unsign_object
# to raise SignatureExpired on the second call (with max_age)
original_unsign = TimestampSigner.unsign_object
def side_effect(value, **kwargs):
if kwargs.get("max_age") is not None:
raise SignatureExpired("Signature age exceeds max_age")
return original_unsign(TimestampSigner(), value, **kwargs)
with patch.object(TimestampSigner, "unsign_object", side_effect=side_effect):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_token_expired_via_freeze_time(self, mock_find):
"""Token created now should be rejected after max_age seconds."""
from freezegun import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
freeze_time,
)
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Advance time beyond RSVP_TOKEN_MAX_AGE_RECURRING (90 days = 7776000s)
with freeze_time(timezone.now() + timedelta(days=91)):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_non_recurring_event_ignores_token_age(self, mock_find, mock_put):
"""Non-recurring events should not enforce token max_age."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
response = self._post(token, "accepted")
assert response.status_code == 200

View File

@@ -0,0 +1,205 @@
"""Tests for Django signals (CalDAV cleanup on user/org deletion)."""
import json
from unittest import mock
from django.test import TestCase, override_settings
import pytest
from core import factories
from core.models import Organization, User
# Signal tests need real signal handlers (they mock at the requests.request
# level), so they must be in the caldav xdist group to skip the conftest
# fixture that disconnects signals for non-CalDAV tests.
pytestmark = pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteUserCaldavData(TestCase):
"""Tests for the delete_user_caldav_data pre_delete signal."""
def test_deleting_user_calls_internal_api(self):
"""Deleting a user triggers a POST to the SabreDAV internal API."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
user.delete()
# Verify the internal API was called to clean up CalDAV data
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
assert call_kwargs.kwargs["method"] == "POST"
url = call_kwargs.kwargs.get("url", "")
assert "internal-api/users/delete" in url
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["email"] == "alice@example.com"
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-Internal-Api-Key") == "test-internal-key"
def test_deleting_user_without_email_skips_cleanup(self):
"""Users without an email don't trigger CalDAV cleanup."""
user = factories.UserFactory(email="")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_user_without_api_key_skips_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, cleanup is skipped."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
def test_deleting_user_handles_http_error_gracefully(self):
"""HTTP errors during cleanup don't prevent user deletion."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=Exception("Connection refused"),
):
# Should not raise — the signal catches exceptions
with self.captureOnCommitCallbacks(execute=True):
user.delete()
assert not User.objects.filter(email="alice@example.com").exists()
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteOrganizationCaldavData(TestCase):
"""Tests for the delete_organization_caldav_data pre_delete signal."""
def test_deleting_org_cleans_up_all_members(self):
"""Deleting an org triggers CalDAV cleanup for every member.
cleanup_organization_caldav_data calls DELETE for each member,
then members.delete() triggers the user pre_delete signal which
schedules on_commit callbacks. So we expect 2 calls from org
cleanup + 2 from user signals = 4 total.
"""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# 2 members x 2 POST calls each (org cleanup + user signal on_commit)
assert mock_request.call_count == 4
bodies = [
json.loads(call.kwargs.get("data", b"{}"))
for call in mock_request.call_args_list
]
emails = [b.get("email", "") for b in bodies]
assert "alice@example.com" in emails
assert "bob@example.com" in emails
def test_deleting_org_deletes_member_users(self):
"""Deleting an org also deletes member Django User objects."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
assert not User.objects.filter(email="alice@example.com").exists()
assert not User.objects.filter(email="bob@example.com").exists()
assert not Organization.objects.filter(external_id="doomed-org").exists()
def test_deleting_org_with_no_members(self):
"""Deleting an org with no members succeeds without errors."""
org = factories.OrganizationFactory(external_id="empty-org")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
org.delete()
mock_request.assert_not_called()
def test_deleting_org_continues_after_member_cleanup_failure(self):
"""If CalDAV cleanup fails for one member, other members still cleaned up."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
call_count = 0
def side_effect(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("Network error") # pylint: disable=broad-exception-raised
resp = mock.Mock()
resp.status_code = 200
return resp
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=side_effect,
):
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# Org cleanup: 2 calls (1 fails, 1 succeeds), then user signal: 2 more
assert call_count == 4
assert not Organization.objects.filter(external_id="doomed-org").exists()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_org_without_api_key_skips_caldav_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, CalDAV cleanup is skipped."""
org = factories.OrganizationFactory(external_id="org-nokey")
factories.UserFactory(email="alice@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
# Without the API key, the signal skips CalDAV cleanup but
# also doesn't delete members, so PROTECT FK blocks deletion.
try:
org.delete()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
pass
mock_request.assert_not_called()

View File

@@ -0,0 +1,422 @@
"""Tests for the task system: task functions, polling endpoint, and async dispatch."""
# pylint: disable=import-outside-toplevel
import uuid
from unittest.mock import MagicMock, patch
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.import_service import ImportResult
pytestmark = pytest.mark.django_db
# ---------------------------------------------------------------------------
# Test import_events_task function directly
# ---------------------------------------------------------------------------
class TestImportEventsTask:
"""Test the import_events_task function itself (via EagerBroker)."""
@patch("core.tasks.ICSImportService")
def test_task_returns_success_result(self, mock_service_cls):
"""Task should return a SUCCESS dict with import results."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service = mock_service_cls.return_value
mock_service.import_events.return_value = ImportResult(
total_events=3,
imported_count=3,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
ics_data = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
result = import_events_task(str(user.id), caldav_path, ics_data.hex())
assert result["status"] == "SUCCESS"
assert result["result"]["total_events"] == 3
assert result["result"]["imported_count"] == 3
assert result["error"] is None
mock_service.import_events.assert_called_once_with(user, caldav_path, ics_data)
@patch("core.tasks.ICSImportService")
def test_task_user_not_found(self, mock_service_cls):
"""Task should return FAILURE if user does not exist."""
from core.tasks import import_events_task # noqa: PLC0415
result = import_events_task(
str(uuid.uuid4()), # non-existent user
"/calendars/users/nobody@example.com/cal/",
b"dummy".hex(),
)
assert result["status"] == "FAILURE"
assert "User not found" in result["error"]
mock_service_cls.return_value.import_events.assert_not_called()
@patch("core.tasks.set_task_progress")
@patch("core.tasks.ICSImportService")
def test_task_reports_progress(self, mock_service_cls, mock_progress):
"""Task should call set_task_progress at 0%, 10%, and 100%."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
import_events_task(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
progress_values = [call.args[0] for call in mock_progress.call_args_list]
assert progress_values == [0, 10, 100]
@patch("core.tasks.ICSImportService")
def test_task_via_delay(self, mock_service_cls):
"""Calling .delay() should dispatch and return a Task with an id."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
task = import_events_task.delay(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
assert task.id is not None
assert isinstance(task.id, str)
# ---------------------------------------------------------------------------
# Test TaskDetailView polling endpoint
# ---------------------------------------------------------------------------
class TestTaskDetailView:
"""Test the /api/v1.0/tasks/<task_id>/ polling endpoint."""
TASK_URL = "/api/v1.0/tasks/{task_id}/"
def test_requires_authentication(self):
"""Unauthenticated requests should be rejected."""
client = APIClient()
response = client.get(self.TASK_URL.format(task_id="some-id"))
assert response.status_code == 401
def test_task_not_found(self):
"""Unknown task_id should return 404."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=str(uuid.uuid4())))
assert response.status_code == 404
assert response.json()["status"] == "FAILURE"
def test_task_forbidden_for_other_user(self):
"""Users cannot poll tasks they don't own."""
owner = factories.UserFactory()
other = factories.UserFactory()
# Simulate a tracked task owned by `owner`
task_id = str(uuid.uuid4())
import json # noqa: PLC0415
cache.set(
f"task_tracking:{task_id}",
json.dumps(
{
"owner": str(owner.id),
"actor_name": "import_events_task",
"queue_name": "import",
}
),
)
client = APIClient()
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task_id))
assert response.status_code == 403
@patch("core.tasks.ICSImportService")
def test_poll_completed_task(self, mock_service_cls):
"""Polling a completed task should return SUCCESS with results."""
from core.tasks import import_events_task # noqa: PLC0415
expected_result = ImportResult(
total_events=5,
imported_count=4,
duplicate_count=1,
skipped_count=0,
errors=[],
)
mock_service_cls.return_value.import_events.return_value = expected_result
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
# Dispatch via .delay() — EagerBroker runs it synchronously
task = import_events_task.delay(str(user.id), caldav_path, b"data".hex())
task.track_owner(user.id)
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
data = response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 5
assert data["result"]["imported_count"] == 4
assert data["error"] is None
@patch("core.tasks.ICSImportService")
def test_poll_task_owner_matches(self, mock_service_cls):
"""Only the task owner can poll the task."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
owner = factories.UserFactory()
other = factories.UserFactory()
caldav_path = f"/calendars/users/{owner.email}/{uuid.uuid4()}/"
task = import_events_task.delay(str(owner.id), caldav_path, b"data".hex())
task.track_owner(owner.id)
client = APIClient()
# Other user gets 403
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 403
# Owner gets 200
client.force_login(owner)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
# ---------------------------------------------------------------------------
# Test API → task dispatch integration
# ---------------------------------------------------------------------------
class TestImportAPITaskDispatch:
"""Test that the import API correctly dispatches a task."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
@patch("core.tasks.import_events_task")
def test_api_calls_delay_with_correct_args(self, mock_task):
"""The API should call .delay() with user_id, caldav_path, ics_hex."""
mock_message = MagicMock()
mock_message.id = str(uuid.uuid4())
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch@example.com")
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
assert response.json()["task_id"] == mock_message.id
mock_task.delay.assert_called_once()
call_args = mock_task.delay.call_args.args
assert call_args[0] == str(user.id)
assert call_args[1] == caldav_path
# Third arg is ics_data as hex
assert bytes.fromhex(call_args[2]) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
@patch("core.tasks.import_events_task")
def test_api_returns_202_with_task_id(self, mock_task):
"""Successful dispatch should return HTTP 202 with task_id."""
mock_message = MagicMock()
mock_message.id = "test-task-id-123"
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch202@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
data = response.json()
assert data["task_id"] == "test-task-id-123"
# ---------------------------------------------------------------------------
# Full round-trip: API dispatch → EagerBroker → poll result
# ---------------------------------------------------------------------------
class TestImportFullRoundTrip:
"""Full integration: POST import → task runs (EagerBroker) → poll result."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
TASK_URL = "/api/v1.0/tasks/{task_id}/"
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip(self, mock_import):
"""POST import → EagerBroker runs task → poll returns SUCCESS."""
mock_import.return_value = ImportResult(
total_events=2,
imported_count=2,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory(email="roundtrip@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
# Step 1: POST triggers task dispatch
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
# Step 2: Poll for result
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 2
assert data["result"]["imported_count"] == 2
assert data["error"] is None
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip_with_errors(self, mock_import):
"""Task that returns partial failure should surface errors via poll."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=1,
duplicate_count=0,
skipped_count=2,
errors=["Event A", "Event B"],
)
user = factories.UserFactory(email="roundtrip-err@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["skipped_count"] == 2
assert data["result"]["errors"] == ["Event A", "Event B"]

View File

@@ -8,19 +8,18 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
from core.api.viewsets_channels import ChannelViewSet
from core.api.viewsets_ical import ICalExportView
from core.api.viewsets_rsvp import RSVPView
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.api.viewsets_task import TaskDetailView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
router.register(
"subscription-tokens",
viewsets.SubscriptionTokenViewSet,
basename="subscription-tokens",
)
router.register("resources", viewsets.ResourceViewSet, basename="resources")
router.register("channels", ChannelViewSet, basename="channels")
urlpatterns = [
path(
@@ -29,35 +28,46 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
# CalDAV proxy - root path (must come before catch-all to match /caldav exactly)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
# CalDAV proxy - catch all paths with content
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# CalDAV scheduling callback endpoint (separate from caldav proxy)
# CalDAV scheduling callback endpoint
path(
"caldav-scheduling-callback/",
CalDAVSchedulingCallbackView.as_view(),
name="caldav-scheduling-callback",
),
# RSVP POST endpoint (state-changing, with DRF throttling)
path(
"rsvp/",
RSVPProcessView.as_view(),
name="rsvp-process",
),
# Task status polling endpoint
path(
"tasks/<str:task_id>/",
TaskDetailView.as_view(),
name="task-detail",
),
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
# CalDAV proxy - top-level stable path (not versioned)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# Public iCal export endpoint (no authentication required)
# Token in URL acts as authentication
path(
"ical/<uuid:token>.ics",
# base64url channel ID for lookup, base64url token for auth, filename cosmetic
re_path(
r"^ical/(?P<short_id>[A-Za-z0-9_-]+)/(?P<token>[A-Za-z0-9_-]+)/[^/]+\.ics$",
ICalExportView.as_view(),
name="ical-export",
),
# RSVP endpoint (no authentication required)
# RSVP GET endpoint (renders auto-submitting confirmation page)
# Signed token in query string acts as authentication
path("rsvp/", RSVPView.as_view(), name="rsvp"),
path("rsvp/", RSVPConfirmView.as_view(), name="rsvp"),
]

View File

@@ -27,11 +27,15 @@ class UserAuthViewSet(drf.viewsets.ViewSet):
serializer.is_valid(raise_exception=True)
# Create user if doesn't exist
user = models.User.objects.filter(
email = serializer.validated_data["email"]
).first()
user = models.User.objects.filter(email=email).first()
if not user:
user = models.User(email=serializer.validated_data["email"])
domain = email.split("@")[-1] if "@" in email else "e2e"
org, _ = models.Organization.objects.get_or_create(
external_id=domain,
defaults={"name": domain},
)
user = models.User(email=email, organization=org)
user.set_unusable_password()
user.save()

View File

@@ -1,455 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+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-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favorit"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lesen"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:74
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:80
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr "Erstellt"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:129
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
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:185
msgid "sub"
msgstr "unter"
#: core/models.py:187
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:196
msgid "full name"
msgstr "Name"
#: core/models.py:197
msgid "short name"
msgstr "Kurzbezeichnung"
#: core/models.py:199
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:204
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: core/models.py:211
msgid "language"
msgstr "Sprache"
#: core/models.py:212
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:218
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:221
msgid "device"
msgstr "Gerät"
#: core/models.py:223
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:226
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: core/models.py:228
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:231
msgid "active"
msgstr "aktiviert"
#: core/models.py:234
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:246
msgid "user"
msgstr "Benutzer"
#: core/models.py:247
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
#, fuzzy
#| msgid "This team is already in this item."
msgid "title already exists in this folder."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:504
msgid "title"
msgstr "Titel"
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
#, fuzzy
#| msgid "items"
msgid "Items"
msgstr "Dokumente"
#: core/models.py:815
#, 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:817
#, 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:820
#, 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:872
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is already hard deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
#, 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:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
#, fuzzy
#| msgid "item/user link trace"
msgid "Item/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1022
#, fuzzy
#| msgid "item/user link traces"
msgid "Item/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
#, fuzzy
#| msgid "item favorite"
msgid "Item favorite"
msgstr "Dokumentenfavorit"
#: core/models.py:1052
#, fuzzy
#| msgid "item favorites"
msgid "Item favorites"
msgstr "Dokumentfavoriten"
#: core/models.py:1058
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:1080
#, fuzzy
#| msgid "item/user relation"
msgid "Item/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:1081
#, fuzzy
#| msgid "item/user relations"
msgid "Item/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:1126
msgid "email address"
msgstr "E-Mail-Adresse"
#: core/models.py:1145
#, fuzzy
#| msgid "item invitation"
msgid "Item invitation"
msgstr "Einladung zum Dokument"
#: core/models.py:1146
#, fuzzy
#| msgid "item invitations"
msgid "Item invitations"
msgstr "Dokumenteinladungen"
#: 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 ""
" Calendars, 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 ""
#: calendars/settings.py:250
msgid "English"
msgstr "Englisch"
#: calendars/settings.py:251
msgid "French"
msgstr "Französisch"
#: calendars/settings.py:252
msgid "German"
msgstr "Deutsch"
#~ msgid "Invalid response format or token verification failed"
#~ msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#~ msgid "User account is disabled"
#~ msgstr "Benutzerkonto ist deaktiviert"
#, fuzzy
#~| msgid "Untitled item"
#~ msgid "Untitled Item"
#~ msgstr "Unbenanntes Dokument"
#~ msgid "This email is already associated to a registered user."
#~ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#~ 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

@@ -1,362 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+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-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr ""
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr ""
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr ""
#: core/models.py:74
msgid "Restricted"
msgstr ""
#: core/models.py:78
msgid "Authenticated"
msgstr ""
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr ""
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr ""
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
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 ""
" Calendars, 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 ""
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
msgid "German"
msgstr ""

View File

@@ -1,371 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+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-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:62
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:63
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:74
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} a partagé un item avec vous!"
#: core/models.py:817
#, 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:820
#, 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:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
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 "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
"Fichiers, votre outil essentiel pour organiser, partager et collaborer en "
"équipe."
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
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

@@ -1,369 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+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-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke gegevens"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Machtigingen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke data"
#: core/admin.py:129
msgid "Tree structure"
msgstr "Boomstructuur"
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ik ben eigenaar"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favoriet"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr "Er bestaat al een item met deze titel in het huidige pad."
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr "Dit veld is verplicht voor bestanden"
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr "Dit veld is verplicht voor mappen."
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lezer"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Redacteur"
#: core/models.py:62
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:63
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:74
msgid "Restricted"
msgstr "Beperkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Ingelogd"
#: core/models.py:80
msgid "Public"
msgstr "Openbaar"
#: core/models.py:86
msgid "Folder"
msgstr "Map"
#: core/models.py:87
msgid "File"
msgstr "Bestand"
#: core/models.py:93
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:94
msgid "Uploaded"
msgstr "Geüpload"
#: core/models.py:116
msgid "id"
msgstr "id"
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:123
msgid "created on"
msgstr "gecreëerd op"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record is gemaakt"
#: core/models.py:129
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr "We konden geen gebruiker vinden met deze id, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/: "
"tekens bevatten."
#: core/models.py:185
msgid "sub"
msgstr "id"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr "Verplicht. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_/: tekens."
#: core/models.py:196
msgid "full name"
msgstr "volledige naam"
#: core/models.py:197
msgid "short name"
msgstr "gebruikersnaam"
#: core/models.py:199
msgid "identity email address"
msgstr "identiteits e-mailadres"
#: core/models.py:204
msgid "admin email address"
msgstr "admin e-mailadres"
#: core/models.py:211
msgid "language"
msgstr "taal"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Tijdzone waarin de gebruiker de tijd wil zien."
#: core/models.py:221
msgid "device"
msgstr "apparaat"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
#: core/models.py:226
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheer site."
#: core/models.py:231
msgid "active"
msgstr "actief"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in plaats van "
"accounts te verwijderen."
#: core/models.py:246
msgid "user"
msgstr "gebruiker"
#: core/models.py:247
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
msgid "Workspace"
msgstr "Werkruimte"
#: core/models.py:457
msgid "Only folders can have children."
msgstr "Alleen mappen kunnen subitems hebben."
#: core/models.py:470
msgid "title already exists in this folder."
msgstr "titel bestaat al in deze map."
#: core/models.py:504
msgid "title"
msgstr "titel"
#: core/models.py:549
msgid "Item"
msgstr "Item"
#: core/models.py:550
msgid "Items"
msgstr "Items"
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} heeft een item met je gedeeld!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} heeft je uitgenodigd met de rol \"{role}\" voor het volgende item:"
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr "{name} heeft een item met je gedeeld: {title}"
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr "Dit item is al permanent verwijderd."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr "Om een item permanent te verwijderen, moet het eerst tijdelijk worden verwijderd."
#: core/models.py:902
msgid "This item is not deleted."
msgstr "Dit item is niet verwijderd."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr "Dit item is permanent verwijderd en kan niet worden hersteld."
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr "Alleen mappen kunnen worden geselecteerd bij het verplaatsen van een item."
#: core/models.py:1021
msgid "Item/user link trace"
msgstr "Item/gebruiker link "
#: core/models.py:1022
msgid "Item/user link traces"
msgstr "Item/gebruiker link"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr "Er bestaat al een link trace voor dit item/gebruiker."
#: core/models.py:1051
msgid "Item favorite"
msgstr "Item favoriet"
#: core/models.py:1052
msgid "Item favorites"
msgstr "Item favorieten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr "Dit item is al het doel van een favorietrelatie voor dezelfde "
"gebruiker."
#: core/models.py:1080
msgid "Item/user relation"
msgstr "Item/gebruiker relatie"
#: core/models.py:1081
msgid "Item/user relations"
msgstr "Item/gebruiker relaties"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Deze gebruiker bestaat al in dit item."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dit team bestaat al in dit item."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:1126
msgid "email address"
msgstr "e-mailadres"
#: core/models.py:1145
msgid "Item invitation"
msgstr "Item uitnodiging"
#: core/models.py:1146
msgid "Item invitations"
msgstr "Item uitnodigingen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr " Calendars, jouw nieuwe essentiële tool voor het organiseren, delen en samenwerken als team"
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Aangeboden door %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr "Engels"
#: calendars/settings.py:251
msgid "French"
msgstr "Frans"
#: calendars/settings.py:252
msgid "German"
msgstr "Duits"
#: calendars/settings.py:253
msgid "Dutch"
msgstr "Nederlands"

View File

@@ -28,10 +28,11 @@ dependencies = [
"Brotli==1.2.0",
"dj-database-url==3.0.1",
"caldav==2.2.3",
"celery[redis]==5.6.0",
"dramatiq[redis]==1.17.1",
"django==5.2.9",
"django-celery-beat==2.8.1",
"django-dramatiq==0.12.0",
"django-configurations==2.5.1",
"django-fernet-encrypted-fields>=0.2",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
@@ -138,6 +139,9 @@ addopts = [
"term-missing",
# Allow test files to have the same name in different directories.
"--import-mode=importlib",
# Group CalDAV E2E tests on a single worker to avoid concurrent
# access issues with the shared SabreDAV server.
"--dist=loadgroup",
]
python_files = [
"test_*.py",

110
src/backend/uv.lock generated
View File

@@ -101,13 +101,13 @@ source = { editable = "." }
dependencies = [
{ name = "brotli" },
{ name = "caldav" },
{ name = "celery", extra = ["redis"] },
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-celery-beat" },
{ name = "django-configurations" },
{ name = "django-cors-headers" },
{ name = "django-countries" },
{ name = "django-dramatiq" },
{ name = "django-fernet-encrypted-fields" },
{ name = "django-filter" },
{ name = "django-lasuite", extra = ["all"] },
{ name = "django-parler" },
@@ -115,6 +115,7 @@ dependencies = [
{ name = "django-timezone-field" },
{ name = "djangorestframework" },
{ name = "djangorestframework-api-key" },
{ name = "dramatiq", extra = ["redis"] },
{ name = "drf-spectacular" },
{ name = "drf-standardized-errors" },
{ name = "factory-boy" },
@@ -157,15 +158,15 @@ dev = [
requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "caldav", specifier = "==2.2.3" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.0" },
{ name = "dj-database-url", specifier = "==3.0.1" },
{ name = "django", specifier = "==5.2.9" },
{ name = "django-celery-beat", specifier = "==2.8.1" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
{ name = "django-debug-toolbar", marker = "extra == 'dev'", specifier = "==6.1.0" },
{ name = "django-dramatiq", specifier = "==0.12.0" },
{ name = "django-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.2" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.21" },
{ name = "django-parler", specifier = "==2.3" },
@@ -173,6 +174,7 @@ requires-dist = [
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-api-key", specifier = "==3.1.0" },
{ name = "dramatiq", extras = ["redis"], specifier = "==1.17.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2025.12.1" },
{ name = "drf-standardized-errors", specifier = "==0.15.0" },
@@ -227,11 +229,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" },
]
[package.optional-dependencies]
redis = [
{ name = "kombu", extra = ["redis"] },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -382,18 +379,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[[package]]
name = "cron-descriptor"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -479,23 +464,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
]
[[package]]
name = "django-celery-beat"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
{ name = "cron-descriptor" },
{ name = "django" },
{ name = "django-timezone-field" },
{ name = "python-crontab" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
]
[[package]]
name = "django-configurations"
version = "2.5.1"
@@ -547,6 +515,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" },
]
[[package]]
name = "django-dramatiq"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "dramatiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/32/cd8b1394be24a5a1c0fb213c3ee3e575414159d03f3d09c3699b09162dc6/django_dramatiq-0.12.0.tar.gz", hash = "sha256:d4f4a6ecccb104b10b2b743052a703b7749cd671d492a0f6f2a7e13e846923a8", size = 14262, upload-time = "2024-12-29T12:46:56.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/26/bc518333e9c62bc9c6c11994342b07933537adcb2dbdf6b4fb7ac0a9d88a/django_dramatiq-0.12.0-py3-none-any.whl", hash = "sha256:f9b8fff9510e7e780e993fcd3b8fd31c503c3caa09a535cbead284fb4b636262", size = 12082, upload-time = "2024-12-29T12:46:55.093Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
@@ -559,6 +540,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "django-fernet-encrypted-fields"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
@@ -664,6 +658,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "dramatiq"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/7a/6792ddc64a77d22bfd97261b751a7a76cf2f9d62edc59aafb679ac48b77d/dramatiq-1.17.1.tar.gz", hash = "sha256:2675d2f57e0d82db3a7d2a60f1f9c536365349db78c7f8d80a63e4c54697647a", size = 99071, upload-time = "2024-10-26T05:09:28.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382, upload-time = "2024-10-26T05:09:26.436Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "drf-spectacular"
version = "0.29.0"
@@ -1024,11 +1035,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/d6/943cf84117cd9ddecf6e1707a3f712a49fc64abdb8ac31b19132871af1dd/kombu-5.6.1-py3-none-any.whl", hash = "sha256:b69e3f5527ec32fc5196028a36376501682973e9620d6175d1c3d4eaf7e95409", size = 214141, upload-time = "2025-11-25T11:07:31.54Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "lxml"
version = "6.0.2"
@@ -1201,6 +1207,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -1414,15 +1429,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"

204
src/backend/worker.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python
# pylint: disable=import-outside-toplevel
"""
Background task worker with sensible queue defaults.
Usage:
python worker.py # Process all queues
python worker.py --queues=import,default # Process only specific queues
python worker.py --exclude=sync # Process all except sync
python worker.py --concurrency=4 # Set worker concurrency
python worker.py -v 2 # Verbose logging
Queue priority order (highest to lowest):
1. default - General / high-priority tasks
2. import - File import processing
3. sync - Background sync tasks
"""
import argparse
import logging
import multiprocessing
import os
import sys
# Workaround for Dramatiq + Python 3.14: forkserver (the new default) breaks
# Dramatiq's Canteen shared-memory mechanism, causing worker processes to never
# consume messages. See https://github.com/Bogdanp/dramatiq/issues/701
# Must be set before dramatiq.cli.main() spawns worker processes.
multiprocessing.set_start_method("fork", force=True)
# Setup Django before importing the task runner
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
# Override $APP if set by the host (e.g. Scalingo)
os.environ.pop("APP", None)
from configurations.importer import install # pylint: disable=wrong-import-position
install(check_options=True)
import django # pylint: disable=wrong-import-position
django.setup()
# Queue definitions in priority order
ALL_QUEUES = ["default", "import", "sync"]
DEFAULT_QUEUES = ALL_QUEUES
def get_default_concurrency():
"""Get default concurrency from environment variables."""
env_value = os.environ.get("WORKER_CONCURRENCY")
if env_value:
try:
return int(env_value)
except ValueError:
return None
return None
def discover_tasks_modules():
"""Discover task modules the same way django_dramatiq does."""
import importlib # noqa: PLC0415 # pylint: disable=wrong-import-position
from django.apps import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
apps,
)
from django.conf import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
settings,
)
from django.utils.module_loading import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
module_has_submodule,
)
task_module_names = settings.DRAMATIQ_AUTODISCOVER_MODULES
modules = ["django_dramatiq.setup"]
for conf in apps.get_app_configs():
if conf.name == "django_dramatiq":
module = conf.name + ".tasks"
importlib.import_module(module)
logging.getLogger(__name__).info("Discovered tasks module: %r", module)
modules.append(module)
else:
for task_module in task_module_names:
if module_has_submodule(conf.module, task_module):
module = conf.name + "." + task_module
importlib.import_module(module)
logging.getLogger(__name__).info(
"Discovered tasks module: %r", module
)
modules.append(module)
return modules
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Start a background task worker.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--queues",
"-Q",
type=str,
default=None,
help=(
"Comma-separated list of queues to process. "
f"Default: {','.join(DEFAULT_QUEUES)}"
),
)
parser.add_argument(
"--exclude",
"-X",
type=str,
default=None,
help="Comma-separated list of queues to exclude.",
)
parser.add_argument(
"--concurrency",
"-c",
type=int,
default=get_default_concurrency(),
help="Number of worker processes. Default: WORKER_CONCURRENCY env var.",
)
parser.add_argument(
"--verbosity",
"-v",
type=int,
default=1,
help="Verbosity level (0=minimal, 1=normal, 2=verbose). Default: 1",
)
return parser.parse_args()
def main():
"""Start the background task worker."""
logger = logging.getLogger(__name__)
args = parse_args()
# Determine which queues to process
if args.queues:
queues = [q.strip() for q in args.queues.split(",")]
invalid = set(queues) - set(ALL_QUEUES)
if invalid:
sys.stderr.write(f"Error: Unknown queues: {', '.join(invalid)}\n")
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
else:
queues = DEFAULT_QUEUES.copy()
# Apply exclusions
if args.exclude:
exclude = [q.strip() for q in args.exclude.split(",")]
invalid_exclude = set(exclude) - set(ALL_QUEUES)
if invalid_exclude:
sys.stderr.write(
f"Error: Unknown queues to exclude: {', '.join(invalid_exclude)}\n"
)
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
queues = [q for q in queues if q not in exclude]
if not queues:
sys.stderr.write("Error: No queues to process after exclusions.\n")
sys.exit(1)
# Discover task modules
tasks_modules = discover_tasks_modules()
# Build dramatiq CLI arguments and call main() directly.
# This avoids rundramatiq's os.execvp which replaces the process and
# discards our multiprocessing.set_start_method("fork") workaround.
dramatiq_args = [
"dramatiq",
"--path",
".",
"--processes",
str(args.concurrency or 4),
"--threads",
"1",
"--worker-shutdown-timeout",
"600000",
]
if args.verbosity > 1:
dramatiq_args.append("-v")
dramatiq_args.extend(tasks_modules)
dramatiq_args.extend(["--queues", *queues])
logger.info("Starting worker with queues: %s", ", ".join(queues))
import dramatiq.cli # noqa: PLC0415 # pylint: disable=wrong-import-position
sys.argv = dramatiq_args
dramatiq.cli.main()
if __name__ == "__main__":
main()

View File

@@ -10,7 +10,7 @@
],
"require": {
"php": ">=8.1",
"sabre/dav": "dev-master",
"sabre/dav": "dev-master#1000fc028469c240fe13459e36648959f1519d09",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},

View File

@@ -47,7 +47,7 @@ SQL_DIR="${SQL_DIR:-/var/www/sabredav/sql}"
TABLES_EXIST=$(psql -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('users', 'principals', 'calendars')" 2>/dev/null || echo "0")
if [ "$TABLES_EXIST" -gt "0" ]; then
echo "sabre/dav tables already exist, skipping initialization"
echo "sabre/dav tables already exist, skipping table creation"
exit 0
fi

View File

@@ -5,7 +5,7 @@ pid = /tmp/php-fpm.pid
[www]
listen = /tmp/php-fpm.sock
listen.mode = 0666
listen.mode = 0660
; When running as non-root, user/group settings are ignored
user = www-data

View File

@@ -14,7 +14,12 @@ use Calendars\SabreDav\HttpCallbackIMipPlugin;
use Calendars\SabreDav\ApiKeyAuthBackend;
use Calendars\SabreDav\CalendarSanitizerPlugin;
use Calendars\SabreDav\AttendeeNormalizerPlugin;
use Calendars\SabreDav\ICSImportPlugin;
use Calendars\SabreDav\InternalApiPlugin;
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
use Calendars\SabreDav\CalendarsRoot;
use Calendars\SabreDav\CustomCalDAVPlugin;
use Calendars\SabreDav\PrincipalsRoot;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
@@ -63,9 +68,11 @@ $carddavBackend = new CardDAV\Backend\PDO($pdo);
$principalBackend = new AutoCreatePrincipalBackend($pdo);
// Create directory tree
// Principal collections: principals/users/ and principals/resources/
// Calendar collections: calendars/users/ and calendars/resources/
$nodes = [
new CalDAV\Principal\Collection($principalBackend),
new CalDAV\CalendarRoot($principalBackend, $caldavBackend),
new PrincipalsRoot($principalBackend),
new CalendarsRoot($principalBackend, $caldavBackend),
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
];
@@ -73,9 +80,13 @@ $nodes = [
$server = new DAV\Server($nodes);
$server->setBaseUri($baseUri);
// Give the principal backend a reference to the server
// so it can read X-CalDAV-Organization from the HTTP request
$principalBackend->setServer($server);
// Add plugins
$server->addPlugin($authPlugin);
$server->addPlugin(new CalDAV\Plugin());
$server->addPlugin(new CustomCalDAVPlugin());
$server->addPlugin(new CardDAV\Plugin());
$server->addPlugin(new DAVACL\Plugin());
$server->addPlugin(new DAV\Browser\Plugin());
@@ -137,9 +148,10 @@ $server->addPlugin(new CalendarSanitizerPlugin(
// when processing calendar objects, fixing issues with REPLY handling
$server->addPlugin(new AttendeeNormalizerPlugin());
// Add ICS import plugin for bulk event import from a single POST request
// Only accessible via the X-Calendars-Import header (backend-only)
$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey));
// Add internal API plugin for resource provisioning and ICS import
// Gated by X-Internal-Api-Key header (separate from X-Api-Key used by proxy)
$internalApiKey = getenv('CALDAV_INTERNAL_API_KEY') ?: $apiKey;
$server->addPlugin(new InternalApiPlugin($pdo, $caldavBackend, $internalApiKey));
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
@@ -164,7 +176,17 @@ $server->addPlugin($imipPlugin);
$schedulePlugin = new CalDAV\Schedule\Plugin();
$server->addPlugin($schedulePlugin);
// error_log("[sabre/dav] Starting server");
// Add resource auto-scheduling plugin
// Handles automatic accept/decline for resource principals based on availability
$server->addPlugin(new ResourceAutoSchedulePlugin($pdo, $caldavBackend));
// Block MKCALENDAR on resource principals (each resource has exactly one calendar)
$server->addPlugin(new ResourceMkCalendarBlockPlugin());
// Add property storage plugin for custom properties (resource metadata, etc.)
$server->addPlugin(new DAV\PropertyStorage\Plugin(
new DAV\PropertyStorage\Backend\PDO($pdo)
));
// Start server
$server->start();

View File

@@ -18,7 +18,7 @@ CREATE TABLE cards (
addressbookid INTEGER NOT NULL,
carddata BYTEA,
uri VARCHAR(200),
lastmodified INTEGER,
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL
);

View File

@@ -3,12 +3,12 @@ CREATE TABLE calendarobjects (
calendardata BYTEA,
uri VARCHAR(200),
calendarid INTEGER NOT NULL,
lastmodified INTEGER,
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL,
componenttype VARCHAR(8),
firstoccurence INTEGER,
lastoccurence INTEGER,
firstoccurence BIGINT,
lastoccurence BIGINT,
uid VARCHAR(200)
);
@@ -32,17 +32,17 @@ ALTER TABLE ONLY calendars
CREATE TABLE calendarinstances (
id SERIAL NOT NULL,
calendarid INTEGER NOT NULL,
principaluri VARCHAR(100),
principaluri VARCHAR(255),
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
displayname VARCHAR(100),
displayname VARCHAR(255),
uri VARCHAR(200),
description TEXT,
calendarorder INTEGER NOT NULL DEFAULT 0,
calendarcolor VARCHAR(10),
timezone TEXT,
transparent SMALLINT NOT NULL DEFAULT '0',
share_href VARCHAR(100),
share_displayname VARCHAR(100),
share_href VARCHAR(255),
share_displayname VARCHAR(255),
share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid'
);
@@ -65,16 +65,16 @@ CREATE INDEX calendarinstances_principaluri_share_href
CREATE TABLE calendarsubscriptions (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
principaluri VARCHAR(100) NOT NULL,
principaluri VARCHAR(255) NOT NULL,
source TEXT,
displayname VARCHAR(100),
displayname VARCHAR(255),
refreshrate VARCHAR(10),
calendarorder INTEGER NOT NULL DEFAULT 0,
calendarcolor VARCHAR(10),
striptodos SMALLINT NULL,
stripalarms SMALLINT NULL,
stripattachments SMALLINT NULL,
lastmodified INTEGER
lastmodified BIGINT
);
ALTER TABLE ONLY calendarsubscriptions
@@ -102,10 +102,16 @@ CREATE TABLE schedulingobjects (
principaluri VARCHAR(255),
calendardata BYTEA,
uri VARCHAR(200),
lastmodified INTEGER,
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL
);
ALTER TABLE ONLY schedulingobjects
ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX schedulingobjects_ukey
ON schedulingobjects USING btree (principaluri, uri);
CREATE INDEX schedulingobjects_principaluri_ix
ON schedulingobjects USING btree (principaluri);

View File

@@ -1,8 +1,8 @@
CREATE TABLE locks (
id SERIAL NOT NULL,
owner VARCHAR(100),
timeout INTEGER,
created INTEGER,
timeout BIGINT,
created BIGINT,
token VARCHAR(100),
scope SMALLINT,
depth SMALLINT,

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