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 (5538)
Showing with 2370 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 ...@@ -52,6 +52,11 @@ DEADJOE
.idea .idea
.idea/ .idea/
# VSCode
.vscode/
.history/
*.code-workspace
# Database # Database
db.sqlite3 db.sqlite3
...@@ -62,19 +67,32 @@ docs/_build/ ...@@ -62,19 +67,32 @@ docs/_build/
*.aux *.aux
# Generated files # Generated files
aleksis/node_modules/ /cache
aleksis/static/ /node_modules
aleksis/whoosh_index/ .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 .coverage
.mypy_cache/ .mypy_cache/
.tox/ .tox/
htmlcov/ htmlcov/
# Data
maintenance_mode_state.txt maintenance_mode_state.txt
media/ media/
package-lock.json
# VSCode aleksis/core/static/style.css
.vscode/
.history/
*.code-workspace
include: include:
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/general.yml file: /ci/general.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/test/test.yml file: /ci/prepare/lock.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/test/lint.yml file: /ci/test/lint.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/test/security.yml file: /ci/test/security.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/build/dist.yml file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/publish/pypi.yml file: /ci/build/dist.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/docker/image.yml file: /ci/publish/pypi.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/review.yml" file: /ci/docker/image.yml
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/trigger_dist.yml" file: "/ci/deploy/review.yml"
- project: "AlekSIS/official/AlekSIS" - project: "AlekSIS/official/AlekSIS"
file: /ci/deploy/pages.yml file: "/ci/deploy/trigger_dist.yml"
...@@ -8,7 +8,9 @@ Jonathan Weth <git@jonathanweth.de> Jonathan Weth <joniweth@gmx.de> ...@@ -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 <mail@jonathanweth.de>
Jonathan Weth <git@jonathanweth.de> Jonathan Weth <wethjo@katharineum.de> Jonathan Weth <git@jonathanweth.de> Jonathan Weth <wethjo@katharineum.de>
Julian Leucker <leuckerj@gmail.com> Julian <leuckerj@gmail.com> 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> 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 <mirabilos@evolvis.org>
mirabilos <thorsten.glaser@teckids.org> mirabilos <t.glaser@tarent.de> mirabilos <thorsten.glaser@teckids.org> mirabilos <t.glaser@tarent.de>
root (Skolelinux) <root@tjener.intern> root <root@tjener.intern> 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 # Build arguments
ARG EXTRAS="ldap,s3" ARG EXTRAS="ldap,s3,sentry"
ARG APP_VERSION="" 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 # Configure Python to be nice inside Docker and pip to stfu
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
...@@ -10,35 +11,44 @@ ENV PYTHONDONTWRITEBYTECODE 1 ...@@ -10,35 +11,44 @@ ENV PYTHONDONTWRITEBYTECODE 1
ENV PIP_DEFAULT_TIMEOUT 100 ENV PIP_DEFAULT_TIMEOUT 100
ENV PIP_DISABLE_PIP_VERSION_CHECK 1 ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV PIP_NO_CACHE_DIR 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 PIP_USE_DEPRECATED legacy-resolver
ENV DEBIAN_FRONTEND noninteractive ENV DEBIAN_FRONTEND noninteractive
# Configure app settings for build and runtime # Configure app settings for build and runtime
ENV ALEKSIS_caching__dir /var/cache/aleksis
ENV ALEKSIS_static__root /usr/share/aleksis/static ENV ALEKSIS_static__root /usr/share/aleksis/static
ENV ALEKSIS_media__root /var/lib/aleksis/media ENV ALEKSIS_media__root /var/lib/aleksis/media
ENV ALEKSIS_backup__location /var/lib/aleksis/backups ENV ALEKSIS_backup__location /var/lib/aleksis/backups
ENV ALEKSIS_dev__uwsgi__celery false ENV ALEKSIS_dev__uwsgi__celery false
ENV PSQL_PAGER=pspg
# Install necessary Debian and PyPI packages for build and runtime # Install necessary Debian and PyPI packages for build and runtime
RUN apt-get -y update && \ 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 -y upgrade && \
eatmydata apt-get install -y --no-install-recommends \ eatmydata apt-get install -y --no-install-recommends \
build-essential \ build-essential \
chromium \ curl \
dumb-init \ dumb-init \
gettext \ gettext \
libpq5 \ grep \
less \
libpq-dev \ libpq-dev \
libssl-dev \ libssl-dev \
postgresql-client \ locales-all \
postgresql-client-14 \
postgresql-client-15 \
postgresql-client-16 \
pspg \
python3-dev \ python3-dev \
python3-magic \ python3-magic \
python3-pip \ python3-pip \
uwsgi \ uwsgi \
uwsgi-plugin-python3 \ uwsgi-plugin-python3 \
yarnpkg yarnpkg \
git
# Install extra dependencies # Install extra dependencies
RUN case ",$EXTRAS," in \ RUN case ",$EXTRAS," in \
...@@ -52,23 +62,28 @@ RUN case ",$EXTRAS," in \ ...@@ -52,23 +62,28 @@ RUN case ",$EXTRAS," in \
# Install core # Install core
RUN set -e; \ RUN set -e; \
mkdir -p ${ALEKSIS_static__root} \ mkdir -p ${ALEKSIS_caching__dir} \
${ALEKSIS_static__root} \
${ALEKSIS_media__root} \ ${ALEKSIS_media__root} \
${ALEKSIS_backup__location}; \ ${ALEKSIS_backup__location}; \
dpkg-divert --rename --add /usr/lib/$(py3versions -d)/EXTERNALLY-MANAGED; \
eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION
# Define entrypoint, volumes and uWSGI running on port 8000 # Define entrypoint, volumes and uWSGI running on port 8000
EXPOSE 8000 EXPOSE 8000
VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
COPY docker-startup.sh /usr/local/bin/aleksis-docker-startup COPY docker-startup.sh /usr/local/bin/aleksis-docker-startup
ENTRYPOINT ["/usr/bin/dumb-init", "--"] ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/usr/local/bin/aleksis-docker-startup"] CMD ["/usr/local/bin/aleksis-docker-startup"]
# Install assets # Install assets
FROM core as 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; \ eatmydata aleksis-admin collectstatic --no-input; \
rm -rf /usr/local/share/.cache 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 # Clean up build dependencies
FROM assets AS clean FROM assets AS clean
...@@ -76,6 +91,7 @@ RUN set -e; \ ...@@ -76,6 +91,7 @@ RUN set -e; \
eatmydata apt-get remove --purge -y \ eatmydata apt-get remove --purge -y \
build-essential \ build-essential \
gettext \ gettext \
gnupg \
libpq-dev \ libpq-dev \
libssl-dev \ libssl-dev \
libldap2-dev \ libldap2-dev \
...@@ -92,6 +108,7 @@ RUN chown -R www-data:www-data \ ...@@ -92,6 +108,7 @@ RUN chown -R www-data:www-data \
${ALEKSIS_media__root} \ ${ALEKSIS_media__root} \
${ALEKSIS_backup__location} ${ALEKSIS_backup__location}
USER 33:33 USER 33:33
VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
# Additional steps # Additional steps
ONBUILD ARG APPS ONBUILD ARG APPS
...@@ -111,8 +128,9 @@ ONBUILD RUN set -e; \ ...@@ -111,8 +128,9 @@ ONBUILD RUN set -e; \
if [ -n "$APPS" ]; then \ if [ -n "$APPS" ]; then \
eatmydata pip install $APPS; \ eatmydata pip install $APPS; \
fi; \ fi; \
eatmydata aleksis-admin yarn install; \ eatmydata aleksis-admin vite build; \
eatmydata aleksis-admin collectstatic --no-input; \ eatmydata aleksis-admin compile_scss; \
eatmydata aleksis-admin collectstatic --no-input --clear; \
rm -rf /usr/local/share/.cache; \ rm -rf /usr/local/share/.cache; \
eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \ eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
eatmydata apt-get autoremove --purge -y; \ eatmydata apt-get autoremove --purge -y; \
......
...@@ -121,7 +121,7 @@ been modified and the date of modification. ...@@ -121,7 +121,7 @@ been modified and the date of modification.
of the Original Works or Derivative Works, this Distribution or of the Original Works or Derivative Works, this Distribution or
Communication will be done under the terms of this Licence or of a Communication will be done under the terms of this Licence or of a
later version of this Licence unless the Original Work is expressly 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) communicating ‘EUPL v. 1.2 only’. The Licensee (becoming Licensor)
cannot offer or impose any additional terms or conditions on the Work cannot offer or impose any additional terms or conditions on the Work
or Derivative Work that alter or restrict the terms of the Licence. or Derivative Work that alter or restrict the terms of the Licence.
...@@ -306,7 +306,7 @@ Appendix ...@@ -306,7 +306,7 @@ Appendix
* Creative Commons Attribution-ShareAlike v. 3.0 Unported * Creative Commons Attribution-ShareAlike v. 3.0 Unported
(CC BY-SA 3.0) for works other than software (CC BY-SA 3.0) for works other than software
* European Union Public Licence (EUPL) v. 1.1, v. 1.2 * 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+) or Strong Reciprocity (LiLiQ-R+)
The European Commission may update this Appendix to later versions of 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 ...@@ -6,7 +6,7 @@ This is the core of the AlekSIS framework and the official distribution
developers and administrators. developers and administrators.
If you are looking for the AlekSIS standard distribution, i.e. the complete 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`_. website or the distribution repository on `EduGit`_.
Features Features
...@@ -16,58 +16,68 @@ The AlekSIS core currently provides the following features: ...@@ -16,58 +16,68 @@ The AlekSIS core currently provides the following features:
* For users: * For users:
* Authentication via OAuth applications * Authentication via local account, LDAP, or social accounts
* Configurable dashboard * Two factor authentication via WebAuthn, OTP, or SMS
* Custom menu entries (e.g. in footer) * Configurable dashboard with widgets
* Global preferences * User-specific preferences
* Global search * Global search
* Group types * Global calendar system
* Manage announcements * CalDAV and CardDAV support
* Manage groups * Manage personal events
* Manage persons * 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 * 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 registration, password changes and password reset
* User invitations with invite codes and targeted invites
* For admins * For admins
* Asynchronous tasks with celery * `aleksis-admin` script to wrap django-admin with pre-configured settings
* Authentication via LDAP * Manage school terms
* Custom menu entries (e.g. in footer)
* Automatic backup of database, static and media files * Automatic backup of database, static and media files
* Generic PDF generation with chromium
* OAuth2 and OpenID Connect provider support * OAuth2 and OpenID Connect provider support
* Serve prometheus metrics * Serve prometheus metrics
* System health and data checks * 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 * For developers
* `aleksis-admin` script to wrap django-admin with pre-configured settings * Generic PDF generation with firefox
* Caching with Redis * Caching with Valkey
* Django REST framework for apps to use at own discretion * 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 * K8s compatible, read-only Docker image
* Object-level permissions and rules with `django-guardian` and `django-rules` * Object-level permissions and rules with ``django-guardian`` and ``django-rules``
* Query caching with `django-cachalot` * uWSGI and Celery via ``django-uwsgi`` in development
* Search with `django-haystack` and `Whoosh` backend * Extensible dashbaord widget system
* uWSGI and Celery via `django-uwsgi` in development * Extensible calendar system
* Extensible OAuth/OpenID Connect scope and claims system
Licence 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 © 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, 2022, 2023, 2024 Hangzhi Yu <yuha@katharineum.de>
Copyright © 2018, 2019, 2020, 2021 Hangzhi Yu <yuha@katharineum.de> Copyright © 2018, 2019, 2020, 2021, 2022, 2023, 2024 Julian Leucker <leuckeju@katharineum.de>
Copyright © 2019, 2020, 2021 Dominik George <dominik.george@teckids.org> Copyright © 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dominik George <dominik.george@teckids.org>
Copyright © 2019, 2020, 2021 Tom Teichler <tom.teichler@teckids.org> Copyright © 2019, 2020, 2021, 2022 Tom Teichler <tom.teichler@teckids.org>
Copyright © 2019 mirabilos <thorsten.glaser@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 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). 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 ...@@ -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 https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers
(including all other official language versions). (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/ .. _European Union Public Licence: https://eupl.eu/
.. _EduGit: https://edugit.org/AlekSIS/official/AlekSIS .. _EduGit: https://edugit.org/AlekSIS/official/AlekSIS
.. _trademark policy: https://aleksis.org/pages/about
from importlib import metadata from importlib import metadata
try: from .celery import app as celery_app # noqa
from .celery import app as celery_app
except ModuleNotFoundError:
# Celery is not available
celery_app = None
try: try:
__version__ = metadata.distribution("AlekSIS-Core").version __version__ = metadata.distribution("AlekSIS-Core").version
except Exception: except Exception:
__version__ = "unknown" __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 import django.apps
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.module_loading import autodiscover_modules from django.utils.module_loading import autodiscover_modules
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dynamic_preferences.registries import preference_models from dynamic_preferences.registries import preference_models
from health_check.plugins import plugin_dir from health_check.plugins import plugin_dir
from oauthlib.common import Request as OauthlibRequest
from .registries import ( from .registries import group_preferences_registry, person_preferences_registry
group_preferences_registry,
person_preferences_registry,
site_preferences_registry,
)
from .util.apps import AppConfig from .util.apps import AppConfig
from .util.core_helpers import get_or_create_favicon, has_person from .util.core_helpers import (
from .util.sass_helpers import clean_scss 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): class CoreConfig(AppConfig):
...@@ -30,35 +35,45 @@ class CoreConfig(AppConfig): ...@@ -30,35 +35,45 @@ class CoreConfig(AppConfig):
} }
licence = "EUPL-1.2+" licence = "EUPL-1.2+"
copyright_info = ( 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"), ([2017, 2018, 2019, 2020], "Frank Poetzsch-Heffter", "p-h@katharineum.de"),
([2018, 2019, 2020, 2021], "Julian Leucker", "leuckeju@katharineum.de"), ([2018, 2019, 2020, 2021, 2022, 2023, 2024], "Hangzhi Yu", "yuha@katharineum.de"),
([2018, 2019, 2020, 2021], "Hangzhi Yu", "yuha@katharineum.de"), ([2018, 2019, 2020, 2021, 2022, 2023, 2024], "Julian Leucker", "leuckeju@katharineum.de"),
([2019, 2020, 2021], "Dominik George", "dominik.george@teckids.org"), (
([2019, 2020, 2021], "Tom Teichler", "tom.teichler@teckids.org"), [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"), ([2019], "mirabilos", "thorsten.glaser@teckids.org"),
([2021, 2022, 2023, 2024], "magicfelix", "felix@felix-zauberer.de"),
([2021], "Lloyd Meins", "meinsll@katharineum.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): def ready(self):
super().ready() super().ready()
setup_types()
from django.conf import settings # noqa from django.conf import settings # noqa
# Autodiscover various modules defined by AlekSIS # 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") personpreferencemodel = self.get_model("PersonPreferenceModel")
grouppreferencemodel = self.get_model("GroupPreferenceModel") grouppreferencemodel = self.get_model("GroupPreferenceModel")
preference_models.register(sitepreferencemodel, site_preferences_registry)
preference_models.register(personpreferencemodel, person_preferences_registry) preference_models.register(personpreferencemodel, person_preferences_registry)
preference_models.register(grouppreferencemodel, group_preferences_registry) preference_models.register(grouppreferencemodel, group_preferences_registry)
self._load_data_checks()
from .health_checks import ( from .health_checks import (
BackupJobHealthCheck, BackupJobHealthCheck,
DataChecksHealthCheckBackend, DataChecksHealthCheckBackend,
...@@ -71,16 +86,6 @@ class CoreConfig(AppConfig): ...@@ -71,16 +86,6 @@ class CoreConfig(AppConfig):
plugin_dir.register(MediaBackupAgeHealthCheck) plugin_dir.register(MediaBackupAgeHealthCheck)
plugin_dir.register(BackupJobHealthCheck) 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( def preference_updated(
self, self,
sender: Any, sender: Any,
...@@ -92,24 +97,43 @@ class CoreConfig(AppConfig): ...@@ -92,24 +97,43 @@ class CoreConfig(AppConfig):
) -> None: ) -> None:
from django.conf import settings # noqa from django.conf import settings # noqa
if section == "theme": if section == "theme" and name in ("favicon", "pwa_icon"):
if name in ("primary", "secondary"): from favicon.models import Favicon, FaviconImg # noqa
clean_scss()
elif name in ("favicon", "pwa_icon"): is_favicon = name == "favicon"
from favicon.models import Favicon # noqa
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: # Run data checks to validate data
Favicon.on_site.update_or_create( check_data_for_migrations(with_dependencies=True)
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
)
def post_migrate( def post_migrate(
self, self,
...@@ -120,18 +144,20 @@ class CoreConfig(AppConfig): ...@@ -120,18 +144,20 @@ class CoreConfig(AppConfig):
**kwargs, **kwargs,
) -> None: ) -> None:
from django.conf import settings # noqa from django.conf import settings # noqa
from .data_checks import check_data_for_migrations
super().post_migrate(app_config, verbosity, interactive, using, **kwargs) 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 # Ensure that default Favicon object exists
for name, default in settings.DEFAULT_FAVICON_PATHS.items(): for name, default in settings.DEFAULT_FAVICON_PATHS.items():
get_or_create_favicon(name, default, is_favicon=name == "favicon") 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( def user_logged_in(
self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs
) -> None: ) -> None:
...@@ -139,6 +165,11 @@ class CoreConfig(AppConfig): ...@@ -139,6 +165,11 @@ class CoreConfig(AppConfig):
# Save the associated person to pick up defaults # Save the associated person to pick up defaults
user.person.save() 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 @classmethod
def get_all_scopes(cls) -> dict[str, str]: def get_all_scopes(cls) -> dict[str, str]:
scopes = { scopes = {
...@@ -149,8 +180,60 @@ class CoreConfig(AppConfig): ...@@ -149,8 +180,60 @@ class CoreConfig(AppConfig):
scopes |= { scopes |= {
"openid": _("OpenID Connect scope"), "openid": _("OpenID Connect scope"),
"profile": _("Given name, family name, link to profile and picture if existing."), "profile": _("Given name, family name, link to profile and picture if existing."),
"address": _("Full home postal address"), "addresses": _("Postal addresses"),
"email": _("Email address"), "email": _("Email address"),
"phone": _("Home and mobile phone"), "phone": _("Home and mobile phone"),
"groups": _("Groups"),
} }
return scopes 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 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 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") 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.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks() 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 from typing import Optional
import django.apps 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 .mixins import ExtensibleModel, GlobalPermissionModel, PureDjangoModel
from .schema.base import BaseBatchCreateMutation, BaseBatchDeleteMutation, BaseBatchPatchMutation
from .util.apps import AppConfig from .util.apps import AppConfig
...@@ -71,3 +73,28 @@ def check_app_models_base_class( ...@@ -71,3 +73,28 @@ def check_app_models_base_class(
) )
return results 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 import logging
from datetime import timedelta
from typing import TYPE_CHECKING
from django.apps import apps from django.apps import apps
from django.contrib.contenttypes.models import ContentType 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.db.models.aggregates import Count
from django.utils.functional import classproperty from django.utils.functional import classproperty
from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
import reversion import reversion
from color_contrast import AccessibilityLevel, ModulationMode, check_contrast, modulate
from reversion import set_comment 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.celery_progress import ProgressRecorder, recorded_task
from .util.core_helpers import get_site_preferences 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: class SolveOption:
...@@ -28,7 +40,7 @@ class SolveOption: ...@@ -28,7 +40,7 @@ class SolveOption:
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
class DeleteSolveOption(SolveOption): class DeleteSolveOption(SolveOption):
name = "delete" # has to be unqiue _class_name = "delete" # has to be unqiue
verbose_name = _("Delete") # should make use of i18n verbose_name = _("Delete") # should make use of i18n
@classmethod @classmethod
...@@ -40,7 +52,7 @@ class SolveOption: ...@@ -40,7 +52,7 @@ class SolveOption:
the corresponding data check result has to be deleted. the corresponding data check result has to be deleted.
""" """
name: str = "default" _class_name: str = "default"
verbose_name: str = "" verbose_name: str = ""
@classmethod @classmethod
...@@ -51,7 +63,7 @@ class SolveOption: ...@@ -51,7 +63,7 @@ class SolveOption:
class IgnoreSolveOption(SolveOption): class IgnoreSolveOption(SolveOption):
"""Mark the object with data issues as solved.""" """Mark the object with data issues as solved."""
name = "ignore" _class_name = "ignore"
verbose_name = _("Ignore problem") verbose_name = _("Ignore problem")
@classmethod @classmethod
...@@ -61,7 +73,7 @@ class IgnoreSolveOption(SolveOption): ...@@ -61,7 +73,7 @@ class IgnoreSolveOption(SolveOption):
check_result.save() check_result.save()
class DataCheck: class DataCheck(RegistryObject):
"""Define a data check. """Define a data check.
Data checks should be used to search objects of Data checks should be used to search objects of
...@@ -83,12 +95,13 @@ class DataCheck: ...@@ -83,12 +95,13 @@ class DataCheck:
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
class ExampleDataCheck(DataCheck): class ExampleDataCheck(DataCheck):
name = "example" # has to be unique _class_name = "example" # has to be unique
verbose_name = _("Ensure that there are no examples.") verbose_name = _("Ensure that there are no examples.")
problem_name = _("There is an example.") # should both make use of i18n problem_name = _("There is an example.") # should both make use of i18n
required_for_migrations = True # Make mandatory for migrations
solve_options = { solve_options = {
IgnoreSolveOption.name: IgnoreSolveOption IgnoreSolveOption._class_name: IgnoreSolveOption
} }
@classmethod @classmethod
...@@ -119,7 +132,7 @@ class DataCheck: ...@@ -119,7 +132,7 @@ class DataCheck:
The dictionary ``solve_options`` should include at least the IgnoreSolveOption, The dictionary ``solve_options`` should include at least the IgnoreSolveOption,
but preferably also own solve options. The keys in this dictionary 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. and the values must be the corresponding solve option classes.
The class method ``check_data`` does the actual work. In this method The class method ``check_data`` does the actual work. In this method
...@@ -151,11 +164,13 @@ class DataCheck: ...@@ -151,11 +164,13 @@ class DataCheck:
the preference ``Send emails if data checks detect problems``. the preference ``Send emails if data checks detect problems``.
""" # noqa: D412 """ # noqa: D412
name: str = ""
verbose_name: str = "" verbose_name: str = ""
problem_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 = [] _current_results = []
...@@ -187,6 +202,12 @@ class DataCheck: ...@@ -187,6 +202,12 @@ class DataCheck:
) )
solve_option_obj.solve(check_result) 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 @classmethod
def register_result(cls, instance) -> "DataCheckResult": def register_result(cls, instance) -> "DataCheckResult":
"""Register an object with data issues in the result database. """Register an object with data issues in the result database.
...@@ -198,7 +219,9 @@ class DataCheck: ...@@ -198,7 +219,9 @@ class DataCheck:
ct = ContentType.objects.get_for_model(instance) ct = ContentType.objects.get_for_model(instance)
result, __ = DataCheckResult.objects.get_or_create( 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) # Track all existing problems (for deleting old results)
...@@ -209,37 +232,26 @@ class DataCheck: ...@@ -209,37 +232,26 @@ class DataCheck:
@classmethod @classmethod
def delete_old_results(cls): def delete_old_results(cls):
"""Delete old data check results for problems which exist no longer.""" """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] 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.") logging.info(f"Delete {old_results.count()} old data check results.")
old_results.delete() old_results.delete()
# Reset list with existing problems # Reset list with existing problems
cls._current_results = [] 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 @classproperty
def data_checks_choices(cls): 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): def check_data(recorder: ProgressRecorder):
"""Execute all registered data checks and send email if activated.""" """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}") logging.info(f"Run check: {check.verbose_name}")
check.run_check_data() check.run_check_data()
...@@ -247,6 +259,36 @@ def check_data(recorder: ProgressRecorder): ...@@ -247,6 +259,36 @@ def check_data(recorder: ProgressRecorder):
send_emails_for_data_checks() 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(): def send_emails_for_data_checks():
"""Notify one or more recipients about new problems with data. """Notify one or more recipients about new problems with data.
...@@ -257,12 +299,12 @@ def send_emails_for_data_checks(): ...@@ -257,12 +299,12 @@ def send_emails_for_data_checks():
results = DataCheckResult.objects.filter(solved=False, sent=False) results = DataCheckResult.objects.filter(solved=False, sent=False)
if results.exists(): 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 = [] results_with_checks = []
for result in results_by_check: for result in results_by_check:
results_with_checks.append( results_with_checks.append(
(DataCheckRegistry.data_checks_by_name[result["check"]], result["count"]) (DataCheck.registered_objects_dict[result["data_check"]], result["count"])
) )
recipient_list = [ recipient_list = [
...@@ -274,9 +316,8 @@ def send_emails_for_data_checks(): ...@@ -274,9 +316,8 @@ def send_emails_for_data_checks():
for group in get_site_preferences()["general__data_checks_recipient_groups"]: 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] recipient_list += [p.mail_sender for p in group.announcement_recipients if p.email]
send_templated_mail( send_email(
template_name="data_checks", template_name="data_checks",
from_email=get_site_preferences()["mail__address"],
recipient_list=recipient_list, recipient_list=recipient_list,
context={"results": results_with_checks}, context={"results": results_with_checks},
) )
...@@ -287,32 +328,303 @@ def send_emails_for_data_checks(): ...@@ -287,32 +328,303 @@ def send_emails_for_data_checks():
class DeactivateDashboardWidgetSolveOption(SolveOption): class DeactivateDashboardWidgetSolveOption(SolveOption):
name = "deactivate_dashboard_widget" _class_name = "deactivate_dashboard_widget"
verbose_name = _("Deactivate DashboardWidget") verbose_name = _("Deactivate DashboardWidget")
@classmethod @classmethod
def solve(cls, check_result: "DataCheckResult"): def solve(cls, check_result: "DataCheckResult"):
from .models import DashboardWidget
widget = check_result.related_object widget = check_result.related_object
widget.active = False widget.status = DashboardWidget.Status.OFF.value
widget.save() widget.save()
check_result.delete() check_result.delete()
class BrokenDashboardWidgetDataCheck(DataCheck): class BrokenDashboardWidgetDataCheck(DataCheck):
name = "broken_dashboard_widgets" _class_name = "broken_dashboard_widgets"
verbose_name = _("Ensure that there are no broken DashboardWidgets.") verbose_name = _("Ensure that there are no broken DashboardWidgets.")
problem_name = _("The DashboardWidget was reported broken automatically.") problem_name = _("The DashboardWidget was reported broken automatically.")
solve_options = { solve_options = {
IgnoreSolveOption.name: IgnoreSolveOption, IgnoreSolveOption._class_name: IgnoreSolveOption,
DeactivateDashboardWidgetSolveOption.name: DeactivateDashboardWidgetSolveOption, DeactivateDashboardWidgetSolveOption._class_name: DeactivateDashboardWidgetSolveOption,
} }
@classmethod @classmethod
def check_data(cls): def check_data(cls):
from .models import DashboardWidget 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: for widget in broken_widgets:
logging.info("Check DashboardWidget %s", widget) logging.info("Check DashboardWidget %s", widget)
cls.register_result(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.db.models import Q
from django.utils.translation import gettext as _ 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 material import Layout, Row
from aleksis.core.models import Group, GroupType, Person, SchoolTerm from aleksis.core.models import Person
class MultipleCharFilter(CharFilter): class MultipleCharFilter(CharFilter):
...@@ -18,10 +23,7 @@ class MultipleCharFilter(CharFilter): ...@@ -18,10 +23,7 @@ class MultipleCharFilter(CharFilter):
def filter(self, qs, value): # noqa def filter(self, qs, value): # noqa
q = None q = None
for field in self.fields: for field in self.fields:
if not q: q = Q(**{field: value}) if not q else q | Q(**{field: value})
q = Q(**{field: value})
else:
q = q | Q(**{field: value})
return qs.filter(q) return qs.filter(q)
def __init__(self, fields: Sequence[str], *args, **kwargs): def __init__(self, fields: Sequence[str], *args, **kwargs):
...@@ -29,19 +31,6 @@ class MultipleCharFilter(CharFilter): ...@@ -29,19 +31,6 @@ class MultipleCharFilter(CharFilter):
super().__init__(self, *args, **kwargs) 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): class PersonFilter(FilterSet):
name = MultipleCharFilter( name = MultipleCharFilter(
[ [
...@@ -49,15 +38,17 @@ class PersonFilter(FilterSet): ...@@ -49,15 +38,17 @@ class PersonFilter(FilterSet):
"additional_name__icontains", "additional_name__icontains",
"last_name__icontains", "last_name__icontains",
"short_name__icontains", "short_name__icontains",
"user__username__icontains",
], ],
label=_("Search by name"), label=_("Search by name"),
) )
contact = MultipleCharFilter( contact = MultipleCharFilter(
[ [
"street__icontains", "addresses__street__icontains",
"housenumber__icontains", "addresses__housenumber__icontains",
"postal_code__icontains", "addresses__postal_code__icontains",
"place__icontains", "addresses__place__icontains",
"addresses__country__icontains",
"phone_number__icontains", "phone_number__icontains",
"mobile_number__icontains", "mobile_number__icontains",
"email__icontains", "email__icontains",
...@@ -67,8 +58,99 @@ class PersonFilter(FilterSet): ...@@ -67,8 +58,99 @@ class PersonFilter(FilterSet):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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: class Meta:
model = Person 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