🎉(all) bootstrap the Calendars project

This repository was forked from Drive in late December 2025 and
boostraped as a minimal demo of backend+caldav server+frontend
integration. There is much left to do and to fix!
This commit is contained in:
Sylvain Zimmer
2026-01-09 00:51:25 +01:00
commit a36348ead1
298 changed files with 41036 additions and 0 deletions

33
src/backend/.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.vscode
.iml
.idea
db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache

472
src/backend/.pylintrc Normal file
View File

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

158
src/backend/Dockerfile Normal file
View File

@@ -0,0 +1,158 @@
# Django calendars
# ---- base image to inherit from ----
FROM python:3.13.9-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
# We must do taht to avoid having an outdated pip version with security issues
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
apk add git
# ---- Back-end builder image ----
FROM base AS back-builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- static link collector ----
FROM base AS link-collector
ARG CALENDARS_STATIC_ROOT=/data/static
# Install pango & rdfind
RUN apk add \
pango \
rdfind
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${CALENDARS_STATIC_ROOT}
# ---- Core application image ----
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apk add \
cairo \
file \
font-noto \
font-noto-emoji \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Give the "root" group the same permissions as the "root" user on /etc/passwd
# to allow a user belonging to the root group to add new users; typically the
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
ENTRYPOINT [ "/app/entrypoint" ]
# ---- Development image ----
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apk add postgresql-client
# Install uv for development dependencies
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
# Install development dependencies (ensure venv is created and used)
RUN uv sync --all-extras --locked
# Ensure venv is accessible and PATH is set
ENV PATH="/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"
# Restore the un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core AS backend-production
ARG CALENDARS_STATIC_ROOT=/data/static
# Remove git, we don't need it in the production image
RUN apk del git
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/calendars.py /usr/local/etc/gunicorn/calendars.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy statics
COPY --from=link-collector ${CALENDARS_STATIC_ROOT} ${CALENDARS_STATIC_ROOT}
# The default command runs gunicorn WSGI server in calendars' main module
CMD ["gunicorn", "-c", "/app/gunicorn.conf.py", "calendars.wsgi:application"]

3
src/backend/MANIFEST.in Normal file
View File

@@ -0,0 +1,3 @@
include LICENSE
include README.md
recursive-include src/backend/calendars *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2

0
src/backend/__init__.py Normal file
View File

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
{
"footer": {
"default": {
"externalLinks": [
{
"label": "Github",
"href": "https://github.com/suitenumerique/calendars/"
},
{
"label": "ANCT",
"href": "https://anct.gouv.fr/"
}
],
"license": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "#"
},
{
"label": "Personal data and cookies",
"href": "#"
},
{
"label": "Accessibility: non compliant",
"href": "#"
}
],
"license": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "#"
},
{
"label": "Données personnelles et cookies",
"href": "#"
},
{
"label": "Accessibilité: non conforme",
"href": "#"
}
],
"license": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
}
}
}

919
src/backend/calendars/settings.py Executable file
View File

@@ -0,0 +1,919 @@
"""
Django settings for calendar project.
Generated by 'django-admin startproject' using Django 3.1.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
# pylint: disable=too-many-lines
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.environ.get("DATA_DIR", os.path.join("/", "data"))
def get_release():
"""
Get the current release of the application
"""
try:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
is recommended to configure third-party applications by creating a configuration mixins in
./configurations and compose the Base configuration with those mixins.
It depends on an environment variable that SHOULD be defined:
* DJANGO_SECRET_KEY
You may also want to override default configuration by setting the following environment
variables:
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
* DB_USER
"""
DEBUG = False
LOAD_E2E_URLS = False
USE_SWAGGER = False
API_VERSION = "v1.0"
# DAViCal CalDAV server URL
DAVICAL_URL = values.Value(
"http://davical:80", environ_name="DAVICAL_URL", environ_prefix=None
)
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = SecretFileValue(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
ROOT_URLCONF = "calendars.urls"
WSGI_APPLICATION = "calendars.wsgi.application"
# Database
DATABASES = {
"default": dj_database_url.config()
if os.environ.get("DATABASE_URL")
else {
"ENGINE": values.Value(
"django.db.backends.postgresql",
environ_name="DB_ENGINE",
environ_prefix=None,
),
"NAME": values.Value(
"calendars", environ_name="DB_NAME", environ_prefix=None
),
"USER": values.Value("pgroot", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": SecretFileValue(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
"localhost", environ_name="DB_HOST", environ_prefix=None
),
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_URL_PREVIEW = "/media/preview/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
),
},
}
# Maximum size of the request body in memory.
# This is used to limit the size of the request body in memory.
# This also limits the size of the file that can be uploaded to the server.
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
2 * (2**30), # 2GB
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "calendar_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
# if is set to False, dict python is returned
"querydict": False,
}
# Careful! Languages should be ordered by priority, as this tuple is used to get
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("nl-nl", _("Dutch")),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Templates
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.csrf",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.request",
"django.template.context_processors.tz",
],
"loaders": [
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
},
},
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"core.authentication.backends.OIDCAuthenticationBackend",
]
# Django applications from the highest priority to the lowest
INSTALLED_APPS = [
"core",
"drf_spectacular",
"drf_standardized_errors",
# Third party apps
"corsheaders",
"django_celery_beat",
"django_filters",
"rest_framework",
"rest_framework_api_key",
"parler",
# Django
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.postgres",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
]
# Cache
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_DEFAULT_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": values.Value(
"calendar",
environ_name="CACHES_DEFAULT_KEY_PREFIX",
environ_prefix=None,
),
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_SESSION_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"DEFAULT_RENDERER_CLASSES": [
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
# see all the data in the database (ie a serializer with a ForeignKey field
# will generate a form with a field with all possible values of the FK).
"rest_framework.renderers.JSONRenderer",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
"DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value(
default="180/hour",
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
environ_prefix=None,
),
"user_list_burst": values.Value(
default="30/minute",
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
},
}
MAX_PAGE_SIZE = values.PositiveIntegerValue(
200, environ_name="MAX_PAGE_SIZE", environ_prefix=None
)
SPECTACULAR_SETTINGS = {
"TITLE": "calendar API",
"DESCRIPTION": "This is the calendar API schema.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue(
default=False,
environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK",
),
"COMPONENT_SPLIT_REQUEST": True,
# OTHER SETTINGS
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.Value(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Allow CalDAV methods (PROPFIND, REPORT, etc.)
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"PROPFIND",
"REPORT",
"MKCOL",
"MKCALENDAR",
]
# Allow CalDAV headers (case-sensitive for CORS preflight)
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"depth", # CalDAV header (lowercase as sent by browsers)
"if-match",
"if-none-match",
"prefer",
]
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
FRONTEND_MORE_LINK = values.Value(
None,
environ_name="FRONTEND_MORE_LINK",
environ_prefix=None,
)
FRONTEND_FEEDBACK_BUTTON_SHOW = values.BooleanValue(
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_SHOW", environ_prefix=None
)
# For instance, you might want to bind this button to an external library to trigger survey instead of the build in feedback modal.
FRONTEND_FEEDBACK_BUTTON_IDLE = values.BooleanValue(
default=False, environ_name="FRONTEND_FEEDBACK_BUTTON_IDLE", environ_prefix=None
)
FRONTEND_FEEDBACK_ITEMS = values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK_ITEMS", environ_prefix=None
)
FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
environ_prefix=None,
)
FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL = values.Value(
None,
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
environ_prefix=None,
)
FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL = values.Value(
None,
environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
environ_prefix=None,
)
FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH = values.Value(
None, environ_name="FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH", environ_prefix=None
)
FRONTEND_HIDE_GAUFRE = values.BooleanValue(
default=False, environ_name="FRONTEND_HIDE_GAUFRE", environ_prefix=None
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "calendars/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
environ_prefix=None,
)
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
environ_prefix=None,
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "session"
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
OIDC_RP_CLIENT_ID = values.Value(
"calendar", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_JWKS_ENDPOINT = values.Value(
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
)
OIDC_OP_TOKEN_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
LOGIN_REDIRECT_URL_FAILURE = values.Value(
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
)
LOGOUT_REDIRECT_URL = values.Value(
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
)
OIDC_USE_NONCE = values.BooleanValue(
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
)
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
)
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
)
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
)
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
)
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
default=None,
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
environ_prefix=None,
)
# OIDC claims to store
OIDC_STORE_CLAIMS = values.ListValue(
default=[],
environ_name="OIDC_STORE_CLAIMS",
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
OIDC_USER_INFO = values.ListValue(
default=values.ListValue( # retrocompatibility
default=[],
environ_name="USER_OIDC_ESSENTIAL_CLAIMS",
environ_prefix=None,
),
environ_name="OIDC_USER_INFO",
environ_prefix=None,
)
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["first_name", "last_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
)
OIDC_RS_BACKEND_CLASS = values.Value(
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
environ_name="OIDC_RS_BACKEND_CLASS",
environ_prefix=None,
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
default=[],
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
environ_prefix=None,
)
# External API Configuration
# Configure available routes and actions for external_api endpoints
EXTERNAL_API = values.DictValue(
default={
"users": {
"enabled": True,
"actions": ["get_me"],
},
},
environ_name="EXTERNAL_API",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": True,
},
},
}
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
# Storage compute
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
"""Environment in which the application is launched."""
return self.__class__.__name__.lower()
# pylint: disable=invalid-name
@property
def RELEASE(self):
"""
Return the release information.
Delegate to the module function to enable easier testing.
"""
return get_release()
# pylint: disable=invalid-name
@property
def PARLER_LANGUAGES(self):
"""
Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings.
"""
return {
self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES),
"default": {
"fallbacks": [self.LANGUAGE_CODE],
"hide_untranslated": False,
},
}
@classmethod
def post_setup(cls):
"""Post setup configuration.
This is the place where you can configure settings that require other
settings to be loaded.
"""
super().post_setup()
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):
"""Settings used when the application is built.
This environment should not be used to run the application. Just to build it with non-blocking
settings.
"""
SESSION_CACHE_ALIAS = "default"
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
}
SECRET_KEY = values.Value("DummyKey")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
),
},
}
class Development(Base):
"""
Development environment settings
We set DEBUG to True and configure the server to respond from all hosts.
"""
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8920",
]
DEBUG = True
LOAD_E2E_URLS = True
SESSION_COOKIE_NAME = "calendar_sessionid"
USE_SWAGGER = True
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
}
def __init__(self):
# pylint: disable=invalid-name
self.MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
# pylint: disable=invalid-name
self.INSTALLED_APPS += [
"django_extensions",
"drf_spectacular_sidecar",
"debug_toolbar",
"e2e",
]
class Test(Base):
"""Test environment settings"""
SESSION_CACHE_ALIAS = "default"
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
}
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
OIDC_STORE_ACCESS_TOKEN = False
OIDC_STORE_REFRESH_TOKEN = False
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar", "e2e"]
class ContinuousIntegration(Test):
"""
Continuous Integration environment settings
nota bene: it should inherit from the Test environment.
"""
class Production(Base):
"""
Production environment settings
You must define the ALLOWED_HOSTS environment variable in Production
configuration (and derived configurations):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
# Security
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
# object when your application is behind a reverse proxy.
#
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
# - your Django app is behind a proxy.
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
#
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
class Feature(Production):
"""
Feature environment settings
nota bene: it should inherit from the Production environment.
"""
class Staging(Production):
"""
Staging environment settings
nota bene: it should inherit from the Production environment.
"""
class PreProduction(Production):
"""
Pre-production environment settings
nota bene: it should inherit from the Production environment.
"""

View File

@@ -0,0 +1,58 @@
"""URL configuration for the calendars project"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularJSONAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from core.api.viewsets_caldav import CalDAVDiscoveryView
urlpatterns = [
path("admin/", admin.site.urls),
# CalDAV discovery - must be at root level per RFC 6764
path(".well-known/caldav", CalDAVDiscoveryView.as_view(), name="caldav-discovery"),
path("", include("core.urls")),
]
if settings.DEBUG:
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+ debug_toolbar_urls()
)
if settings.LOAD_E2E_URLS:
urlpatterns += [path("", include("e2e.urls"))]
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"api/{settings.API_VERSION}/swagger.json",
SpectacularJSONAPIView.as_view(
api_version=settings.API_VERSION,
urlconf="core.urls",
),
name="client-api-schema",
),
path(
f"api/{settings.API_VERSION}/swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"api/{settings.API_VERSION}/redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),
]

View File

@@ -0,0 +1,17 @@
"""
WSGI config for the calendars project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
application = get_wsgi_application()

View File

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

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

View File

@@ -0,0 +1,34 @@
"""Calendars core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from drf_standardized_errors.handler import exception_handler as drf_exception_handler
from rest_framework import exceptions as drf_exceptions
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework.serializers import as_serializer_error
def exception_handler(exc, context):
"""Handle Django ValidationError as an accepted exception.
For the parameters, see ``exception_handler``
This code comes from twidi's gist:
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, DjangoValidationError):
exc = drf_exceptions.ValidationError(as_serializer_error(exc))
return drf_exception_handler(exc, context)
# pylint: disable=unused-argument
@api_view(["GET"])
def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)

View File

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

View File

@@ -0,0 +1,79 @@
"""Permission handlers for the calendars core app."""
from django.core import exceptions
from rest_framework import permissions
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
class IsAuthenticated(permissions.BasePermission):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
"""
def has_permission(self, request, view):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
"""
def has_object_permission(self, request, view, obj):
"""Write permissions are only allowed to the user itself."""
return obj == request.user
class IsOwnedOrPublic(IsAuthenticated):
"""
Allows access to authenticated users only for objects that are owned or not related
to any user via the "owner" field.
"""
def has_object_permission(self, request, view, obj):
"""Unsafe permissions are only allowed for the owner of the object."""
if obj.owner == request.user:
return True
if request.method in permissions.SAFE_METHODS and obj.owner is None:
return True
try:
return obj.user == request.user
except exceptions.ObjectDoesNotExist:
return False
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action not in [
"create",
]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)

View File

@@ -0,0 +1,155 @@
"""Client serializers for the calendars core app."""
import json
from datetime import timedelta
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, serializers
from core import models
class UserLiteSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"language",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
class Meta:
model = models.User
fields = UserSerializer.Meta.fields
read_only_fields = UserSerializer.Meta.read_only_fields
# CalDAV serializers
class CalendarSerializer(serializers.ModelSerializer):
"""Serializer for Calendar model."""
class Meta:
model = models.Calendar
fields = [
"id",
"name",
"color",
"description",
"is_default",
"is_visible",
"created_at",
"updated_at",
]
read_only_fields = ["id", "is_default", "created_at", "updated_at"]
class CalendarCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating a Calendar."""
class Meta:
model = models.Calendar
fields = ["name", "color", "description"]
class CalendarShareSerializer(serializers.ModelSerializer):
"""Serializer for CalendarShare model."""
shared_with_email = serializers.EmailField(write_only=True)
class Meta:
model = models.CalendarShare
fields = ["id", "shared_with_email", "permission", "is_visible", "created_at"]
read_only_fields = ["id", "created_at"]

View File

@@ -0,0 +1,397 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
import re
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models as db
from django.db import transaction
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.text import slugify
import rest_framework as drf
from corsheaders.middleware import (
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN,
)
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, mixins, status, viewsets
from rest_framework import response as drf_response
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from rest_framework_api_key.permissions import HasAPIKey
from core import enums, models
from core.services.caldav_service import CalendarService
from . import permissions, serializers
logger = logging.getLogger(__name__)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = settings.MAX_PAGE_SIZE
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
SerializerPerActionMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.ListModelMixin,
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
pagination_class = None
throttle_classes = []
def get_throttles(self):
self.throttle_classes = []
if self.action == "list":
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
return super().get_throttles()
def get_queryset(self):
"""
Limit listed users by querying the email field.
If query contains "@", search exactly. Otherwise return empty.
"""
queryset = self.queryset
if self.action != "list":
return queryset
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match exactly
if "@" in query:
return queryset.filter(email__iexact=query).order_by("email")[
: settings.API_USERS_LIST_LIMIT
]
# For non-email queries, return empty (no fuzzy search)
return queryset.none()
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
)
def get_me(self, request):
"""
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
self.get_serializer(request.user, context=context).data
)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"ENVIRONMENT",
"FRONTEND_THEME",
"FRONTEND_MORE_LINK",
"FRONTEND_FEEDBACK_BUTTON_SHOW",
"FRONTEND_FEEDBACK_BUTTON_IDLE",
"FRONTEND_FEEDBACK_ITEMS",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH",
"FRONTEND_HIDE_GAUFRE",
"MEDIA_BASE_URL",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
return {}
cache_key = (
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
)
theme_customization = cache.get(cache_key, {})
if theme_customization:
return theme_customization
try:
with open(
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
) as f:
theme_customization = json.load(f)
except FileNotFoundError:
logger.error(
"Configuration file not found: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
except json.JSONDecodeError:
logger.error(
"Configuration file is not a valid JSON: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
else:
cache.set(
cache_key,
theme_customization,
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
)
return theme_customization
# CalDAV ViewSets
class CalendarViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
ViewSet for managing user calendars.
list: Get all calendars accessible by the user (owned + shared)
retrieve: Get a specific calendar
create: Create a new calendar
update: Update calendar properties
destroy: Delete a calendar
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSerializer
def get_queryset(self):
"""Return calendars owned by or shared with the current user."""
user = self.request.user
owned = models.Calendar.objects.filter(owner=user)
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
"calendar_id", flat=True
)
shared = models.Calendar.objects.filter(id__in=shared_ids)
return owned.union(shared).order_by("-is_default", "name")
def get_serializer_class(self):
if self.action == "create":
return serializers.CalendarCreateSerializer
return serializers.CalendarSerializer
def perform_create(self, serializer):
"""Create a new calendar via CalendarService."""
service = CalendarService()
calendar = service.create_calendar(
user=self.request.user,
name=serializer.validated_data["name"],
color=serializer.validated_data.get("color", "#3174ad"),
)
# Update the serializer instance with the created calendar
serializer.instance = calendar
def perform_destroy(self, instance):
"""Delete calendar. Prevent deletion of default calendar."""
if instance.is_default:
raise ValueError("Cannot delete the default calendar.")
if instance.owner != self.request.user:
raise PermissionError("You can only delete your own calendars.")
instance.delete()
@action(detail=True, methods=["patch"])
def toggle_visibility(self, request, pk=None):
"""Toggle calendar visibility."""
calendar = self.get_object()
# Check if it's a shared calendar
share = models.CalendarShare.objects.filter(
calendar=calendar, shared_with=request.user
).first()
if share:
share.is_visible = not share.is_visible
share.save()
is_visible = share.is_visible
elif calendar.owner == request.user:
calendar.is_visible = not calendar.is_visible
calendar.save()
is_visible = calendar.is_visible
else:
return drf_response.Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
return drf_response.Response({"is_visible": is_visible})
@action(
detail=True,
methods=["post"],
serializer_class=serializers.CalendarShareSerializer,
)
def share(self, request, pk=None):
"""Share calendar with another user."""
calendar = self.get_object()
if calendar.owner != request.user:
return drf_response.Response(
{"error": "Only the owner can share this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
serializer = serializers.CalendarShareSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data["shared_with_email"]
try:
user_to_share = models.User.objects.get(email=email)
except models.User.DoesNotExist:
return drf_response.Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
share, created = models.CalendarShare.objects.get_or_create(
calendar=calendar,
shared_with=user_to_share,
defaults={
"permission": serializer.validated_data.get("permission", "read")
},
)
if not created:
share.permission = serializer.validated_data.get(
"permission", share.permission
)
share.save()
return drf_response.Response(
serializers.CalendarShareSerializer(share).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)

View File

@@ -0,0 +1,220 @@
"""CalDAV proxy views for forwarding requests to DAViCal."""
import logging
from django.conf import settings
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.services.caldav_service import DAViCalClient
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVProxyView(View):
"""
Proxy view that forwards all CalDAV requests to DAViCal.
Handles authentication and adds appropriate headers.
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
(PROPFIND, REPORT, etc.) that don't work with Django's CSRF middleware.
Authentication is handled via session cookies instead.
"""
def dispatch(self, request, *args, **kwargs):
"""Forward all HTTP methods to DAViCal."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, REPORT, MKCOL, MKCALENDAR, PUT, DELETE"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
)
return response
if not request.user.is_authenticated:
return HttpResponse(status=401)
# Ensure user exists in DAViCal before making requests
try:
davical_client = DAViCalClient()
davical_client.ensure_user_exists(request.user)
except Exception as e:
logger.warning("Failed to ensure user exists in DAViCal: %s", str(e))
# Continue anyway - user might already exist
# Build the DAViCal URL
davical_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
path = kwargs.get("path", "")
# Use user email as the principal (DAViCal uses email as username)
user_principal = request.user.email
# Handle root CalDAV requests - return principal collection
if not path or path == user_principal:
# For PROPFIND on root, return the user's principal collection
if request.method == "PROPFIND":
# Get the request path to match the href in response
request_path = request.path
if not request_path.endswith("/"):
request_path += "/"
# Return multistatus with href matching request URL and calendar-home-set
multistatus = f"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>{request_path}</D:href>
<D:propstat>
<D:prop>
<D:displayname>{user_principal}</D:displayname>
<C:calendar-home-set>
<D:href>/api/v1.0/caldav/{user_principal}/</D:href>
</C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
response = HttpResponse(
content=multistatus,
status=207,
content_type="application/xml; charset=utf-8",
)
return response
# For other methods, redirect to principal URL
target_url = f"{davical_url}/caldav.php/{user_principal}/"
else:
# Build target URL with path
# Remove leading slash if present
clean_path = path.lstrip("/")
if clean_path.startswith(user_principal):
# Path already includes principal
target_url = f"{davical_url}/caldav.php/{clean_path}"
else:
# Path is relative to principal
target_url = f"{davical_url}/caldav.php/{user_principal}/{clean_path}"
# Prepare headers for DAViCal
# Set headers to tell DAViCal it's behind a proxy so it generates correct URLs
script_name = "/api/v1.0/caldav"
headers = {
"Content-Type": request.content_type or "application/xml",
"X-Forwarded-User": user_principal,
"X-Forwarded-For": request.META.get("REMOTE_ADDR", ""),
"X-Forwarded-Prefix": script_name,
"X-Forwarded-Host": request.get_host(),
"X-Forwarded-Proto": request.scheme,
"X-Script-Name": script_name, # Tell DAViCal the base path
}
# DAViCal authentication: users with password '*' use external auth
# We send the username via X-Forwarded-User header
# For HTTP Basic Auth, we use the email as username with empty password
# This works with DAViCal's external authentication when trust_x_forwarded is true
auth = (user_principal, "")
# Copy relevant headers from the original request
if "HTTP_DEPTH" in request.META:
headers["Depth"] = request.META["HTTP_DEPTH"]
if "HTTP_IF_MATCH" in request.META:
headers["If-Match"] = request.META["HTTP_IF_MATCH"]
if "HTTP_IF_NONE_MATCH" in request.META:
headers["If-None-Match"] = request.META["HTTP_IF_NONE_MATCH"]
if "HTTP_PREFER" in request.META:
headers["Prefer"] = request.META["HTTP_PREFER"]
# Get request body
body = request.body if request.body else None
try:
# Forward the request to DAViCal
# Use HTTP Basic Auth with username (email) and empty password
# DAViCal will authenticate based on X-Forwarded-User header when trust_x_forwarded is true
logger.debug(
"Forwarding %s request to DAViCal: %s (user: %s)",
request.method,
target_url,
user_principal,
)
response = requests.request(
method=request.method,
url=target_url,
headers=headers,
data=body,
auth=auth,
timeout=30,
allow_redirects=False,
)
# Log authentication failures for debugging
if response.status_code == 401:
logger.warning(
"DAViCal returned 401 for user %s at %s. Headers sent: %s",
user_principal,
target_url,
headers,
)
# Build Django response
django_response = HttpResponse(
content=response.content,
status=response.status_code,
content_type=response.headers.get("Content-Type", "application/xml"),
)
# Copy relevant headers from DAViCal response
for header in ["ETag", "DAV", "Allow", "Location"]:
if header in response.headers:
django_response[header] = response.headers[header]
return django_response
except requests.exceptions.RequestException as e:
logger.error("DAViCal proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
status=502,
content_type="text/plain",
)
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVDiscoveryView(View):
"""
Handle CalDAV discovery requests (well-known URLs).
Per RFC 6764, this endpoint should redirect to the CalDAV server base URL,
not to a user-specific principal. Clients will then perform PROPFIND on
the base URL to discover their principal.
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
and this endpoint should be accessible without authentication.
"""
def dispatch(self, request, *args, **kwargs):
"""Handle discovery requests."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = "GET, OPTIONS, PROPFIND"
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization"
)
return response
# Note: Authentication is not required for discovery per RFC 6764
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/v1.0/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
return response

19
src/backend/core/apps.py Normal file
View File

@@ -0,0 +1,19 @@
"""Calendars Core application"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
"""Configuration class for the calendars core app."""
name = "core"
app_label = "core"
verbose_name = _("calendars core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415

View File

@@ -0,0 +1,52 @@
"""Custom authentication classes for the calendars core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create item server to server'"

View File

@@ -0,0 +1,54 @@
"""Authentication Backends for the Calendars core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.authentication.exceptions import UserCannotAccessApp
from core.models import DuplicateEmailError
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Args:
user_info (dict): The user information dictionary.
Returns:
dict: A dictionary of extra claims.
"""
# We need to add the claims that we want to store so that they are
# available in the post_get_or_create_user method.
claims_to_store = {
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
}
return {
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
"claims": claims_to_store,
}
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err

View File

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

View File

@@ -0,0 +1,25 @@
"""Calendars core authentication views."""
from django.http import HttpResponseRedirect
from lasuite.oidc_login.views import (
OIDCAuthenticationCallbackView as LaSuiteOIDCAuthenticationCallbackView,
)
from core.authentication.exceptions import UserCannotAccessApp
class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView):
"""
Custom view for handling the authentication callback from the OpenID Connect (OIDC) provider.
Handles the callback after authentication from the identity provider (OP).
Verifies the state parameter and performs necessary authentication actions.
"""
def get(self, request):
try:
return super().get(request)
except UserCannotAccessApp:
return HttpResponseRedirect(
self.failure_url + "?auth_error=user_cannot_access_app"
)

12
src/backend/core/enums.py Normal file
View File

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

View File

@@ -0,0 +1,41 @@
"""Resource Server Permissions for the Calendars app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import permissions
class ResourceServerClientPermission(permissions.BasePermission):
"""
Permission class for resource server views.
This provides a way to open the resource server views to a limited set of
Service Providers.
Note: we might add a more complex permission system in the future, based on
the Service Provider ID and the requested scopes.
"""
def has_permission(self, request, view):
"""
Check if the user is authenticated and the token introspection
provides an authorized Service Provider.
"""
if not isinstance(
request.successful_authenticator, ResourceServerAuthentication
):
# Not a resource server request
return False
# Check if the user is authenticated
if not request.user.is_authenticated:
return False
if (
hasattr(view, "resource_server_actions")
and view.action not in view.resource_server_actions
):
return False
# When used as a resource server, the request has a token audience
return (
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
)

View File

@@ -0,0 +1,36 @@
"""Resource Server Viewsets for the Calendars app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import AccessPermission, IsSelf
from core.api.viewsets import UserViewSet
from core.external_api.permissions import ResourceServerClientPermission
# pylint: disable=too-many-ancestors
class ResourceServerRestrictionMixin:
"""
Mixin for Resource Server Viewsets to provide shortcut to get
configured actions for a given resource.
"""
def _get_resource_server_actions(self, resource_name):
"""Get resource_server_actions from settings."""
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
return list(external_api_config.get("actions", []))
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
"""Resource Server Viewset for the Calendars app."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & IsSelf]
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("users")

View File

@@ -0,0 +1,28 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
import factory.fuzzy
from faker import Faker
from core import models
fake = Faker()
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")

View File

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

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.2.9 on 2026-01-08 23:49
import core.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(blank=True, choices=[('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'), ('nl-nl', 'Dutch')], default=None, help_text='The language in which the user wants to see the interface.', max_length=10, null=True, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'calendars_user',
},
managers=[
('objects', core.models.UserManager()),
],
),
migrations.CreateModel(
name='Calendar',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('color', models.CharField(default='#3174ad', max_length=7)),
('description', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('is_visible', models.BooleanField(default=True)),
('davical_path', models.CharField(max_length=512, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-is_default', 'name'],
},
),
migrations.CreateModel(
name='CalendarShare',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('permission', models.CharField(choices=[('read', 'Read only'), ('write', 'Read and write')], default='read', max_length=10)),
('is_visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.calendar')),
('shared_with', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_calendars', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='calendar',
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('owner',), name='unique_default_calendar_per_user'),
),
migrations.AlterUniqueTogether(
name='calendarshare',
unique_together={('calendar', 'shared_with')},
),
]

View File

402
src/backend/core/models.py Normal file
View File

@@ -0,0 +1,402 @@
"""
Declare and configure the models for the calendars core application
"""
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.indexes import GistIndex
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models.expressions import RawSQL
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
logger = getLogger(__name__)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a item."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the item
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the item
PUBLIC = "public", _("Public") # Even anonymous users can access the item
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
null=True,
blank=True,
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
is_device = models.BooleanField(
_("device"),
default=False,
help_text=_("Whether the user is a device or a real user."),
)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
claims = models.JSONField(
blank=True,
default=dict,
help_text=_("Claims from the OIDC token."),
)
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "calendars_user"
verbose_name = _("user")
verbose_name_plural = _("users")
def __str__(self):
return self.email or self.admin_email or str(self.id)
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the items to which s.he was invited.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
"""
return []
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class Calendar(models.Model):
"""
Represents a calendar owned by a user.
This model tracks calendars stored in DAViCal and links them to Django users.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="calendars",
)
name = models.CharField(max_length=255)
color = models.CharField(max_length=7, default="#3174ad") # Hex color
description = models.TextField(blank=True, default="")
is_default = models.BooleanField(default=False)
is_visible = models.BooleanField(default=True)
# DAViCal reference - the calendar path in DAViCal
davical_path = models.CharField(max_length=512, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
"""Meta options for Calendar model."""
ordering = ["-is_default", "name"]
constraints = [
models.UniqueConstraint(
fields=["owner"],
condition=models.Q(is_default=True),
name="unique_default_calendar_per_user",
)
]
def __str__(self):
return f"{self.name} ({self.owner.email})"
class CalendarShare(models.Model):
"""
Represents a calendar shared with another user.
"""
PERMISSION_READ = "read"
PERMISSION_WRITE = "write"
PERMISSION_CHOICES = [
(PERMISSION_READ, "Read only"),
(PERMISSION_WRITE, "Read and write"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="shares",
)
shared_with = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="shared_calendars",
)
permission = models.CharField(
max_length=10,
choices=PERMISSION_CHOICES,
default=PERMISSION_READ,
)
is_visible = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
"""Meta options for CalendarShare model."""
unique_together = ["calendar", "shared_with"]
def __str__(self):
return f"{self.calendar.name} shared with {self.shared_with.email}"

View File

View File

@@ -0,0 +1,477 @@
"""Services for CalDAV integration with DAViCal."""
import logging
from datetime import date, datetime, timedelta
from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.utils import timezone
import psycopg
from caldav import DAVClient
from caldav.lib.error import NotFoundError
from core.models import Calendar
logger = logging.getLogger(__name__)
class DAViCalClient:
"""
Client for communicating with DAViCal CalDAV server using the caldav library.
"""
def __init__(self):
self.base_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
self.timeout = 30
def _get_client(self, user) -> DAVClient:
"""
Get a CalDAV client for the given user.
DAViCal uses X-Forwarded-User header for authentication. The caldav
library requires username/password for Basic Auth, but DAViCal users have
password '*' (external auth). We pass the X-Forwarded-User header directly
to the DAVClient constructor.
"""
# DAViCal base URL - the caldav library will discover the principal
caldav_url = f"{self.base_url}/caldav.php/"
return DAVClient(
url=caldav_url,
username=user.email,
password="", # Empty password - DAViCal uses X-Forwarded-User header
timeout=self.timeout,
headers={
"X-Forwarded-User": user.email,
},
)
def ensure_user_exists(self, user) -> None:
"""
Ensure the user exists in DAViCal's database.
Creates the user if they don't exist.
"""
# Connect to shared calendars database (public schema)
default_db = settings.DATABASES["default"]
db_name = default_db.get("NAME", "calendars")
# Get password - handle SecretValue objects
password = default_db.get("PASSWORD", "pass")
if hasattr(password, "value"):
password = password.value
# Connect to calendars database
conn = psycopg.connect(
host=default_db.get("HOST", "localhost"),
port=default_db.get("PORT", 5432),
dbname=db_name,
user=default_db.get("USER", "pgroot"),
password=password,
)
try:
with conn.cursor() as cursor:
# Check if user exists (in public schema)
cursor.execute(
"SELECT user_no FROM usr WHERE lower(username) = lower(%s)",
[user.email],
)
if cursor.fetchone():
# User already exists
return
# Create user in DAViCal (public schema)
# Use email as username, password '*' means external auth
# Get user's full name or use email prefix
fullname = (
getattr(user, "full_name", None)
or getattr(user, "get_full_name", lambda: None)()
or user.email.split("@")[0]
)
cursor.execute(
"""
INSERT INTO usr (username, email, fullname, active, password)
VALUES (%s, %s, %s, true, '*')
ON CONFLICT (lower(username)) DO NOTHING
RETURNING user_no
""",
[user.email, user.email, fullname],
)
result = cursor.fetchone()
if result:
user_no = result[0]
logger.info(
"Created DAViCal user: %s (user_no: %s)", user.email, user_no
)
# Also create a principal record for the user (public schema)
# DAViCal needs both usr and principal records
# Principal type 1 is for users
type_id = 1
cursor.execute(
"""
INSERT INTO principal (type_id, user_no, displayname)
SELECT %s, %s, %s
WHERE NOT EXISTS (SELECT 1 FROM principal WHERE user_no = %s)
RETURNING principal_id
""",
[type_id, user_no, fullname, user_no],
)
principal_result = cursor.fetchone()
if principal_result:
logger.info(
"Created DAViCal principal: %s (principal_id: %s)",
user.email,
principal_result[0],
)
else:
logger.warning("User %s already exists in DAViCal", user.email)
conn.commit()
finally:
conn.close()
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
"""
Create a new calendar in DAViCal for the given user.
Returns the DAViCal path for the calendar.
"""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
principal = client.principal()
try:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# DAViCal calendar path format: /caldav.php/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
calendar_url = str(calendar.url)
# Extract path from full URL
if calendar_url.startswith(self.base_url):
path = calendar_url[len(self.base_url) :]
else:
# Fallback: construct path manually based on DAViCal's structure
# DAViCal creates calendars with a specific path structure
path = f"/caldav.php/{user.email}/{calendar_id}/"
logger.info("Created calendar in DAViCal: %s at %s", calendar_name, path)
return path
except Exception as e:
logger.error("Failed to create calendar in DAViCal: %s", str(e))
raise
def get_events(
self,
user,
calendar_path: str,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> list:
"""
Get events from a calendar within a time range.
Returns list of event dictionaries with parsed data.
"""
# Ensure user exists first
self.ensure_user_exists(user)
# Default to current month if no range specified
if start is None:
start = timezone.now().replace(day=1, hour=0, minute=0, second=0)
if end is None:
end = start + timedelta(days=31)
client = self._get_client(user)
# Get calendar by URL
calendar_url = f"{self.base_url}{calendar_path}"
calendar = client.calendar(url=calendar_url)
try:
# Search for events in the date range
# Convert datetime to date for search if needed
start_date = start.date() if isinstance(start, datetime) else start
end_date = end.date() if isinstance(end, datetime) else end
events = calendar.search(
event=True,
start=start_date,
end=end_date,
expand=True, # Expand recurring events
)
# Parse events into dictionaries
parsed_events = []
for event in events:
event_data = self._parse_event(event)
if event_data:
parsed_events.append(event_data)
return parsed_events
except NotFoundError:
logger.warning("Calendar not found at path: %s", calendar_path)
return []
except Exception as e:
logger.error("Failed to get events from DAViCal: %s", str(e))
raise
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
"""
Create a new event in DAViCal.
Returns the event UID.
"""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar = client.calendar(url=calendar_url)
# Extract event data
dtstart = event_data.get("start", timezone.now())
dtend = event_data.get("end", dtstart + timedelta(hours=1))
summary = event_data.get("title", "New Event")
description = event_data.get("description", "")
location = event_data.get("location", "")
# Generate UID if not provided
event_uid = event_data.get("uid", str(uuid4()))
try:
# Create event using caldav library
event = calendar.save_event(
dtstart=dtstart,
dtend=dtend,
uid=event_uid,
summary=summary,
description=description,
location=location,
)
# Extract UID from created event
# The caldav library returns an Event object
if hasattr(event, "icalendar_component"):
event_uid = str(event.icalendar_component.get("uid", event_uid))
elif hasattr(event, "vobject_instance"):
event_uid = event.vobject_instance.vevent.uid.value
logger.info("Created event in DAViCal: %s", event_uid)
return event_uid
except Exception as e:
logger.error("Failed to create event in DAViCal: %s", str(e))
raise
def update_event(
self, user, calendar_path: str, event_uid: str, event_data: dict
) -> None:
"""Update an existing event in DAViCal."""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
# Update event properties
dtstart = event_data.get("start")
dtend = event_data.get("end")
summary = event_data.get("title")
description = event_data.get("description")
location = event_data.get("location")
# Update using icalendar component
component = target_event.icalendar_component
if dtstart:
component["dtstart"] = dtstart
if dtend:
component["dtend"] = dtend
if summary:
component["summary"] = summary
if description is not None:
component["description"] = description
if location is not None:
component["location"] = location
# Save the updated event
target_event.save()
logger.info("Updated event in DAViCal: %s", event_uid)
except Exception as e:
logger.error("Failed to update event in DAViCal: %s", str(e))
raise
def delete_event(self, user, calendar_path: str, event_uid: str) -> None:
"""Delete an event from DAViCal."""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
# Delete the event
target_event.delete()
logger.info("Deleted event from DAViCal: %s", event_uid)
except Exception as e:
logger.error("Failed to delete event from DAViCal: %s", str(e))
raise
def _parse_event(self, event) -> Optional[dict]:
"""
Parse a caldav Event object and return event data as dictionary.
"""
try:
component = event.icalendar_component
event_data = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"start": component.get("dtstart").dt
if component.get("dtstart")
else None,
"end": component.get("dtend").dt if component.get("dtend") else None,
"description": str(component.get("description", "")),
"location": str(component.get("location", "")),
}
# Convert datetime to string format for consistency
if event_data["start"]:
if isinstance(event_data["start"], datetime):
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["start"], date):
event_data["start"] = event_data["start"].strftime("%Y%m%d")
if event_data["end"]:
if isinstance(event_data["end"], datetime):
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["end"], date):
event_data["end"] = event_data["end"].strftime("%Y%m%d")
return event_data if event_data.get("uid") else None
except Exception as e:
logger.warning("Failed to parse event: %s", str(e))
return None
class CalendarService:
"""
High-level service for managing calendars and events.
"""
def __init__(self):
self.davical = DAViCalClient()
def create_default_calendar(self, user) -> Calendar:
"""
Create a default calendar for a user.
"""
calendar_id = str(uuid4())
calendar_name = "Mon calendrier"
# Create calendar in DAViCal
davical_path = self.davical.create_calendar(user, calendar_name, calendar_id)
# Create local Calendar record
calendar = Calendar.objects.create(
owner=user,
name=calendar_name,
davical_path=davical_path,
is_default=True,
color="#3174ad",
)
return calendar
def create_calendar(self, user, name: str, color: str = "#3174ad") -> Calendar:
"""
Create a new calendar for a user.
"""
calendar_id = str(uuid4())
# Create calendar in DAViCal
davical_path = self.davical.create_calendar(user, name, calendar_id)
# Create local Calendar record
calendar = Calendar.objects.create(
owner=user,
name=name,
davical_path=davical_path,
is_default=False,
color=color,
)
return calendar
def get_user_calendars(self, user):
"""
Get all calendars accessible by a user (owned + shared).
"""
owned = Calendar.objects.filter(owner=user)
shared = Calendar.objects.filter(shares__shared_with=user)
return owned.union(shared)
def get_events(self, user, calendar: Calendar, start=None, end=None) -> list:
"""
Get events from a calendar.
Returns parsed event data.
"""
return self.davical.get_events(user, calendar.davical_path, start, end)
def create_event(self, user, calendar: Calendar, event_data: dict) -> str:
"""Create a new event."""
return self.davical.create_event(user, calendar.davical_path, event_data)
def update_event(
self, user, calendar: Calendar, event_uid: str, event_data: dict
) -> None:
"""Update an existing event."""
self.davical.update_event(user, calendar.davical_path, event_uid, event_data)
def delete_event(self, user, calendar: Calendar, event_uid: str) -> None:
"""Delete an event."""
self.davical.delete_event(user, calendar.davical_path, event_uid)

View File

@@ -0,0 +1,55 @@
"""
Declare and configure the signals for the calendars core application
"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from core.services.caldav_service import CalendarService
logger = logging.getLogger(__name__)
User = get_user_model()
@receiver(post_save, sender=User)
def provision_default_calendar(sender, instance, created, **kwargs):
"""
Auto-provision a default calendar when a new user is created.
"""
if not created:
return
# Check if user already has a default calendar
if instance.calendars.filter(is_default=True).exists():
return
# Skip calendar creation if DAViCal is not configured
if not getattr(settings, "DAVICAL_URL", None):
return
try:
service = CalendarService()
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e:
# In tests, DAViCal tables don't exist, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()
if "does not exist" in error_str or "relation" in error_str:
# Likely in test environment, fail silently
logger.debug(
"Skipped calendar creation for user %s (likely test environment): %s",
instance.email,
str(e),
)
else:
# Real error, log it
logger.error(
"Failed to create default calendar for user %s: %s",
instance.email,
str(e),
)

View File

@@ -0,0 +1,58 @@
"""Custom template tags for the calendars core application."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""

View File

View File

@@ -0,0 +1,579 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.exceptions import UserCannotAccessApp
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the user's info sub, the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_sub(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_email(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the name fields on the user when they change
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": user.email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
"""
klass = OIDCAuthenticationBackend()
email = "calendars@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
"""Test get_userinfo method with a token response."""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response(settings):
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not match the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
@responses.activate
def test_authentication_session_tokens(
django_assert_num_queries, monkeypatch, rf, settings
):
"""
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
"""
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
settings.OIDC_STORE_ACCESS_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
klass = OIDCAuthenticationBackend()
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
request.session = {}
def verify_token_mocked(*args, **kwargs):
return {"sub": "123", "email": "test@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
responses.add(
responses.POST,
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
json={
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
},
status=200,
)
responses.add(
responses.GET,
re.compile(settings.OIDC_OP_USER_ENDPOINT),
json={"sub": "123", "email": "test@example.com"},
status=200,
)
with django_assert_num_queries(27):
user = klass.authenticate(
request,
code="test-code",
nonce="test-nonce",
code_verifier="test-code-verifier",
)
assert user is not None
assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
@override_settings(OIDC_STORE_CLAIMS=["iss"])
def test_authentication_store_claims_new_user(monkeypatch):
"""
Test that the claims are stored on the user when a new user is created.
"""
klass = OIDCAuthenticationBackend()
email = "calendars@example.com"
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": email,
"first_name": "John",
"last_name": "Doe",
"iss": "https://example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert user.claims == {"iss": "https://example.com"}
assert models.User.objects.count() == 1
@override_settings(OIDC_STORE_CLAIMS=["iss"])
def test_authentication_store_claims_existing_user(monkeypatch):
"""
Test that the claims are stored on the user when an existing user is authenticated.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
email="calendars@example.com", sub="123", claims={"iss": "https://obsolete.com"}
)
email = "calendars@example.com"
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": email,
"first_name": "John",
"last_name": "Doe",
"iss": "https://example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
user.refresh_from_db()
assert user.sub == "123"
assert user.email == email
assert user.claims == {"iss": "https://example.com"}
assert models.User.objects.count() == 1

View File

@@ -0,0 +1,66 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import RequestFactory
from django.test.utils import override_settings
import pytest
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core import factories
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.views import (
OIDCAuthenticationCallbackView,
)
pytestmark = pytest.mark.django_db
@override_settings(
LOGIN_REDIRECT_URL_FAILURE="/auth/failure",
LOGIN_REDIRECT_URL="/auth/success",
)
@mock.patch.object(
MozillaOIDCAuthenticationBackend,
"get_token",
return_value={"id_token": "mocked_id_token", "access_token": "mocked_access_token"},
)
@mock.patch.object(
MozillaOIDCAuthenticationBackend, "verify_token", return_value={"not": "needed"}
)
@mock.patch.object(
OIDCAuthenticationBackend,
"get_userinfo",
return_value={"sub": "mocked_sub", "email": "allowed@example.com"},
)
def test_view_login_callback_authorized_by_default(
mocked_get_userinfo, mocked_verify_token, mocked_get_token
):
"""By default, all users are authorized to login."""
user = factories.UserFactory(email="allowed@example.com")
request = RequestFactory().get(
"/callback/", data={"state": "mocked_state", "code": "mocked_code"}
)
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {"nonce": "mocked_nonce"}}
request.session.save()
callback_view = OIDCAuthenticationCallbackView.as_view()
response = callback_view(request)
mocked_get_token.assert_called_once()
mocked_verify_token.assert_called_once()
mocked_get_userinfo.assert_called_once()
assert response.status_code == 302
assert response.url == "/auth/success"

View File

@@ -0,0 +1,148 @@
"""Fixtures for tests in the calendars core application"""
import base64
from unittest import mock
from django.core.cache import cache
from django.db import connection
import pytest
import responses
from cryptography.fernet import Fernet
from core import factories
from core.tests.utils.urls import reload_urls
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
@pytest.fixture(autouse=True)
def truncate_davical_tables(django_db_setup, django_db_blocker):
"""Fixture to truncate DAViCal tables at the start of each test.
DAViCal tables are created by the DAViCal container migrations, not Django.
We just truncate them to ensure clean state for each test.
"""
with django_db_blocker.unblock():
with connection.cursor() as cursor:
# Truncate DAViCal tables if they exist (created by DAViCal container)
cursor.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principal') THEN
TRUNCATE TABLE principal CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usr') THEN
TRUNCATE TABLE usr CASCADE;
END IF;
END $$;
""")
yield
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache after each test."""
yield
cache.clear()
# Clear functools.cache for functions decorated with @functools.cache
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
def resource_server_backend_setup(settings):
"""
A fixture to create a user token for testing.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
)
settings.OIDC_RESOURCE_SERVER_ENABLED = True
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_RS_SCOPES = ["openid", "groups"]
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
@pytest.fixture
def resource_server_backend_conf(settings):
"""
A fixture to create a user token for testing.
"""
resource_server_backend_setup(settings)
reload_urls()
@pytest.fixture
def resource_server_backend(settings):
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.
"""
resource_server_backend_setup(settings)
reload_urls()
with responses.RequestsMock() as rsps:
rsps.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid groups",
"active": True,
},
)
yield rsps
@pytest.fixture
def user_specific_sub():
"""
A fixture to create a user token for testing.
"""
user = factories.UserFactory(sub="very-specific-sub")
yield user
def build_authorization_bearer(token):
"""
Build an Authorization Bearer header value from a token.
This can be used like this:
client.post(
...
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
"""
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
@pytest.fixture
def user_token():
"""
A fixture to create a user token for testing.
"""
return build_authorization_bearer("some_token")

View File

@@ -0,0 +1,152 @@
"""
Tests for the Resource Server API for users.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_api_users_me_anonymous_public_standalone():
"""
Anonymous users should not be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
response = APIClient().get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_api_users_me_connected_not_resource_server():
"""
Connected users should not be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_api_users_me_connected_resource_server(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users should be allowed to retrieve their own user information from external API
if resource server is enabled.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(user_specific_sub.id)
assert data["email"] == user_specific_sub.email
def test_api_users_me_connected_resource_server_with_invalid_token(
user_token, resource_server_backend
):
"""
Connected users should not be allowed to retrieve their own user information from external API
if resource server is enabled with an invalid token.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 401
# Non allowed actions on resource server.
def test_api_users_list_resource_server_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users should notbe allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/")
assert response.status_code == 403
def test_api_users_retrieve_resource_server_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users should notbe allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403
def test_api_users_put_patch_resource_server_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users should notbe allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = client.put(
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
)
assert response.status_code == 403
response = client.patch(
f"/external_api/v1.0/users/{other_user.id!s}/",
{"email": "new_email@example.com"},
)
assert response.status_code == 403
def test_api_users_delete_resource_server_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users should notbe allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

View File

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

View File

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

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

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

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

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

View File

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

35
src/backend/entrypoint Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

1836
src/backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_ORIGIN=

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921

41
src/frontend/apps/calendars/.gitignore vendored Normal file
View 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

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

View File

@@ -0,0 +1,2 @@
module.exports = "test-file-stub";

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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

View 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

View 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

View File

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

View File

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

View 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

View 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="#000091"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

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