🎉(all) bootstrap the Calendars project
This repository was forked from Drive in late December 2025 and boostraped as a minimal demo of backend+caldav server+frontend integration. There is much left to do and to fix!
This commit is contained in:
77
.cursor/rules/django-python.mdc
Normal file
77
.cursor/rules/django-python.mdc
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
description: Rules for writing Python with Django
|
||||
globs: src/backend/**/*.py
|
||||
alwaysApply: false
|
||||
---
|
||||
You are an expert in Python, Django, and scalable web application development.
|
||||
|
||||
Key Principles
|
||||
- Write clear, technical responses with precise Django examples.
|
||||
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
|
||||
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance for the most part, with the one exception being 100 characters per line instead of 79).
|
||||
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
|
||||
|
||||
Django/Python
|
||||
- Use Django REST Framework viewsets for API endpoints.
|
||||
- Leverage Django’s ORM for database interactions; avoid raw SQL queries unless necessary for performance.
|
||||
- Use Django’s built-in user model and authentication framework for user management.
|
||||
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
|
||||
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
|
||||
|
||||
Error Handling and Validation
|
||||
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
|
||||
- Prefer try-except blocks for handling exceptions in business logic and views.
|
||||
|
||||
Dependencies
|
||||
- Django
|
||||
- Django REST Framework (for API development)
|
||||
- Celery (for background tasks)
|
||||
- Redis (for caching and task queues)
|
||||
- PostgreSQL (preferred databases for production)
|
||||
- Minio (file storage for production)
|
||||
- OIDC prodiver (for managing authentication)
|
||||
|
||||
Django-Specific Guidelines
|
||||
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
|
||||
- Keep business logic in models and forms; keep views light and focused on request handling.
|
||||
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
|
||||
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
|
||||
- Use Django’s built-in tools for testing (pytest-django) to ensure code quality and reliability.
|
||||
- Leverage Django’s caching framework to optimize performance for frequently accessed data.
|
||||
- Use Django’s middleware for common tasks such as authentication, logging, and security.
|
||||
|
||||
Performance Optimization
|
||||
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
||||
- Use Django’s cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
|
||||
- Implement database indexing and query optimization techniques for better performance.
|
||||
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
|
||||
- Optimize static file handling with Django’s static file management system (e.g., WhiteNoise).
|
||||
|
||||
Logging
|
||||
- As a general rule, we should have logs for every expected and unexpected actions of the application, using the appropriate log level.
|
||||
- We should also be logging these exceptions to Sentry with the Sentry Python SDK. Python exceptions should almost always be captured automatically without extra instrumentation, but custom ones (such as failed requests to external services, query errors, or Celery task failures) can be tracked using capture_exception().
|
||||
|
||||
Log Levels
|
||||
- A log level or log severity is a piece of information telling how important a given log message is:
|
||||
- DEBUG: should be used for information that may be needed for diagnosing issues and troubleshooting or when running application in the test environment for the purpose of making sure everything is running correctly
|
||||
- INFO: should be used as standard log level, indicating that something happened
|
||||
- WARN: should be used when something unexpected happened but the code can continue the work
|
||||
- ERROR: should be used when the application hits an issue preventing one or more functionalities from properly functioning
|
||||
|
||||
Security
|
||||
- Don’t log sensitive information. Make sure you never log:
|
||||
- authorization tokens
|
||||
- passwords
|
||||
- financial data
|
||||
- health data
|
||||
- PII (Personal Identifiable Information)
|
||||
|
||||
Testing
|
||||
- All new packages and most new significant functionality should come with unit tests
|
||||
|
||||
Unit tests
|
||||
- A good unit test should:
|
||||
- focus on a single use-case at a time
|
||||
- have a minimal set of assertions per test
|
||||
- demonstrate every use case. The rule of thumb is: if it can happen, it should be covered
|
||||
Refer to Django documentation for best practices in views, models, forms, and security considerations.
|
||||
6
.github/ISSUE_TEMPLATE.md
vendored
Normal file
6
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<!---
|
||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
||||
|
||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
||||
-->
|
||||
28
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Calendars version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
23
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
22
.github/ISSUE_TEMPLATE/Support_question.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/Support_question.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/suitenumerique/calendars).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/calendars/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
114
.github/workflows/calendars-frontend.yml
vendored
Normal file
114
.github/workflows/calendars-frontend.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
name: Frontend Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: "22.x"
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-unit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd src/frontend/apps/calendars
|
||||
yarn test
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
strategy:
|
||||
matrix:
|
||||
browser:
|
||||
- chromium
|
||||
- webkit
|
||||
- firefox
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: |
|
||||
cd src/frontend/apps/e2e
|
||||
npx playwright install --with-deps ${{matrix.browser}}
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make bootstrap-e2e
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
cd src/frontend && yarn dev &
|
||||
|
||||
- name: Wait for Keycloak to be ready
|
||||
run: |
|
||||
timeout 30 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' http://localhost:8083/)" != "302" ]]; do echo "Waiting for Keycloak..." && sleep 2; done' && echo "Keycloak is ready!"
|
||||
|
||||
- name: Run e2e tests
|
||||
run: |
|
||||
cd src/frontend/apps/e2e
|
||||
yarn test --project=${{ matrix.browser }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: report-${{ matrix.browser }}
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
187
.github/workflows/calendars.yml
vendored
Normal file
187
.github/workflows/calendars.yml
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
name: Main Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/calendars.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: calendars
|
||||
POSTGRES_USER: pgroot
|
||||
POSTGRES_PASSWORD: pass
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: calendars.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DJANGO_EMAIL_HOST: mailcatcher
|
||||
DB_HOST: localhost
|
||||
DB_NAME: calendars
|
||||
DB_USER: pgroot
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: calendar
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
MEDIA_BASE_URL: http://localhost:8083
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=calendar" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set calendar http://localhost:9000 calendar password && \
|
||||
mc alias ls && \
|
||||
mc mb calendar/calendar-media-storage && \
|
||||
mc version enable calendar/calendar-media-storage"
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest -n 2
|
||||
76
.github/workflows/crowdin_download.yml
vendored
Normal file
76
.github/workflows/crowdin_download.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/impress/
|
||||
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n
|
||||
68
.github/workflows/crowdin_upload.yml
vendored
Normal file
68
.github/workflows/crowdin_upload.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-front
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@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@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
104
.github/workflows/docker-hub.yml
vendored
Normal file
104
.github/workflows/docker-hub.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/calendars-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/calendars-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/calendars-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/calendars-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Install frontend installation reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '20.x'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
84
.gitignore
vendored
Normal file
84
.gitignore
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.DS_Store
|
||||
.next/
|
||||
|
||||
# Translations # Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
|
||||
# Site media
|
||||
/data/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Various
|
||||
.turbo
|
||||
78
.gitlint
Normal file
78
.gitlint
Normal file
@@ -0,0 +1,78 @@
|
||||
# All these sections are optional, edit this file as you like.
|
||||
[general]
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# ignore=title-trailing-punctuation, T3
|
||||
|
||||
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
|
||||
# verbosity = 2
|
||||
|
||||
# By default gitlint will ignore merge commits. Set to 'false' to disable.
|
||||
# ignore-merge-commits=true
|
||||
|
||||
# By default gitlint will ignore fixup commits. Set to 'false' to disable.
|
||||
# ignore-fixup-commits=true
|
||||
|
||||
# By default gitlint will ignore squash commits. Set to 'false' to disable.
|
||||
# ignore-squash-commits=true
|
||||
|
||||
# Enable debug mode (prints more output). Disabled by default.
|
||||
# debug=true
|
||||
|
||||
# Set the extra-path where gitlint will search for user defined rules
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
[title-must-not-contain-word]
|
||||
# Comma-separated list of words that should not occur in the title. Matching is case
|
||||
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
|
||||
# will not cause a violation, but "WIP: my title" will.
|
||||
words=wip
|
||||
|
||||
#[title-match-regex]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit-msg title must be matched to.
|
||||
# Note that the regex can contradict with other rules if not used correctly
|
||||
# (e.g. title-must-not-contain-word).
|
||||
#regex=
|
||||
|
||||
# [B1]
|
||||
# B1 = body-max-line-length
|
||||
# line-length=120
|
||||
# [body-min-length]
|
||||
# min-length=5
|
||||
|
||||
# [body-is-missing]
|
||||
# Whether to ignore this rule on merge commits (which typically only have a title)
|
||||
# default = True
|
||||
# ignore-merge-commits=false
|
||||
|
||||
# [body-changed-file-mention]
|
||||
# List of files that need to be explicitly mentioned in the body when they are changed
|
||||
# This is useful for when developers often erroneously edit certain files or git submodules.
|
||||
# By specifying this rule, developers can only change the file when they explicitly reference
|
||||
# it in the commit message.
|
||||
# files=gitlint/rules.py,README.md
|
||||
|
||||
# [author-valid-email]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit author email address should be matched to
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
[ignore-by-title]
|
||||
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
|
||||
# upgrade dependencies
|
||||
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
|
||||
ignore=B6,UC1
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
# regex=(.*)release(.*)
|
||||
#
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# Use 'all' to ignore all rules
|
||||
# ignore=T1,body-min-length
|
||||
79
CODE_OF_CONDUCT.md
Normal file
79
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
- Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at contact@suite.anct.gouv.fr.
|
||||
|
||||
- All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
- All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct
|
||||
|
||||
## Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
Community Impact: A violation through a single incident or series of actions.
|
||||
|
||||
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
Consequence: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/calendars/blob/main/README.md) for detailed instructions on how to run Calendars locally.
|
||||
|
||||
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons please [sign your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change (*)
|
||||
* **blank line after the commit title
|
||||
* **description**: Include additional details on why you made the changes (**).
|
||||
|
||||
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
|
||||
|
||||
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- signoff your commits
|
||||
- sign your commits with your key (SSH, GPG etc.)
|
||||
- check your commits (see warnings above)
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Agence Nationale de la Cohésion des Territoires - Gouvernement Français
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
369
Makefile
Normal file
369
Makefile
Normal file
@@ -0,0 +1,369 @@
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# This Makefile is only meant to be used for DEVELOPMENT purpose as we are
|
||||
# changing the user id that will run in the container.
|
||||
#
|
||||
# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER...
|
||||
#
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# Note to developers:
|
||||
#
|
||||
# While editing this file, please respect the following statements:
|
||||
#
|
||||
# 1. Every variable should be defined in the ad hoc VARIABLES section with a
|
||||
# relevant subsection
|
||||
# 2. Every new rule should be defined in the ad hoc RULES section with a
|
||||
# relevant subsection depending on the targeted service
|
||||
# 3. Rules should be sorted alphabetically within their section
|
||||
# 4. When a rule has multiple dependencies, you should:
|
||||
# - duplicate the rule name to add the help string (if required)
|
||||
# - write one dependency per line to increase readability and diffs
|
||||
# 5. .PHONY rule statement should be written after the corresponding rule
|
||||
# ==============================================================================
|
||||
# VARIABLES
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
GREEN := \033[1;32m
|
||||
|
||||
|
||||
# -- Database
|
||||
|
||||
DB_HOST = postgresql
|
||||
DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) backend-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) backend-dev
|
||||
COMPOSE_RUN_APP_NO_DEPS = $(COMPOSE_RUN) --no-deps backend-dev
|
||||
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MANAGE_EXEC = $(COMPOSE_EXEC_APP) python manage.py
|
||||
PSQL_E2E = ./bin/postgres_e2e
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
PATH_FRONT_CALENDARS = $(PATH_FRONT)/apps/calendars
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
default: help
|
||||
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Create empty .local env files for local development
|
||||
create-env-files: \
|
||||
env.d/development/crowdin.local \
|
||||
env.d/development/postgresql.local \
|
||||
env.d/development/keycloak.local \
|
||||
env.d/development/backend.local \
|
||||
env.d/development/frontend.local \
|
||||
env.d/development/davical.local
|
||||
.PHONY: create-env-files
|
||||
|
||||
env.d/development/%.local:
|
||||
@echo "# Local development overrides for $(notdir $*)" > $@
|
||||
@echo "# Add your local-specific environment variables below:" >> $@
|
||||
@echo "# Example: DJANGO_DEBUG=True" >> $@
|
||||
@echo "" >> $@
|
||||
.PHONY: env.d/development/%.local
|
||||
|
||||
create-docker-network: ## create the docker network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
create-docker-network \
|
||||
migrate \
|
||||
migrate-davical \
|
||||
back-i18n-compile \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: cache ?= # --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
build-backend: ## build the backend-dev container
|
||||
@$(COMPOSE) build backend-dev $(cache)
|
||||
.PHONY: build-backend
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
.PHONY: build-frontend-development
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
rm -rf data/postgresql.*
|
||||
.PHONY: down
|
||||
|
||||
logs: ## display backend-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f backend-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start the backend container
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
.PHONY: run-backend
|
||||
|
||||
bootstrap-e2e: ## bootstrap the backend container for e2e tests, without frontend
|
||||
bootstrap-e2e: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-local-files \
|
||||
build-backend \
|
||||
create-docker-network \
|
||||
back-i18n-compile \
|
||||
run-backend-e2e
|
||||
.PHONY: bootstrap-e2e
|
||||
|
||||
clear-db-e2e: ## quickly clears the database for e2e tests, used in the e2e tests
|
||||
$(PSQL_E2E) -c "$$(cat bin/clear_db_e2e.sql)"
|
||||
.PHONY: clear-db-e2e
|
||||
|
||||
run-backend-e2e: ## start the backend container for e2e tests, always remove the postgresql.e2e volume first
|
||||
@$(MAKE) stop
|
||||
rm -rf data/postgresql.e2e
|
||||
@ENV_OVERRIDE=e2e $(MAKE) run-backend
|
||||
@ENV_OVERRIDE=e2e $(MAKE) migrate
|
||||
.PHONY: run-backend-e2e
|
||||
|
||||
run-tests-e2e: ## run the e2e tests, example: make run-tests-e2e -- --project chromium --headed
|
||||
@$(MAKE) run-backend-e2e
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
cd src/frontend/apps/e2e && yarn test $${args:-${1}}
|
||||
.PHONY: run-tests-e2e
|
||||
|
||||
backend-exec-command: ## execute a command in the backend container
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
$(MANAGE_EXEC) $${args}
|
||||
.PHONY: backend-exec-command
|
||||
|
||||
run: ## start the development server and frontend development
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
|
||||
migrate-davical: ## Initialize DAViCal database schema
|
||||
@echo "$(BOLD)Initializing DAViCal database schema...$(RESET)"
|
||||
@$(COMPOSE) run --rm davical run-migrations
|
||||
@echo "$(GREEN)DAViCal initialized$(RESET)"
|
||||
.PHONY: migrate-davical
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
|
||||
stop: ## stop the development server using Docker
|
||||
@$(COMPOSE) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Backend
|
||||
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MAKE) resetdb
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
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-ruff-format \
|
||||
lint-ruff-check \
|
||||
lint-pylint
|
||||
.PHONY: lint
|
||||
|
||||
lint-ruff-format: ## format back-end python sources with ruff
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_APP_NO_DEPS) ruff format .
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_APP_NO_DEPS) ruff check . --fix
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
bin/pylint --diff-only=origin/main
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args:-${1}}
|
||||
.PHONY: test-back
|
||||
|
||||
test-back-parallel: ## run all back-end tests in parallel
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
makemigrations: ## run django makemigrations for the calendar project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the calendar project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
||||
.PHONY: superuser
|
||||
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-lock: ## regenerate the uv.lock file (uses temporary container)
|
||||
@echo "$(BOLD)Regenerating uv.lock$(RESET)"
|
||||
@docker run --rm -v $(PWD)/src/backend:/app -w /app ghcr.io/astral-sh/uv:python3.13-alpine uv lock
|
||||
.PHONY: back-lock
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@$(MANAGE) makemessages -a --keep-pot --all
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
back-shell: ## open a shell in the backend container
|
||||
@$(COMPOSE) run --rm --build backend-dev /bin/sh
|
||||
.PHONY: back-shell
|
||||
|
||||
shell: ## connect to django shell
|
||||
@$(MANAGE) shell #_plus
|
||||
.PHONY: shell
|
||||
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec backend-dev python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
resetdb: ## flush database and create a superuser "admin"
|
||||
@echo "$(BOLD)Flush database$(RESET)"
|
||||
@$(MANAGE) flush $(FLUSH_ARGS)
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
crowdin-download-sources: ## Download sources from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-download-sources
|
||||
|
||||
crowdin-upload: ## Upload source translations to crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload
|
||||
|
||||
i18n-compile: ## compile all translations
|
||||
i18n-compile: \
|
||||
back-i18n-compile \
|
||||
frontend-i18n-compile
|
||||
.PHONY: i18n-compile
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
i18n-generate: \
|
||||
back-i18n-generate \
|
||||
frontend-i18n-generate
|
||||
.PHONY: i18n-generate
|
||||
|
||||
i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications
|
||||
i18n-download-and-compile: \
|
||||
crowdin-download \
|
||||
i18n-compile
|
||||
.PHONY: i18n-download-and-compile
|
||||
|
||||
i18n-generate-and-upload: ## generate source translations for all applications and upload them to Crowdin
|
||||
i18n-generate-and-upload: \
|
||||
i18n-generate \
|
||||
crowdin-upload
|
||||
.PHONY: i18n-generate-and-upload
|
||||
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
git clean -idx
|
||||
.PHONY: clean
|
||||
|
||||
clean-media: ## remove all media files
|
||||
rm -rf data/media/*
|
||||
.PHONY: clean-media
|
||||
|
||||
help:
|
||||
@echo "$(BOLD)calendar Makefile"
|
||||
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_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
|
||||
3
Procfile
Normal file
3
Procfile
Normal file
@@ -0,0 +1,3 @@
|
||||
web: bin/scalingo_run_web
|
||||
worker: celery -A calendars.celery_app worker --task-events --beat -l INFO -c $DJANGO_CELERY_CONCURRENCY -Q celery,default
|
||||
postdeploy: python manage.py migrate
|
||||
155
README.md
Normal file
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/calendars">
|
||||
<img alt="Calendars banner" src="/docs/assets/banner-calendars.png" width="100%" />
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/calendars"/>
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/calendars"/>
|
||||
<a href="https://github.com/suitenumerique/calendars/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/calendars"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://matrix.to/#/#messages-official:matrix.org">
|
||||
Chat on Matrix
|
||||
</a> - <a href="/docs/">
|
||||
Documentation
|
||||
</a> - <a href="#getting-started-">
|
||||
Getting started
|
||||
</a> - <a href="contact@suite.anct.gouv.fr">
|
||||
Reach out
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# La Suite Calendars
|
||||
A modern calendar application for managing events and schedules.
|
||||
|
||||
<img src="/docs/assets/calendars-UI.png" width="100%" align="center"/>
|
||||
|
||||
|
||||
## Why use Calendars ❓
|
||||
Calendars empowers teams to manage events and schedules while maintaining full control over their data through a user-friendly, open-source platform.
|
||||
|
||||
### Manage Events
|
||||
- 📅 Create and manage events and schedules
|
||||
- 🌐 Access your calendar from anywhere with our web-based interface
|
||||
|
||||
### Organize
|
||||
- 📂 Organized calendar structure with intuitive navigation
|
||||
|
||||
### Collaborate
|
||||
- 🤝 Share calendars with your team members
|
||||
- 👥 Granular access control to ensure your information is secure and only shared with the right people
|
||||
- 🏢 Create workspaces to organize team collaboration
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure calendar solution
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
### Prerequisite
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```bash
|
||||
$ docker -v
|
||||
Docker version 27.5.1, build 9f9e405
|
||||
|
||||
$ docker compose version
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Bootstrap project
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
$ make bootstrap
|
||||
```
|
||||
|
||||
This command builds the `backend-dev` and `frontend-dev` containers, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running! 🎉
|
||||
|
||||
You can access the project by going to <http://localhost:8920>.
|
||||
|
||||
You will be prompted to log in. The default credentials are:
|
||||
|
||||
```
|
||||
username: calendars
|
||||
password: calendars
|
||||
```
|
||||
|
||||
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
$ make run
|
||||
```
|
||||
|
||||
You can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
```
|
||||
|
||||
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
|
||||
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```shellscript
|
||||
$ make frontend-development-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
|
||||
```shellscript
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```shellscript
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8921/admin](http://localhost:8921/admin).
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
You can then login with sub `admin@example.com` and password `admin`.
|
||||
|
||||
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#messages-official:matrix.org).
|
||||
|
||||
## Contributing 🙌
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
|
||||
## License 📝
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
|
||||
While Calendars is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Credits ❤️
|
||||
|
||||
Calendars is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/). We thank the contributors of all these projects for their awesome work!
|
||||
23
SECURITY.md
Normal file
23
SECURITY.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly emailing us at security@suite.anct.gouv.fr
|
||||
|
||||
We appreciate your effort to make Calendars more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||
|
||||
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||
release).
|
||||
2. In the release notes, we will include the identification numbers from the
|
||||
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||
and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
93
bin/_config.sh
Normal file
93
bin/_config.sh
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
if [ -z ${COMPOSE_FILE+x} ]; then
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
|
||||
fi
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
#
|
||||
# usage: _set_user
|
||||
#
|
||||
# You can override default user ID (the current host user ID), by defining the
|
||||
# USER_ID environment variable.
|
||||
#
|
||||
# To avoid running docker commands with a custom user, please set the
|
||||
# $UNSET_USER environment variable to 1.
|
||||
function _set_user() {
|
||||
|
||||
if [ $UNSET_USER -eq 1 ]; then
|
||||
USER_ID=""
|
||||
return
|
||||
fi
|
||||
|
||||
# USER_ID = USER_ID or `id -u` if USER_ID is not set
|
||||
USER_ID=${USER_ID:-$(id -u)}
|
||||
|
||||
echo "🙋(user) ID: ${USER_ID}"
|
||||
}
|
||||
|
||||
# docker_compose: wrap docker compose command
|
||||
#
|
||||
# usage: docker_compose [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose command options
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# _dc_run: wrap docker compose run command
|
||||
#
|
||||
# usage: _dc_run [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose exec command options
|
||||
# ARGS : docker compose exec command arguments
|
||||
function _dc_exec() {
|
||||
_set_user
|
||||
|
||||
echo "🐳(compose) exec command: '\$@'"
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose exec $user_args "$@"
|
||||
}
|
||||
|
||||
# _django_manage: wrap django's manage.py command with docker compose
|
||||
#
|
||||
# usage : _django_manage [ARGS...]
|
||||
#
|
||||
# ARGS : django's manage.py command arguments
|
||||
function _django_manage() {
|
||||
_dc_run "backend-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
9
bin/clear_db_e2e.sql
Normal file
9
bin/clear_db_e2e.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
DO $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'django_migrations')
|
||||
LOOP
|
||||
RAISE NOTICE 'Truncating table %', r.tablename;
|
||||
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
6
bin/compose
Executable file
6
bin/compose
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
6
bin/fernetkey
Executable file
6
bin/fernetkey
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run backend-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
6
bin/manage
Executable file
6
bin/manage
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_django_manage "$@"
|
||||
12
bin/postgres_e2e
Executable file
12
bin/postgres_e2e
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
# Get database credentials from environment file
|
||||
ENV_FILE="${REPO_DIR}/env.d/development/postgresql.e2e"
|
||||
POSTGRES_USER=$(grep POSTGRES_USER "$ENV_FILE" | cut -d'=' -f2)
|
||||
POSTGRES_DB=$(grep POSTGRES_DB "$ENV_FILE" | cut -d'=' -f2)
|
||||
|
||||
# Execute PostgreSQL command (run as postgres user, not host user)
|
||||
_docker_compose exec -T postgresql psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 "$@"
|
||||
38
bin/pylint
Executable file
38
bin/pylint
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
declare diff_from
|
||||
declare -a paths
|
||||
declare -a args
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--diff-only=*)
|
||||
diff_from="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$arg")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
paths+=("$arg")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${diff_from}" ]]; then
|
||||
# Run pylint only on modified files located in src/backend
|
||||
# (excluding deleted files and migration files)
|
||||
# shellcheck disable=SC2207
|
||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
||||
fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run --no-deps backend-dev pylint "${paths[@]}" "${args[@]}"
|
||||
8
bin/pytest
Executable file
8
bin/pytest
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
backend-dev \
|
||||
pytest "$@"
|
||||
11
bin/scalingo_postcompile
Normal file
11
bin/scalingo_postcompile
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-compile script"
|
||||
|
||||
# Remove all the files we don't need
|
||||
rm -rf src docker env.d .cursor .github compose.yaml README.md .cache
|
||||
|
||||
chmod +x bin/scalingo_run_web
|
||||
15
bin/scalingo_postfrontend
Normal file
15
bin/scalingo_postfrontend
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-frontend script"
|
||||
|
||||
# Move the frontend build to the nginx root and clean up
|
||||
mkdir -p build/
|
||||
mv src/frontend/apps/calendars/out build/frontend-out
|
||||
|
||||
mv src/backend/* ./
|
||||
mv src/nginx/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
15
bin/scalingo_run_web
Normal file
15
bin/scalingo_run_web
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start the Django backend
|
||||
gunicorn -b :8000 calendars.wsgi:application --log-file - &
|
||||
|
||||
# Start the Nginx server
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
# then kill all the other tasks
|
||||
pkill -P $$
|
||||
17
bin/update_app_cacert.sh
Executable file
17
bin/update_app_cacert.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
|
||||
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
|
||||
# The script is executed with the following command:
|
||||
# $ bin/update_app_cacert.sh docs-production-backend-1
|
||||
|
||||
CONTAINER_NAME=${1:-"calendars-production-backend-1"}
|
||||
|
||||
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
|
||||
|
||||
|
||||
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
|
||||
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
|
||||
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
|
||||
echo "end patching cacert.pem in ${CONTAINER_NAME}"
|
||||
12
bin/update_openapi_schema
Executable file
12
bin/update_openapi_schema
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
backend-dev \
|
||||
python manage.py spectacular \
|
||||
--api-version 'v1.0' \
|
||||
--urlconf 'calendars.api_urls' \
|
||||
--format openapi-json \
|
||||
--file /app/core/tests/swagger/swagger.json
|
||||
183
compose.yaml
Normal file
183
compose.yaml
Normal file
@@ -0,0 +1,183 @@
|
||||
name: calendars
|
||||
|
||||
services:
|
||||
# Shared PostgreSQL for all services (calendar, davical, keycloak)
|
||||
postgresql:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- "8912:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql.defaults
|
||||
- env.d/development/postgresql.local
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
backend-dev:
|
||||
build:
|
||||
context: src/backend
|
||||
target: backend-development
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: calendars:backend-development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
- DAVICAL_URL=http://davical:80
|
||||
env_file:
|
||||
- env.d/development/backend.defaults
|
||||
- env.d/development/backend.local
|
||||
ports:
|
||||
- "8921:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
networks:
|
||||
- lasuite
|
||||
- default
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
celery-dev:
|
||||
condition: service_started
|
||||
davical:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: calendars:backend-development
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
command: ["celery", "-A", "calendars.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/backend.defaults
|
||||
- env.d/development/backend.local
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8923:8083"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- nginx
|
||||
volumes:
|
||||
- ./docker/files/development/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- backend-dev
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: src/frontend
|
||||
target: calendars-dev
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8921"
|
||||
image: calendars:frontend-development
|
||||
env_file:
|
||||
- env.d/development/frontend.defaults
|
||||
- env.d/development/frontend.local
|
||||
volumes:
|
||||
- ./src/frontend/:/home/frontend/
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/calendars/node_modules
|
||||
- /home/frontend/packages/open-calendar/dist
|
||||
- /home/frontend/packages/open-calendar/node_modules
|
||||
ports:
|
||||
- "8920:3000"
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin.defaults
|
||||
- env.d/development/crowdin.local
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
# DAViCal CalDAV Server
|
||||
davical:
|
||||
image: fintechstudios/davical:latest
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "8922:80"
|
||||
env_file:
|
||||
- env.d/development/davical.defaults
|
||||
- env.d/development/davical.local
|
||||
volumes:
|
||||
# Mount our custom config.php if we need to override defaults
|
||||
- ./docker/davical/config.php:/etc/davical/config.php:ro
|
||||
- ./docker/davical/run-migrations:/usr/local/bin/run-migrations:ro
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
|
||||
# Keycloak - now using shared PostgreSQL
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.3.2
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --hostname=http://localhost:8925
|
||||
- --hostname-strict=false
|
||||
env_file:
|
||||
- env.d/development/keycloak.defaults
|
||||
- env.d/development/keycloak.local
|
||||
ports:
|
||||
- "8925:8080"
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- lasuite
|
||||
- default
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
external: true
|
||||
7
cron.json
Normal file
7
cron.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"command": "0 0 * * * bin/scalingo_pgdump.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
2358
docker/auth/realm.json
Normal file
2358
docker/auth/realm.json
Normal file
File diff suppressed because it is too large
Load Diff
39
docker/davical/Dockerfile
Normal file
39
docker/davical/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# DAViCal CalDAV Server
|
||||
# Based on Debian with Apache and PHP
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
apache2 \
|
||||
libapache2-mod-php \
|
||||
php-pgsql \
|
||||
php-xml \
|
||||
php-curl \
|
||||
php-imap \
|
||||
php-ldap \
|
||||
davical \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable required Apache modules
|
||||
RUN a2enmod rewrite
|
||||
|
||||
# Copy Apache configuration
|
||||
COPY davical.conf /etc/apache2/sites-available/davical.conf
|
||||
RUN a2dissite 000-default && a2ensite davical
|
||||
|
||||
# Copy DAViCal configuration
|
||||
COPY config.php /etc/davical/config.php
|
||||
|
||||
# Copy and setup entrypoint
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/log/apache2
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
64
docker/davical/config.php
Normal file
64
docker/davical/config.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* DAViCal Configuration
|
||||
* This file is mounted as /etc/davical/config.php
|
||||
* Overrides the default config generated by the fintechstudios/davical image
|
||||
*/
|
||||
|
||||
// Database connection - uses shared calendars database in public schema
|
||||
// The image will set these from PGHOST, PGDATABASE, PGUSER, PGPASSWORD
|
||||
$c->pg_connect[] = 'host=' . getenv('PGHOST') . ' port=' . (getenv('PGPORT') ?: '5432') . ' dbname=' . getenv('PGDATABASE') . ' user=' . getenv('PGUSER') . ' password=' . getenv('PGPASSWORD');
|
||||
|
||||
// System name
|
||||
$c->system_name = 'Calendars DAViCal Server';
|
||||
|
||||
// Admin email
|
||||
$c->admin_email = 'admin@example.com';
|
||||
|
||||
// Allow public access for CalDAV discovery
|
||||
$c->public_freebusy_url = true;
|
||||
|
||||
// Default locale
|
||||
$c->default_locale = 'en_US.UTF-8';
|
||||
|
||||
// Logging - enable for debugging authentication issues
|
||||
$c->log_caldav_queries = true;
|
||||
|
||||
// Trust proxy headers for auth
|
||||
$c->trust_x_forwarded = true;
|
||||
|
||||
// Configure base path when behind reverse proxy
|
||||
// Override SCRIPT_NAME so DAViCal generates correct URLs
|
||||
// DAViCal uses $_SERVER['SCRIPT_NAME'] to determine the base path for URLs
|
||||
// We set it to the proxy path WITHOUT /caldav.php since DAViCal will add that itself
|
||||
if (isset($_SERVER['HTTP_X_FORWARDED_PREFIX'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = rtrim($_SERVER['HTTP_X_FORWARDED_PREFIX'], '/');
|
||||
} elseif (isset($_SERVER['HTTP_X_SCRIPT_NAME'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = rtrim($_SERVER['HTTP_X_SCRIPT_NAME'], '/');
|
||||
}
|
||||
|
||||
// Custom authentication function to use X-Forwarded-User header
|
||||
// This function is called by DAViCal's authentication system
|
||||
function authenticate_via_forwarded_user( $username, $password ) {
|
||||
// Check if X-Forwarded-User header is present
|
||||
if (isset($_SERVER['HTTP_X_FORWARDED_USER'])) {
|
||||
$forwarded_user = trim($_SERVER['HTTP_X_FORWARDED_USER']);
|
||||
|
||||
// If the username from Basic Auth matches X-Forwarded-User, authenticate
|
||||
// Users with password '*' are externally authenticated
|
||||
if (strtolower($username) === strtolower($forwarded_user)) {
|
||||
// Return the username to authenticate as this user
|
||||
// DAViCal will check if user exists and has password '*'
|
||||
return $forwarded_user;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to standard authentication
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use custom authentication hook
|
||||
$c->authenticate_hook = array(
|
||||
'call' => 'authenticate_via_forwarded_user',
|
||||
'config' => array()
|
||||
);
|
||||
22
docker/davical/davical.conf
Normal file
22
docker/davical/davical.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName localhost
|
||||
DocumentRoot /usr/share/davical/htdocs
|
||||
DirectoryIndex index.php
|
||||
|
||||
Alias /images/ /usr/share/davical/htdocs/images/
|
||||
|
||||
<Directory /usr/share/davical/htdocs>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
AcceptPathInfo On
|
||||
|
||||
# CalDAV principal URL
|
||||
RewriteEngine On
|
||||
RewriteRule ^/caldav/(.*)$ /caldav.php/$1 [L]
|
||||
RewriteRule ^/\.well-known/caldav /caldav.php [R=301,L]
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/davical_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/davical_access.log combined
|
||||
</VirtualHost>
|
||||
93
docker/davical/run-migrations
Executable file
93
docker/davical/run-migrations
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
###
|
||||
# Run DB migrations necessary to use Davical.
|
||||
# Will create the database on first-run, and only run necessary migrations on subsequent runs.
|
||||
#
|
||||
# Requires the following environment variables in addition to the container variables.
|
||||
# - ROOT_PGUSER
|
||||
# - ROOT_PGPASSWORD
|
||||
# - DAVICAL_ADMIN_PASS
|
||||
###
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z ${ROOT_PGUSER+x} ]; then
|
||||
echo "ROOT_PGUSER must be set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z ${ROOT_PGPASSWORD+x} ]; then
|
||||
echo "ROOT_PGPASSWORD must be set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z ${DAVICAL_ADMIN_PASS+x} ]; then
|
||||
echo "DAVICAL_ADMIN_PASS must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z ${DBA_PGPASSWORD+x} ]; then
|
||||
DBA_PGPASSWORD=$PGPASSWORD
|
||||
fi
|
||||
|
||||
if [ -z ${DAVICAL_SCHEMA+x} ]; then
|
||||
DAVICAL_SCHEMA=$DBA_PGUSER
|
||||
fi
|
||||
|
||||
# store PG environment so it can be overridden as-needed
|
||||
DAVICAL_PGUSER=$PGUSER
|
||||
DAVICAL_PGPASSWORD=$PGPASSWORD
|
||||
DAVICAL_PGDATABASE=$PGDATABASE
|
||||
|
||||
run_migrations() {
|
||||
echo "Running dba/update-davical-database, which should automatically apply any necessary DB migrations."
|
||||
/usr/share/davical/dba/update-davical-database \
|
||||
--dbname $DAVICAL_PGDATABASE \
|
||||
--dbuser $DBA_PGUSER \
|
||||
--dbhost $PGHOST \
|
||||
--dbpass $DBA_PGPASSWORD \
|
||||
--appuser $DAVICAL_PGUSER \
|
||||
--owner $DBA_PGUSER
|
||||
}
|
||||
|
||||
export PGUSER=$ROOT_PGUSER
|
||||
export PGPASSWORD=$ROOT_PGPASSWORD
|
||||
export PGDATABASE=
|
||||
|
||||
# Wait for PG connection
|
||||
retries=10
|
||||
until pg_isready -q -t 3; do
|
||||
[[ retries -eq 0 ]] && echo "Could not connect to Postgres" && exit 1
|
||||
echo "Waiting for Postgres to be available"
|
||||
retries=$((retries-1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check whether the database has already been setup, with awl tables.
|
||||
tables=$(psql -d $DAVICAL_PGDATABASE -c "\\dt")
|
||||
if echo "$tables" | grep -q "awl_db_revision"; then
|
||||
# The database already exists - just run any outstanding migrations
|
||||
run_migrations
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Database has not been created - running first-time database setup"
|
||||
|
||||
# the rest of the commands are run as the dba superuser
|
||||
export PGUSER=$DBA_PGUSER
|
||||
export PGPASSWORD=$DBA_PGPASSWORD
|
||||
export PGDATABASE=$DAVICAL_PGDATABASE
|
||||
|
||||
psql -qXAt -f /usr/share/awl/dba/awl-tables.sql
|
||||
psql -qXAt -f /usr/share/awl/dba/schema-management.sql
|
||||
psql -qXAt -f /usr/share/davical/dba/davical.sql
|
||||
run_migrations
|
||||
psql -qXAt -f /usr/share/davical/dba/base-data.sql
|
||||
|
||||
# DAViCal only uses salted SHA1 at-best, but it's better than storing the password in plaintext!
|
||||
# see https://wiki.davical.org/index.php?title=Force_Admin_Password
|
||||
# from https://gitlab.com/davical-project/awl/-/blob/3f044e2dc8435c2eeba61a3c41ec11c820711ab3/inc/DataUpdate.php#L48-58
|
||||
salted_password=$(php -r 'require "/usr/share/awl/inc/AWLUtilities.php"; echo session_salted_sha1($argv[1]);' "$DAVICAL_ADMIN_PASS")
|
||||
psql -qX \
|
||||
-v pw="'$salted_password'" \
|
||||
<<EOF
|
||||
UPDATE usr SET password = :pw WHERE user_no = 1;
|
||||
EOF
|
||||
28
docker/files/development/etc/nginx/conf.d/default.conf
Normal file
28
docker/files/development/etc/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
|
||||
# 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";
|
||||
}
|
||||
}
|
||||
46
docker/files/docker-entrypoint-initdb.d/init-databases.sh
Normal file
46
docker/files/docker-entrypoint-initdb.d/init-databases.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Initialize multiple databases on the same PostgreSQL server
|
||||
# This script runs automatically when the PostgreSQL container starts for the first time
|
||||
|
||||
set -e
|
||||
set -u
|
||||
|
||||
# Function to create a database and user if they don't exist
|
||||
create_database() {
|
||||
local database=$1
|
||||
local user=$2
|
||||
local password=$3
|
||||
|
||||
echo "Creating database '$database' with user '$user'..."
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
-- Create user if not exists
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$user') THEN
|
||||
CREATE USER $user WITH PASSWORD '$password';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
|
||||
-- Create database if not exists
|
||||
SELECT 'CREATE DATABASE $database OWNER $user'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$database')\gexec
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE $database TO $user;
|
||||
EOSQL
|
||||
|
||||
echo "Database '$database' created successfully."
|
||||
}
|
||||
|
||||
# Create databases for all services
|
||||
# The main 'calendar' database is created by default via POSTGRES_DB
|
||||
|
||||
# DAViCal database
|
||||
create_database "davical" "davical" "davical_pass"
|
||||
|
||||
# Keycloak database
|
||||
create_database "keycloak" "keycloak" "keycloak_pass"
|
||||
|
||||
echo "All databases initialized successfully!"
|
||||
15
docker/files/etc/nginx/conf.d/default.conf
Normal file
15
docker/files/etc/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
36
docker/postgresql/init-databases.sh
Executable file
36
docker/postgresql/init-databases.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Initialize shared calendars database for local development
|
||||
# All services (Django, DAViCal, Keycloak) use the same database in public schema
|
||||
# This script runs as POSTGRES_USER on first database initialization
|
||||
|
||||
set -e
|
||||
|
||||
echo "Initializing calendars database..."
|
||||
|
||||
# Ensure pgroot user exists with correct password and permissions
|
||||
# This runs as POSTGRES_USER (which may be different from pgroot on existing databases)
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
-- Ensure pgroot user exists with correct password
|
||||
-- POSTGRES_USER has superuser privileges, so it can create users
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'pgroot') THEN
|
||||
CREATE USER pgroot WITH PASSWORD 'pass' SUPERUSER CREATEDB CREATEROLE;
|
||||
ELSE
|
||||
ALTER USER pgroot WITH PASSWORD 'pass';
|
||||
-- Ensure superuser privileges
|
||||
ALTER USER pgroot WITH SUPERUSER CREATEDB CREATEROLE;
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
|
||||
-- Grant all privileges on calendars database
|
||||
GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO pgroot;
|
||||
|
||||
-- Grant all on public schema
|
||||
GRANT ALL ON SCHEMA public TO pgroot;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO pgroot;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO pgroot;
|
||||
EOSQL
|
||||
|
||||
echo "Calendars database ready!"
|
||||
210
docs/architecture.md
Normal file
210
docs/architecture.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Calendar Application Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The Calendar application is a modern, self-hosted calendar solution that combines a Django REST API backend with a separate CalDAV server (DAViCal) for standards-compliant calendar data storage and synchronization. This architecture provides both a modern web interface and full CalDAV protocol support for compatibility with standard calendar clients.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Frontend │
|
||||
│ (Next.js) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ HTTP/REST API + CalDAV Protocol
|
||||
│
|
||||
┌────────▼─────────────────────────────────────┐
|
||||
│ Django Backend │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ REST API Endpoints │ │
|
||||
│ │ - /api/v1.0/calendars │ │
|
||||
│ │ - /api/v1.0/users │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ CalDAV Proxy │ │
|
||||
│ │ - /api/v1.0/caldav/* │ │
|
||||
│ │ - /.well-known/caldav │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ Authentication (OIDC/Keycloak) │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
└────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/CalDAV Protocol
|
||||
│
|
||||
┌────────▼─────────────────────────────────────┐
|
||||
│ DAViCal Server │
|
||||
│ (CalDAV Protocol Implementation) │
|
||||
│ - Calendar storage │
|
||||
│ - Event storage (iCalendar format) │
|
||||
│ - CalDAV protocol handling │
|
||||
└────────┬─────────────────────────────────────┘
|
||||
│
|
||||
│ PostgreSQL
|
||||
│
|
||||
┌────────▼─────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ - Django models (users, calendars metadata) │
|
||||
│ - DAViCal schema (calendar data) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Django Backend
|
||||
|
||||
The Django backend serves as the **orchestration layer** and **business logic engine** for the application.
|
||||
|
||||
**Primary Responsibilities:**
|
||||
- **User Management & Authentication**: OIDC authentication via Keycloak, user profiles, sessions, authorization
|
||||
- **Calendar Metadata Management**: Calendar creation/deletion, sharing, visibility settings, display preferences
|
||||
- **REST API Layer**: Modern RESTful API for the web frontend (JSON, standard HTTP methods, versioned at `/api/v1.0/`)
|
||||
- **CalDAV Proxy**: Proxies CalDAV requests to DAViCal, handles authentication translation, URL routing, discovery endpoint
|
||||
- **Business Logic**: Calendar sharing logic, permission checks, data validation, integration coordination
|
||||
|
||||
**Data Storage:**
|
||||
- User accounts
|
||||
- Calendar metadata (name, color, visibility, owner)
|
||||
- Sharing relationships
|
||||
- Application configuration
|
||||
|
||||
**Important**: Django does NOT store actual calendar events. Events are stored in DAViCal.
|
||||
|
||||
### DAViCal CalDAV Server
|
||||
|
||||
DAViCal is a **standards-compliant CalDAV server** that handles all calendar data storage and protocol operations.
|
||||
|
||||
**Primary Responsibilities:**
|
||||
- **Calendar Data Storage**: Stores actual calendar events in iCalendar format, manages calendar collections
|
||||
- **CalDAV Protocol Implementation**: Full RFC 4791 implementation (PROPFIND, REPORT, MKCALENDAR, PUT, DELETE)
|
||||
- **iCalendar Format Management**: Parses and generates iCalendar files, validates syntax, handles VEVENT/VTODO components
|
||||
- **Database Schema**: Uses PostgreSQL with its own schema for calendar data
|
||||
|
||||
**Authentication Integration:**
|
||||
- Trusts authentication from Django backend via `X-Forwarded-User` header
|
||||
- Users with password `*` are externally authenticated
|
||||
- Custom authentication hook validates forwarded user headers
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
The frontend provides the user interface and interacts with both REST API and CalDAV protocol:
|
||||
- Modern React-based UI
|
||||
- Uses REST API for calendar metadata operations
|
||||
- Uses CalDAV protocol directly for event operations
|
||||
- Supports multiple languages and themes
|
||||
|
||||
## Why This Architecture?
|
||||
|
||||
### Design Decision: CalDAV Server Separation
|
||||
|
||||
The decision to use a separate CalDAV server (DAViCal) rather than implementing CalDAV directly in Django was made for several reasons:
|
||||
|
||||
1. **Standards Compliance**: DAViCal is a mature, well-tested CalDAV server that fully implements RFC 4791. Implementing CalDAV from scratch would be error-prone and time-consuming.
|
||||
|
||||
2. **Protocol Complexity**: CalDAV is built on WebDAV, involving complex XML handling, property management, and collection hierarchies. DAViCal handles all of this complexity.
|
||||
|
||||
3. **Maintenance**: Using a proven, maintained CalDAV server reduces maintenance burden and ensures compatibility with various CalDAV clients.
|
||||
|
||||
4. **Focus**: Django backend can focus on business logic, user management, and REST API, while DAViCal handles calendar protocol operations.
|
||||
|
||||
5. **Shared database**: DAViCal was specifically selected because it stores its data into Postgres, which use use in all LaSuite projects.
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Standards Compliance**
|
||||
- Full CalDAV protocol support enables compatibility with any CalDAV client (Apple Calendar, Thunderbird, etc.)
|
||||
- Users can sync calendars with external applications
|
||||
- Follows industry standards (RFC 4791)
|
||||
|
||||
2. **Separation of Concerns**
|
||||
- Django handles business logic and user management
|
||||
- DAViCal handles calendar protocol and data storage
|
||||
- Each component focuses on its core competency
|
||||
|
||||
3. **Flexibility**
|
||||
- Can expose both REST API (for web app) and CalDAV (for external clients)
|
||||
- Different clients can use different protocols
|
||||
- Future-proof architecture
|
||||
|
||||
4. **Maintainability**
|
||||
- Clear boundaries between components
|
||||
- Easier to test and debug
|
||||
- Can update components independently
|
||||
|
||||
5. **Performance**
|
||||
- DAViCal is optimized for CalDAV operations
|
||||
- Django can focus on application logic
|
||||
- Database can be optimized separately for each use case
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Creating a Calendar
|
||||
|
||||
TODO: should this only be via caldav too?
|
||||
|
||||
1. **Frontend** → POST `/api/v1.0/calendars` (REST API)
|
||||
2. **Django Backend**: Validates request, creates `Calendar` model, calls DAViCal to create calendar collection
|
||||
3. **DAViCal**: Receives MKCALENDAR request, creates calendar collection, returns calendar path
|
||||
4. **Django Backend**: Stores DAViCal path in `Calendar.davical_path`, returns calendar data to frontend
|
||||
|
||||
### Creating an Event
|
||||
|
||||
Events are created directly via CalDAV protocol:
|
||||
|
||||
1. **Frontend** → PUT `/api/v1.0/caldav/{user}/{calendar}/{event_uid}.ics` (CalDAV)
|
||||
2. **Django Backend**: `CalDAVProxyView` authenticates user, forwards request to DAViCal with authentication headers
|
||||
3. **DAViCal**: Receives PUT request with iCalendar data, stores event in calendar collection
|
||||
4. **Django Backend**: Forwards CalDAV response to frontend
|
||||
|
||||
### CalDAV Client Access
|
||||
|
||||
1. **CalDAV Client** → PROPFIND `/api/v1.0/caldav/` (CalDAV protocol)
|
||||
2. **Django Backend**: Authenticates user via Django session, forwards request to DAViCal with `X-Forwarded-User` header
|
||||
3. **DAViCal**: Processes CalDAV request, returns CalDAV response
|
||||
4. **Django Backend**: Forwards response to client
|
||||
|
||||
## Integration Points
|
||||
|
||||
### User Synchronization
|
||||
|
||||
When a user is created in Django, they must also exist in DAViCal. The `ensure_user_exists()` method automatically creates DAViCal users when needed, called before any DAViCal operation.
|
||||
|
||||
### Calendar Creation
|
||||
|
||||
When creating a calendar via REST API:
|
||||
1. Django creates `Calendar` model with metadata
|
||||
2. Django calls DAViCal via HTTP to create calendar collection
|
||||
3. Django stores DAViCal path in `Calendar.davical_path`
|
||||
|
||||
### Authentication Translation
|
||||
|
||||
Django sessions are translated to DAViCal authentication:
|
||||
- Django adds `X-Forwarded-User` header with user email
|
||||
- DAViCal's custom authentication hook validates this header
|
||||
- Users have password `*` indicating external authentication
|
||||
|
||||
### URL Routing
|
||||
|
||||
CalDAV clients expect specific URL patterns. The CalDAV proxy handles path translation:
|
||||
- Discovery endpoint at `.well-known/caldav` redirects to `/api/v1.0/caldav/`
|
||||
- Proxy forwards requests to DAViCal with correct paths
|
||||
|
||||
## Database Schema
|
||||
|
||||
Both Django and DAViCal use the same PostgreSQL database in a local Docker install, but maintain separate schemas:
|
||||
|
||||
**Django Schema (public schema):**
|
||||
- `calendars_user` - User accounts
|
||||
- `caldav_calendar` - Calendar metadata
|
||||
- `caldav_calendarshare` - Sharing relationships
|
||||
- Other Django app tables
|
||||
|
||||
**DAViCal Schema (public schema, same database):**
|
||||
- `usr` - DAViCal user records
|
||||
- `principal` - DAViCal principals
|
||||
- `collection` - Calendar collections
|
||||
- `dav_resource` - Calendar resources (events)
|
||||
- Other DAViCal-specific tables
|
||||
|
||||
This allows them to share the database locally while keeping data organized.
|
||||
70
env.d/development/backend.defaults
Normal file
70
env.d/development/backend.defaults
Normal file
@@ -0,0 +1,70 @@
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=calendars.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Logging
|
||||
# Set to DEBUG level for dev only
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
|
||||
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Calendar settings
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
MEDIA_BASE_URL=http://localhost:8923
|
||||
|
||||
# OIDC - Keycloak on dedicated port 8925
|
||||
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_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_RP_CLIENT_ID=calendars
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
LOGIN_REDIRECT_URL=http://localhost:8920
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:8920
|
||||
LOGOUT_REDIRECT_URL=http://localhost:8920
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="http://localhost:8923,http://localhost:8920"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Resource Server Backend
|
||||
OIDC_OP_URL=http://localhost:8925/realms/calendars
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://keycloak:8080/realms/calendars/protocol/openid-connect/token/introspect
|
||||
OIDC_RESOURCE_SERVER_ENABLED=False
|
||||
OIDC_RS_CLIENT_ID=calendars
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM="client_id"
|
||||
OIDC_RS_ALLOWED_AUDIENCES=""
|
||||
|
||||
# DAViCal CalDAV Server
|
||||
DAVICAL_URL=http://davical:80
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
FRONTEND_MORE_LINK=https://suiteterritoriale.anct.gouv.fr/
|
||||
FRONTEND_FEEDBACK_BUTTON_SHOW=True
|
||||
FRONTEND_FEEDBACK_BUTTON_IDLE=False
|
||||
FRONTEND_FEEDBACK_ITEMS={}
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED=False
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL=
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL=
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH=
|
||||
|
||||
# Indexer
|
||||
|
||||
# Store OIDC tokens in the session
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
0
env.d/development/crowdin.defaults
Normal file
0
env.d/development/crowdin.defaults
Normal file
13
env.d/development/davical.defaults
Normal file
13
env.d/development/davical.defaults
Normal file
@@ -0,0 +1,13 @@
|
||||
PGHOST=postgresql
|
||||
PGPORT=5432
|
||||
PGDATABASE=calendars
|
||||
PGUSER=pgroot
|
||||
PGPASSWORD=pass
|
||||
ROOT_PGUSER=pgroot
|
||||
ROOT_PGPASSWORD=pass
|
||||
DBA_PGUSER=pgroot
|
||||
DBA_PGPASSWORD=pass
|
||||
DAVICAL_ADMIN_PASS=admin
|
||||
HOST_NAME=localhost
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
TZ=UTC
|
||||
2
env.d/development/frontend.defaults
Normal file
2
env.d/development/frontend.defaults
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
13
env.d/development/keycloak.defaults
Normal file
13
env.d/development/keycloak.defaults
Normal file
@@ -0,0 +1,13 @@
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||
KC_DB=postgres
|
||||
KC_DB_URL_HOST=postgresql
|
||||
KC_DB_URL_DATABASE=calendars
|
||||
KC_DB_PASSWORD=pass
|
||||
KC_DB_USERNAME=pgroot
|
||||
KC_DB_SCHEMA=public
|
||||
KC_HOSTNAME_STRICT=false
|
||||
KC_HOSTNAME_STRICT_HTTPS=false
|
||||
KC_HTTP_ENABLED=true
|
||||
KC_HEALTH_ENABLED=true
|
||||
PROXY_ADDRESS_FORWARDING=true
|
||||
4
env.d/development/postgresql.defaults
Normal file
4
env.d/development/postgresql.defaults
Normal file
@@ -0,0 +1,4 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=calendars
|
||||
POSTGRES_USER=pgroot
|
||||
POSTGRES_PASSWORD=pass
|
||||
11
env.d/development/postgresql.e2e
Normal file
11
env.d/development/postgresql.e2e
Normal file
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=calendar_e2e
|
||||
POSTGRES_USER=pgroot
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=calendar_e2e
|
||||
DB_USER=pgroot
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
37
gitlint/gitlint_emoji.py
Normal file
37
gitlint/gitlint_emoji.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
|
||||
class GitmojiTitle(LineRule):
|
||||
"""
|
||||
This rule will enforce that each commit title is of the form "<gitmoji>(<scope>) <subject>"
|
||||
where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and
|
||||
subject should be all lowercase
|
||||
"""
|
||||
|
||||
id = "UC1"
|
||||
name = "title-should-have-gitmoji-and-scope"
|
||||
target = CommitMessageTitle
|
||||
|
||||
def validate(self, title, _commit):
|
||||
"""
|
||||
Download the list possible gitmojis from the project's github repository and check that
|
||||
title contains one of them.
|
||||
"""
|
||||
gitmojis = requests.get(
|
||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
).json()["gitmojis"]
|
||||
emojis = [item["emoji"] for item in gitmojis]
|
||||
pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis))
|
||||
if not re.search(pattern, title):
|
||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||
return [RuleViolation(self.id, violation_msg, title)]
|
||||
25
renovate.json
Normal file
25
renovate.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "allowed redis versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"description": "Disable requires-python updates - managed manually",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["python"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
33
src/backend/.dockerignore
Normal file
33
src/backend/.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
472
src/backend/.pylintrc
Normal file
472
src/backend/.pylintrc
Normal file
@@ -0,0 +1,472 @@
|
||||
[MASTER]
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Add files or directories to the blacklist. They should be base names, not
|
||||
# paths.
|
||||
ignore=migrations
|
||||
|
||||
# Add files or directories matching the regex patterns to the blacklist. The
|
||||
# regex matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use.
|
||||
jobs=0
|
||||
|
||||
# List of plugins (as comma separated values of python modules names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=pylint_django,pylint.extensions.no_self_use
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once).You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=bad-inline-option,
|
||||
deprecated-pragma,
|
||||
django-not-configured,
|
||||
file-ignored,
|
||||
locally-disabled,
|
||||
no-self-use,
|
||||
raw-checker-failed,
|
||||
suppressed-message,
|
||||
useless-suppression
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a note less than 10 (10 is the highest
|
||||
# note). You have access to the variables errors warning, statement which
|
||||
# respectively contain the number of errors / warnings messages and the total
|
||||
# number of statements analyzed. This is used by the global evaluation report
|
||||
# (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio).You can also give a reporter class, eg
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=optparse.Values,sys.exit
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it working
|
||||
# install python-enchant package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to indicated private dictionary in
|
||||
# --spelling-private-dict-file option instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
|
||||
Template,Contact
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis. It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid to define new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||
# not used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(#\s*)?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Ignore comments when computing similarities.
|
||||
ignore-comments=yes
|
||||
|
||||
# Ignore docstrings when computing similarities.
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Ignore imports when computing similarities.
|
||||
ignore-imports=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
# First implementations of CMS wizards have common fields we do not want to factorize for now
|
||||
min-similarity-lines=35
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Naming style matching correct class attribute names
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class names
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-style
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
cm,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style
|
||||
method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
|
||||
|
||||
# Naming style matching correct module names
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming style matching correct variable names
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma
|
||||
deprecated-modules=optparse,tkinter.tix
|
||||
|
||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
ext-import-graph=
|
||||
|
||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||
# given file (report RP0402 must not be disabled)
|
||||
import-graph=
|
||||
|
||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in a if statement
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=20
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=10
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=0
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "Exception"
|
||||
overgeneral-exceptions=builtins.Exception
|
||||
158
src/backend/Dockerfile
Normal file
158
src/backend/Dockerfile
Normal file
@@ -0,0 +1,158 @@
|
||||
# Django calendars
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.9-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
# We must do taht to avoid having an outdated pip version with security issues
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add git
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Disable Python downloads, because we want to use the system interpreter
|
||||
# across both images. If using a managed Python version, it needs to be
|
||||
# copied from the build image into the final image;
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
# install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base AS link-collector
|
||||
ARG CALENDARS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${CALENDARS_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
cairo \
|
||||
file \
|
||||
font-noto \
|
||||
font-noto-emoji \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
pandoc \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
# Give the "root" group the same permissions as the "root" user on /etc/passwd
|
||||
# to allow a user belonging to the root group to add new users; typically the
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
ENTRYPOINT [ "/app/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Install uv for development dependencies
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
|
||||
|
||||
# Install development dependencies (ensure venv is created and used)
|
||||
RUN uv sync --all-extras --locked
|
||||
|
||||
# Ensure venv is accessible and PATH is set
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG CALENDARS_STATIC_ROOT=/data/static
|
||||
|
||||
# Remove git, we don't need it in the production image
|
||||
RUN apk del git
|
||||
|
||||
# Gunicorn
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/calendars.py /usr/local/etc/gunicorn/calendars.py
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Copy statics
|
||||
COPY --from=link-collector ${CALENDARS_STATIC_ROOT} ${CALENDARS_STATIC_ROOT}
|
||||
|
||||
# The default command runs gunicorn WSGI server in calendars' main module
|
||||
CMD ["gunicorn", "-c", "/app/gunicorn.conf.py", "calendars.wsgi:application"]
|
||||
3
src/backend/MANIFEST.in
Normal file
3
src/backend/MANIFEST.in
Normal file
@@ -0,0 +1,3 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
recursive-include src/backend/calendars *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
|
||||
0
src/backend/__init__.py
Normal file
0
src/backend/__init__.py
Normal file
5
src/backend/calendars/__init__.py
Normal file
5
src/backend/calendars/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Calendars package. Import the celery app early to load shared task form dependencies."""
|
||||
|
||||
from .celery_app import app as celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
26
src/backend/calendars/celery_app.py
Normal file
26
src/backend/calendars/celery_app.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""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)
|
||||
69
src/backend/calendars/configuration/theme/default.json
Normal file
69
src/backend/calendars/configuration/theme/default.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"footer": {
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "Github",
|
||||
"href": "https://github.com/suitenumerique/calendars/"
|
||||
},
|
||||
{
|
||||
"label": "ANCT",
|
||||
"href": "https://anct.gouv.fr/"
|
||||
}
|
||||
],
|
||||
"license": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility: non compliant",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"license": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité: non conforme",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"license": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
919
src/backend/calendars/settings.py
Executable file
919
src/backend/calendars/settings.py
Executable file
@@ -0,0 +1,919 @@
|
||||
"""
|
||||
Django settings for calendar project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.1.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join("/", "data"))
|
||||
|
||||
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
"""
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
return pyproject_data["project"]["version"]
|
||||
except (FileNotFoundError, KeyError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
class Base(Configuration):
|
||||
"""
|
||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||
is recommended to configure third-party applications by creating a configuration mixins in
|
||||
./configurations and compose the Base configuration with those mixins.
|
||||
|
||||
It depends on an environment variable that SHOULD be defined:
|
||||
|
||||
* DJANGO_SECRET_KEY
|
||||
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
* DB_USER
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
LOAD_E2E_URLS = False
|
||||
USE_SWAGGER = False
|
||||
|
||||
API_VERSION = "v1.0"
|
||||
|
||||
# DAViCal CalDAV server URL
|
||||
DAVICAL_URL = values.Value(
|
||||
"http://davical:80", environ_name="DAVICAL_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = SecretFileValue(None)
|
||||
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "calendars.urls"
|
||||
WSGI_APPLICATION = "calendars.wsgi.application"
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
"default": dj_database_url.config()
|
||||
if os.environ.get("DATABASE_URL")
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql",
|
||||
environ_name="DB_ENGINE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"NAME": values.Value(
|
||||
"calendars", environ_name="DB_NAME", environ_prefix=None
|
||||
),
|
||||
"USER": values.Value("pgroot", environ_name="DB_USER", environ_prefix=None),
|
||||
"PASSWORD": SecretFileValue(
|
||||
"pass", environ_name="DB_PASSWORD", environ_prefix=None
|
||||
),
|
||||
"HOST": values.Value(
|
||||
"localhost", environ_name="DB_HOST", environ_prefix=None
|
||||
),
|
||||
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
||||
}
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_URL_PREVIEW = "/media/preview/"
|
||||
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": values.Value(
|
||||
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
environ_name="STORAGES_STATICFILES_BACKEND",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Maximum 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.
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
|
||||
2 * (2**30), # 2GB
|
||||
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
# Languages
|
||||
LANGUAGE_CODE = values.Value("en-us")
|
||||
LANGUAGE_COOKIE_NAME = "calendar_language" # cookie & language is set from frontend
|
||||
|
||||
DRF_NESTED_MULTIPART_PARSER = {
|
||||
# output of parser is converted to querydict
|
||||
# if is set to False, dict python is returned
|
||||
"querydict": False,
|
||||
}
|
||||
|
||||
# Careful! Languages should be ordered by priority, as this tuple is used to get
|
||||
# fallback/default languages throughout the app.
|
||||
LANGUAGES = values.SingleNestedTupleValue(
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
("nl-nl", _("Dutch")),
|
||||
)
|
||||
)
|
||||
|
||||
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Templates
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.csrf",
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.i18n",
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.request",
|
||||
"django.template.context_processors.tz",
|
||||
],
|
||||
"loaders": [
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"core.authentication.backends.OIDCAuthenticationBackend",
|
||||
]
|
||||
|
||||
# Django applications from the highest priority to the lowest
|
||||
INSTALLED_APPS = [
|
||||
"core",
|
||||
"drf_spectacular",
|
||||
"drf_standardized_errors",
|
||||
# Third party apps
|
||||
"corsheaders",
|
||||
"django_celery_beat",
|
||||
"django_filters",
|
||||
"rest_framework",
|
||||
"rest_framework_api_key",
|
||||
"parler",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.postgres",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
]
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_DEFAULT_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
"KEY_PREFIX": values.Value(
|
||||
"calendar",
|
||||
environ_name="CACHES_DEFAULT_KEY_PREFIX",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
"session": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_SESSION_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
|
||||
# see all the data in the database (ie a serializer with a ForeignKey field
|
||||
# will generate a form with a field with all possible values of the FK).
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"user_list_sustained": values.Value(
|
||||
default="180/hour",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"user_list_burst": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
MAX_PAGE_SIZE = values.PositiveIntegerValue(
|
||||
200, environ_name="MAX_PAGE_SIZE", environ_prefix=None
|
||||
)
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "calendar API",
|
||||
"DESCRIPTION": "This is the calendar API schema.",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK",
|
||||
),
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
# OTHER SETTINGS
|
||||
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
|
||||
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
TRASHBIN_CUTOFF_DAYS = values.Value(
|
||||
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
|
||||
)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
|
||||
CORS_ALLOWED_ORIGINS = values.ListValue([])
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
# Allow CalDAV methods (PROPFIND, REPORT, etc.)
|
||||
CORS_ALLOW_METHODS = [
|
||||
"DELETE",
|
||||
"GET",
|
||||
"OPTIONS",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PROPFIND",
|
||||
"REPORT",
|
||||
"MKCOL",
|
||||
"MKCALENDAR",
|
||||
]
|
||||
# Allow CalDAV headers (case-sensitive for CORS preflight)
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"dnt",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
"depth", # CalDAV header (lowercase as sent by browsers)
|
||||
"if-match",
|
||||
"if-none-match",
|
||||
"prefer",
|
||||
]
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
FRONTEND_MORE_LINK = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_MORE_LINK",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_SHOW", environ_prefix=None
|
||||
)
|
||||
# For instance, you might want to bind this button to an external library to trigger survey instead of the build in feedback modal.
|
||||
FRONTEND_FEEDBACK_BUTTON_IDLE = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None
|
||||
)
|
||||
FRONTEND_FEEDBACK_ITEMS = values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK_ITEMS", environ_prefix=None
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH = values.Value(
|
||||
None, environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH", environ_prefix=None
|
||||
)
|
||||
FRONTEND_HIDE_GAUFRE = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_HIDE_GAUFRE", environ_prefix=None
|
||||
)
|
||||
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "calendars/configuration/theme/default.json"),
|
||||
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
|
||||
60 * 60 * 24,
|
||||
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
||||
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||
THUMBNAIL_ALIASES = {}
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "session"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
)
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_RP_SIGN_ALGO = values.Value(
|
||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_ID = values.Value(
|
||||
"calendar", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_SECRET = SecretFileValue(
|
||||
None,
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_TOKEN_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
||||
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_SCOPES = values.Value(
|
||||
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL_FAILURE = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
|
||||
)
|
||||
LOGOUT_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
OIDC_USE_NONCE = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
|
||||
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC claims to store
|
||||
OIDC_STORE_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_STORE_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification (see previous setting).
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_USER_INFO = values.ListValue(
|
||||
default=values.ListValue( # retrocompatibility
|
||||
default=[],
|
||||
environ_name="USER_OIDC_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
environ_name="OIDC_USER_INFO",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||
default="first_name",
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC Resource Server
|
||||
|
||||
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_BACKEND_CLASS = values.Value(
|
||||
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
|
||||
environ_name="OIDC_RS_BACKEND_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External API Configuration
|
||||
# Configure available routes and actions for external_api endpoints
|
||||
EXTERNAL_API = values.DictValue(
|
||||
default={
|
||||
"users": {
|
||||
"enabled": True,
|
||||
"actions": ["get_me"],
|
||||
},
|
||||
},
|
||||
environ_name="EXTERNAL_API",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA",
|
||||
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Storage compute
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
"""Environment in which the application is launched."""
|
||||
return self.__class__.__name__.lower()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def RELEASE(self):
|
||||
"""
|
||||
Return the release information.
|
||||
|
||||
Delegate to the module function to enable easier testing.
|
||||
"""
|
||||
return get_release()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def PARLER_LANGUAGES(self):
|
||||
"""
|
||||
Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings.
|
||||
"""
|
||||
return {
|
||||
self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES),
|
||||
"default": {
|
||||
"fallbacks": [self.LANGUAGE_CODE],
|
||||
"hide_untranslated": False,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
This is the place where you can configure settings that require other
|
||||
settings to be loaded.
|
||||
"""
|
||||
super().post_setup()
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(),
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
sentry_sdk.set_tag("application", "backend")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
This environment should not be used to run the application. Just to build it with non-blocking
|
||||
settings.
|
||||
"""
|
||||
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
}
|
||||
SECRET_KEY = values.Value("DummyKey")
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": values.Value(
|
||||
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
environ_name="STORAGES_STATICFILES_BACKEND",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Development(Base):
|
||||
"""
|
||||
Development environment settings
|
||||
|
||||
We set DEBUG to True and configure the server to respond from all hosts.
|
||||
"""
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost:8920",
|
||||
]
|
||||
DEBUG = True
|
||||
LOAD_E2E_URLS = True
|
||||
|
||||
SESSION_COOKIE_NAME = "calendar_sessionid"
|
||||
|
||||
USE_SWAGGER = True
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += [
|
||||
"django_extensions",
|
||||
"drf_spectacular_sidecar",
|
||||
"debug_toolbar",
|
||||
"e2e",
|
||||
]
|
||||
|
||||
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
}
|
||||
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
USE_SWAGGER = True
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
OIDC_STORE_ACCESS_TOKEN = False
|
||||
OIDC_STORE_REFRESH_TOKEN = False
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar", "e2e"]
|
||||
|
||||
|
||||
class ContinuousIntegration(Test):
|
||||
"""
|
||||
Continuous Integration environment settings
|
||||
|
||||
nota bene: it should inherit from the Test environment.
|
||||
"""
|
||||
|
||||
|
||||
class Production(Base):
|
||||
"""
|
||||
Production environment settings
|
||||
|
||||
You must define the ALLOWED_HOSTS environment variable in Production
|
||||
configuration (and derived configurations):
|
||||
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
||||
"""
|
||||
|
||||
# Security
|
||||
# Add allowed host from environment variables.
|
||||
# The machine hostname is added by default,
|
||||
# it makes the application pingable by a load balancer on the same machine by example
|
||||
ALLOWED_HOSTS = [
|
||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||
gethostbyname(gethostname()),
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
|
||||
# object when your application is behind a reverse proxy.
|
||||
#
|
||||
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
|
||||
# - your Django app is behind a proxy.
|
||||
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
|
||||
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
|
||||
#
|
||||
# In other cases, you should comment the following line to avoid security issues.
|
||||
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_REDIRECT_EXEMPT = [
|
||||
"^__lbheartbeat__",
|
||||
"^__heartbeat__",
|
||||
]
|
||||
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# Privacy
|
||||
SECURE_REFERRER_POLICY = "same-origin"
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
"""
|
||||
Feature environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class Staging(Production):
|
||||
"""
|
||||
Staging environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class PreProduction(Production):
|
||||
"""
|
||||
Pre-production environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
58
src/backend/calendars/urls.py
Normal file
58
src/backend/calendars/urls.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""URL configuration for the calendars project"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from drf_spectacular.views import (
|
||||
SpectacularJSONAPIView,
|
||||
SpectacularRedocView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
|
||||
from core.api.viewsets_caldav import CalDAVDiscoveryView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# CalDAV discovery - must be at root level per RFC 6764
|
||||
path(".well-known/caldav", CalDAVDiscoveryView.as_view(), name="caldav-discovery"),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
|
||||
urlpatterns = (
|
||||
urlpatterns
|
||||
+ staticfiles_urlpatterns()
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
+ debug_toolbar_urls()
|
||||
)
|
||||
|
||||
if settings.LOAD_E2E_URLS:
|
||||
urlpatterns += [path("", include("e2e.urls"))]
|
||||
|
||||
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=settings.API_VERSION,
|
||||
urlconf="core.urls",
|
||||
),
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
re_path(
|
||||
f"api/{settings.API_VERSION}/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
]
|
||||
17
src/backend/calendars/wsgi.py
Normal file
17
src/backend/calendars/wsgi.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
WSGI config for the calendars project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from configurations.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
|
||||
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
src/backend/core/__init__.py
Normal file
0
src/backend/core/__init__.py
Normal file
93
src/backend/core/admin.py
Normal file
93
src/backend/core/admin.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"admin_email",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
"full_name",
|
||||
"admin_email",
|
||||
"email",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
34
src/backend/core/api/__init__.py
Normal file
34
src/backend/core/api/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Calendars core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from drf_standardized_errors.handler import exception_handler as drf_exception_handler
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import as_serializer_error
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Handle Django ValidationError as an accepted exception.
|
||||
|
||||
For the parameters, see ``exception_handler``
|
||||
This code comes from twidi's gist:
|
||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||
"""
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
exc = drf_exceptions.ValidationError(as_serializer_error(exc))
|
||||
|
||||
return drf_exception_handler(exc, context)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@api_view(["GET"])
|
||||
def get_frontend_configuration(request):
|
||||
"""Returns the frontend configuration dict as configured in settings."""
|
||||
frontend_configuration = {
|
||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
25
src/backend/core/api/fields.py
Normal file
25
src/backend/core/api/fields.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""A JSONField for DRF to handle serialization/deserialization."""
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class JSONField(serializers.Field):
|
||||
"""
|
||||
A custom field for handling JSON data.
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
"""
|
||||
Convert the JSON string to a Python dictionary for serialization.
|
||||
"""
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Convert the Python dictionary to a JSON string for deserialization.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data)
|
||||
79
src/backend/core/api/permissions.py
Normal file
79
src/backend/core/api/permissions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Permission handlers for the calendars core app."""
|
||||
|
||||
from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||
"children": {"GET": "children_list", "POST": "children_create"},
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Write permissions are only allowed to the user itself."""
|
||||
return obj == request.user
|
||||
|
||||
|
||||
class IsOwnedOrPublic(IsAuthenticated):
|
||||
"""
|
||||
Allows access to authenticated users only for objects that are owned or not related
|
||||
to any user via the "owner" field.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Unsafe permissions are only allowed for the owner of the object."""
|
||||
if obj.owner == request.user:
|
||||
return True
|
||||
|
||||
if request.method in permissions.SAFE_METHODS and obj.owner is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
return obj.user == request.user
|
||||
except exceptions.ObjectDoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated or view.action not in [
|
||||
"create",
|
||||
]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
action = view.action
|
||||
try:
|
||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||
except KeyError:
|
||||
pass
|
||||
return abilities.get(action, False)
|
||||
155
src/backend/core/api/serializers.py
Normal file
155
src/backend/core/api/serializers.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Client serializers for the calendars core app."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class UserLiteSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_abilities(self, access) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return access.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Check access rights specific to writing (create/update)
|
||||
"""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
# Update
|
||||
if self.instance:
|
||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
||||
|
||||
if role and role not in can_set_role_to:
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to set this role for this template."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
|
||||
# Create
|
||||
else:
|
||||
try:
|
||||
resource_id = self.context["resource_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a resource ID in kwargs to create a new access."
|
||||
) from exc
|
||||
|
||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this resource."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a resource can assign other users as owners."
|
||||
)
|
||||
|
||||
# pylint: disable=no-member
|
||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
||||
return attrs
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""Serialize users for me endpoint."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = UserSerializer.Meta.fields
|
||||
read_only_fields = UserSerializer.Meta.read_only_fields
|
||||
|
||||
|
||||
# CalDAV serializers
|
||||
class CalendarSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Calendar model."""
|
||||
|
||||
class Meta:
|
||||
model = models.Calendar
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"color",
|
||||
"description",
|
||||
"is_default",
|
||||
"is_visible",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "is_default", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class CalendarCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating a Calendar."""
|
||||
|
||||
class Meta:
|
||||
model = models.Calendar
|
||||
fields = ["name", "color", "description"]
|
||||
|
||||
|
||||
class CalendarShareSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for CalendarShare model."""
|
||||
|
||||
shared_with_email = serializers.EmailField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.CalendarShare
|
||||
fields = ["id", "shared_with_email", "permission", "is_visible", "created_at"]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
397
src/backend/core/api/viewsets.py
Normal file
397
src/backend/core/api/viewsets.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models as db
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.text import slugify
|
||||
|
||||
import rest_framework as drf
|
||||
from corsheaders.middleware import (
|
||||
ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
)
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, mixins, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from rest_framework_api_key.permissions import HasAPIKey
|
||||
|
||||
from core import enums, models
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, item):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, item + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of items for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
|
||||
This mixin is useful to avoid to define a serializer class for each action in the
|
||||
`get_serializer_class` method.
|
||||
|
||||
Example:
|
||||
```
|
||||
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
|
||||
serializer_class = MySerializer
|
||||
list_serializer_class = MyListSerializer
|
||||
retrieve_serializer_class = MyRetrieveSerializer
|
||||
```
|
||||
"""
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Return the serializer class to use depending on the action.
|
||||
"""
|
||||
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
|
||||
return serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class Pagination(drf.pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
max_page_size = settings.MAX_PAGE_SIZE
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
SerializerPerActionMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.ListModelMixin,
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
pagination_class = None
|
||||
throttle_classes = []
|
||||
|
||||
def get_throttles(self):
|
||||
self.throttle_classes = []
|
||||
if self.action == "list":
|
||||
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
|
||||
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Limit listed users by querying the email field.
|
||||
If query contains "@", search exactly. Otherwise return empty.
|
||||
"""
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action != "list":
|
||||
return queryset
|
||||
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
return queryset.none()
|
||||
|
||||
# For emails, match exactly
|
||||
if "@" in query:
|
||||
return queryset.filter(email__iexact=query).order_by("email")[
|
||||
: settings.API_USERS_LIST_LIMIT
|
||||
]
|
||||
|
||||
# For non-email queries, return empty (no fuzzy search)
|
||||
return queryset.none()
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
url_path="me",
|
||||
)
|
||||
def get_me(self, request):
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf.response.Response(
|
||||
self.get_serializer(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
class ConfigView(drf.views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
"FRONTEND_MORE_LINK",
|
||||
"FRONTEND_FEEDBACK_BUTTON_SHOW",
|
||||
"FRONTEND_FEEDBACK_BUTTON_IDLE",
|
||||
"FRONTEND_FEEDBACK_ITEMS",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH",
|
||||
"FRONTEND_HIDE_GAUFRE",
|
||||
"MEDIA_BASE_URL",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
dict_settings["theme_customization"] = self._load_theme_customization()
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
def _load_theme_customization(self):
|
||||
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
|
||||
return {}
|
||||
|
||||
cache_key = (
|
||||
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
|
||||
)
|
||||
theme_customization = cache.get(cache_key, {})
|
||||
if theme_customization:
|
||||
return theme_customization
|
||||
|
||||
try:
|
||||
with open(
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
|
||||
) as f:
|
||||
theme_customization = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Configuration file not found: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
"Configuration file is not a valid JSON: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
else:
|
||||
cache.set(
|
||||
cache_key,
|
||||
theme_customization,
|
||||
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
|
||||
# CalDAV ViewSets
|
||||
class CalendarViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
ViewSet for managing user calendars.
|
||||
|
||||
list: Get all calendars accessible by the user (owned + shared)
|
||||
retrieve: Get a specific calendar
|
||||
create: Create a new calendar
|
||||
update: Update calendar properties
|
||||
destroy: Delete a calendar
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = serializers.CalendarSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return calendars owned by or shared with the current user."""
|
||||
user = self.request.user
|
||||
owned = models.Calendar.objects.filter(owner=user)
|
||||
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
|
||||
"calendar_id", flat=True
|
||||
)
|
||||
shared = models.Calendar.objects.filter(id__in=shared_ids)
|
||||
return owned.union(shared).order_by("-is_default", "name")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return serializers.CalendarCreateSerializer
|
||||
return serializers.CalendarSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new calendar via CalendarService."""
|
||||
service = CalendarService()
|
||||
calendar = service.create_calendar(
|
||||
user=self.request.user,
|
||||
name=serializer.validated_data["name"],
|
||||
color=serializer.validated_data.get("color", "#3174ad"),
|
||||
)
|
||||
# Update the serializer instance with the created calendar
|
||||
serializer.instance = calendar
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete calendar. Prevent deletion of default calendar."""
|
||||
if instance.is_default:
|
||||
raise ValueError("Cannot delete the default calendar.")
|
||||
if instance.owner != self.request.user:
|
||||
raise PermissionError("You can only delete your own calendars.")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=["patch"])
|
||||
def toggle_visibility(self, request, pk=None):
|
||||
"""Toggle calendar visibility."""
|
||||
calendar = self.get_object()
|
||||
|
||||
# Check if it's a shared calendar
|
||||
share = models.CalendarShare.objects.filter(
|
||||
calendar=calendar, shared_with=request.user
|
||||
).first()
|
||||
|
||||
if share:
|
||||
share.is_visible = not share.is_visible
|
||||
share.save()
|
||||
is_visible = share.is_visible
|
||||
elif calendar.owner == request.user:
|
||||
calendar.is_visible = not calendar.is_visible
|
||||
calendar.save()
|
||||
is_visible = calendar.is_visible
|
||||
else:
|
||||
return drf_response.Response(
|
||||
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
return drf_response.Response({"is_visible": is_visible})
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
serializer_class=serializers.CalendarShareSerializer,
|
||||
)
|
||||
def share(self, request, pk=None):
|
||||
"""Share calendar with another user."""
|
||||
calendar = self.get_object()
|
||||
|
||||
if calendar.owner != request.user:
|
||||
return drf_response.Response(
|
||||
{"error": "Only the owner can share this calendar"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.CalendarShareSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
email = serializer.validated_data["shared_with_email"]
|
||||
try:
|
||||
user_to_share = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist:
|
||||
return drf_response.Response(
|
||||
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
share, created = models.CalendarShare.objects.get_or_create(
|
||||
calendar=calendar,
|
||||
shared_with=user_to_share,
|
||||
defaults={
|
||||
"permission": serializer.validated_data.get("permission", "read")
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
share.permission = serializer.validated_data.get(
|
||||
"permission", share.permission
|
||||
)
|
||||
share.save()
|
||||
|
||||
return drf_response.Response(
|
||||
serializers.CalendarShareSerializer(share).data,
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
220
src/backend/core/api/viewsets_caldav.py
Normal file
220
src/backend/core/api/viewsets_caldav.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""CalDAV proxy views for forwarding requests to DAViCal."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
import requests
|
||||
|
||||
from core.services.caldav_service import DAViCalClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class CalDAVProxyView(View):
|
||||
"""
|
||||
Proxy view that forwards all CalDAV requests to DAViCal.
|
||||
Handles authentication and adds appropriate headers.
|
||||
|
||||
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
|
||||
(PROPFIND, REPORT, etc.) that don't work with Django's CSRF middleware.
|
||||
Authentication is handled via session cookies instead.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Forward all HTTP methods to DAViCal."""
|
||||
# Handle CORS preflight requests
|
||||
if request.method == "OPTIONS":
|
||||
response = HttpResponse(status=200)
|
||||
response["Access-Control-Allow-Methods"] = (
|
||||
"GET, OPTIONS, PROPFIND, REPORT, MKCOL, MKCALENDAR, PUT, DELETE"
|
||||
)
|
||||
response["Access-Control-Allow-Headers"] = (
|
||||
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
|
||||
)
|
||||
return response
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponse(status=401)
|
||||
|
||||
# Ensure user exists in DAViCal before making requests
|
||||
try:
|
||||
davical_client = DAViCalClient()
|
||||
davical_client.ensure_user_exists(request.user)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to ensure user exists in DAViCal: %s", str(e))
|
||||
# Continue anyway - user might already exist
|
||||
|
||||
# Build the DAViCal URL
|
||||
davical_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
|
||||
path = kwargs.get("path", "")
|
||||
|
||||
# Use user email as the principal (DAViCal uses email as username)
|
||||
user_principal = request.user.email
|
||||
|
||||
# Handle root CalDAV requests - return principal collection
|
||||
if not path or path == user_principal:
|
||||
# For PROPFIND on root, return the user's principal collection
|
||||
if request.method == "PROPFIND":
|
||||
# Get the request path to match the href in response
|
||||
request_path = request.path
|
||||
if not request_path.endswith("/"):
|
||||
request_path += "/"
|
||||
|
||||
# Return multistatus with href matching request URL and calendar-home-set
|
||||
multistatus = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:href>{request_path}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:displayname>{user_principal}</D:displayname>
|
||||
<C:calendar-home-set>
|
||||
<D:href>/api/v1.0/caldav/{user_principal}/</D:href>
|
||||
</C:calendar-home-set>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"""
|
||||
response = HttpResponse(
|
||||
content=multistatus,
|
||||
status=207,
|
||||
content_type="application/xml; charset=utf-8",
|
||||
)
|
||||
return response
|
||||
|
||||
# For other methods, redirect to principal URL
|
||||
target_url = f"{davical_url}/caldav.php/{user_principal}/"
|
||||
else:
|
||||
# Build target URL with path
|
||||
# Remove leading slash if present
|
||||
clean_path = path.lstrip("/")
|
||||
if clean_path.startswith(user_principal):
|
||||
# Path already includes principal
|
||||
target_url = f"{davical_url}/caldav.php/{clean_path}"
|
||||
else:
|
||||
# Path is relative to principal
|
||||
target_url = f"{davical_url}/caldav.php/{user_principal}/{clean_path}"
|
||||
|
||||
# Prepare headers for DAViCal
|
||||
# Set headers to tell DAViCal it's behind a proxy so it generates correct URLs
|
||||
script_name = "/api/v1.0/caldav"
|
||||
headers = {
|
||||
"Content-Type": request.content_type or "application/xml",
|
||||
"X-Forwarded-User": user_principal,
|
||||
"X-Forwarded-For": request.META.get("REMOTE_ADDR", ""),
|
||||
"X-Forwarded-Prefix": script_name,
|
||||
"X-Forwarded-Host": request.get_host(),
|
||||
"X-Forwarded-Proto": request.scheme,
|
||||
"X-Script-Name": script_name, # Tell DAViCal the base path
|
||||
}
|
||||
|
||||
# DAViCal authentication: users with password '*' use external auth
|
||||
# We send the username via X-Forwarded-User header
|
||||
# For HTTP Basic Auth, we use the email as username with empty password
|
||||
# This works with DAViCal's external authentication when trust_x_forwarded is true
|
||||
auth = (user_principal, "")
|
||||
|
||||
# Copy relevant headers from the original request
|
||||
if "HTTP_DEPTH" in request.META:
|
||||
headers["Depth"] = request.META["HTTP_DEPTH"]
|
||||
if "HTTP_IF_MATCH" in request.META:
|
||||
headers["If-Match"] = request.META["HTTP_IF_MATCH"]
|
||||
if "HTTP_IF_NONE_MATCH" in request.META:
|
||||
headers["If-None-Match"] = request.META["HTTP_IF_NONE_MATCH"]
|
||||
if "HTTP_PREFER" in request.META:
|
||||
headers["Prefer"] = request.META["HTTP_PREFER"]
|
||||
|
||||
# Get request body
|
||||
body = request.body if request.body else None
|
||||
|
||||
try:
|
||||
# Forward the request to DAViCal
|
||||
# Use HTTP Basic Auth with username (email) and empty password
|
||||
# DAViCal will authenticate based on X-Forwarded-User header when trust_x_forwarded is true
|
||||
logger.debug(
|
||||
"Forwarding %s request to DAViCal: %s (user: %s)",
|
||||
request.method,
|
||||
target_url,
|
||||
user_principal,
|
||||
)
|
||||
response = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
data=body,
|
||||
auth=auth,
|
||||
timeout=30,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
# Log authentication failures for debugging
|
||||
if response.status_code == 401:
|
||||
logger.warning(
|
||||
"DAViCal returned 401 for user %s at %s. Headers sent: %s",
|
||||
user_principal,
|
||||
target_url,
|
||||
headers,
|
||||
)
|
||||
|
||||
# Build Django response
|
||||
django_response = HttpResponse(
|
||||
content=response.content,
|
||||
status=response.status_code,
|
||||
content_type=response.headers.get("Content-Type", "application/xml"),
|
||||
)
|
||||
|
||||
# Copy relevant headers from DAViCal response
|
||||
for header in ["ETag", "DAV", "Allow", "Location"]:
|
||||
if header in response.headers:
|
||||
django_response[header] = response.headers[header]
|
||||
|
||||
return django_response
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("DAViCal proxy error: %s", str(e))
|
||||
return HttpResponse(
|
||||
content=f"CalDAV server error: {str(e)}",
|
||||
status=502,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class CalDAVDiscoveryView(View):
|
||||
"""
|
||||
Handle CalDAV discovery requests (well-known URLs).
|
||||
|
||||
Per RFC 6764, this endpoint should redirect to the CalDAV server base URL,
|
||||
not to a user-specific principal. Clients will then perform PROPFIND on
|
||||
the base URL to discover their principal.
|
||||
|
||||
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
|
||||
and this endpoint should be accessible without authentication.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Handle discovery requests."""
|
||||
# Handle CORS preflight requests
|
||||
if request.method == "OPTIONS":
|
||||
response = HttpResponse(status=200)
|
||||
response["Access-Control-Allow-Methods"] = "GET, OPTIONS, PROPFIND"
|
||||
response["Access-Control-Allow-Headers"] = (
|
||||
"Content-Type, depth, authorization"
|
||||
)
|
||||
return response
|
||||
|
||||
# Note: Authentication is not required for discovery per RFC 6764
|
||||
# Clients need to discover the CalDAV URL before authenticating
|
||||
|
||||
# Return redirect to CalDAV server base URL
|
||||
caldav_base_url = f"/api/v1.0/caldav/"
|
||||
response = HttpResponse(status=301)
|
||||
response["Location"] = caldav_base_url
|
||||
return response
|
||||
19
src/backend/core/apps.py
Normal file
19
src/backend/core/apps.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Calendars Core application"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the calendars core app."""
|
||||
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("calendars core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
52
src/backend/core/authentication/__init__.py
Normal file
52
src/backend/core/authentication/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Custom authentication classes for the calendars core app"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class ServerToServerAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for server-to-server requests.
|
||||
Validates the presence and correctness of the Authorization header.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the server-to-server request by validating the Authorization header.
|
||||
|
||||
This method checks if the Authorization header is present in the request, ensures it
|
||||
contains a valid token with the correct format, and verifies the token against the
|
||||
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
|
||||
or contains an invalid token, an AuthenticationFailed exception is raised.
|
||||
|
||||
Returns:
|
||||
None: If authentication is successful
|
||||
(no user is authenticated for server-to-server requests).
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the Authorization header is missing, malformed,
|
||||
or contains an invalid token.
|
||||
"""
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
raise AuthenticationFailed("Authorization header is missing.")
|
||||
|
||||
# Validate token format and existence
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||
|
||||
# Authentication is successful, but no user is authenticated
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Create item server to server'"
|
||||
54
src/backend/core/authentication/backends.py
Normal file
54
src/backend/core/authentication/backends.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Authentication Backends for the Calendars core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.authentication.exceptions import UserCannotAccessApp
|
||||
from core.models import DuplicateEmailError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of extra claims.
|
||||
"""
|
||||
|
||||
# We need to add the claims that we want to store so that they are
|
||||
# available in the post_get_or_create_user method.
|
||||
claims_to_store = {
|
||||
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
|
||||
}
|
||||
return {
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
"claims": claims_to_store,
|
||||
}
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
try:
|
||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
5
src/backend/core/authentication/exceptions.py
Normal file
5
src/backend/core/authentication/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Exceptions for the authentication module."""
|
||||
|
||||
|
||||
class UserCannotAccessApp(Exception):
|
||||
"""Exception raised when a user cannot access the app."""
|
||||
25
src/backend/core/authentication/views.py
Normal file
25
src/backend/core/authentication/views.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Calendars core authentication views."""
|
||||
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from lasuite.oidc_login.views import (
|
||||
OIDCAuthenticationCallbackView as LaSuiteOIDCAuthenticationCallbackView,
|
||||
)
|
||||
|
||||
from core.authentication.exceptions import UserCannotAccessApp
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView):
|
||||
"""
|
||||
Custom view for handling the authentication callback from the OpenID Connect (OIDC) provider.
|
||||
Handles the callback after authentication from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary authentication actions.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
return super().get(request)
|
||||
except UserCannotAccessApp:
|
||||
return HttpResponseRedirect(
|
||||
self.failure_url + "?auth_error=user_cannot_access_app"
|
||||
)
|
||||
12
src/backend/core/enums.py
Normal file
12
src/backend/core/enums.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
||||
# We can use it for the choice of languages which should not be limited to the few languages
|
||||
# active in the app.
|
||||
# pylint: disable=no-member
|
||||
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
|
||||
41
src/backend/core/external_api/permissions.py
Normal file
41
src/backend/core/external_api/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Resource Server Permissions for the Calendars app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ResourceServerClientPermission(permissions.BasePermission):
|
||||
"""
|
||||
Permission class for resource server views.
|
||||
This provides a way to open the resource server views to a limited set of
|
||||
Service Providers.
|
||||
Note: we might add a more complex permission system in the future, based on
|
||||
the Service Provider ID and the requested scopes.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if the user is authenticated and the token introspection
|
||||
provides an authorized Service Provider.
|
||||
"""
|
||||
if not isinstance(
|
||||
request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# Not a resource server request
|
||||
return False
|
||||
|
||||
# Check if the user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if (
|
||||
hasattr(view, "resource_server_actions")
|
||||
and view.action not in view.resource_server_actions
|
||||
):
|
||||
return False
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
return (
|
||||
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
|
||||
)
|
||||
36
src/backend/core/external_api/viewsets.py
Normal file
36
src/backend/core/external_api/viewsets.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Resource Server Viewsets for the Calendars app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
from core.api.permissions import AccessPermission, IsSelf
|
||||
from core.api.viewsets import UserViewSet
|
||||
from core.external_api.permissions import ResourceServerClientPermission
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class ResourceServerRestrictionMixin:
|
||||
"""
|
||||
Mixin for Resource Server Viewsets to provide shortcut to get
|
||||
configured actions for a given resource.
|
||||
"""
|
||||
|
||||
def _get_resource_server_actions(self, resource_name):
|
||||
"""Get resource_server_actions from settings."""
|
||||
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
|
||||
return list(external_api_config.get("actions", []))
|
||||
|
||||
|
||||
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
|
||||
"""Resource Server Viewset for the Calendars app."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [ResourceServerClientPermission & IsSelf]
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Get resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("users")
|
||||
28
src/backend/core/factories.py
Normal file
28
src/backend/core/factories.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
import factory.fuzzy
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random users for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
skip_postgeneration_save = True
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
0
src/backend/core/management/commands/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
47
src/backend/core/management/commands/createsuperuser.py
Normal file
47
src/backend/core/management/commands/createsuperuser.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Management user to create a superuser."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to create a superuser from an email and password."""
|
||||
|
||||
help = "Create a superuser with an email and a password"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Define required arguments "email" and "password"."""
|
||||
parser.add_argument(
|
||||
"--email",
|
||||
help=("Email for the user."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
help="Password for the user.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Given an email and a password, create a superuser or upgrade the existing
|
||||
user to superuser status.
|
||||
"""
|
||||
email = options.get("email")
|
||||
try:
|
||||
user = UserModel.objects.get(admin_email=email)
|
||||
except UserModel.DoesNotExist:
|
||||
user = UserModel(admin_email=email)
|
||||
message = "Superuser created successfully."
|
||||
else:
|
||||
if user.is_superuser and user.is_staff:
|
||||
message = "Superuser already exists."
|
||||
else:
|
||||
message = "User already existed and was upgraded to superuser."
|
||||
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.set_password(options["password"])
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
90
src/backend/core/migrations/0001_initial.py
Normal file
90
src/backend/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-08 23:49
|
||||
|
||||
import core.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
|
||||
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
|
||||
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(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)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'calendars_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Calendar',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('color', models.CharField(default='#3174ad', max_length=7)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
('is_visible', models.BooleanField(default=True)),
|
||||
('davical_path', models.CharField(max_length=512, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_default', 'name'],
|
||||
},
|
||||
),
|
||||
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')},
|
||||
),
|
||||
]
|
||||
0
src/backend/core/migrations/__init__.py
Normal file
0
src/backend/core/migrations/__init__.py
Normal file
402
src/backend/core/models.py
Normal file
402
src/backend/core/models.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Declare and configure the models for the calendars core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.indexes import GistIndex
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail, validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class LinkRoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a link can offer on a item."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a user can have in a resource."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||
|
||||
|
||||
class LinkReachChoices(models.TextChoices):
|
||||
"""Defines types of access for links"""
|
||||
|
||||
RESTRICTED = (
|
||||
"restricted",
|
||||
_("Restricted"),
|
||||
) # Only users with a specific access can read/edit the item
|
||||
AUTHENTICATED = (
|
||||
"authenticated",
|
||||
_("Authenticated"),
|
||||
) # Any authenticated user can access the item
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the item
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
before saving as Django doesn't do it by default.
|
||||
|
||||
Includes fields common to all models: a UUID primary key and creation/update timestamps.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(
|
||||
verbose_name=_("id"),
|
||||
help_text=_("primary key for the record as UUID"),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created on"),
|
||||
help_text=_("date and time at which a record was created"),
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated on"),
|
||||
help_text=_("date and time at which a record was last updated"),
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call `full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
if not email:
|
||||
return None
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif (
|
||||
self.filter(email=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-:]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
|
||||
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to login to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=settings.LANGUAGES,
|
||||
default=None,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
timezone = TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
use_pytz=False,
|
||||
default=settings.TIME_ZONE,
|
||||
help_text=_("The timezone in which the user wants to see times."),
|
||||
)
|
||||
is_device = models.BooleanField(
|
||||
_("device"),
|
||||
default=False,
|
||||
help_text=_("Whether the user is a device or a real user."),
|
||||
)
|
||||
is_staff = models.BooleanField(
|
||||
_("staff status"),
|
||||
default=False,
|
||||
help_text=_("Whether the user can log into this admin site."),
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
_("active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Whether this user should be treated as active. "
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
|
||||
claims = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text=_("Claims from the OIDC token."),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
db_table = "calendars_user"
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give its user access to the items to which s.he was invited.
|
||||
"""
|
||||
is_adding = self._state.adding
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
@cached_property
|
||||
def teams(self):
|
||||
"""
|
||||
Get list of teams in which the user is, as a list of strings.
|
||||
Must be cached if retrieved remotely.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
team = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def _get_abilities(self, resource, user):
|
||||
"""
|
||||
Compute and return abilities for a given user taking into account
|
||||
the current state of the object.
|
||||
"""
|
||||
roles = []
|
||||
if user.is_authenticated:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = resource.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
is_owner_or_admin = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
if self.role == RoleChoices.OWNER:
|
||||
can_delete = (
|
||||
RoleChoices.OWNER in roles
|
||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
set_role_to = (
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
if can_delete
|
||||
else []
|
||||
)
|
||||
else:
|
||||
can_delete = is_owner_or_admin
|
||||
set_role_to = []
|
||||
if RoleChoices.OWNER in roles:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
try:
|
||||
set_role_to.remove(self.role)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(roles),
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
|
||||
class Calendar(models.Model):
|
||||
"""
|
||||
Represents a calendar owned by a user.
|
||||
This model tracks calendars stored in DAViCal and links them to Django users.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="calendars",
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=7, default="#3174ad") # Hex color
|
||||
description = models.TextField(blank=True, default="")
|
||||
is_default = models.BooleanField(default=False)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
|
||||
# DAViCal reference - the calendar path in DAViCal
|
||||
davical_path = models.CharField(max_length=512, unique=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for Calendar model."""
|
||||
|
||||
ordering = ["-is_default", "name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["owner"],
|
||||
condition=models.Q(is_default=True),
|
||||
name="unique_default_calendar_per_user",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.owner.email})"
|
||||
|
||||
|
||||
class CalendarShare(models.Model):
|
||||
"""
|
||||
Represents a calendar shared with another user.
|
||||
"""
|
||||
|
||||
PERMISSION_READ = "read"
|
||||
PERMISSION_WRITE = "write"
|
||||
PERMISSION_CHOICES = [
|
||||
(PERMISSION_READ, "Read only"),
|
||||
(PERMISSION_WRITE, "Read and write"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
calendar = models.ForeignKey(
|
||||
Calendar,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="shares",
|
||||
)
|
||||
shared_with = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="shared_calendars",
|
||||
)
|
||||
permission = models.CharField(
|
||||
max_length=10,
|
||||
choices=PERMISSION_CHOICES,
|
||||
default=PERMISSION_READ,
|
||||
)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for CalendarShare model."""
|
||||
|
||||
unique_together = ["calendar", "shared_with"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.calendar.name} shared with {self.shared_with.email}"
|
||||
0
src/backend/core/services/__init__.py
Normal file
0
src/backend/core/services/__init__.py
Normal file
477
src/backend/core/services/caldav_service.py
Normal file
477
src/backend/core/services/caldav_service.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""Services for CalDAV integration with DAViCal."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from caldav import DAVClient
|
||||
from caldav.lib.error import NotFoundError
|
||||
from core.models import Calendar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DAViCalClient:
|
||||
"""
|
||||
Client for communicating with DAViCal CalDAV server using the caldav library.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
|
||||
self.timeout = 30
|
||||
|
||||
def _get_client(self, user) -> DAVClient:
|
||||
"""
|
||||
Get a CalDAV client for the given user.
|
||||
|
||||
DAViCal uses X-Forwarded-User header for authentication. The caldav
|
||||
library requires username/password for Basic Auth, but DAViCal users have
|
||||
password '*' (external auth). We pass the X-Forwarded-User header directly
|
||||
to the DAVClient constructor.
|
||||
"""
|
||||
# DAViCal base URL - the caldav library will discover the principal
|
||||
caldav_url = f"{self.base_url}/caldav.php/"
|
||||
|
||||
return DAVClient(
|
||||
url=caldav_url,
|
||||
username=user.email,
|
||||
password="", # Empty password - DAViCal uses X-Forwarded-User header
|
||||
timeout=self.timeout,
|
||||
headers={
|
||||
"X-Forwarded-User": user.email,
|
||||
},
|
||||
)
|
||||
|
||||
def ensure_user_exists(self, user) -> None:
|
||||
"""
|
||||
Ensure the user exists in DAViCal's database.
|
||||
Creates the user if they don't exist.
|
||||
"""
|
||||
# Connect to shared calendars database (public schema)
|
||||
default_db = settings.DATABASES["default"]
|
||||
db_name = default_db.get("NAME", "calendars")
|
||||
|
||||
# Get password - handle SecretValue objects
|
||||
password = default_db.get("PASSWORD", "pass")
|
||||
if hasattr(password, "value"):
|
||||
password = password.value
|
||||
|
||||
# Connect to calendars database
|
||||
conn = psycopg.connect(
|
||||
host=default_db.get("HOST", "localhost"),
|
||||
port=default_db.get("PORT", 5432),
|
||||
dbname=db_name,
|
||||
user=default_db.get("USER", "pgroot"),
|
||||
password=password,
|
||||
)
|
||||
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
# Check if user exists (in public schema)
|
||||
cursor.execute(
|
||||
"SELECT user_no FROM usr WHERE lower(username) = lower(%s)",
|
||||
[user.email],
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# User already exists
|
||||
return
|
||||
|
||||
# Create user in DAViCal (public schema)
|
||||
# Use email as username, password '*' means external auth
|
||||
# Get user's full name or use email prefix
|
||||
fullname = (
|
||||
getattr(user, "full_name", None)
|
||||
or getattr(user, "get_full_name", lambda: None)()
|
||||
or user.email.split("@")[0]
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO usr (username, email, fullname, active, password)
|
||||
VALUES (%s, %s, %s, true, '*')
|
||||
ON CONFLICT (lower(username)) DO NOTHING
|
||||
RETURNING user_no
|
||||
""",
|
||||
[user.email, user.email, fullname],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
user_no = result[0]
|
||||
logger.info(
|
||||
"Created DAViCal user: %s (user_no: %s)", user.email, user_no
|
||||
)
|
||||
|
||||
# Also create a principal record for the user (public schema)
|
||||
# DAViCal needs both usr and principal records
|
||||
# Principal type 1 is for users
|
||||
type_id = 1
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO principal (type_id, user_no, displayname)
|
||||
SELECT %s, %s, %s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM principal WHERE user_no = %s)
|
||||
RETURNING principal_id
|
||||
""",
|
||||
[type_id, user_no, fullname, user_no],
|
||||
)
|
||||
principal_result = cursor.fetchone()
|
||||
if principal_result:
|
||||
logger.info(
|
||||
"Created DAViCal principal: %s (principal_id: %s)",
|
||||
user.email,
|
||||
principal_result[0],
|
||||
)
|
||||
else:
|
||||
logger.warning("User %s already exists in DAViCal", user.email)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
|
||||
"""
|
||||
Create a new calendar in DAViCal for the given user.
|
||||
Returns the DAViCal path for the calendar.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
principal = client.principal()
|
||||
|
||||
try:
|
||||
# Create calendar using caldav library
|
||||
calendar = principal.make_calendar(name=calendar_name)
|
||||
|
||||
# DAViCal calendar path format: /caldav.php/{username}/{calendar_id}/
|
||||
# The caldav library returns a URL object, convert to string and extract path
|
||||
calendar_url = str(calendar.url)
|
||||
# Extract path from full URL
|
||||
if calendar_url.startswith(self.base_url):
|
||||
path = calendar_url[len(self.base_url) :]
|
||||
else:
|
||||
# Fallback: construct path manually based on DAViCal's structure
|
||||
# DAViCal creates calendars with a specific path structure
|
||||
path = f"/caldav.php/{user.email}/{calendar_id}/"
|
||||
|
||||
logger.info("Created calendar in DAViCal: %s at %s", calendar_name, path)
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.error("Failed to create calendar in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def get_events(
|
||||
self,
|
||||
user,
|
||||
calendar_path: str,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get events from a calendar within a time range.
|
||||
Returns list of event dictionaries with parsed data.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
# Default to current month if no range specified
|
||||
if start is None:
|
||||
start = timezone.now().replace(day=1, hour=0, minute=0, second=0)
|
||||
if end is None:
|
||||
end = start + timedelta(days=31)
|
||||
|
||||
client = self._get_client(user)
|
||||
|
||||
# Get calendar by URL
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for events in the date range
|
||||
# Convert datetime to date for search if needed
|
||||
start_date = start.date() if isinstance(start, datetime) else start
|
||||
end_date = end.date() if isinstance(end, datetime) else end
|
||||
|
||||
events = calendar.search(
|
||||
event=True,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
expand=True, # Expand recurring events
|
||||
)
|
||||
|
||||
# Parse events into dictionaries
|
||||
parsed_events = []
|
||||
for event in events:
|
||||
event_data = self._parse_event(event)
|
||||
if event_data:
|
||||
parsed_events.append(event_data)
|
||||
|
||||
return parsed_events
|
||||
except NotFoundError:
|
||||
logger.warning("Calendar not found at path: %s", calendar_path)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Failed to get events from DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
|
||||
"""
|
||||
Create a new event in DAViCal.
|
||||
Returns the event UID.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
# Extract event data
|
||||
dtstart = event_data.get("start", timezone.now())
|
||||
dtend = event_data.get("end", dtstart + timedelta(hours=1))
|
||||
summary = event_data.get("title", "New Event")
|
||||
description = event_data.get("description", "")
|
||||
location = event_data.get("location", "")
|
||||
|
||||
# Generate UID if not provided
|
||||
event_uid = event_data.get("uid", str(uuid4()))
|
||||
|
||||
try:
|
||||
# Create event using caldav library
|
||||
event = calendar.save_event(
|
||||
dtstart=dtstart,
|
||||
dtend=dtend,
|
||||
uid=event_uid,
|
||||
summary=summary,
|
||||
description=description,
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Extract UID from created event
|
||||
# The caldav library returns an Event object
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid = str(event.icalendar_component.get("uid", event_uid))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid = event.vobject_instance.vevent.uid.value
|
||||
|
||||
logger.info("Created event in DAViCal: %s", event_uid)
|
||||
return event_uid
|
||||
except Exception as e:
|
||||
logger.error("Failed to create event in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def update_event(
|
||||
self, user, calendar_path: str, event_uid: str, event_data: dict
|
||||
) -> None:
|
||||
"""Update an existing event in DAViCal."""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
|
||||
# Update event properties
|
||||
dtstart = event_data.get("start")
|
||||
dtend = event_data.get("end")
|
||||
summary = event_data.get("title")
|
||||
description = event_data.get("description")
|
||||
location = event_data.get("location")
|
||||
|
||||
# Update using icalendar component
|
||||
component = target_event.icalendar_component
|
||||
|
||||
if dtstart:
|
||||
component["dtstart"] = dtstart
|
||||
if dtend:
|
||||
component["dtend"] = dtend
|
||||
if summary:
|
||||
component["summary"] = summary
|
||||
if description is not None:
|
||||
component["description"] = description
|
||||
if location is not None:
|
||||
component["location"] = location
|
||||
|
||||
# Save the updated event
|
||||
target_event.save()
|
||||
|
||||
logger.info("Updated event in DAViCal: %s", event_uid)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update event in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def delete_event(self, user, calendar_path: str, event_uid: str) -> None:
|
||||
"""Delete an event from DAViCal."""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
|
||||
# Delete the event
|
||||
target_event.delete()
|
||||
|
||||
logger.info("Deleted event from DAViCal: %s", event_uid)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete event from DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def _parse_event(self, event) -> Optional[dict]:
|
||||
"""
|
||||
Parse a caldav Event object and return event data as dictionary.
|
||||
"""
|
||||
try:
|
||||
component = event.icalendar_component
|
||||
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"start": component.get("dtstart").dt
|
||||
if component.get("dtstart")
|
||||
else None,
|
||||
"end": component.get("dtend").dt if component.get("dtend") else None,
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
}
|
||||
|
||||
# Convert datetime to string format for consistency
|
||||
if event_data["start"]:
|
||||
if isinstance(event_data["start"], datetime):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["start"], date):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%d")
|
||||
|
||||
if event_data["end"]:
|
||||
if isinstance(event_data["end"], datetime):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["end"], date):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
||||
|
||||
return event_data if event_data.get("uid") else None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse event: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
class CalendarService:
|
||||
"""
|
||||
High-level service for managing calendars and events.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.davical = DAViCalClient()
|
||||
|
||||
def create_default_calendar(self, user) -> Calendar:
|
||||
"""
|
||||
Create a default calendar for a user.
|
||||
"""
|
||||
calendar_id = str(uuid4())
|
||||
calendar_name = "Mon calendrier"
|
||||
|
||||
# Create calendar in DAViCal
|
||||
davical_path = self.davical.create_calendar(user, calendar_name, calendar_id)
|
||||
|
||||
# Create local Calendar record
|
||||
calendar = Calendar.objects.create(
|
||||
owner=user,
|
||||
name=calendar_name,
|
||||
davical_path=davical_path,
|
||||
is_default=True,
|
||||
color="#3174ad",
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
def create_calendar(self, user, name: str, color: str = "#3174ad") -> Calendar:
|
||||
"""
|
||||
Create a new calendar for a user.
|
||||
"""
|
||||
calendar_id = str(uuid4())
|
||||
|
||||
# Create calendar in DAViCal
|
||||
davical_path = self.davical.create_calendar(user, name, calendar_id)
|
||||
|
||||
# Create local Calendar record
|
||||
calendar = Calendar.objects.create(
|
||||
owner=user,
|
||||
name=name,
|
||||
davical_path=davical_path,
|
||||
is_default=False,
|
||||
color=color,
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
def get_user_calendars(self, user):
|
||||
"""
|
||||
Get all calendars accessible by a user (owned + shared).
|
||||
"""
|
||||
owned = Calendar.objects.filter(owner=user)
|
||||
shared = Calendar.objects.filter(shares__shared_with=user)
|
||||
return owned.union(shared)
|
||||
|
||||
def get_events(self, user, calendar: Calendar, start=None, end=None) -> list:
|
||||
"""
|
||||
Get events from a calendar.
|
||||
Returns parsed event data.
|
||||
"""
|
||||
return self.davical.get_events(user, calendar.davical_path, start, end)
|
||||
|
||||
def create_event(self, user, calendar: Calendar, event_data: dict) -> str:
|
||||
"""Create a new event."""
|
||||
return self.davical.create_event(user, calendar.davical_path, event_data)
|
||||
|
||||
def update_event(
|
||||
self, user, calendar: Calendar, event_uid: str, event_data: dict
|
||||
) -> None:
|
||||
"""Update an existing event."""
|
||||
self.davical.update_event(user, calendar.davical_path, event_uid, event_data)
|
||||
|
||||
def delete_event(self, user, calendar: Calendar, event_uid: str) -> None:
|
||||
"""Delete an event."""
|
||||
self.davical.delete_event(user, calendar.davical_path, event_uid)
|
||||
55
src/backend/core/signals.py
Normal file
55
src/backend/core/signals.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Declare and configure the signals for the calendars core application
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def provision_default_calendar(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Auto-provision a default calendar when a new user is created.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Check if user already has a default calendar
|
||||
if instance.calendars.filter(is_default=True).exists():
|
||||
return
|
||||
|
||||
# Skip calendar creation if DAViCal is not configured
|
||||
if not getattr(settings, "DAVICAL_URL", None):
|
||||
return
|
||||
|
||||
try:
|
||||
service = CalendarService()
|
||||
service.create_default_calendar(instance)
|
||||
logger.info("Created default calendar for user %s", instance.email)
|
||||
except Exception as e:
|
||||
# In tests, DAViCal tables don't exist, so fail silently
|
||||
# Check if it's a database error that suggests we're in tests
|
||||
error_str = str(e).lower()
|
||||
if "does not exist" in error_str or "relation" in error_str:
|
||||
# Likely in test environment, fail silently
|
||||
logger.debug(
|
||||
"Skipped calendar creation for user %s (likely test environment): %s",
|
||||
instance.email,
|
||||
str(e),
|
||||
)
|
||||
else:
|
||||
# Real error, log it
|
||||
logger.error(
|
||||
"Failed to create default calendar for user %s: %s",
|
||||
instance.email,
|
||||
str(e),
|
||||
)
|
||||
0
src/backend/core/templatetags/__init__.py
Normal file
0
src/backend/core/templatetags/__init__.py
Normal file
58
src/backend/core/templatetags/extra_tags.py
Normal file
58
src/backend/core/templatetags/extra_tags.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Custom template tags for the calendars core application."""
|
||||
|
||||
import base64
|
||||
|
||||
from django import template
|
||||
from django.contrib.staticfiles import finders
|
||||
|
||||
from PIL import ImageFile as PillowImageFile
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def image_to_base64(file_or_path, close=False):
|
||||
"""
|
||||
Return the src string of the base64 encoding of an image represented by its path
|
||||
or file opened or not.
|
||||
|
||||
Inspired by Django's "get_image_dimensions"
|
||||
"""
|
||||
pil_parser = PillowImageFile.Parser()
|
||||
if hasattr(file_or_path, "read"):
|
||||
file = file_or_path
|
||||
if file.closed and hasattr(file, "open"):
|
||||
file_or_path.open()
|
||||
file_pos = file.tell()
|
||||
file.seek(0)
|
||||
else:
|
||||
try:
|
||||
# pylint: disable=consider-using-with
|
||||
file = open(file_or_path, "rb")
|
||||
except OSError:
|
||||
return ""
|
||||
close = True
|
||||
|
||||
try:
|
||||
image_data = file.read()
|
||||
if not image_data:
|
||||
return ""
|
||||
pil_parser.feed(image_data)
|
||||
if pil_parser.image:
|
||||
mime_type = pil_parser.image.get_format_mimetype()
|
||||
encoded_string = base64.b64encode(image_data)
|
||||
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
|
||||
return ""
|
||||
finally:
|
||||
if close:
|
||||
file.close()
|
||||
else:
|
||||
file.seek(file_pos)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def base64_static(path):
|
||||
"""Return a static file into a base64."""
|
||||
full_path = finders.find(path)
|
||||
if full_path:
|
||||
return image_to_base64(full_path, True)
|
||||
return ""
|
||||
0
src/backend/core/tests/__init__.py
Normal file
0
src/backend/core/tests/__init__.py
Normal file
0
src/backend/core/tests/authentication/__init__.py
Normal file
0
src/backend/core/tests/authentication/__init__.py
Normal file
579
src/backend/core/tests/authentication/test_backends.py
Normal file
579
src/backend/core/tests/authentication/test_backends.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import random
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.fernet import Fernet
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.exceptions import UserCannotAccessApp
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email=None)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
user_info = {"sub": "123"}
|
||||
if random.choice([True, False]):
|
||||
user_info["email"] = None
|
||||
return user_info
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub and email didn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(full_name="John Doe", short_name="John")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_sub(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change
|
||||
and the user was identified by its "sub".
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_email(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the name fields on the user when they change
|
||||
and the user was identified by its "email" as fallback.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response(settings):
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info response was not valid JWT",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the sub but is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(1),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not match the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "random",
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_authentication_session_tokens(
|
||||
django_assert_num_queries, monkeypatch, rf, settings
|
||||
):
|
||||
"""
|
||||
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
|
||||
"""
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
|
||||
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
|
||||
settings.OIDC_STORE_ACCESS_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
|
||||
request.session = {}
|
||||
|
||||
def verify_token_mocked(*args, **kwargs):
|
||||
return {"sub": "123", "email": "test@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||
json={
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(settings.OIDC_OP_USER_ENDPOINT),
|
||||
json={"sub": "123", "email": "test@example.com"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
with django_assert_num_queries(27):
|
||||
user = klass.authenticate(
|
||||
request,
|
||||
code="test-code",
|
||||
nonce="test-nonce",
|
||||
code_verifier="test-code-verifier",
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
|
||||
@override_settings(OIDC_STORE_CLAIMS=["iss"])
|
||||
def test_authentication_store_claims_new_user(monkeypatch):
|
||||
"""
|
||||
Test that the claims are stored on the user when a new user is created.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"iss": "https://example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.has_usable_password() is False
|
||||
assert user.claims == {"iss": "https://example.com"}
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_STORE_CLAIMS=["iss"])
|
||||
def test_authentication_store_claims_existing_user(monkeypatch):
|
||||
"""
|
||||
Test that the claims are stored on the user when an existing user is authenticated.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
email="calendars@example.com", sub="123", claims={"iss": "https://obsolete.com"}
|
||||
)
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"iss": "https://example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.claims == {"iss": "https://example.com"}
|
||||
assert models.User.objects.count() == 1
|
||||
66
src/backend/core/tests/authentication/test_views.py
Normal file
66
src/backend/core/tests/authentication/test_views.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core import factories
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.views import (
|
||||
OIDCAuthenticationCallbackView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
LOGIN_REDIRECT_URL_FAILURE="/auth/failure",
|
||||
LOGIN_REDIRECT_URL="/auth/success",
|
||||
)
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationBackend,
|
||||
"get_token",
|
||||
return_value={"id_token": "mocked_id_token", "access_token": "mocked_access_token"},
|
||||
)
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationBackend, "verify_token", return_value={"not": "needed"}
|
||||
)
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationBackend,
|
||||
"get_userinfo",
|
||||
return_value={"sub": "mocked_sub", "email": "allowed@example.com"},
|
||||
)
|
||||
def test_view_login_callback_authorized_by_default(
|
||||
mocked_get_userinfo, mocked_verify_token, mocked_get_token
|
||||
):
|
||||
"""By default, all users are authorized to login."""
|
||||
|
||||
user = factories.UserFactory(email="allowed@example.com")
|
||||
|
||||
request = RequestFactory().get(
|
||||
"/callback/", data={"state": "mocked_state", "code": "mocked_code"}
|
||||
)
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
request.session["oidc_states"] = {mocked_state: {"nonce": "mocked_nonce"}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCAuthenticationCallbackView.as_view()
|
||||
|
||||
response = callback_view(request)
|
||||
mocked_get_token.assert_called_once()
|
||||
mocked_verify_token.assert_called_once()
|
||||
mocked_get_userinfo.assert_called_once()
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/auth/success"
|
||||
148
src/backend/core/tests/conftest.py
Normal file
148
src/backend/core/tests/conftest.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Fixtures for tests in the calendars core application"""
|
||||
|
||||
import base64
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from core import factories
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
VIA = [USER, TEAM]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def truncate_davical_tables(django_db_setup, django_db_blocker):
|
||||
"""Fixture to truncate DAViCal tables at the start of each test.
|
||||
|
||||
DAViCal tables are created by the DAViCal container migrations, not Django.
|
||||
We just truncate them to ensure clean state for each test.
|
||||
"""
|
||||
with django_db_blocker.unblock():
|
||||
with connection.cursor() as cursor:
|
||||
# Truncate DAViCal tables if they exist (created by DAViCal container)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principal') THEN
|
||||
TRUNCATE TABLE principal CASCADE;
|
||||
END IF;
|
||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usr') THEN
|
||||
TRUNCATE TABLE usr CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache after each test."""
|
||||
yield
|
||||
cache.clear()
|
||||
# Clear functools.cache for functions decorated with @functools.cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_teams():
|
||||
"""Mock for the "teams" property on the User model."""
|
||||
with mock.patch(
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
def resource_server_backend_setup(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RESOURCE_SERVER_ENABLED = True
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "groups"]
|
||||
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend_conf(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
Including a mocked introspection endpoint.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
yield rsps
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_specific_sub():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
user = factories.UserFactory(sub="very-specific-sub")
|
||||
|
||||
yield user
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
return build_authorization_bearer("some_token")
|
||||
152
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
152
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Tests for the Resource Server API for users.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_api_users_me_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
response = APIClient().get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_me_connected_not_resource_server():
|
||||
"""
|
||||
Connected users should not be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_me_connected_resource_server(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(user_specific_sub.id)
|
||||
assert data["email"] == user_specific_sub.email
|
||||
|
||||
|
||||
def test_api_users_me_connected_resource_server_with_invalid_token(
|
||||
user_token, resource_server_backend
|
||||
):
|
||||
"""
|
||||
Connected users should not be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled with an invalid token.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# Non allowed actions on resource server.
|
||||
|
||||
|
||||
def test_api_users_list_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_retrieve_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_put_patch_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/",
|
||||
{"email": "new_email@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_delete_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
0
src/backend/core/tests/swagger/__init__.py
Normal file
0
src/backend/core/tests/swagger/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user