✨(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:
12
.github/workflows/calendars-frontend.yml
vendored
12
.github/workflows/calendars-frontend.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22.x"
|
node-version: "24.x"
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
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
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: cd src/frontend/ && yarn lint
|
run: cd src/frontend/ && npm run lint
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -45,16 +45,16 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22.x"
|
node-version: "24.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
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
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: |
|
run: |
|
||||||
cd src/frontend/apps/calendars
|
cd src/frontend/apps/calendars
|
||||||
TZ=Europe/Paris yarn test
|
TZ=Europe/Paris npm test
|
||||||
|
|||||||
13
.github/workflows/calendars.yml
vendored
13
.github/workflows/calendars.yml
vendored
@@ -65,6 +65,7 @@ jobs:
|
|||||||
CALDAV_URL: http://localhost:80
|
CALDAV_URL: http://localhost:80
|
||||||
CALDAV_OUTBOUND_API_KEY: test-outbound-key
|
CALDAV_OUTBOUND_API_KEY: test-outbound-key
|
||||||
CALDAV_INBOUND_API_KEY: test-inbound-key
|
CALDAV_INBOUND_API_KEY: test-inbound-key
|
||||||
|
CALDAV_INTERNAL_API_KEY: test-internal-key
|
||||||
CALDAV_CALLBACK_HOST: localhost
|
CALDAV_CALLBACK_HOST: localhost
|
||||||
TRANSLATIONS_JSON_PATH: ${{ github.workspace }}/src/frontend/apps/calendars/src/features/i18n/translations.json
|
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
|
- name: Build and start CalDAV server
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: |
|
run: |
|
||||||
docker build -t caldav-test docker/sabredav
|
docker build -t caldav-test src/caldav
|
||||||
docker run -d --name caldav-test \
|
docker run -d --name caldav-test \
|
||||||
--network host \
|
--network host \
|
||||||
-e PGHOST=localhost \
|
-e PGHOST=localhost \
|
||||||
@@ -88,9 +89,10 @@ jobs:
|
|||||||
-e PGDATABASE=calendars \
|
-e PGDATABASE=calendars \
|
||||||
-e PGUSER=pgroot \
|
-e PGUSER=pgroot \
|
||||||
-e PGPASSWORD=pass \
|
-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_INBOUND_API_KEY=test-inbound-key \
|
||||||
-e CALDAV_OUTBOUND_API_KEY=test-outbound-key \
|
-e CALDAV_OUTBOUND_API_KEY=test-outbound-key \
|
||||||
|
-e CALDAV_INTERNAL_API_KEY=test-internal-key \
|
||||||
caldav-test \
|
caldav-test \
|
||||||
sh -c "/usr/local/bin/init-database.sh && apache2-foreground"
|
sh -c "/usr/local/bin/init-database.sh && apache2-foreground"
|
||||||
|
|
||||||
@@ -108,13 +110,10 @@ jobs:
|
|||||||
- name: Install the dependencies
|
- name: Install the dependencies
|
||||||
run: uv sync --locked --all-extras
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages) and MIME support
|
- name: Install MIME support
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y gettext pandoc shared-mime-info media-types
|
sudo apt-get install -y pandoc shared-mime-info media-types
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
|
||||||
run: uv run python manage.py compilemessages
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|||||||
74
.github/workflows/crowdin_download.yml
vendored
74
.github/workflows/crowdin_download.yml
vendored
@@ -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
|
|
||||||
68
.github/workflows/crowdin_upload.yml
vendored
68
.github/workflows/crowdin_upload.yml
vendored
@@ -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/"
|
|
||||||
11
.github/workflows/docker-hub.yml
vendored
11
.github/workflows/docker-hub.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
context: ./src/backend
|
context: ./src/backend
|
||||||
target: backend-production
|
target: backend-production
|
||||||
platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
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' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/frontend
|
context: ./src/frontend
|
||||||
@@ -90,19 +90,18 @@ jobs:
|
|||||||
load: true
|
load: true
|
||||||
tags: calendars-builder:local
|
tags: calendars-builder:local
|
||||||
-
|
-
|
||||||
name: Extract SSG build output
|
name: Extract static output
|
||||||
run: |
|
run: |
|
||||||
docker create --name extract calendars-builder:local
|
docker create --name extract calendars-builder:local
|
||||||
docker cp extract:/home/frontend/apps/calendars/out ./src/frontend/out
|
docker cp extract:/home/frontend/apps/calendars/out ./src/frontend/out
|
||||||
docker rm extract
|
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/frontend
|
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' }}
|
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' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: '22.x'
|
default: '24.x'
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
id: front-node_modules
|
id: front-node_modules
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
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
|
- name: Setup Node.js
|
||||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -27,10 +27,10 @@ jobs:
|
|||||||
node-version: ${{ inputs.node_version }}
|
node-version: ${{ inputs.node_version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
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
|
- name: Cache install frontend
|
||||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/frontend/**/node_modules"
|
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
3
.gitignore
vendored
@@ -47,8 +47,9 @@ env.d/terraform
|
|||||||
compose.override.yml
|
compose.override.yml
|
||||||
docker/auth/*.local
|
docker/auth/*.local
|
||||||
|
|
||||||
# npm
|
# npm / pnpm
|
||||||
node_modules
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
# Mails
|
# Mails
|
||||||
src/backend/core/templates/mail/
|
src/backend/core/templates/mail/
|
||||||
|
|||||||
54
CLAUDE.md
54
CLAUDE.md
@@ -18,44 +18,39 @@ In this project, you can create events, invite people to events, create calendar
|
|||||||
### Development Setup
|
### Development Setup
|
||||||
```bash
|
```bash
|
||||||
make bootstrap # Initial setup: builds containers, runs migrations, starts services
|
make bootstrap # Initial setup: builds containers, runs migrations, starts services
|
||||||
make run # Start all services (backend + frontend containers)
|
make start # Start all services (backend + frontend containers)
|
||||||
make run-backend # Start backend services only (for local frontend development)
|
make start-back # Start backend services only (for local frontend development)
|
||||||
make stop # Stop all containers
|
make stop # Stop all containers
|
||||||
make down # Stop and remove containers, networks, volumes
|
make down # Stop and remove containers, networks, volumes
|
||||||
|
make update # Update project after pulling changes
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Development
|
### Backend Development
|
||||||
```bash
|
```bash
|
||||||
make test-back -- path/to/test.py::TestClass::test_method # Run specific test
|
make test-back -- path/to/test.py::TestClass::test_method # Run specific test
|
||||||
make test-back-parallel # Run all tests in parallel
|
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 migrate # Run Django migrations
|
||||||
make makemigrations # Create new migrations
|
make makemigrations # Create new migrations
|
||||||
make shell # Django shell
|
make shell-back-django # Django shell
|
||||||
make dbshell # PostgreSQL shell
|
make shell-db # PostgreSQL shell
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
```bash
|
```bash
|
||||||
make frontend-development-install # Install frontend dependencies locally
|
make install-front # Install frontend dependencies
|
||||||
make run-frontend-development # Run frontend locally (after run-backend)
|
make lint-front # Run ESLint on frontend
|
||||||
make frontend-lint # Run ESLint on frontend
|
make typecheck-front # Run TypeScript type checker
|
||||||
cd src/frontend/apps/calendars && yarn test # Run frontend tests
|
make test-front # Run frontend tests
|
||||||
cd src/frontend/apps/calendars && yarn test:watch # Watch mode
|
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
|
### E2E Tests
|
||||||
```bash
|
```bash
|
||||||
make run-tests-e2e # Run all e2e tests
|
make test-e2e # Run all e2e tests
|
||||||
make run-tests-e2e -- --project chromium --headed # Run with specific browser
|
make test-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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -70,14 +65,14 @@ make crowdin-download # Download translations from Crowdin
|
|||||||
- `tests/` - pytest test files
|
- `tests/` - pytest test files
|
||||||
|
|
||||||
### Frontend Structure (`src/frontend/`)
|
### Frontend Structure (`src/frontend/`)
|
||||||
Yarn workspaces monorepo:
|
npm workspaces:
|
||||||
- `apps/calendars/` - Main Next.js application
|
- `apps/calendars/` - Main Next.js application
|
||||||
- `src/features/` - Feature modules (calendar, auth, api, i18n, etc.)
|
- `src/features/` - Feature modules (calendar, auth, api, i18n, etc.)
|
||||||
- `src/pages/` - Next.js pages
|
- `src/pages/` - Next.js pages
|
||||||
- `src/hooks/` - Custom React hooks
|
- `src/hooks/` - Custom React hooks
|
||||||
- `apps/e2e/` - Playwright end-to-end tests
|
- `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.
|
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.).
|
**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 |
|
| Service | URL / Port | Description |
|
||||||
|---------|------------|-------------|
|
|---------|------------|-------------|
|
||||||
| **Frontend** | [http://localhost:8920](http://localhost:8920) | Next.js Calendar frontend |
|
| **Frontend** | [http://localhost:8930](http://localhost:8930) | Next.js Calendar frontend |
|
||||||
| **Backend API** | [http://localhost:8921](http://localhost:8921) | Django REST API |
|
| **Backend API** | [http://localhost:8931](http://localhost:8931) | Django REST API |
|
||||||
| **CalDAV** | [http://localhost:8922](http://localhost:8922) | SabreDAV CalDAV server |
|
| **CalDAV** | [http://localhost:8932](http://localhost:8932) | SabreDAV CalDAV server |
|
||||||
| **Nginx** | [http://localhost:8923](http://localhost:8923) | Reverse proxy (frontend + API) |
|
| **Redis** | 8934 | Cache and Celery broker |
|
||||||
| **Redis** | 8924 | Cache and Celery broker |
|
| **Keycloak** | [http://localhost:8935](http://localhost:8935) | OIDC identity provider |
|
||||||
| **Keycloak** | [http://localhost:8925](http://localhost:8925) | OIDC identity provider |
|
| **PostgreSQL** | 8936 | Database server |
|
||||||
| **PostgreSQL** | 8926 | Database server |
|
| **Mailcatcher** | [http://localhost:8937](http://localhost:8937) | Email testing interface |
|
||||||
| **Mailcatcher** | [http://localhost:8927](http://localhost:8927) | Email testing interface |
|
|
||||||
|
|
||||||
## Key Technologies
|
## Key Technologies
|
||||||
|
|
||||||
|
|||||||
371
Makefile
371
Makefile
@@ -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:
|
# Note to developers:
|
||||||
#
|
#
|
||||||
# While editing this file, please respect the following statements:
|
# While editing this file, please respect the following statements:
|
||||||
@@ -26,35 +17,24 @@
|
|||||||
BOLD := \033[1m
|
BOLD := \033[1m
|
||||||
RESET := \033[0m
|
RESET := \033[0m
|
||||||
GREEN := \033[1;32m
|
GREEN := \033[1;32m
|
||||||
|
BLUE := \033[1;34m
|
||||||
|
|
||||||
# -- Database
|
|
||||||
|
|
||||||
DB_HOST = postgresql
|
|
||||||
DB_PORT = 5432
|
|
||||||
|
|
||||||
# -- Docker
|
# -- Docker
|
||||||
# Get the current user ID to use for docker run and docker exec commands
|
# Get the current user ID to use for docker run and docker exec commands
|
||||||
DOCKER_UID = $(shell id -u)
|
DOCKER_UID = $(shell id -u)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_GID = $(shell id -g)
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) backend-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) backend-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) backend-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) backend-dev
|
||||||
COMPOSE_RUN_APP_NO_DEPS = $(COMPOSE_RUN) --no-deps backend-dev
|
COMPOSE_RUN_APP_NO_DEPS = $(COMPOSE_RUN) --no-deps backend-dev
|
||||||
|
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MANAGE_EXEC = $(COMPOSE_EXEC_APP) python manage.py
|
MANAGE_EXEC = $(COMPOSE_EXEC_APP) python manage.py
|
||||||
PSQL_E2E = ./bin/postgres_e2e
|
PSQL_E2E = ./bin/postgres_e2e
|
||||||
|
|
||||||
# -- Frontend
|
|
||||||
PATH_FRONT = ./src/frontend
|
|
||||||
PATH_FRONT_CALENDARS = $(PATH_FRONT)/apps/calendars
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# RULES
|
# RULES
|
||||||
@@ -71,7 +51,6 @@ data/static:
|
|||||||
|
|
||||||
create-env-files: ## Create empty .local env files for local development
|
create-env-files: ## Create empty .local env files for local development
|
||||||
create-env-files: \
|
create-env-files: \
|
||||||
env.d/development/crowdin.local \
|
|
||||||
env.d/development/postgresql.local \
|
env.d/development/postgresql.local \
|
||||||
env.d/development/keycloak.local \
|
env.d/development/keycloak.local \
|
||||||
env.d/development/backend.local \
|
env.d/development/backend.local \
|
||||||
@@ -84,13 +63,12 @@ env.d/development/%.local:
|
|||||||
@echo "# Add your local-specific environment variables below:" >> $@
|
@echo "# Add your local-specific environment variables below:" >> $@
|
||||||
@echo "# Example: DJANGO_DEBUG=True" >> $@
|
@echo "# Example: DJANGO_DEBUG=True" >> $@
|
||||||
@echo "" >> $@
|
@echo "" >> $@
|
||||||
.PHONY: env.d/development/%.local
|
|
||||||
|
|
||||||
create-docker-network: ## create the docker network if it doesn't exist
|
create-docker-network: ## create the docker network if it doesn't exist
|
||||||
@docker network create lasuite-network || true
|
@docker network create lasuite-network || true
|
||||||
.PHONY: create-docker-network
|
.PHONY: create-docker-network
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare the project for local development
|
||||||
bootstrap: \
|
bootstrap: \
|
||||||
data/media \
|
data/media \
|
||||||
data/static \
|
data/static \
|
||||||
@@ -99,84 +77,56 @@ bootstrap: \
|
|||||||
create-docker-network \
|
create-docker-network \
|
||||||
migrate \
|
migrate \
|
||||||
migrate-caldav \
|
migrate-caldav \
|
||||||
back-i18n-compile \
|
start
|
||||||
run
|
|
||||||
.PHONY: bootstrap
|
.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
|
# -- Docker/compose
|
||||||
|
|
||||||
build: cache ?= # --no-cache
|
build: cache ?= # --no-cache
|
||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(COMPOSE) build $(cache)
|
||||||
@$(MAKE) build-frontend cache=$(cache)
|
|
||||||
@$(MAKE) build-caldav cache=$(cache)
|
|
||||||
.PHONY: build
|
.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
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE) down
|
||||||
rm -rf data/postgresql.*
|
rm -rf data/postgresql.*
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
|
|
||||||
logs: ## display backend-dev logs (follow mode)
|
logs: ## display all services logs (follow mode)
|
||||||
@$(COMPOSE) logs -f backend-dev
|
@$(COMPOSE) logs -f
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
|
|
||||||
run-backend: ## start the backend container
|
start: ## start all development services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d worker-dev frontend-dev
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
.PHONY: start
|
||||||
.PHONY: run-backend
|
|
||||||
|
|
||||||
bootstrap-e2e: ## bootstrap the backend container for e2e tests, without frontend
|
start-back: ## start backend services only (for local frontend development)
|
||||||
bootstrap-e2e: \
|
@$(COMPOSE) up --force-recreate -d worker-dev
|
||||||
data/media \
|
.PHONY: start-back
|
||||||
data/static \
|
|
||||||
create-env-local-files \
|
|
||||||
build-backend \
|
|
||||||
create-docker-network \
|
|
||||||
back-i18n-compile \
|
|
||||||
run-backend-e2e
|
|
||||||
.PHONY: bootstrap-e2e
|
|
||||||
|
|
||||||
clear-db-e2e: ## quickly clears the database for e2e tests, used in the e2e tests
|
status: ## an alias for "docker compose ps"
|
||||||
$(PSQL_E2E) -c "$$(cat bin/clear_db_e2e.sql)"
|
@$(COMPOSE) ps
|
||||||
.PHONY: clear-db-e2e
|
.PHONY: status
|
||||||
|
|
||||||
run-backend-e2e: ## start the backend container for e2e tests, always remove the postgresql.e2e volume first
|
stop: ## stop all development services
|
||||||
@$(MAKE) stop
|
@$(COMPOSE) stop
|
||||||
rm -rf data/postgresql.e2e
|
.PHONY: stop
|
||||||
@ENV_OVERRIDE=e2e $(MAKE) run-backend
|
|
||||||
@ENV_OVERRIDE=e2e $(MAKE) migrate
|
|
||||||
.PHONY: run-backend-e2e
|
|
||||||
|
|
||||||
run-tests-e2e: ## run the e2e tests, example: make run-tests-e2e -- --project chromium --headed
|
restart: ## restart all development services
|
||||||
@$(MAKE) run-backend-e2e
|
restart: \
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
stop \
|
||||||
cd src/frontend/apps/e2e && yarn test $${args:-${1}}
|
start
|
||||||
.PHONY: run-tests-e2e
|
.PHONY: restart
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
migrate-caldav: ## Initialize CalDAV server database schema
|
migrate-caldav: ## Initialize CalDAV server database schema
|
||||||
@echo "$(BOLD)Initializing CalDAV server database schema...$(RESET)"
|
@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)"
|
@echo "$(GREEN)CalDAV server initialized$(RESET)"
|
||||||
.PHONY: migrate-caldav
|
.PHONY: migrate-caldav
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
# -- Linters
|
||||||
@$(COMPOSE) ps
|
|
||||||
.PHONY: status
|
|
||||||
|
|
||||||
stop: ## stop the development server using Docker
|
lint: ## run all linters
|
||||||
@$(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: \
|
lint: \
|
||||||
lint-ruff-format \
|
lint-back \
|
||||||
lint-ruff-check \
|
lint-front
|
||||||
lint-pylint
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-back: ## run back-end linters (with auto-fix)
|
||||||
@echo 'lint:ruff-format started…'
|
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 .
|
@$(COMPOSE_RUN_APP_NO_DEPS) ruff format .
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: format-back
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
check-back: ## check back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
|
||||||
@$(COMPOSE_RUN_APP_NO_DEPS) ruff check . --fix
|
@$(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
|
analyze-back: ## lint all back-end python sources with pylint
|
||||||
@echo 'lint:pylint started…'
|
@$(COMPOSE_RUN_APP_NO_DEPS) pylint .
|
||||||
bin/pylint --diff-only=origin/main
|
.PHONY: analyze-back
|
||||||
.PHONY: lint-pylint
|
|
||||||
|
|
||||||
test: ## run project tests
|
lint-front: ## run the frontend linter
|
||||||
@$(MAKE) test-back-parallel
|
@$(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
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests (rebuilds and recreates containers)
|
test-back: ## run back-end tests
|
||||||
@echo "$(BOLD)Rebuilding containers (using Docker cache)...$(RESET)"
|
|
||||||
@$(MAKE) build-caldav
|
|
||||||
@echo "$(BOLD)Recreating containers...$(RESET)"
|
|
||||||
@$(COMPOSE) up -d --force-recreate postgresql caldav
|
|
||||||
@echo "$(BOLD)Running tests...$(RESET)"
|
@echo "$(BOLD)Running tests...$(RESET)"
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest $${args:-${1}}
|
bin/pytest $${args:-${1}}
|
||||||
@@ -245,13 +188,49 @@ test-back-parallel: ## run all back-end tests in parallel
|
|||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args:-${1}}
|
||||||
.PHONY: test-back-parallel
|
.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)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@$(MANAGE) makemigrations
|
@$(MANAGE) makemigrations
|
||||||
.PHONY: makemigrations
|
.PHONY: makemigrations
|
||||||
|
|
||||||
migrate: ## run django migrations for the calendar project.
|
migrate: ## run django migrations
|
||||||
@echo "$(BOLD)Running migrations$(RESET)"
|
@echo "$(BOLD)Running migrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@$(MANAGE) migrate
|
@$(MANAGE) migrate
|
||||||
@@ -262,81 +241,57 @@ superuser: ## Create an admin superuser with password "admin"
|
|||||||
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
||||||
.PHONY: superuser
|
.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
|
exec-back: ## open a shell in the running backend-dev container
|
||||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
@$(COMPOSE) exec backend-dev /bin/sh
|
||||||
.PHONY: back-i18n-compile
|
.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)"
|
@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
|
@docker run --rm -v $(PWD)/src/backend:/app -w /app ghcr.io/astral-sh/uv:python3.13-alpine uv lock
|
||||||
.PHONY: back-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
|
# -- Database
|
||||||
|
|
||||||
dbshell: ## connect to database shell
|
shell-db: ## connect to database shell
|
||||||
docker compose exec backend-dev python manage.py dbshell
|
@$(COMPOSE) exec backend-dev python manage.py dbshell
|
||||||
.PHONY: dbshell
|
.PHONY: shell-db
|
||||||
|
|
||||||
resetdb: FLUSH_ARGS ?=
|
reset-db: FLUSH_ARGS ?=
|
||||||
resetdb: ## flush database and create a superuser "admin"
|
reset-db: ## flush database
|
||||||
@echo "$(BOLD)Flush database$(RESET)"
|
@echo "$(BOLD)Flush database$(RESET)"
|
||||||
@$(MANAGE) flush $(FLUSH_ARGS)
|
@$(MANAGE) flush $(FLUSH_ARGS)
|
||||||
@${MAKE} superuser
|
.PHONY: reset-db
|
||||||
.PHONY: resetdb
|
|
||||||
|
|
||||||
# -- Internationalization
|
demo: ## flush db then create a demo
|
||||||
|
@$(MAKE) reset-db
|
||||||
|
@$(MANAGE) create_demo
|
||||||
|
.PHONY: demo
|
||||||
|
|
||||||
crowdin-download: ## Download translated message from crowdin
|
# -- Frontend
|
||||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
|
||||||
.PHONY: crowdin-download
|
|
||||||
|
|
||||||
crowdin-download-sources: ## Download sources from Crowdin
|
install-front: ## install the frontend dependencies
|
||||||
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
|
@$(COMPOSE) run --rm frontend-dev sh -c "npm install"
|
||||||
.PHONY: crowdin-download-sources
|
.PHONY: install-front
|
||||||
|
|
||||||
crowdin-upload: ## Upload source translations to crowdin
|
install-frozen-front: ## install frontend dependencies from lockfile
|
||||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
@echo "Installing frontend dependencies..."
|
||||||
.PHONY: crowdin-upload
|
@$(COMPOSE) run --rm frontend-dev sh -c "npm ci"
|
||||||
|
.PHONY: install-frozen-front
|
||||||
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
|
|
||||||
|
|
||||||
|
shell-front: ## open a shell in the frontend container
|
||||||
|
@$(COMPOSE) run --rm frontend-dev /bin/sh
|
||||||
|
.PHONY: shell-front
|
||||||
|
|
||||||
# -- Misc
|
# -- Misc
|
||||||
|
|
||||||
clean: ## restore repository state as it was freshly cloned
|
clean: ## restore repository state as it was freshly cloned
|
||||||
git clean -idx
|
git clean -idx
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
@@ -350,31 +305,3 @@ help:
|
|||||||
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
|
@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}'
|
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||||
.PHONY: help
|
.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
|
|
||||||
|
|||||||
2
Procfile
2
Procfile
@@ -1,3 +1,3 @@
|
|||||||
web: bin/scalingo_run_web
|
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
|
postdeploy: source bin/export_pg_vars.sh && python manage.py migrate && SQL_DIR=/app/sabredav/sql bash sabredav/init-database.sh
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -56,10 +56,10 @@ Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ docker -v
|
$ docker -v
|
||||||
Docker version 27.5.1, build 9f9e405
|
Docker version 27.x
|
||||||
|
|
||||||
$ docker compose version
|
$ 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
|
> ⚠️ 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
|
$ make bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
This command builds the `backend-dev` and `frontend-dev` containers, installs dependencies, performs
|
This command builds the containers, installs dependencies, and runs database
|
||||||
database migrations and compile translations. It's a good idea to use this
|
migrations. It's a good idea to use this command each time you are pulling
|
||||||
command each time you are pulling code from the project repository to avoid
|
code from the project repository to avoid dependency-related or
|
||||||
dependency-related or migration-related issues.
|
migration-related issues.
|
||||||
|
|
||||||
Your Docker services should now be up and running! 🎉
|
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:
|
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:
|
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ make run
|
$ make start
|
||||||
```
|
```
|
||||||
|
|
||||||
You can check all available Make rules using:
|
You can check all available Make rules using:
|
||||||
@@ -101,30 +101,30 @@ You can check all available Make rules using:
|
|||||||
$ make help
|
$ 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
|
```bash
|
||||||
$ make frontend-development-install
|
$ make install-front
|
||||||
```
|
```
|
||||||
|
|
||||||
And run the frontend locally in development mode with the following command:
|
Then start the backend services:
|
||||||
|
|
||||||
```shellscript
|
```bash
|
||||||
$ make run-frontend-development
|
$ 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
|
```bash
|
||||||
$ make run-backend
|
$ cd src/frontend/apps/calendars && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Django admin
|
### Django admin
|
||||||
|
|
||||||
You can access the Django admin site at
|
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:
|
You first need to create a superuser account:
|
||||||
|
|
||||||
|
|||||||
38
bin/pylint
38
bin/pylint
@@ -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[@]}"
|
|
||||||
@@ -5,14 +5,27 @@ set -o pipefail # don't ignore exit codes when piping output
|
|||||||
|
|
||||||
echo "-----> Running post-frontend script"
|
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/
|
mkdir -p build/
|
||||||
mv src/frontend/apps/calendars/out build/frontend-out
|
mv src/frontend/apps/calendars/out build/frontend-out
|
||||||
|
|
||||||
cp src/frontend/apps/calendars/src/features/i18n/translations.json translations.json
|
cp src/frontend/apps/calendars/src/features/i18n/translations.json translations.json
|
||||||
|
|
||||||
mv src/backend/* ./
|
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
|
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)
|
# 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
|
# Source: https://launchpad.net/ubuntu/noble/amd64/php8.3-fpm/8.3.6-0maysync1
|
||||||
declare -A PHP_DEBS=(
|
# Format: "package_name url sha256"
|
||||||
[php8.3-cli]="http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb"
|
PHP_DEBS=(
|
||||||
[php8.3-fpm]="http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb"
|
"php8.3-cli http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb 8cb7461dd06fb214b30c060b80b1c6f95d1ff5e2656fdadf215e50b9f299f196"
|
||||||
[php8.3-common]="http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb"
|
"php8.3-fpm http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb b3a9435025766bcbf6c16199c06481c5196098c084933dfabf8867c982edc2b2"
|
||||||
[php8.3-opcache]="http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb"
|
"php8.3-common http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb 0e0d0ad9c17add5fb2afcc14c6fffb81c2beb99114108b8ebd0461d910a79dfc"
|
||||||
[php8.3-readline]="http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb"
|
"php8.3-opcache http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb 13b2662201c57904c1eda9b048b1349acaf3609c7d9e8df5b2d93833a059bdb0"
|
||||||
[php8.3-pgsql]="http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb"
|
"php8.3-readline http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb 380f8ed79196914ee2eebb68bf518a752204826af1fdb8a0d5c9609c76086b90"
|
||||||
[php8.3-xml]="http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb"
|
"php8.3-pgsql http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb b1ed204c980c348d1870cfa88c1b40257621ae5696a2a7f44f861a9d00eb7477"
|
||||||
[php8.3-mbstring]="http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb"
|
"php8.3-xml http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb 6c6ded219d1966a50108d032b7a522e641765a8a6aa48747483313fa7dafd533"
|
||||||
[php8.3-curl]="http://launchpadlibrarian.net/724872607/php8.3-curl_8.3.6-0maysync1_amd64.deb"
|
"php8.3-mbstring http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb 42c89945eb105c2232ab208b893ef65e9abc8af5c95aa10c507498655ef812c4"
|
||||||
[php-common]="http://launchpadlibrarian.net/710804987/php-common_93ubuntu2_all.deb"
|
"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}"
|
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
|
done
|
||||||
|
|
||||||
for deb in "$DEB_DIR"/*.deb; do
|
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 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' ' ')"
|
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"
|
echo "-----> Installing SabreDAV dependencies"
|
||||||
|
COMPOSER_VERSION="2.9.5"
|
||||||
|
COMPOSER_SHA256="c86ce603fe836bf0861a38c93ac566c8f1e69ac44b2445d9b7a6a17ea2e9972a"
|
||||||
curl -fsSL -o bin/composer.phar \
|
curl -fsSL -o bin/composer.phar \
|
||||||
https://getcomposer.org/download/latest-stable/composer.phar
|
"https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar"
|
||||||
cp -r docker/sabredav sabredav
|
echo "${COMPOSER_SHA256} bin/composer.phar" | sha256sum -c -
|
||||||
|
cp -r src/caldav sabredav
|
||||||
cd sabredav
|
cd sabredav
|
||||||
"../$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" ../bin/composer.phar install \
|
"../$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" ../bin/composer.phar install \
|
||||||
--no-dev --optimize-autoloader --no-interaction
|
--no-dev --optimize-autoloader --no-interaction
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
# Parse DATABASE_URL into PG* vars for PHP and psql
|
# Parse DATABASE_URL into PG* vars for PHP and psql
|
||||||
source bin/export_pg_vars.sh
|
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)
|
# Start PHP-FPM for SabreDAV (CalDAV server)
|
||||||
.php/usr/sbin/php-fpm8.3 \
|
.php/usr/sbin/php-fpm8.3 \
|
||||||
-n -c /app/.php/php.ini \
|
-n -c /app/.php/php.ini \
|
||||||
@@ -12,11 +17,11 @@ source bin/export_pg_vars.sh
|
|||||||
# Start the Django backend
|
# Start the Django backend
|
||||||
gunicorn -b :8000 calendars.wsgi:application --log-file - &
|
gunicorn -b :8000 calendars.wsgi:application --log-file - &
|
||||||
|
|
||||||
# Start the Nginx server
|
# Start the Caddy server
|
||||||
bin/run &
|
bin/caddy run --config Caddyfile --adapter caddyfile &
|
||||||
|
|
||||||
# if the current shell is killed, also terminate all its children
|
# 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 for a single child to finish,
|
||||||
wait -n
|
wait -n
|
||||||
|
|||||||
61
compose.yaml
61
compose.yaml
@@ -5,7 +5,7 @@ services:
|
|||||||
postgresql:
|
postgresql:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
ports:
|
ports:
|
||||||
- "8926:5432"
|
- "8936:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
||||||
interval: 1s
|
interval: 1s
|
||||||
@@ -18,12 +18,12 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
ports:
|
ports:
|
||||||
- "8924:6379"
|
- "8934:6379"
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: sj26/mailcatcher:latest
|
image: sj26/mailcatcher:latest
|
||||||
ports:
|
ports:
|
||||||
- "8927:1080"
|
- "8937:1080"
|
||||||
|
|
||||||
backend-dev:
|
backend-dev:
|
||||||
build:
|
build:
|
||||||
@@ -40,7 +40,7 @@ services:
|
|||||||
- env.d/development/backend.defaults
|
- env.d/development/backend.defaults
|
||||||
- env.d/development/backend.local
|
- env.d/development/backend.local
|
||||||
ports:
|
ports:
|
||||||
- "8921:8000"
|
- "8931:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
@@ -57,18 +57,18 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
celery-dev:
|
worker-dev:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
caldav:
|
caldav:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
celery-dev:
|
worker-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: calendars:backend-development
|
image: calendars:backend-development
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- lasuite
|
- lasuite
|
||||||
command: [ "celery", "-A", "calendars.celery_app", "worker", "-l", "DEBUG" ]
|
command: [ "python", "worker.py", "-v", "2" ]
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
@@ -80,28 +80,13 @@ services:
|
|||||||
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
|
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
|
||||||
- /app/.venv
|
- /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:
|
frontend-dev:
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
build:
|
build:
|
||||||
context: src/frontend
|
context: src/frontend
|
||||||
target: calendars-dev
|
target: calendars-dev
|
||||||
args:
|
args:
|
||||||
API_ORIGIN: "http://localhost:8921"
|
API_ORIGIN: "http://localhost:8931"
|
||||||
image: calendars:frontend-development
|
image: calendars:frontend-development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/frontend.defaults
|
- env.d/development/frontend.defaults
|
||||||
@@ -111,20 +96,10 @@ services:
|
|||||||
- /home/frontend/node_modules
|
- /home/frontend/node_modules
|
||||||
- /home/frontend/apps/calendars/node_modules
|
- /home/frontend/apps/calendars/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "8920:3000"
|
- "8930: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
|
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:22
|
image: node:24
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
@@ -134,17 +109,17 @@ services:
|
|||||||
# CalDAV Server
|
# CalDAV Server
|
||||||
caldav:
|
caldav:
|
||||||
build:
|
build:
|
||||||
context: docker/sabredav
|
context: src/caldav
|
||||||
ports:
|
ports:
|
||||||
- "8922:80"
|
- "8932:80"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/caldav.defaults
|
- env.d/development/caldav.defaults
|
||||||
- env.d/development/caldav.local
|
- env.d/development/caldav.local
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/sabredav/server.php:/var/www/sabredav/server.php
|
- ./src/caldav/server.php:/var/www/sabredav/server.php
|
||||||
- ./docker/sabredav/src:/var/www/sabredav/src
|
- ./src/caldav/src:/var/www/sabredav/src
|
||||||
- ./docker/sabredav/sql:/var/www/sabredav/sql
|
- ./src/caldav/sql:/var/www/sabredav/sql
|
||||||
- ./docker/sabredav/init-database.sh:/usr/local/bin/init-database.sh
|
- ./src/caldav/init-database.sh:/usr/local/bin/init-database.sh
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- lasuite
|
- lasuite
|
||||||
@@ -166,13 +141,13 @@ services:
|
|||||||
- start-dev
|
- start-dev
|
||||||
- --features=preview
|
- --features=preview
|
||||||
- --import-realm
|
- --import-realm
|
||||||
- --hostname=http://localhost:8925
|
- --hostname=http://localhost:8935
|
||||||
- --hostname-strict=false
|
- --hostname-strict=false
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/keycloak.defaults
|
- env.d/development/keycloak.defaults
|
||||||
- env.d/development/keycloak.local
|
- env.d/development/keycloak.local
|
||||||
ports:
|
ports:
|
||||||
- "8925:8080"
|
- "8935:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgresql:
|
postgresql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -694,16 +694,16 @@
|
|||||||
"clientAuthenticatorType": "client-secret",
|
"clientAuthenticatorType": "client-secret",
|
||||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:8920/*",
|
"http://localhost:8930/*",
|
||||||
"http://localhost:8921/*",
|
"http://localhost:8931/*",
|
||||||
"http://localhost:8922/*",
|
"http://localhost:8932/*",
|
||||||
"http://localhost:8923/*"
|
"http://localhost:8933/*"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"http://localhost:8920",
|
"http://localhost:8930",
|
||||||
"http://localhost:8921",
|
"http://localhost:8931",
|
||||||
"http://localhost:8922",
|
"http://localhost:8932",
|
||||||
"http://localhost:8923"
|
"http://localhost:8933"
|
||||||
],
|
],
|
||||||
"notBefore": 0,
|
"notBefore": 0,
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
||||||
@@ -719,7 +719,7 @@
|
|||||||
"access.token.lifespan": "-1",
|
"access.token.lifespan": "-1",
|
||||||
"client.secret.creation.time": "1707820779",
|
"client.secret.creation.time": "1707820779",
|
||||||
"user.info.response.signature.alg": "RS256",
|
"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",
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
"use.jwks.url": "false",
|
"use.jwks.url": "false",
|
||||||
"backchannel.logout.revoke.offline.tokens": "false",
|
"backchannel.logout.revoke.offline.tokens": "false",
|
||||||
@@ -765,16 +765,16 @@
|
|||||||
"clientAuthenticatorType": "client-secret",
|
"clientAuthenticatorType": "client-secret",
|
||||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:8920/*",
|
"http://localhost:8930/*",
|
||||||
"http://localhost:8921/*",
|
"http://localhost:8931/*",
|
||||||
"http://localhost:8922/*",
|
"http://localhost:8932/*",
|
||||||
"http://localhost:8923/*"
|
"http://localhost:8933/*"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"http://localhost:8920",
|
"http://localhost:8930",
|
||||||
"http://localhost:8921",
|
"http://localhost:8931",
|
||||||
"http://localhost:8922",
|
"http://localhost:8932",
|
||||||
"http://localhost:8923"
|
"http://localhost:8933"
|
||||||
],
|
],
|
||||||
"notBefore": 0,
|
"notBefore": 0,
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
||||||
@@ -790,7 +790,7 @@
|
|||||||
"access.token.lifespan": "-1",
|
"access.token.lifespan": "-1",
|
||||||
"client.secret.creation.time": "1707820779",
|
"client.secret.creation.time": "1707820779",
|
||||||
"user.info.response.signature.alg": "RS256",
|
"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",
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
"use.jwks.url": "false",
|
"use.jwks.url": "false",
|
||||||
"backchannel.logout.revoke.offline.tokens": "false",
|
"backchannel.logout.revoke.offline.tokens": "false",
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
integrates with the DeployCenter API in production and uses a local
|
||||||
backend for development.
|
backend for development.
|
||||||
|
|
||||||
Unlike La Suite Messages, Calendars only checks `can_access` — there
|
Calendars checks two entitlements:
|
||||||
is no admin permission sync.
|
- `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
|
## Architecture
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ is no admin permission sync.
|
|||||||
│
|
│
|
||||||
┌──────────────▼──────────────────────────────┐
|
┌──────────────▼──────────────────────────────┐
|
||||||
│ UserMeSerializer │
|
│ 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).
|
is denied (returns 403).
|
||||||
- **Import events is fail-closed**: if the entitlements service is
|
- **Import events is fail-closed**: if the entitlements service is
|
||||||
unavailable, ICS import is denied (returns 403).
|
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
|
- The DeployCenter backend falls back to stale cached data when the
|
||||||
API is unavailable.
|
API is unavailable.
|
||||||
- `EntitlementsUnavailableError` is only raised when the API fails
|
- `EntitlementsUnavailableError` is only raised when the API fails
|
||||||
@@ -157,7 +163,7 @@ class MyBackend(EntitlementsBackend):
|
|||||||
def get_user_entitlements(
|
def get_user_entitlements(
|
||||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
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.
|
# Raise EntitlementsUnavailableError on failure.
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
@@ -175,7 +181,8 @@ Headers: `X-Service-Auth: Bearer {api_key}`
|
|||||||
Query parameters include any configured `oidc_claims` extracted from
|
Query parameters include any configured `oidc_claims` extracted from
|
||||||
the OIDC user_info response (e.g. `siret`).
|
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
|
## Access control flow
|
||||||
|
|
||||||
|
|||||||
@@ -171,9 +171,9 @@ When an event with attendees is deleted:
|
|||||||
| Email service | `src/backend/core/services/calendar_invitation_service.py` |
|
| Email service | `src/backend/core/services/calendar_invitation_service.py` |
|
||||||
| ICS parser | `src/backend/core/services/calendar_invitation_service.py` (`ICalendarParser`) |
|
| ICS parser | `src/backend/core/services/calendar_invitation_service.py` (`ICalendarParser`) |
|
||||||
| Email templates | `src/backend/core/templates/emails/calendar_invitation*.html` |
|
| Email templates | `src/backend/core/templates/emails/calendar_invitation*.html` |
|
||||||
| SabreDAV sanitizer | `docker/sabredav/src/CalendarSanitizerPlugin.php` |
|
| SabreDAV sanitizer | `src/caldav/src/CalendarSanitizerPlugin.php` |
|
||||||
| SabreDAV attendee dedup | `docker/sabredav/src/AttendeeNormalizerPlugin.php` |
|
| SabreDAV attendee dedup | `src/caldav/src/AttendeeNormalizerPlugin.php` |
|
||||||
| SabreDAV callback plugin | `docker/sabredav/src/HttpCallbackIMipPlugin.php` |
|
| SabreDAV callback plugin | `src/caldav/src/HttpCallbackIMipPlugin.php` |
|
||||||
|
|
||||||
## Future: Messages mail client integration
|
## Future: Messages mail client integration
|
||||||
|
|
||||||
|
|||||||
503
docs/organizations.md
Normal file
503
docs/organizations.md
Normal 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
1002
docs/resources.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@ PYTHONPATH=/app
|
|||||||
|
|
||||||
# Media
|
# Media
|
||||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
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_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_TOKEN_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/userinfo
|
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_SIGN_ALGO=RS256
|
||||||
OIDC_RP_SCOPES="openid email"
|
OIDC_RP_SCOPES="openid email"
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL=http://localhost:8920
|
LOGIN_REDIRECT_URL=http://localhost:8930
|
||||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:8920
|
LOGIN_REDIRECT_URL_FAILURE=http://localhost:8930
|
||||||
LOGOUT_REDIRECT_URL=http://localhost:8920
|
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"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
# Resource Server Backend
|
# 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_OP_INTROSPECTION_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/token/introspect
|
||||||
OIDC_RESOURCE_SERVER_ENABLED=False
|
OIDC_RESOURCE_SERVER_ENABLED=False
|
||||||
OIDC_RS_CLIENT_ID=calendars
|
OIDC_RS_CLIENT_ID=calendars
|
||||||
@@ -50,6 +50,7 @@ OIDC_RS_ALLOWED_AUDIENCES=""
|
|||||||
CALDAV_URL=http://caldav:80
|
CALDAV_URL=http://caldav:80
|
||||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||||
CALDAV_INBOUND_API_KEY=changeme-inbound-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)
|
# Internal URL for CalDAV scheduling callbacks (accessible from CalDAV container)
|
||||||
CALDAV_CALLBACK_BASE_URL=http://backend-dev:8000
|
CALDAV_CALLBACK_BASE_URL=http://backend-dev:8000
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ PGPORT=5432
|
|||||||
PGDATABASE=calendars
|
PGDATABASE=calendars
|
||||||
PGUSER=pgroot
|
PGUSER=pgroot
|
||||||
PGPASSWORD=pass
|
PGPASSWORD=pass
|
||||||
CALDAV_BASE_URI=/api/v1.0/caldav/
|
CALDAV_BASE_URI=/caldav/
|
||||||
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
|
||||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-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)
|
# Default callback URL for sending scheduling notifications (emails)
|
||||||
# Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header
|
# 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
|
CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921
|
NEXT_PUBLIC_API_ORIGIN=http://localhost:8931
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -94,9 +94,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
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
|
# We wrap commands run in this container by the following entrypoint that
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
"""Calendars package. Import the celery app early to load shared task form dependencies."""
|
"""Calendars Django project package."""
|
||||||
|
|
||||||
from .celery_app import app as celery_app
|
|
||||||
|
|
||||||
__all__ = ["celery_app"]
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -14,8 +14,6 @@ import os
|
|||||||
import tomllib
|
import tomllib
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
import dj_database_url
|
import dj_database_url
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from configurations import Configuration, values
|
from configurations import Configuration, values
|
||||||
@@ -74,13 +72,24 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# CalDAV API keys for bidirectional authentication
|
# CalDAV API keys for bidirectional authentication
|
||||||
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
|
# 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
|
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
|
||||||
)
|
)
|
||||||
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
|
# 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
|
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)
|
# 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)
|
# In Docker environments, use the internal Docker network URL (e.g., http://backend:8000)
|
||||||
CALDAV_CALLBACK_BASE_URL = values.Value(
|
CALDAV_CALLBACK_BASE_URL = values.Value(
|
||||||
@@ -117,7 +126,7 @@ class Base(Configuration):
|
|||||||
CALENDAR_INVITATION_FROM_EMAIL = values.Value(
|
CALENDAR_INVITATION_FROM_EMAIL = values.Value(
|
||||||
None, environ_name="CALENDAR_INVITATION_FROM_EMAIL", environ_prefix=None
|
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)
|
APP_URL = values.Value("", environ_name="APP_URL", environ_prefix=None)
|
||||||
CALENDAR_ITIP_ENABLED = values.BooleanValue(
|
CALENDAR_ITIP_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
|
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
|
||||||
@@ -133,6 +142,18 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
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
|
||||||
ENTITLEMENTS_BACKEND = values.Value(
|
ENTITLEMENTS_BACKEND = values.Value(
|
||||||
"core.entitlements.backends.local.LocalEntitlementsBackend",
|
"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 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.
|
# This also limits the size of the file that can be uploaded to the server.
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
|
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
|
||||||
2 * (2**30), # 2GB
|
20 * (2**20), # 20MB
|
||||||
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
|
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
@@ -234,15 +255,13 @@ class Base(Configuration):
|
|||||||
# fallback/default languages throughout the app.
|
# fallback/default languages throughout the app.
|
||||||
LANGUAGES = values.SingleNestedTupleValue(
|
LANGUAGES = values.SingleNestedTupleValue(
|
||||||
(
|
(
|
||||||
("en-us", _("English")),
|
("en-us", "English"),
|
||||||
("fr-fr", _("French")),
|
("fr-fr", "French"),
|
||||||
("de-de", _("German")),
|
("de-de", "German"),
|
||||||
("nl-nl", _("Dutch")),
|
("nl-nl", "Dutch"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
|
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
@@ -275,7 +294,6 @@ class Base(Configuration):
|
|||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.locale.LocaleMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
@@ -296,7 +314,7 @@ class Base(Configuration):
|
|||||||
"drf_standardized_errors",
|
"drf_standardized_errors",
|
||||||
# Third party apps
|
# Third party apps
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"django_celery_beat",
|
"django_dramatiq",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework_api_key",
|
"rest_framework_api_key",
|
||||||
@@ -415,7 +433,11 @@ class Base(Configuration):
|
|||||||
)
|
)
|
||||||
|
|
||||||
AUTH_USER_MODEL = "core.User"
|
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
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
@@ -514,10 +536,40 @@ class Base(Configuration):
|
|||||||
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||||
THUMBNAIL_ALIASES = {}
|
THUMBNAIL_ALIASES = {}
|
||||||
|
|
||||||
# Celery
|
# Dramatiq
|
||||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
DRAMATIQ_BROKER = {
|
||||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
"BROKER": "dramatiq.brokers.redis.RedisBroker",
|
||||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
"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
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||||
@@ -635,12 +687,6 @@ class Base(Configuration):
|
|||||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||||
environ_prefix=None,
|
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
|
||||||
|
|
||||||
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
||||||
@@ -870,7 +916,7 @@ class Development(Base):
|
|||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
"http://localhost:8920",
|
"http://localhost:8930",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
]
|
]
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
@@ -887,8 +933,8 @@ class Development(Base):
|
|||||||
EMAIL_USE_SSL = False
|
EMAIL_USE_SSL = False
|
||||||
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
||||||
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
||||||
APP_NAME = "Calendrier (Dev)"
|
APP_NAME = "Calendars (dev)"
|
||||||
APP_URL = "http://localhost:8921"
|
APP_URL = "http://localhost:8931"
|
||||||
|
|
||||||
DEBUG_TOOLBAR_CONFIG = {
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||||
@@ -919,7 +965,18 @@ class Test(Base):
|
|||||||
]
|
]
|
||||||
USE_SWAGGER = True
|
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_ACCESS_TOKEN = False
|
||||||
OIDC_STORE_REFRESH_TOKEN = False
|
OIDC_STORE_REFRESH_TOKEN = False
|
||||||
@@ -977,6 +1034,7 @@ class Production(Base):
|
|||||||
"^__lbheartbeat__",
|
"^__lbheartbeat__",
|
||||||
"^__heartbeat__",
|
"^__heartbeat__",
|
||||||
r"^api/v1\.0/caldav-scheduling-callback/",
|
r"^api/v1\.0/caldav-scheduling-callback/",
|
||||||
|
r"^caldav/",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import admin as auth_admin
|
from django.contrib.auth import admin as auth_admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
@@ -23,20 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
_("Personal info"),
|
"Personal info",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"sub",
|
"sub",
|
||||||
"email",
|
"email",
|
||||||
"full_name",
|
"full_name",
|
||||||
"short_name",
|
|
||||||
"language",
|
"language",
|
||||||
"timezone",
|
"timezone",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
_("Permissions"),
|
"Permissions",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
"is_active",
|
"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 = (
|
add_fieldsets = (
|
||||||
(
|
(
|
||||||
@@ -86,27 +84,27 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"sub",
|
"sub",
|
||||||
"email",
|
"email",
|
||||||
"full_name",
|
"full_name",
|
||||||
"short_name",
|
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.CalendarSubscriptionToken)
|
@admin.register(models.Channel)
|
||||||
class CalendarSubscriptionTokenAdmin(admin.ModelAdmin):
|
class ChannelAdmin(admin.ModelAdmin):
|
||||||
"""Admin class for CalendarSubscriptionToken model."""
|
"""Admin class for Channel model."""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"calendar_name",
|
"name",
|
||||||
"owner",
|
"type",
|
||||||
|
"organization",
|
||||||
|
"user",
|
||||||
"caldav_path",
|
"caldav_path",
|
||||||
"token",
|
|
||||||
"is_active",
|
"is_active",
|
||||||
"last_accessed_at",
|
"last_used_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
)
|
)
|
||||||
list_filter = ("is_active",)
|
list_filter = ("type", "is_active")
|
||||||
search_fields = ("calendar_name", "owner__email", "caldav_path", "token")
|
search_fields = ("name", "user__email", "caldav_path")
|
||||||
readonly_fields = ("id", "token", "created_at", "last_accessed_at")
|
readonly_fields = ("id", "created_at", "updated_at", "last_used_at")
|
||||||
raw_id_fields = ("owner",)
|
raw_id_fields = ("user", "organization")
|
||||||
|
|||||||
@@ -2,19 +2,12 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.core import exceptions
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class IsAuthenticated(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
@@ -26,15 +19,6 @@ class IsAuthenticated(permissions.BasePermission):
|
|||||||
return bool(request.auth) or request.user.is_authenticated
|
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):
|
class IsSelf(IsAuthenticated):
|
||||||
"""
|
"""
|
||||||
Allows access only to authenticated users. Alternative method checking the presence
|
Allows access only to authenticated users. Alternative method checking the presence
|
||||||
@@ -46,27 +30,7 @@ class IsSelf(IsAuthenticated):
|
|||||||
return obj == request.user
|
return obj == request.user
|
||||||
|
|
||||||
|
|
||||||
class IsOwnedOrPublic(IsAuthenticated):
|
class IsEntitledToAccess(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):
|
|
||||||
"""Allows access only to users with can_access entitlement.
|
"""Allows access only to users with can_access entitlement.
|
||||||
|
|
||||||
Fail-closed: denies access when the entitlements service is
|
Fail-closed: denies access when the entitlements service is
|
||||||
@@ -78,25 +42,31 @@ class IsEntitled(IsAuthenticated):
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
entitlements = get_user_entitlements(request.user.sub, request.user.email)
|
entitlements = get_user_entitlements(request.user.sub, request.user.email)
|
||||||
return entitlements.get("can_access", True)
|
return entitlements.get("can_access", False)
|
||||||
except EntitlementsUnavailableError:
|
except EntitlementsUnavailableError:
|
||||||
|
logger.warning(
|
||||||
|
"Entitlements unavailable, denying access for user %s",
|
||||||
|
request.user.pk,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class AccessPermission(permissions.BasePermission):
|
class IsOrgAdmin(IsAuthenticated):
|
||||||
"""Permission class for access objects."""
|
"""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):
|
def has_permission(self, request, view):
|
||||||
return request.user.is_authenticated or view.action not in [
|
if not super().has_permission(request, view):
|
||||||
"create",
|
return False
|
||||||
]
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
"""Check permission for a given object."""
|
|
||||||
abilities = obj.get_abilities(request.user)
|
|
||||||
action = view.action
|
|
||||||
try:
|
try:
|
||||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
entitlements = get_user_entitlements(request.user.sub, request.user.email)
|
||||||
except KeyError:
|
return entitlements.get("can_admin", False)
|
||||||
pass
|
except EntitlementsUnavailableError:
|
||||||
return abilities.get(action, False)
|
logger.warning(
|
||||||
|
"Entitlements unavailable, denying admin for user %s",
|
||||||
|
request.user.pk,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
"""Client serializers for the calendars core app."""
|
"""Client serializers for the calendars core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
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):
|
class UserLiteSerializer(serializers.ModelSerializer):
|
||||||
@@ -15,171 +24,174 @@ class UserLiteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "full_name", "short_name"]
|
fields = ["id", "full_name"]
|
||||||
read_only_fields = ["id", "full_name", "short_name"]
|
read_only_fields = ["id", "full_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
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
|
|
||||||
|
email = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"email",
|
"email",
|
||||||
"full_name",
|
"full_name",
|
||||||
"short_name",
|
|
||||||
"language",
|
"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):
|
class UserMeSerializer(UserSerializer):
|
||||||
"""Serialize users for me endpoint."""
|
"""Serialize users for me endpoint."""
|
||||||
|
|
||||||
can_access = serializers.SerializerMethodField(read_only=True)
|
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:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = [*UserSerializer.Meta.fields, "can_access"]
|
fields = [
|
||||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_access"]
|
*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:
|
def get_can_access(self, user) -> bool:
|
||||||
"""Check entitlements for the current user."""
|
"""Check entitlements for the current user."""
|
||||||
try:
|
entitlements = self._get_entitlements(user)
|
||||||
entitlements = get_user_entitlements(user.sub, user.email)
|
if entitlements is None:
|
||||||
return entitlements.get("can_access", True)
|
return False # fail-closed
|
||||||
except EntitlementsUnavailableError:
|
return entitlements.get("can_access", False)
|
||||||
return True # fail-open
|
|
||||||
|
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):
|
class ChannelSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for CalendarSubscriptionToken model."""
|
"""Read serializer for Channel model."""
|
||||||
|
|
||||||
|
role = serializers.SerializerMethodField()
|
||||||
url = serializers.SerializerMethodField()
|
url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.CalendarSubscriptionToken
|
model = models.Channel
|
||||||
fields = [
|
fields = [
|
||||||
"token",
|
"id",
|
||||||
"url",
|
"name",
|
||||||
|
"type",
|
||||||
|
"organization",
|
||||||
|
"user",
|
||||||
"caldav_path",
|
"caldav_path",
|
||||||
"calendar_name",
|
"role",
|
||||||
"is_active",
|
"is_active",
|
||||||
"last_accessed_at",
|
"settings",
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"token",
|
|
||||||
"url",
|
"url",
|
||||||
"caldav_path",
|
"last_used_at",
|
||||||
"calendar_name",
|
|
||||||
"is_active",
|
|
||||||
"last_accessed_at",
|
|
||||||
"created_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")
|
request = self.context.get("request")
|
||||||
if request:
|
if request:
|
||||||
url = request.build_absolute_uri(f"/ical/{obj.token}.ics")
|
url = request.build_absolute_uri(ical_path)
|
||||||
else:
|
else:
|
||||||
# Fallback to APP_URL if no request context
|
app_url = settings.APP_URL
|
||||||
app_url = getattr(settings, "APP_URL", "")
|
url = f"{app_url.rstrip('/')}{ical_path}"
|
||||||
url = f"{app_url.rstrip('/')}/ical/{obj.token}.ics"
|
|
||||||
|
|
||||||
# Force HTTPS in production to protect the token in transit
|
|
||||||
if not settings.DEBUG and url.startswith("http://"):
|
if not settings.DEBUG and url.startswith("http://"):
|
||||||
url = url.replace("http://", "https://", 1)
|
url = url.replace("http://", "https://", 1)
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
class ChannelCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||||
"""Serializer for creating a CalendarSubscriptionToken."""
|
"""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="")
|
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):
|
def validate_caldav_path(self, value):
|
||||||
"""Validate and normalize the caldav_path."""
|
"""Normalize caldav_path if provided."""
|
||||||
# Normalize path to always have trailing slash
|
if value:
|
||||||
if not value.endswith("/"):
|
if not value.endswith("/"):
|
||||||
value = value + "/"
|
value = value + "/"
|
||||||
# Normalize path to always start with /
|
if not value.startswith("/"):
|
||||||
if not value.startswith("/"):
|
value = "/" + value
|
||||||
value = "/" + value
|
|
||||||
return 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"]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
# pylint: disable=too-many-lines
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -8,9 +7,7 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
import rest_framework as drf
|
from rest_framework import mixins, pagination, response, status, views, viewsets
|
||||||
from rest_framework import response as drf_response
|
|
||||||
from rest_framework import status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
@@ -21,7 +18,8 @@ from core.services.caldav_service import (
|
|||||||
normalize_caldav_path,
|
normalize_caldav_path,
|
||||||
verify_caldav_access,
|
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
|
from . import permissions, serializers
|
||||||
|
|
||||||
@@ -31,60 +29,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# pylint: disable=too-many-ancestors
|
# 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:
|
class SerializerPerActionMixin:
|
||||||
"""
|
"""
|
||||||
A mixin to allow to define serializer classes for each action.
|
A mixin to allow to define serializer classes for each action.
|
||||||
@@ -110,10 +54,10 @@ class SerializerPerActionMixin:
|
|||||||
return super().get_serializer_class()
|
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."""
|
"""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
|
max_page_size = settings.MAX_PAGE_SIZE
|
||||||
page_size_query_param = "page_size"
|
page_size_query_param = "page_size"
|
||||||
|
|
||||||
@@ -132,9 +76,9 @@ class UserListThrottleSustained(UserRateThrottle):
|
|||||||
|
|
||||||
class UserViewSet(
|
class UserViewSet(
|
||||||
SerializerPerActionMixin,
|
SerializerPerActionMixin,
|
||||||
drf.mixins.UpdateModelMixin,
|
mixins.UpdateModelMixin,
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
drf.mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
):
|
):
|
||||||
"""User ViewSet"""
|
"""User ViewSet"""
|
||||||
|
|
||||||
@@ -142,7 +86,7 @@ class UserViewSet(
|
|||||||
queryset = models.User.objects.all().filter(is_active=True)
|
queryset = models.User.objects.all().filter(is_active=True)
|
||||||
serializer_class = serializers.UserSerializer
|
serializer_class = serializers.UserSerializer
|
||||||
get_me_serializer_class = serializers.UserMeSerializer
|
get_me_serializer_class = serializers.UserMeSerializer
|
||||||
pagination_class = None
|
pagination_class = Pagination
|
||||||
throttle_classes = []
|
throttle_classes = []
|
||||||
|
|
||||||
def get_throttles(self):
|
def get_throttles(self):
|
||||||
@@ -155,6 +99,7 @@ class UserViewSet(
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Limit listed users by querying the email field.
|
Limit listed users by querying the email field.
|
||||||
|
Scoped to the requesting user's organization.
|
||||||
If query contains "@", search exactly. Otherwise return empty.
|
If query contains "@", search exactly. Otherwise return empty.
|
||||||
"""
|
"""
|
||||||
queryset = self.queryset
|
queryset = self.queryset
|
||||||
@@ -162,19 +107,22 @@ class UserViewSet(
|
|||||||
if self.action != "list":
|
if self.action != "list":
|
||||||
return queryset
|
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:
|
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
# For emails, match exactly
|
# For emails, match exactly
|
||||||
if "@" in query:
|
if "@" in query:
|
||||||
return queryset.filter(email__iexact=query).order_by("email")[
|
return queryset.filter(email__iexact=query).order_by("email")
|
||||||
: settings.API_USERS_LIST_LIMIT
|
|
||||||
]
|
|
||||||
|
|
||||||
# For non-email queries, return empty (no fuzzy search)
|
# For non-email queries, return empty (no fuzzy search)
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@drf.decorators.action(
|
@action(
|
||||||
detail=False,
|
detail=False,
|
||||||
methods=["get"],
|
methods=["get"],
|
||||||
url_name="me",
|
url_name="me",
|
||||||
@@ -185,12 +133,12 @@ class UserViewSet(
|
|||||||
Return information on currently logged user
|
Return information on currently logged user
|
||||||
"""
|
"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
return drf.response.Response(
|
return response.Response(
|
||||||
self.get_serializer(request.user, context=context).data
|
self.get_serializer(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigView(drf.views.APIView):
|
class ConfigView(views.APIView):
|
||||||
"""API ViewSet for sharing some public settings."""
|
"""API ViewSet for sharing some public settings."""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -224,7 +172,7 @@ class ConfigView(drf.views.APIView):
|
|||||||
|
|
||||||
dict_settings["theme_customization"] = self._load_theme_customization()
|
dict_settings["theme_customization"] = self._load_theme_customization()
|
||||||
|
|
||||||
return drf.response.Response(dict_settings)
|
return response.Response(dict_settings)
|
||||||
|
|
||||||
def _load_theme_customization(self):
|
def _load_theme_customization(self):
|
||||||
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
|
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
|
||||||
@@ -272,7 +220,7 @@ class CalendarViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action == "import_events":
|
if self.action == "import_events":
|
||||||
return [permissions.IsEntitled()]
|
return [permissions.IsEntitledToAccess()]
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
@@ -286,12 +234,18 @@ class CalendarViewSet(viewsets.GenericViewSet):
|
|||||||
"""Import events from an ICS file into a calendar.
|
"""Import events from an ICS file into a calendar.
|
||||||
|
|
||||||
POST /api/v1.0/calendars/import-events/
|
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", "")
|
caldav_path = request.data.get("caldav_path", "")
|
||||||
if not caldav_path:
|
if not caldav_path:
|
||||||
return drf_response.Response(
|
return response.Response(
|
||||||
{"error": "caldav_path is required"},
|
{"detail": "caldav_path is required"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -299,15 +253,15 @@ class CalendarViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
# Verify user access
|
# Verify user access
|
||||||
if not verify_caldav_access(request.user, caldav_path):
|
if not verify_caldav_access(request.user, caldav_path):
|
||||||
return drf_response.Response(
|
return response.Response(
|
||||||
{"error": "You don't have access to this calendar"},
|
{"detail": "You don't have access to this calendar"},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate file presence
|
# Validate file presence
|
||||||
if "file" not in request.FILES:
|
if "file" not in request.FILES:
|
||||||
return drf_response.Response(
|
return response.Response(
|
||||||
{"error": "No file provided"},
|
{"detail": "No file provided"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,124 +269,81 @@ class CalendarViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
if uploaded_file.size > MAX_FILE_SIZE:
|
if uploaded_file.size > MAX_FILE_SIZE:
|
||||||
return drf_response.Response(
|
return response.Response(
|
||||||
{"error": "File too large. Maximum size is 10 MB."},
|
{"detail": "File too large. Maximum size is 10 MB."},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
ics_data = uploaded_file.read()
|
ics_data = uploaded_file.read()
|
||||||
service = ICSImportService()
|
|
||||||
result = service.import_events(request.user, caldav_path, ics_data)
|
|
||||||
|
|
||||||
response_data = {
|
# Queue the import task
|
||||||
"total_events": result.total_events,
|
task = import_events_task.delay(
|
||||||
"imported_count": result.imported_count,
|
str(request.user.id),
|
||||||
"duplicate_count": result.duplicate_count,
|
caldav_path,
|
||||||
"skipped_count": result.skipped_count,
|
ics_data.hex(),
|
||||||
}
|
)
|
||||||
if result.errors:
|
task.track_owner(request.user.id)
|
||||||
response_data["errors"] = result.errors
|
|
||||||
|
|
||||||
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):
|
class ResourceViewSet(viewsets.ViewSet):
|
||||||
"""
|
"""ViewSet for resource provisioning (create/delete).
|
||||||
ViewSet for managing subscription tokens independently of Django Calendar model.
|
|
||||||
|
|
||||||
This viewset operates directly with CalDAV paths, without requiring a Django
|
Resources are CalDAV principals — this endpoint only handles
|
||||||
Calendar record. The backend verifies that the user has access to the calendar
|
provisioning. All metadata, sharing, and discovery goes through CalDAV.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [permissions.IsOrgAdmin]
|
||||||
serializer_class = serializers.CalendarSubscriptionTokenSerializer
|
|
||||||
|
|
||||||
def create(self, request):
|
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:
|
if not name:
|
||||||
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
|
return response.Response(
|
||||||
- calendar_name: Display name of the calendar (optional)
|
{"detail": "name is required."},
|
||||||
"""
|
|
||||||
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"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
caldav_path = normalize_caldav_path(caldav_path)
|
service = ResourceService()
|
||||||
|
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = models.CalendarSubscriptionToken.objects.get(
|
result = service.create_resource(request.user, name, resource_type)
|
||||||
owner=request.user,
|
except ResourceProvisioningError as e:
|
||||||
caldav_path=caldav_path,
|
return response.Response(
|
||||||
)
|
{"detail": str(e)},
|
||||||
except models.CalendarSubscriptionToken.DoesNotExist:
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
return drf_response.Response(
|
|
||||||
{"error": "No subscription token exists for this calendar"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.method == "GET":
|
return response.Response(result, status=status.HTTP_201_CREATED)
|
||||||
serializer = self.get_serializer(token, context={"request": request})
|
|
||||||
return drf_response.Response(serializer.data)
|
|
||||||
|
|
||||||
# DELETE
|
def destroy(self, request, pk=None):
|
||||||
token.delete()
|
"""Delete a resource principal and its calendar.
|
||||||
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
"""CalDAV proxy views for forwarding requests to CalDAV server."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.validators import validate_email
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
@@ -13,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
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.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
|
||||||
from core.services.calendar_invitation_service import calendar_invitation_service
|
from core.services.calendar_invitation_service import calendar_invitation_service
|
||||||
|
|
||||||
@@ -30,6 +34,73 @@ class CalDAVProxyView(View):
|
|||||||
Authentication is handled via session cookies instead.
|
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
|
@staticmethod
|
||||||
def _check_entitlements_for_creation(user):
|
def _check_entitlements_for_creation(user):
|
||||||
"""Check if user is entitled to create calendars.
|
"""Check if user is entitled to create calendars.
|
||||||
@@ -39,7 +110,7 @@ class CalDAVProxyView(View):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
entitlements = get_user_entitlements(user.sub, user.email)
|
entitlements = get_user_entitlements(user.sub, user.email)
|
||||||
if not entitlements.get("can_access", True):
|
if not entitlements.get("can_access", False):
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=403,
|
status=403,
|
||||||
content="Calendar creation not allowed",
|
content="Calendar creation not allowed",
|
||||||
@@ -51,27 +122,42 @@ class CalDAVProxyView(View):
|
|||||||
)
|
)
|
||||||
return None
|
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."""
|
"""Forward all HTTP methods to CalDAV server."""
|
||||||
# Handle CORS preflight requests
|
# Handle CORS preflight requests
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
response = HttpResponse(status=200)
|
response = HttpResponse(status=200)
|
||||||
response["Access-Control-Allow-Methods"] = (
|
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"] = (
|
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
|
return response
|
||||||
|
|
||||||
|
# Try channel token auth first (for external services like Messages)
|
||||||
|
channel = None
|
||||||
|
effective_user = None
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return HttpResponse(status=401)
|
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.
|
if channel:
|
||||||
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
|
# Enforce role-based method restrictions
|
||||||
# so that users invited to shared calendars can still use them.
|
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 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
|
return denied
|
||||||
|
|
||||||
# Build the CalDAV server URL
|
# Build the CalDAV server URL
|
||||||
@@ -81,8 +167,9 @@ class CalDAVProxyView(View):
|
|||||||
if not validate_caldav_proxy_path(path):
|
if not validate_caldav_proxy_path(path):
|
||||||
return HttpResponse(status=400, content="Invalid path")
|
return HttpResponse(status=400, content="Invalid path")
|
||||||
|
|
||||||
# Use user email as the principal (CalDAV server uses email as username)
|
# Enforce channel path scope
|
||||||
user_principal = request.user.email
|
if channel and not self._check_channel_path_access(channel, path):
|
||||||
|
return HttpResponse(status=403, content="Path not allowed for this channel")
|
||||||
|
|
||||||
http = CalDAVHTTPClient()
|
http = CalDAVHTTPClient()
|
||||||
|
|
||||||
@@ -95,7 +182,7 @@ class CalDAVProxyView(View):
|
|||||||
|
|
||||||
# Prepare headers — start with shared auth headers, add proxy-specific ones
|
# Prepare headers — start with shared auth headers, add proxy-specific ones
|
||||||
try:
|
try:
|
||||||
headers = CalDAVHTTPClient.build_base_headers(user_principal)
|
headers = CalDAVHTTPClient.build_base_headers(effective_user)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -112,7 +199,7 @@ class CalDAVProxyView(View):
|
|||||||
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
|
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
|
||||||
# the CalDAV container needs to reach Django via internal network)
|
# the CalDAV container needs to reach Django via internal network)
|
||||||
callback_path = reverse("caldav-scheduling-callback")
|
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:
|
if callback_base_url:
|
||||||
# Use configured internal URL (e.g., http://backend:8000)
|
# Use configured internal URL (e.g., http://backend:8000)
|
||||||
headers["X-CalDAV-Callback-URL"] = (
|
headers["X-CalDAV-Callback-URL"] = (
|
||||||
@@ -145,7 +232,7 @@ class CalDAVProxyView(View):
|
|||||||
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
"Forwarding %s request to CalDAV server: %s (user: %s)",
|
||||||
request.method,
|
request.method,
|
||||||
target_url,
|
target_url,
|
||||||
user_principal,
|
effective_user.email,
|
||||||
)
|
)
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
method=request.method,
|
method=request.method,
|
||||||
@@ -161,7 +248,7 @@ class CalDAVProxyView(View):
|
|||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"CalDAV server returned 401 for user %s at %s",
|
"CalDAV server returned 401 for user %s at %s",
|
||||||
user_principal,
|
effective_user.email,
|
||||||
target_url,
|
target_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,7 +269,7 @@ class CalDAVProxyView(View):
|
|||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error("CalDAV server proxy error: %s", str(e))
|
logger.error("CalDAV server proxy error: %s", str(e))
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
content=f"CalDAV server error: {str(e)}",
|
content="CalDAV server is unavailable",
|
||||||
status=502,
|
status=502,
|
||||||
content_type="text/plain",
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
@@ -216,9 +303,8 @@ class CalDAVDiscoveryView(View):
|
|||||||
# Clients need to discover the CalDAV URL before authenticating
|
# Clients need to discover the CalDAV URL before authenticating
|
||||||
|
|
||||||
# Return redirect to CalDAV server base URL
|
# Return redirect to CalDAV server base URL
|
||||||
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
|
|
||||||
response = HttpResponse(status=301)
|
response = HttpResponse(status=301)
|
||||||
response["Location"] = caldav_base_url
|
response["Location"] = "/caldav/"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -239,24 +325,25 @@ class CalDAVSchedulingCallbackView(View):
|
|||||||
See: https://sabre.io/dav/scheduling/
|
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."""
|
"""Handle scheduling messages from CalDAV server."""
|
||||||
# Authenticate via API key
|
# Authenticate via API key
|
||||||
api_key = request.headers.get("X-Api-Key", "").strip()
|
api_key = request.headers.get("X-Api-Key", "").strip()
|
||||||
expected_key = settings.CALDAV_INBOUND_API_KEY
|
expected_key = settings.CALDAV_INBOUND_API_KEY
|
||||||
|
|
||||||
if not expected_key or not secrets.compare_digest(api_key, expected_key):
|
if not expected_key or not secrets.compare_digest(api_key, expected_key):
|
||||||
logger.warning(
|
logger.warning("CalDAV scheduling callback request with invalid API key.")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
return HttpResponse(status=401)
|
return HttpResponse(status=401)
|
||||||
|
|
||||||
# Extract headers
|
# Extract and validate sender/recipient emails
|
||||||
sender = request.headers.get("X-CalDAV-Sender", "")
|
sender = re.sub(
|
||||||
recipient = request.headers.get("X-CalDAV-Recipient", "")
|
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()
|
method = request.headers.get("X-CalDAV-Method", "").upper()
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
@@ -275,6 +362,22 @@ class CalDAVSchedulingCallbackView(View):
|
|||||||
content_type="text/plain",
|
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
|
# Get iCalendar data from request body
|
||||||
icalendar_data = (
|
icalendar_data = (
|
||||||
request.body.decode("utf-8", errors="replace") if request.body else ""
|
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)
|
logger.exception("Error processing CalDAV scheduling callback: %s", e)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=500,
|
status=500,
|
||||||
content=f"Internal error: {str(e)}",
|
content="Internal server error",
|
||||||
content_type="text/plain",
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
|
|||||||
148
src/backend/core/api/viewsets_channels.py
Normal file
148
src/backend/core/api/viewsets_channels.py
Normal 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
|
||||||
@@ -2,19 +2,24 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.text import slugify
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.models import CalendarSubscriptionToken
|
from core.models import Channel, urlsafe_to_uuid
|
||||||
from core.services.caldav_service import CalDAVHTTPClient
|
from core.services.caldav_service import CalDAVHTTPClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ICAL_RATE_LIMIT = 5 # requests per minute per channel
|
||||||
|
ICAL_RATE_WINDOW = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class ICalExportView(View):
|
class ICalExportView(View):
|
||||||
@@ -22,40 +27,49 @@ class ICalExportView(View):
|
|||||||
Public endpoint for iCal calendar exports.
|
Public endpoint for iCal calendar exports.
|
||||||
|
|
||||||
This view serves calendar data in iCal format without requiring authentication.
|
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
|
Looks up a Channel by base64url-encoded ID, verifies the token, then
|
||||||
RFC 5545 compliant iCal data.
|
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."""
|
"""Handle GET requests for iCal export."""
|
||||||
# Lookup token
|
try:
|
||||||
subscription = (
|
channel_id = urlsafe_to_uuid(short_id)
|
||||||
CalendarSubscriptionToken.objects.filter(token=token, is_active=True)
|
channel = Channel.objects.get(pk=channel_id, is_active=True)
|
||||||
.select_related("owner")
|
except (ValueError, Channel.DoesNotExist) as exc:
|
||||||
.first()
|
raise Http404("Calendar not found") from exc
|
||||||
)
|
|
||||||
|
|
||||||
if not subscription:
|
if channel.type != "ical-feed":
|
||||||
logger.warning("Invalid or inactive subscription token: %s", token)
|
|
||||||
raise Http404("Calendar not found")
|
raise Http404("Calendar not found")
|
||||||
|
|
||||||
# Update last_accessed_at atomically to avoid race conditions
|
if not channel.verify_token(token):
|
||||||
# when multiple calendar clients poll simultaneously
|
raise Http404("Calendar not found")
|
||||||
CalendarSubscriptionToken.objects.filter(token=token, is_active=True).update(
|
|
||||||
last_accessed_at=timezone.now()
|
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
|
# Proxy to SabreDAV
|
||||||
http = CalDAVHTTPClient()
|
http = CalDAVHTTPClient()
|
||||||
try:
|
try:
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
response = http.request(
|
response = http.request(
|
||||||
"GET",
|
"GET",
|
||||||
subscription.owner.email,
|
channel.user,
|
||||||
caldav_path,
|
caldav_path,
|
||||||
query="export",
|
query="export",
|
||||||
)
|
)
|
||||||
@@ -88,15 +102,12 @@ class ICalExportView(View):
|
|||||||
status=200,
|
status=200,
|
||||||
content_type="text/calendar; charset=utf-8",
|
content_type="text/calendar; charset=utf-8",
|
||||||
)
|
)
|
||||||
# Set filename for download (use calendar_name or fallback to "calendar")
|
calendar_name = channel.settings.get("calendar_name", "")
|
||||||
display_name = subscription.calendar_name or "calendar"
|
filename = slugify(calendar_name)[:50] or "feed"
|
||||||
safe_name = display_name.replace('"', '\\"')
|
|
||||||
django_response["Content-Disposition"] = (
|
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"
|
django_response["Cache-Control"] = "no-store, private"
|
||||||
# Prevent token leakage via referrer
|
|
||||||
django_response["Referrer-Policy"] = "no-referrer"
|
django_response["Referrer-Policy"] = "no-referrer"
|
||||||
|
|
||||||
return django_response
|
return django_response
|
||||||
|
|||||||
@@ -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 logging
|
||||||
import re
|
import re
|
||||||
from datetime import timezone as dt_timezone
|
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.shortcuts import render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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.caldav_service import CalDAVHTTPClient
|
||||||
from core.services.translation_service import TranslationService
|
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."""
|
"""Render the RSVP error page."""
|
||||||
t = TranslationService.t
|
t = TranslationService.t
|
||||||
return render(
|
return render(
|
||||||
@@ -85,69 +97,169 @@ def _is_event_past(icalendar_data):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
def _validate_token(token, max_age=None):
|
||||||
class RSVPView(View):
|
"""Unsign and validate an RSVP token.
|
||||||
"""Handle RSVP responses from invitation email links."""
|
|
||||||
|
|
||||||
def get(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements
|
Returns (payload, error_key). On success error_key is None.
|
||||||
"""Process an RSVP response."""
|
"""
|
||||||
|
ts_signer = TimestampSigner(salt="rsvp")
|
||||||
|
try:
|
||||||
|
payload = ts_signer.unsign_object(token, max_age=max_age)
|
||||||
|
except SignatureExpired:
|
||||||
|
return None, "token_expired"
|
||||||
|
except BadSignature:
|
||||||
|
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:", "", organizer_email, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
if not uid or not recipient_email or not organizer_email:
|
||||||
|
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", "")
|
token = request.GET.get("token", "")
|
||||||
action = request.GET.get("action", "")
|
action = request.GET.get("action", "")
|
||||||
lang = TranslationService.resolve_language(request=request)
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if _is_event_past(calendar_data):
|
||||||
|
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
|
||||||
|
|
||||||
|
updated_data = CalDAVHTTPClient.update_attendee_partstat(
|
||||||
|
calendar_data, payload["email"], PARTSTAT_VALUES[action]
|
||||||
|
)
|
||||||
|
if not updated_data:
|
||||||
|
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
|
||||||
|
|
||||||
|
if not http.put_event(organizer, href, updated_data, etag=etag):
|
||||||
|
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
|
||||||
|
|
||||||
|
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
|
t = TranslationService.t
|
||||||
|
|
||||||
# Validate action
|
payload, error_response = _validate_and_render_error(
|
||||||
if action not in PARTSTAT_VALUES:
|
request, token, action, lang
|
||||||
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")
|
|
||||||
try:
|
|
||||||
payload = signer.unsign_object(token)
|
|
||||||
except BadSignature:
|
|
||||||
return _render_error(request, t("rsvp.error.invalidToken", lang), lang)
|
|
||||||
|
|
||||||
uid = payload.get("uid")
|
|
||||||
recipient_email = payload.get("email")
|
|
||||||
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
|
|
||||||
organizer_email = re.sub(
|
|
||||||
r"^mailto:", "", payload.get("organizer", ""), flags=re.IGNORECASE
|
|
||||||
)
|
)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
if not uid or not recipient_email or not organizer_email:
|
result = _process_rsvp(request, payload, action, lang)
|
||||||
return _render_error(request, t("rsvp.error.invalidPayload", lang), lang)
|
|
||||||
|
|
||||||
http = CalDAVHTTPClient()
|
# result is either an error HttpResponse or calendar data string
|
||||||
|
if not isinstance(result, str):
|
||||||
|
return result
|
||||||
|
|
||||||
# Find the event in the organizer's CalDAV calendars
|
|
||||||
calendar_data, href = http.find_event_by_uid(organizer_email, 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
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
|
|
||||||
|
|
||||||
# Extract event summary for display
|
|
||||||
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||||
ICalendarParser,
|
ICalendarParser,
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = ICalendarParser.extract_property(calendar_data, "SUMMARY") or ""
|
summary = ICalendarParser.extract_property(result, "SUMMARY") or ""
|
||||||
label = t(f"rsvp.{action}", lang)
|
label = t(f"rsvp.{action}", lang)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
|
|||||||
104
src/backend/core/api/viewsets_task.py
Normal file
104
src/backend/core/api/viewsets_task.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Calendars Core application"""
|
"""Calendars Core application"""
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
|
|
||||||
class CoreConfig(AppConfig):
|
class CoreConfig(AppConfig):
|
||||||
@@ -9,7 +8,7 @@ class CoreConfig(AppConfig):
|
|||||||
|
|
||||||
name = "core"
|
name = "core"
|
||||||
app_label = "core"
|
app_label = "core"
|
||||||
verbose_name = _("calendars core application")
|
verbose_name = "calendars core application"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,11 +10,51 @@ from lasuite.oidc_login.backends import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||||
from core.models import DuplicateEmailError
|
from core.models import DuplicateEmailError, Organization
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
|
|
||||||
@@ -23,39 +63,46 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def get_extra_claims(self, user_info):
|
def get_extra_claims(self, user_info):
|
||||||
"""
|
"""Return extra claims from 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.
|
|
||||||
claims_to_store = {
|
claims_to_store = {
|
||||||
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
|
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"full_name": self.compute_full_name(user_info),
|
"full_name": self.compute_full_name(user_info),
|
||||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
|
||||||
"claims": claims_to_store,
|
"claims": claims_to_store,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Fetch existing user by sub or email."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||||
except DuplicateEmailError as err:
|
except DuplicateEmailError as err:
|
||||||
raise SuspiciousOperation(err.message) from 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):
|
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:
|
try:
|
||||||
get_user_entitlements(
|
entitlements = get_user_entitlements(
|
||||||
user_sub=user.sub,
|
user_sub=user.sub,
|
||||||
user_email=user.email,
|
user_email=user.email,
|
||||||
user_info=claims,
|
user_info=claims,
|
||||||
@@ -66,3 +113,5 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
"Entitlements unavailable for %s during login",
|
"Entitlements unavailable for %s during login",
|
||||||
user.email,
|
user.email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resolve_organization(user, claims, entitlements)
|
||||||
|
|||||||
@@ -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.
|
force_refresh: If True, bypass backend cache and fetch fresh data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {"can_access": bool}
|
dict: {"can_access": bool, "can_admin": bool, ...}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EntitlementsUnavailableError: If the backend cannot be reached
|
EntitlementsUnavailableError: If the backend cannot be reached
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ class EntitlementsBackend(ABC):
|
|||||||
force_refresh: If True, bypass any cache and fetch fresh data.
|
force_refresh: If True, bypass any cache and fetch fresh data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {"can_access": bool}
|
dict: {
|
||||||
|
"can_access": bool,
|
||||||
|
"can_admin": bool,
|
||||||
|
"organization_name": str, # optional, extracted from response
|
||||||
|
}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EntitlementsUnavailableError: If the backend cannot be reached.
|
EntitlementsUnavailableError: If the backend cannot be reached.
|
||||||
|
|||||||
@@ -114,7 +114,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend):
|
|||||||
entitlements = data.get("entitlements", {})
|
entitlements = data.get("entitlements", {})
|
||||||
result = {
|
result = {
|
||||||
"can_access": entitlements.get("can_access", False),
|
"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)
|
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from core.entitlements.backends.base import EntitlementsBackend
|
|||||||
|
|
||||||
|
|
||||||
class LocalEntitlementsBackend(EntitlementsBackend):
|
class LocalEntitlementsBackend(EntitlementsBackend):
|
||||||
"""Local backend that always grants access."""
|
"""Local backend that always grants access and admin."""
|
||||||
|
|
||||||
def get_user_entitlements(
|
def get_user_entitlements(
|
||||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||||
):
|
):
|
||||||
return {"can_access": True}
|
return {"can_access": True, "can_admin": True}
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
"""
|
"""
|
||||||
Core application enums declaration
|
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}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
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.api.viewsets import UserViewSet
|
||||||
from core.external_api.permissions import ResourceServerClientPermission
|
from core.external_api.permissions import ResourceServerClientPermission
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
@@ -13,6 +15,16 @@ from core import models
|
|||||||
fake = Faker()
|
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):
|
class UserFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to random users for testing purposes."""
|
"""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}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
full_name = factory.Faker("name")
|
full_name = factory.Faker("name")
|
||||||
short_name = factory.Faker("first_name")
|
|
||||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||||
password = make_password("password")
|
password = make_password("password")
|
||||||
|
organization = factory.SubFactory(OrganizationFactory)
|
||||||
|
|
||||||
|
|
||||||
class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory):
|
class ChannelFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to create calendar subscription tokens for testing purposes."""
|
"""A factory to create channels for testing purposes."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.CalendarSubscriptionToken
|
model = models.Channel
|
||||||
|
|
||||||
owner = factory.SubFactory(UserFactory)
|
name = factory.Faker("sentence", nb_words=3)
|
||||||
caldav_path = factory.LazyAttribute(
|
user = factory.SubFactory(UserFactory)
|
||||||
lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}/"
|
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
|
|
||||||
|
|||||||
@@ -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 core.models
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import encrypted_fields.fields
|
||||||
import timezone_field.fields
|
import timezone_field.fields
|
||||||
import uuid
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -18,6 +19,21 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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(
|
migrations.CreateModel(
|
||||||
name='User',
|
name='User',
|
||||||
fields=[
|
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')),
|
('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')),
|
('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')),
|
('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, db_index=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
('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')),
|
('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)),
|
('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.')),
|
('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')),
|
('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')),
|
('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={
|
options={
|
||||||
'verbose_name': 'user',
|
'verbose_name': 'user',
|
||||||
@@ -51,40 +67,26 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Calendar',
|
name='Channel',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('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')),
|
||||||
('name', models.CharField(max_length=255)),
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||||
('color', models.CharField(default='#3174ad', max_length=7)),
|
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||||
('description', models.TextField(blank=True, default='')),
|
('name', models.CharField(help_text='Human-readable name for this channel.', max_length=255)),
|
||||||
('is_default', models.BooleanField(default=False)),
|
('type', models.CharField(default='caldav', help_text='Type of channel.', max_length=255)),
|
||||||
('is_visible', models.BooleanField(default=True)),
|
('caldav_path', models.CharField(blank=True, default='', help_text='CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).', max_length=512)),
|
||||||
('caldav_path', models.CharField(max_length=512, unique=True)),
|
('is_active', models.BooleanField(default=True)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('settings', models.JSONField(blank=True, default=dict, help_text='Channel-specific configuration settings (e.g. role).', verbose_name='settings')),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('encrypted_settings', encrypted_fields.fields.EncryptedJSONField(blank=True, default=dict, help_text='Encrypted channel settings (e.g. token).', verbose_name='encrypted settings')),
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
|
('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={
|
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')},
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
Declare and configure the models for the calendars core application
|
Declare and configure the models for the calendars core application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
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.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.core import mail, validators
|
from django.core import mail, validators
|
||||||
from django.db import models
|
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
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
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):
|
class DuplicateEmailError(Exception):
|
||||||
"""Raised when an email is already associated with a pre-existing user."""
|
"""Raised when an email is already associated with a pre-existing user."""
|
||||||
|
|
||||||
@@ -70,21 +38,21 @@ class BaseModel(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.UUIDField(
|
id = models.UUIDField(
|
||||||
verbose_name=_("id"),
|
verbose_name="id",
|
||||||
help_text=_("primary key for the record as UUID"),
|
help_text="primary key for the record as UUID",
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
default=uuid.uuid4,
|
default=uuid.uuid4,
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
verbose_name=_("created on"),
|
verbose_name="created on",
|
||||||
help_text=_("date and time at which a record was created"),
|
help_text="date and time at which a record was created",
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
updated_at = models.DateTimeField(
|
updated_at = models.DateTimeField(
|
||||||
verbose_name=_("updated on"),
|
verbose_name="updated on",
|
||||||
help_text=_("date and time at which a record was last updated"),
|
help_text="date and time at which a record was last updated",
|
||||||
auto_now=True,
|
auto_now=True,
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
@@ -98,6 +66,46 @@ class BaseModel(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
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):
|
class UserManager(auth_models.UserManager):
|
||||||
"""Custom manager for User model with additional methods."""
|
"""Custom manager for User model with additional methods."""
|
||||||
|
|
||||||
@@ -119,10 +127,8 @@ class UserManager(auth_models.UserManager):
|
|||||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||||
):
|
):
|
||||||
raise DuplicateEmailError(
|
raise DuplicateEmailError(
|
||||||
_(
|
"We couldn't find a user with this sub but the email is already "
|
||||||
"We couldn't find a user with this sub but the email is already "
|
"associated with a registered user."
|
||||||
"associated with a registered user."
|
|
||||||
)
|
|
||||||
) from err
|
) from err
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -132,16 +138,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
|
|
||||||
sub_validator = validators.RegexValidator(
|
sub_validator = validators.RegexValidator(
|
||||||
regex=r"^[\w.@+-:]+\Z",
|
regex=r"^[\w.@+-:]+\Z",
|
||||||
message=_(
|
message=(
|
||||||
"Enter a valid sub. This value may contain only letters, "
|
"Enter a valid sub. This value may contain only letters, "
|
||||||
"numbers, and @/./+/-/_/: characters."
|
"numbers, and @/./+/-/_/: characters."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
sub = models.CharField(
|
sub = models.CharField(
|
||||||
_("sub"),
|
"sub",
|
||||||
help_text=_(
|
help_text=(
|
||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
"Required. 255 characters or fewer."
|
||||||
|
" Letters, numbers, and @/./+/-/_/: characters only."
|
||||||
),
|
),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -150,23 +157,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
full_name = models.CharField("full name", max_length=100, null=True, blank=True)
|
||||||
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
|
|
||||||
|
|
||||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
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
|
# 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
|
# stores the email used by staff users to login to the admin site
|
||||||
admin_email = models.EmailField(
|
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(
|
language = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=settings.LANGUAGES,
|
choices=settings.LANGUAGES,
|
||||||
default=None,
|
default=None,
|
||||||
verbose_name=_("language"),
|
verbose_name="language",
|
||||||
help_text=_("The language in which the user wants to see the interface."),
|
help_text="The language in which the user wants to see the interface.",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
@@ -174,31 +182,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
choices_display="WITH_GMT_OFFSET",
|
choices_display="WITH_GMT_OFFSET",
|
||||||
use_pytz=False,
|
use_pytz=False,
|
||||||
default=settings.TIME_ZONE,
|
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(
|
is_device = models.BooleanField(
|
||||||
_("device"),
|
"device",
|
||||||
default=False,
|
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(
|
is_staff = models.BooleanField(
|
||||||
_("staff status"),
|
"staff status",
|
||||||
default=False,
|
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(
|
is_active = models.BooleanField(
|
||||||
_("active"),
|
"active",
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_(
|
help_text=(
|
||||||
"Whether this user should be treated as active. "
|
"Whether this user should be treated as active. "
|
||||||
"Unselect this instead of deleting accounts."
|
"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(
|
claims = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
help_text=_("Claims from the OIDC token."),
|
help_text="Claims from the OIDC token.",
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
@@ -208,8 +223,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "calendars_user"
|
db_table = "calendars_user"
|
||||||
verbose_name = _("user")
|
verbose_name = "user"
|
||||||
verbose_name_plural = _("users")
|
verbose_name_plural = "users"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.admin_email or str(self.id)
|
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.")
|
raise ValueError("User has no email address.")
|
||||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def teams(self):
|
def uuid_to_urlsafe(u):
|
||||||
"""
|
"""Encode a UUID as unpadded base64url (22 chars)."""
|
||||||
Get list of teams in which the user is, as a list of strings.
|
return base64.urlsafe_b64encode(u.bytes).rstrip(b"=").decode()
|
||||||
Must be cached if retrieved remotely.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccess(BaseModel):
|
def urlsafe_to_uuid(s):
|
||||||
"""Base model for accesses to handle resources."""
|
"""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 = models.ForeignKey(
|
||||||
User,
|
"User",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
related_name="channels",
|
||||||
team = models.CharField(max_length=100, blank=True)
|
help_text="User who created this channel (used for permissions and auditing).",
|
||||||
role = models.CharField(
|
|
||||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
organization = models.ForeignKey(
|
||||||
abstract = True
|
Organization,
|
||||||
|
|
||||||
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,
|
|
||||||
on_delete=models.CASCADE,
|
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(
|
caldav_path = models.CharField(
|
||||||
max_length=512,
|
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,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
help_text=_("Display name of the calendar"),
|
help_text="CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).",
|
||||||
)
|
)
|
||||||
|
|
||||||
token = models.UUIDField(
|
is_active = models.BooleanField(default=True)
|
||||||
unique=True,
|
|
||||||
db_index=True,
|
settings = models.JSONField(
|
||||||
default=uuid.uuid4,
|
"settings",
|
||||||
help_text=_("Secret token used in the subscription URL"),
|
default=dict,
|
||||||
)
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text=_("Whether this subscription token is active"),
|
|
||||||
)
|
|
||||||
last_accessed_at = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
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:
|
class Meta:
|
||||||
verbose_name = _("calendar subscription token")
|
db_table = "calendars_channel"
|
||||||
verbose_name_plural = _("calendar subscription tokens")
|
verbose_name = "channel"
|
||||||
constraints = [
|
verbose_name_plural = "channels"
|
||||||
models.UniqueConstraint(
|
ordering = ["-created_at"]
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
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)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Services for CalDAV integration."""
|
"""Services for CalDAV integration."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from datetime import timezone as dt_timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -30,7 +32,7 @@ class CalDAVHTTPClient:
|
|||||||
and HTTP requests. All higher-level CalDAV consumers delegate to this.
|
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
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -45,11 +47,21 @@ class CalDAVHTTPClient:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build_base_headers(cls, email: str) -> dict:
|
def build_base_headers(cls, user) -> dict:
|
||||||
"""Build authentication headers for CalDAV requests."""
|
"""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 {
|
return {
|
||||||
"X-Api-Key": cls.get_api_key(),
|
"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:
|
def build_url(self, path: str, query: str = "") -> str:
|
||||||
@@ -70,7 +82,7 @@ class CalDAVHTTPClient:
|
|||||||
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
email: str,
|
user,
|
||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -80,7 +92,7 @@ class CalDAVHTTPClient:
|
|||||||
content_type: str | None = None,
|
content_type: str | None = None,
|
||||||
) -> requests.Response:
|
) -> requests.Response:
|
||||||
"""Make an authenticated HTTP request to the CalDAV server."""
|
"""Make an authenticated HTTP request to the CalDAV server."""
|
||||||
headers = self.build_base_headers(email)
|
headers = self.build_base_headers(user)
|
||||||
if content_type:
|
if content_type:
|
||||||
headers["Content-Type"] = content_type
|
headers["Content-Type"] = content_type
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
@@ -95,9 +107,13 @@ class CalDAVHTTPClient:
|
|||||||
timeout=timeout or self.DEFAULT_TIMEOUT,
|
timeout=timeout or self.DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_dav_client(self, email: str) -> DAVClient:
|
def get_dav_client(self, user) -> DAVClient:
|
||||||
"""Return a configured caldav.DAVClient for the given user email."""
|
"""Return a configured caldav.DAVClient for the given user.
|
||||||
headers = self.build_base_headers(email)
|
|
||||||
|
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}/"
|
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
|
||||||
return DAVClient(
|
return DAVClient(
|
||||||
url=caldav_url,
|
url=caldav_url,
|
||||||
@@ -107,38 +123,58 @@ class CalDAVHTTPClient:
|
|||||||
headers=headers,
|
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.
|
"""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:
|
try:
|
||||||
principal = client.principal()
|
principal = client.principal()
|
||||||
for cal in principal.calendars():
|
for cal in principal.calendars():
|
||||||
try:
|
try:
|
||||||
event = cal.object_by_uid(uid)
|
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:
|
except caldav_lib.error.NotFoundError:
|
||||||
continue
|
continue
|
||||||
logger.warning("Event UID %s not found in user %s calendars", uid, email)
|
logger.warning(
|
||||||
return None, None
|
"Event UID %s not found in user %s calendars", uid, user.email
|
||||||
|
)
|
||||||
|
return None, None, None
|
||||||
except Exception: # pylint: disable=broad-exception-caught
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
logger.exception("CalDAV error looking up event %s", uid)
|
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:
|
def put_event(
|
||||||
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
|
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:
|
try:
|
||||||
|
extra_headers = {}
|
||||||
|
if etag:
|
||||||
|
extra_headers["If-Match"] = etag
|
||||||
response = self.request(
|
response = self.request(
|
||||||
"PUT",
|
"PUT",
|
||||||
email,
|
user,
|
||||||
href,
|
href,
|
||||||
data=ical_data.encode("utf-8"),
|
data=ical_data.encode("utf-8"),
|
||||||
content_type="text/calendar; charset=utf-8",
|
content_type="text/calendar; charset=utf-8",
|
||||||
|
extra_headers=extra_headers or None,
|
||||||
)
|
)
|
||||||
if response.status_code in (200, 201, 204):
|
if response.status_code in (200, 201, 204):
|
||||||
return True
|
return True
|
||||||
|
if response.status_code == 412:
|
||||||
|
logger.warning("CalDAV PUT conflict (ETag mismatch) for %s", href)
|
||||||
|
return False
|
||||||
logger.error(
|
logger.error(
|
||||||
"CalDAV PUT failed: %s %s",
|
"CalDAV PUT failed: %s %s",
|
||||||
response.status_code,
|
response.status_code,
|
||||||
@@ -160,10 +196,10 @@ class CalDAVHTTPClient:
|
|||||||
cal = icalendar.Calendar.from_ical(ical_data)
|
cal = icalendar.Calendar.from_ical(ical_data)
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
|
target = f"mailto:{email.lower()}"
|
||||||
for component in cal.walk("VEVENT"):
|
for component in cal.walk("VEVENT"):
|
||||||
for _name, attendee in component.property_items("ATTENDEE"):
|
for _name, attendee in component.property_items("ATTENDEE"):
|
||||||
attendee_val = str(attendee).lower()
|
if str(attendee).lower().strip() == target:
|
||||||
if email.lower() in attendee_val:
|
|
||||||
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
|
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
@@ -182,14 +218,19 @@ class CalDAVClient:
|
|||||||
self._http = CalDAVHTTPClient()
|
self._http = CalDAVHTTPClient()
|
||||||
self.base_url = self._http.base_url
|
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:
|
def _get_client(self, user) -> DAVClient:
|
||||||
"""
|
"""
|
||||||
Get a CalDAV client for the given user.
|
Get a CalDAV client for the given user.
|
||||||
|
|
||||||
The CalDAV server requires API key authentication via Authorization header
|
The CalDAV server requires API key authentication via Authorization header
|
||||||
and X-Forwarded-User header for user identification.
|
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:
|
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.
|
Returns dict with name, color, description or None if not found.
|
||||||
"""
|
"""
|
||||||
client = self._get_client(user)
|
client = self._get_client(user)
|
||||||
calendar_url = f"{self.base_url}{calendar_path}"
|
calendar_url = self._calendar_url(calendar_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
calendar = client.calendar(url=calendar_url)
|
calendar = client.calendar(url=calendar_url)
|
||||||
@@ -227,37 +268,53 @@ class CalDAVClient:
|
|||||||
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create_calendar(
|
def create_calendar( # pylint: disable=too-many-arguments
|
||||||
self, user, calendar_name: str, calendar_id: str, color: str = ""
|
self,
|
||||||
|
user,
|
||||||
|
calendar_name: str = "",
|
||||||
|
calendar_id: str = "",
|
||||||
|
color: str = "",
|
||||||
|
*,
|
||||||
|
name: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create a new calendar in CalDAV server for the given user.
|
Create a new calendar in CalDAV server for the given user.
|
||||||
Returns the CalDAV server path for the calendar.
|
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)
|
client = self._get_client(user)
|
||||||
principal = client.principal()
|
principal = client.principal()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create calendar using caldav library
|
# Pass cal_id so the library uses our UUID for the path.
|
||||||
calendar = principal.make_calendar(name=calendar_name)
|
calendar = principal.make_calendar(name=calendar_name, cal_id=calendar_id)
|
||||||
|
|
||||||
# Set calendar color if provided
|
|
||||||
if color:
|
if color:
|
||||||
calendar.set_properties([CalendarColor(color)])
|
calendar.set_properties([CalendarColor(color)])
|
||||||
|
|
||||||
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
|
# Extract CalDAV-relative path from the calendar URL
|
||||||
# The caldav library returns a URL object, convert to string and extract path
|
|
||||||
calendar_url = str(calendar.url)
|
calendar_url = str(calendar.url)
|
||||||
# Extract path from full URL
|
|
||||||
if calendar_url.startswith(self.base_url):
|
if calendar_url.startswith(self.base_url):
|
||||||
path = calendar_url[len(self.base_url) :]
|
path = calendar_url[len(self.base_url) :]
|
||||||
else:
|
else:
|
||||||
# Fallback: construct path manually based on standard CalDAV structure
|
path = f"/calendars/users/{user.email}/{calendar_id}/"
|
||||||
# CalDAV servers typically create calendars under /calendars/{principal}/
|
|
||||||
path = f"/calendars/{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(
|
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
|
return path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -285,7 +342,7 @@ class CalDAVClient:
|
|||||||
client = self._get_client(user)
|
client = self._get_client(user)
|
||||||
|
|
||||||
# Get calendar by URL
|
# 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)
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -323,7 +380,7 @@ class CalDAVClient:
|
|||||||
Returns the event UID.
|
Returns the event UID.
|
||||||
"""
|
"""
|
||||||
client = self._get_client(user)
|
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)
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -342,7 +399,7 @@ class CalDAVClient:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
client = self._get_client(user)
|
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)
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
# Extract event data
|
# Extract event data
|
||||||
@@ -385,27 +442,11 @@ class CalDAVClient:
|
|||||||
"""Update an existing event in CalDAV server."""
|
"""Update an existing event in CalDAV server."""
|
||||||
|
|
||||||
client = self._get_client(user)
|
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)
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Search for the event by UID
|
target_event = calendar.object_by_uid(event_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")
|
|
||||||
|
|
||||||
# Update event properties
|
# Update event properties
|
||||||
dtstart = event_data.get("start")
|
dtstart = event_data.get("start")
|
||||||
@@ -432,6 +473,8 @@ class CalDAVClient:
|
|||||||
target_event.save()
|
target_event.save()
|
||||||
|
|
||||||
logger.info("Updated event in CalDAV server: %s", event_uid)
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to update event in CalDAV server: %s", str(e))
|
logger.error("Failed to update event in CalDAV server: %s", str(e))
|
||||||
raise
|
raise
|
||||||
@@ -440,36 +483,44 @@ class CalDAVClient:
|
|||||||
"""Delete an event from CalDAV server."""
|
"""Delete an event from CalDAV server."""
|
||||||
|
|
||||||
client = self._get_client(user)
|
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)
|
calendar = client.calendar(url=calendar_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Search for the event by UID
|
target_event = calendar.object_by_uid(event_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.delete()
|
target_event.delete()
|
||||||
|
|
||||||
logger.info("Deleted event from CalDAV server: %s", event_uid)
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to delete event from CalDAV server: %s", str(e))
|
logger.error("Failed to delete event from CalDAV server: %s", str(e))
|
||||||
raise
|
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]:
|
def _parse_event(self, event) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Parse a caldav Event object and return event data as dictionary.
|
Parse a caldav Event object and return event data as dictionary.
|
||||||
@@ -491,13 +542,15 @@ class CalDAVClient:
|
|||||||
# Convert datetime to string format for consistency
|
# Convert datetime to string format for consistency
|
||||||
if event_data["start"]:
|
if event_data["start"]:
|
||||||
if isinstance(event_data["start"], datetime):
|
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):
|
elif isinstance(event_data["start"], date):
|
||||||
event_data["start"] = event_data["start"].strftime("%Y%m%d")
|
event_data["start"] = event_data["start"].strftime("%Y%m%d")
|
||||||
|
|
||||||
if event_data["end"]:
|
if event_data["end"]:
|
||||||
if isinstance(event_data["end"], datetime):
|
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):
|
elif isinstance(event_data["end"], date):
|
||||||
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
||||||
|
|
||||||
@@ -507,60 +560,19 @@ class CalDAVClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class CalendarService:
|
# CalendarService is kept as an alias for backwards compatibility
|
||||||
"""
|
# with tests and signals that reference it.
|
||||||
High-level service for managing calendars and events.
|
CalendarService = CalDAVClient
|
||||||
"""
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CalDAV path utilities
|
# 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(
|
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.
|
"""Normalize CalDAV path to consistent format.
|
||||||
|
|
||||||
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
|
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/
|
so that paths like /api/v1.0/caldav/calendars/users/user@ex.com/uuid/
|
||||||
become /calendars/user@ex.com/uuid/.
|
become /calendars/users/user@ex.com/uuid/.
|
||||||
"""
|
"""
|
||||||
if not caldav_path.startswith("/"):
|
if not caldav_path.startswith("/"):
|
||||||
caldav_path = "/" + caldav_path
|
caldav_path = "/" + caldav_path
|
||||||
@@ -582,19 +594,60 @@ def normalize_caldav_path(caldav_path):
|
|||||||
return 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):
|
def verify_caldav_access(user, caldav_path):
|
||||||
"""Verify that the user has access to the CalDAV calendar.
|
"""Verify that the user has access to the CalDAV calendar.
|
||||||
|
|
||||||
Checks that:
|
Checks that:
|
||||||
1. The path matches the expected pattern (prevents path injection)
|
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):
|
if not CALDAV_PATH_PATTERN.match(caldav_path):
|
||||||
return False
|
return False
|
||||||
parts = caldav_path.strip("/").split("/")
|
parts = caldav_path.strip("/").split("/")
|
||||||
if len(parts) >= 2 and parts[0] == "calendars":
|
if len(parts) < 3 or parts[0] != "calendars":
|
||||||
path_email = unquote(parts[1])
|
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()
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -605,10 +658,16 @@ def validate_caldav_proxy_path(path):
|
|||||||
- Directory traversal sequences (../)
|
- Directory traversal sequences (../)
|
||||||
- Null bytes
|
- Null bytes
|
||||||
- Paths that don't start with expected prefixes
|
- 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:
|
if not path:
|
||||||
return True # Empty path is fine (root request)
|
return True # Empty path is fine (root request)
|
||||||
|
|
||||||
|
# Decode percent-encoded characters before validation
|
||||||
|
path = unquote(path)
|
||||||
|
|
||||||
# Block directory traversal
|
# Block directory traversal
|
||||||
if ".." in path:
|
if ".." in path:
|
||||||
return False
|
return False
|
||||||
@@ -617,10 +676,60 @@ def validate_caldav_proxy_path(path):
|
|||||||
if "\x00" in path:
|
if "\x00" in path:
|
||||||
return False
|
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
|
# Path must start with a known CalDAV resource prefix
|
||||||
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
|
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
|
||||||
clean = path.lstrip("/")
|
|
||||||
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
|
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from urllib.parse import urlencode
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives
|
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 django.template.loader import render_to_string
|
||||||
|
|
||||||
from core.services.translation_service import TranslationService
|
from core.services.translation_service import TranslationService
|
||||||
@@ -424,8 +424,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
"time_str": time_str,
|
"time_str": time_str,
|
||||||
"is_update": event.sequence > 0,
|
"is_update": event.sequence > 0,
|
||||||
"is_cancel": method == self.METHOD_CANCEL,
|
"is_cancel": method == self.METHOD_CANCEL,
|
||||||
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
|
"app_name": settings.APP_NAME,
|
||||||
"app_url": getattr(settings, "APP_URL", ""),
|
"app_url": settings.APP_URL,
|
||||||
# Translated content blocks
|
# Translated content blocks
|
||||||
"content": {
|
"content": {
|
||||||
"title": t(f"email.{type_key}.title", lang),
|
"title": t(f"email.{type_key}.title", lang),
|
||||||
@@ -457,13 +457,13 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
"footer": t(
|
"footer": t(
|
||||||
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
|
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
|
||||||
lang,
|
lang,
|
||||||
appName=getattr(settings, "APP_NAME", "Calendrier"),
|
appName=settings.APP_NAME,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add RSVP links for REQUEST method (invitations and updates)
|
# Add RSVP links for REQUEST method (invitations and updates)
|
||||||
if method == self.METHOD_REQUEST:
|
if method == self.METHOD_REQUEST:
|
||||||
signer = Signer(salt="rsvp")
|
signer = TimestampSigner(salt="rsvp")
|
||||||
# Strip mailto: prefix (case-insensitive) for shorter tokens
|
# Strip mailto: prefix (case-insensitive) for shorter tokens
|
||||||
organizer = re.sub(
|
organizer = re.sub(
|
||||||
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
|
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
|
||||||
@@ -475,7 +475,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
"organizer": organizer,
|
"organizer": organizer,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
app_url = getattr(settings, "APP_URL", "")
|
app_url = settings.APP_URL
|
||||||
base = f"{app_url}/rsvp/"
|
base = f"{app_url}/rsvp/"
|
||||||
for action in ("accept", "tentative", "decline"):
|
for action in ("accept", "tentative", "decline"):
|
||||||
partstat = {
|
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
|
When False (default), strips METHOD so the ICS is treated as a plain
|
||||||
calendar object — our own RSVP web links handle responses instead.
|
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 itip_enabled:
|
||||||
if "METHOD:" not in icalendar_data.upper():
|
if "METHOD:" not in icalendar_data.upper():
|
||||||
@@ -549,10 +549,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get email settings
|
# Get email settings
|
||||||
from_addr = getattr(
|
from_addr = (
|
||||||
settings,
|
settings.CALENDAR_INVITATION_FROM_EMAIL or settings.DEFAULT_FROM_EMAIL
|
||||||
"CALENDAR_INVITATION_FROM_EMAIL",
|
|
||||||
getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@example.com"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the email message
|
# Create the email message
|
||||||
@@ -571,7 +569,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
ics_attachment = MIMEBase("text", "calendar")
|
ics_attachment = MIMEBase("text", "calendar")
|
||||||
ics_attachment.set_payload(ics_content.encode("utf-8"))
|
ics_attachment.set_payload(ics_content.encode("utf-8"))
|
||||||
encoders.encode_base64(ics_attachment)
|
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"
|
content_type = "text/calendar; charset=utf-8"
|
||||||
if itip_enabled:
|
if itip_enabled:
|
||||||
content_type += f"; method={ics_method}"
|
content_type += f"; method={ics_method}"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.services.caldav_service import CalDAVHTTPClient
|
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:
|
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
|
||||||
"""Import events from ICS data into a calendar.
|
"""Import events from ICS data into a calendar.
|
||||||
|
|
||||||
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
|
Sends the raw ICS bytes to the SabreDAV internal API import
|
||||||
handles all ICS parsing, splitting by UID, VALARM repair, and
|
endpoint which handles all ICS parsing, splitting by UID,
|
||||||
per-event insertion.
|
VALARM repair, and per-event insertion.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: The authenticated user performing the import.
|
user: The authenticated user performing the import.
|
||||||
caldav_path: CalDAV path of the calendar
|
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.
|
ics_data: Raw ICS file content.
|
||||||
"""
|
"""
|
||||||
result = ImportResult()
|
result = ImportResult()
|
||||||
|
|
||||||
try:
|
api_key = settings.CALDAV_INTERNAL_API_KEY
|
||||||
api_key = CalDAVHTTPClient.get_api_key()
|
if not api_key:
|
||||||
except ValueError:
|
result.errors.append("CALDAV_INTERNAL_API_KEY is not configured")
|
||||||
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
|
# Extract calendar URI from caldav_path
|
||||||
# 8000 events (~4MB) took ~70s in practice.
|
# Path format: /calendars/users/<email>/<calendar-uri>/
|
||||||
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
|
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:
|
try:
|
||||||
response = self._http.request(
|
response = self._http.request(
|
||||||
"POST",
|
"POST",
|
||||||
user.email,
|
user,
|
||||||
caldav_path,
|
f"internal-api/import/{principal_user}/{calendar_uri}",
|
||||||
query="import",
|
|
||||||
data=ics_data,
|
data=ics_data,
|
||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
extra_headers={"X-Calendars-Import": api_key},
|
extra_headers={"X-Internal-Api-Key": api_key},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
|
|||||||
182
src/backend/core/services/resource_service.py
Normal file
182
src/backend/core/services/resource_service.py
Normal 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")
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ class TranslationService:
|
|||||||
"""Lightweight translation service backed by translations.json."""
|
"""Lightweight translation service backed by translations.json."""
|
||||||
|
|
||||||
_translations = None
|
_translations = None
|
||||||
|
_load_lock = threading.Lock()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _load(cls):
|
def _load(cls):
|
||||||
@@ -47,12 +49,17 @@ class TranslationService:
|
|||||||
if cls._translations is not None:
|
if cls._translations is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
path = getattr(settings, "TRANSLATIONS_JSON_PATH", "")
|
with cls._load_lock:
|
||||||
if not path:
|
# Double-check after acquiring lock
|
||||||
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
|
if cls._translations is not None:
|
||||||
|
return
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
path = settings.TRANSLATIONS_JSON_PATH
|
||||||
cls._translations = json.load(f)
|
if not path:
|
||||||
|
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
cls._translations = json.load(f)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_nested(cls, data: dict, dotted_key: str):
|
def _get_nested(cls, data: dict, dotted_key: str):
|
||||||
@@ -104,15 +111,15 @@ class TranslationService:
|
|||||||
|
|
||||||
if email:
|
if email:
|
||||||
try:
|
try:
|
||||||
from core.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||||
User,
|
get_user_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = User.objects.filter(email=email).first()
|
user = get_user_model().objects.filter(email=email).first()
|
||||||
if user and user.language:
|
if user and user.language:
|
||||||
return cls.normalize_lang(user.language)
|
return cls.normalize_lang(user.language)
|
||||||
except Exception: # pylint: disable=broad-exception-caught
|
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"
|
return "fr"
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
Declare and configure the signals for the calendars core application
|
Declare and configure the signals for the calendars core application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
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 django.dispatch import receiver
|
||||||
|
|
||||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
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.
|
# never create a calendar if we can't confirm access.
|
||||||
try:
|
try:
|
||||||
entitlements = get_user_entitlements(instance.sub, instance.email)
|
entitlements = get_user_entitlements(instance.sub, instance.email)
|
||||||
if not entitlements.get("can_access", True):
|
if not entitlements.get("can_access", False):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skipped calendar creation for %s (not entitled)",
|
"Skipped calendar creation for %s (not entitled)",
|
||||||
instance.email,
|
instance.email,
|
||||||
@@ -48,22 +50,45 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
|
|||||||
try:
|
try:
|
||||||
service = CalendarService()
|
service = CalendarService()
|
||||||
service.create_default_calendar(instance)
|
service.create_default_calendar(instance)
|
||||||
logger.info("Created default calendar for user %s", instance.email)
|
logger.info("Created default calendar for user %s", instance.pk)
|
||||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
# In tests, CalDAV server may not be available, so fail silently
|
logger.exception(
|
||||||
# Check if it's a database error that suggests we're in tests
|
"Failed to create default calendar for user %s",
|
||||||
error_str = str(e).lower()
|
instance.pk,
|
||||||
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",
|
@receiver(pre_delete, sender=User)
|
||||||
instance.email,
|
def delete_user_caldav_data(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||||
str(e),
|
"""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},
|
||||||
)
|
)
|
||||||
else:
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
# Real error, log it
|
logger.exception(
|
||||||
logger.error(
|
"Failed to clean up CalDAV data for user %s",
|
||||||
"Failed to create default calendar for user %s: %s",
|
email,
|
||||||
instance.email,
|
|
||||||
str(e),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
transaction.on_commit(_cleanup)
|
||||||
|
|||||||
170
src/backend/core/task_utils.py
Normal file
170
src/backend/core/task_utils.py
Normal 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
50
src/backend/core/tasks.py
Normal 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,
|
||||||
|
}
|
||||||
71
src/backend/core/templates/rsvp/confirm.html
Normal file
71
src/backend/core/templates/rsvp/confirm.html
Normal 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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for the Authentication Backends."""
|
"""Unit tests for the Authentication Backends."""
|
||||||
|
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
@@ -17,7 +17,14 @@ from core.factories import UserFactory
|
|||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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(
|
def test_authentication_getter_existing_user_no_email(
|
||||||
django_assert_num_queries, monkeypatch
|
django_assert_num_queries, monkeypatch
|
||||||
):
|
):
|
||||||
@@ -41,6 +48,7 @@ def test_authentication_getter_existing_user_no_email(
|
|||||||
assert user == db_user
|
assert user == db_user
|
||||||
|
|
||||||
|
|
||||||
|
@_no_org_resolve
|
||||||
def test_authentication_getter_existing_user_via_email(
|
def test_authentication_getter_existing_user_via_email(
|
||||||
django_assert_num_queries, monkeypatch
|
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)
|
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(
|
user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
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
|
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()
|
klass = OIDCAuthenticationBackend()
|
||||||
db_user = UserFactory(email=None)
|
UserFactory() # existing user with different sub
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
user_info = {"sub": "123"}
|
return {"sub": "123"}
|
||||||
if random.choice([True, False]):
|
|
||||||
user_info["email"] = None
|
|
||||||
return user_info
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
user = klass.get_or_create_user(
|
with pytest.raises(
|
||||||
access_token="test-token", id_token=None, payload=None
|
SuspiciousOperation, match="Cannot create user without an organization"
|
||||||
)
|
):
|
||||||
|
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||||
# Since the sub and email didn't match, it should create a new user
|
|
||||||
assert models.User.objects.count() == 2
|
|
||||||
assert user != db_user
|
|
||||||
assert user.sub == "123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
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
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@_no_org_resolve
|
||||||
def test_authentication_getter_existing_user_with_email(
|
def test_authentication_getter_existing_user_with_email(
|
||||||
django_assert_num_queries, monkeypatch
|
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,
|
When the user's info contains an email and targets an existing user,
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
user = UserFactory(full_name="John Doe", short_name="John")
|
user = UserFactory(full_name="John Doe")
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {
|
return {
|
||||||
@@ -182,6 +185,7 @@ def test_authentication_getter_existing_user_with_email(
|
|||||||
assert user == authenticated_user
|
assert user == authenticated_user
|
||||||
|
|
||||||
|
|
||||||
|
@_no_org_resolve
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"first_name, last_name, email",
|
"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".
|
and the user was identified by its "sub".
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
user = UserFactory(
|
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
|
||||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {
|
return {
|
||||||
@@ -213,8 +215,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
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(3):
|
|
||||||
authenticated_user = klass.get_or_create_user(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
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()
|
user.refresh_from_db()
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||||
assert user.short_name == first_name
|
|
||||||
|
|
||||||
|
|
||||||
|
@_no_org_resolve
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"first_name, last_name, email",
|
"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.
|
and the user was identified by its "email" as fallback.
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
user = UserFactory(
|
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
|
||||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def get_userinfo_mocked(*args):
|
||||||
return {
|
return {
|
||||||
@@ -255,8 +254,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
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(5):
|
||||||
with django_assert_num_queries(4):
|
|
||||||
authenticated_user = klass.get_or_create_user(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
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()
|
user.refresh_from_db()
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
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.
|
If no user matches the sub and no email is provided,
|
||||||
User's info doesn't contain an email, created user's email should be empty.
|
user creation is rejected (organization requires email domain).
|
||||||
"""
|
"""
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
@@ -280,16 +277,10 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
user = klass.get_or_create_user(
|
with pytest.raises(
|
||||||
access_token="test-token", id_token=None, payload=None
|
SuspiciousOperation, match="Cannot create user without an organization"
|
||||||
)
|
):
|
||||||
|
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
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.sub == "123"
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == "John Doe"
|
assert user.full_name == "John Doe"
|
||||||
assert user.short_name == "John"
|
|
||||||
assert user.has_usable_password() is False
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
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
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@_no_org_resolve
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_authentication_session_tokens(
|
def test_authentication_session_tokens(
|
||||||
django_assert_num_queries, monkeypatch, rf, settings
|
django_assert_num_queries, monkeypatch, rf, settings
|
||||||
@@ -498,7 +490,7 @@ def test_authentication_session_tokens(
|
|||||||
status=200,
|
status=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
with django_assert_num_queries(6):
|
with django_assert_num_queries(12):
|
||||||
user = klass.authenticate(
|
user = klass.authenticate(
|
||||||
request,
|
request,
|
||||||
code="test-code",
|
code="test-code",
|
||||||
@@ -538,7 +530,7 @@ def test_authentication_store_claims_new_user(monkeypatch):
|
|||||||
assert user.sub == "123"
|
assert user.sub == "123"
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == "John Doe"
|
assert user.full_name == "John Doe"
|
||||||
assert user.short_name == "John"
|
|
||||||
assert user.has_usable_password() is False
|
assert user.has_usable_password() is False
|
||||||
assert user.claims == {"iss": "https://example.com"}
|
assert user.claims == {"iss": "https://example.com"}
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Fixtures for tests in the calendars core application"""
|
"""Fixtures for tests in the calendars core application"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db import connection
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
@@ -13,43 +12,86 @@ from core import factories
|
|||||||
from core.tests.utils.urls import reload_urls
|
from core.tests.utils.urls import reload_urls
|
||||||
|
|
||||||
USER = "user"
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def truncate_caldav_tables(django_db_setup, django_db_blocker): # pylint: disable=unused-argument
|
def truncate_caldav_tables(request, django_db_setup, django_db_blocker): # pylint: disable=unused-argument
|
||||||
"""Fixture to truncate CalDAV server tables at the start of each test.
|
"""Truncate CalDAV tables before each CalDAV E2E test.
|
||||||
|
|
||||||
CalDAV server tables are created by the CalDAV server container migrations, not Django.
|
Only runs for tests marked with @pytest.mark.xdist_group("caldav").
|
||||||
We just truncate them to ensure clean state for each test.
|
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():
|
if not _has_caldav_marker(request):
|
||||||
with connection.cursor() as cursor:
|
yield
|
||||||
# Truncate CalDAV server tables if they exist (created by CalDAV server container)
|
return
|
||||||
cursor.execute("""
|
|
||||||
DO $$
|
import psycopg # noqa: PLC0415 # pylint: disable=import-outside-toplevel
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN
|
db_settings = settings.DATABASES["default"]
|
||||||
TRUNCATE TABLE principals CASCADE;
|
conn = psycopg.connect(
|
||||||
END IF;
|
host=db_settings["HOST"],
|
||||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN
|
port=db_settings["PORT"],
|
||||||
TRUNCATE TABLE users CASCADE;
|
dbname="calendars", # SabreDAV always uses this DB
|
||||||
END IF;
|
user=db_settings["USER"],
|
||||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN
|
password=db_settings["PASSWORD"],
|
||||||
TRUNCATE TABLE calendars CASCADE;
|
)
|
||||||
END IF;
|
conn.autocommit = True
|
||||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN
|
try:
|
||||||
TRUNCATE TABLE calendarinstances CASCADE;
|
with conn.cursor() as cur: # pylint: disable=no-member
|
||||||
END IF;
|
for table in [
|
||||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN
|
"calendarobjects",
|
||||||
TRUNCATE TABLE calendarobjects CASCADE;
|
"calendarinstances",
|
||||||
END IF;
|
"calendars",
|
||||||
END $$;
|
"principals",
|
||||||
""")
|
]:
|
||||||
|
cur.execute(f"TRUNCATE TABLE {table} CASCADE")
|
||||||
|
finally:
|
||||||
|
conn.close() # pylint: disable=no-member
|
||||||
yield
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_cache():
|
def clear_cache():
|
||||||
"""Fixture to clear the cache after each test."""
|
"""Fixture to clear the cache after each test."""
|
||||||
@@ -58,16 +100,7 @@ def clear_cache():
|
|||||||
# Clear functools.cache for functions decorated with @functools.cache
|
# Clear functools.cache for functions decorated with @functools.cache
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def resource_server_backend_setup(settings): # pylint: disable=redefined-outer-name
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
A fixture to create a user token for testing.
|
A fixture to create a user token for testing.
|
||||||
"""
|
"""
|
||||||
@@ -91,7 +124,7 @@ def resource_server_backend_setup(settings):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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.
|
A fixture to create a user token for testing.
|
||||||
"""
|
"""
|
||||||
@@ -100,7 +133,7 @@ def resource_server_backend_conf(settings):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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.
|
A fixture to create a user token for testing.
|
||||||
Including a mocked introspection endpoint.
|
Including a mocked introspection endpoint.
|
||||||
|
|||||||
@@ -43,29 +43,32 @@ def test_api_users_list_authenticated():
|
|||||||
"/api/v1.0/users/",
|
"/api/v1.0/users/",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_query_inactive():
|
def test_api_users_list_query_inactive():
|
||||||
"""Inactive users should not be listed."""
|
"""Inactive users should not be listed."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
org = user.organization
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
factories.UserFactory(
|
||||||
lennon = factories.UserFactory(email="john.lennon@example.com")
|
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
|
# Use email query to get exact match
|
||||||
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
|
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
|
||||||
|
|
||||||
assert response.status_code == 200
|
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)]
|
assert user_ids == [str(lennon.id)]
|
||||||
|
|
||||||
# Inactive user should not be returned even with exact match
|
# Inactive user should not be returned even with exact match
|
||||||
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
|
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
|
||||||
assert response.status_code == 200
|
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 == []
|
assert user_ids == []
|
||||||
|
|
||||||
|
|
||||||
@@ -83,16 +86,16 @@ def test_api_users_list_query_short_queries():
|
|||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=jo")
|
response = client.get("/api/v1.0/users/?q=jo")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/?q=john")
|
response = client.get("/api/v1.0/users/?q=john")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
# Non-email queries (without @) return empty
|
# Non-email queries (without @) return empty
|
||||||
response = client.get("/api/v1.0/users/?q=john.")
|
response = client.get("/api/v1.0/users/?q=john.")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_limit(settings):
|
def test_api_users_list_limit(settings):
|
||||||
@@ -101,6 +104,7 @@ def test_api_users_list_limit(settings):
|
|||||||
should be limited to 10.
|
should be limited to 10.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
org = user.organization
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
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
|
# Use a base name with a length equal 5 to test that the limit is applied
|
||||||
base_name = "alice"
|
base_name = "alice"
|
||||||
for i in range(15):
|
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
|
# Non-email queries (without @) return empty
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1.0/users/?q=alice",
|
"/api/v1.0/users/?q=alice",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json()["results"] == []
|
||||||
|
|
||||||
# Email queries require exact match
|
# Email queries require exact match
|
||||||
settings.API_USERS_LIST_LIMIT = 100
|
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",
|
"/api/v1.0/users/?q=alice.0@example.com",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert len(response.json()) == 1
|
assert len(response.json()["results"]) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_throttling_authenticated(settings):
|
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"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
org = user.organization
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
dave = factories.UserFactory(email="david.bowman@work.com")
|
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
|
||||||
factories.UserFactory(email="nicole.bowman@work.com")
|
factories.UserFactory(email="nicole.bowman@work.com", organization=org)
|
||||||
|
|
||||||
# Exact match works
|
# Exact match works
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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)]
|
assert user_ids == [str(dave.id)]
|
||||||
|
|
||||||
# Case-insensitive match works
|
# 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",
|
"/api/v1.0/users/?q=David.Bowman@Work.COM",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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)]
|
assert user_ids == [str(dave.id)]
|
||||||
|
|
||||||
# Typos don't match (exact match only)
|
# 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",
|
"/api/v1.0/users/?q=davig.bovman@worm.com",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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 == []
|
assert user_ids == []
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1.0/users/?q=davig.bovman@worm.cop",
|
"/api/v1.0/users/?q=davig.bovman@worm.cop",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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 == []
|
assert user_ids == []
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_list_query_email_matching():
|
def test_api_users_list_query_email_matching():
|
||||||
"""Email queries return exact matches only (case-insensitive)."""
|
"""Email queries return exact matches only (case-insensitive)."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
org = user.organization
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
|
user1 = factories.UserFactory(
|
||||||
factories.UserFactory(email="alice.johnnson@example.gouv.fr")
|
email="alice.johnson@example.gouv.fr", organization=org
|
||||||
factories.UserFactory(email="alice.kohlson@example.gouv.fr")
|
)
|
||||||
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
|
factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org)
|
||||||
factories.UserFactory(email="alicia.johnnson@example.gov.uk")
|
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
|
||||||
factories.UserFactory(email="alice.thomson@example.gouv.fr")
|
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
|
# Exact match returns only that user
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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)]
|
assert user_ids == [str(user1.id)]
|
||||||
|
|
||||||
# Different email returns different user
|
# Different email returns different user
|
||||||
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
||||||
assert response.status_code == 200
|
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)]
|
assert user_ids == [str(user4.id)]
|
||||||
|
|
||||||
|
|
||||||
@@ -260,9 +270,13 @@ def test_api_users_retrieve_me_authenticated():
|
|||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
"short_name": user.short_name,
|
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"can_access": True,
|
"can_access": True,
|
||||||
|
"can_admin": True,
|
||||||
|
"organization": {
|
||||||
|
"id": str(user.organization.id),
|
||||||
|
"name": user.organization.name,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class TestCalDAVProxy:
|
|||||||
def test_proxy_requires_authentication(self):
|
def test_proxy_requires_authentication(self):
|
||||||
"""Test that unauthenticated requests return 401."""
|
"""Test that unauthenticated requests return 401."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
|
response = client.generic("PROPFIND", "/caldav/")
|
||||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
@responses.activate
|
@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
|
# Verify request was made to CalDAV server
|
||||||
assert len(responses.calls) == 1
|
assert len(responses.calls) == 1
|
||||||
@@ -77,7 +77,7 @@ class TestCalDAVProxy:
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PROPFIND",
|
method="PROPFIND",
|
||||||
url=f"{caldav_url}/api/v1.0/caldav/",
|
url=f"{caldav_url}/caldav/",
|
||||||
status=HTTP_207_MULTI_STATUS,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
||||||
headers={"Content-Type": "application/xml"},
|
headers={"Content-Type": "application/xml"},
|
||||||
@@ -88,7 +88,7 @@ class TestCalDAVProxy:
|
|||||||
malicious_email = "attacker@example.com"
|
malicious_email = "attacker@example.com"
|
||||||
client.generic(
|
client.generic(
|
||||||
"PROPFIND",
|
"PROPFIND",
|
||||||
"/api/v1.0/caldav/",
|
"/caldav/",
|
||||||
HTTP_X_FORWARDED_USER=malicious_email,
|
HTTP_X_FORWARDED_USER=malicious_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,10 +107,6 @@ class TestCalDAVProxy:
|
|||||||
"X-Forwarded-User should NOT use client-sent header value"
|
"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):
|
def test_proxy_propfind_response_contains_prefixed_urls(self):
|
||||||
"""PROPFIND responses should contain URLs with proxy prefix.
|
"""PROPFIND responses should contain URLs with proxy prefix.
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ class TestCalDAVProxy:
|
|||||||
)
|
)
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"PROPFIND",
|
"PROPFIND",
|
||||||
"/api/v1.0/caldav/",
|
"/caldav/",
|
||||||
data=propfind_body,
|
data=propfind_body,
|
||||||
content_type="application/xml",
|
content_type="application/xml",
|
||||||
)
|
)
|
||||||
@@ -154,8 +150,8 @@ class TestCalDAVProxy:
|
|||||||
if href and (
|
if href and (
|
||||||
href.startswith("/principals/") or href.startswith("/calendars/")
|
href.startswith("/principals/") or href.startswith("/calendars/")
|
||||||
):
|
):
|
||||||
assert href.startswith("/api/v1.0/caldav/"), (
|
assert href.startswith("/caldav/"), (
|
||||||
f"Expected URL to start with /api/v1.0/caldav/, "
|
f"Expected URL to start with /caldav/, "
|
||||||
f"got {href}. BaseUriPlugin is not using "
|
f"got {href}. BaseUriPlugin is not using "
|
||||||
f"X-Forwarded-Prefix correctly. Full response: "
|
f"X-Forwarded-Prefix correctly. Full response: "
|
||||||
f"{response.content.decode('utf-8', errors='ignore')}"
|
f"{response.content.decode('utf-8', errors='ignore')}"
|
||||||
@@ -178,7 +174,7 @@ class TestCalDAVProxy:
|
|||||||
propfind_xml = """<?xml version="1.0"?>
|
propfind_xml = """<?xml version="1.0"?>
|
||||||
<multistatus xmlns="DAV:">
|
<multistatus xmlns="DAV:">
|
||||||
<response>
|
<response>
|
||||||
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
|
<href>/caldav/calendars/users/test@example.com/calendar-id/</href>
|
||||||
<propstat>
|
<propstat>
|
||||||
<prop>
|
<prop>
|
||||||
<resourcetype>
|
<resourcetype>
|
||||||
@@ -193,14 +189,14 @@ class TestCalDAVProxy:
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PROPFIND",
|
method="PROPFIND",
|
||||||
url=f"{caldav_url}/api/v1.0/caldav/",
|
url=f"{caldav_url}/caldav/",
|
||||||
status=HTTP_207_MULTI_STATUS,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body=propfind_xml,
|
body=propfind_xml,
|
||||||
headers={"Content-Type": "application/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
|
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)
|
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
|
||||||
href = href_elem.text
|
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}"
|
f"Expected URL to be passed through unchanged, got {href}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -234,7 +230,7 @@ class TestCalDAVProxy:
|
|||||||
propfind_xml = """<?xml version="1.0"?>
|
propfind_xml = """<?xml version="1.0"?>
|
||||||
<multistatus xmlns="DAV:" xmlns:D="DAV:">
|
<multistatus xmlns="DAV:" xmlns:D="DAV:">
|
||||||
<response>
|
<response>
|
||||||
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
|
<D:href>/caldav/principals/users/test@example.com/</D:href>
|
||||||
<propstat>
|
<propstat>
|
||||||
<prop>
|
<prop>
|
||||||
<resourcetype>
|
<resourcetype>
|
||||||
@@ -248,14 +244,14 @@ class TestCalDAVProxy:
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PROPFIND",
|
method="PROPFIND",
|
||||||
url=f"{caldav_url}/api/v1.0/caldav/",
|
url=f"{caldav_url}/caldav/",
|
||||||
status=HTTP_207_MULTI_STATUS,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body=propfind_xml,
|
body=propfind_xml,
|
||||||
headers={"Content-Type": "application/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
|
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)
|
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
|
||||||
href = href_elem.text
|
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}"
|
f"Expected URL to be passed through unchanged, got {href}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -283,7 +279,7 @@ class TestCalDAVProxy:
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PROPFIND",
|
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,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
||||||
headers={"Content-Type": "application/xml"},
|
headers={"Content-Type": "application/xml"},
|
||||||
@@ -291,14 +287,12 @@ class TestCalDAVProxy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Request a specific path
|
# 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
|
# Verify the request was made to the correct URL
|
||||||
assert len(responses.calls) == 1
|
assert len(responses.calls) == 1
|
||||||
request = responses.calls[0].request
|
request = responses.calls[0].request
|
||||||
assert (
|
assert request.url == f"{caldav_url}/caldav/principals/users/test@example.com/"
|
||||||
request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/"
|
|
||||||
)
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_proxy_handles_options_request(self):
|
def test_proxy_handles_options_request(self):
|
||||||
@@ -307,7 +301,7 @@ class TestCalDAVProxy:
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.options("/api/v1.0/caldav/")
|
response = client.options("/caldav/")
|
||||||
|
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert "Access-Control-Allow-Methods" in response
|
assert "Access-Control-Allow-Methods" in response
|
||||||
@@ -319,9 +313,7 @@ class TestCalDAVProxy:
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.generic(
|
response = client.generic("PROPFIND", "/caldav/calendars/../../etc/passwd")
|
||||||
"PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd"
|
|
||||||
)
|
|
||||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
def test_proxy_rejects_non_caldav_path(self):
|
def test_proxy_rejects_non_caldav_path(self):
|
||||||
@@ -330,7 +322,16 @@ class TestCalDAVProxy:
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
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
|
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
@@ -343,11 +344,11 @@ class TestValidateCaldavProxyPath:
|
|||||||
|
|
||||||
def test_calendars_path_is_valid(self):
|
def test_calendars_path_is_valid(self):
|
||||||
"""Standard calendars path should be valid."""
|
"""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):
|
def test_principals_path_is_valid(self):
|
||||||
"""Standard principals path should be valid."""
|
"""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):
|
def test_traversal_is_rejected(self):
|
||||||
"""Directory traversal attempts should be rejected."""
|
"""Directory traversal attempts should be rejected."""
|
||||||
@@ -363,4 +364,24 @@ class TestValidateCaldavProxyPath:
|
|||||||
|
|
||||||
def test_leading_slash_calendars_is_valid(self):
|
def test_leading_slash_calendars_is_valid(self):
|
||||||
"""Paths with leading slash should still be valid."""
|
"""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
|
||||||
|
|||||||
@@ -72,13 +72,10 @@ def create_test_server() -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.xdist_group("caldav")
|
||||||
class TestCalDAVScheduling:
|
class TestCalDAVScheduling:
|
||||||
"""Tests for CalDAV scheduling callback when creating events with attendees."""
|
"""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
|
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
|
||||||
self,
|
self,
|
||||||
):
|
):
|
||||||
@@ -125,8 +122,8 @@ class TestCalDAVScheduling:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Create an event with an attendee
|
# Create an event with an attendee
|
||||||
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
|
client = service._get_client(organizer) # pylint: disable=protected-access
|
||||||
calendar_url = f"{settings.CALDAV_URL}{caldav_path}"
|
calendar_url = service._calendar_url(caldav_path) # pylint: disable=protected-access
|
||||||
|
|
||||||
# Add custom callback URL header to the client
|
# Add custom callback URL header to the client
|
||||||
# The CalDAV server will use this URL for the callback
|
# The CalDAV server will use this URL for the callback
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Tests for CalDAV service integration."""
|
"""Tests for CalDAV service integration."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
@@ -9,6 +7,7 @@ from core.services.caldav_service import CalDAVClient, CalendarService
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.xdist_group("caldav")
|
||||||
class TestCalDAVClient:
|
class TestCalDAVClient:
|
||||||
"""Tests for CalDAVClient authentication and communication."""
|
"""Tests for CalDAVClient authentication and communication."""
|
||||||
|
|
||||||
@@ -30,10 +29,6 @@ class TestCalDAVClient:
|
|||||||
assert "X-Forwarded-User" in dav_client.headers
|
assert "X-Forwarded-User" in dav_client.headers
|
||||||
assert dav_client.headers["X-Forwarded-User"] == user.email
|
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):
|
def test_create_calendar_authenticates_with_caldav_server(self):
|
||||||
"""Test that calendar creation authenticates successfully with CalDAV server."""
|
"""Test that calendar creation authenticates successfully with CalDAV server."""
|
||||||
user = factories.UserFactory(email="test@example.com")
|
user = factories.UserFactory(email="test@example.com")
|
||||||
@@ -65,10 +60,6 @@ class TestCalDAVClient:
|
|||||||
assert isinstance(caldav_path, str)
|
assert isinstance(caldav_path, str)
|
||||||
assert "calendars/" in caldav_path
|
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):
|
def test_create_calendar_with_color_persists(self):
|
||||||
"""Test that creating a calendar with a color saves it in CalDAV."""
|
"""Test that creating a calendar with a color saves it in CalDAV."""
|
||||||
user = factories.UserFactory(email="color-test@example.com")
|
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)
|
caldav_path = service.create_calendar(user, name="Red Calendar", color=color)
|
||||||
|
|
||||||
# Fetch the calendar info and verify the color was persisted
|
# 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 is not None
|
||||||
assert info["color"] == color
|
assert info["color"] == color
|
||||||
assert info["name"] == "Red Calendar"
|
assert info["name"] == "Red Calendar"
|
||||||
|
|||||||
@@ -1,40 +1,37 @@
|
|||||||
"""Tests for calendar subscription token API."""
|
"""Tests for iCal feed channel creation via the channels API."""
|
||||||
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.status import (
|
from rest_framework.status import (
|
||||||
HTTP_200_OK,
|
HTTP_200_OK,
|
||||||
HTTP_201_CREATED,
|
HTTP_201_CREATED,
|
||||||
HTTP_204_NO_CONTENT,
|
HTTP_204_NO_CONTENT,
|
||||||
HTTP_400_BAD_REQUEST,
|
|
||||||
HTTP_401_UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED,
|
||||||
HTTP_403_FORBIDDEN,
|
HTTP_403_FORBIDDEN,
|
||||||
HTTP_404_NOT_FOUND,
|
|
||||||
)
|
)
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
from core.models import CalendarSubscriptionToken
|
from core.models import Channel
|
||||||
|
|
||||||
|
CHANNELS_URL = "/api/v1.0/channels/"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestSubscriptionTokenViewSet:
|
class TestICalFeedChannels:
|
||||||
"""Tests for the new standalone SubscriptionTokenViewSet."""
|
"""Tests for ical-feed channel creation via ChannelViewSet."""
|
||||||
|
|
||||||
def test_create_subscription_token(self):
|
def test_create_ical_feed_channel(self):
|
||||||
"""Test creating a subscription token for a calendar."""
|
"""Test creating an ical-feed channel for a calendar."""
|
||||||
user = factories.UserFactory()
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{
|
{
|
||||||
|
"name": "My Test Calendar",
|
||||||
|
"type": "ical-feed",
|
||||||
"caldav_path": caldav_path,
|
"caldav_path": caldav_path,
|
||||||
"calendar_name": "My Test Calendar",
|
"calendar_name": "My Test Calendar",
|
||||||
},
|
},
|
||||||
@@ -47,233 +44,210 @@ class TestSubscriptionTokenViewSet:
|
|||||||
assert "/ical/" in response.data["url"]
|
assert "/ical/" in response.data["url"]
|
||||||
assert ".ics" in response.data["url"]
|
assert ".ics" in response.data["url"]
|
||||||
assert response.data["caldav_path"] == caldav_path
|
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
|
# Verify channel was created in database
|
||||||
assert CalendarSubscriptionToken.objects.filter(
|
assert Channel.objects.filter(
|
||||||
owner=user, caldav_path=caldav_path
|
user=user, caldav_path=caldav_path, type="ical-feed"
|
||||||
).exists()
|
).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."""
|
"""Test that caldav_path is normalized to have leading/trailing slashes."""
|
||||||
user = factories.UserFactory()
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{"name": "Cal", "type": "ical-feed", "caldav_path": caldav_path},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTP_201_CREATED
|
assert response.status_code == HTTP_201_CREATED
|
||||||
# Path should be normalized
|
assert (
|
||||||
assert response.data["caldav_path"] == f"/calendars/{user.email}/test-uuid/"
|
response.data["caldav_path"] == f"/calendars/users/{user.email}/test-uuid/"
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_subscription_token_returns_existing(self):
|
def test_create_ical_feed_returns_existing(self):
|
||||||
"""Test that creating a token when one exists returns the existing one."""
|
"""Test that creating an ical-feed channel when one exists returns it."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(subscription.owner)
|
client.force_login(channel.user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
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",
|
"calendar_name": "Updated Name",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response.data["token"] == str(subscription.token)
|
|
||||||
# Name should be updated
|
# Name should be updated
|
||||||
subscription.refresh_from_db()
|
channel.refresh_from_db()
|
||||||
assert subscription.calendar_name == "Updated Name"
|
assert channel.settings["calendar_name"] == "Updated Name"
|
||||||
|
|
||||||
def test_get_subscription_token_by_path(self):
|
def test_list_ical_feed_channels(self):
|
||||||
"""Test retrieving an existing subscription token by CalDAV path."""
|
"""Test filtering channels by type=ical-feed."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(subscription.owner)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-by-path")
|
# Create one ical-feed and one caldav channel
|
||||||
response = client.get(url, {"caldav_path": subscription.caldav_path})
|
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.status_code == HTTP_200_OK
|
||||||
assert response.data["token"] == str(subscription.token)
|
assert len(response.data) == 1
|
||||||
assert "url" in response.data
|
assert response.data[0]["type"] == "ical-feed"
|
||||||
|
|
||||||
def test_get_subscription_token_not_found(self):
|
# Without filter, both show up
|
||||||
"""Test retrieving token when none exists."""
|
response = client.get(CHANNELS_URL)
|
||||||
user = factories.UserFactory()
|
assert len(response.data) == 2
|
||||||
caldav_path = f"/calendars/{user.email}/nonexistent/"
|
|
||||||
|
def test_delete_ical_feed_channel(self):
|
||||||
|
"""Test deleting an ical-feed channel."""
|
||||||
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(channel.user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-by-path")
|
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
|
||||||
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)
|
|
||||||
|
|
||||||
assert response.status_code == HTTP_204_NO_CONTENT
|
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):
|
def test_non_owner_cannot_create_ical_feed(self):
|
||||||
"""Test deleting token when none exists."""
|
"""Test that users cannot create ical-feed channels for others' calendars."""
|
||||||
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."""
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
other_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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{
|
||||||
|
"name": "Stolen",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTP_403_FORBIDDEN
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
def test_non_owner_cannot_get_token(self):
|
def test_non_owner_cannot_list_others_channels(self):
|
||||||
"""Test that users cannot get tokens for other users' calendars."""
|
"""Test that users only see their own channels."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
factories.ICalFeedChannelFactory()
|
||||||
other_user = factories.UserFactory()
|
other_user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(other_user)
|
client.force_login(other_user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-by-path")
|
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
|
||||||
response = client.get(url, {"caldav_path": subscription.caldav_path})
|
assert response.status_code == HTTP_200_OK
|
||||||
|
assert len(response.data) == 0
|
||||||
|
|
||||||
assert response.status_code == HTTP_403_FORBIDDEN
|
def test_unauthenticated_cannot_create(self):
|
||||||
|
"""Test that unauthenticated users cannot create channels."""
|
||||||
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."""
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
caldav_path = f"/calendars/{user.email}/test-calendar/"
|
caldav_path = f"/calendars/users/{user.email}/test-calendar/"
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{
|
||||||
|
"name": "Feed",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
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):
|
def test_regenerate_token(self):
|
||||||
"""Test regenerating a token by delete + create."""
|
"""Test regenerating a token by delete + create."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
old_token = subscription.token
|
old_token = channel.encrypted_settings["token"]
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(subscription.owner)
|
client.force_login(channel.user)
|
||||||
|
|
||||||
base_by_path_url = reverse("subscription-tokens-by-path")
|
# Delete old channel
|
||||||
by_path_url = (
|
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
|
||||||
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)
|
|
||||||
assert response.status_code == HTTP_204_NO_CONTENT
|
assert response.status_code == HTTP_204_NO_CONTENT
|
||||||
|
|
||||||
# Create new token
|
# Create new one for the same path
|
||||||
response = client.post(
|
response = client.post(
|
||||||
create_url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": subscription.caldav_path},
|
{
|
||||||
|
"name": "Feed",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": channel.caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTP_201_CREATED
|
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):
|
def test_unique_constraint_per_owner_calendar(self):
|
||||||
"""Test that only one token can exist per owner+caldav_path."""
|
"""Test that only one ical-feed channel exists per owner+caldav_path."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
|
|
||||||
# Try to create another token for the same path - should return existing
|
|
||||||
client = APIClient()
|
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(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": subscription.caldav_path},
|
{
|
||||||
|
"name": "Duplicate",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": channel.caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return the existing token, not create a new one
|
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response.data["token"] == str(subscription.token)
|
assert Channel.objects.filter(user=channel.user, type="ical-feed").count() == 1
|
||||||
assert (
|
|
||||||
CalendarSubscriptionToken.objects.filter(owner=subscription.owner).count()
|
def test_url_contains_slugified_calendar_name(self):
|
||||||
== 1
|
"""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
|
@pytest.mark.django_db
|
||||||
class TestPathInjectionProtection:
|
class TestPathInjectionProtection:
|
||||||
@@ -290,45 +264,40 @@ class TestPathInjectionProtection:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"malicious_suffix",
|
"malicious_suffix",
|
||||||
[
|
[
|
||||||
# Path traversal attacks
|
|
||||||
"../other-calendar/",
|
"../other-calendar/",
|
||||||
"../../etc/passwd/",
|
"../../etc/passwd/",
|
||||||
"..%2F..%2Fetc%2Fpasswd/", # URL-encoded traversal
|
"..%2F..%2Fetc%2Fpasswd/",
|
||||||
# Query parameter injection
|
|
||||||
"uuid?export=true/",
|
"uuid?export=true/",
|
||||||
"uuid?admin=true/",
|
"uuid?admin=true/",
|
||||||
# Fragment injection
|
|
||||||
"uuid#malicious/",
|
"uuid#malicious/",
|
||||||
# Special characters that shouldn't be in calendar IDs
|
|
||||||
"uuid;rm -rf/",
|
"uuid;rm -rf/",
|
||||||
"uuid|cat /etc/passwd/",
|
"uuid|cat /etc/passwd/",
|
||||||
"uuid$(whoami)/",
|
"uuid$(whoami)/",
|
||||||
"uuid`whoami`/",
|
"uuid`whoami`/",
|
||||||
# Double slashes
|
|
||||||
"uuid//",
|
"uuid//",
|
||||||
"/uuid/",
|
"/uuid/",
|
||||||
# Spaces and other whitespace
|
|
||||||
"uuid with spaces/",
|
"uuid with spaces/",
|
||||||
"uuid\ttab/",
|
"uuid\ttab/",
|
||||||
# Unicode tricks
|
"uuid\u002e\u002e/",
|
||||||
"uuid\u002e\u002e/", # Unicode dots
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
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."""
|
"""Test that malicious calendar IDs in paths are rejected."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
caldav_path = f"/calendars/{user.email}/{malicious_suffix}"
|
caldav_path = f"/calendars/users/{user.email}/{malicious_suffix}"
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{
|
||||||
|
"name": "Bad",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should be rejected - either 403 (invalid format) or path doesn't normalize
|
|
||||||
assert response.status_code == HTTP_403_FORBIDDEN, (
|
assert response.status_code == HTTP_403_FORBIDDEN, (
|
||||||
f"Path '{caldav_path}' should be rejected but got {response.status_code}"
|
f"Path '{caldav_path}' should be rejected but got {response.status_code}"
|
||||||
)
|
)
|
||||||
@@ -336,31 +305,30 @@ class TestPathInjectionProtection:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"malicious_path",
|
"malicious_path",
|
||||||
[
|
[
|
||||||
# Completely wrong structure
|
|
||||||
"/etc/passwd/",
|
"/etc/passwd/",
|
||||||
"/admin/calendars/user@test.com/uuid/",
|
"/admin/calendars/user@test.com/uuid/",
|
||||||
"/../calendars/user@test.com/uuid/",
|
"/../calendars/user@test.com/uuid/",
|
||||||
# Missing segments
|
|
||||||
"/calendars/",
|
"/calendars/",
|
||||||
"/calendars/user@test.com/",
|
"/calendars/user@test.com/",
|
||||||
# Path traversal to access another user's calendar
|
|
||||||
"/calendars/victim@test.com/../attacker@test.com/uuid/",
|
"/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."""
|
"""Test that malformed CalDAV paths are rejected."""
|
||||||
user = factories.UserFactory(email="attacker@test.com")
|
user = factories.UserFactory(email="attacker@test.com")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": malicious_path},
|
{
|
||||||
|
"name": "Bad",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": malicious_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should be rejected
|
|
||||||
assert response.status_code == HTTP_403_FORBIDDEN, (
|
assert response.status_code == HTTP_403_FORBIDDEN, (
|
||||||
f"Path '{malicious_path}' should be rejected but got {response.status_code}"
|
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):
|
def test_path_traversal_to_other_user_calendar_rejected(self):
|
||||||
"""Test that path traversal to access another user's calendar is blocked."""
|
"""Test that path traversal to access another user's calendar is blocked."""
|
||||||
attacker = factories.UserFactory(email="attacker@example.com")
|
attacker = factories.UserFactory(email="attacker@example.com")
|
||||||
victim = factories.UserFactory(email="victim@example.com")
|
factories.UserFactory(email="victim@example.com")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(attacker)
|
client.force_login(attacker)
|
||||||
|
|
||||||
# Try to access victim's calendar via path traversal
|
|
||||||
malicious_paths = [
|
malicious_paths = [
|
||||||
f"/calendars/{attacker.email}/../{victim.email}/secret-calendar/",
|
f"/calendars/{attacker.email}/../victim@example.com/secret-calendar/",
|
||||||
f"/calendars/{victim.email}/secret-calendar/", # Direct access
|
"/calendars/victim@example.com/secret-calendar/",
|
||||||
]
|
]
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
for path in malicious_paths:
|
for path in malicious_paths:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": path},
|
{
|
||||||
|
"name": "Bad",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTP_403_FORBIDDEN, (
|
assert response.status_code == HTTP_403_FORBIDDEN, (
|
||||||
@@ -392,15 +362,19 @@ class TestPathInjectionProtection:
|
|||||||
def test_valid_uuid_path_accepted(self):
|
def test_valid_uuid_path_accepted(self):
|
||||||
"""Test that valid UUID-style calendar IDs are accepted."""
|
"""Test that valid UUID-style calendar IDs are accepted."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
# Standard UUID format
|
caldav_path = (
|
||||||
caldav_path = f"/calendars/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
|
f"/calendars/users/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
|
||||||
|
)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{
|
||||||
|
"name": "Good",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -409,43 +383,18 @@ class TestPathInjectionProtection:
|
|||||||
def test_valid_alphanumeric_path_accepted(self):
|
def test_valid_alphanumeric_path_accepted(self):
|
||||||
"""Test that valid alphanumeric calendar IDs are accepted."""
|
"""Test that valid alphanumeric calendar IDs are accepted."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
# Alphanumeric with hyphens (allowed by regex)
|
caldav_path = f"/calendars/users/{user.email}/my-calendar-2024/"
|
||||||
caldav_path = f"/calendars/{user.email}/my-calendar-2024/"
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
url = reverse("subscription-tokens-list")
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
CHANNELS_URL,
|
||||||
{"caldav_path": caldav_path},
|
{
|
||||||
|
"name": "Good",
|
||||||
|
"type": "ical-feed",
|
||||||
|
"caldav_path": caldav_path,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTP_201_CREATED
|
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
|
|
||||||
|
|||||||
456
src/backend/core/tests/test_channels.py
Normal file
456
src/backend/core/tests/test_channels.py
Normal 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)
|
||||||
709
src/backend/core/tests/test_cross_org_e2e.py
Normal file
709
src/backend/core/tests/test_cross_org_e2e.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -27,10 +27,10 @@ from core.entitlements.factory import get_entitlements_backend
|
|||||||
|
|
||||||
|
|
||||||
def test_local_backend_always_grants_access():
|
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()
|
backend = LocalEntitlementsBackend()
|
||||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
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():
|
def test_local_backend_ignores_parameters():
|
||||||
@@ -42,7 +42,7 @@ def test_local_backend_ignores_parameters():
|
|||||||
user_info={"some": "claim"},
|
user_info={"some": "claim"},
|
||||||
force_refresh=True,
|
force_refresh=True,
|
||||||
)
|
)
|
||||||
assert result == {"can_access": True}
|
assert result == {"can_access": True, "can_admin": True}
|
||||||
|
|
||||||
|
|
||||||
# -- Factory --
|
# -- Factory --
|
||||||
@@ -99,7 +99,7 @@ def test_deploycenter_backend_grants_access():
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
DC_URL,
|
DC_URL,
|
||||||
json={"entitlements": {"can_access": True}},
|
json={"entitlements": {"can_access": True, "can_admin": True}},
|
||||||
status=200,
|
status=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ def test_deploycenter_backend_grants_access():
|
|||||||
api_key="test-key",
|
api_key="test-key",
|
||||||
)
|
)
|
||||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
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
|
# Verify request was made with correct params and header
|
||||||
assert len(responses.calls) == 1
|
assert len(responses.calls) == 1
|
||||||
@@ -135,7 +135,7 @@ def test_deploycenter_backend_denies_access():
|
|||||||
api_key="test-key",
|
api_key="test-key",
|
||||||
)
|
)
|
||||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
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
|
@responses.activate
|
||||||
@@ -157,12 +157,12 @@ def test_deploycenter_backend_uses_cache():
|
|||||||
|
|
||||||
# First call hits the API
|
# First call hits the API
|
||||||
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
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
|
assert len(responses.calls) == 1
|
||||||
|
|
||||||
# Second call should use cache
|
# Second call should use cache
|
||||||
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
|
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
|
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(
|
result = backend.get_user_entitlements(
|
||||||
"sub-123", "user@example.com", force_refresh=True
|
"sub-123", "user@example.com", force_refresh=True
|
||||||
)
|
)
|
||||||
assert result == {"can_access": True}
|
assert result["can_access"] is True
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@@ -355,20 +355,21 @@ def test_user_me_serializer_includes_can_access_false():
|
|||||||
assert data["can_access"] is False
|
assert data["can_access"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_user_me_serializer_can_access_fail_open():
|
def test_user_me_serializer_can_access_fail_closed():
|
||||||
"""UserMeSerializer should return can_access=True when entitlements unavailable."""
|
"""UserMeSerializer should return can_access=False when entitlements unavailable."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"core.api.serializers.get_user_entitlements",
|
"core.api.serializers.get_user_entitlements",
|
||||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||||
):
|
):
|
||||||
data = UserMeSerializer(user).data
|
data = UserMeSerializer(user).data
|
||||||
assert data["can_access"] is True
|
assert data["can_access"] is False
|
||||||
|
|
||||||
|
|
||||||
# -- Signals integration --
|
# -- Signals integration --
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xdist_group("caldav")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CALDAV_URL="http://caldav:80",
|
CALDAV_URL="http://caldav:80",
|
||||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
@@ -392,6 +393,7 @@ def test_signal_skips_calendar_when_not_entitled():
|
|||||||
get_entitlements_backend.cache_clear()
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xdist_group("caldav")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CALDAV_URL="http://caldav:80",
|
CALDAV_URL="http://caldav:80",
|
||||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
@@ -414,6 +416,7 @@ def test_signal_skips_calendar_when_entitlements_unavailable():
|
|||||||
get_entitlements_backend.cache_clear()
|
get_entitlements_backend.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xdist_group("caldav")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
CALDAV_URL="http://caldav:80",
|
CALDAV_URL="http://caldav:80",
|
||||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||||
@@ -456,7 +459,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
):
|
):
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"MKCALENDAR",
|
"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
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
@@ -474,7 +477,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
):
|
):
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"MKCOL",
|
"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
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
@@ -493,7 +496,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
):
|
):
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"MKCALENDAR",
|
"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
|
assert response.status_code == HTTP_403_FORBIDDEN
|
||||||
@@ -509,7 +512,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="MKCALENDAR",
|
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,
|
status=201,
|
||||||
body="",
|
body="",
|
||||||
)
|
)
|
||||||
@@ -521,7 +524,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
):
|
):
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"MKCALENDAR",
|
"MKCALENDAR",
|
||||||
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
|
"/caldav/calendars/users/test@example.com/new-cal/",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
@@ -538,7 +541,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PROPFIND",
|
method="PROPFIND",
|
||||||
url=f"{caldav_url}/api/v1.0/caldav/",
|
url=f"{caldav_url}/caldav/",
|
||||||
status=HTTP_207_MULTI_STATUS,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
||||||
headers={"Content-Type": "application/xml"},
|
headers={"Content-Type": "application/xml"},
|
||||||
@@ -546,7 +549,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
)
|
)
|
||||||
|
|
||||||
# No entitlements mock needed — PROPFIND should not check entitlements
|
# 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
|
assert response.status_code == HTTP_207_MULTI_STATUS
|
||||||
|
|
||||||
@@ -561,7 +564,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="REPORT",
|
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,
|
status=HTTP_207_MULTI_STATUS,
|
||||||
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
|
||||||
headers={"Content-Type": "application/xml"},
|
headers={"Content-Type": "application/xml"},
|
||||||
@@ -570,7 +573,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
|
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"REPORT",
|
"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
|
assert response.status_code == HTTP_207_MULTI_STATUS
|
||||||
@@ -587,7 +590,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.Response(
|
responses.Response(
|
||||||
method="PUT",
|
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,
|
status=HTTP_200_OK,
|
||||||
body="",
|
body="",
|
||||||
)
|
)
|
||||||
@@ -595,7 +598,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
|
|||||||
|
|
||||||
response = client.generic(
|
response = client.generic(
|
||||||
"PUT",
|
"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",
|
data=b"BEGIN:VCALENDAR\nEND:VCALENDAR",
|
||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Tests for iCal export endpoint."""
|
"""Tests for iCal export endpoint (using Channel type=ical-feed)."""
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
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 rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
|
from core.models import uuid_to_urlsafe
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestICalExport:
|
class TestICalExport:
|
||||||
"""Tests for ICalExportView."""
|
"""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):
|
def test_export_with_valid_token_returns_ics(self):
|
||||||
"""Test that a valid token returns iCal data."""
|
"""Test that a valid token returns iCal data."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
# Mock CalDAV server response
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
ics_content = b"""BEGIN:VCALENDAR
|
ics_content = b"""BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
@@ -47,8 +50,7 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response["Content-Type"] == "text/calendar; charset=utf-8"
|
assert response["Content-Type"] == "text/calendar; charset=utf-8"
|
||||||
@@ -58,35 +60,38 @@ END:VCALENDAR"""
|
|||||||
|
|
||||||
def test_export_with_invalid_token_returns_404(self):
|
def test_export_with_invalid_token_returns_404(self):
|
||||||
"""Test that an invalid token returns 404."""
|
"""Test that an invalid token returns 404."""
|
||||||
|
channel = factories.ICalFeedChannelFactory()
|
||||||
|
short_id = uuid_to_urlsafe(channel.pk)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
invalid_token = uuid.uuid4()
|
response = client.get(f"/ical/{short_id}/WrongTokenHere123/calendar.ics")
|
||||||
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
url = reverse("ical-export", kwargs={"token": invalid_token})
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
|
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
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
def test_export_with_inactive_token_returns_404(self):
|
def test_export_with_inactive_token_returns_404(self):
|
||||||
"""Test that an inactive token returns 404."""
|
"""Test that an inactive token returns 404."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory(is_active=False)
|
channel = factories.ICalFeedChannelFactory(is_active=False)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == HTTP_404_NOT_FOUND
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
def test_export_updates_last_accessed_at(self):
|
def test_export_updates_last_used_at(self):
|
||||||
"""Test that accessing the export updates last_accessed_at."""
|
"""Test that accessing the export updates last_used_at."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
assert subscription.last_accessed_at is None
|
assert channel.last_used_at is None
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -96,22 +101,20 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
client.get(self._ical_url(channel))
|
||||||
client.get(url)
|
|
||||||
|
|
||||||
subscription.refresh_from_db()
|
channel.refresh_from_db()
|
||||||
assert subscription.last_accessed_at is not None
|
assert channel.last_used_at is not None
|
||||||
|
|
||||||
def test_export_does_not_require_authentication(self):
|
def test_export_does_not_require_authentication(self):
|
||||||
"""Test that the endpoint is accessible without authentication."""
|
"""Test that the endpoint is accessible without authentication."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
# Not logging in - should still work
|
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -121,20 +124,18 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
|
|
||||||
def test_export_sends_correct_headers_to_caldav(self):
|
def test_export_sends_correct_headers_to_caldav(self):
|
||||||
"""Test that the proxy sends correct authentication headers to CalDAV."""
|
"""Test that the proxy sends correct authentication headers to CalDAV."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -144,24 +145,22 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
client.get(self._ical_url(channel))
|
||||||
client.get(url)
|
|
||||||
|
|
||||||
# Verify headers sent to CalDAV
|
|
||||||
assert len(rsps.calls) == 1
|
assert len(rsps.calls) == 1
|
||||||
request = rsps.calls[0].request
|
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
|
assert request.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
|
||||||
|
|
||||||
def test_export_handles_caldav_error(self):
|
def test_export_handles_caldav_error(self):
|
||||||
"""Test that CalDAV server errors are handled gracefully."""
|
"""Test that CalDAV server errors are handled gracefully."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -170,20 +169,18 @@ END:VCALENDAR"""
|
|||||||
status=500,
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
assert response.status_code == HTTP_502_BAD_GATEWAY
|
assert response.status_code == HTTP_502_BAD_GATEWAY
|
||||||
|
|
||||||
def test_export_sets_security_headers(self):
|
def test_export_sets_security_headers(self):
|
||||||
"""Test that security headers are set correctly."""
|
"""Test that security headers are set correctly."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory()
|
channel = factories.ICalFeedChannelFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -193,24 +190,22 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
# Verify security headers
|
|
||||||
assert response["Cache-Control"] == "no-store, private"
|
assert response["Cache-Control"] == "no-store, private"
|
||||||
assert response["Referrer-Policy"] == "no-referrer"
|
assert response["Referrer-Policy"] == "no-referrer"
|
||||||
|
|
||||||
def test_export_uses_calendar_name_in_filename(self):
|
def test_export_uses_calendar_name_in_filename(self):
|
||||||
"""Test that the export filename uses the calendar_name."""
|
"""Test that the export filename uses the calendar_name from settings."""
|
||||||
subscription = factories.CalendarSubscriptionTokenFactory(
|
channel = factories.ICalFeedChannelFactory(
|
||||||
calendar_name="My Test Calendar"
|
settings={"role": "reader", "calendar_name": "My Test Calendar"}
|
||||||
)
|
)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
caldav_url = settings.CALDAV_URL
|
caldav_url = settings.CALDAV_URL
|
||||||
caldav_path = subscription.caldav_path.lstrip("/")
|
caldav_path = channel.caldav_path.lstrip("/")
|
||||||
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
|
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
|
||||||
|
|
||||||
rsps.add(
|
rsps.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
@@ -220,7 +215,15 @@ END:VCALENDAR"""
|
|||||||
content_type="text/calendar",
|
content_type="text/calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("ical-export", kwargs={"token": subscription.token})
|
response = client.get(self._ical_url(channel))
|
||||||
response = client.get(url)
|
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
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ END:VCALENDAR"""
|
|||||||
|
|
||||||
def _make_caldav_path(user):
|
def _make_caldav_path(user):
|
||||||
"""Build a caldav_path string for a user (test helper)."""
|
"""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
|
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")
|
@patch("core.services.caldav_service.requests.request")
|
||||||
def test_import_passes_calendar_path(self, mock_post):
|
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(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
total_events=1, imported_count=1
|
total_events=1, imported_count=1
|
||||||
)
|
)
|
||||||
@@ -495,8 +495,11 @@ class TestICSImportService:
|
|||||||
|
|
||||||
call_args = mock_post.call_args
|
call_args = mock_post.call_args
|
||||||
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
|
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
|
||||||
assert caldav_path in url
|
assert "internal-api/import/" in url
|
||||||
assert "?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")
|
@patch("core.services.caldav_service.requests.request")
|
||||||
def test_import_sends_auth_headers(self, mock_post):
|
def test_import_sends_auth_headers(self, mock_post):
|
||||||
@@ -515,7 +518,7 @@ class TestICSImportService:
|
|||||||
headers = call_kwargs["headers"]
|
headers = call_kwargs["headers"]
|
||||||
assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
|
assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
|
||||||
assert headers["X-Forwarded-User"] == user.email
|
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"
|
assert headers["Content-Type"] == "text/calendar"
|
||||||
|
|
||||||
@patch("core.services.caldav_service.requests.request")
|
@patch("core.services.caldav_service.requests.request")
|
||||||
@@ -571,7 +574,7 @@ class TestImportEventsAPI:
|
|||||||
"""Users cannot import to a calendar they don't own."""
|
"""Users cannot import to a calendar they don't own."""
|
||||||
owner = factories.UserFactory(email="owner@example.com")
|
owner = factories.UserFactory(email="owner@example.com")
|
||||||
other_user = factories.UserFactory(email="other@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 = APIClient()
|
||||||
client.force_login(other_user)
|
client.force_login(other_user)
|
||||||
@@ -597,12 +600,12 @@ class TestImportEventsAPI:
|
|||||||
)
|
)
|
||||||
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
|
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
|
||||||
assert response.status_code == 400
|
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):
|
def test_import_events_missing_file(self):
|
||||||
"""Request without a file should return 400."""
|
"""Request without a file should return 400."""
|
||||||
user = factories.UserFactory(email="nofile@example.com")
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -613,12 +616,12 @@ class TestImportEventsAPI:
|
|||||||
format="multipart",
|
format="multipart",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
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):
|
def test_import_events_file_too_large(self):
|
||||||
"""Files exceeding MAX_FILE_SIZE should be rejected."""
|
"""Files exceeding MAX_FILE_SIZE should be rejected."""
|
||||||
user = factories.UserFactory(email="largefile@example.com")
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -634,11 +637,11 @@ class TestImportEventsAPI:
|
|||||||
format="multipart",
|
format="multipart",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "too large" in response.json()["error"]
|
assert "too large" in response.json()["detail"]
|
||||||
|
|
||||||
@patch.object(ICSImportService, "import_events")
|
@patch.object(ICSImportService, "import_events")
|
||||||
def test_import_events_success(self, mock_import):
|
def test_import_events_returns_task_id(self, mock_import):
|
||||||
"""Successful import should return result data."""
|
"""Successful import should return a task_id for polling."""
|
||||||
mock_import.return_value = ImportResult(
|
mock_import.return_value = ImportResult(
|
||||||
total_events=3,
|
total_events=3,
|
||||||
imported_count=3,
|
imported_count=3,
|
||||||
@@ -648,7 +651,7 @@ class TestImportEventsAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = factories.UserFactory(email="success@example.com")
|
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 = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -662,51 +665,20 @@ class TestImportEventsAPI:
|
|||||||
format="multipart",
|
format="multipart",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 202
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["total_events"] == 3
|
assert "task_id" in data
|
||||||
assert data["imported_count"] == 3
|
|
||||||
assert data["skipped_count"] == 0
|
|
||||||
assert "errors" not in data
|
|
||||||
|
|
||||||
@patch.object(ICSImportService, "import_events")
|
# With EagerBroker, the task runs synchronously — poll for result
|
||||||
def test_import_events_partial_success(self, mock_import):
|
task_response = client.get(f"/api/v1.0/tasks/{data['task_id']}/")
|
||||||
"""Partial success should include errors in response."""
|
assert task_response.status_code == 200
|
||||||
mock_import.return_value = ImportResult(
|
task_data = task_response.json()
|
||||||
total_events=3,
|
assert task_data["status"] == "SUCCESS"
|
||||||
imported_count=2,
|
assert task_data["result"]["total_events"] == 3
|
||||||
duplicate_count=0,
|
assert task_data["result"]["imported_count"] == 3
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.xdist_group("caldav")
|
||||||
not settings.CALDAV_URL,
|
|
||||||
reason="CalDAV server URL not configured",
|
|
||||||
)
|
|
||||||
class TestImportEventsE2E:
|
class TestImportEventsE2E:
|
||||||
"""End-to-end tests that import ICS events through the real SabreDAV server."""
|
"""End-to-end tests that import ICS events through the real SabreDAV server."""
|
||||||
|
|
||||||
@@ -827,11 +799,16 @@ class TestImportEventsE2E:
|
|||||||
format="multipart",
|
format="multipart",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 202
|
||||||
data = response.json()
|
task_id = response.json()["task_id"]
|
||||||
assert data["total_events"] == 3
|
|
||||||
assert data["imported_count"] == 3
|
# With EagerBroker, poll for the synchronous result
|
||||||
assert data["skipped_count"] == 0
|
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
|
# Verify events actually exist in SabreDAV
|
||||||
caldav = CalDAVClient()
|
caldav = CalDAVClient()
|
||||||
@@ -955,7 +932,7 @@ class TestImportEventsE2E:
|
|||||||
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
|
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
|
||||||
caldav_client = CalDAVClient()
|
caldav_client = CalDAVClient()
|
||||||
client = caldav_client._get_client(user) # pylint: disable=protected-access
|
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)
|
cal = client.calendar(url=cal_url)
|
||||||
event = cal.event_by_uid(uid)
|
event = cal.event_by_uid(uid)
|
||||||
return event.data
|
return event.data
|
||||||
@@ -1022,10 +999,7 @@ class TestImportEventsE2E:
|
|||||||
assert "..." in raw
|
assert "..." in raw
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.xdist_group("caldav")
|
||||||
not settings.CALDAV_URL,
|
|
||||||
reason="CalDAV server URL not configured",
|
|
||||||
)
|
|
||||||
class TestCalendarSanitizerE2E:
|
class TestCalendarSanitizerE2E:
|
||||||
"""E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations."""
|
"""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."""
|
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
|
||||||
caldav_client = CalDAVClient()
|
caldav_client = CalDAVClient()
|
||||||
client = caldav_client._get_client(user) # pylint: disable=protected-access
|
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)
|
cal = client.calendar(url=cal_url)
|
||||||
event = cal.event_by_uid(uid)
|
event = cal.event_by_uid(uid)
|
||||||
return event.data
|
return event.data
|
||||||
|
|||||||
196
src/backend/core/tests/test_organizations.py
Normal file
196
src/backend/core/tests/test_organizations.py
Normal 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()
|
||||||
382
src/backend/core/tests/test_permissions_security.py
Normal file
382
src/backend/core/tests/test_permissions_security.py
Normal 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
|
||||||
309
src/backend/core/tests/test_resources.py
Normal file
309
src/backend/core/tests/test_resources.py
Normal 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")
|
||||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from django.core import mail
|
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.template.loader import render_to_string
|
||||||
from django.test import RequestFactory, TestCase, override_settings
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -16,7 +16,8 @@ from django.utils import timezone
|
|||||||
import icalendar
|
import icalendar
|
||||||
import pytest
|
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.caldav_service import CalDAVHTTPClient
|
||||||
from core.services.calendar_invitation_service import (
|
from core.services.calendar_invitation_service import (
|
||||||
CalendarInvitationService,
|
CalendarInvitationService,
|
||||||
@@ -56,10 +57,10 @@ SAMPLE_CALDAV_RESPONSE = """\
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||||
<d:response>
|
<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:propstat>
|
||||||
<d:prop>
|
<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>
|
<cal:calendar-data>{ics_data}</cal:calendar-data>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<d:status>HTTP/1.1 200 OK</d:status>
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
@@ -71,8 +72,8 @@ SAMPLE_CALDAV_RESPONSE = """\
|
|||||||
def _make_token(
|
def _make_token(
|
||||||
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
|
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
|
||||||
):
|
):
|
||||||
"""Create a valid signed RSVP token."""
|
"""Create a valid signed RSVP token using TimestampSigner."""
|
||||||
signer = Signer(salt="rsvp")
|
signer = TimestampSigner(salt="rsvp")
|
||||||
return signer.sign_object(
|
return signer.sign_object(
|
||||||
{
|
{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
@@ -88,7 +89,7 @@ class TestRSVPTokenGeneration:
|
|||||||
def test_token_roundtrip(self):
|
def test_token_roundtrip(self):
|
||||||
"""A generated token can be unsigned to recover the payload."""
|
"""A generated token can be unsigned to recover the payload."""
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
signer = Signer(salt="rsvp")
|
signer = TimestampSigner(salt="rsvp")
|
||||||
payload = signer.unsign_object(token)
|
payload = signer.unsign_object(token)
|
||||||
assert payload["uid"] == "test-uid-123"
|
assert payload["uid"] == "test-uid-123"
|
||||||
assert payload["email"] == "bob@example.com"
|
assert payload["email"] == "bob@example.com"
|
||||||
@@ -97,7 +98,7 @@ class TestRSVPTokenGeneration:
|
|||||||
def test_tampered_token_fails(self):
|
def test_tampered_token_fails(self):
|
||||||
"""A tampered token raises BadSignature."""
|
"""A tampered token raises BadSignature."""
|
||||||
token = _make_token() + "tampered"
|
token = _make_token() + "tampered"
|
||||||
signer = Signer(salt="rsvp")
|
signer = TimestampSigner(salt="rsvp")
|
||||||
with pytest.raises(BadSignature):
|
with pytest.raises(BadSignature):
|
||||||
signer.unsign_object(token)
|
signer.unsign_object(token)
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ class TestRSVPEmailTemplateRendering:
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateAttendeePartstat:
|
class TestUpdateAttendeePartstat:
|
||||||
"""Tests for the _update_attendee_partstat function."""
|
"""Tests for the update_attendee_partstat function."""
|
||||||
|
|
||||||
def test_update_existing_partstat(self):
|
def test_update_existing_partstat(self):
|
||||||
result = CalDAVHTTPClient.update_attendee_partstat(
|
result = CalDAVHTTPClient.update_attendee_partstat(
|
||||||
@@ -232,18 +233,50 @@ class TestUpdateAttendeePartstat:
|
|||||||
assert "CN=Bob" in result
|
assert "CN=Bob" in result
|
||||||
assert "mailto:bob@example.com" 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(
|
@override_settings(
|
||||||
CALDAV_URL="http://caldav:80",
|
CALDAV_URL="http://caldav:80",
|
||||||
CALDAV_OUTBOUND_API_KEY="test-api-key",
|
CALDAV_OUTBOUND_API_KEY="test-api-key",
|
||||||
APP_URL="http://localhost:8921",
|
APP_URL="http://localhost:8931",
|
||||||
|
API_VERSION="v1.0",
|
||||||
)
|
)
|
||||||
class TestRSVPView(TestCase):
|
class TestRSVPConfirmView(TestCase):
|
||||||
"""Tests for the RSVPView."""
|
"""Tests for the RSVPConfirmView (GET handler)."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
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):
|
def test_invalid_action_returns_400(self):
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
@@ -251,12 +284,6 @@ class TestRSVPView(TestCase):
|
|||||||
response = self.view(request)
|
response = self.view(request)
|
||||||
assert response.status_code == 400
|
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):
|
def test_invalid_token_returns_400(self):
|
||||||
request = self.factory.get(
|
request = self.factory.get(
|
||||||
"/rsvp/", {"token": "bad-token", "action": "accepted"}
|
"/rsvp/", {"token": "bad-token", "action": "accepted"}
|
||||||
@@ -264,8 +291,46 @@ class TestRSVPView(TestCase):
|
|||||||
response = self.view(request)
|
response = self.view(request)
|
||||||
assert response.status_code == 400
|
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):
|
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)
|
response = self.view(request)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
@@ -275,33 +340,37 @@ class TestRSVPView(TestCase):
|
|||||||
"""Full accept flow: find event, update partstat, put back."""
|
"""Full accept flow: find event, update partstat, put back."""
|
||||||
mock_find.return_value = (
|
mock_find.return_value = (
|
||||||
SAMPLE_ICS,
|
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
|
mock_put.return_value = True
|
||||||
|
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
|
response = self._post(token, "accepted")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "accepted the invitation" in response.content.decode()
|
assert "accepted the invitation" in response.content.decode()
|
||||||
|
|
||||||
# Verify CalDAV calls
|
# 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()
|
mock_put.assert_called_once()
|
||||||
# Check the updated data contains ACCEPTED
|
# Check the updated data contains ACCEPTED
|
||||||
put_args = mock_put.call_args
|
put_args = mock_put.call_args
|
||||||
assert "PARTSTAT=ACCEPTED" in put_args[0][2]
|
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, "put_event")
|
||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_decline_flow(self, mock_find, mock_put):
|
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
|
mock_put.return_value = True
|
||||||
|
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "declined"})
|
response = self._post(token, "declined")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "declined the invitation" in response.content.decode()
|
assert "declined the invitation" in response.content.decode()
|
||||||
@@ -311,12 +380,11 @@ class TestRSVPView(TestCase):
|
|||||||
@patch.object(CalDAVHTTPClient, "put_event")
|
@patch.object(CalDAVHTTPClient, "put_event")
|
||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_tentative_flow(self, mock_find, mock_put):
|
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
|
mock_put.return_value = True
|
||||||
|
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"})
|
response = self._post(token, "tentative")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@@ -326,11 +394,10 @@ class TestRSVPView(TestCase):
|
|||||||
|
|
||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_event_not_found_returns_400(self, mock_find):
|
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()
|
token = _make_token()
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
|
response = self._post(token, "accepted")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "not found" in response.content.decode().lower()
|
assert "not found" in response.content.decode().lower()
|
||||||
@@ -338,12 +405,11 @@ class TestRSVPView(TestCase):
|
|||||||
@patch.object(CalDAVHTTPClient, "put_event")
|
@patch.object(CalDAVHTTPClient, "put_event")
|
||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_put_failure_returns_400(self, mock_find, mock_put):
|
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
|
mock_put.return_value = False
|
||||||
|
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
|
response = self._post(token, "accepted")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "error occurred" in response.content.decode().lower()
|
assert "error occurred" in response.content.decode().lower()
|
||||||
@@ -351,12 +417,11 @@ class TestRSVPView(TestCase):
|
|||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_attendee_not_in_event_returns_400(self, mock_find):
|
def test_attendee_not_in_event_returns_400(self, mock_find):
|
||||||
"""If the attendee email is not in the event, return error."""
|
"""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 with an email that's not in the event
|
||||||
token = _make_token(email="stranger@example.com")
|
token = _make_token(email="stranger@example.com")
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
|
response = self._post(token, "accepted")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "not listed" in response.content.decode().lower()
|
assert "not listed" in response.content.decode().lower()
|
||||||
@@ -364,11 +429,10 @@ class TestRSVPView(TestCase):
|
|||||||
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
|
||||||
def test_past_event_returns_400(self, mock_find):
|
def test_past_event_returns_400(self, mock_find):
|
||||||
"""Cannot RSVP to an event that has already ended."""
|
"""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")
|
token = _make_token(uid="test-uid-past")
|
||||||
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
|
response = self._post(token, "accepted")
|
||||||
response = self.view(request)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "already passed" in response.content.decode().lower()
|
assert "already passed" in response.content.decode().lower()
|
||||||
@@ -430,28 +494,29 @@ class TestItipSetting:
|
|||||||
CALDAV_URL="http://caldav:80",
|
CALDAV_URL="http://caldav:80",
|
||||||
CALDAV_OUTBOUND_API_KEY="test-api-key",
|
CALDAV_OUTBOUND_API_KEY="test-api-key",
|
||||||
CALDAV_INBOUND_API_KEY="test-inbound-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",
|
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||||
)
|
)
|
||||||
class TestRSVPEndToEndFlow(TestCase):
|
class TestRSVPEndToEndFlow(TestCase):
|
||||||
"""
|
"""
|
||||||
Integration test: scheduling callback sends email → extract RSVP links
|
Integration test: scheduling callback sends email -> extract RSVP links
|
||||||
→ follow link → verify event is updated.
|
-> follow link (GET confirm -> POST process) -> 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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.factory = RequestFactory()
|
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):
|
def test_email_to_rsvp_accept_flow(self):
|
||||||
"""
|
"""
|
||||||
1. CalDAV scheduling callback sends an invitation email
|
1. CalDAV scheduling callback sends an invitation email
|
||||||
2. Extract RSVP accept link from the email HTML
|
2. Extract RSVP accept link from the email HTML
|
||||||
3. Follow the RSVP link
|
3. GET the RSVP link (renders auto-submit form)
|
||||||
4. Verify the event PARTSTAT is updated to ACCEPTED
|
4. POST to process the RSVP
|
||||||
|
5. Verify the event PARTSTAT is updated to ACCEPTED
|
||||||
"""
|
"""
|
||||||
# Step 1: Send invitation via the CalendarInvitationService
|
# Step 1: Send invitation via the CalendarInvitationService
|
||||||
service = CalendarInvitationService()
|
service = CalendarInvitationService()
|
||||||
@@ -488,22 +553,32 @@ class TestRSVPEndToEndFlow(TestCase):
|
|||||||
assert "token" in params
|
assert "token" in params
|
||||||
assert params["action"] == ["accepted"]
|
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 (
|
with (
|
||||||
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
|
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
|
||||||
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
|
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
|
||||||
):
|
):
|
||||||
mock_find.return_value = (
|
mock_find.return_value = (
|
||||||
SAMPLE_ICS,
|
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
|
mock_put.return_value = True
|
||||||
|
|
||||||
request = self.factory.get(
|
request = self.factory.post(
|
||||||
"/rsvp/",
|
"/api/v1.0/rsvp/",
|
||||||
{"token": params["token"][0], "action": "accepted"},
|
{"token": params["token"][0], "action": "accepted"},
|
||||||
)
|
)
|
||||||
response = self.rsvp_view(request)
|
response = self.process_view(request)
|
||||||
|
|
||||||
# Step 5: Verify success
|
# Step 5: Verify success
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -511,7 +586,10 @@ class TestRSVPEndToEndFlow(TestCase):
|
|||||||
assert "accepted the invitation" in content
|
assert "accepted the invitation" in content
|
||||||
|
|
||||||
# Verify CalDAV was called with the right data
|
# 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()
|
mock_put.assert_called_once()
|
||||||
put_data = mock_put.call_args[0][2]
|
put_data = mock_put.call_args[0][2]
|
||||||
assert "PARTSTAT=ACCEPTED" in put_data
|
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, "find_event_by_uid") as mock_find,
|
||||||
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
|
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
|
mock_put.return_value = True
|
||||||
|
|
||||||
request = self.factory.get(
|
request = self.factory.post(
|
||||||
"/rsvp/",
|
"/api/v1.0/rsvp/",
|
||||||
{"token": params["token"][0], "action": "declined"},
|
{"token": params["token"][0], "action": "declined"},
|
||||||
)
|
)
|
||||||
response = self.rsvp_view(request)
|
response = self.process_view(request)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "declined the invitation" in response.content.decode()
|
assert "declined the invitation" in response.content.decode()
|
||||||
@@ -612,13 +690,134 @@ class TestRSVPEndToEndFlow(TestCase):
|
|||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
# The event is in the past
|
# 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(
|
request = self.factory.post(
|
||||||
"/rsvp/",
|
"/api/v1.0/rsvp/",
|
||||||
{"token": params["token"][0], "action": "accepted"},
|
{"token": params["token"][0], "action": "accepted"},
|
||||||
)
|
)
|
||||||
response = self.rsvp_view(request)
|
response = self.process_view(request)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "already passed" in response.content.decode().lower()
|
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
|
||||||
|
|||||||
205
src/backend/core/tests/test_signals.py
Normal file
205
src/backend/core/tests/test_signals.py
Normal 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()
|
||||||
422
src/backend/core/tests/test_tasks.py
Normal file
422
src/backend/core/tests/test_tasks.py
Normal 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"]
|
||||||
@@ -8,19 +8,18 @@ from rest_framework.routers import DefaultRouter
|
|||||||
|
|
||||||
from core.api import viewsets
|
from core.api import viewsets
|
||||||
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
|
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_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
|
from core.external_api import viewsets as external_api_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("users", viewsets.UserViewSet, basename="users")
|
router.register("users", viewsets.UserViewSet, basename="users")
|
||||||
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
|
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
|
||||||
router.register(
|
router.register("resources", viewsets.ResourceViewSet, basename="resources")
|
||||||
"subscription-tokens",
|
router.register("channels", ChannelViewSet, basename="channels")
|
||||||
viewsets.SubscriptionTokenViewSet,
|
|
||||||
basename="subscription-tokens",
|
|
||||||
)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
@@ -29,35 +28,46 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
# CalDAV proxy - root path (must come before catch-all to match /caldav exactly)
|
# CalDAV scheduling callback endpoint
|
||||||
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)
|
|
||||||
path(
|
path(
|
||||||
"caldav-scheduling-callback/",
|
"caldav-scheduling-callback/",
|
||||||
CalDAVSchedulingCallbackView.as_view(),
|
CalDAVSchedulingCallbackView.as_view(),
|
||||||
name="caldav-scheduling-callback",
|
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()),
|
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)
|
# Public iCal export endpoint (no authentication required)
|
||||||
# Token in URL acts as authentication
|
# base64url channel ID for lookup, base64url token for auth, filename cosmetic
|
||||||
path(
|
re_path(
|
||||||
"ical/<uuid:token>.ics",
|
r"^ical/(?P<short_id>[A-Za-z0-9_-]+)/(?P<token>[A-Za-z0-9_-]+)/[^/]+\.ics$",
|
||||||
ICalExportView.as_view(),
|
ICalExportView.as_view(),
|
||||||
name="ical-export",
|
name="ical-export",
|
||||||
),
|
),
|
||||||
# RSVP endpoint (no authentication required)
|
# RSVP GET endpoint (renders auto-submitting confirmation page)
|
||||||
# Signed token in query string acts as authentication
|
# Signed token in query string acts as authentication
|
||||||
path("rsvp/", RSVPView.as_view(), name="rsvp"),
|
path("rsvp/", RSVPConfirmView.as_view(), name="rsvp"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,15 @@ class UserAuthViewSet(drf.viewsets.ViewSet):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Create user if doesn't exist
|
# Create user if doesn't exist
|
||||||
user = models.User.objects.filter(
|
email = serializer.validated_data["email"]
|
||||||
email=serializer.validated_data["email"]
|
user = models.User.objects.filter(email=email).first()
|
||||||
).first()
|
|
||||||
if not user:
|
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.set_unusable_password()
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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 ""
|
|
||||||
@@ -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 :"
|
|
||||||
@@ -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"
|
|
||||||
@@ -28,10 +28,11 @@ dependencies = [
|
|||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"dj-database-url==3.0.1",
|
"dj-database-url==3.0.1",
|
||||||
"caldav==2.2.3",
|
"caldav==2.2.3",
|
||||||
"celery[redis]==5.6.0",
|
"dramatiq[redis]==1.17.1",
|
||||||
"django==5.2.9",
|
"django==5.2.9",
|
||||||
"django-celery-beat==2.8.1",
|
"django-dramatiq==0.12.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
|
"django-fernet-encrypted-fields>=0.2",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==8.2.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
@@ -138,6 +139,9 @@ addopts = [
|
|||||||
"term-missing",
|
"term-missing",
|
||||||
# Allow test files to have the same name in different directories.
|
# Allow test files to have the same name in different directories.
|
||||||
"--import-mode=importlib",
|
"--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 = [
|
python_files = [
|
||||||
"test_*.py",
|
"test_*.py",
|
||||||
|
|||||||
110
src/backend/uv.lock
generated
110
src/backend/uv.lock
generated
@@ -101,13 +101,13 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "brotli" },
|
{ name = "brotli" },
|
||||||
{ name = "caldav" },
|
{ name = "caldav" },
|
||||||
{ name = "celery", extra = ["redis"] },
|
|
||||||
{ name = "dj-database-url" },
|
{ name = "dj-database-url" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-celery-beat" },
|
|
||||||
{ name = "django-configurations" },
|
{ name = "django-configurations" },
|
||||||
{ name = "django-cors-headers" },
|
{ name = "django-cors-headers" },
|
||||||
{ name = "django-countries" },
|
{ name = "django-countries" },
|
||||||
|
{ name = "django-dramatiq" },
|
||||||
|
{ name = "django-fernet-encrypted-fields" },
|
||||||
{ name = "django-filter" },
|
{ name = "django-filter" },
|
||||||
{ name = "django-lasuite", extra = ["all"] },
|
{ name = "django-lasuite", extra = ["all"] },
|
||||||
{ name = "django-parler" },
|
{ name = "django-parler" },
|
||||||
@@ -115,6 +115,7 @@ dependencies = [
|
|||||||
{ name = "django-timezone-field" },
|
{ name = "django-timezone-field" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "djangorestframework-api-key" },
|
{ name = "djangorestframework-api-key" },
|
||||||
|
{ name = "dramatiq", extra = ["redis"] },
|
||||||
{ name = "drf-spectacular" },
|
{ name = "drf-spectacular" },
|
||||||
{ name = "drf-standardized-errors" },
|
{ name = "drf-standardized-errors" },
|
||||||
{ name = "factory-boy" },
|
{ name = "factory-boy" },
|
||||||
@@ -157,15 +158,15 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "brotli", specifier = "==1.2.0" },
|
{ name = "brotli", specifier = "==1.2.0" },
|
||||||
{ name = "caldav", specifier = "==2.2.3" },
|
{ name = "caldav", specifier = "==2.2.3" },
|
||||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.0" },
|
|
||||||
{ name = "dj-database-url", specifier = "==3.0.1" },
|
{ name = "dj-database-url", specifier = "==3.0.1" },
|
||||||
{ name = "django", specifier = "==5.2.9" },
|
{ name = "django", specifier = "==5.2.9" },
|
||||||
{ name = "django-celery-beat", specifier = "==2.8.1" },
|
|
||||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
{ name = "django-countries", specifier = "==8.2.0" },
|
{ name = "django-countries", specifier = "==8.2.0" },
|
||||||
{ name = "django-debug-toolbar", marker = "extra == 'dev'", specifier = "==6.1.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-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
|
||||||
|
{ name = "django-fernet-encrypted-fields", specifier = ">=0.2" },
|
||||||
{ name = "django-filter", specifier = "==25.2" },
|
{ name = "django-filter", specifier = "==25.2" },
|
||||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.21" },
|
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.21" },
|
||||||
{ name = "django-parler", specifier = "==2.3" },
|
{ name = "django-parler", specifier = "==2.3" },
|
||||||
@@ -173,6 +174,7 @@ requires-dist = [
|
|||||||
{ name = "django-timezone-field", specifier = ">=5.1" },
|
{ name = "django-timezone-field", specifier = ">=5.1" },
|
||||||
{ name = "djangorestframework", specifier = "==3.16.1" },
|
{ name = "djangorestframework", specifier = "==3.16.1" },
|
||||||
{ name = "djangorestframework-api-key", specifier = "==3.1.0" },
|
{ 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", specifier = "==0.29.0" },
|
||||||
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2025.12.1" },
|
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2025.12.1" },
|
||||||
{ name = "drf-standardized-errors", specifier = "==0.15.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "django-configurations"
|
name = "django-configurations"
|
||||||
version = "2.5.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "django-extensions"
|
name = "django-extensions"
|
||||||
version = "4.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "django-filter"
|
name = "django-filter"
|
||||||
version = "25.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "drf-spectacular"
|
name = "drf-spectacular"
|
||||||
version = "0.29.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
version = "6.0.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
version = "3.0.52"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
204
src/backend/worker.py
Normal file
204
src/backend/worker.py
Normal 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()
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
"sabre/dav": "dev-master",
|
"sabre/dav": "dev-master#1000fc028469c240fe13459e36648959f1519d09",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"ext-pdo_pgsql": "*"
|
"ext-pdo_pgsql": "*"
|
||||||
},
|
},
|
||||||
@@ -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")
|
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
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ pid = /tmp/php-fpm.pid
|
|||||||
|
|
||||||
[www]
|
[www]
|
||||||
listen = /tmp/php-fpm.sock
|
listen = /tmp/php-fpm.sock
|
||||||
listen.mode = 0666
|
listen.mode = 0660
|
||||||
|
|
||||||
; When running as non-root, user/group settings are ignored
|
; When running as non-root, user/group settings are ignored
|
||||||
user = www-data
|
user = www-data
|
||||||
@@ -14,7 +14,12 @@ use Calendars\SabreDav\HttpCallbackIMipPlugin;
|
|||||||
use Calendars\SabreDav\ApiKeyAuthBackend;
|
use Calendars\SabreDav\ApiKeyAuthBackend;
|
||||||
use Calendars\SabreDav\CalendarSanitizerPlugin;
|
use Calendars\SabreDav\CalendarSanitizerPlugin;
|
||||||
use Calendars\SabreDav\AttendeeNormalizerPlugin;
|
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
|
// Composer autoloader
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
@@ -63,9 +68,11 @@ $carddavBackend = new CardDAV\Backend\PDO($pdo);
|
|||||||
$principalBackend = new AutoCreatePrincipalBackend($pdo);
|
$principalBackend = new AutoCreatePrincipalBackend($pdo);
|
||||||
|
|
||||||
// Create directory tree
|
// Create directory tree
|
||||||
|
// Principal collections: principals/users/ and principals/resources/
|
||||||
|
// Calendar collections: calendars/users/ and calendars/resources/
|
||||||
$nodes = [
|
$nodes = [
|
||||||
new CalDAV\Principal\Collection($principalBackend),
|
new PrincipalsRoot($principalBackend),
|
||||||
new CalDAV\CalendarRoot($principalBackend, $caldavBackend),
|
new CalendarsRoot($principalBackend, $caldavBackend),
|
||||||
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
|
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,9 +80,13 @@ $nodes = [
|
|||||||
$server = new DAV\Server($nodes);
|
$server = new DAV\Server($nodes);
|
||||||
$server->setBaseUri($baseUri);
|
$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
|
// Add plugins
|
||||||
$server->addPlugin($authPlugin);
|
$server->addPlugin($authPlugin);
|
||||||
$server->addPlugin(new CalDAV\Plugin());
|
$server->addPlugin(new CustomCalDAVPlugin());
|
||||||
$server->addPlugin(new CardDAV\Plugin());
|
$server->addPlugin(new CardDAV\Plugin());
|
||||||
$server->addPlugin(new DAVACL\Plugin());
|
$server->addPlugin(new DAVACL\Plugin());
|
||||||
$server->addPlugin(new DAV\Browser\Plugin());
|
$server->addPlugin(new DAV\Browser\Plugin());
|
||||||
@@ -137,9 +148,10 @@ $server->addPlugin(new CalendarSanitizerPlugin(
|
|||||||
// when processing calendar objects, fixing issues with REPLY handling
|
// when processing calendar objects, fixing issues with REPLY handling
|
||||||
$server->addPlugin(new AttendeeNormalizerPlugin());
|
$server->addPlugin(new AttendeeNormalizerPlugin());
|
||||||
|
|
||||||
// Add ICS import plugin for bulk event import from a single POST request
|
// Add internal API plugin for resource provisioning and ICS import
|
||||||
// Only accessible via the X-Calendars-Import header (backend-only)
|
// Gated by X-Internal-Api-Key header (separate from X-Api-Key used by proxy)
|
||||||
$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey));
|
$internalApiKey = getenv('CALDAV_INTERNAL_API_KEY') ?: $apiKey;
|
||||||
|
$server->addPlugin(new InternalApiPlugin($pdo, $caldavBackend, $internalApiKey));
|
||||||
|
|
||||||
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
|
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
|
||||||
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
|
// 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();
|
$schedulePlugin = new CalDAV\Schedule\Plugin();
|
||||||
$server->addPlugin($schedulePlugin);
|
$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
|
// Start server
|
||||||
$server->start();
|
$server->start();
|
||||||
@@ -18,7 +18,7 @@ CREATE TABLE cards (
|
|||||||
addressbookid INTEGER NOT NULL,
|
addressbookid INTEGER NOT NULL,
|
||||||
carddata BYTEA,
|
carddata BYTEA,
|
||||||
uri VARCHAR(200),
|
uri VARCHAR(200),
|
||||||
lastmodified INTEGER,
|
lastmodified BIGINT,
|
||||||
etag VARCHAR(32),
|
etag VARCHAR(32),
|
||||||
size INTEGER NOT NULL
|
size INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -3,12 +3,12 @@ CREATE TABLE calendarobjects (
|
|||||||
calendardata BYTEA,
|
calendardata BYTEA,
|
||||||
uri VARCHAR(200),
|
uri VARCHAR(200),
|
||||||
calendarid INTEGER NOT NULL,
|
calendarid INTEGER NOT NULL,
|
||||||
lastmodified INTEGER,
|
lastmodified BIGINT,
|
||||||
etag VARCHAR(32),
|
etag VARCHAR(32),
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
componenttype VARCHAR(8),
|
componenttype VARCHAR(8),
|
||||||
firstoccurence INTEGER,
|
firstoccurence BIGINT,
|
||||||
lastoccurence INTEGER,
|
lastoccurence BIGINT,
|
||||||
uid VARCHAR(200)
|
uid VARCHAR(200)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,17 +32,17 @@ ALTER TABLE ONLY calendars
|
|||||||
CREATE TABLE calendarinstances (
|
CREATE TABLE calendarinstances (
|
||||||
id SERIAL NOT NULL,
|
id SERIAL NOT NULL,
|
||||||
calendarid INTEGER NOT NULL,
|
calendarid INTEGER NOT NULL,
|
||||||
principaluri VARCHAR(100),
|
principaluri VARCHAR(255),
|
||||||
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
|
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
|
||||||
displayname VARCHAR(100),
|
displayname VARCHAR(255),
|
||||||
uri VARCHAR(200),
|
uri VARCHAR(200),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
calendarorder INTEGER NOT NULL DEFAULT 0,
|
calendarorder INTEGER NOT NULL DEFAULT 0,
|
||||||
calendarcolor VARCHAR(10),
|
calendarcolor VARCHAR(10),
|
||||||
timezone TEXT,
|
timezone TEXT,
|
||||||
transparent SMALLINT NOT NULL DEFAULT '0',
|
transparent SMALLINT NOT NULL DEFAULT '0',
|
||||||
share_href VARCHAR(100),
|
share_href VARCHAR(255),
|
||||||
share_displayname VARCHAR(100),
|
share_displayname VARCHAR(255),
|
||||||
share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid'
|
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 (
|
CREATE TABLE calendarsubscriptions (
|
||||||
id SERIAL NOT NULL,
|
id SERIAL NOT NULL,
|
||||||
uri VARCHAR(200) NOT NULL,
|
uri VARCHAR(200) NOT NULL,
|
||||||
principaluri VARCHAR(100) NOT NULL,
|
principaluri VARCHAR(255) NOT NULL,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
displayname VARCHAR(100),
|
displayname VARCHAR(255),
|
||||||
refreshrate VARCHAR(10),
|
refreshrate VARCHAR(10),
|
||||||
calendarorder INTEGER NOT NULL DEFAULT 0,
|
calendarorder INTEGER NOT NULL DEFAULT 0,
|
||||||
calendarcolor VARCHAR(10),
|
calendarcolor VARCHAR(10),
|
||||||
striptodos SMALLINT NULL,
|
striptodos SMALLINT NULL,
|
||||||
stripalarms SMALLINT NULL,
|
stripalarms SMALLINT NULL,
|
||||||
stripattachments SMALLINT NULL,
|
stripattachments SMALLINT NULL,
|
||||||
lastmodified INTEGER
|
lastmodified BIGINT
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE ONLY calendarsubscriptions
|
ALTER TABLE ONLY calendarsubscriptions
|
||||||
@@ -102,10 +102,16 @@ CREATE TABLE schedulingobjects (
|
|||||||
principaluri VARCHAR(255),
|
principaluri VARCHAR(255),
|
||||||
calendardata BYTEA,
|
calendardata BYTEA,
|
||||||
uri VARCHAR(200),
|
uri VARCHAR(200),
|
||||||
lastmodified INTEGER,
|
lastmodified BIGINT,
|
||||||
etag VARCHAR(32),
|
etag VARCHAR(32),
|
||||||
size INTEGER NOT NULL
|
size INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE ONLY schedulingobjects
|
ALTER TABLE ONLY schedulingobjects
|
||||||
ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id);
|
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);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
CREATE TABLE locks (
|
CREATE TABLE locks (
|
||||||
id SERIAL NOT NULL,
|
id SERIAL NOT NULL,
|
||||||
owner VARCHAR(100),
|
owner VARCHAR(100),
|
||||||
timeout INTEGER,
|
timeout BIGINT,
|
||||||
created INTEGER,
|
created BIGINT,
|
||||||
token VARCHAR(100),
|
token VARCHAR(100),
|
||||||
scope SMALLINT,
|
scope SMALLINT,
|
||||||
depth SMALLINT,
|
depth SMALLINT,
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user