🎉(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:
Sylvain Zimmer
2026-01-09 00:51:25 +01:00
commit a36348ead1
298 changed files with 41036 additions and 0 deletions

33
src/backend/.dockerignore Normal file
View 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
View File

@@ -0,0 +1,472 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code
extension-pkg-whitelist=
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=migrations
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=0
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=pylint_django,pylint.extensions.no_self_use
# Pickle collected data for later comparisons.
persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=bad-inline-option,
deprecated-pragma,
django-not-configured,
file-ignored,
locally-disabled,
no-self-use,
raw-checker-failed,
suppressed-message,
useless-suppression
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[REPORTS]
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details
#msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio).You can also give a reporter class, eg
# mypackage.mymodule.MyReporterClass.
output-format=text
# Tells whether to display a full report or only the messages
reports=no
# Activate the evaluation score.
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=optparse.Values,sys.exit
[LOGGING]
# Logging modules to check that the string format arguments are in logging
# function parameter format
logging-modules=logging
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it working
# install python-enchant package.
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
spelling-store-unknown-words=no
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
Template,Contact
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid to define new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expectedly
# not used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(#\s*)?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[SIMILARITIES]
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=yes
# Minimum lines number of a similarity.
# First implementations of CMS wizards have common fields we do not want to factorize for now
min-similarity-lines=35
[BASIC]
# Naming style matching correct argument names
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style
#argument-rgx=
# Naming style matching correct attribute names
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Naming style matching correct class attribute names
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style
#class-attribute-rgx=
# Naming style matching correct class names
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-style
#class-rgx=
# Naming style matching correct constant names
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style
#function-rgx=
# Good variable names which should always be accepted, separated by a comma
good-names=i,
j,
k,
cm,
ex,
Run,
_
# Include a hint for the correct naming format with invalid-name
include-naming-hint=no
# Naming style matching correct inline iteration names
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style
#inlinevar-rgx=
# Naming style matching correct method names
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style
method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
# Naming style matching correct module names
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
property-classes=abc.abstractproperty
# Naming style matching correct variable names
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style
#variable-rgx=
[IMPORTS]
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=optparse,tkinter.tix
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in a if statement
max-bool-expr=5
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of locals for function / method body
max-locals=20
# Maximum number of parents for a class (see R0901).
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of statements in function / method body
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=0
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=builtins.Exception

158
src/backend/Dockerfile Normal file
View 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
View 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
View File

View 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"]

View 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)

