🎉(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!
33
src/backend/.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
472
src/backend/.pylintrc
Normal file
@@ -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
@@ -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
@@ -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
5
src/backend/calendars/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Calendars package. Import the celery app early to load shared task form dependencies."""
|
||||
|
||||
from .celery_app import app as celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
26
src/backend/calendars/celery_app.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Calendars celery configuration file."""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from configurations.importer import install
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
|
||||
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
|
||||
install(check_options=True)
|
||||
|
||||
# Can be loaded only after install call.
|
||||
from django.conf import settings # pylint: disable=wrong-import-position
|
||||
|
||||
app = Celery("calendars")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||
69
src/backend/calendars/configuration/theme/default.json
Normal file
@@ -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
@@ -0,0 +1,919 @@
|
||||
"""
|
||||
Django settings for calendar project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.1.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join("/", "data"))
|
||||
|
||||
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
"""
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
return pyproject_data["project"]["version"]
|
||||
except (FileNotFoundError, KeyError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
class Base(Configuration):
|
||||
"""
|
||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||
is recommended to configure third-party applications by creating a configuration mixins in
|
||||
./configurations and compose the Base configuration with those mixins.
|
||||
|
||||
It depends on an environment variable that SHOULD be defined:
|
||||
|
||||
* DJANGO_SECRET_KEY
|
||||
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
* DB_USER
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
LOAD_E2E_URLS = False
|
||||
USE_SWAGGER = False
|
||||
|
||||
API_VERSION = "v1.0"
|
||||
|
||||
# DAViCal CalDAV server URL
|
||||
DAVICAL_URL = values.Value(
|
||||
"http://davical:80", environ_name="DAVICAL_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = SecretFileValue(None)
|
||||
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "calendars.urls"
|
||||
WSGI_APPLICATION = "calendars.wsgi.application"
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
"default": dj_database_url.config()
|
||||
if os.environ.get("DATABASE_URL")
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql",
|
||||
environ_name="DB_ENGINE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"NAME": values.Value(
|
||||
"calendars", environ_name="DB_NAME", environ_prefix=None
|
||||
),
|
||||
"USER": values.Value("pgroot", environ_name="DB_USER", environ_prefix=None),
|
||||
"PASSWORD": SecretFileValue(
|
||||
"pass", environ_name="DB_PASSWORD", environ_prefix=None
|
||||
),
|
||||
"HOST": values.Value(
|
||||
"localhost", environ_name="DB_HOST", environ_prefix=None
|
||||
),
|
||||
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
||||
}
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_URL_PREVIEW = "/media/preview/"
|
||||
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": values.Value(
|
||||
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
environ_name="STORAGES_STATICFILES_BACKEND",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# Maximum size of the request body in memory.
|
||||
# This is used to limit the size of the request body in memory.
|
||||
# This also limits the size of the file that can be uploaded to the server.
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
|
||||
2 * (2**30), # 2GB
|
||||
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
# Languages
|
||||
LANGUAGE_CODE = values.Value("en-us")
|
||||
LANGUAGE_COOKIE_NAME = "calendar_language" # cookie & language is set from frontend
|
||||
|
||||
DRF_NESTED_MULTIPART_PARSER = {
|
||||
# output of parser is converted to querydict
|
||||
# if is set to False, dict python is returned
|
||||
"querydict": False,
|
||||
}
|
||||
|
||||
# Careful! Languages should be ordered by priority, as this tuple is used to get
|
||||
# fallback/default languages throughout the app.
|
||||
LANGUAGES = values.SingleNestedTupleValue(
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
("nl-nl", _("Dutch")),
|
||||
)
|
||||
)
|
||||
|
||||
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Templates
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.csrf",
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.i18n",
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.request",
|
||||
"django.template.context_processors.tz",
|
||||
],
|
||||
"loaders": [
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"core.authentication.backends.OIDCAuthenticationBackend",
|
||||
]
|
||||
|
||||
# Django applications from the highest priority to the lowest
|
||||
INSTALLED_APPS = [
|
||||
"core",
|
||||
"drf_spectacular",
|
||||
"drf_standardized_errors",
|
||||
# Third party apps
|
||||
"corsheaders",
|
||||
"django_celery_beat",
|
||||
"django_filters",
|
||||
"rest_framework",
|
||||
"rest_framework_api_key",
|
||||
"parler",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.postgres",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
]
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_DEFAULT_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
"KEY_PREFIX": values.Value(
|
||||
"calendar",
|
||||
environ_name="CACHES_DEFAULT_KEY_PREFIX",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
"session": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_SESSION_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
|
||||
# see all the data in the database (ie a serializer with a ForeignKey field
|
||||
# will generate a form with a field with all possible values of the FK).
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"user_list_sustained": values.Value(
|
||||
default="180/hour",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"user_list_burst": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
MAX_PAGE_SIZE = values.PositiveIntegerValue(
|
||||
200, environ_name="MAX_PAGE_SIZE", environ_prefix=None
|
||||
)
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "calendar API",
|
||||
"DESCRIPTION": "This is the calendar API schema.",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK",
|
||||
),
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
# OTHER SETTINGS
|
||||
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
|
||||
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
TRASHBIN_CUTOFF_DAYS = values.Value(
|
||||
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
|
||||
)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
|
||||
CORS_ALLOWED_ORIGINS = values.ListValue([])
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
# Allow CalDAV methods (PROPFIND, REPORT, etc.)
|
||||
CORS_ALLOW_METHODS = [
|
||||
"DELETE",
|
||||
"GET",
|
||||
"OPTIONS",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PROPFIND",
|
||||
"REPORT",
|
||||
"MKCOL",
|
||||
"MKCALENDAR",
|
||||
]
|
||||
# Allow CalDAV headers (case-sensitive for CORS preflight)
|
||||
CORS_ALLOW_HEADERS = [
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"authorization",
|
||||
"content-type",
|
||||
"dnt",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
"depth", # CalDAV header (lowercase as sent by browsers)
|
||||
"if-match",
|
||||
"if-none-match",
|
||||
"prefer",
|
||||
]
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
FRONTEND_MORE_LINK = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_MORE_LINK",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_SHOW", environ_prefix=None
|
||||
)
|
||||
# For instance, you might want to bind this button to an external library to trigger survey instead of the build in feedback modal.
|
||||
FRONTEND_FEEDBACK_BUTTON_IDLE = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None
|
||||
)
|
||||
FRONTEND_FEEDBACK_ITEMS = values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK_ITEMS", environ_prefix=None
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL = values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH = values.Value(
|
||||
None, environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH", environ_prefix=None
|
||||
)
|
||||
FRONTEND_HIDE_GAUFRE = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_HIDE_GAUFRE", environ_prefix=None
|
||||
)
|
||||
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "calendars/configuration/theme/default.json"),
|
||||
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
|
||||
60 * 60 * 24,
|
||||
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
||||
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||
THUMBNAIL_ALIASES = {}
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "session"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
)
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_RP_SIGN_ALGO = values.Value(
|
||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_ID = values.Value(
|
||||
"calendar", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_SECRET = SecretFileValue(
|
||||
None,
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_TOKEN_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
||||
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_SCOPES = values.Value(
|
||||
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
LOGIN_REDIRECT_URL_FAILURE = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
|
||||
)
|
||||
LOGOUT_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
OIDC_USE_NONCE = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
|
||||
)
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
|
||||
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC claims to store
|
||||
OIDC_STORE_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_STORE_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification (see previous setting).
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_USER_INFO = values.ListValue(
|
||||
default=values.ListValue( # retrocompatibility
|
||||
default=[],
|
||||
environ_name="USER_OIDC_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
environ_name="OIDC_USER_INFO",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||
default="first_name",
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC Resource Server
|
||||
|
||||
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_BACKEND_CLASS = values.Value(
|
||||
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
|
||||
environ_name="OIDC_RS_BACKEND_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||
)
|
||||
|
||||
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External API Configuration
|
||||
# Configure available routes and actions for external_api endpoints
|
||||
EXTERNAL_API = values.DictValue(
|
||||
default={
|
||||
"users": {
|
||||
"enabled": True,
|
||||
"actions": ["get_me"],
|
||||
},
|
||||
},
|
||||
environ_name="EXTERNAL_API",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA",
|
||||
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Storage compute
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
"""Environment in which the application is launched."""
|
||||
return self.__class__.__name__.lower()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def RELEASE(self):
|
||||
"""
|
||||
Return the release information.
|
||||
|
||||
Delegate to the module function to enable easier testing.
|
||||
"""
|
||||
return get_release()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def PARLER_LANGUAGES(self):
|
||||
"""
|
||||
Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings.
|
||||
"""
|
||||
return {
|
||||
self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES),
|
||||
"default": {
|
||||
"fallbacks": [self.LANGUAGE_CODE],
|
||||
"hide_untranslated": False,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
This is the place where you can configure settings that require other
|
||||
settings to be loaded.
|
||||
"""
|
||||
super().post_setup()
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(),
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
sentry_sdk.set_tag("application", "backend")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
This environment should not be used to run the application. Just to build it with non-blocking
|
||||
settings.
|
||||
"""
|
||||
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
}
|
||||
SECRET_KEY = values.Value("DummyKey")
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": values.Value(
|
||||
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
environ_name="STORAGES_STATICFILES_BACKEND",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Development(Base):
|
||||
"""
|
||||
Development environment settings
|
||||
|
||||
We set DEBUG to True and configure the server to respond from all hosts.
|
||||
"""
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost:8920",
|
||||
]
|
||||
DEBUG = True
|
||||
LOAD_E2E_URLS = True
|
||||
|
||||
SESSION_COOKIE_NAME = "calendar_sessionid"
|
||||
|
||||
USE_SWAGGER = True
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += [
|
||||
"django_extensions",
|
||||
"drf_spectacular_sidecar",
|
||||
"debug_toolbar",
|
||||
"e2e",
|
||||
]
|
||||
|
||||
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
}
|
||||
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
USE_SWAGGER = True
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
OIDC_STORE_ACCESS_TOKEN = False
|
||||
OIDC_STORE_REFRESH_TOKEN = False
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar", "e2e"]
|
||||
|
||||
|
||||
class ContinuousIntegration(Test):
|
||||
"""
|
||||
Continuous Integration environment settings
|
||||
|
||||
nota bene: it should inherit from the Test environment.
|
||||
"""
|
||||
|
||||
|
||||
class Production(Base):
|
||||
"""
|
||||
Production environment settings
|
||||
|
||||
You must define the ALLOWED_HOSTS environment variable in Production
|
||||
configuration (and derived configurations):
|
||||
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
||||
"""
|
||||
|
||||
# Security
|
||||
# Add allowed host from environment variables.
|
||||
# The machine hostname is added by default,
|
||||
# it makes the application pingable by a load balancer on the same machine by example
|
||||
ALLOWED_HOSTS = [
|
||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||
gethostbyname(gethostname()),
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
|
||||
# object when your application is behind a reverse proxy.
|
||||
#
|
||||
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
|
||||
# - your Django app is behind a proxy.
|
||||
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
|
||||
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
|
||||
#
|
||||
# In other cases, you should comment the following line to avoid security issues.
|
||||
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_REDIRECT_EXEMPT = [
|
||||
"^__lbheartbeat__",
|
||||
"^__heartbeat__",
|
||||
]
|
||||
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# Privacy
|
||||
SECURE_REFERRER_POLICY = "same-origin"
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
"""
|
||||
Feature environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class Staging(Production):
|
||||
"""
|
||||
Staging environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class PreProduction(Production):
|
||||
"""
|
||||
Pre-production environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
58
src/backend/calendars/urls.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""URL configuration for the calendars project"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from drf_spectacular.views import (
|
||||
SpectacularJSONAPIView,
|
||||
SpectacularRedocView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
|
||||
from core.api.viewsets_caldav import CalDAVDiscoveryView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
# CalDAV discovery - must be at root level per RFC 6764
|
||||
path(".well-known/caldav", CalDAVDiscoveryView.as_view(), name="caldav-discovery"),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
|
||||
urlpatterns = (
|
||||
urlpatterns
|
||||
+ staticfiles_urlpatterns()
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
+ debug_toolbar_urls()
|
||||
)
|
||||
|
||||
if settings.LOAD_E2E_URLS:
|
||||
urlpatterns += [path("", include("e2e.urls"))]
|
||||
|
||||
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=settings.API_VERSION,
|
||||
urlconf="core.urls",
|
||||
),
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
re_path(
|
||||
f"api/{settings.API_VERSION}/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
]
|
||||
17
src/backend/calendars/wsgi.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
WSGI config for the calendars project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from configurations.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
|
||||
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
src/backend/core/__init__.py
Normal file
93
src/backend/core/admin.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"admin_email",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
"full_name",
|
||||
"admin_email",
|
||||
"email",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
34
src/backend/core/api/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Calendars core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from drf_standardized_errors.handler import exception_handler as drf_exception_handler
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import as_serializer_error
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Handle Django ValidationError as an accepted exception.
|
||||
|
||||
For the parameters, see ``exception_handler``
|
||||
This code comes from twidi's gist:
|
||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||
"""
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
exc = drf_exceptions.ValidationError(as_serializer_error(exc))
|
||||
|
||||
return drf_exception_handler(exc, context)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@api_view(["GET"])
|
||||
def get_frontend_configuration(request):
|
||||
"""Returns the frontend configuration dict as configured in settings."""
|
||||
frontend_configuration = {
|
||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
25
src/backend/core/api/fields.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""A JSONField for DRF to handle serialization/deserialization."""
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class JSONField(serializers.Field):
|
||||
"""
|
||||
A custom field for handling JSON data.
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
"""
|
||||
Convert the JSON string to a Python dictionary for serialization.
|
||||
"""
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Convert the Python dictionary to a JSON string for deserialization.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data)
|
||||
79
src/backend/core/api/permissions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Permission handlers for the calendars core app."""
|
||||
|
||||
from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||
"children": {"GET": "children_list", "POST": "children_create"},
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Write permissions are only allowed to the user itself."""
|
||||
return obj == request.user
|
||||
|
||||
|
||||
class IsOwnedOrPublic(IsAuthenticated):
|
||||
"""
|
||||
Allows access to authenticated users only for objects that are owned or not related
|
||||
to any user via the "owner" field.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Unsafe permissions are only allowed for the owner of the object."""
|
||||
if obj.owner == request.user:
|
||||
return True
|
||||
|
||||
if request.method in permissions.SAFE_METHODS and obj.owner is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
return obj.user == request.user
|
||||
except exceptions.ObjectDoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated or view.action not in [
|
||||
"create",
|
||||
]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
action = view.action
|
||||
try:
|
||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||
except KeyError:
|
||||
pass
|
||||
return abilities.get(action, False)
|
||||
155
src/backend/core/api/serializers.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Client serializers for the calendars core app."""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class UserLiteSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_abilities(self, access) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return access.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Check access rights specific to writing (create/update)
|
||||
"""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
# Update
|
||||
if self.instance:
|
||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
||||
|
||||
if role and role not in can_set_role_to:
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to set this role for this template."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
|
||||
# Create
|
||||
else:
|
||||
try:
|
||||
resource_id = self.context["resource_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a resource ID in kwargs to create a new access."
|
||||
) from exc
|
||||
|
||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this resource."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a resource can assign other users as owners."
|
||||
)
|
||||
|
||||
# pylint: disable=no-member
|
||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
||||
return attrs
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""Serialize users for me endpoint."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = UserSerializer.Meta.fields
|
||||
read_only_fields = UserSerializer.Meta.read_only_fields
|
||||
|
||||
|
||||
# CalDAV serializers
|
||||
class CalendarSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Calendar model."""
|
||||
|
||||
class Meta:
|
||||
model = models.Calendar
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"color",
|
||||
"description",
|
||||
"is_default",
|
||||
"is_visible",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "is_default", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class CalendarCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating a Calendar."""
|
||||
|
||||
class Meta:
|
||||
model = models.Calendar
|
||||
fields = ["name", "color", "description"]
|
||||
|
||||
|
||||
class CalendarShareSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for CalendarShare model."""
|
||||
|
||||
shared_with_email = serializers.EmailField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.CalendarShare
|
||||
fields = ["id", "shared_with_email", "permission", "is_visible", "created_at"]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
397
src/backend/core/api/viewsets.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models as db
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.text import slugify
|
||||
|
||||
import rest_framework as drf
|
||||
from corsheaders.middleware import (
|
||||
ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
)
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, mixins, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from rest_framework_api_key.permissions import HasAPIKey
|
||||
|
||||
from core import enums, models
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, item):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, item + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of items for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
|
||||
This mixin is useful to avoid to define a serializer class for each action in the
|
||||
`get_serializer_class` method.
|
||||
|
||||
Example:
|
||||
```
|
||||
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
|
||||
serializer_class = MySerializer
|
||||
list_serializer_class = MyListSerializer
|
||||
retrieve_serializer_class = MyRetrieveSerializer
|
||||
```
|
||||
"""
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Return the serializer class to use depending on the action.
|
||||
"""
|
||||
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
|
||||
return serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class Pagination(drf.pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
max_page_size = settings.MAX_PAGE_SIZE
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
SerializerPerActionMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.ListModelMixin,
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
pagination_class = None
|
||||
throttle_classes = []
|
||||
|
||||
def get_throttles(self):
|
||||
self.throttle_classes = []
|
||||
if self.action == "list":
|
||||
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
|
||||
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Limit listed users by querying the email field.
|
||||
If query contains "@", search exactly. Otherwise return empty.
|
||||
"""
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action != "list":
|
||||
return queryset
|
||||
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
return queryset.none()
|
||||
|
||||
# For emails, match exactly
|
||||
if "@" in query:
|
||||
return queryset.filter(email__iexact=query).order_by("email")[
|
||||
: settings.API_USERS_LIST_LIMIT
|
||||
]
|
||||
|
||||
# For non-email queries, return empty (no fuzzy search)
|
||||
return queryset.none()
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
url_path="me",
|
||||
)
|
||||
def get_me(self, request):
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf.response.Response(
|
||||
self.get_serializer(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
class ConfigView(drf.views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
"FRONTEND_MORE_LINK",
|
||||
"FRONTEND_FEEDBACK_BUTTON_SHOW",
|
||||
"FRONTEND_FEEDBACK_BUTTON_IDLE",
|
||||
"FRONTEND_FEEDBACK_ITEMS",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
|
||||
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH",
|
||||
"FRONTEND_HIDE_GAUFRE",
|
||||
"MEDIA_BASE_URL",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
dict_settings["theme_customization"] = self._load_theme_customization()
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
def _load_theme_customization(self):
|
||||
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
|
||||
return {}
|
||||
|
||||
cache_key = (
|
||||
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
|
||||
)
|
||||
theme_customization = cache.get(cache_key, {})
|
||||
if theme_customization:
|
||||
return theme_customization
|
||||
|
||||
try:
|
||||
with open(
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
|
||||
) as f:
|
||||
theme_customization = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Configuration file not found: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
"Configuration file is not a valid JSON: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
else:
|
||||
cache.set(
|
||||
cache_key,
|
||||
theme_customization,
|
||||
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
|
||||
# CalDAV ViewSets
|
||||
class CalendarViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
ViewSet for managing user calendars.
|
||||
|
||||
list: Get all calendars accessible by the user (owned + shared)
|
||||
retrieve: Get a specific calendar
|
||||
create: Create a new calendar
|
||||
update: Update calendar properties
|
||||
destroy: Delete a calendar
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = serializers.CalendarSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return calendars owned by or shared with the current user."""
|
||||
user = self.request.user
|
||||
owned = models.Calendar.objects.filter(owner=user)
|
||||
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
|
||||
"calendar_id", flat=True
|
||||
)
|
||||
shared = models.Calendar.objects.filter(id__in=shared_ids)
|
||||
return owned.union(shared).order_by("-is_default", "name")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return serializers.CalendarCreateSerializer
|
||||
return serializers.CalendarSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new calendar via CalendarService."""
|
||||
service = CalendarService()
|
||||
calendar = service.create_calendar(
|
||||
user=self.request.user,
|
||||
name=serializer.validated_data["name"],
|
||||
color=serializer.validated_data.get("color", "#3174ad"),
|
||||
)
|
||||
# Update the serializer instance with the created calendar
|
||||
serializer.instance = calendar
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete calendar. Prevent deletion of default calendar."""
|
||||
if instance.is_default:
|
||||
raise ValueError("Cannot delete the default calendar.")
|
||||
if instance.owner != self.request.user:
|
||||
raise PermissionError("You can only delete your own calendars.")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=["patch"])
|
||||
def toggle_visibility(self, request, pk=None):
|
||||
"""Toggle calendar visibility."""
|
||||
calendar = self.get_object()
|
||||
|
||||
# Check if it's a shared calendar
|
||||
share = models.CalendarShare.objects.filter(
|
||||
calendar=calendar, shared_with=request.user
|
||||
).first()
|
||||
|
||||
if share:
|
||||
share.is_visible = not share.is_visible
|
||||
share.save()
|
||||
is_visible = share.is_visible
|
||||
elif calendar.owner == request.user:
|
||||
calendar.is_visible = not calendar.is_visible
|
||||
calendar.save()
|
||||
is_visible = calendar.is_visible
|
||||
else:
|
||||
return drf_response.Response(
|
||||
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
return drf_response.Response({"is_visible": is_visible})
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
serializer_class=serializers.CalendarShareSerializer,
|
||||
)
|
||||
def share(self, request, pk=None):
|
||||
"""Share calendar with another user."""
|
||||
calendar = self.get_object()
|
||||
|
||||
if calendar.owner != request.user:
|
||||
return drf_response.Response(
|
||||
{"error": "Only the owner can share this calendar"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.CalendarShareSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
email = serializer.validated_data["shared_with_email"]
|
||||
try:
|
||||
user_to_share = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist:
|
||||
return drf_response.Response(
|
||||
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
share, created = models.CalendarShare.objects.get_or_create(
|
||||
calendar=calendar,
|
||||
shared_with=user_to_share,
|
||||
defaults={
|
||||
"permission": serializer.validated_data.get("permission", "read")
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
share.permission = serializer.validated_data.get(
|
||||
"permission", share.permission
|
||||
)
|
||||
share.save()
|
||||
|
||||
return drf_response.Response(
|
||||
serializers.CalendarShareSerializer(share).data,
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
220
src/backend/core/api/viewsets_caldav.py
Normal file
@@ -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
@@ -0,0 +1,19 @@
|
||||
"""Calendars Core application"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the calendars core app."""
|
||||
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("calendars core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
52
src/backend/core/authentication/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Custom authentication classes for the calendars core app"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class ServerToServerAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for server-to-server requests.
|
||||
Validates the presence and correctness of the Authorization header.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the server-to-server request by validating the Authorization header.
|
||||
|
||||
This method checks if the Authorization header is present in the request, ensures it
|
||||
contains a valid token with the correct format, and verifies the token against the
|
||||
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
|
||||
or contains an invalid token, an AuthenticationFailed exception is raised.
|
||||
|
||||
Returns:
|
||||
None: If authentication is successful
|
||||
(no user is authenticated for server-to-server requests).
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the Authorization header is missing, malformed,
|
||||
or contains an invalid token.
|
||||
"""
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
raise AuthenticationFailed("Authorization header is missing.")
|
||||
|
||||
# Validate token format and existence
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||
|
||||
# Authentication is successful, but no user is authenticated
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Create item server to server'"
|
||||
54
src/backend/core/authentication/backends.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Authentication Backends for the Calendars core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.authentication.exceptions import UserCannotAccessApp
|
||||
from core.models import DuplicateEmailError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of extra claims.
|
||||
"""
|
||||
|
||||
# We need to add the claims that we want to store so that they are
|
||||
# available in the post_get_or_create_user method.
|
||||
claims_to_store = {
|
||||
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
|
||||
}
|
||||
return {
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
"claims": claims_to_store,
|
||||
}
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
try:
|
||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
5
src/backend/core/authentication/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Exceptions for the authentication module."""
|
||||
|
||||
|
||||
class UserCannotAccessApp(Exception):
|
||||
"""Exception raised when a user cannot access the app."""
|
||||
25
src/backend/core/authentication/views.py
Normal file
@@ -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
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
||||
# We can use it for the choice of languages which should not be limited to the few languages
|
||||
# active in the app.
|
||||
# pylint: disable=no-member
|
||||
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
|
||||
41
src/backend/core/external_api/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Resource Server Permissions for the Calendars app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ResourceServerClientPermission(permissions.BasePermission):
|
||||
"""
|
||||
Permission class for resource server views.
|
||||
This provides a way to open the resource server views to a limited set of
|
||||
Service Providers.
|
||||
Note: we might add a more complex permission system in the future, based on
|
||||
the Service Provider ID and the requested scopes.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if the user is authenticated and the token introspection
|
||||
provides an authorized Service Provider.
|
||||
"""
|
||||
if not isinstance(
|
||||
request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# Not a resource server request
|
||||
return False
|
||||
|
||||
# Check if the user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if (
|
||||
hasattr(view, "resource_server_actions")
|
||||
and view.action not in view.resource_server_actions
|
||||
):
|
||||
return False
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
return (
|
||||
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
|
||||
)
|
||||
36
src/backend/core/external_api/viewsets.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Resource Server Viewsets for the Calendars app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
from core.api.permissions import AccessPermission, IsSelf
|
||||
from core.api.viewsets import UserViewSet
|
||||
from core.external_api.permissions import ResourceServerClientPermission
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class ResourceServerRestrictionMixin:
|
||||
"""
|
||||
Mixin for Resource Server Viewsets to provide shortcut to get
|
||||
configured actions for a given resource.
|
||||
"""
|
||||
|
||||
def _get_resource_server_actions(self, resource_name):
|
||||
"""Get resource_server_actions from settings."""
|
||||
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
|
||||
return list(external_api_config.get("actions", []))
|
||||
|
||||
|
||||
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
|
||||
"""Resource Server Viewset for the Calendars app."""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
permission_classes = [ResourceServerClientPermission & IsSelf]
|
||||
|
||||
@property
|
||||
def resource_server_actions(self):
|
||||
"""Get resource_server_actions from settings."""
|
||||
return self._get_resource_server_actions("users")
|
||||
28
src/backend/core/factories.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
import factory.fuzzy
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random users for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
skip_postgeneration_save = True
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
0
src/backend/core/management/commands/__init__.py
Normal file
47
src/backend/core/management/commands/createsuperuser.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Management user to create a superuser."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to create a superuser from an email and password."""
|
||||
|
||||
help = "Create a superuser with an email and a password"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Define required arguments "email" and "password"."""
|
||||
parser.add_argument(
|
||||
"--email",
|
||||
help=("Email for the user."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
help="Password for the user.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Given an email and a password, create a superuser or upgrade the existing
|
||||
user to superuser status.
|
||||
"""
|
||||
email = options.get("email")
|
||||
try:
|
||||
user = UserModel.objects.get(admin_email=email)
|
||||
except UserModel.DoesNotExist:
|
||||
user = UserModel(admin_email=email)
|
||||
message = "Superuser created successfully."
|
||||
else:
|
||||
if user.is_superuser and user.is_staff:
|
||||
message = "Superuser already exists."
|
||||
else:
|
||||
message = "User already existed and was upgraded to superuser."
|
||||
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.set_password(options["password"])
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(message))
|
||||
90
src/backend/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-08 23:49
|
||||
|
||||
import core.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
|
||||
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
|
||||
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(blank=True, choices=[('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'), ('nl-nl', 'Dutch')], default=None, help_text='The language in which the user wants to see the interface.', max_length=10, null=True, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'calendars_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Calendar',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('color', models.CharField(default='#3174ad', max_length=7)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
('is_visible', models.BooleanField(default=True)),
|
||||
('davical_path', models.CharField(max_length=512, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_default', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CalendarShare',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('permission', models.CharField(choices=[('read', 'Read only'), ('write', 'Read and write')], default='read', max_length=10)),
|
||||
('is_visible', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.calendar')),
|
||||
('shared_with', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_calendars', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='calendar',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('owner',), name='unique_default_calendar_per_user'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='calendarshare',
|
||||
unique_together={('calendar', 'shared_with')},
|
||||
),
|
||||
]
|
||||
0
src/backend/core/migrations/__init__.py
Normal file
402
src/backend/core/models.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Declare and configure the models for the calendars core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.indexes import GistIndex
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail, validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class LinkRoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a link can offer on a item."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a user can have in a resource."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||
|
||||
|
||||
class LinkReachChoices(models.TextChoices):
|
||||
"""Defines types of access for links"""
|
||||
|
||||
RESTRICTED = (
|
||||
"restricted",
|
||||
_("Restricted"),
|
||||
) # Only users with a specific access can read/edit the item
|
||||
AUTHENTICATED = (
|
||||
"authenticated",
|
||||
_("Authenticated"),
|
||||
) # Any authenticated user can access the item
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the item
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
before saving as Django doesn't do it by default.
|
||||
|
||||
Includes fields common to all models: a UUID primary key and creation/update timestamps.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(
|
||||
verbose_name=_("id"),
|
||||
help_text=_("primary key for the record as UUID"),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created on"),
|
||||
help_text=_("date and time at which a record was created"),
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated on"),
|
||||
help_text=_("date and time at which a record was last updated"),
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call `full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
if not email:
|
||||
return None
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif (
|
||||
self.filter(email=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-:]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
|
||||
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to login to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=settings.LANGUAGES,
|
||||
default=None,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
timezone = TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
use_pytz=False,
|
||||
default=settings.TIME_ZONE,
|
||||
help_text=_("The timezone in which the user wants to see times."),
|
||||
)
|
||||
is_device = models.BooleanField(
|
||||
_("device"),
|
||||
default=False,
|
||||
help_text=_("Whether the user is a device or a real user."),
|
||||
)
|
||||
is_staff = models.BooleanField(
|
||||
_("staff status"),
|
||||
default=False,
|
||||
help_text=_("Whether the user can log into this admin site."),
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
_("active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Whether this user should be treated as active. "
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
|
||||
claims = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text=_("Claims from the OIDC token."),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
db_table = "calendars_user"
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give its user access to the items to which s.he was invited.
|
||||
"""
|
||||
is_adding = self._state.adding
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
@cached_property
|
||||
def teams(self):
|
||||
"""
|
||||
Get list of teams in which the user is, as a list of strings.
|
||||
Must be cached if retrieved remotely.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
team = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def _get_abilities(self, resource, user):
|
||||
"""
|
||||
Compute and return abilities for a given user taking into account
|
||||
the current state of the object.
|
||||
"""
|
||||
roles = []
|
||||
if user.is_authenticated:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = resource.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
is_owner_or_admin = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
if self.role == RoleChoices.OWNER:
|
||||
can_delete = (
|
||||
RoleChoices.OWNER in roles
|
||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
set_role_to = (
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
if can_delete
|
||||
else []
|
||||
)
|
||||
else:
|
||||
can_delete = is_owner_or_admin
|
||||
set_role_to = []
|
||||
if RoleChoices.OWNER in roles:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
try:
|
||||
set_role_to.remove(self.role)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(roles),
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
|
||||
class Calendar(models.Model):
|
||||
"""
|
||||
Represents a calendar owned by a user.
|
||||
This model tracks calendars stored in DAViCal and links them to Django users.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="calendars",
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=7, default="#3174ad") # Hex color
|
||||
description = models.TextField(blank=True, default="")
|
||||
is_default = models.BooleanField(default=False)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
|
||||
# DAViCal reference - the calendar path in DAViCal
|
||||
davical_path = models.CharField(max_length=512, unique=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for Calendar model."""
|
||||
|
||||
ordering = ["-is_default", "name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["owner"],
|
||||
condition=models.Q(is_default=True),
|
||||
name="unique_default_calendar_per_user",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.owner.email})"
|
||||
|
||||
|
||||
class CalendarShare(models.Model):
|
||||
"""
|
||||
Represents a calendar shared with another user.
|
||||
"""
|
||||
|
||||
PERMISSION_READ = "read"
|
||||
PERMISSION_WRITE = "write"
|
||||
PERMISSION_CHOICES = [
|
||||
(PERMISSION_READ, "Read only"),
|
||||
(PERMISSION_WRITE, "Read and write"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
calendar = models.ForeignKey(
|
||||
Calendar,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="shares",
|
||||
)
|
||||
shared_with = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="shared_calendars",
|
||||
)
|
||||
permission = models.CharField(
|
||||
max_length=10,
|
||||
choices=PERMISSION_CHOICES,
|
||||
default=PERMISSION_READ,
|
||||
)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for CalendarShare model."""
|
||||
|
||||
unique_together = ["calendar", "shared_with"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.calendar.name} shared with {self.shared_with.email}"
|
||||
0
src/backend/core/services/__init__.py
Normal file
477
src/backend/core/services/caldav_service.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""Services for CalDAV integration with DAViCal."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from caldav import DAVClient
|
||||
from caldav.lib.error import NotFoundError
|
||||
from core.models import Calendar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DAViCalClient:
|
||||
"""
|
||||
Client for communicating with DAViCal CalDAV server using the caldav library.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
|
||||
self.timeout = 30
|
||||
|
||||
def _get_client(self, user) -> DAVClient:
|
||||
"""
|
||||
Get a CalDAV client for the given user.
|
||||
|
||||
DAViCal uses X-Forwarded-User header for authentication. The caldav
|
||||
library requires username/password for Basic Auth, but DAViCal users have
|
||||
password '*' (external auth). We pass the X-Forwarded-User header directly
|
||||
to the DAVClient constructor.
|
||||
"""
|
||||
# DAViCal base URL - the caldav library will discover the principal
|
||||
caldav_url = f"{self.base_url}/caldav.php/"
|
||||
|
||||
return DAVClient(
|
||||
url=caldav_url,
|
||||
username=user.email,
|
||||
password="", # Empty password - DAViCal uses X-Forwarded-User header
|
||||
timeout=self.timeout,
|
||||
headers={
|
||||
"X-Forwarded-User": user.email,
|
||||
},
|
||||
)
|
||||
|
||||
def ensure_user_exists(self, user) -> None:
|
||||
"""
|
||||
Ensure the user exists in DAViCal's database.
|
||||
Creates the user if they don't exist.
|
||||
"""
|
||||
# Connect to shared calendars database (public schema)
|
||||
default_db = settings.DATABASES["default"]
|
||||
db_name = default_db.get("NAME", "calendars")
|
||||
|
||||
# Get password - handle SecretValue objects
|
||||
password = default_db.get("PASSWORD", "pass")
|
||||
if hasattr(password, "value"):
|
||||
password = password.value
|
||||
|
||||
# Connect to calendars database
|
||||
conn = psycopg.connect(
|
||||
host=default_db.get("HOST", "localhost"),
|
||||
port=default_db.get("PORT", 5432),
|
||||
dbname=db_name,
|
||||
user=default_db.get("USER", "pgroot"),
|
||||
password=password,
|
||||
)
|
||||
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
# Check if user exists (in public schema)
|
||||
cursor.execute(
|
||||
"SELECT user_no FROM usr WHERE lower(username) = lower(%s)",
|
||||
[user.email],
|
||||
)
|
||||
if cursor.fetchone():
|
||||
# User already exists
|
||||
return
|
||||
|
||||
# Create user in DAViCal (public schema)
|
||||
# Use email as username, password '*' means external auth
|
||||
# Get user's full name or use email prefix
|
||||
fullname = (
|
||||
getattr(user, "full_name", None)
|
||||
or getattr(user, "get_full_name", lambda: None)()
|
||||
or user.email.split("@")[0]
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO usr (username, email, fullname, active, password)
|
||||
VALUES (%s, %s, %s, true, '*')
|
||||
ON CONFLICT (lower(username)) DO NOTHING
|
||||
RETURNING user_no
|
||||
""",
|
||||
[user.email, user.email, fullname],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
user_no = result[0]
|
||||
logger.info(
|
||||
"Created DAViCal user: %s (user_no: %s)", user.email, user_no
|
||||
)
|
||||
|
||||
# Also create a principal record for the user (public schema)
|
||||
# DAViCal needs both usr and principal records
|
||||
# Principal type 1 is for users
|
||||
type_id = 1
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO principal (type_id, user_no, displayname)
|
||||
SELECT %s, %s, %s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM principal WHERE user_no = %s)
|
||||
RETURNING principal_id
|
||||
""",
|
||||
[type_id, user_no, fullname, user_no],
|
||||
)
|
||||
principal_result = cursor.fetchone()
|
||||
if principal_result:
|
||||
logger.info(
|
||||
"Created DAViCal principal: %s (principal_id: %s)",
|
||||
user.email,
|
||||
principal_result[0],
|
||||
)
|
||||
else:
|
||||
logger.warning("User %s already exists in DAViCal", user.email)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
|
||||
"""
|
||||
Create a new calendar in DAViCal for the given user.
|
||||
Returns the DAViCal path for the calendar.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
principal = client.principal()
|
||||
|
||||
try:
|
||||
# Create calendar using caldav library
|
||||
calendar = principal.make_calendar(name=calendar_name)
|
||||
|
||||
# DAViCal calendar path format: /caldav.php/{username}/{calendar_id}/
|
||||
# The caldav library returns a URL object, convert to string and extract path
|
||||
calendar_url = str(calendar.url)
|
||||
# Extract path from full URL
|
||||
if calendar_url.startswith(self.base_url):
|
||||
path = calendar_url[len(self.base_url) :]
|
||||
else:
|
||||
# Fallback: construct path manually based on DAViCal's structure
|
||||
# DAViCal creates calendars with a specific path structure
|
||||
path = f"/caldav.php/{user.email}/{calendar_id}/"
|
||||
|
||||
logger.info("Created calendar in DAViCal: %s at %s", calendar_name, path)
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.error("Failed to create calendar in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def get_events(
|
||||
self,
|
||||
user,
|
||||
calendar_path: str,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get events from a calendar within a time range.
|
||||
Returns list of event dictionaries with parsed data.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
# Default to current month if no range specified
|
||||
if start is None:
|
||||
start = timezone.now().replace(day=1, hour=0, minute=0, second=0)
|
||||
if end is None:
|
||||
end = start + timedelta(days=31)
|
||||
|
||||
client = self._get_client(user)
|
||||
|
||||
# Get calendar by URL
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for events in the date range
|
||||
# Convert datetime to date for search if needed
|
||||
start_date = start.date() if isinstance(start, datetime) else start
|
||||
end_date = end.date() if isinstance(end, datetime) else end
|
||||
|
||||
events = calendar.search(
|
||||
event=True,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
expand=True, # Expand recurring events
|
||||
)
|
||||
|
||||
# Parse events into dictionaries
|
||||
parsed_events = []
|
||||
for event in events:
|
||||
event_data = self._parse_event(event)
|
||||
if event_data:
|
||||
parsed_events.append(event_data)
|
||||
|
||||
return parsed_events
|
||||
except NotFoundError:
|
||||
logger.warning("Calendar not found at path: %s", calendar_path)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Failed to get events from DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
|
||||
"""
|
||||
Create a new event in DAViCal.
|
||||
Returns the event UID.
|
||||
"""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
# Extract event data
|
||||
dtstart = event_data.get("start", timezone.now())
|
||||
dtend = event_data.get("end", dtstart + timedelta(hours=1))
|
||||
summary = event_data.get("title", "New Event")
|
||||
description = event_data.get("description", "")
|
||||
location = event_data.get("location", "")
|
||||
|
||||
# Generate UID if not provided
|
||||
event_uid = event_data.get("uid", str(uuid4()))
|
||||
|
||||
try:
|
||||
# Create event using caldav library
|
||||
event = calendar.save_event(
|
||||
dtstart=dtstart,
|
||||
dtend=dtend,
|
||||
uid=event_uid,
|
||||
summary=summary,
|
||||
description=description,
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Extract UID from created event
|
||||
# The caldav library returns an Event object
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid = str(event.icalendar_component.get("uid", event_uid))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid = event.vobject_instance.vevent.uid.value
|
||||
|
||||
logger.info("Created event in DAViCal: %s", event_uid)
|
||||
return event_uid
|
||||
except Exception as e:
|
||||
logger.error("Failed to create event in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def update_event(
|
||||
self, user, calendar_path: str, event_uid: str, event_data: dict
|
||||
) -> None:
|
||||
"""Update an existing event in DAViCal."""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
|
||||
# Update event properties
|
||||
dtstart = event_data.get("start")
|
||||
dtend = event_data.get("end")
|
||||
summary = event_data.get("title")
|
||||
description = event_data.get("description")
|
||||
location = event_data.get("location")
|
||||
|
||||
# Update using icalendar component
|
||||
component = target_event.icalendar_component
|
||||
|
||||
if dtstart:
|
||||
component["dtstart"] = dtstart
|
||||
if dtend:
|
||||
component["dtend"] = dtend
|
||||
if summary:
|
||||
component["summary"] = summary
|
||||
if description is not None:
|
||||
component["description"] = description
|
||||
if location is not None:
|
||||
component["location"] = location
|
||||
|
||||
# Save the updated event
|
||||
target_event.save()
|
||||
|
||||
logger.info("Updated event in DAViCal: %s", event_uid)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update event in DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def delete_event(self, user, calendar_path: str, event_uid: str) -> None:
|
||||
"""Delete an event from DAViCal."""
|
||||
# Ensure user exists first
|
||||
self.ensure_user_exists(user)
|
||||
|
||||
client = self._get_client(user)
|
||||
calendar_url = f"{self.base_url}{calendar_path}"
|
||||
calendar = client.calendar(url=calendar_url)
|
||||
|
||||
try:
|
||||
# Search for the event by UID
|
||||
events = calendar.search(event=True)
|
||||
target_event = None
|
||||
|
||||
for event in events:
|
||||
event_uid_value = None
|
||||
if hasattr(event, "icalendar_component"):
|
||||
event_uid_value = str(event.icalendar_component.get("uid", ""))
|
||||
elif hasattr(event, "vobject_instance"):
|
||||
event_uid_value = event.vobject_instance.vevent.uid.value
|
||||
|
||||
if event_uid_value == event_uid:
|
||||
target_event = event
|
||||
break
|
||||
|
||||
if not target_event:
|
||||
raise ValueError(f"Event with UID {event_uid} not found")
|
||||
|
||||
# Delete the event
|
||||
target_event.delete()
|
||||
|
||||
logger.info("Deleted event from DAViCal: %s", event_uid)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete event from DAViCal: %s", str(e))
|
||||
raise
|
||||
|
||||
def _parse_event(self, event) -> Optional[dict]:
|
||||
"""
|
||||
Parse a caldav Event object and return event data as dictionary.
|
||||
"""
|
||||
try:
|
||||
component = event.icalendar_component
|
||||
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"start": component.get("dtstart").dt
|
||||
if component.get("dtstart")
|
||||
else None,
|
||||
"end": component.get("dtend").dt if component.get("dtend") else None,
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
}
|
||||
|
||||
# Convert datetime to string format for consistency
|
||||
if event_data["start"]:
|
||||
if isinstance(event_data["start"], datetime):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["start"], date):
|
||||
event_data["start"] = event_data["start"].strftime("%Y%m%d")
|
||||
|
||||
if event_data["end"]:
|
||||
if isinstance(event_data["end"], datetime):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
|
||||
elif isinstance(event_data["end"], date):
|
||||
event_data["end"] = event_data["end"].strftime("%Y%m%d")
|
||||
|
||||
return event_data if event_data.get("uid") else None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse event: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
class CalendarService:
|
||||
"""
|
||||
High-level service for managing calendars and events.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.davical = DAViCalClient()
|
||||
|
||||
def create_default_calendar(self, user) -> Calendar:
|
||||
"""
|
||||
Create a default calendar for a user.
|
||||
"""
|
||||
calendar_id = str(uuid4())
|
||||
calendar_name = "Mon calendrier"
|
||||
|
||||
# Create calendar in DAViCal
|
||||
davical_path = self.davical.create_calendar(user, calendar_name, calendar_id)
|
||||
|
||||
# Create local Calendar record
|
||||
calendar = Calendar.objects.create(
|
||||
owner=user,
|
||||
name=calendar_name,
|
||||
davical_path=davical_path,
|
||||
is_default=True,
|
||||
color="#3174ad",
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
def create_calendar(self, user, name: str, color: str = "#3174ad") -> Calendar:
|
||||
"""
|
||||
Create a new calendar for a user.
|
||||
"""
|
||||
calendar_id = str(uuid4())
|
||||
|
||||
# Create calendar in DAViCal
|
||||
davical_path = self.davical.create_calendar(user, name, calendar_id)
|
||||
|
||||
# Create local Calendar record
|
||||
calendar = Calendar.objects.create(
|
||||
owner=user,
|
||||
name=name,
|
||||
davical_path=davical_path,
|
||||
is_default=False,
|
||||
color=color,
|
||||
)
|
||||
|
||||
return calendar
|
||||
|
||||
def get_user_calendars(self, user):
|
||||
"""
|
||||
Get all calendars accessible by a user (owned + shared).
|
||||
"""
|
||||
owned = Calendar.objects.filter(owner=user)
|
||||
shared = Calendar.objects.filter(shares__shared_with=user)
|
||||
return owned.union(shared)
|
||||
|
||||
def get_events(self, user, calendar: Calendar, start=None, end=None) -> list:
|
||||
"""
|
||||
Get events from a calendar.
|
||||
Returns parsed event data.
|
||||
"""
|
||||
return self.davical.get_events(user, calendar.davical_path, start, end)
|
||||
|
||||
def create_event(self, user, calendar: Calendar, event_data: dict) -> str:
|
||||
"""Create a new event."""
|
||||
return self.davical.create_event(user, calendar.davical_path, event_data)
|
||||
|
||||
def update_event(
|
||||
self, user, calendar: Calendar, event_uid: str, event_data: dict
|
||||
) -> None:
|
||||
"""Update an existing event."""
|
||||
self.davical.update_event(user, calendar.davical_path, event_uid, event_data)
|
||||
|
||||
def delete_event(self, user, calendar: Calendar, event_uid: str) -> None:
|
||||
"""Delete an event."""
|
||||
self.davical.delete_event(user, calendar.davical_path, event_uid)
|
||||
55
src/backend/core/signals.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Declare and configure the signals for the calendars core application
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def provision_default_calendar(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Auto-provision a default calendar when a new user is created.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Check if user already has a default calendar
|
||||
if instance.calendars.filter(is_default=True).exists():
|
||||
return
|
||||
|
||||
# Skip calendar creation if DAViCal is not configured
|
||||
if not getattr(settings, "DAVICAL_URL", None):
|
||||
return
|
||||
|
||||
try:
|
||||
service = CalendarService()
|
||||
service.create_default_calendar(instance)
|
||||
logger.info("Created default calendar for user %s", instance.email)
|
||||
except Exception as e:
|
||||
# In tests, DAViCal tables don't exist, so fail silently
|
||||
# Check if it's a database error that suggests we're in tests
|
||||
error_str = str(e).lower()
|
||||
if "does not exist" in error_str or "relation" in error_str:
|
||||
# Likely in test environment, fail silently
|
||||
logger.debug(
|
||||
"Skipped calendar creation for user %s (likely test environment): %s",
|
||||
instance.email,
|
||||
str(e),
|
||||
)
|
||||
else:
|
||||
# Real error, log it
|
||||
logger.error(
|
||||
"Failed to create default calendar for user %s: %s",
|
||||
instance.email,
|
||||
str(e),
|
||||
)
|
||||
0
src/backend/core/templatetags/__init__.py
Normal file
58
src/backend/core/templatetags/extra_tags.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Custom template tags for the calendars core application."""
|
||||
|
||||
import base64
|
||||
|
||||
from django import template
|
||||
from django.contrib.staticfiles import finders
|
||||
|
||||
from PIL import ImageFile as PillowImageFile
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def image_to_base64(file_or_path, close=False):
|
||||
"""
|
||||
Return the src string of the base64 encoding of an image represented by its path
|
||||
or file opened or not.
|
||||
|
||||
Inspired by Django's "get_image_dimensions"
|
||||
"""
|
||||
pil_parser = PillowImageFile.Parser()
|
||||
if hasattr(file_or_path, "read"):
|
||||
file = file_or_path
|
||||
if file.closed and hasattr(file, "open"):
|
||||
file_or_path.open()
|
||||
file_pos = file.tell()
|
||||
file.seek(0)
|
||||
else:
|
||||
try:
|
||||
# pylint: disable=consider-using-with
|
||||
file = open(file_or_path, "rb")
|
||||
except OSError:
|
||||
return ""
|
||||
close = True
|
||||
|
||||
try:
|
||||
image_data = file.read()
|
||||
if not image_data:
|
||||
return ""
|
||||
pil_parser.feed(image_data)
|
||||
if pil_parser.image:
|
||||
mime_type = pil_parser.image.get_format_mimetype()
|
||||
encoded_string = base64.b64encode(image_data)
|
||||
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
|
||||
return ""
|
||||
finally:
|
||||
if close:
|
||||
file.close()
|
||||
else:
|
||||
file.seek(file_pos)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def base64_static(path):
|
||||
"""Return a static file into a base64."""
|
||||
full_path = finders.find(path)
|
||||
if full_path:
|
||||
return image_to_base64(full_path, True)
|
||||
return ""
|
||||
0
src/backend/core/tests/__init__.py
Normal file
0
src/backend/core/tests/authentication/__init__.py
Normal file
579
src/backend/core/tests/authentication/test_backends.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import random
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.fernet import Fernet
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.exceptions import UserCannotAccessApp
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email=None)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
user_info = {"sub": "123"}
|
||||
if random.choice([True, False]):
|
||||
user_info["email"] = None
|
||||
return user_info
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub and email didn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(full_name="John Doe", short_name="John")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_sub(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change
|
||||
and the user was identified by its "sub".
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields_email(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the name fields on the user when they change
|
||||
and the user was identified by its "email" as fallback.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response(settings):
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info response was not valid JWT",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the sub but is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(1),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not match the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "random",
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_authentication_session_tokens(
|
||||
django_assert_num_queries, monkeypatch, rf, settings
|
||||
):
|
||||
"""
|
||||
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
|
||||
"""
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
|
||||
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
|
||||
settings.OIDC_STORE_ACCESS_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
|
||||
request.session = {}
|
||||
|
||||
def verify_token_mocked(*args, **kwargs):
|
||||
return {"sub": "123", "email": "test@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||
json={
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(settings.OIDC_OP_USER_ENDPOINT),
|
||||
json={"sub": "123", "email": "test@example.com"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
with django_assert_num_queries(27):
|
||||
user = klass.authenticate(
|
||||
request,
|
||||
code="test-code",
|
||||
nonce="test-nonce",
|
||||
code_verifier="test-code-verifier",
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
|
||||
@override_settings(OIDC_STORE_CLAIMS=["iss"])
|
||||
def test_authentication_store_claims_new_user(monkeypatch):
|
||||
"""
|
||||
Test that the claims are stored on the user when a new user is created.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"iss": "https://example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.has_usable_password() is False
|
||||
assert user.claims == {"iss": "https://example.com"}
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_STORE_CLAIMS=["iss"])
|
||||
def test_authentication_store_claims_existing_user(monkeypatch):
|
||||
"""
|
||||
Test that the claims are stored on the user when an existing user is authenticated.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
email="calendars@example.com", sub="123", claims={"iss": "https://obsolete.com"}
|
||||
)
|
||||
email = "calendars@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"iss": "https://example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.claims == {"iss": "https://example.com"}
|
||||
assert models.User.objects.count() == 1
|
||||
66
src/backend/core/tests/authentication/test_views.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core import factories
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.views import (
|
||||
OIDCAuthenticationCallbackView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
LOGIN_REDIRECT_URL_FAILURE="/auth/failure",
|
||||
LOGIN_REDIRECT_URL="/auth/success",
|
||||
)
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationBackend,
|
||||
"get_token",
|
||||
return_value={"id_token": "mocked_id_token", "access_token": "mocked_access_token"},
|
||||
)
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationBackend, "verify_token", return_value={"not": "needed"}
|
||||
)
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationBackend,
|
||||
"get_userinfo",
|
||||
return_value={"sub": "mocked_sub", "email": "allowed@example.com"},
|
||||
)
|
||||
def test_view_login_callback_authorized_by_default(
|
||||
mocked_get_userinfo, mocked_verify_token, mocked_get_token
|
||||
):
|
||||
"""By default, all users are authorized to login."""
|
||||
|
||||
user = factories.UserFactory(email="allowed@example.com")
|
||||
|
||||
request = RequestFactory().get(
|
||||
"/callback/", data={"state": "mocked_state", "code": "mocked_code"}
|
||||
)
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
request.session["oidc_states"] = {mocked_state: {"nonce": "mocked_nonce"}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCAuthenticationCallbackView.as_view()
|
||||
|
||||
response = callback_view(request)
|
||||
mocked_get_token.assert_called_once()
|
||||
mocked_verify_token.assert_called_once()
|
||||
mocked_get_userinfo.assert_called_once()
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/auth/success"
|
||||
148
src/backend/core/tests/conftest.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Fixtures for tests in the calendars core application"""
|
||||
|
||||
import base64
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from core import factories
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
VIA = [USER, TEAM]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def truncate_davical_tables(django_db_setup, django_db_blocker):
|
||||
"""Fixture to truncate DAViCal tables at the start of each test.
|
||||
|
||||
DAViCal tables are created by the DAViCal container migrations, not Django.
|
||||
We just truncate them to ensure clean state for each test.
|
||||
"""
|
||||
with django_db_blocker.unblock():
|
||||
with connection.cursor() as cursor:
|
||||
# Truncate DAViCal tables if they exist (created by DAViCal container)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principal') THEN
|
||||
TRUNCATE TABLE principal CASCADE;
|
||||
END IF;
|
||||
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usr') THEN
|
||||
TRUNCATE TABLE usr CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Fixture to clear the cache after each test."""
|
||||
yield
|
||||
cache.clear()
|
||||
# Clear functools.cache for functions decorated with @functools.cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_teams():
|
||||
"""Mock for the "teams" property on the User model."""
|
||||
with mock.patch(
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
def resource_server_backend_setup(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
assert (
|
||||
settings.OIDC_RS_BACKEND_CLASS
|
||||
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||
)
|
||||
|
||||
settings.OIDC_RESOURCE_SERVER_ENABLED = True
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "groups"]
|
||||
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend_conf(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_server_backend(settings):
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
Including a mocked introspection endpoint.
|
||||
"""
|
||||
resource_server_backend_setup(settings)
|
||||
reload_urls()
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
yield rsps
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_specific_sub():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
user = factories.UserFactory(sub="very-specific-sub")
|
||||
|
||||
yield user
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token():
|
||||
"""
|
||||
A fixture to create a user token for testing.
|
||||
"""
|
||||
return build_authorization_bearer("some_token")
|
||||
152
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Tests for the Resource Server API for users.
|
||||
|
||||
Not testing external API endpoints that are already tested in the /api
|
||||
because the resource server viewsets inherit from the api viewsets.
|
||||
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
def test_api_users_me_anonymous_public_standalone():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
response = APIClient().get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_me_connected_not_resource_server():
|
||||
"""
|
||||
Connected users should not be allowed to retrieve their own user information from external
|
||||
API if resource server is not enabled.
|
||||
"""
|
||||
reload_urls()
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_me_connected_resource_server(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(user_specific_sub.id)
|
||||
assert data["email"] == user_specific_sub.email
|
||||
|
||||
|
||||
def test_api_users_me_connected_resource_server_with_invalid_token(
|
||||
user_token, resource_server_backend
|
||||
):
|
||||
"""
|
||||
Connected users should not be allowed to retrieve their own user information from external API
|
||||
if resource server is enabled with an invalid token.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# Non allowed actions on resource server.
|
||||
|
||||
|
||||
def test_api_users_list_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
response = client.get("/external_api/v1.0/users/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_retrieve_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_put_patch_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
response = client.put(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.patch(
|
||||
f"/external_api/v1.0/users/{other_user.id!s}/",
|
||||
{"email": "new_email@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_users_delete_resource_server_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
"""
|
||||
Connected users should notbe allowed to list users from a resource server.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
0
src/backend/core/tests/swagger/__init__.py
Normal file
42
src/backend/core/tests/swagger/test_openapi_schema.py
Normal 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)
|
||||
161
src/backend/core/tests/test_api_config.py
Normal 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
|
||||
638
src/backend/core/tests/test_api_users.py
Normal 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
|
||||
71
src/backend/core/tests/test_caldav_service.py
Normal 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/")
|
||||
46
src/backend/core/tests/test_models_users.py
Normal 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."
|
||||
30
src/backend/core/tests/test_settings.py
Normal 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. "
|
||||
)
|
||||
20
src/backend/core/tests/utils/urls.py
Normal 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
@@ -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),
|
||||
)
|
||||
)
|
||||
0
src/backend/e2e/__init__.py
Normal file
0
src/backend/e2e/management/commands/__init__.py
Normal file
12
src/backend/e2e/serializers.py
Normal 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)
|
||||
83
src/backend/e2e/tests/test_api_e2e.py
Normal 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
@@ -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
@@ -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
|
||||
40
src/backend/e2e/viewsets.py
Normal 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
@@ -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 "$@"
|
||||
16
src/backend/gunicorn.conf.py
Normal 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"
|
||||
455
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal 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."
|
||||
362
src/backend/locale/en_US/LC_MESSAGES/django.po
Normal 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 ""
|
||||
371
src/backend/locale/fr_FR/LC_MESSAGES/django.po
Normal 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 :"
|
||||
369
src/backend/locale/nl_NL/LC_MESSAGES/django.po
Normal 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
@@ -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
@@ -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
29
src/frontend/.dockerignore
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
72
src/frontend/Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
COPY ./package.json ./package.json
|
||||
COPY ./yarn.lock ./yarn.lock
|
||||
COPY ./apps/calendars/package.json ./apps/calendars/package.json
|
||||
COPY ./packages/open-calendar/package.json ./packages/open-calendar/package.json
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
# COPY ./.prettierrc.js ./.prettierrc.js
|
||||
#COPY ./packages/eslint-config-calendars ./packages/eslint-config-calendars
|
||||
COPY ./packages/open-calendar ./packages/open-calendar
|
||||
COPY ./apps/calendars ./apps/calendars
|
||||
|
||||
# Build open-calendar package
|
||||
WORKDIR /home/frontend/packages/open-calendar
|
||||
RUN yarn build
|
||||
WORKDIR /home/frontend
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-deps AS calendars
|
||||
|
||||
WORKDIR /home/frontend/apps/calendars
|
||||
|
||||
FROM frontend-deps AS calendars-dev
|
||||
|
||||
WORKDIR /home/frontend/apps/calendars
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
RUN yarn build-theme
|
||||
|
||||
# Build open-calendar package if dist doesn't exist, then start dev server
|
||||
CMD ["/bin/sh", "-c", "cd /home/frontend/packages/open-calendar && ([ -d dist ] || yarn build) && cd /home/frontend/apps/calendars && yarn dev"]
|
||||
|
||||
# Tilt will rebuild calendars target so, we dissociate calendars and calendars-builder
|
||||
# to avoid rebuilding the app at every changes.
|
||||
FROM calendars AS calendars-builder
|
||||
|
||||
WORKDIR /home/frontend/apps/calendars
|
||||
|
||||
ARG API_ORIGIN
|
||||
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
USER root
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=calendars-builder \
|
||||
/home/frontend/apps/calendars/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./apps/calendars/conf/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
1
src/frontend/apps/calendars/.env
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
1
src/frontend/apps/calendars/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921
|
||||
41
src/frontend/apps/calendars/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
40
src/frontend/apps/calendars/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
|
||||
2
src/frontend/apps/calendars/__mocks__/fileMock.js
Normal file
@@ -0,0 +1,2 @@
|
||||
module.exports = "test-file-stub";
|
||||
|
||||
26
src/frontend/apps/calendars/conf/default.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 8080;
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
location ~ "^/401/?$" {
|
||||
try_files $uri /401.html;
|
||||
}
|
||||
|
||||
location ~ "^/403/?$" {
|
||||
try_files $uri /403.html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
411
src/frontend/apps/calendars/cunningham.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { cunninghamConfig } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
// TODO: Temporary solution to override the default button tertiary text color, waiting for the new ui-kit to be released
|
||||
|
||||
/**
|
||||
* Deep merge function that recursively merges objects
|
||||
* @param target - The target object to merge into
|
||||
* @param source - The source object to merge from
|
||||
* @returns A new object with deeply merged properties
|
||||
*
|
||||
* @example
|
||||
* const obj1 = { a: { x: 1, y: 2 }, b: 3 };
|
||||
* const obj2 = { a: { y: 3, z: 4 }, c: 5 };
|
||||
* const merged = deepMerge(obj1, obj2);
|
||||
* // Result: { a: { x: 1, y: 3, z: 4 }, b: 3, c: 5 }
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function deepMerge(target: any, source: any): any {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
if (
|
||||
sourceValue &&
|
||||
typeof sourceValue === "object" &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue &&
|
||||
typeof targetValue === "object" &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
// Recursively merge nested objects
|
||||
result[key] = deepMerge(targetValue, sourceValue);
|
||||
} else if (sourceValue !== undefined) {
|
||||
// Override with source value
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const themesImages = {
|
||||
anct: {
|
||||
favicon: "/assets/anct_favicon.png",
|
||||
logo: "/assets/anct_logo_beta.svg",
|
||||
"logo-icon": "/assets/anct_logo-icon.svg",
|
||||
},
|
||||
dark: {
|
||||
favicon: "/assets/favicon.png",
|
||||
logo: "/assets/logo_beta.svg",
|
||||
"logo-icon": "/assets/logo-icon_beta.svg",
|
||||
},
|
||||
default: {
|
||||
favicon: "/assets/favicon.png",
|
||||
logo: "/assets/logo_beta.svg",
|
||||
"logo-icon": "/assets/logo-icon_beta.svg",
|
||||
},
|
||||
};
|
||||
|
||||
const themesGaufre = {
|
||||
anct: {
|
||||
widgetPath: "https://static.suite.anct.gouv.fr/widgets/lagaufre.js",
|
||||
apiUrl:
|
||||
"https://operateurs.suite.anct.gouv.fr/api/v1.0/lagaufre/services/?operator=9f5624fc-ef99-4d10-ae3f-403a81eb16ef&siret=21870030000013",
|
||||
},
|
||||
dark: {
|
||||
widgetPath: "https://static.suite.anct.gouv.fr/widgets/lagaufre.js",
|
||||
apiUrl: "https://lasuite.numerique.gouv.fr/api/services",
|
||||
},
|
||||
default: {
|
||||
widgetPath: "https://static.suite.anct.gouv.fr/widgets/lagaufre.js",
|
||||
apiUrl: "https://lasuite.numerique.gouv.fr/api/services",
|
||||
},
|
||||
};
|
||||
|
||||
const getComponents = (theme: keyof typeof themesImages) => {
|
||||
return {
|
||||
datagrid: {
|
||||
"body--background-color-hover":
|
||||
"ref(contextuals.background.semantic.contextual.primary)",
|
||||
},
|
||||
gaufre: {
|
||||
widgetPath: `'${themesGaufre[theme].widgetPath}'`,
|
||||
apiUrl: `'${themesGaufre[theme].apiUrl}'`,
|
||||
},
|
||||
favicon: {
|
||||
src: `'${themesImages[theme].favicon}'`,
|
||||
},
|
||||
logo: {
|
||||
src: `url('${themesImages[theme].logo}')`,
|
||||
},
|
||||
"logo-icon": {
|
||||
src: `url('${themesImages[theme]["logo-icon"]}')`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const defaultConfig = deepMerge(cunninghamConfig, {
|
||||
themes: {
|
||||
anct: {
|
||||
globals: {
|
||||
colors: {
|
||||
"brand-050": "#EEF0F9",
|
||||
"brand-100": "#DDE2F5",
|
||||
"brand-150": "#CBD4F1",
|
||||
"brand-200": "#B8C6F0",
|
||||
"brand-250": "#A5B7F2",
|
||||
"brand-300": "#91A8F7",
|
||||
"brand-350": "#7C98FE",
|
||||
"brand-400": "#6A89FF",
|
||||
"brand-450": "#5A7AFB",
|
||||
"brand-500": "#4B6BF0",
|
||||
"brand-550": "#3E5CE7",
|
||||
"brand-600": "#3352D5",
|
||||
"brand-650": "#2A47C0",
|
||||
"brand-700": "#2340A3",
|
||||
"brand-750": "#223A7F",
|
||||
"brand-800": "#1F325F",
|
||||
"brand-850": "#1B2845",
|
||||
"brand-900": "#151E30",
|
||||
"brand-950": "#0D121D",
|
||||
"gray-000": "#FFFFFF",
|
||||
"gray-025": "#F6F8FA",
|
||||
"gray-050": "#ECF1F7",
|
||||
"gray-100": "#DDE3F3",
|
||||
"gray-150": "#CCD4EA",
|
||||
"gray-200": "#BCC7E0",
|
||||
"gray-250": "#AEB9D2",
|
||||
"gray-300": "#A1ABC4",
|
||||
"gray-350": "#949EB6",
|
||||
"gray-400": "#8791A9",
|
||||
"gray-450": "#7A849B",
|
||||
"gray-500": "#6D778E",
|
||||
"gray-550": "#616A81",
|
||||
"gray-600": "#555E75",
|
||||
"gray-650": "#495268",
|
||||
"gray-700": "#3E475C",
|
||||
"gray-750": "#333B50",
|
||||
"gray-800": "#283044",
|
||||
"gray-850": "#1E2539",
|
||||
"gray-900": "#171B28",
|
||||
"gray-950": "#0F1118",
|
||||
"gray-1000": "#000000",
|
||||
"info-050": "#E9F2F8",
|
||||
"info-100": "#D2E5F1",
|
||||
"info-150": "#C0D7F0",
|
||||
"info-200": "#AEC8F0",
|
||||
"info-250": "#9EB9F2",
|
||||
"info-300": "#8DABEE",
|
||||
"info-350": "#7B9DE9",
|
||||
"info-400": "#6E8FDB",
|
||||
"info-450": "#6282CD",
|
||||
"info-500": "#5575BF",
|
||||
"info-550": "#4968B1",
|
||||
"info-600": "#3E5CA3",
|
||||
"info-650": "#344F97",
|
||||
"info-700": "#294389",
|
||||
"info-750": "#243972",
|
||||
"info-800": "#1D2F5B",
|
||||
"info-850": "#1A2744",
|
||||
"info-900": "#151D2F",
|
||||
"info-950": "#0E131F",
|
||||
"success-050": "#E7F1E9",
|
||||
"success-100": "#CFE3D3",
|
||||
"success-150": "#B9D8C0",
|
||||
"success-200": "#A1CEAC",
|
||||
"success-250": "#85C496",
|
||||
"success-300": "#63BC7F",
|
||||
"success-350": "#45B16B",
|
||||
"success-400": "#1CA659",
|
||||
"success-450": "#00984C",
|
||||
"success-500": "#008A3F",
|
||||
"success-550": "#007C32",
|
||||
"success-600": "#006E24",
|
||||
"success-650": "#016016",
|
||||
"success-700": "#005305",
|
||||
"success-750": "#0D450A",
|
||||
"success-800": "#11380E",
|
||||
"success-850": "#132A11",
|
||||
"success-900": "#101E0F",
|
||||
"success-950": "#091209",
|
||||
"warning-050": "#F6F0E8",
|
||||
"warning-100": "#EDE2D1",
|
||||
"warning-150": "#E6D3B8",
|
||||
"warning-200": "#E3C39F",
|
||||
"warning-250": "#E3B082",
|
||||
"warning-300": "#E19E5C",
|
||||
"warning-350": "#D98E3F",
|
||||
"warning-400": "#CF7D19",
|
||||
"warning-450": "#C17000",
|
||||
"warning-500": "#B36300",
|
||||
"warning-550": "#A45600",
|
||||
"warning-600": "#964900",
|
||||
"warning-650": "#893C00",
|
||||
"warning-700": "#7B2F00",
|
||||
"warning-750": "#68270D",
|
||||
"warning-800": "#562013",
|
||||
"warning-850": "#411D18",
|
||||
"warning-900": "#2E1714",
|
||||
"warning-950": "#1D0F0D",
|
||||
"error-050": "#F9EFEC",
|
||||
"error-100": "#F4DFD9",
|
||||
"error-150": "#F0CEC6",
|
||||
"error-200": "#EEBCB2",
|
||||
"error-250": "#EEA99D",
|
||||
"error-300": "#EF9487",
|
||||
"error-350": "#F37C6E",
|
||||
"error-400": "#F65F53",
|
||||
"error-450": "#EF443C",
|
||||
"error-500": "#E0342E",
|
||||
"error-550": "#D0201F",
|
||||
"error-600": "#C0000C",
|
||||
"error-650": "#AA0000",
|
||||
"error-700": "#910C06",
|
||||
"error-750": "#731E16",
|
||||
"error-800": "#58201A",
|
||||
"error-850": "#411D18",
|
||||
"error-900": "#2E1714",
|
||||
"error-950": "#1D0F0D",
|
||||
"red-050": "#F9EFEC",
|
||||
"red-100": "#F4DEDA",
|
||||
"red-150": "#F0CDC9",
|
||||
"red-200": "#EEBBB6",
|
||||
"red-250": "#EEA8A2",
|
||||
"red-300": "#F0938D",
|
||||
"red-350": "#EC7E78",
|
||||
"red-400": "#E46D67",
|
||||
"red-450": "#D95B58",
|
||||
"red-500": "#CA4E4B",
|
||||
"red-550": "#BB403F",
|
||||
"red-600": "#AC3233",
|
||||
"red-650": "#9D2227",
|
||||
"red-700": "#882023",
|
||||
"red-750": "#721D1B",
|
||||
"red-800": "#58201A",
|
||||
"red-850": "#401D18",
|
||||
"red-900": "#2E1714",
|
||||
"red-950": "#1D0F0D",
|
||||
"orange-050": "#F8F0E9",
|
||||
"orange-100": "#F1E0D3",
|
||||
"orange-150": "#ECD0BD",
|
||||
"orange-200": "#E9C0A5",
|
||||
"orange-250": "#E8AE8A",
|
||||
"orange-300": "#EB9870",
|
||||
"orange-350": "#EB845A",
|
||||
"orange-400": "#E66E37",
|
||||
"orange-450": "#DD5B16",
|
||||
"orange-500": "#CE4D00",
|
||||
"orange-550": "#BF3E00",
|
||||
"orange-600": "#B02F00",
|
||||
"orange-650": "#A11E00",
|
||||
"orange-700": "#8A1E14",
|
||||
"orange-750": "#731E16",
|
||||
"orange-800": "#58201A",
|
||||
"orange-850": "#401D18",
|
||||
"orange-900": "#2E1714",
|
||||
"orange-950": "#1D0F0D",
|
||||
"brown-050": "#F5F0E8",
|
||||
"brown-100": "#ECE2D1",
|
||||
"brown-150": "#E9D1B9",
|
||||
"brown-200": "#E3C19D",
|
||||
"brown-250": "#DCB187",
|
||||
"brown-300": "#D2A26F",
|
||||
"brown-350": "#C49562",
|
||||
"brown-400": "#B68855",
|
||||
"brown-450": "#A97B48",
|
||||
"brown-500": "#9B6E3B",
|
||||
"brown-550": "#8E612F",
|
||||
"brown-600": "#815521",
|
||||
"brown-650": "#744913",
|
||||
"brown-700": "#673D00",
|
||||
"brown-750": "#5A3100",
|
||||
"brown-800": "#4E2600",
|
||||
"brown-850": "#3D1F0B",
|
||||
"brown-900": "#2C170F",
|
||||
"brown-950": "#1C0F0B",
|
||||
"yellow-050": "#F3F0E7",
|
||||
"yellow-100": "#E9E2CF",
|
||||
"yellow-150": "#E0D4B7",
|
||||
"yellow-200": "#DAC59A",
|
||||
"yellow-250": "#D5B67A",
|
||||
"yellow-300": "#D0A559",
|
||||
"yellow-350": "#CC9331",
|
||||
"yellow-400": "#C48400",
|
||||
"yellow-450": "#B77600",
|
||||
"yellow-500": "#AA6800",
|
||||
"yellow-550": "#9D5A00",
|
||||
"yellow-600": "#914D00",
|
||||
"yellow-650": "#843F00",
|
||||
"yellow-700": "#773200",
|
||||
"yellow-750": "#6A2601",
|
||||
"yellow-800": "#56210F",
|
||||
"yellow-850": "#401D16",
|
||||
"yellow-900": "#2E1714",
|
||||
"yellow-950": "#1D0F0D",
|
||||
"green-050": "#E3F1EF",
|
||||
"green-100": "#CAE5E1",
|
||||
"green-150": "#B0DBD4",
|
||||
"green-200": "#91D1C7",
|
||||
"green-250": "#6AC8BC",
|
||||
"green-300": "#4DBCAF",
|
||||
"green-350": "#3CAFA2",
|
||||
"green-400": "#2AA194",
|
||||
"green-450": "#109487",
|
||||
"green-500": "#00867A",
|
||||
"green-550": "#00786D",
|
||||
"green-600": "#016A60",
|
||||
"green-650": "#015D53",
|
||||
"green-700": "#005047",
|
||||
"green-750": "#00443B",
|
||||
"green-800": "#00382F",
|
||||
"green-850": "#002C25",
|
||||
"green-900": "#041F1A",
|
||||
"green-950": "#041310",
|
||||
"blue1-050": "#EAF2F9",
|
||||
"blue1-100": "#D4E4F3",
|
||||
"blue1-150": "#BFD7F0",
|
||||
"blue1-200": "#AAC9EF",
|
||||
"blue1-250": "#96BBF1",
|
||||
"blue1-300": "#82ACF6",
|
||||
"blue1-350": "#709BFE",
|
||||
"blue1-400": "#608BFF",
|
||||
"blue1-450": "#537BFB",
|
||||
"blue1-500": "#476DEC",
|
||||
"blue1-550": "#3C60DD",
|
||||
"blue1-600": "#3252CF",
|
||||
"blue1-650": "#2B48B9",
|
||||
"blue1-700": "#28409B",
|
||||
"blue1-750": "#24397E",
|
||||
"blue1-800": "#223260",
|
||||
"blue1-850": "#1F2A48",
|
||||
"blue1-900": "#191F32",
|
||||
"blue1-950": "#111320",
|
||||
"blue2-050": "#E5F2F3",
|
||||
"blue2-100": "#CDE6EC",
|
||||
"blue2-150": "#B7D9EA",
|
||||
"blue2-200": "#9BCDE7",
|
||||
"blue2-250": "#84C1E0",
|
||||
"blue2-300": "#6BB4D8",
|
||||
"blue2-350": "#5CA6CB",
|
||||
"blue2-400": "#4D99BC",
|
||||
"blue2-450": "#3E8CAE",
|
||||
"blue2-500": "#2F7FA2",
|
||||
"blue2-550": "#1F7295",
|
||||
"blue2-600": "#056688",
|
||||
"blue2-650": "#00597B",
|
||||
"blue2-700": "#004C6C",
|
||||
"blue2-750": "#003F5E",
|
||||
"blue2-800": "#003353",
|
||||
"blue2-850": "#0C273E",
|
||||
"blue2-900": "#0E1C2C",
|
||||
"blue2-950": "#0A121C",
|
||||
"purple-050": "#EDF1FA",
|
||||
"purple-100": "#DCE2F5",
|
||||
"purple-150": "#CCD4F1",
|
||||
"purple-200": "#BCC4F0",
|
||||
"purple-250": "#ADB6F2",
|
||||
"purple-300": "#9EA6F6",
|
||||
"purple-350": "#8E95FD",
|
||||
"purple-400": "#8083FF",
|
||||
"purple-450": "#7173FF",
|
||||
"purple-500": "#6665F1",
|
||||
"purple-550": "#5B57E2",
|
||||
"purple-600": "#5049D4",
|
||||
"purple-650": "#4641BC",
|
||||
"purple-700": "#3D39A2",
|
||||
"purple-750": "#363680",
|
||||
"purple-800": "#2E3162",
|
||||
"purple-850": "#242848",
|
||||
"purple-900": "#1C1E32",
|
||||
"purple-950": "#121320",
|
||||
"pink-050": "#F8EFF4",
|
||||
"pink-100": "#F0DEE9",
|
||||
"pink-150": "#EBCDDF",
|
||||
"pink-200": "#E7BDD6",
|
||||
"pink-250": "#E5A9CC",
|
||||
"pink-300": "#E695C0",
|
||||
"pink-350": "#EA7CAE",
|
||||
"pink-400": "#E4659F",
|
||||
"pink-450": "#DD4F93",
|
||||
"pink-500": "#CD4085",
|
||||
"pink-550": "#BE3279",
|
||||
"pink-600": "#AE216D",
|
||||
"pink-650": "#9B195D",
|
||||
"pink-700": "#86164E",
|
||||
"pink-750": "#6E1B3D",
|
||||
"pink-800": "#551E31",
|
||||
"pink-850": "#3F1C24",
|
||||
"pink-900": "#2D161A",
|
||||
"pink-950": "#1C0E10",
|
||||
},
|
||||
},
|
||||
components: getComponents("anct"),
|
||||
},
|
||||
dark: {
|
||||
globals: cunninghamConfig.themes.default.globals,
|
||||
components: getComponents("dark"),
|
||||
},
|
||||
default: {
|
||||
components: getComponents("default"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = defaultConfig;
|
||||
|
||||
export default config;
|
||||
22
src/frontend/apps/calendars/eslint.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
39
src/frontend/apps/calendars/jest.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Config } from "jest";
|
||||
import { pathsToModuleNameMapper } from "ts-jest";
|
||||
import tsconfig from "./tsconfig.json";
|
||||
|
||||
const config: Config = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src"],
|
||||
testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"],
|
||||
moduleNameMapper: {
|
||||
// Handle static assets FIRST (before path aliases)
|
||||
"\\.(css|less|scss|sass|svg|png|jpg|jpeg|gif)$":
|
||||
"<rootDir>/__mocks__/fileMock.js",
|
||||
// Then handle path aliases
|
||||
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
}),
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": [
|
||||
"ts-jest",
|
||||
{
|
||||
tsconfig: {
|
||||
jsx: "react",
|
||||
moduleResolution: "node",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ["node_modules/(?!(.*\\.mjs$))"],
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "svg"],
|
||||
collectCoverageFrom: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"!src/**/*.d.ts",
|
||||
"!src/**/__tests__/**",
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
src/frontend/apps/calendars/next.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
reactStrictMode: false,
|
||||
webpack: (config, { isServer }) => {
|
||||
// Resolve workspace packages
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"open-dav-calendar": path.resolve(
|
||||
__dirname,
|
||||
"../../packages/open-calendar/dist/index.js"
|
||||
),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
54
src/frontend/apps/calendars/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "calendars",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build --no-lint",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"build-theme": "cunningham -g css,scss,ts -o src/styles && mv src/styles/cunningham-tokens.scss src/styles/cunningham-tokens-sass.scss",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <25.0.0",
|
||||
"yarn": "1.22.22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.4",
|
||||
"@openfun/cunningham-react": "4.0.0",
|
||||
"open-dav-calendar": "*",
|
||||
"date-fns": "4.1.0",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@viselect/react": "3.9.0",
|
||||
"clsx": "2.1.1",
|
||||
"i18next": "25.6.2",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"next": "15.4.9",
|
||||
"next-i18next": "15.4.2",
|
||||
"pretty-bytes": "7.1.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dropzone": "14.3.8",
|
||||
"react-hook-form": "7.66.0",
|
||||
"react-i18next": "16.3.3",
|
||||
"react-toastify": "11.0.5",
|
||||
"sass": "1.94.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "5.66.9",
|
||||
"@eslint/eslintrc": "3.2.0",
|
||||
"@tanstack/eslint-plugin-query": "5.66.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "19.2.5",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "9.20.1",
|
||||
"eslint-config-next": "15.1.7",
|
||||
"jest": "29.7.0",
|
||||
"ts-jest": "29.2.5",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
BIN
src/frontend/apps/calendars/public/assets/401-background.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
src/frontend/apps/calendars/public/assets/403-background.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
src/frontend/apps/calendars/public/assets/anct_favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
16
src/frontend/apps/calendars/public/assets/anct_logo-icon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.6765 9.72627H8.41971C6.23248 9.72627 4.03303 11.4473 3.50711 13.5704L2 19.6543V7.84882C2 6.57794 2.29141 5.62077 2.87422 4.97729C3.46451 4.32576 4.28269 4 5.32877 4H8.50063C8.88917 4 9.22541 4.02815 9.50935 4.08446C9.79328 4.14076 10.0548 4.23728 10.2939 4.37402C10.533 4.50272 10.7833 4.6837 11.0448 4.91696L11.6837 5.48403C11.9975 5.74946 12.2927 5.93848 12.5691 6.05109C12.8456 6.1637 13.1856 6.22001 13.5891 6.22001H24.0125C25.2155 6.22001 26.1271 6.55381 26.7472 7.22142C27.3035 7.81312 27.6133 8.64807 27.6765 9.72627Z" fill="#FBC63A"/>
|
||||
<path d="M5.67656 27C4.46609 27 3.6366 26.6536 3.18806 25.9607C2.73746 25.2761 2.6776 24.266 3.00848 22.9303L5.32709 13.5705C5.61138 12.4229 6.80027 11.4926 7.98256 11.4926H29.3898C30.5721 11.4926 31.3001 12.4229 31.0158 13.5705L28.6972 22.9303C28.3663 24.266 27.8172 25.2761 27.0499 25.9607C26.2805 26.6536 25.3615 27 24.293 27H5.67656Z" fill="#2845C1"/>
|
||||
<rect x="4" y="23" width="24" height="9" rx="4.5" fill="#ECF1F7"/>
|
||||
<rect x="4" y="23" width="24" height="9" rx="4.5" fill="url(#paint0_linear_1156_814)" fill-opacity="0.1"/>
|
||||
<path d="M8.34245 29.7362C8.29257 29.7362 8.26763 29.7362 8.24858 29.7265C8.23182 29.718 8.2182 29.7044 8.20966 29.6876C8.19995 29.6686 8.19995 29.6436 8.19995 29.5937V25.2425C8.19995 25.1926 8.19995 25.1677 8.20966 25.1486C8.2182 25.1318 8.23182 25.1182 8.24858 25.1097C8.26763 25.1 8.29257 25.1 8.34245 25.1H9.56434C10.5313 25.1 11.1208 25.5768 11.1208 26.3584C11.1208 26.648 11.0195 26.9064 10.8257 27.1068C10.7572 27.1777 10.7229 27.2131 10.7171 27.2364C10.7109 27.2615 10.7123 27.274 10.7237 27.2972C10.7343 27.3187 10.7739 27.3451 10.8532 27.3978C11.1981 27.6275 11.3857 27.9699 11.3857 28.3719C11.3857 29.2196 10.73 29.7362 9.66369 29.7362H8.34245ZM9.58421 25.8815H9.28295C9.23307 25.8815 9.20813 25.8815 9.18908 25.8912C9.17232 25.8998 9.1587 25.9134 9.15016 25.9301C9.14045 25.9492 9.14045 25.9741 9.14045 26.024V26.7855C9.14045 26.8354 9.14045 26.8603 9.15016 26.8794C9.1587 26.8961 9.17232 26.9097 9.18908 26.9183C9.20813 26.928 9.23307 26.928 9.28295 26.928H9.58421C9.94186 26.928 10.1538 26.7359 10.1538 26.3981C10.1538 26.0736 9.94186 25.8815 9.58421 25.8815ZM9.71667 27.7095H9.28295C9.23307 27.7095 9.20813 27.7095 9.18908 27.7192C9.17232 27.7278 9.1587 27.7414 9.15016 27.7582C9.14045 27.7772 9.14045 27.8022 9.14045 27.852V28.8122C9.14045 28.8621 9.14045 28.887 9.15016 28.9061C9.1587 28.9228 9.17232 28.9365 9.18908 28.945C9.20813 28.9547 9.23307 28.9547 9.28295 28.9547H9.71667C10.1538 28.9547 10.4187 28.7229 10.4187 28.3321C10.4187 27.9347 10.1538 27.7095 9.71667 27.7095Z" fill="#2845C1"/>
|
||||
<path d="M12.4271 29.7362C12.3772 29.7362 12.3523 29.7362 12.3333 29.7265C12.3165 29.718 12.3029 29.7044 12.2943 29.6876C12.2846 29.6686 12.2846 29.6436 12.2846 29.5937V25.2425C12.2846 25.1926 12.2846 25.1677 12.2943 25.1486C12.3029 25.1318 12.3165 25.1182 12.3333 25.1097C12.3523 25.1 12.3772 25.1 12.4271 25.1H14.8444C14.8943 25.1 14.9192 25.1 14.9383 25.1097C14.955 25.1182 14.9687 25.1318 14.9772 25.1486C14.9869 25.1677 14.9869 25.1926 14.9869 25.2425V25.739C14.9869 25.7889 14.9869 25.8138 14.9772 25.8329C14.9687 25.8496 14.955 25.8633 14.9383 25.8718C14.9192 25.8815 14.8943 25.8815 14.8444 25.8815H13.3676C13.3177 25.8815 13.2928 25.8815 13.2738 25.8912C13.257 25.8998 13.2434 25.9134 13.2348 25.9301C13.2251 25.9492 13.2251 25.9741 13.2251 26.024V26.8385C13.2251 26.8884 13.2251 26.9133 13.2348 26.9323C13.2434 26.9491 13.257 26.9627 13.2738 26.9713C13.2928 26.981 13.3177 26.981 13.3676 26.981H14.5795C14.6294 26.981 14.6543 26.981 14.6733 26.9907C14.6901 26.9992 14.7037 27.0128 14.7123 27.0296C14.722 27.0487 14.722 27.0736 14.722 27.1235V27.62C14.722 27.6699 14.722 27.6948 14.7123 27.7139C14.7037 27.7306 14.6901 27.7443 14.6733 27.7528C14.6543 27.7625 14.6294 27.7625 14.5795 27.7625H13.3676C13.3177 27.7625 13.2928 27.7625 13.2738 27.7722C13.257 27.7808 13.2434 27.7944 13.2348 27.8111C13.2251 27.8302 13.2251 27.8551 13.2251 27.905V28.8122C13.2251 28.8621 13.2251 28.887 13.2348 28.9061C13.2434 28.9228 13.257 28.9365 13.2738 28.945C13.2928 28.9547 13.3177 28.9547 13.3676 28.9547H14.8444C14.8943 28.9547 14.9192 28.9547 14.9383 28.9644C14.955 28.9729 14.9687 28.9866 14.9772 29.0033C14.9869 29.0224 14.9869 29.0473 14.9869 29.0972V29.5937C14.9869 29.6436 14.9869 29.6686 14.9772 29.6876C14.9687 29.7044 14.955 29.718 14.9383 29.7265C14.9192 29.7362 14.8943 29.7362 14.8444 29.7362H12.4271Z" fill="#2845C1"/>
|
||||
<path d="M15.7762 25.9544C15.7263 25.9544 15.7014 25.9544 15.6823 25.9447C15.6656 25.9361 15.652 25.9225 15.6434 25.9057C15.6337 25.8867 15.6337 25.8618 15.6337 25.8119V25.2425C15.6337 25.1926 15.6337 25.1677 15.6434 25.1486C15.652 25.1318 15.6656 25.1182 15.6823 25.1097C15.7014 25.1 15.7263 25.1 15.7762 25.1H19.2929C19.3428 25.1 19.3678 25.1 19.3868 25.1097C19.4036 25.1182 19.4172 25.1318 19.4257 25.1486C19.4354 25.1677 19.4354 25.1926 19.4354 25.2425V25.8119C19.4354 25.8618 19.4354 25.8867 19.4257 25.9057C19.4172 25.9225 19.4036 25.9361 19.3868 25.9447C19.3678 25.9544 19.3428 25.9544 19.2929 25.9544H18.1473C18.0974 25.9544 18.0725 25.9544 18.0535 25.9641C18.0367 25.9726 18.0231 25.9862 18.0145 26.003C18.0048 26.0221 18.0048 26.047 18.0048 26.0969V29.5937C18.0048 29.6436 18.0048 29.6686 17.9951 29.6876C17.9866 29.7044 17.973 29.718 17.9562 29.7265C17.9371 29.7362 17.9122 29.7362 17.8623 29.7362H17.2068C17.1569 29.7362 17.132 29.7362 17.113 29.7265C17.0962 29.718 17.0826 29.7044 17.074 29.6876C17.0643 29.6686 17.0643 29.6436 17.0643 29.5937V26.0969C17.0643 26.047 17.0643 26.0221 17.0546 26.003C17.0461 25.9862 17.0325 25.9726 17.0157 25.9641C16.9966 25.9544 16.9717 25.9544 16.9218 25.9544H15.7762Z" fill="#2845C1"/>
|
||||
<path d="M19.3724 29.7362C19.3031 29.7362 19.2685 29.7362 19.2463 29.7217C19.2268 29.709 19.2131 29.6891 19.2081 29.6664C19.2024 29.6405 19.2146 29.6081 19.2392 29.5433L20.8864 25.192C20.899 25.1588 20.9053 25.1422 20.9161 25.13C20.9257 25.1192 20.9377 25.1109 20.9512 25.1058C20.9665 25.1 20.9842 25.1 21.0197 25.1H22.0548C22.0903 25.1 22.108 25.1 22.1233 25.1058C22.1368 25.1109 22.1488 25.1192 22.1584 25.13C22.1692 25.1422 22.1755 25.1588 22.188 25.192L23.8353 29.5433C23.8598 29.6081 23.8721 29.6405 23.8664 29.6664C23.8613 29.6891 23.8476 29.709 23.8282 29.7217C23.806 29.7362 23.7713 29.7362 23.702 29.7362H23.0076C22.9718 29.7362 22.9539 29.7362 22.9385 29.7304C22.925 29.7252 22.9129 29.7167 22.9033 29.7058C22.8925 29.6934 22.8863 29.6766 22.8739 29.643L22.4988 28.6241C22.4865 28.5905 22.4803 28.5737 22.4694 28.5613C22.4599 28.5503 22.4478 28.5419 22.4342 28.5367C22.4188 28.5308 22.4009 28.5308 22.3651 28.5308H20.7094C20.6736 28.5308 20.6556 28.5308 20.6403 28.5367C20.6267 28.5419 20.6146 28.5503 20.605 28.5613C20.5942 28.5737 20.588 28.5905 20.5757 28.6241L20.2006 29.643C20.1882 29.6766 20.182 29.6934 20.1712 29.7058C20.1616 29.7167 20.1495 29.7252 20.1359 29.7304C20.1206 29.7362 20.1027 29.7362 20.0668 29.7362H19.3724ZM20.9713 27.538C20.9478 27.6024 20.936 27.6346 20.9419 27.6603C20.9471 27.6828 20.9608 27.7024 20.9802 27.715C21.0023 27.7294 21.0366 27.7294 21.1051 27.7294H21.9694C22.0379 27.7294 22.0721 27.7294 22.0943 27.715C22.1136 27.7024 22.1274 27.6828 22.1325 27.6603C22.1385 27.6346 22.1267 27.6024 22.1032 27.538L21.6711 26.3542C21.6298 26.2411 21.6092 26.1846 21.579 26.1686C21.5529 26.1547 21.5216 26.1547 21.4955 26.1686C21.4653 26.1846 21.4447 26.2411 21.4034 26.3542L20.9713 27.538Z" fill="#2845C1"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1156_814" x1="16" y1="23" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2845C1"/>
|
||||
<stop offset="1" stop-color="#ECF1F7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="177" height="40" viewBox="0 0 177 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M31.6765 13.7263H12.4197C10.2325 13.7263 8.03303 15.4473 7.50711 17.5704L6 23.6543V11.8488C6 10.5779 6.29141 9.62077 6.87422 8.97729C7.46451 8.32576 8.28269 8 9.32877 8H12.5006C12.8892 8 13.2254 8.02815 13.5093 8.08446C13.7933 8.14076 14.0548 8.23728 14.2939 8.37402C14.533 8.50272 14.7833 8.6837 15.0448 8.91696L15.6837 9.48403C15.9975 9.74946 16.2927 9.93848 16.5691 10.0511C16.8456 10.1637 17.1856 10.22 17.5891 10.22H28.0125C29.2155 10.22 30.1271 10.5538 30.7472 11.2214C31.3035 11.8131 31.6133 12.6481 31.6765 13.7263Z" fill="#FCC73B"/>
|
||||
<path d="M9.67656 31.0001C8.46609 31.0001 7.6366 30.6537 7.18806 29.9608C6.73746 29.2762 6.6776 28.2661 7.00848 26.9304L9.32709 17.5706C9.61138 16.423 10.8003 15.4927 11.9826 15.4927H33.3898C34.5721 15.4927 35.3001 16.423 35.0158 17.5706L32.6972 26.9304C32.3663 28.2661 31.8172 29.2762 31.0499 29.9608C30.2805 30.6537 29.3615 31.0001 28.293 31.0001H9.67656Z" fill="#000091"/>
|
||||
<path d="M42.266 28V12.6H51.242V15.196H45.39V18.848H50.362V21.444H45.39V28H42.266ZM54.5853 14.976C53.5953 14.976 52.7593 14.14 52.7593 13.15C52.7593 12.16 53.5953 11.324 54.5853 11.324C55.5753 11.324 56.3893 12.16 56.3893 13.15C56.3893 14.14 55.5753 14.976 54.5853 14.976ZM53.1773 28V16.912H55.9713V28H53.1773ZM64.2058 25.8C65.3058 25.8 66.1858 25.294 66.7138 24.524L68.8918 26.196C67.8578 27.56 66.2298 28.44 64.2058 28.44C60.3778 28.44 58.0898 25.69 58.0898 22.456C58.0898 19.222 60.3778 16.472 64.2058 16.472C66.2298 16.472 67.8578 17.352 68.8918 18.716L66.7138 20.388C66.1858 19.618 65.3058 19.112 64.1618 19.112C62.3358 19.112 60.9938 20.52 60.9938 22.456C60.9938 24.414 62.3358 25.8 64.2058 25.8ZM70.5375 28V11.5H73.3315V17.792C74.1015 17.044 75.1355 16.472 76.6095 16.472C79.0075 16.472 80.9215 18.122 80.9215 21.4V28H78.1055V21.51C78.1055 20.036 77.2915 19.112 75.9055 19.112C74.4975 19.112 73.7495 20.058 73.3315 20.762V28H70.5375ZM85.0622 14.976C84.0722 14.976 83.2362 14.14 83.2362 13.15C83.2362 12.16 84.0722 11.324 85.0622 11.324C86.0522 11.324 86.8662 12.16 86.8662 13.15C86.8662 14.14 86.0522 14.976 85.0622 14.976ZM83.6542 28V16.912H86.4482V28H83.6542ZM99.5667 26.196C98.5327 27.582 96.8167 28.44 94.7267 28.44C90.7887 28.44 88.5667 25.69 88.5667 22.456C88.5667 19.178 90.6347 16.472 94.2647 16.472C97.3447 16.472 99.3687 18.562 99.3687 21.466C99.3687 22.082 99.2807 22.654 99.1927 23.028H91.4267C91.6907 25.096 92.9667 25.932 94.7047 25.932C95.9147 25.932 96.9707 25.404 97.5647 24.612L99.5667 26.196ZM94.1987 18.76C92.7687 18.76 91.8227 19.552 91.5147 21.004H96.6187C96.5747 19.882 95.7607 18.76 94.1987 18.76ZM101.659 28V16.912H104.453V18.012C105.179 17.264 106.125 16.692 107.379 16.692C107.753 16.692 108.083 16.758 108.347 16.846V19.596C107.995 19.508 107.621 19.442 107.115 19.442C105.751 19.442 104.871 20.19 104.453 20.894V28H101.659ZM109.188 26.394L111.036 24.722C111.718 25.58 112.532 26.196 113.588 26.196C114.49 26.196 114.952 25.668 114.952 25.008C114.952 23.072 109.65 23.798 109.65 19.904C109.65 17.946 111.3 16.472 113.61 16.472C115.304 16.472 116.844 17.286 117.636 18.364L115.788 19.992C115.216 19.288 114.49 18.716 113.632 18.716C112.752 18.716 112.334 19.2 112.334 19.772C112.334 21.664 117.636 21.004 117.636 24.832C117.592 27.164 115.722 28.44 113.632 28.44C111.652 28.44 110.244 27.648 109.188 26.394Z" fill="#000091"/>
|
||||
<rect x="123" y="12" width="50" height="16" rx="8" fill="#E0E0FF"/>
|
||||
<path d="M129.165 24L132.08 16.3H134.126L137.041 24H135.38L134.643 21.998H131.563L130.826 24H129.165ZM132.047 20.667H134.159L133.103 17.774L132.047 20.667ZM138.12 24V16.3H139.682V22.581H142.608V24H138.12ZM143.873 24V16.3H146.414C148.108 16.3 149.142 17.169 149.142 18.599C149.142 20.018 148.108 20.887 146.414 20.887H145.435V24H143.873ZM146.48 17.598H145.435V19.589H146.48C147.14 19.589 147.536 19.204 147.536 18.577C147.536 17.994 147.14 17.598 146.48 17.598ZM150.324 24V16.3H151.886V19.369H155.769V16.3H157.331V24H155.769V20.788H151.886V24H150.324ZM158.406 24L161.321 16.3H163.367L166.282 24H164.621L163.884 21.998H160.804L160.067 24H158.406ZM161.288 20.667H163.4L162.344 17.774L161.288 20.667Z" fill="#5151BB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
26
src/frontend/apps/calendars/public/assets/anct_logo_beta.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg width="126" height="40" viewBox="0 0 126 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M31.6765 13.7263H12.4197C10.2325 13.7263 8.03303 15.4473 7.50711 17.5704L6 23.6543V11.8488C6 10.5779 6.29141 9.62077 6.87422 8.97729C7.46451 8.32576 8.28269 8 9.32877 8H12.5006C12.8892 8 13.2254 8.02815 13.5093 8.08446C13.7933 8.14076 14.0548 8.23728 14.2939 8.37402C14.533 8.50272 14.7833 8.6837 15.0448 8.91696L15.6837 9.48403C15.9975 9.74946 16.2927 9.93848 16.5691 10.0511C16.8456 10.1637 17.1856 10.22 17.5891 10.22H28.0125C29.2155 10.22 30.1271 10.5538 30.7472 11.2214C31.3035 11.8131 31.6133 12.6481 31.6765 13.7263Z" fill="#FBC63A"/>
|
||||
<path d="M9.67656 31.0001C8.46609 31.0001 7.6366 30.6537 7.18806 29.9608C6.73746 29.2762 6.6776 28.2661 7.00848 26.9304L9.32709 17.5706C9.61138 16.423 10.8003 15.4927 11.9826 15.4927H33.3898C34.5721 15.4927 35.3001 16.423 35.0158 17.5706L32.6972 26.9304C32.3663 28.2661 31.8172 29.2762 31.0499 29.9608C30.2805 30.6537 29.3615 31.0001 28.293 31.0001H9.67656Z" fill="#2845C1"/>
|
||||
<rect x="8" y="27" width="24" height="9" rx="4.5" fill="#ECF1F7"/>
|
||||
<rect x="8" y="27" width="24" height="9" rx="4.5" fill="url(#paint0_linear_3435_94)" fill-opacity="0.1"/>
|
||||
<path d="M12.3425 33.7364C12.2926 33.7364 12.2676 33.7364 12.2486 33.7267C12.2318 33.7181 12.2182 33.7045 12.2097 33.6877C12.2 33.6687 12.2 33.6437 12.2 33.5939V29.2426C12.2 29.1927 12.2 29.1678 12.2097 29.1487C12.2182 29.132 12.2318 29.1183 12.2486 29.1098C12.2676 29.1001 12.2926 29.1001 12.3425 29.1001H13.5643C14.5313 29.1001 15.1208 29.577 15.1208 30.3585C15.1208 30.6481 15.0195 30.9066 14.8257 31.1069C14.7572 31.1778 14.7229 31.2132 14.7171 31.2365C14.7109 31.2616 14.7123 31.2741 14.7237 31.2973C14.7343 31.3188 14.7739 31.3452 14.8532 31.398C15.1981 31.6276 15.3857 31.9701 15.3857 32.372C15.3857 33.2198 14.73 33.7364 13.6637 33.7364H12.3425ZM13.5842 29.8816H13.283C13.2331 29.8816 13.2081 29.8816 13.1891 29.8913C13.1723 29.8999 13.1587 29.9135 13.1502 29.9303C13.1405 29.9493 13.1405 29.9743 13.1405 30.0241V30.7856C13.1405 30.8355 13.1405 30.8604 13.1502 30.8795C13.1587 30.8962 13.1723 30.9099 13.1891 30.9184C13.2081 30.9281 13.2331 30.9281 13.283 30.9281H13.5842C13.9419 30.9281 14.1538 30.736 14.1538 30.3983C14.1538 30.0737 13.9419 29.8816 13.5842 29.8816ZM13.7167 31.7097H13.283C13.2331 31.7097 13.2081 31.7097 13.1891 31.7194C13.1723 31.7279 13.1587 31.7415 13.1502 31.7583C13.1405 31.7773 13.1405 31.8023 13.1405 31.8522V32.8123C13.1405 32.8622 13.1405 32.8871 13.1502 32.9062C13.1587 32.923 13.1723 32.9366 13.1891 32.9451C13.2081 32.9548 13.2331 32.9548 13.283 32.9548H13.7167C14.1538 32.9548 14.4187 32.723 14.4187 32.3322C14.4187 31.9348 14.1538 31.7097 13.7167 31.7097Z" fill="#2845C1"/>
|
||||
<path d="M16.4271 33.7364C16.3772 33.7364 16.3523 33.7364 16.3333 33.7267C16.3165 33.7181 16.3029 33.7045 16.2943 33.6877C16.2846 33.6687 16.2846 33.6437 16.2846 33.5939V29.2426C16.2846 29.1927 16.2846 29.1678 16.2943 29.1487C16.3029 29.132 16.3165 29.1183 16.3333 29.1098C16.3523 29.1001 16.3772 29.1001 16.4271 29.1001H18.8444C18.8943 29.1001 18.9192 29.1001 18.9383 29.1098C18.955 29.1183 18.9687 29.132 18.9772 29.1487C18.9869 29.1678 18.9869 29.1927 18.9869 29.2426V29.7391C18.9869 29.789 18.9869 29.814 18.9772 29.833C18.9687 29.8498 18.955 29.8634 18.9383 29.8719C18.9192 29.8816 18.8943 29.8816 18.8444 29.8816H17.3676C17.3177 29.8816 17.2928 29.8816 17.2738 29.8913C17.257 29.8999 17.2434 29.9135 17.2348 29.9303C17.2251 29.9493 17.2251 29.9743 17.2251 30.0241V30.8386C17.2251 30.8885 17.2251 30.9134 17.2348 30.9325C17.2434 30.9492 17.257 30.9629 17.2738 30.9714C17.2928 30.9811 17.3177 30.9811 17.3676 30.9811H18.5795C18.6294 30.9811 18.6543 30.9811 18.6733 30.9908C18.6901 30.9993 18.7037 31.013 18.7123 31.0297C18.722 31.0488 18.722 31.0737 18.722 31.1236V31.6201C18.722 31.67 18.722 31.695 18.7123 31.714C18.7037 31.7308 18.6901 31.7444 18.6733 31.7529C18.6543 31.7626 18.6294 31.7626 18.5795 31.7626H17.3676C17.3177 31.7626 17.2928 31.7626 17.2738 31.7723C17.257 31.7809 17.2434 31.7945 17.2348 31.8113C17.2251 31.8303 17.2251 31.8553 17.2251 31.9051V32.8123C17.2251 32.8622 17.2251 32.8871 17.2348 32.9062C17.2434 32.923 17.257 32.9366 17.2738 32.9451C17.2928 32.9548 17.3177 32.9548 17.3676 32.9548H18.8444C18.8943 32.9548 18.9192 32.9548 18.9383 32.9645C18.955 32.9731 18.9687 32.9867 18.9772 33.0035C18.9869 33.0225 18.9869 33.0474 18.9869 33.0973V33.5939C18.9869 33.6437 18.9869 33.6687 18.9772 33.6877C18.9687 33.7045 18.955 33.7181 18.9383 33.7267C18.9192 33.7364 18.8943 33.7364 18.8444 33.7364H16.4271Z" fill="#2845C1"/>
|
||||
<path d="M19.7762 29.9545C19.7263 29.9545 19.7014 29.9545 19.6823 29.9448C19.6656 29.9362 19.652 29.9226 19.6434 29.9059C19.6337 29.8868 19.6337 29.8619 19.6337 29.812V29.2426C19.6337 29.1927 19.6337 29.1678 19.6434 29.1487C19.652 29.132 19.6656 29.1183 19.6823 29.1098C19.7014 29.1001 19.7263 29.1001 19.7762 29.1001H23.2929C23.3428 29.1001 23.3678 29.1001 23.3868 29.1098C23.4036 29.1183 23.4172 29.132 23.4257 29.1487C23.4354 29.1678 23.4354 29.1927 23.4354 29.2426V29.812C23.4354 29.8619 23.4354 29.8868 23.4257 29.9059C23.4172 29.9226 23.4036 29.9362 23.3868 29.9448C23.3678 29.9545 23.3428 29.9545 23.2929 29.9545H22.1473C22.0974 29.9545 22.0725 29.9545 22.0535 29.9642C22.0367 29.9727 22.0231 29.9864 22.0145 30.0031C22.0048 30.0222 22.0048 30.0471 22.0048 30.097V33.5939C22.0048 33.6437 22.0048 33.6687 21.9951 33.6877C21.9866 33.7045 21.973 33.7181 21.9562 33.7267C21.9371 33.7364 21.9122 33.7364 21.8623 33.7364H21.2068C21.1569 33.7364 21.132 33.7364 21.113 33.7267C21.0962 33.7181 21.0826 33.7045 21.074 33.6877C21.0643 33.6687 21.0643 33.6437 21.0643 33.5939V30.097C21.0643 30.0471 21.0643 30.0222 21.0546 30.0031C21.0461 29.9864 21.0325 29.9727 21.0157 29.9642C20.9966 29.9545 20.9717 29.9545 20.9218 29.9545H19.7762Z" fill="#2845C1"/>
|
||||
<path d="M23.3724 33.7364C23.3031 33.7364 23.2685 33.7364 23.2463 33.7218C23.2268 33.7091 23.2131 33.6892 23.2081 33.6666C23.2024 33.6406 23.2146 33.6082 23.2392 33.5434L24.8864 29.1921C24.899 29.159 24.9053 29.1424 24.9161 29.1301C24.9257 29.1193 24.9377 29.111 24.9512 29.1059C24.9665 29.1001 24.9842 29.1001 25.0197 29.1001H26.0548C26.0903 29.1001 26.108 29.1001 26.1233 29.1059C26.1368 29.111 26.1488 29.1193 26.1584 29.1301C26.1692 29.1424 26.1755 29.159 26.188 29.1921L27.8353 33.5434C27.8598 33.6082 27.8721 33.6406 27.8664 33.6666C27.8613 33.6892 27.8476 33.7091 27.8282 33.7218C27.806 33.7364 27.7713 33.7364 27.702 33.7364H27.0076C26.9718 33.7364 26.9539 33.7364 26.9385 33.7305C26.925 33.7253 26.9129 33.7169 26.9033 33.7059C26.8925 33.6935 26.8863 33.6767 26.8739 33.6431L26.4988 32.6242C26.4865 32.5906 26.4803 32.5738 26.4694 32.5614C26.4599 32.5504 26.4478 32.542 26.4342 32.5368C26.4188 32.5309 26.4009 32.5309 26.3651 32.5309H24.7094C24.6736 32.5309 24.6556 32.5309 24.6403 32.5368C24.6267 32.542 24.6146 32.5504 24.605 32.5614C24.5942 32.5738 24.588 32.5906 24.5757 32.6242L24.2006 33.6431C24.1882 33.6767 24.182 33.6935 24.1712 33.7059C24.1616 33.7169 24.1495 33.7253 24.1359 33.7305C24.1206 33.7364 24.1027 33.7364 24.0668 33.7364H23.3724ZM24.9713 31.5382C24.9478 31.6025 24.936 31.6347 24.9419 31.6604C24.9471 31.6829 24.9608 31.7026 24.9802 31.7151C25.0023 31.7295 25.0366 31.7295 25.1051 31.7295H25.9694C26.0379 31.7295 26.0721 31.7295 26.0943 31.7151C26.1136 31.7026 26.1274 31.6829 26.1325 31.6604C26.1385 31.6347 26.1267 31.6025 26.1032 31.5382L25.6711 30.3543C25.6298 30.2413 25.6092 30.1847 25.579 30.1687C25.5529 30.1548 25.5216 30.1548 25.4955 30.1687C25.4653 30.1847 25.4447 30.2413 25.4034 30.3543L24.9713 31.5382Z" fill="#2845C1"/>
|
||||
<path d="M64.9111 15.5364C65.9592 15.5364 66.8937 15.7488 67.7139 16.1741C68.4871 16.5537 69.1304 17.0638 69.6434 17.7037C69.7121 17.7894 69.6942 17.9141 69.6067 17.9804L67.953 19.235C67.8606 19.305 67.7288 19.2818 67.6586 19.1895C67.3841 18.8284 67.0298 18.5295 66.5967 18.2932C66.1106 18.0199 65.5408 17.8831 64.8877 17.8831C64.3563 17.8831 63.8627 17.9816 63.4072 18.179C62.9668 18.3764 62.5866 18.6499 62.2676 18.9993C61.9486 19.3486 61.6979 19.7589 61.5156 20.2297C61.3334 20.6854 61.2422 21.1869 61.2422 21.7336C61.2422 22.4627 61.4018 23.1237 61.7207 23.7161C62.0397 24.2933 62.4727 24.7491 63.0195 25.0833C63.5664 25.4174 64.2045 25.5842 64.9336 25.5842C65.5564 25.5842 66.1106 25.4475 66.5967 25.1741C67.0298 24.9378 67.3841 24.6396 67.6586 24.2787C67.7288 24.1865 67.8606 24.1632 67.953 24.2333L69.6051 25.4866C69.6932 25.5534 69.7106 25.6793 69.6406 25.7649C69.128 26.391 68.4855 26.9003 67.7139 27.2932C66.8937 27.7185 65.9592 27.9318 64.9111 27.9319C63.6199 27.9319 62.5033 27.6502 61.5615 27.0881C60.6197 26.5109 59.8905 25.7516 59.374 24.8098C58.8728 23.868 58.6221 22.8425 58.6221 21.7336C58.6221 20.8984 58.7662 20.1087 59.0547 19.3645C59.3433 18.6202 59.7617 17.9593 60.3086 17.3821C60.8554 16.805 61.5155 16.3563 62.29 16.0374C63.0648 15.7032 63.9389 15.5364 64.9111 15.5364Z" fill="#2845C1"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M95.3818 15.5364C96.1716 15.5364 96.8856 15.665 97.5234 15.9231C98.1614 16.1813 98.7084 16.5388 99.1641 16.9944C99.635 17.4501 99.9923 17.9897 100.235 18.6125C100.478 19.2352 100.6 19.911 100.6 20.6399C100.6 20.9133 100.585 21.1795 100.555 21.4377C100.533 21.6247 100.506 21.7878 100.476 21.927C100.457 22.0152 100.378 22.0754 100.288 22.0754H92.2901C92.174 22.0754 92.082 22.1741 92.0942 22.2896C92.1581 22.8936 92.3041 23.4146 92.5342 23.8528C92.8531 24.4603 93.3008 24.9162 93.8779 25.22C94.4552 25.5238 95.1087 25.676 95.8379 25.676C96.5214 25.676 97.1368 25.5393 97.6836 25.2659C98.1764 25.0261 98.5814 24.716 98.899 24.336C98.9718 24.2488 99.1013 24.2311 99.1901 24.3019L100.677 25.487C100.761 25.5543 100.777 25.6766 100.71 25.7608C100.195 26.4035 99.5428 26.9222 98.7539 27.3167C97.9033 27.7267 96.9387 27.9319 95.8604 27.9319C94.5236 27.9319 93.3842 27.6502 92.4424 27.0881C91.5007 26.5109 90.7795 25.7515 90.2783 24.8098C89.777 23.868 89.5264 22.8425 89.5264 21.7336C89.5264 20.8679 89.6549 20.0628 89.9131 19.3186C90.1865 18.5743 90.5739 17.9207 91.0752 17.3586C91.5764 16.7815 92.1919 16.3339 92.9209 16.0149C93.65 15.6959 94.4704 15.5364 95.3818 15.5364ZM95.3135 17.6096C94.6451 17.6096 94.0599 17.7619 93.5586 18.0657C93.0728 18.3694 92.7005 18.8173 92.4424 19.4094C92.3587 19.5964 92.2895 19.7981 92.2334 20.014C92.2015 20.1366 92.2966 20.2532 92.4234 20.2532H97.9293C98.0423 20.2532 98.1336 20.1596 98.1218 20.0473C98.0839 19.6869 97.9762 19.3455 97.7969 19.0227C97.5843 18.5975 97.2733 18.2555 96.8633 17.9973C96.4531 17.7391 95.9363 17.6096 95.3135 17.6096Z" fill="#2845C1"/>
|
||||
<path d="M114.805 15.5364C115.64 15.5364 116.415 15.7187 117.129 16.0833C117.793 16.4155 118.326 16.8296 118.725 17.3258C118.791 17.4073 118.776 17.5253 118.697 17.5932L117.293 18.791C117.204 18.8665 117.071 18.8504 116.997 18.7601C116.738 18.441 116.44 18.1714 116.104 17.9514C115.709 17.6933 115.276 17.5637 114.805 17.5637C114.471 17.5638 114.182 17.6249 113.939 17.7463C113.712 17.8679 113.537 18.0274 113.415 18.2249C113.309 18.4223 113.256 18.6429 113.256 18.886C113.256 19.2503 113.385 19.5542 113.643 19.7971C113.916 20.025 114.266 20.2228 114.691 20.3899C115.117 20.5569 115.564 20.7394 116.035 20.9368C116.521 21.1191 116.977 21.3469 117.402 21.6204C117.828 21.8786 118.17 22.2277 118.428 22.6682C118.701 23.0934 118.838 23.6324 118.838 24.2854C118.838 25.0599 118.648 25.721 118.269 26.2678C117.904 26.7993 117.418 27.2097 116.811 27.4983C116.203 27.7869 115.534 27.9319 114.805 27.9319C113.818 27.9319 112.959 27.7494 112.23 27.385C111.559 27.0351 110.964 26.575 110.447 26.0057C110.373 25.9247 110.382 25.7996 110.465 25.7273L111.868 24.4922C111.954 24.4168 112.085 24.4286 112.159 24.515C112.49 24.8982 112.855 25.2171 113.256 25.4709C113.711 25.7594 114.22 25.9036 114.782 25.9036C115.314 25.9036 115.717 25.7668 115.99 25.4934C116.279 25.2049 116.423 24.8555 116.423 24.4456C116.423 24.0659 116.286 23.7623 116.013 23.5344C115.754 23.3066 115.413 23.1087 114.987 22.9417C114.562 22.7746 114.106 22.5995 113.62 22.4172C113.149 22.2198 112.701 21.9846 112.275 21.7112C111.85 21.4378 111.501 21.0885 111.228 20.6633C110.969 20.2228 110.84 19.6604 110.84 18.9768C110.84 18.3388 111.008 17.7618 111.342 17.2454C111.676 16.7137 112.139 16.2953 112.731 15.9915C113.339 15.6877 114.03 15.5364 114.805 15.5364Z" fill="#2845C1"/>
|
||||
<path d="M52.001 13.6733C52.001 13.7837 51.9114 13.8733 51.801 13.8733H46.0027C45.8923 13.8733 45.8027 13.9628 45.8027 14.0733V17.979C45.8027 18.0894 45.8923 18.179 46.0027 18.179H50.8889C50.9993 18.179 51.0889 18.2685 51.0889 18.379V20.3266C51.0889 20.4371 50.9993 20.5266 50.8889 20.5266H46.0027C45.8923 20.5266 45.8027 20.6162 45.8027 20.7266V27.2758C45.8027 27.3863 45.7132 27.4758 45.6027 27.4758H43.2C43.0895 27.4758 43 27.3863 43 27.2758V11.7256C43 11.6152 43.0895 11.5256 43.2 11.5256H51.801C51.9114 11.5256 52.001 11.6152 52.001 11.7256V13.6733Z" fill="#2845C1"/>
|
||||
<path d="M56.4697 27.2758C56.4697 27.3863 56.3802 27.4758 56.2697 27.4758H54.1639C54.0534 27.4758 53.9639 27.3863 53.9639 27.2758V16.1915C53.9639 16.081 54.0534 15.9915 54.1639 15.9915H56.2697C56.3802 15.9915 56.4697 16.081 56.4697 16.1915V27.2758Z" fill="#2845C1"/>
|
||||
<path d="M73.9727 16.7886C73.9727 16.8787 74.0837 16.924 74.1493 16.8622C74.5262 16.5068 74.9607 16.2089 75.4531 15.969C76.0456 15.6804 76.7374 15.5364 77.5273 15.5364C78.3779 15.5364 79.1374 15.726 79.8057 16.1057C80.4892 16.4855 81.0289 17.0479 81.4238 17.7922C81.8186 18.5365 82.0156 19.4711 82.0156 20.595V27.2758C82.0156 27.3863 81.9261 27.4758 81.8156 27.4758H79.6863C79.5759 27.4758 79.4863 27.3863 79.4863 27.2758V20.6858C79.4863 19.8047 79.2584 19.1211 78.8027 18.635C78.347 18.1338 77.7244 17.8831 76.9346 17.8831C76.4334 17.8831 75.993 17.9816 75.6133 18.179C75.2335 18.3612 74.9062 18.5974 74.6328 18.886C74.3934 19.1536 74.1868 19.4345 74.0127 19.7283C73.9861 19.7732 73.9727 19.8246 73.9727 19.8768V27.2758C73.9727 27.3863 73.8831 27.4758 73.7727 27.4758H71.6658C71.5554 27.4758 71.4658 27.3863 71.4658 27.2758V10.587C71.4658 10.4765 71.5554 10.387 71.6658 10.387H73.7727C73.8831 10.387 73.9727 10.4765 73.9727 10.587V16.7886Z" fill="#2845C1"/>
|
||||
<path d="M87.374 27.2758C87.374 27.3863 87.2845 27.4758 87.174 27.4758H85.0682C84.9577 27.4758 84.8682 27.3863 84.8682 27.2758V16.1915C84.8682 16.081 84.9577 15.9915 85.0682 15.9915H87.174C87.2845 15.9915 87.374 16.081 87.374 16.1915V27.2758Z" fill="#2845C1"/>
|
||||
<path d="M108.6 15.7639C108.797 15.7639 108.979 15.7794 109.146 15.8098C109.259 15.8303 109.365 15.854 109.464 15.8813C109.547 15.9041 109.602 15.9812 109.602 16.0673V18.1279C109.602 18.258 109.479 18.3535 109.352 18.3276C109.248 18.3064 109.141 18.2875 109.032 18.2708C108.85 18.2404 108.629 18.2249 108.371 18.2249C107.9 18.2249 107.475 18.3006 107.096 18.4524C106.731 18.6043 106.412 18.8095 106.139 19.0676C105.937 19.2472 105.759 19.4436 105.607 19.6564C105.502 19.8038 105.455 19.9837 105.455 20.165V27.2758C105.455 27.3863 105.366 27.4758 105.255 27.4758H103.148C103.038 27.4758 102.948 27.3863 102.948 27.2758V16.1915C102.948 16.081 103.038 15.9915 103.148 15.9915H105.255C105.366 15.9915 105.455 16.081 105.455 16.1915V16.9959C105.455 17.087 105.568 17.1315 105.632 17.0666C105.759 16.9377 105.89 16.8149 106.024 16.6985C106.359 16.4099 106.739 16.182 107.164 16.0149C107.589 15.8478 108.068 15.7639 108.6 15.7639Z" fill="#2845C1"/>
|
||||
<path d="M55.2393 10.2502C55.5429 10.2502 55.8166 10.326 56.0596 10.4778C56.3178 10.6297 56.5156 10.8348 56.6523 11.093C56.8042 11.3361 56.8799 11.6095 56.8799 11.9133C56.8799 12.2171 56.8042 12.4979 56.6523 12.7561C56.5156 13.0143 56.3178 13.2194 56.0596 13.3713C55.8166 13.508 55.543 13.5764 55.2393 13.5764C54.9356 13.5763 54.6546 13.508 54.3965 13.3713C54.1383 13.2194 53.9331 13.0143 53.7812 12.7561C53.6295 12.498 53.5537 12.217 53.5537 11.9133C53.5537 11.4576 53.7132 11.0702 54.0322 10.7512C54.3663 10.4172 54.7686 10.2504 55.2393 10.2502Z" fill="#2845C1"/>
|
||||
<path d="M86.1436 10.2502C86.4473 10.2502 86.7208 10.3259 86.9639 10.4778C87.2221 10.6297 87.4199 10.8348 87.5566 11.093C87.7085 11.336 87.7842 11.6096 87.7842 11.9133C87.7842 12.217 87.7084 12.498 87.5566 12.7561C87.4199 13.0143 87.2221 13.2194 86.9639 13.3713C86.7208 13.508 86.4473 13.5764 86.1436 13.5764C85.8398 13.5764 85.559 13.508 85.3008 13.3713C85.0425 13.2194 84.8375 13.0143 84.6855 12.7561C84.5337 12.4979 84.458 12.2171 84.458 11.9133C84.458 11.4576 84.6175 11.0702 84.9365 10.7512C85.2706 10.4172 85.6728 10.2503 86.1436 10.2502Z" fill="#2845C1"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3435_94" x1="20" y1="27" x2="20" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2845C1"/>
|
||||
<stop offset="1" stop-color="#ECF1F7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
BIN
src/frontend/apps/calendars/public/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.6765 9.72627H8.41971C6.23248 9.72627 4.03303 11.4473 3.50711 13.5704L2 19.6543V7.84882C2 6.57794 2.29141 5.62077 2.87422 4.97729C3.46451 4.32576 4.28269 4 5.32877 4H8.50063C8.88917 4 9.22541 4.02815 9.50935 4.08446C9.79328 4.14076 10.0548 4.23728 10.2939 4.37402C10.533 4.50272 10.7833 4.6837 11.0448 4.91696L11.6837 5.48403C11.9975 5.74946 12.2927 5.93848 12.5691 6.05109C12.8456 6.1637 13.1856 6.22001 13.5891 6.22001H24.0125C25.2155 6.22001 26.1271 6.55381 26.7472 7.22142C27.3035 7.81312 27.6133 8.64807 27.6765 9.72627Z" fill="#C83F49"/>
|
||||
<path d="M5.67656 27C4.46609 27 3.6366 26.6536 3.18806 25.9607C2.73746 25.2761 2.6776 24.266 3.00848 22.9303L5.32709 13.5705C5.61138 12.4229 6.80027 11.4926 7.98256 11.4926H29.3898C30.5721 11.4926 31.3001 12.4229 31.0158 13.5705L28.6972 22.9303C28.3663 24.266 27.8172 25.2761 27.0499 25.9607C26.2805 26.6536 25.3615 27 24.293 27H5.67656Z" fill="#2845C1"/>
|
||||
<rect x="10" y="22" width="12" height="9" rx="4.5" fill="#EEF1F4"/>
|
||||
<rect x="10" y="22" width="12" height="9" rx="4.5" fill="url(#paint0_linear_1156_814)" fill-opacity="0.1"/>
|
||||
<path d="M18.9901 29.2782C18.7635 29.3485 18.5616 29.3864 18.3842 29.3864C17.7931 29.3864 17.3941 28.9103 17.1921 27.9527H17.1675C16.6798 28.986 15.9951 29.5 15.133 29.5C14.4877 29.5 13.9704 29.2349 13.5813 28.6993C13.1921 28.1637 13 27.4928 13 26.6812C13 25.7344 13.2217 24.977 13.6601 24.3873C14.0985 23.7976 14.6946 23.5 15.4483 23.5C15.8522 23.5 16.2167 23.6244 16.532 23.8679C16.8473 24.1168 17.0887 24.463 17.2562 24.9121H17.2759L17.6256 23.6136H18.8867L17.8325 26.4919C17.9507 27.1628 18.0739 27.6226 18.2118 27.8661C18.33 28.1096 18.4975 28.234 18.7044 28.234C18.8227 28.234 18.9163 28.2124 19 28.1745L18.9901 29.2782ZM16.8916 26.4432C16.7882 25.8318 16.6207 25.3557 16.3941 25.0311C16.1724 24.7011 15.9015 24.5388 15.5911 24.5388C15.1872 24.5388 14.8621 24.739 14.6207 25.1339C14.3793 25.5343 14.2709 26.0266 14.2709 26.6055C14.2709 27.1357 14.3645 27.5739 14.5764 27.931C14.7833 28.2881 15.064 28.4612 15.4138 28.4612C15.7094 28.4612 15.9803 28.3043 16.2217 28.0068C16.468 27.6984 16.67 27.2493 16.8325 26.6596L16.8916 26.4432Z" fill="#C83F49"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1156_814" x1="16" y1="22" x2="16" y2="31" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#C83F49"/>
|
||||
<stop offset="1" stop-color="#EEF1F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
16
src/frontend/apps/calendars/public/assets/logo-icon_beta.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M55.3531 19.4525H16.8394C12.465 19.4525 8.06606 22.8947 7.01421 27.1408L4 39.3087V15.6976C4 13.1559 4.58282 11.2415 5.74845 9.95457C6.92902 8.65152 8.56539 8 10.6575 8H17.0013C17.7783 8 18.4508 8.0563 19.0187 8.16891C19.5866 8.28152 20.1096 8.47457 20.5878 8.74805C21.066 9.00544 21.5666 9.3674 22.0897 9.83392L23.3674 10.9681C23.995 11.4989 24.5853 11.877 25.1383 12.1022C25.6912 12.3274 26.3711 12.44 27.1781 12.44H48.025C50.4309 12.44 52.2541 13.1076 53.4945 14.4428C54.607 15.6262 55.2265 17.2961 55.3531 19.4525Z" fill="#C83F49"/>
|
||||
<path d="M11.3531 54C8.93219 54 7.27319 53.3071 6.37613 51.9213C5.47493 50.5522 5.3552 48.532 6.01696 45.8606L10.6542 27.1409C11.2228 24.8457 13.6005 22.9851 15.9651 22.9851H58.7796C61.1442 22.9851 62.6002 24.8457 62.0316 27.1409L57.3944 45.8606C56.7326 48.532 55.6344 50.5522 54.0997 51.9213C52.5609 53.3071 50.723 54 48.586 54H11.3531Z" fill="#2845C1"/>
|
||||
<rect x="8" y="46" width="48" height="18" rx="9" fill="#EEF1F4"/>
|
||||
<rect x="8" y="46" width="48" height="18" rx="9" fill="url(#paint0_linear_11386_15350)" fill-opacity="0.1"/>
|
||||
<path d="M16.6854 59.4725C16.5856 59.4725 16.5358 59.4725 16.4976 59.4531C16.4641 59.436 16.4369 59.4087 16.4198 59.3752C16.4004 59.3371 16.4004 59.2872 16.4004 59.1875V50.485C16.4004 50.3852 16.4004 50.3353 16.4198 50.2972C16.4369 50.2637 16.4641 50.2364 16.4976 50.2194C16.5358 50.2 16.5856 50.2 16.6854 50.2H19.1292C21.0632 50.2 22.2421 51.1537 22.2421 52.7168C22.2421 53.296 22.0394 53.8129 21.6519 54.2136C21.5149 54.3553 21.4463 54.4262 21.4348 54.4727C21.4223 54.5229 21.425 54.5479 21.4479 54.5943C21.4691 54.6373 21.5483 54.6901 21.7069 54.7957C22.3966 55.255 22.7719 55.9399 22.7719 56.7437C22.7719 58.4393 21.4605 59.4725 19.3279 59.4725H16.6854ZM19.1689 51.763H18.5664C18.4666 51.763 18.4168 51.763 18.3786 51.7825C18.3451 51.7995 18.3179 51.8268 18.3008 51.8603C18.2814 51.8984 18.2814 51.9483 18.2814 52.048V53.571C18.2814 53.6707 18.2814 53.7206 18.3008 53.7587C18.3179 53.7922 18.3451 53.8195 18.3786 53.8366C18.4168 53.856 18.4666 53.856 18.5664 53.856H19.1689C19.8842 53.856 20.3081 53.4718 20.3081 52.7963C20.3081 52.1472 19.8842 51.763 19.1689 51.763ZM19.4338 55.4191H18.5664C18.4666 55.4191 18.4168 55.4191 18.3786 55.4385C18.3451 55.4556 18.3179 55.4828 18.3008 55.5163C18.2814 55.5544 18.2814 55.6043 18.2814 55.7041V57.6244C18.2814 57.7242 18.2814 57.774 18.3008 57.8121C18.3179 57.8457 18.3451 57.8729 18.3786 57.89C18.4168 57.9094 18.4666 57.9094 18.5664 57.9094H19.4338C20.3081 57.9094 20.838 57.4458 20.838 56.6642C20.838 55.8694 20.3081 55.4191 19.4338 55.4191Z" fill="#2845C1"/>
|
||||
<path d="M24.8547 59.4725C24.755 59.4725 24.7051 59.4725 24.667 59.4531C24.6335 59.436 24.6062 59.4087 24.5892 59.3752C24.5697 59.3371 24.5697 59.2872 24.5697 59.1875V50.485C24.5697 50.3852 24.5697 50.3353 24.5892 50.2972C24.6062 50.2637 24.6335 50.2364 24.667 50.2194C24.7051 50.2 24.755 50.2 24.8547 50.2H29.6893C29.7891 50.2 29.8389 50.2 29.877 50.2194C29.9106 50.2364 29.9378 50.2637 29.9549 50.2972C29.9743 50.3353 29.9743 50.3852 29.9743 50.485V51.478C29.9743 51.5778 29.9743 51.6277 29.9549 51.6658C29.9378 51.6993 29.9106 51.7265 29.877 51.7436C29.8389 51.763 29.7891 51.763 29.6893 51.763H26.7357C26.636 51.763 26.5861 51.763 26.548 51.7825C26.5145 51.7995 26.4872 51.8268 26.4702 51.8603C26.4507 51.8984 26.4507 51.9483 26.4507 52.048V53.677C26.4507 53.7767 26.4507 53.8266 26.4702 53.8647C26.4872 53.8982 26.5145 53.9255 26.548 53.9425C26.5861 53.962 26.636 53.962 26.7357 53.962H29.1594C29.2592 53.962 29.3091 53.962 29.3472 53.9814C29.3807 53.9984 29.408 54.0257 29.425 54.0592C29.4444 54.0973 29.4444 54.1472 29.4444 54.247V55.24C29.4444 55.3398 29.4444 55.3897 29.425 55.4278C29.408 55.4613 29.3807 55.4885 29.3472 55.5056C29.3091 55.525 29.2592 55.525 29.1594 55.525H26.7357C26.636 55.525 26.5861 55.525 26.548 55.5445C26.5145 55.5615 26.4872 55.5888 26.4702 55.6223C26.4507 55.6604 26.4507 55.7103 26.4507 55.81V57.6244C26.4507 57.7242 26.4507 57.774 26.4702 57.8121C26.4872 57.8457 26.5145 57.8729 26.548 57.89C26.5861 57.9094 26.636 57.9094 26.7357 57.9094H29.6893C29.7891 57.9094 29.8389 57.9094 29.877 57.9288C29.9106 57.9459 29.9378 57.9731 29.9549 58.0067C29.9743 58.0448 29.9743 58.0946 29.9743 58.1944V59.1875C29.9743 59.2872 29.9743 59.3371 29.9549 59.3752C29.9378 59.4087 29.9106 59.436 29.877 59.4531C29.8389 59.4725 29.7891 59.4725 29.6893 59.4725H24.8547Z" fill="#2845C1"/>
|
||||
<path d="M31.5529 51.9087C31.4531 51.9087 31.4033 51.9087 31.3652 51.8893C31.3316 51.8723 31.3044 51.845 31.2873 51.8115C31.2679 51.7734 31.2679 51.7235 31.2679 51.6237V50.485C31.2679 50.3852 31.2679 50.3353 31.2873 50.2972C31.3044 50.2637 31.3316 50.2364 31.3652 50.2194C31.4033 50.2 31.4531 50.2 31.5529 50.2H38.5864C38.6861 50.2 38.736 50.2 38.7741 50.2194C38.8076 50.2364 38.8349 50.2637 38.852 50.2972C38.8714 50.3353 38.8714 50.3852 38.8714 50.485V51.6237C38.8714 51.7235 38.8714 51.7734 38.852 51.8115C38.8349 51.845 38.8076 51.8723 38.7741 51.8893C38.736 51.9087 38.6861 51.9087 38.5864 51.9087H36.2951C36.1954 51.9087 36.1455 51.9087 36.1074 51.9282C36.0739 51.9452 36.0466 51.9725 36.0296 52.006C36.0101 52.0441 36.0101 52.094 36.0101 52.1937V59.1875C36.0101 59.2872 36.0101 59.3371 35.9907 59.3752C35.9737 59.4087 35.9464 59.436 35.9129 59.4531C35.8748 59.4725 35.8249 59.4725 35.7251 59.4725H34.4141C34.3144 59.4725 34.2645 59.4725 34.2264 59.4531C34.1929 59.436 34.1656 59.4087 34.1486 59.3752C34.1291 59.3371 34.1291 59.2872 34.1291 59.1875V52.1937C34.1291 52.094 34.1291 52.0441 34.1097 52.006C34.0927 51.9725 34.0654 51.9452 34.0319 51.9282C33.9938 51.9087 33.9439 51.9087 33.8441 51.9087H31.5529Z" fill="#2845C1"/>
|
||||
<path d="M38.7454 59.4725C38.6068 59.4725 38.5375 59.4725 38.493 59.4434C38.4541 59.4179 38.4268 59.3783 38.4167 59.3329C38.4052 59.281 38.4298 59.2162 38.4788 59.0866L41.7734 50.384C41.7985 50.3177 41.8111 50.2845 41.8327 50.26C41.8518 50.2384 41.8759 50.2218 41.9029 50.2115C41.9334 50.2 41.9689 50.2 42.0399 50.2H44.11C44.181 50.2 44.2165 50.2 44.2471 50.2115C44.274 50.2218 44.2981 50.2384 44.3172 50.26C44.3389 50.2845 44.3515 50.3177 44.3766 50.384L47.6711 59.0866C47.7202 59.2162 47.7447 59.281 47.7332 59.3329C47.7232 59.3783 47.6958 59.4179 47.6569 59.4434C47.6124 59.4725 47.5432 59.4725 47.4046 59.4725H46.0158C45.9441 59.4725 45.9083 59.4725 45.8776 59.4607C45.8504 59.4503 45.8262 59.4335 45.8071 59.4116C45.7854 59.3868 45.7731 59.3532 45.7483 59.2859L44.9981 57.2482C44.9734 57.1809 44.961 57.1473 44.9394 57.1225C44.9202 57.1007 44.896 57.0838 44.8689 57.0734C44.8382 57.0616 44.8023 57.0616 44.7307 57.0616H41.4192C41.3476 57.0616 41.3118 57.0616 41.281 57.0734C41.2539 57.0838 41.2297 57.1007 41.2106 57.1225C41.1889 57.1473 41.1765 57.1809 41.1518 57.2482L40.4016 59.2859C40.3769 59.3532 40.3645 59.3868 40.3428 59.4116C40.3237 59.4335 40.2995 59.4503 40.2724 59.4607C40.2416 59.4725 40.2058 59.4725 40.1342 59.4725H38.7454ZM41.943 55.0761C41.896 55.2048 41.8725 55.2692 41.8844 55.3206C41.8947 55.3656 41.9222 55.4049 41.9609 55.43C42.0052 55.4588 42.0737 55.4588 42.2107 55.4588H43.9392C44.0763 55.4588 44.1448 55.4588 44.189 55.43C44.2278 55.4049 44.2552 55.3656 44.2656 55.3206C44.2774 55.2692 44.2539 55.2048 44.2069 55.0761L43.3427 52.7084C43.2601 52.4823 43.2189 52.3692 43.1585 52.3372C43.1063 52.3094 43.0437 52.3094 42.9914 52.3372C42.9311 52.3692 42.8898 52.4823 42.8072 52.7084L41.943 55.0761Z" fill="#2845C1"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_11386_15350" x1="32" y1="46" x2="32" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2845C1"/>
|
||||
<stop offset="1" stop-color="#EEF1F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
6
src/frontend/apps/calendars/public/assets/logo_alpha.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="153" height="40" viewBox="0 0 153 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.18213 11.5846C8.59434 11.4599 9.03364 11.3924 9.49206 11.3924H30.5085C30.9669 11.3924 31.4062 11.4599 31.8184 11.5846C31.8178 10.391 31.8053 9.77042 31.5608 9.29057C31.3342 8.84582 30.9726 8.48423 30.5279 8.25762C30.0223 8 29.3604 8 28.0366 8H11.9639C10.6402 8 9.97827 8 9.47266 8.25762C9.02792 8.48423 8.66633 8.84582 8.43972 9.29057C8.19522 9.77041 8.18276 10.391 8.18213 11.5846Z" fill="#FBC63A"/>
|
||||
<path d="M24.2853 24.4406C24.2853 24.4959 24.2405 24.5406 24.1853 24.5406H21.6615C21.5786 24.5406 21.5317 24.4454 21.5823 24.3796L24.106 21.1015C24.1643 21.0259 24.2853 21.0671 24.2853 21.1625V24.4406Z" fill="#2845C1"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3585 22.7595L32.9244 16.496C33.3174 14.9241 32.1285 13.4014 30.5082 13.4014H9.49178C7.8715 13.4014 6.68261 14.9241 7.07559 16.496L8.64148 22.7595C8.74063 23.1561 8.74063 23.571 8.64148 23.9676L7.07559 30.2312C6.68261 31.8031 7.8715 33.3258 9.49178 33.3258H30.5082C32.1285 33.3258 33.3174 31.8031 32.9244 30.2312L31.3585 23.9676C31.2594 23.571 31.2594 23.1561 31.3585 22.7595ZM15.1567 20.7227C15.2234 20.685 15.3059 20.7331 15.3059 20.8097V29.1573C15.3059 29.2125 15.3507 29.2573 15.4059 29.2573H17.28C17.3352 29.2573 17.38 29.2125 17.38 29.1573V18.6027C17.38 18.5475 17.3352 18.5027 17.28 18.5027H15.3942C15.3766 18.5027 15.3594 18.5074 15.3441 18.5162L12.1431 20.3686C12.0924 20.398 12.0776 20.4644 12.1111 20.5124L13.0261 21.8235C13.0557 21.8658 13.1125 21.8787 13.1574 21.8533L15.1567 20.7227ZM19.3434 24.5137C19.33 24.5312 19.3228 24.5525 19.3228 24.5745V26.315C19.3228 26.3702 19.3676 26.415 19.4228 26.415H24.1853C24.2405 26.415 24.2853 26.4598 24.2853 26.515V29.1726C24.2853 29.2279 24.33 29.2726 24.3853 29.2726H26.2593C26.3146 29.2726 26.3593 29.2279 26.3593 29.1726V26.515C26.3593 26.4598 26.4041 26.415 26.4593 26.415H27.3502C27.4054 26.415 27.4502 26.3702 27.4502 26.315V24.6406C27.4502 24.5854 27.4054 24.5406 27.3502 24.5406H26.4593C26.4041 24.5406 26.3593 24.4959 26.3593 24.4406V18.6181C26.3593 18.5629 26.3146 18.5181 26.2593 18.5181H23.9813C23.9502 18.5181 23.9208 18.5326 23.9019 18.5573L19.3434 24.5137Z" fill="#2845C1"/>
|
||||
<path d="M49.13 25.514C50.89 25.514 52.276 24.656 53.156 23.402L55.62 25.294C54.212 27.208 51.924 28.44 49.13 28.44C44.268 28.44 40.946 24.7 40.946 20.3C40.946 15.9 44.268 12.16 49.13 12.16C51.924 12.16 54.212 13.414 55.62 15.284L53.156 17.198C52.276 15.944 50.89 15.086 49.13 15.086C46.226 15.086 44.158 17.352 44.158 20.3C44.158 23.248 46.226 25.514 49.13 25.514ZM60.4263 28.33C58.2043 28.33 56.6863 27.054 56.6863 25.008C56.6863 23.336 57.9843 22.082 60.3603 21.686L63.7483 21.114V20.828C63.7483 19.662 62.8683 18.914 61.5923 18.914C60.5143 18.914 59.6783 19.42 59.0843 20.234L57.0383 18.672C58.0283 17.308 59.6563 16.472 61.6803 16.472C64.8923 16.472 66.5423 18.386 66.5423 20.828V28H63.7483V26.922C63.0443 27.78 61.7243 28.33 60.4263 28.33ZM59.4583 24.876C59.4583 25.624 60.0523 26.108 60.9983 26.108C62.2743 26.108 63.1983 25.514 63.7483 24.634V23.072L61.1083 23.512C59.9423 23.71 59.4583 24.194 59.4583 24.876ZM69.3018 28V11.5H72.0958V28H69.3018ZM85.2143 26.196C84.1803 27.582 82.4643 28.44 80.3743 28.44C76.4363 28.44 74.2143 25.69 74.2143 22.456C74.2143 19.178 76.2823 16.472 79.9123 16.472C82.9923 16.472 85.0163 18.562 85.0163 21.466C85.0163 22.082 84.9283 22.654 84.8403 23.028H77.0743C77.3383 25.096 78.6143 25.932 80.3523 25.932C81.5623 25.932 82.6183 25.404 83.2123 24.612L85.2143 26.196ZM79.8463 18.76C78.4163 18.76 77.4703 19.552 77.1623 21.004H82.2663C82.2223 19.882 81.4083 18.76 79.8463 18.76ZM87.3065 28V16.912H90.1005V17.792C90.8705 17.044 91.9045 16.472 93.3785 16.472C95.7765 16.472 97.6905 18.122 97.6905 21.4V28H94.8745V21.51C94.8745 20.036 94.0385 19.112 92.6745 19.112C91.2665 19.112 90.5185 20.058 90.1005 20.762V28H87.3065ZM99.6312 22.456C99.6312 19.222 101.765 16.472 105.263 16.472C106.759 16.472 107.837 16.934 108.695 17.792V11.5H111.511V28H108.695V27.12C107.837 27.978 106.759 28.44 105.263 28.44C101.765 28.44 99.6312 25.69 99.6312 22.456ZM102.557 22.456C102.557 24.392 103.767 25.8 105.659 25.8C106.913 25.8 107.947 25.272 108.695 24.26V20.652C107.947 19.64 106.913 19.112 105.659 19.112C103.767 19.112 102.557 20.52 102.557 22.456ZM114.421 28V16.912H117.215V18.012C117.941 17.264 118.887 16.692 120.141 16.692C120.515 16.692 120.845 16.758 121.109 16.846V19.596C120.757 19.508 120.383 19.442 119.877 19.442C118.513 19.442 117.633 20.19 117.215 20.894V28H114.421ZM124.326 14.976C123.336 14.976 122.5 14.14 122.5 13.15C122.5 12.16 123.336 11.324 124.326 11.324C125.316 11.324 126.13 12.16 126.13 13.15C126.13 14.14 125.316 14.976 124.326 14.976ZM122.918 28V16.912H125.712V28H122.918ZM138.831 26.196C137.797 27.582 136.081 28.44 133.991 28.44C130.053 28.44 127.831 25.69 127.831 22.456C127.831 19.178 129.899 16.472 133.529 16.472C136.609 16.472 138.633 18.562 138.633 21.466C138.633 22.082 138.545 22.654 138.457 23.028H130.691C130.955 25.096 132.231 25.932 133.969 25.932C135.179 25.932 136.235 25.404 136.829 24.612L138.831 26.196ZM133.463 18.76C132.033 18.76 131.087 19.552 130.779 21.004H135.883C135.839 19.882 135.025 18.76 133.463 18.76ZM140.923 28V16.912H143.717V18.012C144.443 17.264 145.389 16.692 146.643 16.692C147.017 16.692 147.347 16.758 147.611 16.846V19.596C147.259 19.508 146.885 19.442 146.379 19.442C145.015 19.442 144.135 20.19 143.717 20.894V28H140.923Z" fill="#2845C1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
68
src/frontend/apps/calendars/public/mime-video.svg
Normal file
@@ -0,0 +1,68 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_3239_3207)">
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="#FAFAFA"/>
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="url(#paint0_linear_3239_3207)"/>
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" fill="url(#paint1_linear_3239_3207)" fill-opacity="0.04"/>
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="#CECECE" stroke-width="0.7"/>
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="url(#paint2_linear_3239_3207)" stroke-opacity="0.5" stroke-width="0.7"/>
|
||||
<rect x="2.85" y="5.85" width="26.3" height="21.3" rx="3.42778" stroke="url(#paint3_linear_3239_3207)" stroke-opacity="0.2" stroke-width="0.7"/>
|
||||
<path d="M13.2866 19.2658V13.7304C13.2866 13.487 13.3513 13.3043 13.4806 13.1826C13.61 13.0609 13.7647 13 13.9448 13C14.102 13 14.2567 13.0431 14.4089 13.1293L19.0122 15.8152C19.1872 15.9167 19.3178 16.0194 19.404 16.1234C19.4902 16.2248 19.5334 16.3504 19.5334 16.5C19.5334 16.6446 19.4902 16.7701 19.404 16.8766C19.3178 16.9806 19.1872 17.0821 19.0122 17.181L14.4089 19.8668C14.2567 19.9556 14.102 20 13.9448 20C13.7647 20 13.61 19.9379 13.4806 19.8136C13.3513 19.6918 13.2866 19.5092 13.2866 19.2658Z" fill="#CECECE"/>
|
||||
<path d="M13.2866 19.2658V13.7304C13.2866 13.487 13.3513 13.3043 13.4806 13.1826C13.61 13.0609 13.7647 13 13.9448 13C14.102 13 14.2567 13.0431 14.4089 13.1293L19.0122 15.8152C19.1872 15.9167 19.3178 16.0194 19.404 16.1234C19.4902 16.2248 19.5334 16.3504 19.5334 16.5C19.5334 16.6446 19.4902 16.7701 19.404 16.8766C19.3178 16.9806 19.1872 17.0821 19.0122 17.181L14.4089 19.8668C14.2567 19.9556 14.102 20 13.9448 20C13.7647 20 13.61 19.9379 13.4806 19.8136C13.3513 19.6918 13.2866 19.5092 13.2866 19.2658Z" fill="url(#paint4_linear_3239_3207)" fill-opacity="0.77"/>
|
||||
<g opacity="0.52">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.86977C5 8.38941 5.38941 8 5.86977 8H7.71165C8.19201 8 8.58142 8.38941 8.58142 8.86977C8.58142 9.35014 8.19201 9.73955 7.71165 9.73955H5.86977C5.38941 9.73955 5 9.35014 5 8.86977Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.86977C5 8.38941 5.38941 8 5.86977 8H7.71165C8.19201 8 8.58142 8.38941 8.58142 8.86977C8.58142 9.35014 8.19201 9.73955 7.71165 9.73955H5.86977C5.38941 9.73955 5 9.35014 5 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 8.86977C9.60449 8.38941 9.9939 8 10.4743 8H12.3161C12.7965 8 13.1859 8.38941 13.1859 8.86977C13.1859 9.35014 12.7965 9.73955 12.3161 9.73955H10.4743C9.9939 9.73955 9.60449 9.35014 9.60449 8.86977Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 8.86977C9.60449 8.38941 9.9939 8 10.4743 8H12.3161C12.7965 8 13.1859 8.38941 13.1859 8.86977C13.1859 9.35014 12.7965 9.73955 12.3161 9.73955H10.4743C9.9939 9.73955 9.60449 9.35014 9.60449 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 8.86977C14.2095 8.38941 14.5989 8 15.0792 8H16.9211C17.4015 8 17.7909 8.38941 17.7909 8.86977C17.7909 9.35014 17.4015 9.73955 16.9211 9.73955H15.0792C14.5989 9.73955 14.2095 9.35014 14.2095 8.86977Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 8.86977C14.2095 8.38941 14.5989 8 15.0792 8H16.9211C17.4015 8 17.7909 8.38941 17.7909 8.86977C17.7909 9.35014 17.4015 9.73955 16.9211 9.73955H15.0792C14.5989 9.73955 14.2095 9.35014 14.2095 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 8.86977C18.814 8.38941 19.2034 8 19.6837 8H21.5256C22.006 8 22.3954 8.38941 22.3954 8.86977C22.3954 9.35014 22.006 9.73955 21.5256 9.73955H19.6837C19.2034 9.73955 18.814 9.35014 18.814 8.86977Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 8.86977C18.814 8.38941 19.2034 8 19.6837 8H21.5256C22.006 8 22.3954 8.38941 22.3954 8.86977C22.3954 9.35014 22.006 9.73955 21.5256 9.73955H19.6837C19.2034 9.73955 18.814 9.35014 18.814 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 8.86977C23.4185 8.38941 23.8079 8 24.2882 8H26.1301C26.6105 8 26.9999 8.38941 26.9999 8.86977C26.9999 9.35014 26.6105 9.73955 26.1301 9.73955H24.2882C23.8079 9.73955 23.4185 9.35014 23.4185 8.86977Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 8.86977C23.4185 8.38941 23.8079 8 24.2882 8H26.1301C26.6105 8 26.9999 8.38941 26.9999 8.86977C26.9999 9.35014 26.6105 9.73955 26.1301 9.73955H24.2882C23.8079 9.73955 23.4185 9.35014 23.4185 8.86977Z" fill="#6A6AF4" fill-opacity="0.18"/>
|
||||
</g>
|
||||
<g opacity="0.38">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 24.1298C5 23.6494 5.38941 23.26 5.86977 23.26H7.71165C8.19201 23.26 8.58142 23.6494 8.58142 24.1298C8.58142 24.6101 8.19201 24.9996 7.71165 24.9996H5.86977C5.38941 24.9996 5 24.6101 5 24.1298Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 24.1298C5 23.6494 5.38941 23.26 5.86977 23.26H7.71165C8.19201 23.26 8.58142 23.6494 8.58142 24.1298C8.58142 24.6101 8.19201 24.9996 7.71165 24.9996H5.86977C5.38941 24.9996 5 24.6101 5 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 24.1298C9.60449 23.6494 9.9939 23.26 10.4743 23.26H12.3161C12.7965 23.26 13.1859 23.6494 13.1859 24.1298C13.1859 24.6101 12.7965 24.9996 12.3161 24.9996H10.4743C9.9939 24.9996 9.60449 24.6101 9.60449 24.1298Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.60449 24.1298C9.60449 23.6494 9.9939 23.26 10.4743 23.26H12.3161C12.7965 23.26 13.1859 23.6494 13.1859 24.1298C13.1859 24.6101 12.7965 24.9996 12.3161 24.9996H10.4743C9.9939 24.9996 9.60449 24.6101 9.60449 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 24.1298C14.2095 23.6494 14.5989 23.26 15.0792 23.26H16.9211C17.4015 23.26 17.7909 23.6494 17.7909 24.1298C17.7909 24.6101 17.4015 24.9996 16.9211 24.9996H15.0792C14.5989 24.9996 14.2095 24.6101 14.2095 24.1298Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.2095 24.1298C14.2095 23.6494 14.5989 23.26 15.0792 23.26H16.9211C17.4015 23.26 17.7909 23.6494 17.7909 24.1298C17.7909 24.6101 17.4015 24.9996 16.9211 24.9996H15.0792C14.5989 24.9996 14.2095 24.6101 14.2095 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 24.1298C18.814 23.6494 19.2034 23.26 19.6837 23.26H21.5256C22.006 23.26 22.3954 23.6494 22.3954 24.1298C22.3954 24.6101 22.006 24.9996 21.5256 24.9996H19.6837C19.2034 24.9996 18.814 24.6101 18.814 24.1298Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.814 24.1298C18.814 23.6494 19.2034 23.26 19.6837 23.26H21.5256C22.006 23.26 22.3954 23.6494 22.3954 24.1298C22.3954 24.6101 22.006 24.9996 21.5256 24.9996H19.6837C19.2034 24.9996 18.814 24.6101 18.814 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 24.1298C23.4185 23.6494 23.8079 23.26 24.2882 23.26H26.1301C26.6105 23.26 26.9999 23.6494 26.9999 24.1298C26.9999 24.6101 26.6105 24.9996 26.1301 24.9996H24.2882C23.8079 24.9996 23.4185 24.6101 23.4185 24.1298Z" fill="#CECECE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4185 24.1298C23.4185 23.6494 23.8079 23.26 24.2882 23.26H26.1301C26.6105 23.26 26.9999 23.6494 26.9999 24.1298C26.9999 24.6101 26.6105 24.9996 26.1301 24.9996H24.2882C23.8079 24.9996 23.4185 24.6101 23.4185 24.1298Z" fill="#6A6AF4" fill-opacity="0.44"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_3239_3207" x="-1.5" y="0.5" width="36" height="36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="2"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3239_3207"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3239_3207" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_3239_3207" x1="16" y1="5.5" x2="26" y2="23.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3239_3207" x1="9.75363" y1="1.56622" x2="15.1333" y2="29.9727" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6A6AF4"/>
|
||||
<stop offset="1" stop-color="#6A6AF4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3239_3207" x1="10" y1="-1" x2="18.5564" y2="36.5113" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6A6AF4" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#6A6AF4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_3239_3207" x1="16" y1="5.5" x2="16" y2="27.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_3239_3207" x1="13" y1="9.5" x2="17.459" y2="24.5122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6A6AF4" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#6A6AF4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
3
src/frontend/apps/calendars/src/assets/feedback/form.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.297 8.92893C15.8505 8.92893 15.5005 8.79702 15.2468 8.53321C14.9931 8.2694 14.8663 7.89905 14.8663 7.42217V5.82409C14.8663 5.3472 14.9931 4.97685 15.2468 4.71304C15.5005 4.44923 15.8505 4.31733 16.297 4.31733H17.682C17.7327 3.45487 18.0726 2.71925 18.7017 2.11045C19.3409 1.50166 20.1019 1.19727 20.9847 1.19727C21.8674 1.19727 22.6233 1.50166 23.2524 2.11045C23.8917 2.71925 24.2366 3.45487 24.2874 4.31733H25.6876C26.134 4.31733 26.4841 4.44923 26.7378 4.71304C26.9914 4.97685 27.1183 5.3472 27.1183 5.82409V7.42217C27.1183 7.89905 26.9914 8.2694 26.7378 8.53321C26.4841 8.79702 26.134 8.92893 25.6876 8.92893H16.297ZM20.9847 5.76321C21.3601 5.76321 21.6746 5.6313 21.9283 5.36749C22.182 5.10368 22.3088 4.79421 22.3088 4.43908C22.3088 4.06366 22.182 3.74912 21.9283 3.49546C21.6746 3.23165 21.3601 3.09974 20.9847 3.09974C20.6194 3.09974 20.3049 3.23165 20.041 3.49546C19.7874 3.74912 19.6605 4.06366 19.6605 4.43908C19.6605 4.79421 19.7874 5.10368 20.041 5.36749C20.3049 5.6313 20.6194 5.76321 20.9847 5.76321ZM12.903 38.0749C11.3302 38.0749 10.1482 37.6741 9.35674 36.8725C8.57546 36.0811 8.18481 34.8888 8.18481 33.2958V10.2226C8.18481 8.66004 8.56024 7.4729 9.31108 6.66118C10.0721 5.84945 11.2136 5.44359 12.7355 5.44359H13.1008C13.0907 5.50447 13.0856 5.57042 13.0856 5.64145C13.0856 5.70233 13.0856 5.76321 13.0856 5.82409V7.19387C13.0856 7.47797 13.1059 7.71134 13.1465 7.89398H12.7964C12.076 7.89398 11.5332 8.10706 11.1679 8.53321C10.8128 8.95937 10.6352 9.53265 10.6352 10.2531V33.2654C10.6352 34.0264 10.8381 34.6098 11.244 35.0157C11.6499 35.4215 12.2485 35.6245 13.0399 35.6245H28.9446C29.7361 35.6245 30.3296 35.4215 30.7253 35.0157C31.1312 34.6098 31.3341 34.0264 31.3341 33.2654V22.6724L33.7845 20.222V33.2958C33.7845 34.8888 33.3888 36.0811 32.5974 36.8725C31.8161 37.6741 30.6391 38.0749 29.0664 38.0749H12.903ZM31.3341 12.5817V10.2531C31.3341 9.53265 31.1515 8.95937 30.7862 8.53321C30.4311 8.10706 29.8984 7.89398 29.1881 7.89398H28.8229C28.8635 7.71134 28.8838 7.47797 28.8838 7.19387V5.82409C28.8838 5.76321 28.8838 5.70233 28.8838 5.64145C28.8838 5.57042 28.8787 5.50447 28.8685 5.44359H29.2338C30.7152 5.44359 31.8415 5.80887 32.6126 6.53942C33.3939 7.26997 33.7845 8.29984 33.7845 9.62904V10.1313C33.7135 10.2023 33.6425 10.2733 33.5715 10.3444C33.5004 10.4154 33.4294 10.4864 33.3584 10.5574L31.3341 12.5817ZM13.3139 16.478C13.3139 16.2141 13.4052 15.9909 13.5878 15.8083C13.7806 15.6155 14.0089 15.5191 14.2727 15.5191H27.7118C27.9249 15.5191 28.1075 15.5648 28.2597 15.6561L26.4942 17.4216H14.2727C14.0089 17.4216 13.7806 17.3303 13.5878 17.1476C13.4052 16.9548 13.3139 16.7316 13.3139 16.478ZM13.3139 21.8658C13.3139 21.6121 13.4052 21.394 13.5878 21.2113C13.7806 21.0287 14.0089 20.9374 14.2727 20.9374H22.9785L21.0912 22.8246H14.2727C14.0089 22.8246 13.7806 22.7333 13.5878 22.5507C13.4052 22.3579 13.3139 22.1296 13.3139 21.8658ZM14.2727 28.5168C14.0089 28.5168 13.7806 28.4255 13.5878 28.2429C13.4052 28.0501 13.3139 27.8269 13.3139 27.5732C13.3139 27.3094 13.4052 27.0862 13.5878 26.9035C13.7806 26.7209 14.0089 26.6296 14.2727 26.6296H16.3883C16.6521 26.6296 16.8753 26.7209 17.058 26.9035C17.2507 27.0862 17.3471 27.3094 17.3471 27.5732C17.3471 27.8269 17.2507 28.0501 17.058 28.2429C16.8753 28.4255 16.6521 28.5168 16.3883 28.5168H14.2727ZM36.8894 14.2102L34.7738 12.0794L35.9153 10.9379C36.169 10.6843 36.4632 10.5473 36.7981 10.527C37.143 10.5067 37.4373 10.6183 37.6808 10.8618L38.0461 11.2271C38.3099 11.4909 38.4418 11.7953 38.4418 12.1403C38.4418 12.4751 38.3048 12.7795 38.0309 13.0535L36.8894 14.2102ZM19.828 29.5365C19.6859 29.5974 19.554 29.5619 19.4323 29.43C19.3105 29.2981 19.2851 29.1662 19.3562 29.0343L20.8173 26.036L33.6171 13.2361L35.7631 15.3517L22.9328 28.1515L19.828 29.5365Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="42" height="43" viewBox="0 0 42 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.588 37.7378C13.0603 37.7378 12.6494 37.5603 12.3552 37.2051C12.0711 36.8602 11.929 36.3934 11.929 35.8049V31.8478H11.1985C9.68662 31.8478 8.41323 31.5789 7.37828 31.0411C6.34334 30.4932 5.55698 29.7018 5.01921 28.6668C4.49159 27.6319 4.22778 26.3686 4.22778 24.8771V13.2187C4.22778 11.7272 4.49159 10.4639 5.01921 9.42899C5.55698 8.39404 6.34334 7.60768 7.37828 7.06992C8.41323 6.522 9.68662 6.24805 11.1985 6.24805H30.8016C32.3134 6.24805 33.5868 6.522 34.6217 7.06992C35.6567 7.60768 36.438 8.39404 36.9656 9.42899C37.5034 10.4639 37.7722 11.7272 37.7722 13.2187V24.8771C37.7722 26.3686 37.5034 27.6319 36.9656 28.6668C36.438 29.7018 35.6567 30.4932 34.6217 31.0411C33.5868 31.5789 32.3134 31.8478 30.8016 31.8478H21.0305L15.7796 36.5202C15.323 36.9261 14.9375 37.2305 14.6229 37.4334C14.3084 37.6364 13.9634 37.7378 13.588 37.7378ZM14.212 34.9526L19.0823 30.1127C19.3664 29.8185 19.6404 29.6257 19.9042 29.5344C20.168 29.443 20.513 29.3974 20.9391 29.3974H30.8016C32.3337 29.3974 33.4701 29.0169 34.2108 28.2559C34.9515 27.4848 35.3218 26.3534 35.3218 24.8619V13.2187C35.3218 11.7373 34.9515 10.6161 34.2108 9.85514C33.4701 9.084 32.3337 8.69844 30.8016 8.69844H11.1985C9.65618 8.69844 8.5147 9.084 7.774 9.85514C7.04345 10.6161 6.67817 11.7373 6.67817 13.2187V24.8619C6.67817 26.3534 7.04345 27.4848 7.774 28.2559C8.5147 29.0169 9.65618 29.3974 11.1985 29.3974H13.0705C13.4865 29.3974 13.7807 29.4836 13.9532 29.6561C14.1257 29.8286 14.212 30.1229 14.212 30.5389V34.9526ZM13.1618 14.8472C12.9081 14.8472 12.6951 14.761 12.5226 14.5885C12.3602 14.416 12.2791 14.208 12.2791 13.9645C12.2791 13.721 12.3602 13.518 12.5226 13.3557C12.6951 13.1832 12.9081 13.097 13.1618 13.097H28.6404C28.894 13.097 29.102 13.1832 29.2644 13.3557C29.4369 13.518 29.5231 13.721 29.5231 13.9645C29.5231 14.208 29.4369 14.416 29.2644 14.5885C29.102 14.761 28.894 14.8472 28.6404 14.8472H13.1618ZM13.1618 19.7937C12.9081 19.7937 12.6951 19.7125 12.5226 19.5502C12.3602 19.3777 12.2791 19.1646 12.2791 18.9109C12.2791 18.6776 12.3602 18.4746 12.5226 18.3021C12.6951 18.1195 12.9081 18.0282 13.1618 18.0282H28.6404C28.894 18.0282 29.102 18.1195 29.2644 18.3021C29.4369 18.4746 29.5231 18.6776 29.5231 18.9109C29.5231 19.1646 29.4369 19.3777 29.2644 19.5502C29.102 19.7125 28.894 19.7937 28.6404 19.7937H13.1618ZM13.1618 24.7553C12.9081 24.7553 12.6951 24.6742 12.5226 24.5118C12.3602 24.3393 12.2791 24.1313 12.2791 23.8878C12.2791 23.6341 12.3602 23.4211 12.5226 23.2486C12.6951 23.0761 12.9081 22.9898 13.1618 22.9898H23.2221C23.4656 22.9898 23.6736 23.0761 23.8461 23.2486C24.0186 23.4211 24.1049 23.6341 24.1049 23.8878C24.1049 24.1313 24.0186 24.3393 23.8461 24.5118C23.6736 24.6742 23.4656 24.7553 23.2221 24.7553H13.1618Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="42" height="43" viewBox="0 0 42 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.1288 27.1205C13.8568 27.1205 13.6502 27.0399 13.5092 26.8787C13.3783 26.7074 13.3128 26.4908 13.3128 26.2289C13.3128 25.8562 13.4588 25.3676 13.751 24.7631C14.0431 24.1486 14.4814 23.5341 15.0656 22.9196C15.66 22.3051 16.4105 21.7913 17.3172 21.3783C18.2239 20.9552 19.2867 20.7436 20.5056 20.7436C21.7346 20.7436 22.7975 20.9552 23.694 21.3783C24.6007 21.7913 25.3512 22.3051 25.9456 22.9196C26.5399 23.5341 26.9832 24.1486 27.2754 24.7631C27.5675 25.3676 27.7136 25.8562 27.7136 26.2289C27.7136 26.4908 27.6431 26.7074 27.502 26.8787C27.3711 27.0399 27.1645 27.1205 26.8825 27.1205H14.1288ZM20.5056 19.6103C19.8407 19.6103 19.2363 19.439 18.6923 19.0965C18.1584 18.754 17.7302 18.2906 17.4079 17.7063C17.0855 17.1119 16.9243 16.442 16.9243 15.6965C16.9243 15.0014 17.0855 14.3617 17.4079 13.7774C17.7302 13.1931 18.1584 12.7297 18.6923 12.3872C19.2363 12.0346 19.8407 11.8583 20.5056 11.8583C21.1705 11.8583 21.7699 12.0346 22.3038 12.3872C22.8478 12.7297 23.281 13.1931 23.6034 13.7774C23.9257 14.3617 24.0869 15.0014 24.0869 15.6965C24.0869 16.442 23.9257 17.1119 23.6034 17.7063C23.281 18.2906 22.8478 18.754 22.3038 19.0965C21.7699 19.439 21.1705 19.6103 20.5056 19.6103ZM13.1465 38.1968C12.6227 38.1968 12.2147 38.0205 11.9226 37.6679C11.6405 37.3254 11.4994 36.862 11.4994 36.2777V32.3489H10.7741C9.27309 32.3489 8.0088 32.0819 6.98125 31.548C5.9537 31.004 5.17297 30.2182 4.63904 29.1907C4.1152 28.1631 3.85327 26.9089 3.85327 25.428V13.853C3.85327 12.3721 4.1152 11.1179 4.63904 10.0903C5.17297 9.06278 5.9537 8.28205 6.98125 7.74812C8.0088 7.20413 9.27309 6.93213 10.7741 6.93213H30.2371C31.7381 6.93213 33.0024 7.20413 34.03 7.74812C35.0575 8.28205 35.8332 9.06278 36.3571 10.0903C36.891 11.1179 37.158 12.3721 37.158 13.853V25.428C37.158 26.9089 36.891 28.1631 36.3571 29.1907C35.8332 30.2182 35.0575 31.004 34.03 31.548C33.0024 32.0819 31.7381 32.3489 30.2371 32.3489H20.5358L15.3225 36.9879C14.8692 37.3909 14.4864 37.6931 14.1741 37.8946C13.8618 38.0961 13.5193 38.1968 13.1465 38.1968ZM13.7661 35.4315L18.6016 30.6262C18.8837 30.3341 19.1557 30.1427 19.4176 30.052C19.6795 29.9613 20.0221 29.916 20.4452 29.916H30.2371C31.7583 29.916 32.8866 29.5382 33.622 28.7827C34.3574 28.017 34.7251 26.8938 34.7251 25.4129V13.853C34.7251 12.3822 34.3574 11.269 33.622 10.5134C32.8866 9.74782 31.7583 9.365 30.2371 9.365H10.7741C9.24287 9.365 8.10954 9.74782 7.37414 10.5134C6.64881 11.269 6.28615 12.3822 6.28615 13.853V25.4129C6.28615 26.8938 6.64881 28.017 7.37414 28.7827C8.10954 29.5382 9.24287 29.916 10.7741 29.916H12.6328C13.0458 29.916 13.338 30.0016 13.5092 30.1729C13.6805 30.3441 13.7661 30.6363 13.7661 31.0493V35.4315Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
3
src/frontend/apps/calendars/src/assets/icons/cancel.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.4 17.5L12 13.9L15.6 17.5L17 16.1L13.4 12.5L17 8.9L15.6 7.5L12 11.1L8.4 7.5L7 8.9L10.6 12.5L7 16.1L8.4 17.5ZM12 22.5C10.6167 22.5 9.31667 22.2375 8.1 21.7125C6.88333 21.1875 5.825 20.475 4.925 19.575C4.025 18.675 3.3125 17.6167 2.7875 16.4C2.2625 15.1833 2 13.8833 2 12.5C2 11.1167 2.2625 9.81667 2.7875 8.6C3.3125 7.38333 4.025 6.325 4.925 5.425C5.825 4.525 6.88333 3.8125 8.1 3.2875C9.31667 2.7625 10.6167 2.5 12 2.5C13.3833 2.5 14.6833 2.7625 15.9 3.2875C17.1167 3.8125 18.175 4.525 19.075 5.425C19.975 6.325 20.6875 7.38333 21.2125 8.6C21.7375 9.81667 22 11.1167 22 12.5C22 13.8833 21.7375 15.1833 21.2125 16.4C20.6875 17.6167 19.975 18.675 19.075 19.575C18.175 20.475 17.1167 21.1875 15.9 21.7125C14.6833 22.2375 13.3833 22.5 12 22.5ZM12 20.5C14.2333 20.5 16.125 19.725 17.675 18.175C19.225 16.625 20 14.7333 20 12.5C20 10.2667 19.225 8.375 17.675 6.825C16.125 5.275 14.2333 4.5 12 4.5C9.76667 4.5 7.875 5.275 6.325 6.825C4.775 8.375 4 10.2667 4 12.5C4 14.7333 4.775 16.625 6.325 18.175C7.875 19.725 9.76667 20.5 12 20.5Z" fill="#3A3A3A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.4 17.5L12 13.9L15.6 17.5L17 16.1L13.4 12.5L17 8.9L15.6 7.5L12 11.1L8.4 7.5L7 8.9L10.6 12.5L7 16.1L8.4 17.5ZM12 22.5C10.6167 22.5 9.31667 22.2375 8.1 21.7125C6.88333 21.1875 5.825 20.475 4.925 19.575C4.025 18.675 3.3125 17.6167 2.7875 16.4C2.2625 15.1833 2 13.8833 2 12.5C2 11.1167 2.2625 9.81667 2.7875 8.6C3.3125 7.38333 4.025 6.325 4.925 5.425C5.825 4.525 6.88333 3.8125 8.1 3.2875C9.31667 2.7625 10.6167 2.5 12 2.5C13.3833 2.5 14.6833 2.7625 15.9 3.2875C17.1167 3.8125 18.175 4.525 19.075 5.425C19.975 6.325 20.6875 7.38333 21.2125 8.6C21.7375 9.81667 22 11.1167 22 12.5C22 13.8833 21.7375 15.1833 21.2125 16.4C20.6875 17.6167 19.975 18.675 19.075 19.575C18.175 20.475 17.1167 21.1875 15.9 21.7125C14.6833 22.2375 13.3833 22.5 12 22.5ZM12 20.5C14.2333 20.5 16.125 19.725 17.675 18.175C19.225 16.625 20 14.7333 20 12.5C20 10.2667 19.225 8.375 17.675 6.825C16.125 5.275 14.2333 4.5 12 4.5C9.76667 4.5 7.875 5.275 6.325 6.825C4.775 8.375 4 10.2667 4 12.5C4 14.7333 4.775 16.625 6.325 18.175C7.875 19.725 9.76667 20.5 12 20.5Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.66699 14C4.30033 14 3.98644 13.8694 3.72533 13.6083C3.46421 13.3472 3.33366 13.0333 3.33366 12.6667V4H2.66699V2.66667H6.00033V2H10.0003V2.66667H13.3337V4H12.667V12.6667C12.667 13.0333 12.5364 13.3472 12.2753 13.6083C12.0142 13.8694 11.7003 14 11.3337 14H4.66699ZM6.00033 11.3333H7.33366V5.33333H6.00033V11.3333ZM8.66699 11.3333H10.0003V5.33333H8.66699V11.3333Z" fill="#929292"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |