diff --git a/.env.sample b/.env.sample index 4960a541..31c0ac71 100644 --- a/.env.sample +++ b/.env.sample @@ -24,6 +24,7 @@ DEFAULT_FROM_EMAIL=service@ksp.de # Proxy PROXY=CHANGE_ME +MAP_PROXY_HOST_WHITELIST=CHANGE_ME_1,CHANGE_ME_2 GEOPORTAL_RLP_USER=CHANGE_ME GEOPORTAL_RLP_PASSWORD=CHANGE_ME @@ -37,6 +38,7 @@ SSO_SERVER_BASE_URL=https://login.naturschutz.rlp.de OAUTH_CODE_VERIFIER=CHANGE_ME OAUTH_CLIENT_ID=CHANGE_ME OAUTH_CLIENT_SECRET=CHANGE_ME +PROPAGATION_SECRET=CHANGE_ME # RabbitMQ ## For connections to EGON diff --git a/api/models/token.py b/api/models/token.py index dac0b4a7..5e52bf2e 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -51,7 +51,7 @@ class APIUserToken(models.Model): if token_obj.valid_until is not None and token_obj.valid_until < _today: raise PermissionError("Token validity expired") except ObjectDoesNotExist: - raise PermissionError("Credentials invalid") + raise PermissionError("Token unknown") return token_obj.user @@ -155,3 +155,25 @@ class OAuthToken(UuidModel): return user + def revoke(self) -> int: + """ Revokes the OAuth2 token of the user + + (/o/revoke_token/ indeed removes the corresponding access token on provider side and invalidates the + submitted refresh token in one step) + + Returns: + revocation_status_code (int): HTTP status code for revocation of refresh_token + """ + revoke_url = f"{SSO_SERVER_BASE}o/revoke_token/" + token = self.refresh_token + revocation_status_code = requests.post( + revoke_url, + data={ + 'token': token, + 'token_type_hint': "refresh_token", + }, + auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET), + ).status_code + + return revocation_status_code + diff --git a/api/views/views.py b/api/views/views.py index 72ee2779..17755d3f 100644 --- a/api/views/views.py +++ b/api/views/views.py @@ -50,14 +50,19 @@ class AbstractAPIView(View): def dispatch(self, request, *args, **kwargs): try: # Fetch the proper user from the given request header token - ksp_token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None) + token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None) ksp_user = request.headers.get(KSP_USER_HEADER_IDENTIFIER, None) - token_user = APIUserToken.get_user_from_token(ksp_token) - if ksp_user != token_user.username: + if not token and not ksp_user: + bearer_token = request.headers.get("authorization", None) + if not bearer_token: + raise PermissionError("No token provided") + token = bearer_token.split(" ")[1] + + token_user = APIUserToken.get_user_from_token(token) + if ksp_user and ksp_user != token_user.username: raise PermissionError(f"Invalid token for {ksp_user}") - else: - self.user = token_user + self.user = token_user request.user = self.user if not self.user.is_default_user(): diff --git a/codelist/management/commands/update_codelist.py b/codelist/management/commands/update_codelist.py index d514c993..247c67e2 100644 --- a/codelist/management/commands/update_codelist.py +++ b/codelist/management/commands/update_codelist.py @@ -82,8 +82,8 @@ class Command(BaseKonovaCommand): atom_id = element.find("atomid").text selectable = element.find("selectable").text.lower() selectable = bool_map.get(selectable, False) - short_name = element.find("shortname").text - long_name = element.find("longname").text + short_name = element.find("shortname").text or "" + long_name = element.find("longname").text or "" is_archived = bool_map.get((element.find("archive").text.lower()), False) code = KonovaCode.objects.get_or_create( diff --git a/konova/management/commands/recalculate_parcels.py b/konova/management/commands/recalculate_parcels.py index da3c3269..021e3d68 100644 --- a/konova/management/commands/recalculate_parcels.py +++ b/konova/management/commands/recalculate_parcels.py @@ -34,7 +34,9 @@ class Command(BaseKonovaCommand): def recalculate_parcels(self, options: dict): force_all = options.get("force_all", False) - geometry_objects = Geometry.objects.all().exclude( + geometry_objects = Geometry.objects.filter( + geom__isempty=False, + ).exclude( geom=None ) @@ -48,11 +50,14 @@ class Command(BaseKonovaCommand): "geometry__id", flat=True ) + # ... and resolve into Geometry objects again ... intersected_geom_objs = Geometry.objects.filter( id__in=geom_with_intersection_ids ) - # Filter those geometries out (they have intersections and do not need to be processed) - geometry_objects = geometry_objects.difference(intersected_geom_objs) + # ... to be able to use the way more efficient difference() function ... + geometry_objects_ids = geometry_objects.difference(intersected_geom_objs).values_list("id", flat=True) + # ... so we can resolve these into proper Geometry objects again for further annotation usage + geometry_objects = Geometry.objects.filter(id__in=geometry_objects_ids) self._write_warning("=== Update parcels and districts ===") # Order geometries by size to process smaller once at first diff --git a/konova/management/commands/test_identifier_generating.py b/konova/management/commands/test_identifier_generating.py deleted file mode 100644 index 09c35731..00000000 --- a/konova/management/commands/test_identifier_generating.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Author: Michel Peltriaux -Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany -Contact: michel.peltriaux@sgdnord.rlp.de -Created on: 19.08.21 - -""" -from django.core.management import BaseCommand - -from intervention.models import Intervention - - -class Command(BaseCommand): - help = "Performs test on collisions using the identifier generation" - - def handle(self, *args, **options): - identifiers = {} - max_iterations = 100000 - try: - collisions = 0 - len_ids = len(identifiers) - while len_ids < max_iterations: - tmp_intervention = Intervention() - _id = tmp_intervention.generate_new_identifier() - len_ids = len(identifiers) - if _id not in identifiers: - if len_ids % (max_iterations/5) == 0: - print(len_ids) - identifiers[_id] = None - else: - collisions += 1 - print("+++ Collision after {} identifiers +++".format(len_ids)) - - except KeyboardInterrupt: - self._break_line() - exit(-1) - print( - "\n{} collisions in {} identifiers; Collision rate {}%".format( - collisions, - len_ids, - (collisions / len_ids)*100, - ) - ) - - def _break_line(self): - """ Simply prints a line break - - Returns: - - """ - self.stdout.write("\n") \ No newline at end of file diff --git a/konova/sub_settings/django_settings.py b/konova/sub_settings/django_settings.py index fa87e11a..0f1f3509 100644 --- a/konova/sub_settings/django_settings.py +++ b/konova/sub_settings/django_settings.py @@ -66,7 +66,6 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.gis', 'django.contrib.humanize', - 'simple_sso.sso_server', 'django_tables2', 'bootstrap_modal_forms', 'fontawesome_5', diff --git a/konova/sub_settings/lanis_settings.py b/konova/sub_settings/lanis_settings.py index 72082931..25ed4d25 100644 --- a/konova/sub_settings/lanis_settings.py +++ b/konova/sub_settings/lanis_settings.py @@ -5,6 +5,7 @@ Contact: michel.peltriaux@sgdnord.rlp.de Created on: 31.01.22 """ +from konova.sub_settings.django_settings import env # MAPS DEFAULT_LAT = 50.00 @@ -28,3 +29,6 @@ LANIS_ZOOM_LUT = { 1000: 30, 500: 31, } + +MAP_PROXY_HOST_WHITELIST = env.list("MAP_PROXY_HOST_WHITELIST") +i = 0 \ No newline at end of file diff --git a/konova/sub_settings/sso_settings.py b/konova/sub_settings/sso_settings.py index 7ff66777..2a50e60c 100644 --- a/konova/sub_settings/sso_settings.py +++ b/konova/sub_settings/sso_settings.py @@ -16,3 +16,5 @@ OAUTH_CODE_VERIFIER = env("OAUTH_CODE_VERIFIER") OAUTH_CLIENT_ID = env("OAUTH_CLIENT_ID") OAUTH_CLIENT_SECRET = env("OAUTH_CLIENT_SECRET") + +PROPAGATION_SECRET = env("PROPAGATION_SECRET") diff --git a/konova/utils/message_templates.py b/konova/utils/message_templates.py index b36e2a0a..e5e71557 100644 --- a/konova/utils/message_templates.py +++ b/konova/utils/message_templates.py @@ -91,3 +91,6 @@ INTERVENTION_HAS_REVOCATIONS_TEMPLATE = _("This intervention has {} revocations" DATA_CHECKED_ON_TEMPLATE = _("Checked on {} by {}") DATA_CHECKED_PREVIOUSLY_TEMPLATE = _("Data has changed since last check on {} by {}") DATA_IS_UNCHECKED = _("Current data not checked yet") + +# API TOKEN SETTINGS +NEW_API_TOKEN_GENERATED = _("New token generated. Administrators need to validate.") \ No newline at end of file diff --git a/konova/utils/messenger.py b/konova/utils/messenger.py deleted file mode 100644 index 5be4dc1e..00000000 --- a/konova/utils/messenger.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Author: Michel Peltriaux -Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany -Contact: michel.peltriaux@sgdnord.rlp.de -Created on: 17.08.21 - -""" -from collections import Iterable - -import requests -from user.models import User -from django.utils.translation import gettext_lazy as _ - -from konova.settings import SSO_SERVER_BASE, SSO_PUBLIC_KEY, PROXIES -from konova.sub_settings.context_settings import BASE_TITLE_SHORT - - -class Messenger: - """ Used to send messages to the SSO server. - - Messages can be seen by the user the next time they login on their SSO dashboard. - Documentation for SSO Server-Client communication can be found here: - https://git.naturschutz.rlp.de/SGD-Nord/arnova/wiki/Messages - - """ - server_url = "{}communication/message/".format(SSO_SERVER_BASE) - - def __init__(self, users: Iterable, subject: str = None, body: str = None, type: str = None): - self.users = users - self.msg_subject = subject - self.msg_body = body - self.msg_type = type - - def send(self): - """ Sends a message - - """ - if self.msg_body is None or len(self.msg_body) == 0: - raise AttributeError("No message body set") - - headers = { - "x-services-public-key": SSO_PUBLIC_KEY - } - for user in self.users: - data = { - "type": self.msg_type, - "sender": BASE_TITLE_SHORT, - "receiver": user.username, - "subject": self.msg_subject, - "body": self.msg_body, - } - requests.post( - self.server_url, - data=data, - headers=headers, - proxies=PROXIES - ) - - def send_object_checked(self, obj_identifier: str, performing_user: User, detail_view_url: str = ""): - """ Wraps sending of a message related to the checking of an object, like an intervention - - Args: - obj_identifier (str): The object's identifier (e.g. 'EIV-123' - performing_user (User): The user who performed the checking - detail_view_url (str): If a direct link to the object shall be added to the message, it can be provided here - - Returns: - - """ - self.msg_subject = _("{} checked").format(obj_identifier) - if len(detail_view_url) > 0: - detail_view_url = _('Check it out').format(detail_view_url) - self.msg_body = _("{} has been checked successfully by user {}! {}").format( - obj_identifier, - performing_user.username, - detail_view_url - ) - self.send() diff --git a/konova/utils/schneider/fetcher.py b/konova/utils/schneider/fetcher.py index 6e4cc209..2fa79cb9 100644 --- a/konova/utils/schneider/fetcher.py +++ b/konova/utils/schneider/fetcher.py @@ -11,6 +11,7 @@ from json import JSONDecodeError import requests from konova.sub_settings import schneider_settings +from konova.sub_settings.lanis_settings import DEFAULT_SRID from konova.sub_settings.proxy_settings import PROXIES @@ -34,6 +35,7 @@ class ParcelFetcher: if geom.area < buffer_threshold: # Fallback for malicious geometries which are way too small and would disappear on negative buffering geom = geometry.geom + geom.transform(DEFAULT_SRID) self.geojson = geom.ewkt self.results = [] diff --git a/konova/views/geometry.py b/konova/views/geometry.py index c9eed0d5..30aa8cd9 100644 --- a/konova/views/geometry.py +++ b/konova/views/geometry.py @@ -5,7 +5,6 @@ Contact: ksp-servicestelle@sgdnord.rlp.de Created on: 19.08.22 """ -from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.gis.geos import MultiPolygon from django.http import HttpResponse, HttpRequest from django.shortcuts import get_object_or_404 diff --git a/konova/views/logout.py b/konova/views/logout.py index fe4d0db4..abeb9b3e 100644 --- a/konova/views/logout.py +++ b/konova/views/logout.py @@ -24,5 +24,13 @@ class LogoutView(View): Returns: A redirect """ + user = request.user + try: + oauth_token = user.oauth_token + if oauth_token: + oauth_token.revoke() + except AttributeError: + pass + logout(request) return redirect(SSO_SERVER_BASE) diff --git a/konova/views/map_proxy.py b/konova/views/map_proxy.py index 4834ad58..daadb3c2 100644 --- a/konova/views/map_proxy.py +++ b/konova/views/map_proxy.py @@ -9,6 +9,7 @@ import json from json import JSONDecodeError import requests +import urllib3.util from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpRequest, HttpResponse from django.utils.decorators import method_decorator @@ -17,6 +18,7 @@ from django.views import View from requests.auth import HTTPDigestAuth +from konova.sub_settings.lanis_settings import MAP_PROXY_HOST_WHITELIST from konova.sub_settings.proxy_settings import PROXIES, GEOPORTAL_RLP_USER, GEOPORTAL_RLP_PASSWORD @@ -31,6 +33,13 @@ class BaseClientProxyView(View): def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) + def _check_with_whitelist(self, url): + parsed_url = urllib3.util.parse_url(url) + parsed_url_host = parsed_url.host + whitelist = set(MAP_PROXY_HOST_WHITELIST) + is_allowed = parsed_url_host in whitelist + return is_allowed + def perform_url_call(self, url, headers={}, auth=None): """ Generic proxied call @@ -61,6 +70,11 @@ class ClientProxyParcelSearch(BaseClientProxyView): def get(self, request: HttpRequest): url = request.META.get("QUERY_STRING") + + is_url_allowed = self._check_with_whitelist(url) + if not is_url_allowed: + raise PermissionError(f"Proxied url '{url}' is not allowed!") + content, response_code = self.perform_url_call(url) return HttpResponse(content) diff --git a/konova/views/oauth.py b/konova/views/oauth.py index 5748d482..8649e164 100644 --- a/konova/views/oauth.py +++ b/konova/views/oauth.py @@ -115,10 +115,10 @@ class OAuthCallbackView(View): if status_code_invalid: raise RuntimeError(f"OAuth access token could not be fetched: {access_code_response.text}") - oauth_access_token = OAuthToken.from_access_token_response(access_code_response_body, received_on) - oauth_access_token.save() - user = oauth_access_token.update_and_get_user() - user.oauth_replace_token(oauth_access_token) + oauth_token = OAuthToken.from_access_token_response(access_code_response_body, received_on) + oauth_token.save() + user = oauth_token.update_and_get_user() + user.oauth_replace_token(oauth_token) login(request, user) return redirect("home") diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index 17694946..2a3ee10f 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 9bc92c82..eb35247c 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -38,13 +38,14 @@ #: konova/forms/modals/remove_form.py:23 #: konova/forms/modals/resubmission_form.py:22 #: konova/forms/modals/resubmission_form.py:38 konova/forms/remove_form.py:25 -#: konova/tests/unit/test_forms.py:59 user/forms/user.py:39 +#: konova/tests/unit/test_forms.py:59 user/forms/modals/api_token.py:17 +#: user/forms/user.py:39 #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-08-19 10:32+0200\n" +"POT-Creation-Date: 2025-01-08 15:26+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -373,7 +374,6 @@ msgid "Identifier" msgstr "Kennung" #: compensation/forms/compensation.py:33 intervention/forms/intervention.py:33 -#: user/forms/user.py:77 msgid "Generated automatically - not editable" msgstr "Automatisch generiert - nicht bearbeitbar" @@ -1795,8 +1795,7 @@ msgstr "Bearbeitender Nutzer" msgid "" "Search for entries where this person has been participated according to log " "history" -msgstr "" -"Sucht nach Einträgen, an denen diese Person gearbeitet hat" +msgstr "Sucht nach Einträgen, an denen diese Person gearbeitet hat" #: konova/forms/base_form.py:23 templates/form/collapsable/form.html:62 msgid "Save" @@ -1858,6 +1857,7 @@ msgstr "" "Ich, {} {}, bestätige, dass diese Daten wieder entzeichnet werden müssen." #: konova/forms/modals/remove_form.py:22 konova/forms/remove_form.py:24 +#: user/forms/modals/api_token.py:16 msgid "Confirm" msgstr "Bestätige" @@ -2283,6 +2283,10 @@ msgstr "" msgid "Current data not checked yet" msgstr "Momentane Daten noch nicht geprüft" +#: konova/utils/message_templates.py:96 +msgid "New token generated. Administrators need to validate." +msgstr "Neuer Token generiert. Administratoren sind informiert." + #: konova/utils/messenger.py:70 msgid "{} checked" msgstr "{} geprüft" @@ -2321,7 +2325,7 @@ msgstr "Home" msgid "Log" msgstr "Log" -#: konova/views/map_proxy.py:70 +#: konova/views/map_proxy.py:84 msgid "" "The external service is currently unavailable.
Please try again in a few " "moments..." @@ -2831,10 +2835,8 @@ msgid "Reports" msgstr "Berichte" #: templates/navbars/navbar.html:57 -#, fuzzy -#| msgid "Admins" msgid "Admin" -msgstr "Administratoren" +msgstr "" #: templates/navbars/navbar.html:59 user/templates/user/index.html:31 msgid "Settings" @@ -2873,6 +2875,21 @@ msgstr "" "Falls die Geometrie nicht leer ist, werden die Flurstücke aktuell berechnet. " "Bitte laden Sie diese Seite in ein paar Augenblicken erneut..." +#: user/forms/modals/api_token.py:25 +msgid "Generate API Token" +msgstr "API Token generieren" + +#: user/forms/modals/api_token.py:29 +msgid "" +"You are about to create a new API token. The existing one will not be usable " +"afterwards." +msgstr "" +"Wenn Sie fortfahren, generieren Sie einen neuen API Token. Ihren existierenden werden Sie dann nicht länger nutzen können." + +#: user/forms/modals/api_token.py:31 +msgid "A new token needs to be validated by an administrator!" +msgstr "Neue Tokens müssen durch Administratoren freigeschaltet werden!" + #: user/forms/modals/team.py:20 user/forms/modals/team.py:24 #: user/forms/team.py:17 user/forms/team.py:22 msgid "Team name" @@ -2895,11 +2912,11 @@ msgstr "" "Mehrfachauswahl möglich - Sie können nur Nutzer wählen, die noch nicht " "Mitglieder dieses Teams sind. Geben Sie den ganzen Nutzernamen an." -#: user/forms/modals/team.py:56 user/tests/unit/test_forms.py:31 +#: user/forms/modals/team.py:56 user/tests/unit/test_forms.py:29 msgid "Create new team" msgstr "Neues Team anlegen" -#: user/forms/modals/team.py:57 user/tests/unit/test_forms.py:32 +#: user/forms/modals/team.py:57 user/tests/unit/test_forms.py:30 msgid "" "You will become the administrator for this group by default. You do not need " "to add yourself to the list of members." @@ -2928,11 +2945,11 @@ msgid "There must be at least one admin on this team." msgstr "Es muss mindestens einen Administrator für das Team geben." #: user/forms/modals/team.py:160 user/templates/user/team/index.html:60 -#: user/tests/unit/test_forms.py:88 +#: user/tests/unit/test_forms.py:86 msgid "Edit team" msgstr "Team bearbeiten" -#: user/forms/modals/team.py:187 user/tests/unit/test_forms.py:165 +#: user/forms/modals/team.py:187 user/tests/unit/test_forms.py:163 msgid "" "ATTENTION!\n" "\n" @@ -2949,7 +2966,7 @@ msgstr "" "Sind Sie sicher, dass Sie dieses Team löschen möchten?" #: user/forms/modals/team.py:197 user/templates/user/team/index.html:56 -#: user/tests/unit/test_forms.py:198 +#: user/tests/unit/test_forms.py:196 msgid "Leave team" msgstr "Team verlassen" @@ -2981,22 +2998,10 @@ msgstr "Benachrichtigungen" msgid "Select the situations when you want to receive a notification" msgstr "Wann wollen Sie per E-Mail benachrichtigt werden?" -#: user/forms/user.py:38 user/tests/unit/test_forms.py:234 +#: user/forms/user.py:38 user/tests/unit/test_forms.py:232 msgid "Edit notifications" msgstr "Benachrichtigungen bearbeiten" -#: user/forms/user.py:73 -msgid "Token" -msgstr "" - -#: user/forms/user.py:88 user/tests/unit/test_forms.py:260 -msgid "Create new token" -msgstr "Neuen Token generieren" - -#: user/forms/user.py:89 user/tests/unit/test_forms.py:261 -msgid "A new token needs to be validated by an administrator!" -msgstr "Neue Tokens müssen durch Administratoren freigeschaltet werden!" - #: user/models/user_action.py:23 msgid "Unrecorded" msgstr "Entzeichnet" @@ -3051,7 +3056,7 @@ msgid "Manage teams" msgstr "" #: user/templates/user/index.html:53 user/templates/user/team/index.html:19 -#: user/views/views.py:171 +#: user/views/views.py:135 msgid "Teams" msgstr "" @@ -3087,270 +3092,58 @@ msgstr "API Einstellungen" msgid "Current token" msgstr "Aktueller Token" -#: user/templates/user/token.html:14 +#: user/templates/user/token.html:15 +msgid "Create new token" +msgstr "Neuen Token generieren" + +#: user/templates/user/token.html:23 msgid "Authenticated by admins" msgstr "Von Admin freigeschaltet" -#: user/templates/user/token.html:18 +#: user/templates/user/token.html:27 msgid "Token has been verified and can be used" msgstr "Token wurde freigeschaltet und kann verwendet werden" -#: user/templates/user/token.html:20 +#: user/templates/user/token.html:29 msgid "Token waiting for verification" msgstr "Token noch nicht freigeschaltet" -#: user/templates/user/token.html:24 +#: user/templates/user/token.html:33 msgid "Valid until" msgstr "Läuft ab am" -#: user/views/views.py:35 -msgid "User settings" -msgstr "Einstellungen" - -#: user/views/views.py:61 -msgid "Notifications edited" -msgstr "Benachrichtigungen bearbeitet" - -#: user/views/views.py:73 -msgid "User notifications" -msgstr "Benachrichtigungen" - -#: user/views/views.py:96 -msgid "New token generated. Administrators need to validate." -msgstr "Neuer Token generiert. Administratoren sind informiert." - -#: user/views/views.py:107 +#: user/views/api_token.py:33 msgid "User API token" msgstr "API Nutzer Token" -#: user/views/views.py:183 +#: user/views/views.py:33 +msgid "User settings" +msgstr "Einstellungen" + +#: user/views/views.py:59 +msgid "Notifications edited" +msgstr "Benachrichtigungen bearbeitet" + +#: user/views/views.py:71 +msgid "User notifications" +msgstr "Benachrichtigungen" + +#: user/views/views.py:147 msgid "New team added" msgstr "Neues Team hinzugefügt" -#: user/views/views.py:198 +#: user/views/views.py:162 msgid "Team edited" msgstr "Team bearbeitet" -#: user/views/views.py:213 +#: user/views/views.py:177 msgid "Team removed" msgstr "Team gelöscht" -#: user/views/views.py:228 +#: user/views/views.py:192 msgid "You are not a member of this team" msgstr "Sie sind kein Mitglied dieses Teams" -#: user/views/views.py:235 +#: user/views/views.py:199 msgid "Left Team" msgstr "Team verlassen" - -#~ msgid "close" -#~ msgstr "Schließen" - -#~ msgid "Options" -#~ msgstr "Optionen" - -#~ msgid "Commands" -#~ msgstr "Befehle" - -#~ msgid "Missing command." -#~ msgstr "Befehl fehlt" - -#~ msgid "Missing argument" -#~ msgstr "Argument fehlt" - -#~ msgid "Missing option" -#~ msgstr "Option fehlt" - -#~ msgid "Missing parameter" -#~ msgstr "Parameter fehlt" - -#~ msgid "Messages" -#~ msgstr "Nachrichten" - -#~ msgid "This field is required." -#~ msgstr "Pflichtfeld" - -#~ msgid "Monday" -#~ msgstr "Montag" - -#~ msgid "Tuesday" -#~ msgstr "Dienstag" - -#~ msgid "Wednesday" -#~ msgstr "Mittwoch" - -#~ msgid "Thursday" -#~ msgstr "Donnerstag" - -#~ msgid "Friday" -#~ msgstr "Freitag" - -#~ msgid "Saturday" -#~ msgstr "Samstag" - -#~ msgid "Sunday" -#~ msgstr "Sonntag" - -#~ msgid "Mon" -#~ msgstr "Mo" - -#~ msgid "Tue" -#~ msgstr "Di" - -#~ msgid "Wed" -#~ msgstr "Mi" - -#~ msgid "Thu" -#~ msgstr "Do" - -#~ msgid "Fri" -#~ msgstr "Fr" - -#~ msgid "Sat" -#~ msgstr "Sa" - -#~ msgid "Sun" -#~ msgstr "So" - -#~ msgid "January" -#~ msgstr "Januar" - -#~ msgid "February" -#~ msgstr "Februar" - -#~ msgid "March" -#~ msgstr "März" - -#~ msgid "May" -#~ msgstr "Mai" - -#~ msgid "June" -#~ msgstr "Juni" - -#~ msgid "July" -#~ msgstr "Juli" - -#~ msgid "October" -#~ msgstr "Oktober" - -#~ msgid "December" -#~ msgstr "Dezember" - -#~ msgid "mar" -#~ msgstr "mär" - -#~ msgid "may" -#~ msgstr "mai" - -#~ msgid "oct" -#~ msgstr "okt" - -#~ msgid "dec" -#~ msgstr "dez" - -#~ msgctxt "abbrev. month" -#~ msgid "March" -#~ msgstr "Mär" - -#~ msgctxt "abbrev. month" -#~ msgid "May" -#~ msgstr "Mai" - -#~ msgctxt "abbrev. month" -#~ msgid "June" -#~ msgstr "Juni" - -#~ msgctxt "abbrev. month" -#~ msgid "July" -#~ msgstr "Juli" - -#~ msgctxt "abbrev. month" -#~ msgid "Oct." -#~ msgstr "Okt." - -#~ msgctxt "abbrev. month" -#~ msgid "Dec." -#~ msgstr "Dez." - -#~ msgctxt "alt. month" -#~ msgid "January" -#~ msgstr "Januar" - -#~ msgctxt "alt. month" -#~ msgid "February" -#~ msgstr "Februar" - -#~ msgctxt "alt. month" -#~ msgid "March" -#~ msgstr "März" - -#~ msgctxt "alt. month" -#~ msgid "May" -#~ msgstr "Mai" - -#~ msgctxt "alt. month" -#~ msgid "June" -#~ msgstr "Juni" - -#~ msgctxt "alt. month" -#~ msgid "July" -#~ msgstr "Juli" - -#~ msgctxt "alt. month" -#~ msgid "October" -#~ msgstr "Oktober" - -#~ msgctxt "alt. month" -#~ msgid "December" -#~ msgstr "Dezember" - -#~ msgid "or" -#~ msgstr "oder" - -#~ msgid "" -#~ "Deductable surface can not be larger than existing surfaces in after " -#~ "states" -#~ msgstr "" -#~ "Die abbuchbare Fläche darf die Gesamtfläche der Zielzustände nicht " -#~ "überschreiten" - -#~ msgid "" -#~ "Deductable surface can not be smaller than the sum of already existing " -#~ "deductions. Please contact the responsible users for the deductions!" -#~ msgstr "" -#~ "Es wurde bereits mehr Fläche abgebucht, als Sie nun als abbuchbar " -#~ "einstellen wollen. Kontaktieren Sie die für die Abbuchungen " -#~ "verantwortlichen Nutzer!" - -#~ msgid "Added deadline" -#~ msgstr "Frist/Termin hinzugefügt" - -#~ msgid "Change default configuration for your KSP map" -#~ msgstr "Karteneinstellungen ändern" - -#~ msgid "Map settings" -#~ msgstr "Karte" - -#~ msgid "There are errors on this intervention:" -#~ msgstr "Es liegen Fehler in diesem Eingriff vor:" - -#~ msgid "Before" -#~ msgstr "Vor" - -#~ msgid "Groups" -#~ msgstr "Gruppen" - -#~ msgid "Show more..." -#~ msgstr "Mehr anzeigen..." - -#~ msgid "Kreis" -#~ msgstr "Kreis" - -#~ msgid "Gemarkung" -#~ msgstr "Gemarkung" - -#~ msgid "Loading..." -#~ msgstr "Lade..." - -#~ msgid "Who handles the eco-account" -#~ msgstr "Wer für die Herrichtung des Ökokontos verantwortlich ist" diff --git a/requirements.txt b/requirements.txt index e66fb477..2f00fdd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,65 +1,63 @@ -amqp==5.2.0 +amqp==5.3.1 asgiref==3.8.1 -async-timeout==4.0.3 +async-timeout==5.0.1 beautifulsoup4==4.13.0b2 -billiard==4.2.0 -cached-property==1.5.2 +billiard==4.2.1 +cached-property==2.0.1 celery==5.4.0 -certifi==2024.7.4 -cffi==1.17.0 +certifi==2024.12.14 +cffi==1.17.1 chardet==5.2.0 -charset-normalizer==3.3.2 -click==8.1.7 +charset-normalizer==3.4.0 +click==8.1.8 click-didyoumean==0.3.1 click-plugins==1.1.1 click-repl==0.3.0 -coverage==7.5.4 -cryptography==43.0.0 -Deprecated==1.2.14 -Django==5.0.8 +coverage==7.6.9 +cryptography==44.0.0 +Deprecated==1.2.15 +Django==5.1.4 django-autocomplete-light==3.11.0 -django-bootstrap-modal-forms==3.0.4 -django-bootstrap4==24.3 +django-bootstrap-modal-forms==3.0.5 +django-bootstrap4==24.4 django-environ==0.11.2 django-filter==24.3 django-fontawesome-5==1.0.18 -django-oauth-toolkit==2.4.0 -django-simple-sso==1.2.0 -django-tables2==2.7.0 -et-xmlfile==1.1.0 -gunicorn==22.0.0 -idna==3.7 -importlib_metadata==8.2.0 -itsdangerous==0.24 +django-oauth-toolkit==3.0.1 +django-tables2==2.7.1 +et_xmlfile==2.0.0 +gunicorn==23.0.0 +idna==3.10 +importlib_metadata==8.5.0 jwcrypto==1.5.6 kombu==5.4.0rc1 oauthlib==3.2.2 openpyxl==3.2.0b1 -packaging==24.1 +packaging==24.2 pika==1.3.2 -pillow==10.4.0 -prompt_toolkit==3.0.47 -psycopg==3.2.1 -psycopg-binary==3.2.1 +pillow==11.0.0 +prompt_toolkit==3.0.48 +psycopg==3.2.3 +psycopg-binary==3.2.3 pycparser==2.22 -pyparsing==3.1.2 +pyparsing==3.2.0 pypng==0.20220715.0 -pyproj==3.6.1 +pyproj==3.7.0 python-dateutil==2.9.0.post0 -pytz==2024.1 +pytz==2024.2 PyYAML==6.0.2 qrcode==7.3.1 redis==5.1.0b6 -requests<2.32.0 # kombu 5.4.0rc1 depends on requests<2.32.0 +requests==2.32.3 six==1.16.0 soupsieve==2.5 sqlparse==0.5.1 typing_extensions==4.12.2 -tzdata==2024.1 -urllib3==2.2.2 +tzdata==2024.2 +urllib3==2.3.0 vine==5.1.0 wcwidth==0.2.13 webservices==0.7 wrapt==1.16.0 -xmltodict==0.13.0 -zipp==3.19.2 +xmltodict==0.14.2 +zipp==3.21.0 diff --git a/user/autocomplete/share.py b/user/autocomplete/share.py index 215e32c6..4e450286 100644 --- a/user/autocomplete/share.py +++ b/user/autocomplete/share.py @@ -17,10 +17,11 @@ class ShareUserAutocomplete(Select2QuerySetView): """ def get_queryset(self): + qs = User.objects.none() if self.request.user.is_anonymous: - return User.objects.none() - qs = User.objects.all() + return qs if self.q: + qs = User.objects.all() # Due to privacy concerns only a full username match will return the proper user entry qs = qs.filter( Q(username=self.q) | @@ -41,13 +42,13 @@ class ShareTeamAutocomplete(Select2QuerySetView): """ def get_queryset(self): + qs = Team.objects.none() if self.request.user.is_anonymous: - return Team.objects.none() - qs = Team.objects.filter( - deleted__isnull=True - ) + return qs if self.q: - # Due to privacy concerns only a full username match will return the proper user entry + qs = Team.objects.filter( + deleted__isnull=True + ) q_parts = self.q.split(" ") q = Q() for part in q_parts: diff --git a/user/forms/modals/api_token.py b/user/forms/modals/api_token.py new file mode 100644 index 00000000..d5ead620 --- /dev/null +++ b/user/forms/modals/api_token.py @@ -0,0 +1,49 @@ +""" +Author: Michel Peltriaux +Created on: 08.01.25 + +""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from api.models import APIUserToken +from konova.forms.modals import BaseModalForm +from konova.utils.mailer import Mailer + + +class NewAPITokenModalForm(BaseModalForm): + confirm = forms.BooleanField( + label=_("Confirm"), + label_suffix=_(""), + widget=forms.CheckboxInput(), + required=True, + ) + + def __init__(self, *args, **kwargs): + self.template = "modal/modal_form.html" + super().__init__(*args, **kwargs) + self.form_title = _("Generate API Token") + + self.form_caption = "" + if self.__user_has_api_token(): + self.form_caption = _("You are about to create a new API token. The existing one will not be usable afterwards.") + self.form_caption += "\n" + self.form_caption += _("A new token needs to be validated by an administrator!") + # Disable automatic w-100 setting for this type of modal form. Looks kinda strange + self.fields["confirm"].widget.attrs["class"] = "" + + def __user_has_api_token(self): + return self.instance.api_token is not None + + def save(self): + user = self.instance + if user.api_token is not None: + user.api_token.delete() + user.api_token = APIUserToken.objects.create() + user.save() + + mailer = Mailer() + mailer.send_mail_verify_api_token(user) + + return user.api_token + diff --git a/user/forms/user.py b/user/forms/user.py index e27dba9b..190ce126 100644 --- a/user/forms/user.py +++ b/user/forms/user.py @@ -66,48 +66,3 @@ class UserNotificationForm(BaseForm): id__in=selected_notification_ids, ) self.user.notifications.set(notifications) - - -class UserAPITokenForm(BaseForm): - token = forms.CharField( - label=_("Token"), - label_suffix="", - max_length=255, - required=True, - help_text=_("Generated automatically - not editable"), - widget=GenerateInput( - attrs={ - "class": "form-control", - "url": reverse_lazy("api:generate-new-token"), - } - ) - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form_title = _("Create new token") - self.form_caption = _("A new token needs to be validated by an administrator!") - - self.action_url = reverse("user:api-token") - self.cancel_redirect = reverse("user:index") - - # Make direct token editing by user impossible. Instead set the proper url for generating a new token - self.initialize_form_field("token", None) - self.fields["token"].widget.attrs["readonly"] = True - - def save(self): - """ Saves the form data - - Returns: - api_token (APIUserToken) - """ - user = self.instance - new_token = self.cleaned_data["token"] - if user.api_token is not None: - user.api_token.delete() - new_token = APIUserToken.objects.create( - token=new_token - ) - user.api_token = new_token - user.save() - return new_token \ No newline at end of file diff --git a/user/templates/user/token.html b/user/templates/user/token.html index 47b873d7..ab0f8272 100644 --- a/user/templates/user/token.html +++ b/user/templates/user/token.html @@ -8,7 +8,16 @@ - + @@ -27,7 +36,9 @@
{% trans 'Current token' %}{{ user.api_token.token }} +
+
{{ user.api_token.token }}
+
+ +
+
+
{% trans 'Authenticated by admins' %}
-
- {% include 'form/table/generic_table_form.html' %} - + +{% with 'btn-modal' as btn_class %} + {% include 'modal/modal_form_script.html' %} +{% endwith %} + {% endblock %} \ No newline at end of file diff --git a/user/tests/unit/test_forms.py b/user/tests/unit/test_forms.py index 48ecd5d9..20e4333c 100644 --- a/user/tests/unit/test_forms.py +++ b/user/tests/unit/test_forms.py @@ -5,15 +5,14 @@ Contact: ksp-servicestelle@sgdnord.rlp.de Created on: 12.09.23 """ -from django.core.exceptions import ObjectDoesNotExist from django.test import RequestFactory from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from api.models import APIUserToken from konova.tests.test_views import BaseTestCase +from user.forms.modals.api_token import NewAPITokenModalForm from user.forms.modals.team import NewTeamModalForm, EditTeamModalForm, RemoveTeamModalForm, LeaveTeamModalForm -from user.forms.user import UserNotificationForm, UserAPITokenForm +from user.forms.user import UserNotificationForm from user.models import Team, UserAction, UserNotification @@ -252,35 +251,28 @@ class UserNotificationFormTestCase(BaseTestCase): self.assertIn(selected_notification, self.user.notifications.all()) -class UserAPITokenFormTestCase(BaseTestCase): - def test_init(self): - form = UserAPITokenForm( - instance=self.user - ) - self.assertEqual(form.form_title, str(_("Create new token"))) - self.assertEqual(form.form_caption, str(_("A new token needs to be validated by an administrator!"))) - self.assertEqual(form.action_url, reverse("user:api-token")) - self.assertEqual(form.cancel_redirect, reverse("user:index")) - - self.assertIsNone(form.fields["token"].initial) - self.assertTrue(form.fields["token"].widget.attrs["readonly"]) - - def test_save(self): - data = { - "token": APIUserToken().token +class ApiTokenFormTestCase(BaseTestCase): + def test_new_token_and_recreating_token(self): + request = RequestFactory().request() + request.user = self.user + request.POST = { + "confirm": True } - form = UserAPITokenForm( - data, - instance=self.user - ) - self.assertTrue(form.is_valid(), msg=form.errors) + self.assertIsNone(self.user.api_token) - token = form.save() - self.assertEqual(self.user.api_token, token) - new_token = form.save() - self.assertEqual(self.user.api_token, new_token) - try: - token.refresh_from_db() - self.fail("Token should be deleted and not be fetchable anymore") - except ObjectDoesNotExist: - pass + form = NewAPITokenModalForm(request.POST, instance=self.user) + form.save() + self.user.refresh_from_db() + token = self.user.api_token + self.assertFalse(token.is_active) + self.assertIsNone(token.valid_until) + self.assertIsNotNone(token.token) + + old_token = token.token + form.save() + self.user.refresh_from_db() + new_token = self.user.api_token + self.assertNotEqual(new_token.token, old_token) + self.assertFalse(new_token.is_active) + self.assertIsNone(new_token.valid_until) + diff --git a/user/urls.py b/user/urls.py index cb6e20b0..c3127a1e 100644 --- a/user/urls.py +++ b/user/urls.py @@ -9,6 +9,7 @@ from django.urls import path from user.autocomplete.share import ShareUserAutocomplete, ShareTeamAutocomplete from user.autocomplete.team import TeamAdminAutocomplete +from user.views.api_token import APITokenView, new_api_token_view from user.views.propagate import PropagateUserView from user.views.views import * @@ -17,7 +18,8 @@ urlpatterns = [ path("", index_view, name="index"), path("propagate/", PropagateUserView.as_view(), name="propagate"), path("notifications/", notifications_view, name="notifications"), - path("token/api", api_token_view, name="api-token"), + path("token/api", APITokenView.as_view(), name="api-token"), + path("token/api/new", new_api_token_view, name="api-token-new"), path("contact/", contact_view, name="contact"), path("team/", index_team_view, name="team-index"), path("team/new", new_team_view, name="team-new"), @@ -30,4 +32,4 @@ urlpatterns = [ path("atcmplt/share/u", ShareUserAutocomplete.as_view(), name="share-user-autocomplete"), path("atcmplt/share/t", ShareTeamAutocomplete.as_view(), name="share-team-autocomplete"), path("atcmplt/team/admin", TeamAdminAutocomplete.as_view(), name="team-admin-autocomplete"), -] \ No newline at end of file +] diff --git a/user/views/api_token.py b/user/views/api_token.py new file mode 100644 index 00000000..3b653c28 --- /dev/null +++ b/user/views/api_token.py @@ -0,0 +1,57 @@ +""" +Author: Michel Peltriaux +Created on: 08.01.25 + +""" +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views import View +from django.utils.translation import gettext_lazy as _ + +from konova.contexts import BaseContext +from konova.decorators import default_group_required +from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER +from konova.utils.message_templates import NEW_API_TOKEN_GENERATED +from user.forms.modals.api_token import NewAPITokenModalForm + + +class APITokenView(View): + + @method_decorator(login_required) + @method_decorator(default_group_required) + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + + def get(self, request: HttpRequest): + template = "user/token.html" + user = request.user + + context = { + "user": user, + TAB_TITLE_IDENTIFIER: _("User API token"), + } + context = BaseContext(request, context).context + return render(request, template, context) + + +def new_api_token_view(request: HttpRequest): + """ Function based view for processing ModalForm + (Currently ModalForms only work properly with function based views) + + Args: + request (): + + Returns: + + """ + user = request.user + + form = NewAPITokenModalForm(request.POST or None, instance=user, request=request) + return form.process_request( + request=request, + msg_success=NEW_API_TOKEN_GENERATED, + redirect_url=reverse("user:api-token"), + ) \ No newline at end of file diff --git a/user/views/propagate.py b/user/views/propagate.py index e6a0e5c9..3afb6fcf 100644 --- a/user/views/propagate.py +++ b/user/views/propagate.py @@ -16,7 +16,7 @@ from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt -from konova.sub_settings.sso_settings import OAUTH_CLIENT_ID +from konova.sub_settings.sso_settings import PROPAGATION_SECRET from user.models import User @@ -36,7 +36,7 @@ class PropagateUserView(View): # Decrypt encrypted_body = request.body _hash = hashlib.md5() - _hash.update(OAUTH_CLIENT_ID.encode("utf-8")) + _hash.update(PROPAGATION_SECRET.encode("utf-8")) key = base64.urlsafe_b64encode(_hash.hexdigest().encode("utf-8")) fernet = Fernet(key) body = fernet.decrypt(encrypted_body).decode("utf-8") diff --git a/user/views/views.py b/user/views/views.py index 30ce627e..5d773926 100644 --- a/user/views/views.py +++ b/user/views/views.py @@ -3,19 +3,17 @@ from django.contrib.auth.decorators import login_required from django.urls import reverse from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER -from konova.utils.mailer import Mailer -from konova.utils.message_templates import FORM_INVALID from user.forms.modals.team import NewTeamModalForm, EditTeamModalForm, RemoveTeamModalForm, LeaveTeamModalForm from user.forms.modals.user import UserContactForm from user.forms.team import TeamDataForm -from user.forms.user import UserNotificationForm, UserAPITokenForm +from user.forms.user import UserNotificationForm from user.models import User, Team from django.http import HttpRequest, Http404 from django.shortcuts import render, redirect, get_object_or_404 from django.utils.translation import gettext_lazy as _ from konova.contexts import BaseContext -from konova.decorators import any_group_check, default_group_required, login_required_modal +from konova.decorators import any_group_check, login_required_modal @login_required @@ -76,40 +74,6 @@ def notifications_view(request: HttpRequest): return render(request, template, context) -@login_required -@default_group_required -def api_token_view(request: HttpRequest): - """ Handles the request for user api frontend settings - - Args: - request (HttpRequest): The incoming request - - Returns: - - """ - template = "user/token.html" - user = request.user - form = UserAPITokenForm(request.POST or None, instance=user) - if request.method == "POST": - if form.is_valid(): - token = form.save() - messages.info(request, _("New token generated. Administrators need to validate.")) - mailer = Mailer() - mailer.send_mail_verify_api_token(user) - return redirect("user:api-token") - else: - messages.error(request, FORM_INVALID, extra_tags="danger") - elif request.method != "GET": - raise NotImplementedError - context = { - "user": user, - "form": form, - TAB_TITLE_IDENTIFIER: _("User API token"), - } - context = BaseContext(request, context).context - return render(request, template, context) - - @login_required_modal @login_required def contact_view(request: HttpRequest, id: str):