Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0446d50438 | |||
| 12f78c85bf | |||
| 78485a4506 | |||
| 21a5c84b18 | |||
| a93f509d51 | |||
| 38967da201 | |||
| 60d749db2d | |||
| dff577309e | |||
| ea590d0868 | |||
| e09c15bd51 | |||
| c3019f83fd | |||
| 93a71a7055 | |||
| 35b1409359 | |||
| c9aeb393b5 | |||
| 6df46e7642 | |||
| fe366bc568 | |||
| a9f04a28c1 | |||
| 8c9f4888dd | |||
| 5c727b2eaa | |||
| 76b2a78fe2 | |||
| 86db08fca0 | |||
| fe1dce6440 | |||
| a5e6f5a1db | |||
| 78e9cbab71 | |||
| 572348f9f1 | |||
| 8ff3cb9adc | |||
| f135008447 | |||
| 94b7f3ad70 | |||
| d69bab36da | |||
| fa86cc142f | |||
| 6523891703 | |||
| 18f590f4a6 | |||
| b441518334 | |||
| 1a80912960 | |||
| 04dc7fcd30 | |||
| 09546212b9 | |||
| b1cd7dee40 | |||
| c772e1de06 | |||
| 4332a750d1 | |||
| 47279dd55d | |||
| e2eb0ecbb0 | |||
| be5b8457a6 | |||
| 72f1d80261 | |||
| df55c16498 | |||
| 11cc8b6766 | |||
| 0b5691f501 | |||
| d76a1fc85f | |||
| 476447c621 | |||
| 2b94e537ae | |||
| c06088a260 | |||
| 4fc15f6a9d | |||
| cf90f9710c | |||
| 8bcccb4685 | |||
| 50bd6feb89 |
46
.env.sample
Normal file
46
.env.sample
Normal file
@@ -0,0 +1,46 @@
|
||||
# General
|
||||
SECRET_KEY=CHANGE_ME
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=127.0.0.1,localhost,example.org
|
||||
BASE_URL=http://localhost:8002
|
||||
ADMINS=Admin1:mail@example.org,Admin2:mail2@example.org
|
||||
|
||||
# Database
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
DB_NAME=konova
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
|
||||
# Redis (for celery)
|
||||
REDIS_HOST=CHANGE_ME
|
||||
REDIS_PORT=CHANGE_ME
|
||||
|
||||
# E-Mail
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=25
|
||||
REPLY_TO_ADDR=ksp-servicestelle@sgdnord.rlp.de
|
||||
DEFAULT_FROM_EMAIL=service@ksp.de
|
||||
|
||||
# Proxy
|
||||
PROXY=CHANGE_ME
|
||||
GEOPORTAL_RLP_USER=CHANGE_ME
|
||||
GEOPORTAL_RLP_PASSWORD=CHANGE_ME
|
||||
|
||||
# Schneider
|
||||
SCHNEIDER_BASE_URL=https://schneider.naturschutz.rlp.de
|
||||
SCHNEIDER_AUTH_TOKEN=CHANGE_ME
|
||||
SCHNEIDER_AUTH_HEADER=auth
|
||||
|
||||
# SSO
|
||||
SSO_SERVER_BASE_URL=https://login.naturschutz.rlp.de
|
||||
OAUTH_CODE_VERIFIER=CHANGE_ME
|
||||
OAUTH_CLIENT_ID=CHANGE_ME
|
||||
OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# RabbitMQ
|
||||
## For connections to EGON
|
||||
EGON_RABBITMQ_HOST=CHANGE_ME
|
||||
EGON_RABBITMQ_PORT=CHANGE_ME
|
||||
EGON_RABBITMQ_USER=CHANGE_ME
|
||||
EGON_RABBITMQ_PW=CHANGE_ME
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
/.idea/
|
||||
/.coverage
|
||||
/htmlcov/
|
||||
/.env
|
||||
|
||||
15
api/admin.py
15
api/admin.py
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from api.models.token import APIUserToken
|
||||
from api.models.token import APIUserToken, OAuthToken
|
||||
|
||||
|
||||
class APITokenAdmin(admin.ModelAdmin):
|
||||
@@ -17,4 +17,17 @@ class APITokenAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class OAuthTokenAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"expires_on",
|
||||
]
|
||||
search_fields = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(APIUserToken, APITokenAdmin)
|
||||
admin.site.register(OAuthToken, OAuthTokenAdmin)
|
||||
|
||||
26
api/migrations/0003_oauthtoken.py
Normal file
26
api/migrations/0003_oauthtoken.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.4 on 2024-04-30 07:20
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0002_alter_apiusertoken_valid_until'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OAuthToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('access_token', models.CharField(db_comment='OAuth access token', max_length=255)),
|
||||
('refresh_token', models.CharField(db_comment='OAuth refresh token', max_length=255)),
|
||||
('expires_on', models.DateTimeField(db_comment='When the token will be expired')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,14 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
|
||||
from konova.models import UuidModel
|
||||
from konova.sub_settings.sso_settings import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, SSO_SERVER_BASE
|
||||
from konova.utils.generators import generate_token
|
||||
|
||||
|
||||
@@ -46,3 +53,105 @@ class APIUserToken(models.Model):
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionError("Credentials invalid")
|
||||
return token_obj.user
|
||||
|
||||
|
||||
class OAuthToken(UuidModel):
|
||||
access_token = models.CharField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
null=False,
|
||||
db_comment="OAuth access token"
|
||||
)
|
||||
refresh_token = models.CharField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
null=False,
|
||||
db_comment="OAuth refresh token"
|
||||
)
|
||||
expires_on = models.DateTimeField(
|
||||
db_comment="When the token will be expired"
|
||||
)
|
||||
|
||||
ASSUMED_LATENCY = 1000 # assumed latency between creation and receiving of an access token
|
||||
|
||||
def __str__(self):
|
||||
return str(self.access_token)
|
||||
|
||||
@staticmethod
|
||||
def from_access_token_response(access_token_data: str, received_on):
|
||||
"""
|
||||
Creates an OAuthToken based on retrieved access token data (OAuth2.0 specification)
|
||||
|
||||
Args:
|
||||
access_token_data (str): OAuth2.0 response data
|
||||
received_on (): Timestamp when the response has been received
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
oauth_token = OAuthToken()
|
||||
data = json.loads(access_token_data)
|
||||
|
||||
oauth_token.access_token = data.get("access_token")
|
||||
oauth_token.refresh_token = data.get("refresh_token")
|
||||
|
||||
expires_on = received_on + timedelta(
|
||||
seconds=(data.get("expires_in") + OAuthToken.ASSUMED_LATENCY)
|
||||
)
|
||||
oauth_token.expires_on = expires_on
|
||||
|
||||
return oauth_token
|
||||
|
||||
def refresh(self):
|
||||
url = f"{SSO_SERVER_BASE}o/token/"
|
||||
params = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"client_id": OAUTH_CLIENT_ID,
|
||||
"client_secret": OAUTH_CLIENT_SECRET
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
params
|
||||
)
|
||||
_now = now()
|
||||
is_response_invalid = response.status_code != 200
|
||||
if is_response_invalid:
|
||||
raise RuntimeError(f"Refreshing token not possible: {response.status_code}")
|
||||
|
||||
response_content = response.content.decode("utf-8")
|
||||
response_content = json.loads(response_content)
|
||||
|
||||
access_token = response_content.get("access_token")
|
||||
refresh_token = response_content.get("refresh_token")
|
||||
expires_in = response_content.get("expires")
|
||||
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
self.expires_in = expires_in
|
||||
self.save()
|
||||
|
||||
return self
|
||||
|
||||
def update_and_get_user(self):
|
||||
from user.models import User
|
||||
url = f"{SSO_SERVER_BASE}users/oauth/data/"
|
||||
|
||||
access_token = self.access_token
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
)
|
||||
|
||||
is_response_code_invalid = response.status_code != 200
|
||||
if is_response_code_invalid:
|
||||
raise RuntimeError(f"OAuth user data fetching unsuccessful: {response.status_code}")
|
||||
|
||||
response_content = response.content.decode("utf-8")
|
||||
response_content = json.loads(response_content)
|
||||
user = User.oauth_update_user(response_content)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from abc import abstractmethod
|
||||
from django.contrib.gis import geos
|
||||
from django.contrib.gis.geos import GEOSGeometry
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
|
||||
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
|
||||
from konova.utils.message_templates import DATA_UNSHARED
|
||||
@@ -32,8 +33,8 @@ class AbstractModelAPISerializer:
|
||||
self.lookup = {
|
||||
"id": None, # must be set
|
||||
"deleted__isnull": True,
|
||||
"users__in": [], # must be set
|
||||
}
|
||||
self.shared_lookup = Q() # must be set, so user or team based share will be used properly
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
@@ -76,7 +77,11 @@ class AbstractModelAPISerializer:
|
||||
else:
|
||||
# Return certain object
|
||||
self.lookup["id"] = _id
|
||||
self.lookup["users__in"] = [user]
|
||||
|
||||
self.shared_lookup = Q(
|
||||
Q(users__in=[user]) |
|
||||
Q(teams__in=list(user.shared_teams))
|
||||
)
|
||||
|
||||
def fetch_and_serialize(self):
|
||||
""" Serializes the model entry according to the given lookup data
|
||||
@@ -86,7 +91,13 @@ class AbstractModelAPISerializer:
|
||||
Returns:
|
||||
serialized_data (dict)
|
||||
"""
|
||||
entries = self.model.objects.filter(**self.lookup).order_by("id")
|
||||
entries = self.model.objects.filter(
|
||||
**self.lookup
|
||||
).filter(
|
||||
self.shared_lookup
|
||||
).order_by(
|
||||
"id"
|
||||
).distinct()
|
||||
self.paginator = Paginator(entries, self.rpp)
|
||||
requested_entries = self.paginator.page(self.page_number)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Created on: 24.01.22
|
||||
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin
|
||||
from compensation.models import Compensation
|
||||
@@ -21,8 +22,10 @@ class CompensationAPISerializerV1(AbstractModelAPISerializerV1, AbstractCompensa
|
||||
|
||||
def prepare_lookup(self, id, user):
|
||||
super().prepare_lookup(id, user)
|
||||
del self.lookup["users__in"]
|
||||
self.lookup["intervention__users__in"] = [user]
|
||||
self.shared_lookup = Q(
|
||||
Q(intervention__users__in=[user]) |
|
||||
Q(intervention__teams__in=user.shared_teams)
|
||||
)
|
||||
|
||||
def intervention_to_json(self, entry):
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ Created on: 28.01.22
|
||||
|
||||
"""
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
|
||||
from api.utils.serializer.v1.serializer import DeductableAPISerializerV1Mixin, AbstractModelAPISerializerV1
|
||||
from compensation.models import EcoAccountDeduction, EcoAccount
|
||||
@@ -28,9 +29,11 @@ class DeductionAPISerializerV1(AbstractModelAPISerializerV1,
|
||||
|
||||
"""
|
||||
super().prepare_lookup(_id, user)
|
||||
del self.lookup["users__in"]
|
||||
del self.lookup["deleted__isnull"]
|
||||
self.lookup["intervention__users__in"] = [user]
|
||||
self.shared_lookup = Q(
|
||||
Q(intervention__users__in=[user]) |
|
||||
Q(intervention__teams__in=user.shared_teams)
|
||||
)
|
||||
|
||||
def _model_to_geo_json(self, entry):
|
||||
""" Adds the basic data
|
||||
|
||||
@@ -23,11 +23,6 @@ class AbstractAPIViewV1(AbstractAPIView):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.lookup = {
|
||||
"id": None, # must be set in subclasses
|
||||
"deleted__isnull": True,
|
||||
"users__in": [], # must be set in subclasses
|
||||
}
|
||||
super().__init__(*args, **kwargs)
|
||||
self.serializer = self.serializer()
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ class CompensationActionAdmin(admin.ModelAdmin):
|
||||
|
||||
admin.site.register(Compensation, CompensationAdmin)
|
||||
admin.site.register(EcoAccount, EcoAccountAdmin)
|
||||
admin.site.register(EcoAccountDeduction, EcoAccountDeductionAdmin)
|
||||
#admin.site.register(EcoAccountDeduction, EcoAccountDeductionAdmin)
|
||||
|
||||
# For a more cleaner admin interface these rarely used admin views are not important for deployment
|
||||
#admin.site.register(Payment, PaymentAdmin)
|
||||
|
||||
@@ -213,7 +213,6 @@ class EditCompensationForm(NewCompensationForm):
|
||||
action = UserActionLogEntry.get_edited_action(user)
|
||||
|
||||
# Fetch data from cleaned POST values
|
||||
identifier = self.cleaned_data.get("identifier", None)
|
||||
title = self.cleaned_data.get("title", None)
|
||||
intervention = self.cleaned_data.get("intervention", None)
|
||||
is_cef = self.cleaned_data.get("is_cef", None)
|
||||
@@ -221,7 +220,6 @@ class EditCompensationForm(NewCompensationForm):
|
||||
is_pik = self.cleaned_data.get("is_pik", None)
|
||||
comment = self.cleaned_data.get("comment", None)
|
||||
|
||||
self.instance.identifier = identifier
|
||||
self.instance.title = title
|
||||
self.instance.intervention = intervention
|
||||
self.instance.is_cef = is_cef
|
||||
|
||||
@@ -192,7 +192,6 @@ class EditEcoAccountForm(NewEcoAccountForm):
|
||||
def save(self, user: User, geom_form: SimpleGeomForm):
|
||||
with transaction.atomic():
|
||||
# Fetch data from cleaned POST values
|
||||
identifier = self.cleaned_data.get("identifier", None)
|
||||
title = self.cleaned_data.get("title", None)
|
||||
registration_date = self.cleaned_data.get("registration_date", None)
|
||||
handler_type = self.cleaned_data.get("handler_type", None)
|
||||
@@ -219,7 +218,6 @@ class EditEcoAccountForm(NewEcoAccountForm):
|
||||
self.instance.legal.save()
|
||||
|
||||
# Update main oject data
|
||||
self.instance.identifier = identifier
|
||||
self.instance.title = title
|
||||
self.instance.deductable_surface = surface
|
||||
self.instance.comment = comment
|
||||
|
||||
@@ -315,7 +315,6 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin, PikMixin):
|
||||
def get_detail_url_absolute(self):
|
||||
return BASE_URL + self.get_detail_url()
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.identifier is None or len(self.identifier) == 0:
|
||||
# Create new identifier is none was given
|
||||
|
||||
@@ -125,10 +125,16 @@ class CompensationWorkflowTestCase(BaseWorkflowTestCase):
|
||||
self.compensation = self.fill_out_compensation(self.compensation)
|
||||
pre_edit_log_count = self.compensation.log.count()
|
||||
|
||||
self.assertTrue(self.compensation.is_shared_with(self.superuser))
|
||||
|
||||
old_identifier = self.compensation.identifier
|
||||
new_title = self.create_dummy_string()
|
||||
new_identifier = self.create_dummy_string()
|
||||
new_comment = self.create_dummy_string()
|
||||
new_geometry = MultiPolygon(srid=4326) # Create an empty geometry
|
||||
new_geometry = MultiPolygon(
|
||||
self.compensation.geometry.geom.buffer(10),
|
||||
srid=self.compensation.geometry.geom.srid
|
||||
) # Create a geometry which differs from the stored one
|
||||
geojson = self.create_geojson(new_geometry)
|
||||
|
||||
check_on_elements = {
|
||||
@@ -151,19 +157,21 @@ class CompensationWorkflowTestCase(BaseWorkflowTestCase):
|
||||
|
||||
check_on_elements = {
|
||||
self.compensation.title: new_title,
|
||||
self.compensation.identifier: new_identifier,
|
||||
self.compensation.comment: new_comment,
|
||||
}
|
||||
|
||||
for k, v in check_on_elements.items():
|
||||
self.assertEqual(k, v)
|
||||
|
||||
self.assert_equal_geometries(self.compensation.geometry.geom, new_geometry)
|
||||
# Expect identifier to not be editable
|
||||
self.assertEqual(self.compensation.identifier, old_identifier, msg="Identifier was editable!")
|
||||
|
||||
# Expect logs to be set
|
||||
self.assertEqual(pre_edit_log_count + 1, self.compensation.log.count())
|
||||
self.assertEqual(self.compensation.log.first().action, UserAction.EDITED)
|
||||
|
||||
self.assert_equal_geometries(self.compensation.geometry.geom, new_geometry)
|
||||
|
||||
def test_checkability(self):
|
||||
"""
|
||||
This tests if the checkability of the compensation (which is defined by the linked intervention's checked
|
||||
|
||||
@@ -82,6 +82,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
|
||||
url = reverse("compensation:acc:edit", args=(self.eco_account.id,))
|
||||
pre_edit_log_count = self.eco_account.log.count()
|
||||
|
||||
old_identifier = self.eco_account.identifier
|
||||
new_title = self.create_dummy_string()
|
||||
new_identifier = self.create_dummy_string()
|
||||
new_comment = self.create_dummy_string()
|
||||
@@ -114,7 +115,6 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
|
||||
|
||||
check_on_elements = {
|
||||
self.eco_account.title: new_title,
|
||||
self.eco_account.identifier: new_identifier,
|
||||
self.eco_account.deductable_surface: test_deductable_surface,
|
||||
self.eco_account.deductable_rest: test_deductable_surface - deductions_surface,
|
||||
self.eco_account.comment: new_comment,
|
||||
@@ -123,6 +123,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
|
||||
for k, v in check_on_elements.items():
|
||||
self.assertEqual(k, v)
|
||||
|
||||
self.assertEqual(self.eco_account.identifier, old_identifier)
|
||||
self.assert_equal_geometries(self.eco_account.geometry.geom, new_geometry)
|
||||
|
||||
# Expect logs to be set
|
||||
|
||||
@@ -19,7 +19,8 @@ from compensation.models import Compensation
|
||||
from compensation.tables.compensation import CompensationTable
|
||||
from intervention.models import Intervention
|
||||
from konova.contexts import BaseContext
|
||||
from konova.decorators import shared_access_required, default_group_required, any_group_check, login_required_modal
|
||||
from konova.decorators import shared_access_required, default_group_required, any_group_check, login_required_modal, \
|
||||
uuid_required
|
||||
from konova.forms import SimpleGeomForm
|
||||
from konova.forms.modals import RemoveModalForm
|
||||
from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP
|
||||
@@ -200,6 +201,7 @@ def edit_view(request: HttpRequest, id: str):
|
||||
|
||||
@login_required
|
||||
@any_group_check
|
||||
@uuid_required
|
||||
def detail_view(request: HttpRequest, id: str):
|
||||
""" Renders a detail view for a compensation
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from compensation.forms.eco_account import EditEcoAccountForm, NewEcoAccountForm
|
||||
from compensation.models import EcoAccount
|
||||
from compensation.tables.eco_account import EcoAccountTable
|
||||
from konova.contexts import BaseContext
|
||||
from konova.decorators import shared_access_required, default_group_required, any_group_check, login_required_modal
|
||||
from konova.decorators import shared_access_required, default_group_required, any_group_check, login_required_modal, \
|
||||
uuid_required
|
||||
from konova.forms import SimpleGeomForm
|
||||
from konova.settings import ETS_GROUP, DEFAULT_GROUP, ZB_GROUP
|
||||
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
|
||||
@@ -177,6 +178,7 @@ def edit_view(request: HttpRequest, id: str):
|
||||
|
||||
@login_required
|
||||
@any_group_check
|
||||
@uuid_required
|
||||
def detail_view(request: HttpRequest, id: str):
|
||||
""" Renders a detail view for a compensation
|
||||
|
||||
|
||||
@@ -16,4 +16,5 @@ class EmaAdmin(AbstractCompensationAdmin):
|
||||
"teams",
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(Ema, EmaAdmin)
|
||||
|
||||
@@ -133,7 +133,6 @@ class EditEmaForm(NewEmaForm):
|
||||
def save(self, user: User, geom_form: SimpleGeomForm):
|
||||
with transaction.atomic():
|
||||
# Fetch data from cleaned POST values
|
||||
identifier = self.cleaned_data.get("identifier", None)
|
||||
title = self.cleaned_data.get("title", None)
|
||||
handler_type = self.cleaned_data.get("handler_type", None)
|
||||
handler_detail = self.cleaned_data.get("handler_detail", None)
|
||||
@@ -154,7 +153,6 @@ class EditEmaForm(NewEmaForm):
|
||||
self.instance.responsible.save()
|
||||
|
||||
# Update main oject data
|
||||
self.instance.identifier = identifier
|
||||
self.instance.title = title
|
||||
self.instance.comment = comment
|
||||
self.instance.is_pik = is_pik
|
||||
|
||||
@@ -80,6 +80,7 @@ class EmaWorkflowTestCase(BaseWorkflowTestCase):
|
||||
self.ema = self.fill_out_ema(self.ema)
|
||||
pre_edit_log_count = self.ema.log.count()
|
||||
|
||||
old_identifier = self.ema.identifier
|
||||
new_title = self.create_dummy_string()
|
||||
new_identifier = self.create_dummy_string()
|
||||
new_comment = self.create_dummy_string()
|
||||
@@ -106,13 +107,13 @@ class EmaWorkflowTestCase(BaseWorkflowTestCase):
|
||||
|
||||
check_on_elements = {
|
||||
self.ema.title: new_title,
|
||||
self.ema.identifier: new_identifier,
|
||||
self.ema.comment: new_comment,
|
||||
}
|
||||
|
||||
for k, v in check_on_elements.items():
|
||||
self.assertEqual(k, v)
|
||||
|
||||
self.assertEqual(self.ema.identifier, old_identifier)
|
||||
self.assert_equal_geometries(self.ema.geometry.geom, new_geometry)
|
||||
|
||||
# Expect logs to be set
|
||||
|
||||
@@ -130,7 +130,7 @@ class EditEmaFormTestCase(BaseTestCase):
|
||||
self.assertIsNotNone(obj.responsible.handler)
|
||||
self.assertEqual(obj.responsible.conservation_office, data["conservation_office"])
|
||||
self.assertEqual(obj.responsible.conservation_file_number, data["conservation_file_number"])
|
||||
self.assertEqual(obj.identifier, data["identifier"])
|
||||
self.assertNotEqual(obj.identifier, data["identifier"], msg="Identifier editable via form!")
|
||||
self.assertEqual(obj.comment, data["comment"])
|
||||
|
||||
last_log = obj.log.first()
|
||||
|
||||
@@ -17,7 +17,8 @@ from ema.forms import NewEmaForm, EditEmaForm
|
||||
from ema.models import Ema
|
||||
from ema.tables import EmaTable
|
||||
from konova.contexts import BaseContext
|
||||
from konova.decorators import shared_access_required, conservation_office_group_required, login_required_modal
|
||||
from konova.decorators import shared_access_required, conservation_office_group_required, login_required_modal, \
|
||||
uuid_required
|
||||
from konova.forms import SimpleGeomForm
|
||||
from konova.forms.modals import RemoveModalForm
|
||||
from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP
|
||||
@@ -124,6 +125,7 @@ def new_id_view(request: HttpRequest):
|
||||
|
||||
|
||||
@login_required
|
||||
@uuid_required
|
||||
def detail_view(request: HttpRequest, id: str):
|
||||
""" Renders the detail view of an EMA
|
||||
|
||||
|
||||
@@ -345,7 +345,6 @@ class EditInterventionForm(NewInterventionForm):
|
||||
|
||||
"""
|
||||
with transaction.atomic():
|
||||
identifier = self.cleaned_data.get("identifier", None)
|
||||
title = self.cleaned_data.get("title", None)
|
||||
process_type = self.cleaned_data.get("type", None)
|
||||
laws = self.cleaned_data.get("laws", None)
|
||||
@@ -379,7 +378,6 @@ class EditInterventionForm(NewInterventionForm):
|
||||
|
||||
self.instance.log.add(user_action)
|
||||
|
||||
self.instance.identifier = identifier
|
||||
self.instance.title = title
|
||||
self.instance.comment = comment
|
||||
self.instance.modified = user_action
|
||||
|
||||
@@ -33,7 +33,7 @@ class CheckModalForm(BaseModalForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_title = _("Run check")
|
||||
self.form_caption = _("I, {} {}, confirm that all necessary control steps have been performed by myself.").format(self.user.first_name, self.user.last_name)
|
||||
self.form_caption = _("The necessary control steps have been performed:").format(self.user.first_name, self.user.last_name)
|
||||
self.valid = False
|
||||
|
||||
def _are_deductions_valid(self):
|
||||
|
||||
@@ -5,6 +5,8 @@ Contact: michel.peltriaux@sgdnord.rlp.de
|
||||
Created on: 30.11.20
|
||||
|
||||
"""
|
||||
from konova.sub_settings.django_settings import env
|
||||
|
||||
INTERVENTION_IDENTIFIER_LENGTH = 6
|
||||
INTERVENTION_IDENTIFIER_TEMPLATE = "EIV-{}"
|
||||
|
||||
@@ -14,7 +16,7 @@ INTERVENTION_LANIS_LAYER_NAME_UNRECORDED_OLD_ENTRY = "eiv_unrecorded_old_entries
|
||||
|
||||
# EGON connection settings via rabbitmq
|
||||
# NEEDED FOR BACKWARDS COMPATIBILITY
|
||||
EGON_RABBITMQ_HOST = "CHANGE_ME"
|
||||
EGON_RABBITMQ_PORT = "CHANGE_ME"
|
||||
EGON_RABBITMQ_USER = "CHANGE_ME"
|
||||
EGON_RABBITMQ_PW = "CHANGE_ME"
|
||||
EGON_RABBITMQ_HOST = env("EGON_RABBITMQ_HOST")
|
||||
EGON_RABBITMQ_PORT = env("EGON_RABBITMQ_PORT")
|
||||
EGON_RABBITMQ_USER = env("EGON_RABBITMQ_USER")
|
||||
EGON_RABBITMQ_PW = env("EGON_RABBITMQ_PW")
|
||||
|
||||
@@ -33,6 +33,11 @@ class InterventionTable(BaseTable, TableRenderMixin, TableOrderMixin):
|
||||
verbose_name=_("Parcel gmrkng"),
|
||||
orderable=False,
|
||||
accessor="geometry",
|
||||
attrs={
|
||||
"th": {
|
||||
"class": "w-25",
|
||||
}
|
||||
}
|
||||
)
|
||||
c = tables.Column(
|
||||
verbose_name=_("Checked"),
|
||||
|
||||
@@ -16,7 +16,8 @@ from intervention.forms.intervention import EditInterventionForm, NewInterventio
|
||||
from intervention.models import Intervention
|
||||
from intervention.tables import InterventionTable
|
||||
from konova.contexts import BaseContext
|
||||
from konova.decorators import default_group_required, shared_access_required, any_group_check, login_required_modal
|
||||
from konova.decorators import default_group_required, shared_access_required, any_group_check, login_required_modal, \
|
||||
uuid_required
|
||||
from konova.forms import SimpleGeomForm
|
||||
from konova.forms.modals import RemoveModalForm
|
||||
from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP
|
||||
@@ -39,7 +40,7 @@ def index_view(request: HttpRequest):
|
||||
"""
|
||||
template = "generic_index.html"
|
||||
|
||||
# Filtering by user access is performed in table filter inside of InterventionTableFilter class
|
||||
# Filtering by user access is performed in table filter inside InterventionTableFilter class
|
||||
interventions = Intervention.objects.filter(
|
||||
deleted=None, # not deleted
|
||||
).select_related(
|
||||
@@ -128,6 +129,7 @@ def new_id_view(request: HttpRequest):
|
||||
|
||||
@login_required
|
||||
@any_group_check
|
||||
@uuid_required
|
||||
def detail_view(request: HttpRequest, id: str):
|
||||
""" Renders a detail view for viewing an intervention's data
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from intervention.models import Intervention
|
||||
from konova.contexts import BaseContext
|
||||
from konova.decorators import uuid_required
|
||||
from konova.forms import SimpleGeomForm
|
||||
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
|
||||
from konova.utils.generators import generate_qr_code
|
||||
|
||||
|
||||
@uuid_required
|
||||
def report_view(request: HttpRequest, id: str):
|
||||
""" Renders the public report view
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from konova.sub_settings.django_settings import env
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'konova.settings')
|
||||
@@ -17,7 +18,7 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Declare redis as broker
|
||||
app.conf.broker_url = 'redis://localhost:6379/0'
|
||||
app.conf.broker_url = f'redis://{env("REDIS_HOST")}:{env.int("REDIS_PORT")}/0'
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
|
||||
@@ -7,9 +7,11 @@ Created on: 16.11.20
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from bootstrap_modal_forms.mixins import is_ajax
|
||||
from django.contrib import messages
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect, get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -171,3 +173,20 @@ def login_required_modal(function):
|
||||
return render(request, template, context)
|
||||
return function(request, *args, **kwargs)
|
||||
return wrap
|
||||
|
||||
|
||||
def uuid_required(function):
|
||||
"""
|
||||
Checks whether the given input is a valid UUID
|
||||
"""
|
||||
@wraps(function)
|
||||
def wrap(request, *args, **kwargs):
|
||||
uuid = kwargs.get("uuid", None) or kwargs.get("id", None)
|
||||
try:
|
||||
uuid = UUID(uuid)
|
||||
except ValueError:
|
||||
raise Http404(
|
||||
"Invalid UUID"
|
||||
)
|
||||
return function(request, *args, **kwargs)
|
||||
return wrap
|
||||
|
||||
@@ -98,12 +98,14 @@ class SimpleGeomForm(BaseForm):
|
||||
|
||||
if g.geom_type not in accepted_ogr_types:
|
||||
self.add_error("geom", _("Only surfaces allowed. Points or lines must be buffered."))
|
||||
is_valid = False
|
||||
is_valid &= False
|
||||
return is_valid
|
||||
|
||||
is_valid &= self.__is_area_valid(g)
|
||||
|
||||
polygon = Polygon.from_ewkt(g.ewkt)
|
||||
is_valid = polygon.valid
|
||||
if not is_valid:
|
||||
is_valid &= polygon.valid
|
||||
if not polygon.valid:
|
||||
self.add_error("geom", polygon.valid_reason)
|
||||
return is_valid
|
||||
|
||||
@@ -137,6 +139,24 @@ class SimpleGeomForm(BaseForm):
|
||||
|
||||
return num_vertices <= GEOM_MAX_VERTICES
|
||||
|
||||
def __is_area_valid(self, geom: gdal.OGRGeometry):
|
||||
""" Checks whether the area is at least > 1m²
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
is_area_valid = geom.area > 1 # > 1m² (SRID:25832)
|
||||
|
||||
if not is_area_valid:
|
||||
self.add_error(
|
||||
"geom",
|
||||
_("Geometry must be greater than 1m². Currently is {}m²").format(
|
||||
float(geom.area)
|
||||
)
|
||||
)
|
||||
|
||||
return is_area_valid
|
||||
|
||||
def __simplify_geometry(self, geom, max_vert: int):
|
||||
""" Simplifies a geometry
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class RecordModalForm(BaseModalForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_title = _("Record data")
|
||||
self.form_caption = _("I, {} {}, confirm that all necessary control steps have been performed by myself.").format(self.user.first_name, self.user.last_name)
|
||||
self.form_caption = _("The necessary control steps have been performed:").format(self.user.first_name, self.user.last_name)
|
||||
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
|
||||
self.fields["confirm"].widget.attrs["class"] = ""
|
||||
|
||||
|
||||
@@ -61,14 +61,24 @@ class Command(BaseKonovaCommand):
|
||||
action=UserAction.CREATED
|
||||
)
|
||||
|
||||
intervention_log_entries_ids = self.get_all_log_entries_ids(Intervention)
|
||||
attached_log_entries_id = intervention_log_entries_ids.union(
|
||||
self.get_all_log_entries_ids(Compensation),
|
||||
self.get_all_log_entries_ids(EcoAccount),
|
||||
self.get_all_log_entries_ids(Ema),
|
||||
)
|
||||
EIV_log_entries_ids = self.get_all_log_entries_ids(Intervention)
|
||||
self._write_warning(f" EIV: {EIV_log_entries_ids.count()} attached log entries")
|
||||
KOM_log_entries_ids = self.get_all_log_entries_ids(Compensation)
|
||||
self._write_warning(f" KOM: {KOM_log_entries_ids.count()} attached log entries")
|
||||
OEK_log_entries_ids = self.get_all_log_entries_ids(EcoAccount)
|
||||
self._write_warning(f" OEK: {OEK_log_entries_ids.count()} attached log entries")
|
||||
EMA_log_entries_ids = self.get_all_log_entries_ids(Ema)
|
||||
self._write_warning(f" EMA: {EMA_log_entries_ids.count()} attached log entries")
|
||||
|
||||
unattached_log_entries = all_log_entries.exclude(id__in=attached_log_entries_id)
|
||||
unattached_log_entries = all_log_entries.exclude(
|
||||
id__in=EIV_log_entries_ids
|
||||
).exclude(
|
||||
id__in=KOM_log_entries_ids
|
||||
).exclude(
|
||||
id__in=OEK_log_entries_ids
|
||||
).exclude(
|
||||
id__in=EMA_log_entries_ids
|
||||
)
|
||||
|
||||
num_entries = unattached_log_entries.count()
|
||||
if num_entries > 0:
|
||||
@@ -108,13 +118,20 @@ class Command(BaseKonovaCommand):
|
||||
self._write_warning("=== Sanitize compensation actions ===")
|
||||
all_actions = CompensationAction.objects.all()
|
||||
|
||||
compensation_action_ids = self.get_all_action_ids(Compensation)
|
||||
attached_action_ids = compensation_action_ids.union(
|
||||
self.get_all_action_ids(EcoAccount),
|
||||
self.get_all_action_ids(Ema),
|
||||
)
|
||||
kom_action_ids = self.get_all_action_ids(Compensation)
|
||||
self._write_warning(f" KOM: {kom_action_ids.count()} attached actions")
|
||||
oek_action_ids = self.get_all_action_ids(EcoAccount)
|
||||
self._write_warning(f" OEK: {oek_action_ids.count()} attached actions")
|
||||
ema_action_ids = self.get_all_action_ids(Ema)
|
||||
self._write_warning(f" EMA: {ema_action_ids.count()} attached actions")
|
||||
|
||||
unattached_actions = all_actions.exclude(id__in=attached_action_ids)
|
||||
unattached_actions = all_actions.exclude(
|
||||
id__in=kom_action_ids
|
||||
).exclude(
|
||||
id__in=oek_action_ids
|
||||
).exclude(
|
||||
id__in=ema_action_ids
|
||||
)
|
||||
|
||||
num_entries = unattached_actions.count()
|
||||
if num_entries > 0:
|
||||
@@ -125,7 +142,7 @@ class Command(BaseKonovaCommand):
|
||||
self._write_success("No unattached actions found.")
|
||||
self._break_line()
|
||||
|
||||
def get_all_deadline_ids(self, cls):
|
||||
def _get_all_deadline_ids(self, cls):
|
||||
""" Getter for all deadline ids of a model
|
||||
|
||||
Args:
|
||||
@@ -154,13 +171,20 @@ class Command(BaseKonovaCommand):
|
||||
self._write_warning("=== Sanitize deadlines ===")
|
||||
all_deadlines = Deadline.objects.all()
|
||||
|
||||
compensation_deadline_ids = self.get_all_deadline_ids(Compensation)
|
||||
attached_deadline_ids = compensation_deadline_ids.union(
|
||||
self.get_all_deadline_ids(EcoAccount),
|
||||
self.get_all_deadline_ids(Ema),
|
||||
)
|
||||
kom_deadline_ids = self._get_all_deadline_ids(Compensation)
|
||||
self._write_warning(f" KOM: {kom_deadline_ids.count()} attached deadlines")
|
||||
oek_deadline_ids = self._get_all_deadline_ids(EcoAccount)
|
||||
self._write_warning(f" OEK: {kom_deadline_ids.count()} attached deadlines")
|
||||
ema_deadline_ids = self._get_all_deadline_ids(Ema)
|
||||
self._write_warning(f" EMA: {kom_deadline_ids.count()} attached deadlines")
|
||||
|
||||
unattached_deadlines = all_deadlines.exclude(id__in=attached_deadline_ids)
|
||||
unattached_deadlines = all_deadlines.exclude(
|
||||
id__in=kom_deadline_ids
|
||||
).exclude(
|
||||
id__in=oek_deadline_ids
|
||||
).exclude(
|
||||
id__in=ema_deadline_ids
|
||||
)
|
||||
|
||||
num_entries = unattached_deadlines.count()
|
||||
if num_entries > 0:
|
||||
@@ -171,7 +195,7 @@ class Command(BaseKonovaCommand):
|
||||
self._write_success("No unattached deadlines found.")
|
||||
self._break_line()
|
||||
|
||||
def get_all_geometry_ids(self, cls):
|
||||
def _get_all_geometry_ids(self, cls):
|
||||
""" Getter for all geometry ids of a model
|
||||
|
||||
Args:
|
||||
@@ -200,14 +224,24 @@ class Command(BaseKonovaCommand):
|
||||
self._write_warning("=== Sanitize geometries ===")
|
||||
all_geometries = Geometry.objects.all()
|
||||
|
||||
compensation_geometry_ids = self.get_all_geometry_ids(Compensation)
|
||||
attached_geometry_ids = compensation_geometry_ids.union(
|
||||
self.get_all_geometry_ids(Intervention),
|
||||
self.get_all_geometry_ids(EcoAccount),
|
||||
self.get_all_geometry_ids(Ema),
|
||||
)
|
||||
kom_geometry_ids = self._get_all_geometry_ids(Compensation)
|
||||
self._write_warning(f" KOM: {kom_geometry_ids.count()} attached geometries")
|
||||
eiv_geometry_ids = self._get_all_geometry_ids(Intervention)
|
||||
self._write_warning(f" EIV: {eiv_geometry_ids.count()} attached geometries")
|
||||
oek_geometry_ids = self._get_all_geometry_ids(EcoAccount)
|
||||
self._write_warning(f" OEK: {oek_geometry_ids.count()} attached geometries")
|
||||
ema_geometry_ids = self._get_all_geometry_ids(Ema)
|
||||
self._write_warning(f" EMA: {ema_geometry_ids.count()} attached geometries")
|
||||
|
||||
unattached_geometries = all_geometries.exclude(id__in=attached_geometry_ids)
|
||||
unattached_geometries = all_geometries.exclude(
|
||||
id__in=kom_geometry_ids
|
||||
).exclude(
|
||||
id__in=eiv_geometry_ids
|
||||
).exclude(
|
||||
id__in=oek_geometry_ids
|
||||
).exclude(
|
||||
id__in=ema_geometry_ids
|
||||
)
|
||||
|
||||
num_entries = unattached_geometries.count()
|
||||
if num_entries > 0:
|
||||
@@ -218,7 +252,7 @@ class Command(BaseKonovaCommand):
|
||||
self._write_success("No unattached geometries found.")
|
||||
self._break_line()
|
||||
|
||||
def get_all_state_ids(self, cls):
|
||||
def _get_all_state_ids(self, cls):
|
||||
""" Getter for all states (before and after) of a class
|
||||
|
||||
Args:
|
||||
@@ -254,14 +288,19 @@ class Command(BaseKonovaCommand):
|
||||
"""
|
||||
self._write_warning("=== Sanitize compensation states ===")
|
||||
all_states = CompensationState.objects.all()
|
||||
compensation_state_ids = self.get_all_state_ids(Compensation)
|
||||
account_state_ids = self.get_all_state_ids(EcoAccount)
|
||||
ema_state_ids = self.get_all_state_ids(Ema)
|
||||
attached_state_ids = compensation_state_ids.union(account_state_ids, ema_state_ids)
|
||||
|
||||
kom_state_ids = self._get_all_state_ids(Compensation)
|
||||
oek_state_ids = self._get_all_state_ids(EcoAccount)
|
||||
ema_state_ids = self._get_all_state_ids(Ema)
|
||||
|
||||
unattached_states = all_states.exclude(
|
||||
id__in=attached_state_ids
|
||||
id__in=kom_state_ids
|
||||
).exclude(
|
||||
id__in=oek_state_ids
|
||||
).exclude(
|
||||
id__in=ema_state_ids
|
||||
)
|
||||
|
||||
num_unattached_states = unattached_states.count()
|
||||
if num_unattached_states > 0:
|
||||
self._write_error(f"Found {num_unattached_states} unused compensation states. Delete now...")
|
||||
|
||||
54
konova/management/commands/send_to_egon.py
Normal file
54
konova/management/commands/send_to_egon.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: ksp-servicestelle@sgdnord.rlp.de
|
||||
Created on: 18.06.24
|
||||
|
||||
"""
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from intervention.models import Intervention
|
||||
from konova.management.commands.setup import BaseKonovaCommand
|
||||
|
||||
|
||||
class Command(BaseKonovaCommand):
|
||||
help = "Send specific intervention entries to EGON if there are any payments on them"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
try:
|
||||
parser.add_argument("--intervention-ids", type=str)
|
||||
except ValueError as e:
|
||||
self._write_error(f"Argument error: {e}")
|
||||
exit(-1)
|
||||
|
||||
def __handle_arguments(self, options):
|
||||
self.intervention_ids = options["intervention_ids"] or ""
|
||||
self.intervention_ids = self.intervention_ids.split(",")
|
||||
self.intervention_ids = [x.strip() for x in self.intervention_ids]
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
self.__handle_arguments(options)
|
||||
interventions = self.get_interventions()
|
||||
self.process_egon_sending(interventions)
|
||||
except KeyboardInterrupt:
|
||||
self._break_line()
|
||||
exit(-1)
|
||||
|
||||
def get_interventions(self) -> QuerySet:
|
||||
"""
|
||||
Getter for interventions, defined by parameter 'intervention-ids'
|
||||
|
||||
Returns:
|
||||
interventions (QuerySet): The interventions
|
||||
"""
|
||||
interventions = Intervention.objects.filter(
|
||||
id__in=self.intervention_ids,
|
||||
)
|
||||
self._write_success(f"... Found {interventions.count()} interventions")
|
||||
return interventions
|
||||
|
||||
def process_egon_sending(self, interventions: QuerySet):
|
||||
for intervention in interventions:
|
||||
intervention.send_data_to_egon()
|
||||
self._write_warning(f"... {intervention.identifier} has been sent to EGON (if it has payments)")
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.1 on 2024-01-09 10:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('konova', '0014_resubmission'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='geometry',
|
||||
name='parcel_update_end',
|
||||
field=models.DateTimeField(blank=True, db_comment='When the last parcel calculation finished', help_text='When the last parcel calculation finished', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='geometry',
|
||||
name='parcel_update_start',
|
||||
field=models.DateTimeField(blank=True, db_comment='When the last parcel calculation started', help_text='When the last parcel calculation started', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.0.1 on 2024-02-16 07:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('konova', '0015_geometry_parcel_calculation_end_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='parcelintersection',
|
||||
name='calculated_on',
|
||||
),
|
||||
]
|
||||
@@ -8,19 +8,31 @@ Created on: 15.11.21
|
||||
import json
|
||||
|
||||
from django.contrib.gis.db.models import MultiPolygonField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from konova.models import BaseResource, UuidModel
|
||||
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
|
||||
from konova.utils.schneider.fetcher import ParcelFetcher
|
||||
from konova.utils.wfs.spatial import ParcelWFSFetcher
|
||||
|
||||
|
||||
class Geometry(BaseResource):
|
||||
"""
|
||||
Geometry model
|
||||
"""
|
||||
parcel_update_start = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_comment="When the last parcel calculation started",
|
||||
help_text="When the last parcel calculation started"
|
||||
)
|
||||
parcel_update_end = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_comment="When the last parcel calculation finished",
|
||||
help_text="When the last parcel calculation finished",
|
||||
)
|
||||
geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID_RLP)
|
||||
|
||||
def __str__(self):
|
||||
@@ -109,82 +121,14 @@ class Geometry(BaseResource):
|
||||
objs += set_objs
|
||||
return objs
|
||||
|
||||
@transaction.atomic
|
||||
def update_parcels_wfs(self):
|
||||
""" Updates underlying parcel information using the WFS of LVermGeo
|
||||
|
||||
Returns:
|
||||
|
||||
def get_data_object(self):
|
||||
"""
|
||||
from konova.models import Parcel, District, ParcelIntersection, Municipal, ParcelGroup
|
||||
|
||||
if self.geom.empty:
|
||||
# Nothing to do
|
||||
return
|
||||
|
||||
parcel_fetcher = ParcelWFSFetcher(
|
||||
geometry_id=self.id,
|
||||
)
|
||||
typename = "ave:Flurstueck"
|
||||
fetched_parcels = parcel_fetcher.get_features(
|
||||
typename
|
||||
)
|
||||
_now = timezone.now()
|
||||
underlying_parcels = []
|
||||
for result in fetched_parcels:
|
||||
parcel_properties = result["properties"]
|
||||
# There could be parcels which include the word 'Flur',
|
||||
# which needs to be deleted and just keep the numerical values
|
||||
## THIS CAN BE REMOVED IN THE FUTURE, WHEN 'Flur' WON'T OCCUR ANYMORE!
|
||||
flr_val = parcel_properties["flur"].replace("Flur ", "")
|
||||
district = District.objects.get_or_create(
|
||||
key=parcel_properties["kreisschl"],
|
||||
name=parcel_properties["kreis"],
|
||||
)[0]
|
||||
municipal = Municipal.objects.get_or_create(
|
||||
key=parcel_properties["gmdschl"],
|
||||
name=parcel_properties["gemeinde"],
|
||||
district=district,
|
||||
)[0]
|
||||
parcel_group = ParcelGroup.objects.get_or_create(
|
||||
key=parcel_properties["gemaschl"],
|
||||
name=parcel_properties["gemarkung"],
|
||||
municipal=municipal,
|
||||
)[0]
|
||||
flrstck_nnr = parcel_properties['flstnrnen']
|
||||
if not flrstck_nnr:
|
||||
flrstck_nnr = None
|
||||
flrstck_zhlr = parcel_properties['flstnrzae']
|
||||
if not flrstck_zhlr:
|
||||
flrstck_zhlr = None
|
||||
parcel_obj = Parcel.objects.get_or_create(
|
||||
district=district,
|
||||
municipal=municipal,
|
||||
parcel_group=parcel_group,
|
||||
flr=flr_val,
|
||||
flrstck_nnr=flrstck_nnr,
|
||||
flrstck_zhlr=flrstck_zhlr,
|
||||
)[0]
|
||||
parcel_obj.district = district
|
||||
parcel_obj.updated_on = _now
|
||||
parcel_obj.save()
|
||||
underlying_parcels.append(parcel_obj)
|
||||
|
||||
# Update the linked parcels
|
||||
self.parcels.clear()
|
||||
self.parcels.set(underlying_parcels)
|
||||
|
||||
# Set the calculated_on intermediate field, so this related data will be found on lookups
|
||||
intersections_without_ts = self.parcelintersection_set.filter(
|
||||
parcel__in=self.parcels.all(),
|
||||
calculated_on__isnull=True,
|
||||
)
|
||||
for entry in intersections_without_ts:
|
||||
entry.calculated_on = _now
|
||||
ParcelIntersection.objects.bulk_update(
|
||||
intersections_without_ts,
|
||||
["calculated_on"]
|
||||
)
|
||||
Getter for the specific data object which is related to this geometry
|
||||
"""
|
||||
objs = self.get_data_objects()
|
||||
assert (len(objs) <= 1)
|
||||
result = objs.pop()
|
||||
return result
|
||||
|
||||
def update_parcels(self):
|
||||
""" Updates underlying parcel information
|
||||
@@ -192,72 +136,141 @@ class Geometry(BaseResource):
|
||||
Returns:
|
||||
|
||||
"""
|
||||
from konova.models import Parcel, District, ParcelIntersection, Municipal, ParcelGroup
|
||||
|
||||
if self.geom.empty:
|
||||
# Nothing to do
|
||||
return
|
||||
|
||||
self._set_parcel_update_start_time()
|
||||
self._perform_parcel_update()
|
||||
self._set_parcel_update_end_time()
|
||||
|
||||
def _perform_parcel_update(self):
|
||||
"""
|
||||
Performs the main logic of parcel updating.
|
||||
"""
|
||||
from konova.models import Parcel, District, Municipal, ParcelGroup
|
||||
|
||||
parcel_fetcher = ParcelFetcher(
|
||||
geometry=self
|
||||
)
|
||||
fetched_parcels = parcel_fetcher.get_parcels()
|
||||
|
||||
_now = timezone.now()
|
||||
underlying_parcels = []
|
||||
|
||||
districts = {}
|
||||
municipals = {}
|
||||
parcel_groups = {}
|
||||
|
||||
parcels_to_update = []
|
||||
parcels_to_create = []
|
||||
for result in fetched_parcels:
|
||||
# There could be parcels which include the word 'Flur',
|
||||
# which needs to be deleted and just keep the numerical values
|
||||
## THIS CAN BE REMOVED IN THE FUTURE, WHEN 'Flur' WON'T OCCUR ANYMORE!
|
||||
flr_val = result["flur"].replace("Flur ", "")
|
||||
district = District.objects.get_or_create(
|
||||
key=result["kreisschl"],
|
||||
name=result["kreis"],
|
||||
)[0]
|
||||
municipal = Municipal.objects.get_or_create(
|
||||
key=result["gmdschl"],
|
||||
name=result["gemeinde"],
|
||||
district=district,
|
||||
)[0]
|
||||
parcel_group = ParcelGroup.objects.get_or_create(
|
||||
key=result["gemaschl"],
|
||||
name=result["gemarkung"],
|
||||
municipal=municipal,
|
||||
)[0]
|
||||
flrstck_nnr = result['flstnrnen']
|
||||
if not flrstck_nnr:
|
||||
flrstck_nnr = None
|
||||
flrstck_zhlr = result['flstnrzae']
|
||||
if not flrstck_zhlr:
|
||||
flrstck_zhlr = None
|
||||
parcel_obj = Parcel.objects.get_or_create(
|
||||
district=district,
|
||||
municipal=municipal,
|
||||
parcel_group=parcel_group,
|
||||
flr=flr_val,
|
||||
flrstck_nnr=flrstck_nnr,
|
||||
flrstck_zhlr=flrstck_zhlr,
|
||||
)[0]
|
||||
parcel_obj.district = district
|
||||
parcel_obj.updated_on = _now
|
||||
parcel_obj.save()
|
||||
underlying_parcels.append(parcel_obj)
|
||||
|
||||
# Update the linked parcels
|
||||
self.parcels.clear()
|
||||
# Get district (cache in dict)
|
||||
try:
|
||||
district = districts["kreisschl"]
|
||||
except KeyError:
|
||||
district = District.objects.get_or_create(
|
||||
key=result["kreisschl"],
|
||||
name=result["kreis"],
|
||||
)[0]
|
||||
districts[district.key] = district
|
||||
|
||||
# Get municipal (cache in dict)
|
||||
try:
|
||||
municipal = municipals["gmdschl"]
|
||||
except KeyError:
|
||||
municipal = Municipal.objects.get_or_create(
|
||||
key=result["gmdschl"],
|
||||
name=result["gemeinde"],
|
||||
district=district,
|
||||
)[0]
|
||||
municipals[municipal.key] = municipal
|
||||
|
||||
# Get parcel group (cache in dict)
|
||||
try:
|
||||
parcel_group = parcel_groups["gemaschl"]
|
||||
except KeyError:
|
||||
parcel_group = ParcelGroup.objects.get_or_create(
|
||||
key=result["gemaschl"],
|
||||
name=result["gemarkung"],
|
||||
municipal=municipal,
|
||||
)[0]
|
||||
parcel_groups[parcel_group.key] = parcel_group
|
||||
|
||||
# Preprocess parcel data
|
||||
flrstck_nnr = result['flstnrnen']
|
||||
match flrstck_nnr:
|
||||
case "":
|
||||
flrstck_nnr = None
|
||||
|
||||
flrstck_zhlr = result['flstnrzae']
|
||||
match flrstck_zhlr:
|
||||
case "":
|
||||
flrstck_zhlr = None
|
||||
|
||||
try:
|
||||
# Try to fetch parcel from db. If it already exists, just update timestamp.
|
||||
parcel_obj = Parcel.objects.get(
|
||||
district=district,
|
||||
municipal=municipal,
|
||||
parcel_group=parcel_group,
|
||||
flr=flr_val,
|
||||
flrstck_nnr=flrstck_nnr,
|
||||
flrstck_zhlr=flrstck_zhlr,
|
||||
)
|
||||
parcel_obj.updated_on = _now
|
||||
parcels_to_update.append(parcel_obj)
|
||||
except ObjectDoesNotExist:
|
||||
# If not existing, create object but do not commit, yet
|
||||
parcel_obj = Parcel(
|
||||
district=district,
|
||||
municipal=municipal,
|
||||
parcel_group=parcel_group,
|
||||
flr=flr_val,
|
||||
flrstck_nnr=flrstck_nnr,
|
||||
flrstck_zhlr=flrstck_zhlr,
|
||||
updated_on=_now,
|
||||
)
|
||||
parcels_to_create.append(parcel_obj)
|
||||
|
||||
# Create new parcels
|
||||
Parcel.objects.bulk_create(
|
||||
parcels_to_create,
|
||||
batch_size=500
|
||||
)
|
||||
# Update existing parcels
|
||||
Parcel.objects.bulk_update(
|
||||
parcels_to_update,
|
||||
[
|
||||
"updated_on"
|
||||
],
|
||||
batch_size=500
|
||||
)
|
||||
|
||||
# Update linking to geometry
|
||||
parcel_ids = [x.id for x in parcels_to_update] + [x.id for x in parcels_to_create]
|
||||
underlying_parcels = Parcel.objects.filter(id__in=parcel_ids)
|
||||
self.parcels.set(underlying_parcels)
|
||||
|
||||
# Set the calculated_on intermediate field, so this related data will be found on lookups
|
||||
intersections_without_ts = self.parcelintersection_set.filter(
|
||||
parcel__in=self.parcels.all(),
|
||||
calculated_on__isnull=True,
|
||||
)
|
||||
for entry in intersections_without_ts:
|
||||
entry.calculated_on = _now
|
||||
ParcelIntersection.objects.bulk_update(
|
||||
intersections_without_ts,
|
||||
["calculated_on"]
|
||||
)
|
||||
@transaction.atomic
|
||||
def _set_parcel_update_start_time(self):
|
||||
"""
|
||||
Sets the current time for the parcel calculation begin
|
||||
"""
|
||||
self.parcel_update_start = timezone.now()
|
||||
self.parcel_update_end = None
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def _set_parcel_update_end_time(self):
|
||||
"""
|
||||
Sets the current time for the parcel calculation end
|
||||
"""
|
||||
self.parcel_update_end = timezone.now()
|
||||
self.save()
|
||||
|
||||
def get_underlying_parcels(self):
|
||||
""" Getter for related parcels and their districts
|
||||
@@ -265,9 +278,7 @@ class Geometry(BaseResource):
|
||||
Returns:
|
||||
parcels (QuerySet): The related parcels as queryset
|
||||
"""
|
||||
parcels = self.parcels.filter(
|
||||
parcelintersection__calculated_on__isnull=False,
|
||||
).prefetch_related(
|
||||
parcels = self.parcels.prefetch_related(
|
||||
"district",
|
||||
"municipal",
|
||||
).order_by(
|
||||
@@ -292,17 +303,6 @@ class Geometry(BaseResource):
|
||||
municipals = Municipal.objects.filter(id__in=municipals).order_by("name")
|
||||
return municipals
|
||||
|
||||
def count_underlying_parcels(self):
|
||||
""" Getter for number of underlying parcels
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
num_parcels = self.parcels.filter(
|
||||
parcelintersection__calculated_on__isnull=False,
|
||||
).count()
|
||||
return num_parcels
|
||||
|
||||
def as_feature_collection(self, srid=DEFAULT_SRID_RLP):
|
||||
""" Returns a FeatureCollection structure holding all polygons of the MultiPolygon as single features
|
||||
|
||||
@@ -337,6 +337,42 @@ class Geometry(BaseResource):
|
||||
}
|
||||
return geojson
|
||||
|
||||
@property
|
||||
def complexity_factor(self) -> float:
|
||||
""" Calculates a factor to estimate the complexity of a Geometry
|
||||
|
||||
0 = very low complexity
|
||||
1 = very high complexity
|
||||
|
||||
ASSUMPTION:
|
||||
The envelope is the bounding box of a geometry. If the geometry's area is similar to the area of it's bounding
|
||||
box, it is considered as rather simple, since it seems to be a closer shape like a simple box.
|
||||
If the geometry has a very big bounding box, but the geometry's own area is rather small,
|
||||
compared to the one of the bounding box, the complexity can be higher.
|
||||
|
||||
Example:
|
||||
geometry area similar to bounding box --> geometry / bounding_box ~ 1
|
||||
geometry area far smaller than bb --> geometry / bounding_box ~ 0
|
||||
|
||||
Result is being inverted for better understanding of 'low' and 'high' complexity.
|
||||
|
||||
Returns:
|
||||
complexity_factor (float): The estimated complexity
|
||||
"""
|
||||
if self.geom.empty:
|
||||
return 0
|
||||
|
||||
geom_envelope = self.geom.envelope
|
||||
diff = geom_envelope - self.geom
|
||||
|
||||
if diff.area == 0:
|
||||
ratio = 1
|
||||
else:
|
||||
ratio = self.geom.area / diff.area
|
||||
|
||||
complexity_factor = 1 - ratio
|
||||
return complexity_factor
|
||||
|
||||
|
||||
class GeometryConflict(UuidModel):
|
||||
"""
|
||||
|
||||
@@ -672,17 +672,6 @@ class GeoReferencedMixin(models.Model):
|
||||
result = self.geometry.get_underlying_parcels()
|
||||
return result
|
||||
|
||||
def count_underlying_parcels(self):
|
||||
""" Getter for number of underlying parcels
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
result = 0
|
||||
if self.geometry is not None:
|
||||
result = self.geometry.count_underlying_parcels()
|
||||
return result
|
||||
|
||||
def set_geometry_conflict_message(self, request: HttpRequest):
|
||||
if self.geometry is None:
|
||||
return request
|
||||
|
||||
@@ -160,19 +160,9 @@ class Parcel(UuidModel):
|
||||
|
||||
|
||||
class ParcelIntersection(UuidModel):
|
||||
""" ParcelIntersection is an intermediary model, which is used to configure the
|
||||
"""
|
||||
ParcelIntersection is an intermediary model, which is used to add extras to the
|
||||
M2M relation between Parcel and Geometry.
|
||||
|
||||
Based on uuids, we will not have (practically) any problems on outrunning primary keys
|
||||
and extending the model with calculated_on timestamp, we can 'hide' entries while they
|
||||
are being recalculated and keep track on the last time they have been calculated this
|
||||
way.
|
||||
|
||||
Please note: The calculated_on describes when the relation between the Parcel and the Geometry
|
||||
has been established. The updated_on field of Parcel describes when this Parcel has been
|
||||
changed the last time.
|
||||
|
||||
"""
|
||||
parcel = models.ForeignKey(Parcel, on_delete=models.CASCADE)
|
||||
geometry = models.ForeignKey("konova.Geometry", on_delete=models.CASCADE)
|
||||
calculated_on = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
|
||||
@@ -18,7 +18,6 @@ from konova.sub_settings.proxy_settings import *
|
||||
from konova.sub_settings.sso_settings import *
|
||||
from konova.sub_settings.table_settings import *
|
||||
from konova.sub_settings.lanis_settings import *
|
||||
from konova.sub_settings.wfs_parcel_settings import *
|
||||
from konova.sub_settings.logging_settings import *
|
||||
|
||||
# Max upload size for POST forms
|
||||
@@ -46,4 +45,8 @@ DEFAULT_GROUP = "Default"
|
||||
ZB_GROUP = "Registration office"
|
||||
ETS_GROUP = "Conservation office"
|
||||
|
||||
# GEOMETRY
|
||||
## Max number of allowed vertices. Geometries larger will be simplified until they reach this threshold
|
||||
GEOM_MAX_VERTICES = 10000
|
||||
## Max seconds to wait for a parcel calculation result before a new request will be started (default: 30 minutes)
|
||||
GEOM_THRESHOLD_RECALCULATION_SECONDS = 60 * 30
|
||||
|
||||
@@ -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 django.http import HttpResponse
|
||||
from django.urls import re_path
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from itsdangerous import TimedSerializer
|
||||
from simple_sso.sso_client.client import Client
|
||||
|
||||
from user.models import User
|
||||
|
||||
|
||||
class PropagateView(View):
|
||||
""" View used to receive propagated sso-server user data
|
||||
|
||||
"""
|
||||
client = None
|
||||
signer = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.signer = TimedSerializer(self.client.private_key)
|
||||
|
||||
@csrf_exempt
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request):
|
||||
user_data = request.body
|
||||
user_data = self.signer.loads(user_data)
|
||||
self.client.build_user(user_data)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
class KonovaSSOClient(Client):
|
||||
""" Konova specialized derivative of general sso.Client.
|
||||
|
||||
Adds some custom behaviour for konova usage.
|
||||
|
||||
"""
|
||||
propagate_view = PropagateView
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
urls += re_path(r'^propagate/$', self.propagate_view.as_view(client=self), name='simple-sso-propagate'),
|
||||
return urls
|
||||
|
||||
def build_user(self, user_data):
|
||||
""" Creates a user or updates user data
|
||||
|
||||
Args:
|
||||
user_data ():
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(username=user_data['username'])
|
||||
# Update user data, excluding some changes
|
||||
skipable_attrs = {
|
||||
"username",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
}
|
||||
for _attr, _val in user_data.items():
|
||||
if _attr in skipable_attrs:
|
||||
continue
|
||||
setattr(user, _attr, _val)
|
||||
except User.DoesNotExist:
|
||||
user = User(**user_data)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
return user
|
||||
@@ -275,4 +275,7 @@ Similar to bootstraps 'shadow-lg'
|
||||
}
|
||||
.tree-label.badge{
|
||||
font-size: 90%;
|
||||
}
|
||||
.alert{
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
import os
|
||||
|
||||
import environ
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf.locale.de import formats as de_formats
|
||||
|
||||
@@ -24,32 +26,28 @@ BASE_DIR = os.path.dirname(
|
||||
)
|
||||
)
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||
env = environ.Env()
|
||||
# Take environment variables from .env.dev file
|
||||
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = '5=9-)2)h$u9=!zrhia9=lj-2#cpcb8=#$7y+)l$5tto$3q(n_+'
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
|
||||
ADMINS = [
|
||||
('KSP-Servicestelle', 'ksp-servicestelle@sgdnord.rlp.de'),
|
||||
]
|
||||
ADMINS = [x.split(':') for x in env.list('ADMINS')]
|
||||
|
||||
BASE_URL = "http://localhost:8001"
|
||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
]
|
||||
BASE_URL = env("BASE_URL")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost", # not only host but schema (http/s) as well!
|
||||
BASE_URL
|
||||
]
|
||||
|
||||
# Authentication settings
|
||||
LOGIN_URL = "/login/"
|
||||
LOGIN_URL = "/oauth/login/"
|
||||
|
||||
# Session settings
|
||||
SESSION_COOKIE_AGE = 60 * 60 # 60 minutes
|
||||
@@ -83,10 +81,6 @@ INSTALLED_APPS = [
|
||||
'analysis',
|
||||
'api',
|
||||
]
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += [
|
||||
'debug_toolbar',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
@@ -98,10 +92,6 @@ MIDDLEWARE = [
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
if DEBUG:
|
||||
MIDDLEWARE += [
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'konova.urls'
|
||||
|
||||
@@ -131,10 +121,11 @@ WSGI_APPLICATION = 'konova.wsgi.application'
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.contrib.gis.db.backends.postgis',
|
||||
'NAME': 'konova',
|
||||
'USER': 'postgres',
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': '5432',
|
||||
'NAME': env("DB_NAME"),
|
||||
'USER': env("DB_USER"),
|
||||
'PASSWORD': env("DB_PASSWORD"),
|
||||
'HOST': env("DB_HOST"),
|
||||
'PORT': env("DB_PORT"),
|
||||
}
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
@@ -201,28 +192,6 @@ STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, 'templates/map/client/libs'), # NETGIS map client files
|
||||
]
|
||||
|
||||
# DJANGO DEBUG TOOLBAR
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1"
|
||||
]
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"DISABLE_PANELS": {
|
||||
'debug_toolbar.panels.versions.VersionsPanel',
|
||||
'debug_toolbar.panels.timer.TimerPanel',
|
||||
'debug_toolbar.panels.settings.SettingsPanel',
|
||||
'debug_toolbar.panels.headers.HeadersPanel',
|
||||
'debug_toolbar.panels.request.RequestPanel',
|
||||
'debug_toolbar.panels.sql.SQLPanel',
|
||||
'debug_toolbar.panels.staticfiles.StaticFilesPanel',
|
||||
'debug_toolbar.panels.templates.TemplatesPanel',
|
||||
'debug_toolbar.panels.cache.CachePanel',
|
||||
'debug_toolbar.panels.signals.SignalsPanel',
|
||||
'debug_toolbar.panels.logging.LoggingPanel',
|
||||
'debug_toolbar.panels.redirects.RedirectsPanel',
|
||||
'debug_toolbar.panels.profiling.ProfilingPanel',
|
||||
}
|
||||
}
|
||||
|
||||
# EMAIL (see https://docs.djangoproject.com/en/dev/topics/email/)
|
||||
|
||||
# CHANGE_ME !!! ONLY FOR DEVELOPMENT !!!
|
||||
@@ -230,13 +199,10 @@ if DEBUG:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
|
||||
EMAIL_FILE_PATH = '/tmp/app-messages' # change this to a proper location
|
||||
|
||||
DEFAULT_FROM_EMAIL = "service@ksp.de" # The default email address for the 'from' element
|
||||
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") # The default email address for the 'from' element
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL # The default email sender address, which is used by Django to send errors via mail
|
||||
EMAIL_HOST = "localhost"
|
||||
EMAIL_REPLY_TO = "ksp-servicestelle@sgdnord.rlp.de"
|
||||
SUPPORT_MAIL_RECIPIENT = EMAIL_REPLY_TO
|
||||
EMAIL_PORT = "25"
|
||||
#EMAIL_HOST_USER = ""
|
||||
#EMAIL_HOST_PASSWORD = ""
|
||||
EMAIL_HOST = env("SMTP_HOST")
|
||||
EMAIL_REPLY_TO = env("REPLY_TO_ADDR")
|
||||
EMAIL_PORT = env("SMTP_PORT")
|
||||
EMAIL_USE_TLS = False
|
||||
EMAIL_USE_SSL = False
|
||||
|
||||
@@ -5,12 +5,13 @@ Contact: michel.peltriaux@sgdnord.rlp.de
|
||||
Created on: 31.01.22
|
||||
|
||||
"""
|
||||
from konova.sub_settings.django_settings import env
|
||||
|
||||
proxy = ""
|
||||
proxy = env("PROXY")
|
||||
PROXIES = {
|
||||
"http": proxy,
|
||||
"https": proxy,
|
||||
}
|
||||
|
||||
CLIENT_PROXY_AUTH_USER = "CHANGE_ME"
|
||||
CLIENT_PROXY_AUTH_PASSWORD = "CHANGE_ME"
|
||||
GEOPORTAL_RLP_USER = env("GEOPORTAL_RLP_USER")
|
||||
GEOPORTAL_RLP_PASSWORD = env("GEOPORTAL_RLP_PASSWORD")
|
||||
|
||||
@@ -5,7 +5,8 @@ Contact: ksp-servicestelle@sgdnord.rlp.de
|
||||
Created on: 14.12.22
|
||||
|
||||
"""
|
||||
from konova.sub_settings.django_settings import env
|
||||
|
||||
base_url = "http://127.0.0.1:8002"
|
||||
auth_header = "auth"
|
||||
auth_header_token = "CHANGE_ME"
|
||||
base_url = env("SCHNEIDER_BASE_URL")
|
||||
auth_header = env("SCHNEIDER_AUTH_HEADER")
|
||||
auth_header_token = env("SCHNEIDER_AUTH_TOKEN")
|
||||
|
||||
@@ -5,9 +5,14 @@ Contact: michel.peltriaux@sgdnord.rlp.de
|
||||
Created on: 31.01.22
|
||||
|
||||
"""
|
||||
from konova.sub_settings.django_settings import env
|
||||
|
||||
# SSO settings
|
||||
SSO_SERVER_BASE = "http://127.0.0.1:8000/"
|
||||
SSO_SERVER_BASE = env("SSO_SERVER_BASE_URL")
|
||||
SSO_SERVER = f"{SSO_SERVER_BASE}sso/"
|
||||
SSO_PRIVATE_KEY = "CHANGE_ME"
|
||||
SSO_PUBLIC_KEY = "CHANGE_ME"
|
||||
|
||||
# OAuth settings
|
||||
OAUTH_CODE_VERIFIER = env("OAUTH_CODE_VERIFIER")
|
||||
|
||||
OAUTH_CLIENT_ID = env("OAUTH_CLIENT_ID")
|
||||
OAUTH_CLIENT_SECRET = env("OAUTH_CLIENT_SECRET")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: michel.peltriaux@sgdnord.rlp.de
|
||||
Created on: 31.01.22
|
||||
|
||||
"""
|
||||
|
||||
# Parcel WFS settings
|
||||
PARCEL_WFS_BASE_URL = "https://www.geoportal.rlp.de/registry/wfs/519"
|
||||
PARCEL_WFS_USER = "ksp"
|
||||
PARCEL_WFS_PW = "CHANGE_ME"
|
||||
@@ -7,18 +7,12 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
@shared_task
|
||||
def celery_update_parcels(geometry_id: str, recheck: bool = True):
|
||||
from konova.models import Geometry, ParcelIntersection
|
||||
from konova.models import Geometry
|
||||
try:
|
||||
geom = Geometry.objects.get(id=geometry_id)
|
||||
objs = geom.parcelintersection_set.all()
|
||||
for obj in objs:
|
||||
obj.calculated_on = None
|
||||
ParcelIntersection.objects.bulk_update(
|
||||
objs,
|
||||
["calculated_on"]
|
||||
)
|
||||
|
||||
geom.parcels.clear()
|
||||
geom.update_parcels()
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
if recheck:
|
||||
sleep(5)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="scroll-150 font-italic">
|
||||
{{obj.comment}}
|
||||
{{obj.comment|linebreaks}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,6 @@ class BaseTestCase(TestCase):
|
||||
geometry = Geometry.objects.create()
|
||||
# Finally create main object, holding the other objects
|
||||
intervention = Intervention.objects.create(
|
||||
identifier="TEST",
|
||||
title="Test_title",
|
||||
responsible=responsibility_data,
|
||||
legal=legal_data,
|
||||
@@ -174,7 +173,6 @@ class BaseTestCase(TestCase):
|
||||
geometry = Geometry.objects.create()
|
||||
# Finally create main object, holding the other objects
|
||||
compensation = Compensation.objects.create(
|
||||
identifier="TEST",
|
||||
title="Test_title",
|
||||
intervention=interv,
|
||||
created=action,
|
||||
@@ -200,10 +198,8 @@ class BaseTestCase(TestCase):
|
||||
responsible_data.handler = handler
|
||||
responsible_data.save()
|
||||
|
||||
identifier = EcoAccount().generate_new_identifier()
|
||||
# Finally create main object, holding the other objects
|
||||
eco_account = EcoAccount.objects.create(
|
||||
identifier=identifier,
|
||||
title="Test_title",
|
||||
deductable_surface=500,
|
||||
legal=lega_data,
|
||||
@@ -230,7 +226,6 @@ class BaseTestCase(TestCase):
|
||||
responsible_data.save()
|
||||
# Finally create main object, holding the other objects
|
||||
ema = Ema.objects.create(
|
||||
identifier="TEST",
|
||||
title="Test_title",
|
||||
responsible=responsible_data,
|
||||
created=action,
|
||||
@@ -474,7 +469,7 @@ class BaseTestCase(TestCase):
|
||||
eco_account.save()
|
||||
return eco_account
|
||||
|
||||
def assert_equal_geometries(self, geom1: MultiPolygon, geom2: MultiPolygon):
|
||||
def assert_equal_geometries(self, geom1: MultiPolygon, geom2: MultiPolygon, tolerance = 0.001):
|
||||
""" Assert for geometries to be equal
|
||||
|
||||
Transforms the geometries to matching srids before checking
|
||||
@@ -491,7 +486,6 @@ class BaseTestCase(TestCase):
|
||||
self.assertTrue(True)
|
||||
return
|
||||
|
||||
tolerance = 0.001
|
||||
if geom1.srid != geom2.srid:
|
||||
# Due to prior possible transformation of any of these geometries, we need to make sure there exists a
|
||||
# transformation from one coordinate system into the other, which is valid
|
||||
@@ -515,7 +509,7 @@ class BaseViewTestCase(BaseTestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.login_url = reverse("simple-sso-login")
|
||||
self.login_url = reverse("oauth-login")
|
||||
|
||||
def assert_url_success(self, client: Client, urls: list):
|
||||
""" Assert for all given urls a direct 200 response
|
||||
|
||||
@@ -152,7 +152,7 @@ class RecordModalFormTestCase(BaseTestCase):
|
||||
)
|
||||
self.assertEqual(form.form_title, str(_("Record data")))
|
||||
self.assertEqual(form.form_caption, str(
|
||||
_("I, {} {}, confirm that all necessary control steps have been performed by myself.").format(
|
||||
_("The necessary control steps have been performed:").format(
|
||||
self.user.first_name,
|
||||
self.user.last_name
|
||||
)
|
||||
|
||||
@@ -13,21 +13,19 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
import debug_toolbar
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
from konova.settings import SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY, DEBUG
|
||||
from konova.sso.sso import KonovaSSOClient
|
||||
from konova.views.logout import LogoutView
|
||||
from konova.views.geometry import GeomParcelsView, GeomParcelsContentView
|
||||
from konova.views.home import HomeView
|
||||
from konova.views.map_proxy import ClientProxyParcelSearch, ClientProxyParcelWFS
|
||||
from konova.views.oauth import OAuthLoginView, OAuthCallbackView
|
||||
|
||||
sso_client = KonovaSSOClient(SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY)
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('login/', include(sso_client.get_urls())),
|
||||
path('oauth/callback/', OAuthCallbackView.as_view(), name="oauth-callback"),
|
||||
path('oauth/login/', OAuthLoginView.as_view(), name="oauth-login"),
|
||||
path('logout/', LogoutView.as_view(), name="logout"),
|
||||
path('', HomeView.as_view(), name="home"),
|
||||
path('intervention/', include("intervention.urls")),
|
||||
@@ -44,10 +42,5 @@ urlpatterns = [
|
||||
path('client/proxy/wfs', ClientProxyParcelWFS.as_view(), name="client-proxy-wfs"),
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
urlpatterns += [
|
||||
path('__debug__/', include(debug_toolbar.urls)),
|
||||
]
|
||||
|
||||
handler404 = "konova.views.error.get_404_view"
|
||||
handler500 = "konova.views.error.get_500_view"
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from konova.sub_settings.django_settings import DEFAULT_FROM_EMAIL, EMAIL_REPLY_TO, SUPPORT_MAIL_RECIPIENT
|
||||
from konova.sub_settings.django_settings import DEFAULT_FROM_EMAIL, EMAIL_REPLY_TO
|
||||
|
||||
|
||||
class Mailer:
|
||||
@@ -416,7 +416,7 @@ class Mailer:
|
||||
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
|
||||
}
|
||||
msg = render_to_string("email/api/verify_token.html", context)
|
||||
user_mail_address = [SUPPORT_MAIL_RECIPIENT]
|
||||
user_mail_address = [EMAIL_REPLY_TO]
|
||||
self.send(
|
||||
user_mail_address,
|
||||
_("Request for new API token"),
|
||||
|
||||
@@ -11,6 +11,7 @@ from json import JSONDecodeError
|
||||
import requests
|
||||
|
||||
from konova.sub_settings import schneider_settings
|
||||
from konova.sub_settings.proxy_settings import PROXIES
|
||||
|
||||
|
||||
class ParcelFetcher:
|
||||
@@ -28,7 +29,11 @@ class ParcelFetcher:
|
||||
self.geometry = geometry
|
||||
|
||||
# Reduce size of geometry to avoid "intersections" because of exact border matching
|
||||
geom = geometry.geom.buffer(-0.001)
|
||||
buffer_threshold = 0.001
|
||||
geom = geometry.geom.buffer(-buffer_threshold)
|
||||
if geom.area < buffer_threshold:
|
||||
# Fallback for malicious geometries which are way too small and would disappear on negative buffering
|
||||
geom = geometry.geom
|
||||
self.geojson = geom.ewkt
|
||||
self.results = []
|
||||
|
||||
@@ -39,6 +44,7 @@ class ParcelFetcher:
|
||||
|
||||
response = requests.post(
|
||||
url=post_url,
|
||||
proxies=PROXIES,
|
||||
data=self.geojson,
|
||||
headers={
|
||||
self.auth_header: self.auth_header_token
|
||||
|
||||
@@ -173,9 +173,13 @@ class TableRenderMixin:
|
||||
Returns:
|
||||
|
||||
"""
|
||||
value_orig = value
|
||||
max_length = 75
|
||||
if len(value) > max_length:
|
||||
value = f"{value[:max_length]}..."
|
||||
value = format_html(
|
||||
f'<div title="{value_orig}">{value}</div>'
|
||||
)
|
||||
return value
|
||||
|
||||
def render_d(self, value, record: GeoReferencedMixin):
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: michel.peltriaux@sgdnord.rlp.de
|
||||
Created on: 17.12.21
|
||||
|
||||
"""
|
||||
import json
|
||||
from abc import abstractmethod
|
||||
from json import JSONDecodeError
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from django.contrib.gis.db.models.functions import AsGML, MakeValid
|
||||
from django.db.models import Func, F
|
||||
from requests.auth import HTTPDigestAuth
|
||||
|
||||
from konova.settings import PARCEL_WFS_USER, PARCEL_WFS_PW, PROXIES
|
||||
|
||||
|
||||
class AbstractWFSFetcher:
|
||||
""" Base class for fetching WFS data
|
||||
|
||||
"""
|
||||
# base_url represents not the capabilities url but the parameter-free base url
|
||||
base_url = None
|
||||
version = None
|
||||
auth_user = None
|
||||
auth_pw = None
|
||||
auth_digest_obj = None
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __init__(self, base_url: str, version: str = "1.1.0", auth_user: str = None, auth_pw: str = None, *args, **kwargs):
|
||||
self.base_url = base_url
|
||||
self.version = version
|
||||
self.auth_pw = auth_pw
|
||||
self.auth_user = auth_user
|
||||
|
||||
self._create_auth_obj()
|
||||
|
||||
def _create_auth_obj(self):
|
||||
if self.auth_pw is not None and self.auth_user is not None:
|
||||
self.auth_digest_obj = HTTPDigestAuth(
|
||||
self.auth_user,
|
||||
self.auth_pw
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_features(self, feature_identifier: str, filter_str: str):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ParcelWFSFetcher(AbstractWFSFetcher):
|
||||
""" Fetches features from a special parcel WFS
|
||||
|
||||
"""
|
||||
geometry_id = None
|
||||
geometry_property_name = None
|
||||
count = 100
|
||||
|
||||
def __init__(self, geometry_id: str, geometry_property_name: str = "msGeometry", *args, **kwargs):
|
||||
super().__init__(
|
||||
version="2.0.0",
|
||||
base_url="https://www.geoportal.rlp.de/registry/wfs/519",
|
||||
auth_user=PARCEL_WFS_USER,
|
||||
auth_pw=PARCEL_WFS_PW,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
self.geometry_id = geometry_id
|
||||
self.geometry_property_name = geometry_property_name
|
||||
|
||||
def _create_spatial_filter(self,
|
||||
geometry_operation: str):
|
||||
""" Creates a xml spatial filter according to the WFS filter specification
|
||||
|
||||
The geometry needs to be shrinked by a very small factor (-0.01) before a GML can be created for intersection
|
||||
checking. Otherwise perfect parcel outline placement on top of a neighbouring parcel would result in an
|
||||
intersection hit, despite the fact they do not truly intersect just because their vertices match.
|
||||
|
||||
Args:
|
||||
geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities)
|
||||
|
||||
Returns:
|
||||
spatial_filter (str): The spatial filter element
|
||||
"""
|
||||
from konova.models import Geometry
|
||||
|
||||
geom = Geometry.objects.filter(
|
||||
id=self.geometry_id
|
||||
).annotate(
|
||||
smaller=Func(F('geom'), -0.001, function="ST_Buffer") # same as geometry.geom_small_buffered but for QuerySet
|
||||
).annotate(
|
||||
gml=AsGML(MakeValid('smaller'))
|
||||
).first()
|
||||
geom_gml = geom.gml
|
||||
spatial_filter = f"<Filter><{geometry_operation}><PropertyName>{self.geometry_property_name}</PropertyName>{geom_gml}</{geometry_operation}></Filter>"
|
||||
return spatial_filter
|
||||
|
||||
def _create_post_data(self,
|
||||
geometry_operation: str,
|
||||
typenames: str = None,
|
||||
start_index: int = 0,
|
||||
):
|
||||
""" Creates a POST body content for fetching features
|
||||
|
||||
Args:
|
||||
geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities)
|
||||
|
||||
Returns:
|
||||
_filter (str): A proper xml WFS filter
|
||||
"""
|
||||
start_index = str(start_index)
|
||||
spatial_filter = self._create_spatial_filter(
|
||||
geometry_operation
|
||||
)
|
||||
_filter = f'<wfs:GetFeature service="WFS" version="{self.version}" xmlns:wfs="http://www.opengis.net/wfs/2.0" xmlns:fes="http://www.opengis.net/fes/2.0" xmlns:myns="http://www.someserver.com/myns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs/2.0 http://schemas.opengis.net/wfs/2.0.0/wfs.xsd" count="{self.count}" startindex="{start_index}" outputFormat="application/json; subtype=geojson"><wfs:Query typeNames="{typenames}">{spatial_filter}</wfs:Query></wfs:GetFeature>'
|
||||
return _filter
|
||||
|
||||
def get_features(self,
|
||||
typenames: str,
|
||||
spatial_operator: str = "Intersects",
|
||||
filter_srid: str = None,
|
||||
start_index: int = 0,
|
||||
rerun_on_exception: bool = True
|
||||
):
|
||||
""" Fetches features from the WFS using POST
|
||||
|
||||
POST is required since GET has a character limit around 4000. Having a larger filter would result in errors,
|
||||
which do not occur in case of POST.
|
||||
|
||||
Args:
|
||||
typenames (str): References to parameter 'typenames' in a WFS GetFeature request
|
||||
spatial_operator (str): Defines the spatial operation for filtering
|
||||
filter_srid (str): Defines the spatial reference system, the geometry shall be transformed into for filtering
|
||||
start_index (str): References to parameter 'startindex' in a
|
||||
|
||||
Returns:
|
||||
features (list): A list of returned features
|
||||
"""
|
||||
found_features = []
|
||||
while start_index is not None:
|
||||
post_body = self._create_post_data(
|
||||
spatial_operator,
|
||||
typenames,
|
||||
start_index
|
||||
)
|
||||
response = requests.post(
|
||||
url=self.base_url,
|
||||
data=post_body,
|
||||
auth=self.auth_digest_obj,
|
||||
proxies=PROXIES,
|
||||
)
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
try:
|
||||
# Check if collection is an exception and does not contain the requested data
|
||||
content = json.loads(content)
|
||||
except JSONDecodeError as e:
|
||||
if rerun_on_exception:
|
||||
# Wait a second before another try
|
||||
sleep(1)
|
||||
self.get_features(
|
||||
typenames,
|
||||
spatial_operator,
|
||||
filter_srid,
|
||||
start_index,
|
||||
rerun_on_exception=False
|
||||
)
|
||||
else:
|
||||
e.msg += content
|
||||
raise e
|
||||
fetched_features = content.get(
|
||||
"features",
|
||||
{},
|
||||
)
|
||||
|
||||
found_features += fetched_features
|
||||
|
||||
if len(fetched_features) < self.count:
|
||||
# The response was not 'full', so we got everything to fetch
|
||||
start_index = None
|
||||
else:
|
||||
# If a 'full' response returned, there might be more to fetch. Increase the start_index!
|
||||
start_index += self.count
|
||||
|
||||
return found_features
|
||||
@@ -10,13 +10,16 @@ from django.contrib.gis.geos import MultiPolygon
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
|
||||
from konova.models import Geometry
|
||||
from konova.settings import GEOM_THRESHOLD_RECALCULATION_SECONDS
|
||||
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
|
||||
from konova.tasks import celery_update_parcels
|
||||
|
||||
|
||||
class GeomParcelsView(LoginRequiredMixin, View):
|
||||
class GeomParcelsView(View):
|
||||
|
||||
def get(self, request: HttpRequest, id: str):
|
||||
""" Getter for HTMX
|
||||
@@ -30,24 +33,43 @@ class GeomParcelsView(LoginRequiredMixin, View):
|
||||
Returns:
|
||||
A rendered piece of HTML
|
||||
"""
|
||||
# HTTP code 286 states that the HTMX should stop polling for updates
|
||||
# https://htmx.org/docs/#polling
|
||||
status_code = 286
|
||||
template = "konova/includes/parcels/parcel_table_frame.html"
|
||||
geom = get_object_or_404(Geometry, id=id)
|
||||
parcels = geom.get_underlying_parcels()
|
||||
geos_geom = geom.geom or MultiPolygon(srid=DEFAULT_SRID_RLP)
|
||||
|
||||
geometry_exists = not geos_geom.empty
|
||||
parcels_are_currently_calculated = geometry_exists and geos_geom.area > 0 and len(parcels) == 0
|
||||
parcels_available = len(parcels) > 0
|
||||
geom = get_object_or_404(Geometry, id=id)
|
||||
geos_geom = geom.geom or MultiPolygon(srid=DEFAULT_SRID_RLP)
|
||||
geometry_exists = not geos_geom.empty and geos_geom.area > 0
|
||||
geom_parcel_update_started = geom.parcel_update_start is not None
|
||||
geom_parcel_update_finished = geom.parcel_update_end is not None
|
||||
|
||||
parcels = geom.get_underlying_parcels()
|
||||
parcels_are_available = len(parcels) > 0
|
||||
|
||||
waiting_too_long = self._check_waiting_too_long(geom)
|
||||
|
||||
if geometry_exists and not parcels_are_available and waiting_too_long:
|
||||
# Trigger calculation again - process may have failed silently
|
||||
celery_update_parcels.delay(geom.id)
|
||||
parcels_are_currently_calculated = True
|
||||
else:
|
||||
parcels_are_currently_calculated = (
|
||||
geometry_exists and
|
||||
not parcels_are_available and
|
||||
geom_parcel_update_started and
|
||||
not geom_parcel_update_finished
|
||||
)
|
||||
|
||||
if parcels_are_currently_calculated:
|
||||
# Parcels are being calculated right now. Change the status code, so polling stays active for fetching
|
||||
# resutls after the calculation
|
||||
# results after the calculation
|
||||
status_code = 200
|
||||
else:
|
||||
# HTTP code 286 states that the HTMX should stop polling for updates
|
||||
# https://htmx.org/docs/#polling
|
||||
status_code = 286
|
||||
|
||||
if parcels_available or not geometry_exists:
|
||||
if parcels_are_available or not geometry_exists:
|
||||
# Default case: Parcels are calculated or there is no geometry at all
|
||||
# (so there will be no parcels to expect)
|
||||
municipals = geom.get_underlying_municipals(parcels)
|
||||
|
||||
rpp = 100
|
||||
@@ -69,8 +91,25 @@ class GeomParcelsView(LoginRequiredMixin, View):
|
||||
else:
|
||||
return HttpResponse(None, status=404)
|
||||
|
||||
def _check_waiting_too_long(self, geom: Geometry):
|
||||
""" Check whether the client is waiting too long for a parcel calculation result
|
||||
|
||||
class GeomParcelsContentView(LoginRequiredMixin, View):
|
||||
Depending on the geometry's modified attribute
|
||||
|
||||
"""
|
||||
# Scale time to wait longer with increasing geometry complexity
|
||||
complexity_factor = geom.complexity_factor + 1
|
||||
wait_for_seconds = int(GEOM_THRESHOLD_RECALCULATION_SECONDS * complexity_factor)
|
||||
try:
|
||||
pcs_diff = (timezone.now() - geom.parcel_update_start).seconds
|
||||
except TypeError:
|
||||
pcs_diff = wait_for_seconds
|
||||
|
||||
waiting_too_long = (pcs_diff >= wait_for_seconds)
|
||||
return waiting_too_long
|
||||
|
||||
|
||||
class GeomParcelsContentView(View):
|
||||
|
||||
def get(self, request: HttpRequest, id: str, page: int):
|
||||
""" Getter for infinite scroll of HTMX
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from requests.auth import HTTPDigestAuth
|
||||
|
||||
from konova.sub_settings.proxy_settings import PROXIES, CLIENT_PROXY_AUTH_USER, CLIENT_PROXY_AUTH_PASSWORD
|
||||
from konova.sub_settings.proxy_settings import PROXIES, GEOPORTAL_RLP_USER, GEOPORTAL_RLP_PASSWORD
|
||||
|
||||
|
||||
class BaseClientProxyView(View):
|
||||
@@ -90,17 +90,26 @@ class ClientProxyParcelWFS(BaseClientProxyView):
|
||||
url = f"{base_url}?{urlencode(params, doseq=True)}"
|
||||
|
||||
url = url.replace("typename", "typenames")
|
||||
auth = HTTPDigestAuth(CLIENT_PROXY_AUTH_USER, CLIENT_PROXY_AUTH_PASSWORD)
|
||||
auth = HTTPDigestAuth(GEOPORTAL_RLP_USER, GEOPORTAL_RLP_PASSWORD)
|
||||
|
||||
content, response_code = self.perform_url_call(url, auth=auth)
|
||||
body = json.loads(content)
|
||||
error_detected = response_code != 200
|
||||
error_code = f"response code:{response_code}"
|
||||
try:
|
||||
body = json.loads(content)
|
||||
except JSONDecodeError:
|
||||
body = {}
|
||||
error_code = "json invalid"
|
||||
error_detected = True
|
||||
|
||||
body["crs"] = {
|
||||
"type": "name",
|
||||
"properties": {
|
||||
"name": "urn:ogc:def:crs:EPSG::25832"
|
||||
"name": "urn:ogc:def:crs:EPSG::25832",
|
||||
}
|
||||
}
|
||||
if response_code != 200:
|
||||
if error_detected:
|
||||
body["crs"]["properties"]["msg"] = f"Error detected ({error_code})"
|
||||
return JsonResponse({
|
||||
"status_code": response_code,
|
||||
"content": body,
|
||||
|
||||
125
konova/views/oauth.py
Normal file
125
konova/views/oauth.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: ksp-servicestelle@sgdnord.rlp.de
|
||||
Created on: 26.04.24
|
||||
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from django.contrib.auth import login
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.views import View
|
||||
|
||||
from api.models import OAuthToken
|
||||
from konova.sub_settings.django_settings import BASE_URL
|
||||
from konova.sub_settings.sso_settings import SSO_SERVER_BASE, OAUTH_CODE_VERIFIER, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET
|
||||
|
||||
|
||||
class OAuthLoginView(View):
|
||||
"""
|
||||
Starts OAuth Login procedure
|
||||
-> AnonymousUser is redirected to SSO component using specific parameters
|
||||
-> After successful login (in SSO component), user will be redirected to a specific callback url (OAuthCallbackView)
|
||||
-> Callback view uses retrieved authorization token to get a proper access token from SSO component
|
||||
-> SSO component answers with access token
|
||||
-> OAuthCallbackView uses token in Authorization header to access user data of logged-in user in SSO component
|
||||
-> OAuthCallbackView creates/updates user
|
||||
-> OAuthCallbackView logs in user and redirects to default home view
|
||||
|
||||
"""
|
||||
|
||||
def __create_code_challenge(self):
|
||||
"""
|
||||
Creates a code verifier and code challenge for extra security.
|
||||
See https://django-oauth-toolkit.readthedocs.io/en/latest/getting_started.html#authorization-code for further
|
||||
information
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
code_verifier = OAUTH_CODE_VERIFIER
|
||||
|
||||
code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '')
|
||||
return code_verifier, code_challenge
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
"""
|
||||
Redirects user to OAuth SSO webservice for credential based login there
|
||||
|
||||
Args:
|
||||
request ():
|
||||
*args ():
|
||||
**kwargs ():
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
oauth_authentication_code_url = f"{SSO_SERVER_BASE}o/authorize/"
|
||||
redirect_uri = f'{BASE_URL}{reverse("oauth-callback")}'
|
||||
|
||||
code_verifier, code_challenge = self.__create_code_challenge()
|
||||
|
||||
urlencode_params = urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"client_id": OAUTH_CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
)
|
||||
url = f"{oauth_authentication_code_url}?{urlencode_params}"
|
||||
return redirect(url)
|
||||
|
||||
|
||||
class OAuthCallbackView(View):
|
||||
"""
|
||||
Callback view for OAuth2.0 authentication token.
|
||||
Authentication tokens will be exchanged for access token.
|
||||
Access Token will be used for fetching user data from SSO component.
|
||||
User data will be used for creating/updating user data inside this app.
|
||||
User will be logged-in and redirected to default home view.
|
||||
|
||||
"""
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
authentication_code = request.GET.get("code")
|
||||
oauth_acces_token_url = f"{SSO_SERVER_BASE}o/token/"
|
||||
|
||||
callback_url = f'{BASE_URL}{reverse("oauth-callback")}'
|
||||
|
||||
params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": authentication_code,
|
||||
"redirect_uri": callback_url,
|
||||
"code_verifier": OAUTH_CODE_VERIFIER,
|
||||
"client_id": OAUTH_CLIENT_ID,
|
||||
"client_secret": OAUTH_CLIENT_SECRET
|
||||
}
|
||||
access_code_response = requests.post(
|
||||
oauth_acces_token_url,
|
||||
data=params
|
||||
)
|
||||
received_on = now()
|
||||
|
||||
access_code_response_body = access_code_response.content.decode("utf-8")
|
||||
status_code_invalid = access_code_response.status_code != 200
|
||||
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)
|
||||
|
||||
login(request, user)
|
||||
return redirect("home")
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,9 @@ class ServerMessageAdmin(admin.ModelAdmin):
|
||||
search_fields = [
|
||||
"subject"
|
||||
]
|
||||
ordering = [
|
||||
"-publish_on"
|
||||
]
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.save(user=request.user)
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
amqp==5.2.0
|
||||
asgiref==3.7.2
|
||||
asgiref==3.8.1
|
||||
async-timeout==4.0.3
|
||||
beautifulsoup4==4.12.2
|
||||
beautifulsoup4==4.13.0b2
|
||||
billiard==4.2.0
|
||||
cached-property==1.5.2
|
||||
celery==5.3.6
|
||||
certifi==2023.11.17
|
||||
celery==5.4.0
|
||||
certifi==2024.6.2
|
||||
cffi==1.17.0rc1
|
||||
chardet==5.2.0
|
||||
charset-normalizer==3.3.2
|
||||
click==8.1.7
|
||||
click-didyoumean==0.3.0
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1
|
||||
click-repl==0.3.0
|
||||
coverage==7.3.3
|
||||
coverage==7.5.3
|
||||
cryptography==42.0.8
|
||||
Deprecated==1.2.14
|
||||
Django==5.0.1
|
||||
django-autocomplete-light==3.10.0rc4
|
||||
Django==5.0.6
|
||||
django-autocomplete-light==3.11.0
|
||||
django-bootstrap-modal-forms==3.0.4
|
||||
django-bootstrap4==23.2
|
||||
django-debug-toolbar==4.2.0
|
||||
django-filter==23.5
|
||||
django-bootstrap4==24.3
|
||||
django-environ==0.11.2
|
||||
django-filter==24.2
|
||||
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
|
||||
idna==3.6
|
||||
importlib-metadata==7.0.0
|
||||
gunicorn==22.0.0
|
||||
idna==3.7
|
||||
itsdangerous==0.24
|
||||
kombu==5.3.4
|
||||
importlib_metadata==7.1.0
|
||||
jwcrypto==1.5.6
|
||||
kombu==5.3.7
|
||||
oauthlib==3.2.2
|
||||
openpyxl==3.2.0b1
|
||||
packaging==23.2
|
||||
packaging==24.1
|
||||
pika==1.3.2
|
||||
prompt-toolkit==3.0.43
|
||||
psycopg==3.1.16
|
||||
psycopg-binary==3.1.16
|
||||
psycopg2-binary==2.9.9
|
||||
pyparsing==3.1.1
|
||||
pillow==10.3.0
|
||||
prompt_toolkit==3.0.47
|
||||
psycopg==3.1.19
|
||||
psycopg-binary==3.1.19
|
||||
pycparser==2.22
|
||||
pyparsing==3.1.2
|
||||
pypng==0.20220715.0
|
||||
pyproj==3.6.1
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.3.post1
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2024.1
|
||||
PyYAML==6.0.1
|
||||
qrcode==7.4.2
|
||||
redis==5.1.0a1
|
||||
requests==2.31.0
|
||||
qrcode==7.3.1
|
||||
redis==5.1.0b6
|
||||
requests==2.32.3
|
||||
six==1.16.0
|
||||
soupsieve==2.5
|
||||
sqlparse==0.4.4
|
||||
typing_extensions==4.9.0
|
||||
tzdata==2023.3
|
||||
urllib3==2.1.0
|
||||
sqlparse==0.5.0
|
||||
typing_extensions==4.12.2
|
||||
tzdata==2024.1
|
||||
urllib3==2.2.1
|
||||
vine==5.1.0
|
||||
wcwidth==0.2.12
|
||||
webservices==0.7
|
||||
wcwidth==0.2.13
|
||||
wrapt==1.16.0
|
||||
xmltodict==0.13.0
|
||||
zipp==3.17.0
|
||||
zipp==3.19.2
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<hr>
|
||||
<p class="lead">
|
||||
{% trans 'The requested data does not exist.' %}
|
||||
{% trans 'Make sure the URL is valid (no whitespaces, ...).' %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -27,7 +27,18 @@
|
||||
<div class="col">
|
||||
{% for message in messages %}
|
||||
<div class="row alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
<div>
|
||||
<span class="mr-3">
|
||||
{% if "danger" in message.tags %}
|
||||
{% fa5_icon 'exclamation' %}
|
||||
{% elif "info" in message.tags %}
|
||||
{% fa5_icon 'info' %}
|
||||
{% elif "success" in message.tags %}
|
||||
{% fa5_icon 'check' %}
|
||||
{% endif %}
|
||||
</span>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"import":
|
||||
{
|
||||
"geopackageLibURL": "/libs/geopackage/4.2.3/"
|
||||
"geopackageLibURL": "/static/libs/geopackage/4.2.3/"
|
||||
},
|
||||
"export":
|
||||
{
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<a class="dropdown-item" target="_blank" href="{% url 'admin:index' %}">{% fa5_icon 'tools' %} {% trans 'Admin' %}</a>
|
||||
{% endif %}
|
||||
<a class="dropdown-item" href="{% url 'user:index' %}">{% fa5_icon 'cogs' %} {% trans 'Settings' %}</a>
|
||||
<a class="dropdown-item" href="{% url 'logout' %}">{% fa5_icon 'sign-out-alt' %} {% trans 'Logout' %}</a>
|
||||
</div>
|
||||
|
||||
@@ -23,13 +23,22 @@
|
||||
{% endblock %}
|
||||
</header>
|
||||
<div class="container-fluid mt-3 px-5">
|
||||
<div class="">
|
||||
{% for message in messages %}
|
||||
<div class="row alert alert-{{ message.tags }}">
|
||||
<div class="row alert alert-{{ message.tags }}">
|
||||
<div>
|
||||
<span class="mr-3">
|
||||
{% if "danger" in message.tags %}
|
||||
{% fa5_icon 'exclamation' %}
|
||||
{% elif "info" in message.tags %}
|
||||
{% fa5_icon 'info' %}
|
||||
{% elif "success" in message.tags %}
|
||||
{% fa5_icon 'check' %}
|
||||
{% endif %}
|
||||
</span>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class UserAdmin(admin.ModelAdmin):
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"api_token",
|
||||
"oauth_token",
|
||||
"groups",
|
||||
"notifications",
|
||||
"date_joined",
|
||||
|
||||
20
user/migrations/0009_user_oauth_token.py
Normal file
20
user/migrations/0009_user_oauth_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.0.4 on 2024-04-30 07:20
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0003_oauthtoken'),
|
||||
('user', '0008_alter_user_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='oauth_token',
|
||||
field=models.ForeignKey(blank=True, db_comment='OAuth token for the user', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='api.oauthtoken'),
|
||||
),
|
||||
]
|
||||
@@ -9,7 +9,7 @@ from django.contrib.auth.models import AbstractUser
|
||||
|
||||
from django.db import models
|
||||
|
||||
from api.models import APIUserToken
|
||||
from api.models import APIUserToken, OAuthToken
|
||||
from konova.settings import ZB_GROUP, DEFAULT_GROUP, ETS_GROUP
|
||||
from konova.utils.mailer import Mailer
|
||||
from user.enums import UserNotificationEnum
|
||||
@@ -24,6 +24,14 @@ class User(AbstractUser):
|
||||
help_text="The user's API token",
|
||||
on_delete=models.SET_NULL
|
||||
)
|
||||
oauth_token = models.ForeignKey(
|
||||
"api.OAuthToken",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
db_comment="OAuth token for the user",
|
||||
related_name="+"
|
||||
)
|
||||
|
||||
def is_notification_setting_set(self, notification_enum: UserNotificationEnum):
|
||||
return self.notifications.filter(
|
||||
@@ -214,4 +222,46 @@ class User(AbstractUser):
|
||||
shared_teams = self.teams.filter(
|
||||
deleted__isnull=True
|
||||
)
|
||||
return shared_teams
|
||||
return shared_teams
|
||||
|
||||
@staticmethod
|
||||
def oauth_update_user(user_data: dict):
|
||||
"""
|
||||
Get or create a user depending on given user_data.
|
||||
If the user record already exists, it's data will be updated using user_data.
|
||||
|
||||
Args:
|
||||
user_data (dict): User data from OAuth SSO component
|
||||
|
||||
Returns:
|
||||
user (User): The resolved user
|
||||
"""
|
||||
username = user_data.get("username")
|
||||
user, is_created = User.objects.get_or_create(
|
||||
username=username
|
||||
)
|
||||
|
||||
if is_created:
|
||||
user.set_unusable_password()
|
||||
|
||||
user.first_name = user_data.get("first_name")
|
||||
user.last_name = user_data.get("last_name")
|
||||
user.email = user_data.get("email")
|
||||
|
||||
return user
|
||||
|
||||
def oauth_replace_token(self, token: OAuthToken):
|
||||
"""
|
||||
Drops old token (if existing) and stores given token.
|
||||
|
||||
Args:
|
||||
token (OAuthToken): New token
|
||||
|
||||
Returns:
|
||||
user (User)
|
||||
"""
|
||||
if self.oauth_token:
|
||||
self.oauth_token.delete()
|
||||
self.oauth_token = token
|
||||
self.save()
|
||||
return self
|
||||
@@ -9,11 +9,13 @@ from django.urls import path
|
||||
|
||||
from user.autocomplete.share import ShareUserAutocomplete, ShareTeamAutocomplete
|
||||
from user.autocomplete.team import TeamAdminAutocomplete
|
||||
from user.views import *
|
||||
from user.views.propagate import PropagateUserView
|
||||
from user.views.views import *
|
||||
|
||||
app_name = "user"
|
||||
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("contact/<id>", contact_view, name="contact"),
|
||||
|
||||
7
user/views/__init__.py
Normal file
7
user/views/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: ksp-servicestelle@sgdnord.rlp.de
|
||||
Created on: 10.05.24
|
||||
|
||||
"""
|
||||
69
user/views/propagate.py
Normal file
69
user/views/propagate.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Author: Michel Peltriaux
|
||||
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
||||
Contact: ksp-servicestelle@sgdnord.rlp.de
|
||||
Created on: 10.05.24
|
||||
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
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 user.models import User
|
||||
|
||||
|
||||
class PropagateUserView(View):
|
||||
""" Receives user data to be stored in db
|
||||
|
||||
Used if new user gets access to application and needs to be created in application before first login (e.g.
|
||||
proper rights management)
|
||||
|
||||
"""
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs):
|
||||
# Decrypt
|
||||
encrypted_body = request.body
|
||||
hash = hashlib.md5()
|
||||
hash.update(OAUTH_CLIENT_ID.encode("utf-8"))
|
||||
key = base64.urlsafe_b64encode(hash.hexdigest().encode("utf-8"))
|
||||
fernet = Fernet(key)
|
||||
body = fernet.decrypt(encrypted_body).decode("utf-8")
|
||||
body = json.loads(body)
|
||||
|
||||
try:
|
||||
status = "updated"
|
||||
user = User.objects.get(username=body.get('username'))
|
||||
# Update user data, excluding some changes
|
||||
skipable_attrs = {
|
||||
"username",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
}
|
||||
for _attr, _val in body.items():
|
||||
if _attr in skipable_attrs:
|
||||
continue
|
||||
setattr(user, _attr, _val)
|
||||
except ObjectDoesNotExist:
|
||||
user = User(**body)
|
||||
status = "created"
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
data = {
|
||||
"success": True,
|
||||
"status": status
|
||||
}
|
||||
|
||||
return JsonResponse(data)
|
||||
Reference in New Issue
Block a user