View 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
View 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.
"""

View 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",
),
]

View 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()

View File

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

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

View File

@@ -0,0 +1,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)

View File

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

View File

@@ -0,0 +1,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)

View 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"]

View 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,
)

View 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
View 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

View 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'"

View 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

View File

@@ -0,0 +1,5 @@
"""Exceptions for the authentication module."""
class UserCannotAccessApp(Exception):
"""Exception raised when a user cannot access the app."""

View 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
View File

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

View File

@@ -0,0 +1,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
)

View 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")

View 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")

View File

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

View File

@@ -0,0 +1,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')},
),
]

View File

402
src/backend/core/models.py Normal file
View 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}"

View File

View 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)

View 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),
)

View 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 ""

View File

View 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

View 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"

View 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")

View 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

View File

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

View File

@@ -0,0 +1,161 @@
"""
Test config API endpoints in the calendars core app.
"""
import json
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
FRONTEND_THEME="test-theme",
FRONTEND_MORE_LINK="https://test.com",
FRONTEND_FEEDBACK_BUTTON_SHOW=True,
FRONTEND_FEEDBACK_BUTTON_IDLE=False,
FRONTEND_FEEDBACK_ITEMS={"form": {"url": "https://test.com"}},
FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED=True,
FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL="https://test.com",
FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL="test",
FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH="https://test.com",
FRONTEND_HIDE_GAUFRE=True,
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
THEME_CUSTOMIZATION_FILE_PATH="",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"FRONTEND_MORE_LINK": "https://test.com",
"FRONTEND_FEEDBACK_BUTTON_SHOW": True,
"FRONTEND_FEEDBACK_BUTTON_IDLE": False,
"FRONTEND_FEEDBACK_ITEMS": {"form": {"url": "https://test.com"}},
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED": True,
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL": "https://test.com",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL": "test",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH": "https://test.com",
"FRONTEND_HIDE_GAUFRE": True,
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "French"],
["de-de", "German"],
["nl-nl", "Dutch"],
],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
"theme_customization": {},
}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_invalid_theme_customization_file(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs):
"""Anonymous users should be allowed to get the configuration."""
fs.create_file(
"/configuration/theme/invalid.json",
contents="invalid json",
)
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_theme_customization(is_authenticated, fs):
"""Anonymous users should be allowed to get the configuration."""
fs.create_file(
"/configuration/theme/default.json",
contents=json.dumps(
{
"colors": {
"primary": "#000000",
"secondary": "#000000",
},
}
),
)
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {
"colors": {
"primary": "#000000",
"secondary": "#000000",
},
}
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_original_theme_customization(is_authenticated, settings):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f:
theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization

View File

@@ -0,0 +1,638 @@
"""
Test users API endpoints in the calendars core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_users_list_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory()
client = APIClient()
response = client.get("/api/v1.0/users/")
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
def test_api_users_list_authenticated():
"""
Authenticated users should not be able to list users without a query.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == 200
assert response.json() == []
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
# Use email query to get exact match
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(lennon.id)]
# Inactive user should not be returned even with exact match
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
# Non-email queries (without @) return empty
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert response.json() == []
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
# Non-email queries (without @) return empty
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert response.json() == []
# Email queries require exact match
settings.API_USERS_LIST_LIMIT = 100
response = client.get(
"/api/v1.0/users/?q=alice.0@example.com",
)
assert response.status_code == 200
assert len(response.json()) == 1
def test_api_users_list_throttling_authenticated(settings):
"""
Authenticated users should be throttled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
for _i in range(3):
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 429
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users and filter by email.
Only exact email matches are returned (case-insensitive).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
# Exact match works
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
# Case-insensitive match works
response = client.get(
"/api/v1.0/users/?q=David.Bowman@Work.COM",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
# Typos don't match (exact match only)
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""Email queries return exact matches only (case-insensitive)."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr")
user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
# Exact match returns only that user
response = client.get(
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user1.id)]
# Different email returns different user
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user4.id)]
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
user = factories.UserFactory()
response = client.get(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
def test_api_users_retrieve_authenticated_self():
"""
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == 405
assert response.json() == {
"errors": [
{
"attr": None,
"code": "method_not_allowed",
"detail": 'Method "GET" not allowed.',
},
],
"type": "client_error",
}
def test_api_users_retrieve_authenticated_other():
"""
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == 405
assert response.json() == {
"errors": [
{
"attr": None,
"code": "method_not_allowed",
"detail": 'Method "GET" not allowed.',
},
],
"type": "client_error",
}
def test_api_users_create_anonymous():
"""Anonymous users should not be able to create users via the API."""
response = APIClient().post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
)
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
assert models.User.objects.exists() is False
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
format="json",
)
assert response.status_code == 405
assert response.json() == {
"errors": [
{
"attr": None,
"code": "method_not_allowed",
"detail": 'Method "POST" not allowed.',
},
],
"type": "client_error",
}
assert models.User.objects.exclude(id=user.id).exists() is False
def test_api_users_update_anonymous():
"""Anonymous users should not be able to update users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = APIClient().put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_update_authenticated_self():
"""
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_anonymous():
"""Anonymous users should not be able to patch users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = APIClient().patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"errors": [
{
"attr": None,
"code": "not_authenticated",
"detail": "Authentication credentials were not provided.",
},
],
"type": "client_error",
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_authenticated_self():
"""
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == 401
assert models.User.objects.count() == 2
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
)
assert response.status_code == 405
assert models.User.objects.count() == 3
def test_api_users_delete_anonymous():
"""Anonymous users should not be allowed to delete a user."""
user = factories.UserFactory()
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == 401
assert models.User.objects.count() == 1
def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
response = client.delete(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == 405
assert models.User.objects.count() == 2
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == 405
assert models.User.objects.count() == 1

View File

@@ -0,0 +1,71 @@
"""Tests for CalDAV service integration with DAViCal."""
from unittest.mock import Mock, patch
from django.conf import settings
import pytest
from core import factories
from core.services.caldav_service import CalendarService, DAViCalClient
@pytest.mark.django_db
class TestDAViCalClient:
"""Tests for DAViCalClient authentication and communication."""
def test_get_client_sends_x_forwarded_user_header(self):
"""Test that DAVClient is configured with X-Forwarded-User header."""
user = factories.UserFactory(email="test@example.com")
client = DAViCalClient()
dav_client = client._get_client(user)
# Verify the client is configured correctly
assert dav_client.username == user.email
# Password should be empty (None or empty string) for external auth
assert not dav_client.password or dav_client.password == ""
# Verify the X-Forwarded-User header is set
# The caldav library stores headers as a CaseInsensitiveDict
assert hasattr(dav_client, "headers")
assert "X-Forwarded-User" in dav_client.headers
assert dav_client.headers["X-Forwarded-User"] == user.email
@pytest.mark.skipif(
not getattr(settings, "DAVICAL_URL", None),
reason="DAViCal URL not configured",
)
def test_create_calendar_authenticates_with_davical(self):
"""Test that calendar creation authenticates successfully with DAViCal."""
user = factories.UserFactory(email="test@example.com")
client = DAViCalClient()
# Ensure user exists in DAViCal
client.ensure_user_exists(user)
# Try to create a calendar - this should authenticate successfully
calendar_path = client.create_calendar(
user, calendar_name="Test Calendar", calendar_id="test-calendar-id"
)
# Verify calendar path was returned
assert calendar_path is not None
assert calendar_path.startswith("/caldav.php/")
assert user.email in calendar_path
def test_calendar_service_creates_calendar(self):
"""Test that CalendarService can create a calendar through DAViCal."""
user = factories.UserFactory(email="test@example.com")
service = CalendarService()
# Create a calendar
calendar = service.create_calendar(user, name="My Calendar", color="#ff0000")
# Verify calendar was created
assert calendar is not None
assert calendar.owner == user
assert calendar.name == "My Calendar"
assert calendar.color == "#ff0000"
assert calendar.davical_path is not None
assert calendar.davical_path.startswith("/caldav.php/")

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
"""Utils for testing URLs."""
import importlib
from django.urls import clear_url_caches
def reload_urls():
"""
Reload the URLs. Since the url are loaded based on a
settings value, we need to reload the urls to make the
URL settings based condition effective.
"""
import core.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
import calendars.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
importlib.reload(core.urls)
importlib.reload(calendars.urls)
clear_url_caches()

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

@@ -0,0 +1,61 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
include(
[
*router.urls,
*oidc_urls,
# CalDAV proxy - root path (must come before catch-all to match /caldav exactly)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
# CalDAV proxy - catch all paths with content
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
if settings.OIDC_RESOURCE_SERVER_ENABLED:
# - Resource server routes
external_api_router = DefaultRouter()
users_access_config = settings.EXTERNAL_API.get("users", {})
if users_access_config.get("enabled", False):
external_api_router.register(
"users",
external_api_viewsets.ResourceServerUserViewSet,
basename="resource_server_users",
)
external_api_urls = [*external_api_router.urls]
if external_api_urls:
urlpatterns.append(
path(
f"external_api/{settings.API_VERSION}/",
include(external_api_urls),
)
)

View File

View File

@@ -0,0 +1,12 @@
"""Serializers for E2E tests."""
from rest_framework import serializers
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class E2EAuthSerializer(serializers.Serializer):
"""Serializer for E2E authentication."""
email = serializers.EmailField(required=True)

View File

@@ -0,0 +1,83 @@
"""
Test e2e API endpoints.
"""
from django.test.utils import override_settings
import pytest
from rest_framework.test import APIClient
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
def test_api_e2e_user_auth_no_urls():
"""E2E URLs not enabled should 404."""
client = APIClient()
response = client.post("/api/v1.0/e2e/user-auth/", {"email": "test@example.com"})
assert response.status_code == 404
@override_settings(LOAD_E2E_URLS=True)
def test_api_e2e_user_auth_anonymous():
"""Anonymous users should be allowed to create and login a user."""
reload_urls()
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
response = client.post("/api/v1.0/e2e/user-auth/", {"email": "test@example.com"})
assert response.status_code == 200
assert response.json() == {"email": "test@example.com"}
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
@override_settings(LOAD_E2E_URLS=True)
def test_api_e2e_user_auth_authenticated():
"""Authenticated users should be allowed to create and login a new user."""
reload_urls()
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
response = client.post("/api/v1.0/e2e/user-auth/", {"email": "test@example.com"})
assert response.status_code == 200
assert response.json() == {"email": "test@example.com"}
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
response = client.post("/api/v1.0/e2e/user-auth/", {"email": "test2@example.com"})
assert response.status_code == 200
assert response.json() == {"email": "test2@example.com"}
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["email"] == "test2@example.com"
@override_settings(LOAD_E2E_URLS=True)
def test_api_e2e_user_auth_email_required():
"""Email is required."""
reload_urls()
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
response = client.post("/api/v1.0/e2e/user-auth/", {})
assert response.status_code == 400
assert response.json() == {
"type": "validation_error",
"errors": [
{"code": "required", "detail": "This field is required.", "attr": "email"}
],
}

26
src/backend/e2e/urls.py Normal file
View File

@@ -0,0 +1,26 @@
"""URL configuration for the e2e app."""
from django.conf import settings
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from e2e import viewsets
user_auth_router = DefaultRouter()
user_auth_router.register(
"user-auth",
viewsets.UserAuthViewSet,
basename="user-auth",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/e2e/",
include(
[
*user_auth_router.urls,
]
),
),
]

11
src/backend/e2e/utils.py Normal file
View File

@@ -0,0 +1,11 @@
"""E2E utils."""
from core import factories, models
def get_or_create_e2e_user(email):
"""Get or create an E2E user."""
user = models.User.objects.filter(email=email).first()
if not user:
user = factories.UserFactory(email=email, sub=None, language="en-us")
return user

View File

@@ -0,0 +1,40 @@
"""Viewsets for the e2e app."""
from django.contrib.auth import login
import rest_framework as drf
from rest_framework import response as drf_response
from rest_framework import status
from rest_framework.permissions import AllowAny
from core import models
from e2e.serializers import E2EAuthSerializer
class UserAuthViewSet(drf.viewsets.ViewSet):
"""Viewset to handle user authentication"""
permission_classes = [AllowAny]
authentication_classes = []
def create(self, request):
"""
POST /api/v1.0/e2e/user-auth/
Create a user with the given email if it doesn't exist and log them in
"""
serializer = E2EAuthSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create user if doesn't exist
user = models.User.objects.filter(
email=serializer.validated_data["email"]
).first()
if not user:
user = models.User(email=serializer.validated_data["email"])
user.set_unusable_password()
user.save()
login(request, user, "django.contrib.auth.backends.ModelBackend")
return drf_response.Response({"email": user.email}, status=status.HTTP_200_OK)

35
src/backend/entrypoint Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/sh
#
# The container user (see USER in the Dockerfile) is an un-privileged user that
# does not exists and is not created during the build phase (see Dockerfile).
# Hence, we use this entrypoint to wrap commands that will be run in the
# container to create an entry for this user in the /etc/passwd file.
#
# The following environment variables may be passed to the container to
# customize running user account:
#
# * USER_NAME: container user name (default: default)
# * HOME : container user home directory (default: none)
#
# To pass environment variables, you can either use the -e option of the docker run command:
#
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' calendars-backend:latest python manage.py migrate
#
# or define new variables in an environment file to use with docker or docker compose:
#
# # env.d/production
# USER_NAME=foo
# HOME=/home/foo
#
# docker run --rm --env-file env.d/production calendars-backend:latest python manage.py migrate
#
echo "🐳(entrypoint) creating user running in the container..."
if ! whoami > /dev/null 2>&1; then
if [ -w /etc/passwd ]; then
echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd
fi
fi
echo "🐳(entrypoint) running your command: ${*}"
exec "$@"

View File

@@ -0,0 +1,16 @@
# Gunicorn-django settings
bind = ["0.0.0.0:8000"]
name = "calendars"
python_path = "/app"
# Run
graceful_timeout = 90
timeout = 90
workers = 3
# Logging
# Using '-' for the access log file makes gunicorn log accesses to stdout
accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-"
loglevel = "info"

View File

@@ -0,0 +1,455 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favorit"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lesen"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:74
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:80
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr "Erstellt"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:129
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, "
"Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:185
msgid "sub"
msgstr "unter"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen "
"@/./+/-/_/:"
#: core/models.py:196
msgid "full name"
msgstr "Name"
#: core/models.py:197
msgid "short name"
msgstr "Kurzbezeichnung"
#: core/models.py:199
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:204
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: core/models.py:211
msgid "language"
msgstr "Sprache"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:221
msgid "device"
msgstr "Gerät"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:226
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:231
msgid "active"
msgstr "aktiviert"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie "
"diese Option, anstatt Konten zu löschen."
#: core/models.py:246
msgid "user"
msgstr "Benutzer"
#: core/models.py:247
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
#, fuzzy
#| msgid "This team is already in this item."
msgid "title already exists in this folder."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:504
msgid "title"
msgstr "Titel"
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
#, fuzzy
#| msgid "items"
msgid "Items"
msgstr "Dokumente"
#: core/models.py:815
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you!"
msgid "{name} shared an item with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
"{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:820
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:872
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is already hard deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is not deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
#, fuzzy
#| msgid "item/user link trace"
msgid "Item/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1022
#, fuzzy
#| msgid "item/user link traces"
msgid "Item/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
#, fuzzy
#| msgid "item favorite"
msgid "Item favorite"
msgstr "Dokumentenfavorit"
#: core/models.py:1052
#, fuzzy
#| msgid "item favorites"
msgid "Item favorites"
msgstr "Dokumentfavoriten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
"Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:1080
#, fuzzy
#| msgid "item/user relation"
msgid "Item/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:1081
#, fuzzy
#| msgid "item/user relations"
msgid "Item/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:1126
msgid "email address"
msgstr "E-Mail-Adresse"
#: core/models.py:1145
#, fuzzy
#| msgid "item invitation"
msgid "Item invitation"
msgstr "Einladung zum Dokument"
#: core/models.py:1146
#, fuzzy
#| msgid "item invitations"
msgid "Item invitations"
msgstr "Dokumenteinladungen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: calendars/settings.py:250
msgid "English"
msgstr "Englisch"
#: calendars/settings.py:251
msgid "French"
msgstr "Französisch"
#: calendars/settings.py:252
msgid "German"
msgstr "Deutsch"
#~ msgid "Invalid response format or token verification failed"
#~ msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#~ msgid "User account is disabled"
#~ msgstr "Benutzerkonto ist deaktiviert"
#, fuzzy
#~| msgid "Untitled item"
#~ msgid "Untitled Item"
#~ msgstr "Unbenanntes Dokument"
#~ msgid "This email is already associated to a registered user."
#~ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Sie sind Besitzer eines neuen Dokuments:"
#~ msgid "Body"
#~ msgstr "Inhalt"
#~ msgid "Body type"
#~ msgstr "Typ"
#~ msgid "item"
#~ msgstr "Dokument"
#~ msgid "description"
#~ msgstr "Beschreibung"
#~ msgid "code"
#~ msgstr "Code"
#~ msgid "css"
#~ msgstr "CSS"
#~ msgid "public"
#~ msgstr "öffentlich"
#~ msgid "Whether this template is public for anyone to use."
#~ msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#~ msgid "Template"
#~ msgstr "Vorlage"
#~ msgid "Templates"
#~ msgstr "Vorlagen"
#~ msgid "Template/user relation"
#~ msgstr "Vorlage/Benutzer-Beziehung"
#~ msgid "Template/user relations"
#~ msgstr "Vorlage/Benutzerbeziehungen"
#~ msgid "This user is already in this template."
#~ msgstr "Dieser Benutzer ist bereits in dieser Vorlage."

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,145 @@
#
# calendar package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "calendars"
version = "0.10.1"
authors = [{ "name" = "anct", "email" = "contact@suite.anct.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Framework :: Django :: 5",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
]
description = "A calendar application."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = "~=3.13.0"
dependencies = [
"Brotli==1.2.0",
"dj-database-url==3.0.1",
"caldav==2.2.3",
"celery[redis]==5.6.0",
"django==5.2.9",
"django-celery-beat==2.8.1",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.21",
"django-parler==2.3",
"django-redis==6.0.0",
"django-timezone-field>=5.1",
"djangorestframework==3.16.1",
"djangorestframework-api-key==3.1.0",
"drf_spectacular==0.29.0",
"drf-standardized-errors==0.15.0",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"pillow>=10.0",
"psycopg[binary]==3.3.1",
"PyJWT==2.10.1",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.46.0",
"url-normalize==2.2.1",
"whitenoise==6.11.0",
]
[project.urls]
"Bug Tracker" = "https://github.com/suitenumerique/calendars/issues/new"
"Changelog" = "https://github.com/suitenumerique/calendars/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/calendars"
"Repository" = "https://github.com/suitenumerique/calendars"
[project.optional-dependencies]
dev = [
"django-debug-toolbar==6.1.0",
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.12.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.7",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.ruff]
exclude = [
".git",
".venv",
"build",
"venv",
"__pycache__",
"*/migrations/*",
]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"DJ", # flake8-django
"I", # isort
"PLC", # pylint-convention
"PLE", # pylint-error
"PLR", # pylint-refactoring
"PLW", # pylint-warning
"RUF100", # Ruff unused-noqa
"RUF200", # Ruff check pyproject.toml
"S", # flake8-bandit
"SLF", # flake8-self
"T20", # flake8-print
]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","calendars","first-party","local-folder"]
sections = { calendars=["core", "caldav"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
addopts = [
"-v",
"--cov-report",
"term-missing",
# Allow test files to have the same name in different directories.
"--import-mode=importlib",
]
python_files = [
"test_*.py",
"tests.py",
]

1836
src/backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff