Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hansegucker/AlekSIS-Core
  • pinguin/AlekSIS-Core
  • AlekSIS/official/AlekSIS-Core
  • sunweaver/AlekSIS-Core
  • sggua/AlekSIS-Core
  • edward/AlekSIS-Core
  • magicfelix/AlekSIS-Core
7 results
Show changes
Commits on Source (5517)
Showing with 2367 additions and 266 deletions
module.exports = {
root: true,
overrides: [
{
files: ["*.js", "*.vue"],
// parser: "vue-eslint-parser",
//processor: "@graphql-eslint/graphql",
extends: [
"eslint:recommended",
"plugin:vue/strongly-recommended",
"plugin:@intlify/vue-i18n/recommended",
],
rules: {
"no-unused-vars": "warn",
"vue/no-unused-vars": "off",
"vue/multi-word-component-names": "off",
"vue/attribute-hyphenation": "error",
"vue/v-slot-style": "error",
"@intlify/vue-i18n/key-format-style": [
"error",
"snake_case",
{
splitByDots: false,
},
],
// "@intlify/vue-i18n/no-unused-keys": ["warn", {}],
"@intlify/vue-i18n/no-raw-text": [
"error",
{
ignoreNodes: ["v-icon"],
ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$",
},
],
"@intlify/vue-i18n/no-deprecated-tc": "off",
// Fixes for prettier (avoid eslint-config-prettier)
// The following rules can be used in some cases. See the README for more
// information. (These are marked with `0` instead of `"off"` so that a
// script can distinguish them.)
curly: 0,
"lines-around-comment": 0,
"max-len": 0,
"no-confusing-arrow": 0,
"no-mixed-operators": 0,
"no-tabs": 0,
"no-unexpected-multiline": 0,
quotes: 0,
"@typescript-eslint/quotes": 0,
"babel/quotes": 0,
"vue/html-self-closing": 0,
"vue/max-len": 0,
// The rest are rules that you never need to enable when using Prettier.
"array-bracket-newline": "off",
"array-bracket-spacing": "off",
"array-element-newline": "off",
"arrow-parens": "off",
"arrow-spacing": "off",
"block-spacing": "off",
"brace-style": "off",
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": "off",
"computed-property-spacing": "off",
"dot-location": "off",
"eol-last": "off",
"func-call-spacing": "off",
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star": "off",
"generator-star-spacing": "off",
"implicit-arrow-linebreak": "off",
indent: "off",
"jsx-quotes": "off",
"key-spacing": "off",
"keyword-spacing": "off",
"linebreak-style": "off",
"multiline-ternary": "off",
"newline-per-chained-call": "off",
"new-parens": "off",
"no-arrow-condition": "off",
"no-comma-dangle": "off",
"no-extra-parens": "off",
"no-extra-semi": "off",
"no-floating-decimal": "off",
"no-mixed-spaces-and-tabs": "off",
"no-multi-spaces": "off",
"no-multiple-empty-lines": "off",
"no-reserved-keys": "off",
"no-space-before-semi": "off",
"no-trailing-spaces": "off",
"no-whitespace-before-property": "off",
"no-wrap-func": "off",
"nonblock-statement-body-position": "off",
"object-curly-newline": "off",
"object-curly-spacing": "off",
"object-property-newline": "off",
"one-var-declaration-per-line": "off",
"operator-linebreak": "off",
"padded-blocks": "off",
"quote-props": "off",
"rest-spread-spacing": "off",
semi: "off",
"semi-spacing": "off",
"semi-style": "off",
"space-after-function-name": "off",
"space-after-keywords": "off",
"space-before-blocks": "off",
"space-before-function-paren": "off",
"space-before-function-parentheses": "off",
"space-before-keywords": "off",
"space-in-brackets": "off",
"space-in-parens": "off",
"space-infix-ops": "off",
"space-return-throw-case": "off",
"space-unary-ops": "off",
"space-unary-word-ops": "off",
"switch-colon-spacing": "off",
"template-curly-spacing": "off",
"template-tag-spacing": "off",
"unicode-bom": "off",
"wrap-iife": "off",
"wrap-regex": "off",
"yield-star-spacing": "off",
"@babel/object-curly-spacing": "off",
"@babel/semi": "off",
"@typescript-eslint/brace-style": "off",
"@typescript-eslint/comma-dangle": "off",
"@typescript-eslint/comma-spacing": "off",
"@typescript-eslint/func-call-spacing": "off",
"@typescript-eslint/indent": "off",
"@typescript-eslint/keyword-spacing": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/no-extra-parens": "off",
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/object-curly-spacing": "off",
"@typescript-eslint/semi": "off",
"@typescript-eslint/space-before-blocks": "off",
"@typescript-eslint/space-before-function-paren": "off",
"@typescript-eslint/space-infix-ops": "off",
"@typescript-eslint/type-annotation-spacing": "off",
"babel/object-curly-spacing": "off",
"babel/semi": "off",
"flowtype/boolean-style": "off",
"flowtype/delimiter-dangle": "off",
"flowtype/generic-spacing": "off",
"flowtype/object-type-curly-spacing": "off",
"flowtype/object-type-delimiter": "off",
"flowtype/quotes": "off",
"flowtype/semi": "off",
"flowtype/space-after-type-colon": "off",
"flowtype/space-before-generic-bracket": "off",
"flowtype/space-before-type-colon": "off",
"flowtype/union-intersection-spacing": "off",
"react/jsx-child-element-spacing": "off",
"react/jsx-closing-bracket-location": "off",
"react/jsx-closing-tag-location": "off",
"react/jsx-curly-newline": "off",
"react/jsx-curly-spacing": "off",
"react/jsx-equals-spacing": "off",
"react/jsx-first-prop-new-line": "off",
"react/jsx-indent": "off",
"react/jsx-indent-props": "off",
"react/jsx-max-props-per-line": "off",
"react/jsx-newline": "off",
"react/jsx-one-expression-per-line": "off",
"react/jsx-props-no-multi-spaces": "off",
"react/jsx-tag-spacing": "off",
"react/jsx-wrap-multilines": "off",
"standard/array-bracket-even-spacing": "off",
"standard/computed-property-even-spacing": "off",
"standard/object-curly-even-spacing": "off",
"unicorn/empty-brace-spaces": "off",
"unicorn/no-nested-ternary": "off",
"unicorn/number-literal-case": "off",
"vue/array-bracket-newline": "off",
"vue/array-bracket-spacing": "off",
"vue/arrow-spacing": "off",
"vue/block-spacing": "off",
"vue/block-tag-newline": "off",
"vue/brace-style": "off",
"vue/comma-dangle": "off",
"vue/comma-spacing": "off",
"vue/comma-style": "off",
"vue/dot-location": "off",
"vue/func-call-spacing": "off",
"vue/html-closing-bracket-newline": "off",
"vue/html-closing-bracket-spacing": "off",
"vue/html-end-tags": "off",
"vue/html-indent": "off",
"vue/html-quotes": "off",
"vue/key-spacing": "off",
"vue/keyword-spacing": "off",
"vue/max-attributes-per-line": "off",
"vue/multiline-html-element-content-newline": "off",
"vue/multiline-ternary": "off",
"vue/mustache-interpolation-spacing": "off",
"vue/no-extra-parens": "off",
"vue/no-multi-spaces": "off",
"vue/no-spaces-around-equal-signs-in-attribute": "off",
"vue/object-curly-newline": "off",
"vue/object-curly-spacing": "off",
"vue/object-property-newline": "off",
"vue/operator-linebreak": "off",
"vue/quote-props": "off",
"vue/script-indent": "off",
"vue/singleline-html-element-content-newline": "off",
"vue/space-in-parens": "off",
"vue/space-infix-ops": "off",
"vue/space-unary-ops": "off",
"vue/template-curly-spacing": "off",
},
settings: {
"vue-i18n": {
localeDir: "./aleksis/core/frontend/messages/*.{json}",
messageSyntaxVersion: "^8.0.0",
},
},
env: {
es2021: true,
},
parserOptions: {
ecmaVersion: "latest",
},
},
{
files: ["*.graphql"],
parser: "@graphql-eslint/eslint-plugin",
plugins: ["@graphql-eslint"],
extends: "plugin:@graphql-eslint/operations-recommended",
parserOptions: {
graphQLConfig: {
schema: "./schema.json",
documents: "../aleksis/**/*/frontend/**/*.graphql",
},
},
rules: {
"@graphql-eslint/no-anonymous-operations": "error",
"@graphql-eslint/no-duplicate-fields": "error",
"@graphql-eslint/naming-convention": [
"error",
{
OperationDefinition: {
style: "camelCase",
forbiddenPrefixes: ["Query", "Mutation", "Subscription", "Get"],
forbiddenSuffixes: ["Query", "Mutation", "Subscription"],
},
},
],
},
},
],
};
{
"name": "aleksis-builddeps",
"version": "1.0.0",
"dependencies": {
"@graphql-eslint/eslint-plugin": "^4.3.0",
"@intlify/eslint-plugin-vue-i18n": "^3.0.0",
"eslint": "^8.26.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-vue": "^9.7.0",
"graphql": "^16.10.0",
"prettier": "^3.4.0",
"stylelint": "^16.0.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^34.0.0"
}
}
<!-- AlekSIS is developed on EduGit. GitHub only serves as
backup mirror and to help people find the project. If
possible, please submit your merge request on EduGit!
EduGit accepts logins with GitHub accounts.
-->
[ ] I have read the above and have no way to contribute on EduGit
[ ] I understand that GitHub's terms of service exclude young and
learning contributors, but still cannot contribute on EduGit
instead.
......@@ -52,6 +52,11 @@ DEADJOE
.idea
.idea/
# VSCode
.vscode/
.history/
*.code-workspace
# Database
db.sqlite3
......@@ -62,19 +67,32 @@ docs/_build/
*.aux
# Generated files
aleksis/node_modules/
aleksis/static/
aleksis/whoosh_index/
/cache
/node_modules
.dev-js/node_modules
/static/
/whoosh_index/
.vite
.dev-js/.yarn
.dev-js/.pnp.cjs
.dev-js/.pnp.loader.mjs
.dev-js/.yarnrc.yml
.dev-js/schema.json
# Lock files
poetry.lock
package-lock.json
yarn.lock
.dev-js/yarn.lock
# Tests
.coverage
.mypy_cache/
.tox/
htmlcov/
# Data
maintenance_mode_state.txt
media/
package-lock.json
# VSCode
.vscode/
.history/
*.code-workspace
aleksis/core/static/style.css
include:
- project: "AlekSIS/official/AlekSIS"
file: /ci/general.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/lint.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/security.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/build/dist.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/publish/pypi.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/docker/image.yml
- project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/review.yml"
- project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/trigger_dist.yml"
- project: "AlekSIS/official/AlekSIS"
file: /ci/deploy/pages.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/general.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/prepare/lock.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/lint.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/security.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/build/dist.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/publish/pypi.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/docker/image.yml
- project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/review.yml"
- project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/trigger_dist.yml"
......@@ -8,7 +8,9 @@ Jonathan Weth <git@jonathanweth.de> Jonathan Weth <joniweth@gmx.de>
Jonathan Weth <git@jonathanweth.de> Jonathan Weth <mail@jonathanweth.de>
Jonathan Weth <git@jonathanweth.de> Jonathan Weth <wethjo@katharineum.de>
Julian Leucker <leuckerj@gmail.com> Julian <leuckerj@gmail.com>
Lloyd Meins <git@lloydmeins.de> Aithus <lloydmeins@gmx.net>
Silas Della Contrada <s.developer@4-dc.de> sdcieo0330 <silasdc0@gmail.com>
Tom Teichler <tom.teichler@teckids.org> Tom Teichler <t.teichler@babiel.com>
mirabilos <thorsten.glaser@teckids.org> mirabilos <mirabilos@evolvis.org>
mirabilos <thorsten.glaser@teckids.org> mirabilos <t.glaser@tarent.de>
root (Skolelinux) <root@tjener.intern> root <root@tjener.intern>
......
# Byte-compiled / optimized / DLL files
*$py.class
*.py[cod]
__pycache__/
# Distribution / packaging
*.egg
*.egg-info/
.Python
.eggs/
.installed.cfg
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
# Installer logs
pip-delete-this-directory.txt
pip-log.txt
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# pyenv
.python-version
# Environments
.env
.venv
ENV/
env/
venv/
# Editors
*~
DEADJOE
\#*#
# IntelliJ
.idea
.idea/
# Database
db.sqlite3
# Sphinx
docs/_build/
# TeX
*.aux
# Generated files
/node_modules/
/static/
/whoosh_index/
poetry.lock
.coverage
.mypy_cache/
.tox/
htmlcov/
maintenance_mode_state.txt
media/
package-lock.json
yarn.lock
# VSCode
.vscode/
.history/
*.code-workspace
/cache
# Add HTML files to avoid problems with unsupported Django templates
*.html
# Do not check/reformat generated files
aleksis/core/util/licenses.json
.vite/
.pnp.cjs
.pnp.loader.mjs
.git/
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"]
}
This diff is collapsed.
FROM debian:bullseye-slim AS core
FROM debian:bookworm-slim AS core
# Build arguments
ARG EXTRAS="ldap,s3"
ARG APP_VERSION=""
ARG EXTRAS="ldap,s3,sentry"
ARG APP_VERSION="==3.0b0"
ARG PIP_EXTRA_INDEX_URL="https://edugit.org/api/v4/projects/461/packages/pypi/simple"
# Configure Python to be nice inside Docker and pip to stfu
ENV PYTHONUNBUFFERED 1
......@@ -10,35 +11,44 @@ ENV PYTHONDONTWRITEBYTECODE 1
ENV PIP_DEFAULT_TIMEOUT 100
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV PIP_NO_CACHE_DIR 1
ENV PIP_EXTRA_INDEX_URL https://edugit.org/api/v4/projects/461/packages/pypi/simple
ENV PIP_EXTRA_INDEX_URL $PIP_EXTRA_INDEX_URL
ENV PIP_USE_DEPRECATED legacy-resolver
ENV DEBIAN_FRONTEND noninteractive
# Configure app settings for build and runtime
ENV ALEKSIS_caching__dir /var/cache/aleksis
ENV ALEKSIS_static__root /usr/share/aleksis/static
ENV ALEKSIS_media__root /var/lib/aleksis/media
ENV ALEKSIS_backup__location /var/lib/aleksis/backups
ENV ALEKSIS_dev__uwsgi__celery false
ENV PSQL_PAGER=pspg
# Install necessary Debian and PyPI packages for build and runtime
RUN apt-get -y update && \
apt-get -y install eatmydata && \
apt-get -y install eatmydata gnupg postgresql-common && \
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y && \
eatmydata apt-get -y upgrade && \
eatmydata apt-get install -y --no-install-recommends \
build-essential \
chromium \
curl \
dumb-init \
gettext \
libpq5 \
grep \
less \
libpq-dev \
libssl-dev \
postgresql-client \
locales-all \
postgresql-client-14 \
postgresql-client-15 \
postgresql-client-16 \
pspg \
python3-dev \
python3-magic \
python3-pip \
uwsgi \
uwsgi-plugin-python3 \
yarnpkg
yarnpkg \
git
# Install extra dependencies
RUN case ",$EXTRAS," in \
......@@ -52,23 +62,28 @@ RUN case ",$EXTRAS," in \
# Install core
RUN set -e; \
mkdir -p ${ALEKSIS_static__root} \
mkdir -p ${ALEKSIS_caching__dir} \
${ALEKSIS_static__root} \
${ALEKSIS_media__root} \
${ALEKSIS_backup__location}; \
dpkg-divert --rename --add /usr/lib/$(py3versions -d)/EXTERNALLY-MANAGED; \
eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION
# Define entrypoint, volumes and uWSGI running on port 8000
EXPOSE 8000
VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
COPY docker-startup.sh /usr/local/bin/aleksis-docker-startup
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/usr/local/bin/aleksis-docker-startup"]
# Install assets
FROM core as assets
RUN eatmydata aleksis-admin yarn install; \
RUN eatmydata aleksis-admin vite build; \
eatmydata aleksis-admin compile_scss; \
eatmydata aleksis-admin collectstatic --no-input; \
rm -rf /usr/local/share/.cache
# FIXME Introduce deletion after we don't need materializecss anymore for SASS
# also in ONBUILD below
# rm -rf /usr/local/share/.cache ${ALEKSIS_caching__dir}/*
# Clean up build dependencies
FROM assets AS clean
......@@ -76,6 +91,7 @@ RUN set -e; \
eatmydata apt-get remove --purge -y \
build-essential \
gettext \
gnupg \
libpq-dev \
libssl-dev \
libldap2-dev \
......@@ -92,6 +108,7 @@ RUN chown -R www-data:www-data \
${ALEKSIS_media__root} \
${ALEKSIS_backup__location}
USER 33:33
VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
# Additional steps
ONBUILD ARG APPS
......@@ -111,8 +128,9 @@ ONBUILD RUN set -e; \
if [ -n "$APPS" ]; then \
eatmydata pip install $APPS; \
fi; \
eatmydata aleksis-admin yarn install; \
eatmydata aleksis-admin collectstatic --no-input; \
eatmydata aleksis-admin vite build; \
eatmydata aleksis-admin compile_scss; \
eatmydata aleksis-admin collectstatic --no-input --clear; \
rm -rf /usr/local/share/.cache; \
eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
eatmydata apt-get autoremove --purge -y; \
......
......@@ -121,7 +121,7 @@ been modified and the date of modification.
of the Original Works or Derivative Works, this Distribution or
Communication will be done under the terms of this Licence or of a
later version of this Licence unless the Original Work is expressly
distributed only under this version of the Licencefor example by
distributed only under this version of the Licencefor example by
communicating ‘EUPL v. 1.2 only’. The Licensee (becoming Licensor)
cannot offer or impose any additional terms or conditions on the Work
or Derivative Work that alter or restrict the terms of the Licence.
......@@ -306,7 +306,7 @@ Appendix
* Creative Commons Attribution-ShareAlike v. 3.0 Unported
(CC BY-SA 3.0) for works other than software
* European Union Public Licence (EUPL) v. 1.1, v. 1.2
* Québec Free and Open-Source LicenceReciprocity (LiLiQ-R)
* Québec Free and Open-Source LicenceReciprocity (LiLiQ-R)
or Strong Reciprocity (LiLiQ-R+)
The European Commission may update this Appendix to later versions of
......
......@@ -6,7 +6,7 @@ This is the core of the AlekSIS framework and the official distribution
developers and administrators.
If you are looking for the AlekSIS standard distribution, i.e. the complete
software product ready for installation and usage, please visit the `AlekSIS`_
software product ready for installation and usage, please visit the `AlekSIS®`_
website or the distribution repository on `EduGit`_.
Features
......@@ -16,58 +16,68 @@ The AlekSIS core currently provides the following features:
* For users:
* Authentication via OAuth applications
* Configurable dashboard
* Custom menu entries (e.g. in footer)
* Global preferences
* Authentication via local account, LDAP, or social accounts
* Two factor authentication via WebAuthn, OTP, or SMS
* Configurable dashboard with widgets
* User-specific preferences
* Global search
* Group types
* Manage announcements
* Manage groups
* Global calendar system
* CalDAV and CardDAV support
* Manage personal events
* Manage persons
* Notifications via SMS email or dashboard
* Manage groups and group types
* Manage roles per group
* Manage announcements
* Manage holidays
* Notifications via SMS, email, or dashboard
* PWA with offline caching
* Rules and permissions for users, objects and pages
* Two factor authentication via Yubikey, OTP or SMS
* User preferences
* User registration, password changes and password reset
* User invitations with invite codes and targeted invites
* For admins
* Asynchronous tasks with celery
* Authentication via LDAP
* `aleksis-admin` script to wrap django-admin with pre-configured settings
* Manage school terms
* Custom menu entries (e.g. in footer)
* Automatic backup of database, static and media files
* Generic PDF generation with chromium
* OAuth2 and OpenID Connect provider support
* Serve prometheus metrics
* System health and data checks
* Configuration of low-level settings via configuration files
* System-wide preferenes
* Creating dashboard widgets for external links/apps
* For developers
* `aleksis-admin` script to wrap django-admin with pre-configured settings
* Caching with Redis
* Generic PDF generation with firefox
* Caching with Valkey
* Django REST framework for apps to use at own discretion
* Injection of fields, methods, permissions and properties via custom `ExtensibleModel`
* Injection of fields, methods, permissions and properties via custom ``ExtensibleModel``
* K8s compatible, read-only Docker image
* Object-level permissions and rules with `django-guardian` and `django-rules`
* Query caching with `django-cachalot`
* Search with `django-haystack` and `Whoosh` backend
* uWSGI and Celery via `django-uwsgi` in development
* Object-level permissions and rules with ``django-guardian`` and ``django-rules``
* uWSGI and Celery via ``django-uwsgi`` in development
* Extensible dashbaord widget system
* Extensible calendar system
* Extensible OAuth/OpenID Connect scope and claims system
Licence
-------
::
Copyright © 2017, 2018, 2019, 2020, 2021 Jonathan Weth <dev@jonathanweth.de>
Copyright © 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 Jonathan Weth <dev@jonathanweth.de>
Copyright © 2017, 2018, 2019, 2020 Frank Poetzsch-Heffter <p-h@katharineum.de>
Copyright © 2018, 2019, 2020, 2021 Julian Leucker <leuckeju@katharineum.de>
Copyright © 2018, 2019, 2020, 2021 Hangzhi Yu <yuha@katharineum.de>
Copyright © 2019, 2020, 2021 Dominik George <dominik.george@teckids.org>
Copyright © 2019, 2020, 2021 Tom Teichler <tom.teichler@teckids.org>
Copyright © 2018, 2019, 2020, 2021, 2022, 2023, 2024 Hangzhi Yu <yuha@katharineum.de>
Copyright © 2018, 2019, 2020, 2021, 2022, 2023, 2024 Julian Leucker <leuckeju@katharineum.de>
Copyright © 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dominik George <dominik.george@teckids.org>
Copyright © 2019, 2020, 2021, 2022 Tom Teichler <tom.teichler@teckids.org>
Copyright © 2019 mirabilos <thorsten.glaser@teckids.org>
Copyright © 2021, 2022, 2023, 2024 magicfelix <felix@felix-zauberer.de>
Copyright © 2021 Lloyd Meins <meinsll@katharineum.de>
Copyright © 2021 magicfelix <felix@felix-zauberer.de>
Copyright © 2022 Benedict Suska <benedict.suska@teckids.org>
Copyright © 2022, 2023, 2024 Lukas Weichelt <lukas.weichelt@teckids.org>
Copyright © 2023, 2024 Michael Bauer <michael-bauer@posteo.de>
Copyright © 2024 Jonathan Krüger <jonathan.krueger@teckids.org>
Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany).
......@@ -76,6 +86,14 @@ full licence text or on the `European Union Public Licence`_ website
https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers
(including all other official language versions).
.. _AlekSIS: https://aleksis.org
Trademark
---------
AlekSIS® is a registered trademark of the AlekSIS open source project, represented
by Teckids e.V. Please refer to the `trademark policy`_ for hints on using the trademark
AlekSIS®.
.. _AlekSIS®: https://aleksis.org
.. _European Union Public Licence: https://eupl.eu/
.. _EduGit: https://edugit.org/AlekSIS/official/AlekSIS
.. _trademark policy: https://aleksis.org/pages/about
from importlib import metadata
try:
from .celery import app as celery_app
except ModuleNotFoundError:
# Celery is not available
celery_app = None
from .celery import app as celery_app # noqa
try:
__version__ = metadata.distribution("AlekSIS-Core").version
except Exception:
__version__ = "unknown"
default_app_config = "aleksis.core.apps.CoreConfig"
# noqa
from django.contrib import admin
from guardian.admin import GuardedModelAdminMixin
from reversion.admin import VersionAdmin
from .mixins import BaseModelAdmin
from .models import (
Activity,
Announcement,
AnnouncementRecipient,
CustomMenuItem,
DataCheckResult,
Group,
Notification,
Person,
)
admin.site.register(Activity, VersionAdmin)
admin.site.register(Notification, VersionAdmin)
admin.site.register(CustomMenuItem, VersionAdmin)
class AnnouncementRecipientInline(admin.StackedInline):
model = AnnouncementRecipient
class AnnouncementAdmin(BaseModelAdmin, VersionAdmin):
inlines = [
AnnouncementRecipientInline,
]
class GuardedVersionAdmin(GuardedModelAdminMixin, VersionAdmin):
pass
admin.site.register(Announcement, AnnouncementAdmin)
admin.site.register(DataCheckResult)
admin.site.register(Person, GuardedVersionAdmin)
admin.site.register(Group, GuardedVersionAdmin)
from typing import Any, Optional
from typing import TYPE_CHECKING, Any, Optional
import django.apps
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest
from django.utils.module_loading import autodiscover_modules
from django.utils.translation import gettext as _
from dynamic_preferences.registries import preference_models
from health_check.plugins import plugin_dir
from oauthlib.common import Request as OauthlibRequest
from .registries import (
group_preferences_registry,
person_preferences_registry,
site_preferences_registry,
)
from .registries import group_preferences_registry, person_preferences_registry
from .util.apps import AppConfig
from .util.core_helpers import get_or_create_favicon, has_person
from .util.sass_helpers import clean_scss
from .util.core_helpers import (
create_default_celery_schedule,
get_or_create_favicon,
get_site_preferences,
has_person,
)
from .util.types import setup_types
if TYPE_CHECKING:
from django.contrib.auth.models import User
class CoreConfig(AppConfig):
......@@ -30,35 +35,45 @@ class CoreConfig(AppConfig):
}
licence = "EUPL-1.2+"
copyright_info = (
([2017, 2018, 2019, 2020, 2021], "Jonathan Weth", "wethjo@katharineum.de"),
(
[2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024],
"Jonathan Weth",
"dev@jonathanweth.de",
),
([2017, 2018, 2019, 2020], "Frank Poetzsch-Heffter", "p-h@katharineum.de"),
([2018, 2019, 2020, 2021], "Julian Leucker", "leuckeju@katharineum.de"),
([2018, 2019, 2020, 2021], "Hangzhi Yu", "yuha@katharineum.de"),
([2019, 2020, 2021], "Dominik George", "dominik.george@teckids.org"),
([2019, 2020, 2021], "Tom Teichler", "tom.teichler@teckids.org"),
([2018, 2019, 2020, 2021, 2022, 2023, 2024], "Hangzhi Yu", "yuha@katharineum.de"),
([2018, 2019, 2020, 2021, 2022, 2023, 2024], "Julian Leucker", "leuckeju@katharineum.de"),
(
[2019, 2020, 2021, 2022, 2023, 2024, 2025],
"Dominik George",
"dominik.george@teckids.org",
),
([2019, 2020, 2021, 2022], "Tom Teichler", "tom.teichler@teckids.org"),
([2019], "mirabilos", "thorsten.glaser@teckids.org"),
([2021, 2022, 2023, 2024], "magicfelix", "felix@felix-zauberer.de"),
([2021], "Lloyd Meins", "meinsll@katharineum.de"),
([2021], "magicfelix", "felix@felix-zauberer.de"),
([2022], "Benedict Suska", "benedict.suska@teckids.org"),
([2022, 2023, 2024], "Lukas Weichelt", "lukas.weichelt@teckids.org"),
([2023, 2024], "Michael Bauer", "michael-bauer@posteo.de"),
([2024], "Jonathan Krüger", "jonathan.krueger@teckids.org"),
)
def ready(self):
super().ready()
setup_types()
from django.conf import settings # noqa
# Autodiscover various modules defined by AlekSIS
autodiscover_modules("form_extensions", "model_extensions", "checks")
autodiscover_modules("model_extensions", "form_extensions", "checks", "util.dav_handler")
sitepreferencemodel = self.get_model("SitePreferenceModel")
personpreferencemodel = self.get_model("PersonPreferenceModel")
grouppreferencemodel = self.get_model("GroupPreferenceModel")
preference_models.register(sitepreferencemodel, site_preferences_registry)
preference_models.register(personpreferencemodel, person_preferences_registry)
preference_models.register(grouppreferencemodel, group_preferences_registry)
self._load_data_checks()
from .health_checks import (
BackupJobHealthCheck,
DataChecksHealthCheckBackend,
......@@ -71,16 +86,6 @@ class CoreConfig(AppConfig):
plugin_dir.register(MediaBackupAgeHealthCheck)
plugin_dir.register(BackupJobHealthCheck)
@classmethod
def _load_data_checks(cls):
"""Get all data checks from all loaded models."""
from aleksis.core.data_checks import DataCheckRegistry
data_checks = set()
for model in apps.get_models():
data_checks.update(getattr(model, "data_checks", []))
DataCheckRegistry.data_checks = data_checks
def preference_updated(
self,
sender: Any,
......@@ -92,24 +97,43 @@ class CoreConfig(AppConfig):
) -> None:
from django.conf import settings # noqa
if section == "theme":
if name in ("primary", "secondary"):
clean_scss()
elif name in ("favicon", "pwa_icon"):
from favicon.models import Favicon # noqa
if section == "theme" and name in ("favicon", "pwa_icon"):
from favicon.models import Favicon, FaviconImg # noqa
is_favicon = name == "favicon"
if new_value:
# Get file object from preferences instead of using new_value
# to prevent problems with special file storages
file_obj = get_site_preferences()[f"{section}__{name}"]
favicon = Favicon.objects.update_or_create(
title=name,
defaults={"isFavicon": is_favicon, "faviconImage": file_obj},
)[0]
FaviconImg.objects.filter(faviconFK=favicon).delete()
else:
Favicon.objects.filter(title=name, isFavicon=is_favicon).delete()
if name in settings.DEFAULT_FAVICON_PATHS:
get_or_create_favicon(
name, settings.DEFAULT_FAVICON_PATHS[name], is_favicon=is_favicon
)
is_favicon = name == "favicon"
def pre_migrate(
self,
app_config: django.apps.AppConfig,
verbosity: int,
interactive: bool,
using: str,
plan: list[tuple],
apps: django.apps.registry.Apps,
**kwargs,
) -> None:
super().pre_migrate(app_config, verbosity, interactive, using, plan, apps)
from .data_checks import check_data_for_migrations
if new_value:
Favicon.on_site.update_or_create(
title=name, defaults={"isFavicon": is_favicon, "faviconImage": new_value},
)
else:
Favicon.on_site.filter(title=name, isFavicon=is_favicon).delete()
if name in settings.DEFAULT_FAVICON_PATHS:
get_or_create_favicon(
name, settings.DEFAULT_FAVICON_PATHS[name], is_favicon=is_favicon
)
# Run data checks to validate data
check_data_for_migrations(with_dependencies=True)
def post_migrate(
self,
......@@ -120,18 +144,20 @@ class CoreConfig(AppConfig):
**kwargs,
) -> None:
from django.conf import settings # noqa
from .data_checks import check_data_for_migrations
super().post_migrate(app_config, verbosity, interactive, using, **kwargs)
# Ensure presence of an OTP YubiKey default config
apps.get_model("otp_yubikey", "ValidationService").objects.using(using).update_or_create(
name="default", defaults={"use_ssl": True, "param_sl": "", "param_timeout": ""}
)
# Ensure that default Favicon object exists
for name, default in settings.DEFAULT_FAVICON_PATHS.items():
get_or_create_favicon(name, default, is_favicon=name == "favicon")
# Create default periodic tasks
create_default_celery_schedule()
# Run data checks to validate data
check_data_for_migrations()
def user_logged_in(
self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs
) -> None:
......@@ -139,6 +165,11 @@ class CoreConfig(AppConfig):
# Save the associated person to pick up defaults
user.person.save()
def user_logged_out(
self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs
) -> None:
messages.success(request, _("You have been logged out successfully."))
@classmethod
def get_all_scopes(cls) -> dict[str, str]:
scopes = {
......@@ -149,8 +180,60 @@ class CoreConfig(AppConfig):
scopes |= {
"openid": _("OpenID Connect scope"),
"profile": _("Given name, family name, link to profile and picture if existing."),
"address": _("Full home postal address"),
"addresses": _("Postal addresses"),
"email": _("Email address"),
"phone": _("Home and mobile phone"),
"groups": _("Groups"),
}
return scopes
@classmethod
def get_additional_claims(cls, scopes: list[str], request: OauthlibRequest) -> dict[str, Any]:
django_request = HttpRequest()
django_request.META = request.headers
claims = {
"preferred_username": request.user.username,
}
if "profile" in scopes:
if has_person(request.user):
claims["given_name"] = request.user.person.first_name
claims["family_name"] = request.user.person.last_name
claims["profile"] = django_request.build_absolute_uri(
request.user.person.get_absolute_url()
)
if request.user.person.avatar:
claims["picture"] = django_request.build_absolute_uri(
request.user.person.avatar.url
)
else:
claims["given_name"] = request.user.first_name
claims["family_name"] = request.user.last_name
if "email" in scopes:
if has_person(request.user):
claims["email"] = request.user.person.email
else:
claims["email"] = request.user.email
if "addresses" in scopes and has_person(request.user):
claims["addresses"] = [
{
"street_address": address.street + " " + address.housenumber,
"locality": address.place,
"postal_code": address.postal_code,
}
for address in request.user.person.addresses.all()
]
if "phone" in scopes and has_person(request.user):
claims["mobile_number"] = request.user.person.mobile_number
claims["phone_number"] = request.user.person.phone_number
if "groups" in scopes and has_person(request.user):
claims["groups"] = list(
request.user.person.member_of.values_list("name", flat=True).all()
)
return claims
import logging
import os
from traceback import format_exception
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from aleksis.core.models import TaskUserAssignment
from django.conf import settings
from django.db import transaction
from celery import Celery
from celery.contrib.django.task import DjangoTask
from celery.signals import setup_logging, task_failure
from .util.core_helpers import get_site_preferences
from .util.email import send_email
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "aleksis.core.settings")
app = Celery("aleksis") # noqa
class CeleryTask(DjangoTask):
"""Custom Task object for progress tracking."""
def delay_with_progress(
self, user_assignment: "TaskUserAssignment", *args, **kwargs
) -> "TaskUserAssignment":
"""Start task and track the progress."""
user_assignment.save()
def _inner():
task_result = self.delay(*args, **kwargs)
user_assignment.task_id = task_result.id
user_assignment.save()
transaction.on_commit(_inner)
return user_assignment
app = Celery("aleksis", task_cls=CeleryTask) # noqa
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@task_failure.connect
def task_failure_notifier(
sender=None, task_id=None, exception=None, args=None, kwargs=None, traceback=None, **__
):
recipient_list = [e[1] for e in settings.ADMINS]
send_email(
template_name="celery_failure",
from_email=get_site_preferences()["mail__address"],
recipient_list=recipient_list,
context={
"task_name": sender.name,
"task": str(sender),
"task_id": str(task_id),
"exception": str(exception),
"args": args,
"kwargs": kwargs,
"traceback": "".join(format_exception(type(exception), exception, traceback)),
},
)
@setup_logging.connect
def on_setup_logging(*args, **kwargs):
"""Load Django's logging configuration when running inside Celery."""
logging.config.dictConfig(settings.LOGGING)
from collections.abc import Iterable
from typing import Optional
import django.apps
from django.core.checks import Tags, Warning, register
from django.core.checks import Error, Tags, Warning, register # noqa
from .mixins import ExtensibleModel, GlobalPermissionModel, PureDjangoModel
from .schema.base import BaseBatchCreateMutation, BaseBatchDeleteMutation, BaseBatchPatchMutation
from .util.apps import AppConfig
......@@ -71,3 +73,28 @@ def check_app_models_base_class(
)
return results
@register(Tags.security)
def check_all_mutations_with_permissions(
app_configs: Optional[django.apps.registry.Apps] = None, **kwargs
) -> list:
results = []
for base_class in [BaseBatchCreateMutation, BaseBatchPatchMutation, BaseBatchDeleteMutation]:
for subclass in base_class.__subclasses__():
if (
not isinstance(subclass._meta.permissions, Iterable)
or not subclass._meta.permissions
):
results.append(
Error(
f"Mutation {subclass.__name__} doesn't set required permission",
hint=(
"Ensure that the mutation is protected by setting the "
"permissions attribute in the mutation's Meta class."
),
obj=subclass,
id="aleksis.core.E001",
)
)
return results
import logging
from datetime import timedelta
from typing import TYPE_CHECKING
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import connections
from django.db.migrations.recorder import MigrationRecorder
from django.db.models import Model
from django.db.models.aggregates import Count
from django.utils.functional import classproperty
from django.utils.text import slugify
from django.utils.translation import gettext as _
import reversion
from color_contrast import AccessibilityLevel, ModulationMode, check_contrast, modulate
from reversion import set_comment
from templated_email import send_templated_mail
from .mixins import RegistryObject
from .util.celery_progress import ProgressRecorder, recorded_task
from .util.core_helpers import get_site_preferences
from .util.email import send_email
if TYPE_CHECKING:
from aleksis.core.models import DataCheckResult
class SolveOption:
......@@ -28,7 +40,7 @@ class SolveOption:
from django.utils.translation import gettext as _
class DeleteSolveOption(SolveOption):
name = "delete" # has to be unqiue
_class_name = "delete" # has to be unqiue
verbose_name = _("Delete") # should make use of i18n
@classmethod
......@@ -40,7 +52,7 @@ class SolveOption:
the corresponding data check result has to be deleted.
"""
name: str = "default"
_class_name: str = "default"
verbose_name: str = ""
@classmethod
......@@ -51,7 +63,7 @@ class SolveOption:
class IgnoreSolveOption(SolveOption):
"""Mark the object with data issues as solved."""
name = "ignore"
_class_name = "ignore"
verbose_name = _("Ignore problem")
@classmethod
......@@ -61,7 +73,7 @@ class IgnoreSolveOption(SolveOption):
check_result.save()
class DataCheck:
class DataCheck(RegistryObject):
"""Define a data check.
Data checks should be used to search objects of
......@@ -83,12 +95,13 @@ class DataCheck:
from django.utils.translation import gettext as _
class ExampleDataCheck(DataCheck):
name = "example" # has to be unique
_class_name = "example" # has to be unique
verbose_name = _("Ensure that there are no examples.")
problem_name = _("There is an example.") # should both make use of i18n
required_for_migrations = True # Make mandatory for migrations
solve_options = {
IgnoreSolveOption.name: IgnoreSolveOption
IgnoreSolveOption._class_name: IgnoreSolveOption
}
@classmethod
......@@ -119,7 +132,7 @@ class DataCheck:
The dictionary ``solve_options`` should include at least the IgnoreSolveOption,
but preferably also own solve options. The keys in this dictionary
have to be ``<YourOption>SolveOption.name``
have to be ``<YourOption>SolveOption._class_name``
and the values must be the corresponding solve option classes.
The class method ``check_data`` does the actual work. In this method
......@@ -151,11 +164,13 @@ class DataCheck:
the preference ``Send emails if data checks detect problems``.
""" # noqa: D412
name: str = ""
verbose_name: str = ""
problem_name: str = ""
solve_options = {IgnoreSolveOption.name: IgnoreSolveOption}
required_for_migrations: bool = False
migration_dependencies: list[str] = []
solve_options = {IgnoreSolveOption._class_name: IgnoreSolveOption}
_current_results = []
......@@ -187,6 +202,12 @@ class DataCheck:
)
solve_option_obj.solve(check_result)
@classmethod
def get_results(cls):
DataCheckResult = apps.get_model("core", "DataCheckResult")
return DataCheckResult.objects.filter(data_check=cls._class_name)
@classmethod
def register_result(cls, instance) -> "DataCheckResult":
"""Register an object with data issues in the result database.
......@@ -198,7 +219,9 @@ class DataCheck:
ct = ContentType.objects.get_for_model(instance)
result, __ = DataCheckResult.objects.get_or_create(
check=cls.name, content_type=ct, object_id=instance.id
data_check=cls._class_name,
content_type=ct,
object_id=instance.id if not isinstance(instance, int) else instance,
)
# Track all existing problems (for deleting old results)
......@@ -209,37 +232,26 @@ class DataCheck:
@classmethod
def delete_old_results(cls):
"""Delete old data check results for problems which exist no longer."""
DataCheckResult = apps.get_model("core", "DataCheckResult")
pks = [r.pk for r in cls._current_results]
old_results = DataCheckResult.objects.filter(check=cls.name).exclude(pk__in=pks)
old_results = cls.get_results().exclude(pk__in=pks)
if old_results:
if old_results.exists():
logging.info(f"Delete {old_results.count()} old data check results.")
old_results.delete()
# Reset list with existing problems
cls._current_results = []
class DataCheckRegistry:
"""Create central registry for all data checks in AlekSIS."""
data_checks: set = set()
@classproperty
def data_checks_by_name(cls):
return {check.name: check for check in cls.data_checks}
@classproperty
def data_checks_choices(cls):
return [(check.name, check.verbose_name) for check in cls.data_checks]
return [(check._class_name, check.verbose_name) for check in cls.registered_objects_list]
@recorded_task
@recorded_task(run_every=timedelta(minutes=15))
def check_data(recorder: ProgressRecorder):
"""Execute all registered data checks and send email if activated."""
for check in recorder.iterate(DataCheckRegistry.data_checks):
for check in recorder.iterate(DataCheck.registered_objects_list):
logging.info(f"Run check: {check.verbose_name}")
check.run_check_data()
......@@ -247,6 +259,36 @@ def check_data(recorder: ProgressRecorder):
send_emails_for_data_checks()
def check_data_for_migrations(with_dependencies: bool = False):
"""Run data checks before/after migrations to ensure consistency of data."""
applied_migrations = set(MigrationRecorder(connections["default"]).applied_migrations())
for check in filter(lambda d: d.required_for_migrations, DataCheck.registered_objects_list):
check: DataCheck
if set(check.migration_dependencies).issubset(applied_migrations) or not with_dependencies:
logging.info(f"Run data check: {check.verbose_name}")
check.run_check_data()
# Show results
results = check.get_results().values("id")
for result in results:
result: DataCheckResult
logging.info(f"#{result['id']}")
if results and with_dependencies:
logging.error(
"There are unresolved data checks necessary for migrating. "
"Please resolve them as described in the documentation before migrating."
)
exit(1)
elif results:
logging.error(
"There are unresolved data checks. "
"Please check and resolve them in the web interface."
)
def send_emails_for_data_checks():
"""Notify one or more recipients about new problems with data.
......@@ -257,12 +299,12 @@ def send_emails_for_data_checks():
results = DataCheckResult.objects.filter(solved=False, sent=False)
if results.exists():
results_by_check = results.values("check").annotate(count=Count("check"))
results_by_check = results.values("data_check").annotate(count=Count("data_check"))
results_with_checks = []
for result in results_by_check:
results_with_checks.append(
(DataCheckRegistry.data_checks_by_name[result["check"]], result["count"])
(DataCheck.registered_objects_dict[result["data_check"]], result["count"])
)
recipient_list = [
......@@ -274,9 +316,8 @@ def send_emails_for_data_checks():
for group in get_site_preferences()["general__data_checks_recipient_groups"]:
recipient_list += [p.mail_sender for p in group.announcement_recipients if p.email]
send_templated_mail(
send_email(
template_name="data_checks",
from_email=get_site_preferences()["mail__address"],
recipient_list=recipient_list,
context={"results": results_with_checks},
)
......@@ -287,32 +328,303 @@ def send_emails_for_data_checks():
class DeactivateDashboardWidgetSolveOption(SolveOption):
name = "deactivate_dashboard_widget"
_class_name = "deactivate_dashboard_widget"
verbose_name = _("Deactivate DashboardWidget")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
from .models import DashboardWidget
widget = check_result.related_object
widget.active = False
widget.status = DashboardWidget.Status.OFF.value
widget.save()
check_result.delete()
class BrokenDashboardWidgetDataCheck(DataCheck):
name = "broken_dashboard_widgets"
_class_name = "broken_dashboard_widgets"
verbose_name = _("Ensure that there are no broken DashboardWidgets.")
problem_name = _("The DashboardWidget was reported broken automatically.")
solve_options = {
IgnoreSolveOption.name: IgnoreSolveOption,
DeactivateDashboardWidgetSolveOption.name: DeactivateDashboardWidgetSolveOption,
IgnoreSolveOption._class_name: IgnoreSolveOption,
DeactivateDashboardWidgetSolveOption._class_name: DeactivateDashboardWidgetSolveOption,
}
@classmethod
def check_data(cls):
from .models import DashboardWidget
broken_widgets = DashboardWidget.objects.filter(broken=True, active=True)
broken_widgets = DashboardWidget.objects.filter(status=DashboardWidget.Status.BROKEN.value)
for widget in broken_widgets:
logging.info("Check DashboardWidget %s", widget)
cls.register_result(widget)
def field_validation_data_check_factory(app_name: str, model_name: str, field_name: str) -> type:
from django.apps import apps
class FieldValidationDataCheck(DataCheck):
_class_name = f"field_validation_{slugify(model_name)}_{slugify(field_name)}"
verbose_name = _("Validate field {field} of model {model}.").format(
field=field_name, model=app_name + "." + model_name
)
problem_name = _("The field {} couldn't be validated successfully.").format(field_name)
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
}
@classmethod
def check_data(cls):
model: Model = apps.get_model(app_name, model_name)
for obj in model.objects.all():
try:
model._meta.get_field(field_name).validate(getattr(obj, field_name), obj)
except ValidationError:
logging.info(f"Check {model_name} {obj}")
cls.register_result(obj)
FieldValidationDataCheck.__name__ = model_name + "FieldValidationDataCheck"
return FieldValidationDataCheck
class DisallowedUIDDataCheck(DataCheck):
_class_name = "disallowed_uid"
verbose_name = _("Ensure that there are no disallowed usernames.")
problem_name = _("A user with a disallowed username was reported automatically.")
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
}
@classmethod
def check_data(cls):
from django.contrib.auth.models import User
disallowed_uids = get_site_preferences()["auth__disallowed_uids"].split(",")
for user in User.objects.filter(username__in=disallowed_uids):
logging.info(f"Check User {user}")
cls.register_result(user)
field_validation_data_check_factory("core", "CustomMenuItem", "icon")
class ChangeEmailAddressSolveOption(SolveOption):
_class_name = "change_email_address"
verbose_name = _("Change email address")
class EmailUniqueDataCheck(DataCheck):
_class_name = "email_unique"
verbose_name = _("Ensure that email addresses are unique among all persons")
problem_name = _("There was a non-unique email address.")
required_for_migrations = True
migration_dependencies = [("core", "0057_drop_otp_yubikey")]
solve_options = {ChangeEmailAddressSolveOption._class_name: ChangeEmailAddressSolveOption}
@classmethod
def check_data(cls):
known_email_addresses = set()
from .models import Person
persons = Person.objects.values("id", "email")
for person in persons:
if person["email"] and person["email"] in known_email_addresses:
cls.register_result(person["id"])
known_email_addresses.add(person["email"])
def accessible_colors_factory(
app_name: str,
model_name: str,
fg_field_name: str = None,
bg_field_name: str = None,
fg_color: str = "#ffffff",
bg_color: str = "#000000",
modulation_mode: ModulationMode = ModulationMode.BOTH,
) -> None:
ColorAccessibilityDataCheck.models.append(
(
app_name,
model_name,
fg_field_name,
bg_field_name,
fg_color,
bg_color,
modulation_mode,
)
)
def _get_colors_from_model_instance(instance, fg_field_name, bg_field_name, fg_color, bg_color):
colors: list[str] = [fg_color, bg_color]
if fg_field_name is not None:
colors[0] = getattr(instance, fg_field_name)
if bg_field_name is not None:
colors[1] = getattr(instance, bg_field_name)
# Transparency is not support for checking contrasts, so simply truncate it
for index, color in enumerate(colors):
if not color.startswith("#"):
continue
if len(color) == 5:
# color is of format "#RGBA"
colors[index] = color[:-1]
elif len(color) == 9:
# color is of format "#RRGGBBAA"
colors[index] = color[:-2]
return colors
class ModulateColorsSolveOption(SolveOption):
_class_name = "modulate_colors"
verbose_name = _("Auto-adjust Colors")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
instance = check_result.related_object
ctype = check_result.content_type
model_info = list(
filter(
lambda m: m[0] == ctype.app_label and m[1] == ctype.model,
ColorAccessibilityDataCheck.models,
)
)
if len(model_info) == 0:
check_result.solved = False
check_result.save()
logging.error(f"Modulate Colors check failed for {check_result}: Model Info not found")
return
elif len(model_info) > 1:
check_result.solved = False
check_result.save()
logging.error(f"Modulate Colors check failed for {check_result}: Duplicate Model Info")
return
[_, _, fg_field_name, bg_field_name, fg_color, bg_color, modulation_mode] = model_info[0]
colors = _get_colors_from_model_instance(
instance, fg_field_name, bg_field_name, fg_color, bg_color
)
fg_new, bg_new, success = modulate(*colors, mode=modulation_mode)
if not success:
check_result.solved = False
check_result.save()
logging.error(
f"Modulate Colors check failed for {check_result}: Modulation not possible"
)
return
if fg_field_name:
setattr(instance, fg_field_name, fg_new)
if bg_field_name:
setattr(instance, bg_field_name, bg_new)
instance.save()
check_result.solved = True
check_result.save()
class ColorAccessibilityDataCheck(DataCheck):
_class_name = "colors_accessibility_datacheck"
verbose_name = _("Validate contrast accessibility of colors of customizable objects.")
problem_name = _("The colors of this object are not accessible.")
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
ModulateColorsSolveOption._class_name: ModulateColorsSolveOption,
}
models = []
@classmethod
def check_data(cls):
from django.apps import apps
for [
app_name,
model_name,
fg_field_name,
bg_field_name,
fg_color,
bg_color,
_modulation_mode,
] in cls.models:
model: Model = apps.get_model(app_name, model_name)
for obj in model.objects.all():
colors = _get_colors_from_model_instance(
obj, fg_field_name, bg_field_name, fg_color, bg_color
)
if not check_contrast(*colors, level=AccessibilityLevel.AA):
logging.info(f"Insufficient contrast in {app_name}.{model_name}.{obj}")
cls.register_result(obj)
class ModulateThemeColorsSolveOption(SolveOption):
_class_name = "modulate_theme_colors"
verbose_name = _("Auto-adjust Color")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
instance = check_result.related_object
preference = f"{instance.section}__{instance.name}"
prefs = get_site_preferences()
color = prefs[preference]
fg_new, color_new, success = modulate("#fff", color, mode=ModulationMode.BACKGROUND)
if not success:
check_result.solved = False
check_result.save()
logging.error(f"Modulate {instance.name} theme color failed: Modulation not possible.")
return
prefs[preference] = str(color_new)
check_result.solved = True
check_result.save()
class AccessibleThemeColorsDataCheck(DataCheck):
_class_name = "accessible_themes_colors_datacheck"
verbose_name = _("Validate that theme colors are accessible.")
problem_name = _("The color does not provide enough contrast")
solve_options = {
IgnoreSolveOption._class_name: IgnoreSolveOption,
ModulateThemeColorsSolveOption._class_name: ModulateThemeColorsSolveOption,
}
@classmethod
def check_data(cls):
from dynamic_preferences.models import GlobalPreferenceModel
from .util.core_helpers import get_site_preferences
prefs = get_site_preferences()
primary = prefs["theme__primary"]
secondary = prefs["theme__secondary"]
# White text on primary colored background
if not check_contrast("#fff", primary, level=AccessibilityLevel.AA):
logging.info("Insufficient contrast in primary color")
obj = GlobalPreferenceModel.objects.get(section="theme", name="primary")
cls.register_result(obj)
# White text on secondary colored background
if not check_contrast("#fff", secondary, level=AccessibilityLevel.AA):
logging.info("Insufficient contrast in primary color")
obj = GlobalPreferenceModel.objects.get(section="theme", name="secondary")
cls.register_result(obj)
from functools import wraps
def pwa_cache(view_func):
"""Add headers to a response so that the PWA will recognize it as cacheable."""
@wraps(view_func)
def _wrapped_view_func(request, *args, **kwargs):
# Ensure argument looks like a request.
if not hasattr(request, "META"):
raise TypeError(
"pwa_cache didn't receive an HttpRequest. If you are "
"decorating a classmethod, be sure to use @method_decorator."
)
response = view_func(request, *args, **kwargs)
response.headers["PWA-Is-Cacheable"] = "true"
response.headers["Access-Control-Expose-Headers"] = "PWA-Is-Cacheable"
return response
return _wrapped_view_func
from typing import Sequence
from collections.abc import Sequence
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from django_filters import CharFilter, FilterSet, ModelChoiceFilter, ModelMultipleChoiceFilter
from django_filters import CharFilter, FilterSet, ModelChoiceFilter
from django_select2.forms import ModelSelect2Widget
from guardian.models import GroupObjectPermission, UserObjectPermission
from material import Layout, Row
from aleksis.core.models import Group, GroupType, Person, SchoolTerm
from aleksis.core.models import Person
class MultipleCharFilter(CharFilter):
......@@ -18,10 +23,7 @@ class MultipleCharFilter(CharFilter):
def filter(self, qs, value): # noqa
q = None
for field in self.fields:
if not q:
q = Q(**{field: value})
else:
q = q | Q(**{field: value})
q = Q(**{field: value}) if not q else q | Q(**{field: value})
return qs.filter(q)
def __init__(self, fields: Sequence[str], *args, **kwargs):
......@@ -29,19 +31,6 @@ class MultipleCharFilter(CharFilter):
super().__init__(self, *args, **kwargs)
class GroupFilter(FilterSet):
school_term = ModelChoiceFilter(queryset=SchoolTerm.objects.all())
group_type = ModelChoiceFilter(queryset=GroupType.objects.all())
parent_groups = ModelMultipleChoiceFilter(queryset=Group.objects.all())
search = MultipleCharFilter(["name__icontains", "short_name__icontains"], label=_("Search"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form.layout = Layout(Row("search"), Row("school_term", "group_type", "parent_groups"))
self.form.initial = {"school_term": SchoolTerm.current}
class PersonFilter(FilterSet):
name = MultipleCharFilter(
[
......@@ -49,15 +38,17 @@ class PersonFilter(FilterSet):
"additional_name__icontains",
"last_name__icontains",
"short_name__icontains",
"user__username__icontains",
],
label=_("Search by name"),
)
contact = MultipleCharFilter(
[
"street__icontains",
"housenumber__icontains",
"postal_code__icontains",
"place__icontains",
"addresses__street__icontains",
"addresses__housenumber__icontains",
"addresses__postal_code__icontains",
"addresses__place__icontains",
"addresses__country__icontains",
"phone_number__icontains",
"mobile_number__icontains",
"email__icontains",
......@@ -67,8 +58,99 @@ class PersonFilter(FilterSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form.layout = Layout(Row("name", "contact"), Row("is_active", "sex", "primary_group"))
self.form.layout = Layout(Row("name", "contact"), Row("sex", "primary_group"))
class Meta:
model = Person
fields = ["sex", "is_active", "primary_group"]
fields = ["sex", "primary_group"]
class PermissionFilter(FilterSet):
"""Common filter for permissions."""
permission = ModelChoiceFilter(
queryset=Permission.objects.all(),
widget=ModelSelect2Widget(
search_fields=["name__icontains", "codename__icontains"],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
label=_("Permission"),
)
permission__content_type = ModelChoiceFilter(
queryset=ContentType.objects.all(),
widget=ModelSelect2Widget(
search_fields=["app_label__icontains", "model__icontains"],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
label=_("Content type"),
)
class UserPermissionFilter(PermissionFilter):
"""Common filter for user permissions."""
user = ModelChoiceFilter(
queryset=User.objects.all(),
widget=ModelSelect2Widget(
search_fields=["username__icontains", "first_name__icontains", "last_name__icontains"],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
label=_("User"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form.layout = Layout(Row("user", "permission", "permission__content_type"))
class Meta:
fields = ["user", "permission", "permission__content_type"]
class GroupPermissionFilter(PermissionFilter):
"""Common filter for group permissions."""
group = ModelChoiceFilter(
queryset=DjangoGroup.objects.all(),
widget=ModelSelect2Widget(
search_fields=[
"name__icontains",
],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
label=_("Group"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form.layout = Layout(Row("group", "permission", "permission__content_type"))
class Meta:
fields = ["group", "permission", "permission__content_type"]
class UserGlobalPermissionFilter(UserPermissionFilter):
"""Filter for global user permissions."""
class Meta(UserPermissionFilter.Meta):
model = User.user_permissions.through
class GroupGlobalPermissionFilter(GroupPermissionFilter):
"""Filter for global group permissions."""
class Meta(GroupPermissionFilter.Meta):
model = DjangoGroup.permissions.through
class UserObjectPermissionFilter(UserPermissionFilter):
"""Filter for object user permissions."""
class Meta(UserPermissionFilter.Meta):
model = UserObjectPermission
class GroupObjectPermissionFilter(GroupPermissionFilter):
"""Filter for object group permissions."""
class Meta(GroupPermissionFilter.Meta):
model = GroupObjectPermission