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 (765)
Showing
with 3446 additions and 1650 deletions
......@@ -65,6 +65,7 @@ docs/_build/
aleksis/node_modules/
aleksis/static/
aleksis/whoosh_index/
poetry.lock
.coverage
.mypy_cache/
......
include:
- project: "AlekSIS/official/AlekSIS"
file: /ci/general.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/prepare/lock.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS"
......@@ -17,5 +19,3 @@ include:
file: "/ci/deploy/review.yml"
- project: "AlekSIS/official/AlekSIS"
file: "/ci/deploy/trigger_dist.yml"
- project: "AlekSIS/official/AlekSIS"
file: /ci/deploy/pages.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>
......
......@@ -9,17 +9,359 @@ and this project adheres to `Semantic Versioning`_.
Unreleased
----------
Fixed
~~~~~
* ACCOUNT_ADAPTER was overriden by invitations
Added
~~~~~
* Use identicons where avatars are missing.
* Display personal photos instead of avatars based on a site preference.
* Add an account menu in the top navbar.
* Create a reusable snippet for avatar content.
* Allow to configure if additional field is required
* Allow to configure description of additional fields
* Allow configuring regex for allowed usernames
* [Dev] Support scheduled notifications.
* Implement StaticContentWidget
* Allow to enable password change independently of password reset
Changed
~~~~~~~
* Added a `Retry` button to the server error page
Fixed
~~~~~
* The ``reset password`` button on the login site used to overflow the card on smaller devices.
`2.7.4`_ - 2022-02-09
---------------------
Changed
~~~~~~~
* Allow disabling query caching with cachalot
* Add invitation key to success message when a person without e-mail address is invited by id
Fixed
~~~~~
* Only exactly one person without e-mail address could be invited
* No person was created and linked to the PersonInvitation object when invite by e-mail is used
* No valid data in the second e-mail field of the signup form when it was disabled
* Invitation options were displayed to superusers even when the feature was disabled
* Inviting newly created persons for registration failed
* Invited person was not displayed correctly in list of sent invitations
* [Docker] Do not clear cache in migration container die to session invalidation issues
* Notification email about user changes was broken
* SQL cache invalidation could fail when hitting OOT database
`2.7.3`_ - 2022-02-03
---------------------
Fixed
~~~~~
* Migration added in 2.7.2 did not work in all scenarios
* [Dev] Field change tracking API for Person was broken in 2.7.2
* [OAuth] Automatic clean-up of expired OAuth tokens could fail
* Allow maskable icons for non-masked use
* Add missing documentation
Known issues
~~~~~~~~~~~~
* Maskable and non-masked icons *purpose) any cannot be separated
`2.7.2`_ - 2022-01-31
---------------------
Changed
~~~~~~~
* [Dev] The (undocumented) setting PDF_CONTEXT_PROCESSORS is now named NON_REQUEST_CONTEXT_PROCESSORS
* [Docker] Cache is now cleared if migrations are applied
* Update German translations.
Fixed
~~~~~
* Celery progress could be inaccurate if recording progress during a transaction
`2.7.1`_ - 2022-01-28
---------------------
Changed
~~~~~~~
* PWA icons can now be marked maskable
* [OAuth] Expired tokens are now cleared in a periodic task
* PDF file jobs are now automatically expired
* Data checks are now scheduled every 15 minutes by default
Fixed
~~~~~
* PDF generation failed with S3 storage due to incompatibility with boto3
* PWA theme colour defaulted to red
* Form for editing group type displayed irrelevant fields
* Permission groups could get outdated if re-assigning a user account to a different person
* User preferences didn't work correctly sometimes due to race conditions.
`2.7`_ - 2022-01-24
-------------------
Added
~~~~~
* Periodic tasks can now have a default schedule, which is automatically created
Fixed
~~~~~
* Signup was forbidden even if it was enabled in settings
* Phone numbers were not properly linked and suboptimally formatted on person page
* Favicon upload failed with S3 storage.
* Some combinations of allowed self-edit fields on persons could cause errors
* Some preferences were required when they shouldn't, and vice versa.
* IO errors on accessing backup directory in health check are now properly reported
* Date picker was not properly initialized if field was already filled.
* The menu item for entering an invitation code received offline was missing
* CleaveJS was not loaded properly when using an external CDN
Changed
-------
* Allow non-superusers with permission to invite persons
`2.6`_ - 2022-01-10
-------------------
Added
~~~~~
* Add option to open entry in new tab for sidebar navigation menu.
* Add preference for configuring the default phone number country code.
* Persons and groups now have two image fields: official photo and public avatar
* Admins recieve an mail for celery tasks with status "FAILURE"
* OpenID Connect RSA keys can now be passed as string in config files
* Views filtering for person names now also search the username of a linked user
* OAuth2 applications now take an icon which is shown in the authorization progress.
* Add support for hiding the main side nav in ``base.html``.
* Provide base template and function for sending emails with a template.
Fixed
~~~~~
* Changing the favicon did not result in all icons being replaced in some cases
* Superusers with a dummy person were able to access the dashboard edit page.
* GroupManager.get_queryset() returned an incomplete QuerySet
* OAuth was broken by a non-semver-adhering django-oauth-toolkit update
* Too long texts in chips didn't result in a larger chip.
* The ``Person`` model had an ``is_active`` flag that was used in unclear ways; it is now removed
* The data check results list view didn't work if a related object had been deleted in the meanwhile.
* Socialaccount login template was not overriden
* Atomic transactions now cause only one Haystack update task to run
* Too long headlines didn't break in another line.
Changed
~~~~~~~
* Configuration files are now deep merged by default
* Improvements for shell_plus module loading
* core.Group model now takes precedence over auth.Group
* Name collisions are resolved by prefixing with the app label
* Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD
* [Docker] Base image now contains curl, grep, less, sed, and pspg
* Views raising a 404 error can now customise the message that is displayed on the error page
* OpenID Connect is enabled by default now, without RSA support
* Login and authorization pages for OAuth2/OpenID Connect now indicate that the user is in progress
to authorize an external application.
* Tables can be scrolled horizontally.
* Overhauled person detail page
* Use common base template for all emails.
`2.5`_ – 2022-01-02
-------------------
Added
~~~~~
* Recursive helper methods for group hierarchies
Fixed
~~~~~
* Remove left-over reference to preferences in a form definition that caused
form extensions in downstream apps to break
* Allow non-LDAP users to authenticate if LDAP is used with password handling
* Additional button on progress page for background tasks was shown even if the task failed.
* Register preference for available allowed oauth grants.
`2.4`_ – 2021-12-24
-------------------
Added
~~~~~
* Allow configuration of database options
* User invitations with invite codes and targeted invites for existing
persons
Fixed
~~~~~
* Correctly update theme colours on change again
* Use correct favicon as default AlekSIS favicon
* Show all years in a 200 year range around the current year in date pickers
* Imprint is now called "Imprint" and not "Impress".
* Logo files weren't uploaded to public namespace.
* Limit LDAP network timeouts to not hang indefinitely on login if LDAP
server is unreachable
Changed
~~~~~~~
* Modified the appearance of tables for mobile users to be more user friendly
* [Dev] Remove lock file; locking dependencies is the distribution's
responsibility
Removed
~~~~~~~
* Remove old generated AlekSIS icons
`2.3.1`_ – 2021-12-17
---------------------
Fixed
~~~~~
* Small files could fail to upload to S3 storage due to MemoryFileUploadHandler
* Corrected typos in previous changelog
`2.3`_ – 2021-12-15
-------------------
Added
~~~~~
* [OAuth] Allow apps to fill in their own claim data matching their scopes
Fixed
~~~~~
* View for assigning permissions didn't work with some global permissions.
* PDFs generated in background didn't contain logo or site title.
* Admins were redirected to their user preferences
while they wanted to edit the preferences of another user.
* Some CharFields were using NULL values in database when field is empty
* Optional dependecy `sentry-sdk` was not optional
Changed
~~~~~~~
* Docker base image ships PostgreSQL 14 client binaries for maximum compatibility
* Docker base image contains Sentry client by default (disabled in config by default)
Removed
~~~~~~~
* Remove impersonation page. Use the impersonation button on the person
detail view instead.
`2.2.1`_ – 2021-12-02
--------------------
Fixed
~~~~~
* [Docker] Stop initialisation if migrations fail
* [OAuth] Register `groups` scope and fix claim
* [OAuth] Fix OAuth claims for follow-up requests (e.g. UserInfo)
* [OAuth] Fix grant types checking failing on wrong types under some circumstances
* [OAuth] Re-introduce missing algorithm field in application form
* Remove errornous backup folder check for S3
`2.2`_ - 2021-11-29
-------------------
Added
~~~~~
* Support config files in sub-directories
* Provide views for assigning/managing permissions in frontend
* Support (icon) tabs in the top navbar.
Changed
~~~~~~~
* Update German translations.
Fixed
~~~~~
* Use new MaterializeCSS fork because the old version is no longer maintained.
* Sender wasn't displayed for notifications on dashboard.
* Notifications and activities on dashboard weren't sorted from old to new.
`2.1.1`_ - 2021-11-14
---------------------
Added
~~~~~
* Provide ``SITE_PREFERENCES`` template variable for easier and request-independent access on all site preferences.
Fixed
~~~~~
* Make style.css and favicons cachable.
* Import model extensions from other apps before form extensions.
* Recreate backwards compatiblity for OAuth URLs by using ``oauth/`` again.
* Show correct logo and school title in print template if created in the background.
Removed
~~~~~~~
* Remove fallback code from optional Celery as it's now non-optional.
`2.1`_ - 2021-11-05
-------------------
Added
~~~~~
* Provide an ``ExtensiblePolymorphicModel`` to support the features of extensible models for polymorphic models and vice-versa.
* Implement optional Sentry integration for error and performance tracing.
* Option to limit allowed scopes per application, including mixin to enforce that limit on OAuth resource views
* Support trusted OAuth applications that leave out the authorisation screen.
* Add birthplace to Person model.
Changed
~~~~~~~
* Replace dev.sh helper script with tox environments.
* OAuth Grant Flows are now configured system-wide instead of per app.
* Refactor OAuth2 application management views.
Fixed
~~~~~
* Fix default admin contacts
Credits
~~~~~~~
* We welcome new contributor 🐧 Jonathan Krüger!
* We welcome new contributor 🐭 Lukas Weichelt!
`2.0`_ - 2021-10-29
-------------------
......@@ -426,3 +768,17 @@ Fixed
.. _2.0rc6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc6
.. _2.0rc7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc7
.. _2.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0
.. _2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1
.. _2.1.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1.1
.. _2.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2
.. _2.2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2.1
.. _2.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3
.. _2.3.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3.1
.. _2.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.4
.. _2.5: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.5
.. _2.6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.6
.. _2.7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7
.. _2.7.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.1
.. _2.7.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.2
.. _2.7.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.3
.. _2.7.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.4
FROM debian:bullseye-slim AS core
# Build arguments
ARG EXTRAS="ldap,s3"
ARG EXTRAS="ldap,s3,sentry"
ARG APP_VERSION=""
# Configure Python to be nice inside Docker and pip to stfu
......@@ -19,20 +19,25 @@ 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 \
postgresql-client-14 \
pspg \
python3-dev \
python3-magic \
python3-pip \
......@@ -76,6 +81,7 @@ RUN set -e; \
eatmydata apt-get remove --purge -y \
build-essential \
gettext \
gnupg \
libpq-dev \
libssl-dev \
libldap2-dev \
......
......@@ -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,61 @@ 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 Yubikey, OTP or SMS
* Configurable dashboard with widgets
* User-specific preferences
* Global search
* Group types
* Manage announcements
* Manage groups
* Manage groups and types of groups
* Manage roles and additional, informative fields per group
* Manage persons
* Notifications via SMS email or dashboard
* 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
* Generic PDF generation with chromium
* Caching with Redis
* Django REST framework for apps to use at own discretion
* 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
* Extensible dashbaord widget 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 Jonathan Weth <dev@jonathanweth.de>
Copyright © 2017, 2018, 2019, 2020 Frank Poetzsch-Heffter <p-h@katharineum.de>
Copyright © 2018, 2019, 2020, 2021, 2022 Hangzhi Yu <yuha@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 © 2019, 2020, 2021, 2022 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 Lloyd Meins <meinsll@katharineum.de>
Copyright © 2021 magicfelix <felix@felix-zauberer.de>
Copyright © 2022 Benedict Suska <benedict.suska@teckids.org>
Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany).
......@@ -76,6 +79,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
......
......@@ -9,6 +9,7 @@ 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,
......@@ -16,7 +17,12 @@ from .registries import (
site_preferences_registry,
)
from .util.apps import AppConfig
from .util.core_helpers import get_or_create_favicon, has_person
from .util.core_helpers import (
create_default_celery_schedule,
get_or_create_favicon,
get_site_preferences,
has_person,
)
from .util.sass_helpers import clean_scss
......@@ -30,15 +36,16 @@ 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], "Jonathan Weth", "wethjo@katharineum.de"),
([2017, 2018, 2019, 2020], "Frank Poetzsch-Heffter", "p-h@katharineum.de"),
([2018, 2019, 2020, 2021, 2022], "Hangzhi Yu", "yuha@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"),
([2019, 2020, 2021, 2022], "Dominik George", "dominik.george@teckids.org"),
([2019, 2020, 2021, 2022], "Tom Teichler", "tom.teichler@teckids.org"),
([2019], "mirabilos", "thorsten.glaser@teckids.org"),
([2021], "Lloyd Meins", "meinsll@katharineum.de"),
([2021], "magicfelix", "felix@felix-zauberer.de"),
([2022], "Benedict Suska", "benedict.suska@teckids.org"),
)
def ready(self):
......@@ -47,7 +54,7 @@ class CoreConfig(AppConfig):
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")
sitepreferencemodel = self.get_model("SitePreferenceModel")
personpreferencemodel = self.get_model("PersonPreferenceModel")
......@@ -96,14 +103,20 @@ class CoreConfig(AppConfig):
if name in ("primary", "secondary"):
clean_scss()
elif name in ("favicon", "pwa_icon"):
from favicon.models import Favicon # noqa
from favicon.models import Favicon, FaviconImg # noqa
is_favicon = name == "favicon"
if new_value:
Favicon.on_site.update_or_create(
title=name, defaults={"isFavicon": is_favicon, "faviconImage": 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.on_site.update_or_create(
title=name,
defaults={"isFavicon": is_favicon, "faviconImage": file_obj},
)[0]
FaviconImg.objects.filter(faviconFK=favicon).delete()
else:
Favicon.on_site.filter(title=name, isFavicon=is_favicon).delete()
if name in settings.DEFAULT_FAVICON_PATHS:
......@@ -132,6 +145,9 @@ class CoreConfig(AppConfig):
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()
def user_logged_in(
self, sender: type, request: Optional[HttpRequest], user: "User", **kwargs
) -> None:
......@@ -152,5 +168,52 @@ class CoreConfig(AppConfig):
"address": _("Full home postal address"),
"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.photo:
claims["picture"] = django_request.build_absolute_uri(
request.user.person.photo.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 "address" in scopes and has_person(request.user):
claims["address"] = {
"street_address": request.user.person.street
+ " "
+ request.user.person.housenumber,
"locality": request.user.person.place,
"postal_code": request.user.person.postal_code,
}
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 os
from traceback import format_exception
from django.conf import settings
from celery import Celery
from celery.signals import 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
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)),
},
)
import logging
from datetime import timedelta
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
......@@ -8,10 +9,10 @@ from django.utils.translation import gettext as _
import reversion
from reversion import set_comment
from templated_email import send_templated_mail
from .util.celery_progress import ProgressRecorder, recorded_task
from .util.core_helpers import get_site_preferences
from .util.email import send_email
class SolveOption:
......@@ -236,7 +237,7 @@ class DataCheckRegistry:
return [(check.name, check.verbose_name) for check in cls.data_checks]
@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):
......@@ -274,9 +275,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},
)
......
from typing 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_select2.forms import ModelSelect2Widget
from guardian.models import GroupObjectPermission, UserObjectPermission
from material import Layout, Row
from aleksis.core.models import Group, GroupType, Person, SchoolTerm
......@@ -49,6 +54,7 @@ class PersonFilter(FilterSet):
"additional_name__icontains",
"last_name__icontains",
"short_name__icontains",
"user__username__icontains",
],
label=_("Search by name"),
)
......@@ -67,8 +73,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
from datetime import datetime, time
from typing import Callable, Sequence
from typing import Any, Callable, Dict, Sequence
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from allauth.account.adapter import get_adapter
from allauth.account.forms import SignupForm
from allauth.account.utils import get_user_model, setup_user_email
from allauth.account.utils import setup_user_email
from dj_cleavejs import CleaveWidget
from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget
from dynamic_preferences.forms import PreferenceForm
from guardian.shortcuts import assign_perm
from invitations.forms import InviteForm
from material import Fieldset, Layout, Row
from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm
......@@ -24,6 +30,7 @@ from .models import (
GroupType,
OAuthApplication,
Person,
PersonInvitation,
SchoolTerm,
)
from .registries import (
......@@ -31,6 +38,7 @@ from .registries import (
person_preferences_registry,
site_preferences_registry,
)
from .util.auth_helpers import AppScopes
from .util.core_helpers import get_site_preferences
......@@ -42,13 +50,16 @@ class PersonForm(ExtensibleForm):
_("Base data"),
"short_name",
Row("user", "primary_group"),
"is_active",
Row("first_name", "additional_name", "last_name"),
),
Fieldset(_("Address"), Row("street", "housenumber"), Row("postal_code", "place")),
Fieldset(_("Contact data"), "email", Row("phone_number", "mobile_number")),
Fieldset(
_("Advanced personal data"), Row("sex", "date_of_birth"), Row("photo"), "guardians",
_("Advanced personal data"),
Row("date_of_birth", "place_of_birth"),
Row("sex"),
Row("photo", "avatar"),
"guardians",
),
)
......@@ -56,7 +67,6 @@ class PersonForm(ExtensibleForm):
model = Person
fields = [
"user",
"is_active",
"first_name",
"last_name",
"additional_name",
......@@ -69,8 +79,10 @@ class PersonForm(ExtensibleForm):
"mobile_number",
"email",
"date_of_birth",
"place_of_birth",
"sex",
"photo",
"avatar",
"guardians",
"primary_group",
]
......@@ -98,21 +110,16 @@ class PersonForm(ExtensibleForm):
request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)
# Disable non-editable fields
allowed_person_fields = get_site_preferences()["account__editable_fields_person"]
if (
request
and self.instance
and not request.user.has_perm("core.change_person", self.instance)
):
# First, disable all fields
# Disable non-editable fields
allowed_person_fields = get_site_preferences()["account__editable_fields_person"]
for field in self.fields:
self.fields[field].disabled = True
# Then, activate allowed fields
for field in allowed_person_fields:
self.fields[field].disabled = False
if field not in allowed_person_fields:
self.fields[field].disabled = True
def clean(self) -> None:
user = get_user_model()
......@@ -147,6 +154,7 @@ class EditGroupForm(SchoolTermRelatedExtensibleForm):
Fieldset(_("Common data"), "name", "short_name", "group_type"),
Fieldset(_("Persons"), "members", "owners", "parent_groups"),
Fieldset(_("Additional data"), "additional_fields"),
Fieldset(_("Photo"), "photo", "avatar"),
)
class Meta:
......@@ -174,7 +182,9 @@ class EditGroupForm(SchoolTermRelatedExtensibleForm):
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
"additional_fields": ModelSelect2MultipleWidget(
search_fields=["title__icontains",],
search_fields=[
"title__icontains",
],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
}
......@@ -210,7 +220,10 @@ class AnnouncementForm(ExtensibleForm):
label=_("Groups"),
required=False,
widget=ModelSelect2MultipleWidget(
search_fields=["name__icontains", "short_name__icontains",],
search_fields=[
"name__icontains",
"short_name__icontains",
],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
)
......@@ -342,7 +355,7 @@ class EditGroupTypeForm(forms.ModelForm):
class Meta:
model = GroupType
exclude = []
fields = ["name", "description"]
class SchoolTermForm(ExtensibleForm):
......@@ -357,7 +370,8 @@ class SchoolTermForm(ExtensibleForm):
class DashboardWidgetOrderForm(ExtensibleForm):
pk = forms.ModelChoiceField(
queryset=None, widget=forms.HiddenInput(attrs={"class": "pk-input"}),
queryset=None,
widget=forms.HiddenInput(attrs={"class": "pk-input"}),
)
order = forms.IntegerField(initial=0, widget=forms.HiddenInput(attrs={"class": "order-input"}))
......@@ -377,76 +391,256 @@ DashboardWidgetOrderFormSet = forms.formset_factory(
)
class AccountRegisterForm(SignupForm, ExtensibleForm):
"""Form to register new user accounts."""
class InvitationCodeForm(forms.Form):
"""Form to enter an invitation code."""
class Meta:
model = Group
fields = []
code = forms.CharField(
label=_("Invitation code"),
help_text=_("Please enter your invitation code."),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Calculate number of fields
length = get_site_preferences()["auth__invite_code_length"]
packet_size = get_site_preferences()["auth__invite_code_packet_size"]
blocks = [
packet_size,
] * length
self.fields["code"].widget = CleaveWidget(blocks=blocks, delimiter="-", uppercase=True)
class PersonCreateInviteForm(InviteForm):
"""Custom form to create a person and invite them."""
first_name = forms.CharField(label=_("First name"), required=True)
last_name = forms.CharField(label=_("Last name"), required=True)
layout = Layout(
Fieldset(_("Base data"), Row("first_name", "last_name"),),
Fieldset(
_("Account data"), "username", Row("email", "email2"), Row("password1", "password2"),
Row("first_name", "last_name"),
Row("email"),
)
def clean_email(self):
if Person.objects.filter(email=self.cleaned_data["email"]).exists():
raise ValidationError(_("A person is using this e-mail address"))
return super().clean_email()
def save(self, email):
person = Person.objects.create(
first_name=self.cleaned_data["first_name"],
last_name=self.cleaned_data["last_name"],
email=email,
)
return PersonInvitation.create(email=email, person=person)
class SelectPermissionForm(forms.Form):
"""Select a permission to assign."""
selected_permission = forms.ModelChoiceField(
queryset=Permission.objects.all(),
widget=ModelSelect2Widget(
search_fields=["name__icontains", "codename__icontains"],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
Fieldset(_("Consents"), Row("privacy_policy"),),
)
def __init__(self, *args, **kwargs):
super(AccountRegisterForm, self).__init__(*args, **kwargs)
self.fields["password1"] = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
privacy_policy = get_site_preferences()["footer__privacy_url"]
class AssignPermissionForm(forms.Form):
"""Assign a permission to user/groups for all/some objects."""
layout = Layout(
Fieldset(_("Who should get the permission?"), "groups", "persons"),
Fieldset(_("On what?"), "objects", "all_objects"),
)
groups = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(),
widget=ModelSelect2MultipleWidget(
search_fields=["name__icontains", "short_name__icontains"],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
required=False,
)
persons = forms.ModelMultipleChoiceField(
queryset=Person.objects.all(),
widget=ModelSelect2MultipleWidget(
search_fields=[
"first_name__icontains",
"last_name__icontains",
"short_name__icontains",
],
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
),
required=False,
)
objects = forms.ModelMultipleChoiceField(
queryset=None,
required=False,
label=_("Select objects which the permission should be granted for:"),
)
all_objects = forms.BooleanField(
required=False, label=_("Grant the permission for all objects")
)
def clean(self) -> Dict[str, Any]:
"""Clean form to ensure that at least one target and one type is selected."""
cleaned_data = super().clean()
if not cleaned_data.get("persons") and not cleaned_data.get("groups"):
raise ValidationError(
_("You must select at least one group or person which should get the permission.")
)
if settings.SIGNUP_PASSWORD_ENTER_TWICE:
self.fields["password2"] = forms.CharField(
label=_("Password (again)"), widget=forms.PasswordInput
if not cleaned_data.get("objects") and not cleaned_data.get("all_objects"):
raise ValidationError(
_("You must grant the permission to all objects and/" "or to some objects.")
)
return cleaned_data
self.fields["first_name"] = forms.CharField(required=True,)
def __init__(self, *args, permission: Permission, **kwargs):
self.permission = permission
super().__init__(*args, **kwargs)
self.fields["last_name"] = forms.CharField(required=True,)
model_class = self.permission.content_type.model_class()
if model_class._meta.managed and not model_class._meta.abstract:
queryset = model_class.objects.all()
else:
# The following queryset is just a dummy one. It has no real meaning.
# We need it as there are permissions without real objects,
# but we want to use the same form.
queryset = Site.objects.none()
self.fields["objects"].queryset = queryset
search_fields = getattr(model_class, "get_filter_fields", lambda: [])()
# Use select2 only if there are any searchable fields as it can't work without
if search_fields:
self.fields["objects"].widget = ModelSelect2MultipleWidget(
search_fields=search_fields,
queryset=queryset,
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
)
self.fields["privacy_policy"] = forms.BooleanField(
help_text=_(
f"I have read the <a href='{privacy_policy}'>Privacy policy</a>"
" and agree with them."
),
required=True,
)
def save_perms(self):
"""Save permissions for selected user/groups and selected/all objects."""
persons = self.cleaned_data["persons"]
groups = self.cleaned_data["groups"]
all_objects = self.cleaned_data["all_objects"]
objects = self.cleaned_data["objects"]
permission_name = f"{self.permission.content_type.app_label}.{self.permission.codename}"
created = 0
# Create permissions for users
for person in persons:
if getattr(person, "user", None):
# Global permission
if all_objects:
assign_perm(permission_name, person.user)
# Object permissions
for instance in objects:
assign_perm(permission_name, person.user, instance)
# Create permissions for users
for group in groups:
django_group = group.django_group
# Global permission
if all_objects:
assign_perm(permission_name, django_group)
# Object permissions
for instance in objects:
assign_perm(permission_name, django_group, instance)
def clean(self):
super(AccountRegisterForm, self).clean()
dummy_user = get_user_model()
password = self.cleaned_data.get("password1")
if password:
class AccountRegisterForm(SignupForm, ExtensibleForm):
"""Form to register new user accounts."""
class Meta:
model = Person
fields = [
"first_name",
"additional_name",
"last_name",
"street",
"housenumber",
"postal_code",
"place",
"date_of_birth",
"place_of_birth",
"sex",
"photo",
"mobile_number",
"phone_number",
"short_name",
"description",
]
layout = Layout(
Fieldset(
_("Base data"),
Row("first_name", "additional_name", "last_name"),
"short_name",
),
Fieldset(
_("Adress data"),
Row("street", "housenumber"),
Row("postal_code", "place"),
),
Fieldset(_("Contact data"), Row("mobile_number", "phone_number")),
Fieldset(
_("Additional data"),
Row("date_of_birth", "place_of_birth"),
Row("sex", "photo"),
"description",
),
Fieldset(
_("Account data"),
"username",
Row("email", "email2"),
Row("password1", "password2"),
),
)
password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
if settings.SIGNUP_PASSWORD_ENTER_TWICE:
password2 = forms.CharField(label=_("Password (again)"), widget=forms.PasswordInput)
def __init__(self, *args, **kwargs):
request = kwargs.pop("request", None)
super(AccountRegisterForm, self).__init__(*args, **kwargs)
if request.session.get("account_verified_email"):
email = request.session["account_verified_email"]
try:
get_adapter().clean_password(password, user=dummy_user)
except forms.ValidationError as e:
self.add_error("password1", e)
person = Person.objects.get(email=email)
except (Person.DoesNotExist, Person.MultipleObjectsReturned):
raise SuspiciousOperation()
if (
settings.SIGNUP_PASSWORD_ENTER_TWICE
and "password1" in self.cleaned_data
and "password2" in self.cleaned_data
):
if self.cleaned_data["password1"] != self.cleaned_data["password2"]:
self.add_error(
"password2", _("You must type the same password each time."),
)
return self.cleaned_data
self.fields["email"].disabled = True
self.fields["email2"].disabled = True
if person:
available_fields = [field.name for field in Person._meta.get_fields()]
self.fields["email2"].initial = person.email
for field in self.fields:
if field in available_fields and getattr(person, field):
self.fields[field].disabled = True
self.fields[field].initial = getattr(person, field)
def save(self, request):
adapter = get_adapter(request)
user = adapter.new_user(request)
adapter.save_user(request, user, self)
Person.objects.create(
first_name=self.cleaned_data["first_name"],
last_name=self.cleaned_data["last_name"],
email=self.cleaned_data["email"],
user=user,
)
# Create person
data = {}
for field in Person._meta.get_fields():
if field.name in self.cleaned_data:
data[field.name] = self.cleaned_data[field.name]
if not Person.objects.filter(email=data["email"]):
_person, created = Person.objects.update_or_create(user=user, **data)
self.custom_signup(request, user)
setup_user_email(request, user, [])
return user
......@@ -594,12 +788,22 @@ class ListActionForm(ActionForm):
class OAuthApplicationForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["allowed_scopes"].widget = forms.SelectMultiple(
choices=list(AppScopes().get_all_scopes().items())
)
class Meta:
model = OAuthApplication
fields = (
"name",
"icon",
"client_id",
"client_secret",
"client_type",
"algorithm",
"allowed_scopes",
"redirect_uris",
"skip_authorization",
)
......@@ -33,10 +33,13 @@ class BaseBackupHealthCheck(BaseHealthCheckBackend):
def check_status(self):
storage = get_storage()
backups = storage.list_backups(content_type=self.content_type)
if not storage.storage.exists(""):
self.add_error(_("The backup folder doesn't exist."))
try:
backups = storage.list_backups(content_type=self.content_type)
except Exception as ex:
self.add_error(_("Error accessing backup storage: {}").format(str(ex)))
return
if backups:
last_backup = backups[:1]
last_backup_time = dbbackup_utils.filename_to_date(last_backup[0])
......@@ -44,7 +47,7 @@ class BaseBackupHealthCheck(BaseHealthCheckBackend):
# Check if backup is older than configured time
if time_gone_since_backup.seconds > self.configured_seconds:
self.add_error(_(f"Last backup {time_gone_since_backup}!"))
self.add_error(_("Last backup {}!").format(time_gone_since_backup))
else:
self.add_error(_("No backup found!"))
......@@ -75,4 +78,4 @@ class BackupJobHealthCheck(BaseHealthCheckBackend):
if not task:
self.add_error(_("No backup result found!"))
elif task and task.status != "SUCCESS":
self.add_error(_(f"{task.status} - {task.result}"))
self.add_error(f"{task.status} - {task.result}")
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-28 16:18+0200\n"
"POT-Creation-Date: 2022-02-08 23:16+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -18,18 +18,18 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#: aleksis/core/static/js/main.js:15
#: aleksis/core/static/js/main.js:66
msgid "Today"
msgstr ""
#: aleksis/core/static/js/main.js:16
#: aleksis/core/static/js/main.js:67
msgid "Cancel"
msgstr ""
#: aleksis/core/static/js/main.js:17
#: aleksis/core/static/js/main.js:68
msgid "OK"
msgstr ""
#: aleksis/core/static/js/main.js:127
#: aleksis/core/static/js/main.js:191
msgid "This page may contain outdated information since there is no internet connection."
msgstr ""
......@@ -7,11 +7,10 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-08-28 17:53+0200\n"
"POT-Creation-Date: 2022-02-08 23:16+0000\n"
"PO-Revision-Date: 2021-10-28 14:37+0000\n"
"Last-Translator: Jonathan Weth <teckids@jonathanweth.de>\n"
"Language-Team: German <https://translate.edugit.org/projects/aleksis/"
"aleksis-core-js/de/>\n"
"Language-Team: German <https://translate.edugit.org/projects/aleksis/aleksis-core-js/de/>\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
......@@ -19,20 +18,18 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.8\n"
#: aleksis/core/static/js/main.js:15
#: aleksis/core/static/js/main.js:66
msgid "Today"
msgstr "Heute"
#: aleksis/core/static/js/main.js:16
#: aleksis/core/static/js/main.js:67
msgid "Cancel"
msgstr "Abbrechen"
#: aleksis/core/static/js/main.js:17
#: aleksis/core/static/js/main.js:68
msgid "OK"
msgstr "OK"
#: aleksis/core/static/js/main.js:127
#: aleksis/core/static/js/main.js:191
msgid "This page may contain outdated information since there is no internet connection."
msgstr ""
"Diese Seite enthält vielleicht veraltete Informationen, da es keine "
"Internetverbindung gibt."
msgstr "Diese Seite enthält vielleicht veraltete Informationen, da es keine Internetverbindung gibt."
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-28 16:18+0200\n"
"POT-Creation-Date: 2022-02-08 23:16+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -18,18 +18,18 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: aleksis/core/static/js/main.js:15
#: aleksis/core/static/js/main.js:66
msgid "Today"
msgstr ""
#: aleksis/core/static/js/main.js:16
#: aleksis/core/static/js/main.js:67
msgid "Cancel"
msgstr ""
#: aleksis/core/static/js/main.js:17
#: aleksis/core/static/js/main.js:68
msgid "OK"
msgstr ""
#: aleksis/core/static/js/main.js:127
#: aleksis/core/static/js/main.js:191
msgid "This page may contain outdated information since there is no internet connection."
msgstr ""