Compare commits

..

47 Commits
v0.5 ... v0.6

Author SHA1 Message Date
5550b8aa67 Merge pull request '197_Resubmission' (#198) from 197_Resubmission into master
Reviewed-on: SGD-Nord/konova#198
2022-08-15 11:02:07 +02:00
a6f7e605e6 Migrations + Cleanup
* adds needed migrations
* refactors forms.py (700+ lines) in main konova app
    * splits into forms/ and forms/modals and single class/topic-files for better maintainability and overview
* fixes bug in main konova app migration which could occur if a certain compensation migration did not run before
2022-08-15 10:50:01 +02:00
8bce8b8e75 Command
* adds new command to be used with cron for periodic checkin of resubmissions
* updates translations
2022-08-15 10:02:07 +02:00
4f02e8ee1b Templates + Routes
* adds control button for Intervention, Compensation, Ema and EcoAccount for setting a resubmission on an entry
2022-08-15 09:38:51 +02:00
117a4437fe Model
* adds new model and mixin
* adds new functionality for Mailer class for sending resubmission mails
2022-08-15 08:08:15 +02:00
f9c23a8f29 Merge pull request 'Fix' (#196) from 192_Deduction_modal_form_on_recorded_intervention into master
Reviewed-on: SGD-Nord/konova#196
2022-08-10 09:29:18 +02:00
a5f0d7f8c6 Fix
* adds more detailed situation check on check_for_recorded_instance()
2022-08-10 09:28:43 +02:00
982d9f1930 Merge pull request '191_Deduction_change_notification' (#195) from 191_Deduction_change_notification into master
Reviewed-on: SGD-Nord/konova#195
2022-08-10 09:04:29 +02:00
9840c8fa8f Minor changes
* updates translation
2022-08-10 09:03:24 +02:00
890911c2dc Mail sending
* adds mail sending logic for new notification setting
* adds new templates for user and team based sending
* enhances all email template layout
* adds translations
2022-08-10 08:59:24 +02:00
3d10e84852 New Notification
* adds new notification setting to user settings form
* adds translations
* adds initial creating of ENUM on setup command
2022-08-10 08:03:18 +02:00
05145673e1 Merge pull request '#190 Mandatory finished deadline' (#194) from 190_Deadlines_mandatory into master
Reviewed-on: SGD-Nord/konova#194
2022-08-08 15:01:31 +02:00
fd4c9b0e5e #190 Mandatory finished deadline
* adds template message to indicate a finished-deadline is mandatory
* adds finished deadline existance to quality check of compensation-like entries
* adds proper warning to quality check result
* extends tests
2022-08-08 14:57:36 +02:00
23f0567aef Merge pull request '#185 Parcel loading on public reports' (#186) from 185_Parcels_loading_CORS_error into master
Reviewed-on: SGD-Nord/konova#186
2022-08-02 09:38:44 +02:00
70555ee5a3 #185 Parcel loading on public reports
* fixes bug where unauthorized clients would not load a geometries parcel view properly
* minor general templates enhancements
2022-08-02 09:38:03 +02:00
1c38acea25 Revert "Merge branch 'Docker' into master"
This reverts commit cf282c937f, reversing
changes made to ee9834c0da.

Undos accidental remote merge
2022-07-13 11:21:51 +02:00
cf282c937f Merge branch 'Docker' into master 2022-07-13 11:02:57 +02:00
ee9834c0da Merge pull request 'geometry_srid_migration' (#183) from geometry_srid_migration into master
Reviewed-on: SGD-Nord/konova#183
2022-07-13 10:57:37 +02:00
dacfbd0504 Test update
* updates tests for SRID migration
2022-07-13 10:53:45 +02:00
0e7859e538 Geometry Model geom SRID migration
* adds two new migrations for transforming existing geometries into the default SRID
2022-07-13 08:19:27 +02:00
16107f93f6 Merge pull request '#180 Shared data users hidden' (#181) from 180_Visibility_of_contact_data into master
Reviewed-on: SGD-Nord/konova#181
2022-06-27 14:31:30 +02:00
d669adf54f #180 Shared data users hidden
* implements hidden visibility of shared users on non-shared entries
2022-06-27 14:31:09 +02:00
589a7aec60 Merge pull request '177_Minor_enhancements' (#178) from 177_Minor_enhancements into master
Reviewed-on: SGD-Nord/konova#178
2022-06-22 11:53:43 +02:00
3f3ae4e31b # 177 Impressum link
* adds impressum link to footer
2022-06-21 14:27:32 +02:00
1bbede0d12 # 177 Overview layout enhancement
* enhances layout title section layout on overview template
2022-06-21 14:25:50 +02:00
0801b3f6ab # 177 Timespanreport and Excel download
* fixes bug on excel download
* adds new order of columns to excel template for report download
* enhances subtitle for old data entries on timespanreport template
2022-06-21 14:19:10 +02:00
44ad156595 # 177 Timespanreport helptext for deductions
* adds help text for recorded deduction section
2022-06-21 14:11:01 +02:00
5c95bc7d85 # 177 Timespanreport column order
* rearranges the column order so 'Total' will always be the first column
2022-06-21 14:02:49 +02:00
3b045fea8e # 177 Report help texts
* adds report form field help texts
* adds translations
2022-06-21 13:54:41 +02:00
6e5237ab88 Merge pull request '# 175 Report law calculation bugfix' (#176) from 175_Report_law_calculation_wrong into master
Reviewed-on: SGD-Nord/konova#176
2022-06-15 17:46:49 +02:00
f19ad5f639 # 175 Report law calculation bugfix
* fixes bug where amount of used laws in intervention forms would not be calculated properly
2022-06-15 17:44:43 +02:00
766e3c8d37 Merge pull request 'master' (#174) from master into Docker
Reviewed-on: SGD-Nord/konova#174
2022-06-02 09:38:06 +02:00
e90625c8b5 Merge pull request 'master' (#172) from master into Docker
Reviewed-on: SGD-Nord/konova#172
2022-06-02 08:53:32 +02:00
90e76bc8f5 Merge pull request 'Update docker' (#159) from master into Docker
Reviewed-on: SGD-Nord/konova#159
2022-05-09 14:03:12 +02:00
5e84dfc4ae Merge pull request 'Docker_worker_enhance' (#155) from Docker_worker_enhance into Docker
Reviewed-on: SGD-Nord/konova#155
2022-05-09 11:05:33 +02:00
615eb65534 Docker enhancement
* optimizes image build dependency
* increases gunicorn default number of workers
2022-04-26 10:09:58 +02:00
bcde400096 WIP: Docker enhancement
* reduces all needed containers into a single one
* simplifies initial startup command by adding docker-entrypoint.sh
2022-04-26 08:55:07 +02:00
aa02dbab96 WIP: Docker enhancement
* reduce containers into a single one, holding nginx + celery + redis all at once
2022-04-25 16:07:38 +02:00
8b7c4a82aa Merge pull request 'master' (#147) from master into Docker
Reviewed-on: SGD-Nord/konova#147
2022-04-13 08:48:19 +02:00
b668c562dd Merge pull request 'master' (#136) from master into Docker
Reviewed-on: SGD-Nord/konova#136
2022-03-21 12:21:50 +01:00
460011a5e8 Docker worker enhancement
* drops docker worker process in favor of background celery worker on main process
* changes uploaded files folder into host-based folder
2022-03-09 14:03:50 +01:00
76e018e084 Merge pull request 'master' (#134) from master into Docker
Reviewed-on: SGD-Nord/konova#134
2022-03-04 13:33:35 +01:00
e00b050c8b Merge pull request 'master' (#130) from master into Docker
Reviewed-on: SGD-Nord/konova#130
2022-02-25 13:07:59 +01:00
ab9023aad0 Merge pull request 'HOTFIX' (#124) from master into Docker
Reviewed-on: SGD-Nord/konova#124
2022-02-18 15:28:35 +01:00
0dd1ac70d1 Merge pull request 'master' (#123) from master into Docker
Reviewed-on: SGD-Nord/konova#123
2022-02-18 15:21:29 +01:00
2fcc41bf4a Merge pull request 'master' (#113) from master into Docker
Reviewed-on: SGD-Nord/konova#113
2022-02-11 16:10:47 +01:00
b4e75fa2cd Revert "Revert accidental docker->master merge"
This reverts commit db834b581e.
2022-02-04 15:17:08 +01:00
107 changed files with 2336 additions and 1024 deletions

View File

@@ -22,6 +22,7 @@ class TimespanReportForm(BaseForm):
date_from = forms.DateField(
label_suffix="",
label=_("From"),
help_text=_("Entries created from..."),
widget=forms.DateInput(
attrs={
"type": "date",
@@ -34,6 +35,7 @@ class TimespanReportForm(BaseForm):
date_to = forms.DateField(
label_suffix="",
label=_("To"),
help_text=_("Entries created until..."),
widget=forms.DateInput(
attrs={
"type": "date",

View File

@@ -15,40 +15,40 @@
<thead>
<tr>
<th scope="col">{% trans 'Area of responsibility' %}</th>
<th scope="col">{% trans 'Total' %}</th>
<th scope="col">{% trans 'Number single areas' %}</th>
<th scope="col">{% fa5_icon 'star' %} {% trans 'Checked' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col">{% trans 'Number single areas' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{% trans 'Conservation office by law' %}</td>
<td>{{report.compensation_report.queryset_registration_office_unb_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_unb|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_unb_checked_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_unb_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_unb|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_unb_count|default_if_zero:"-"}}</td>
</tr>
<tr>
<td>{% trans 'Land-use planning' %}</td>
<td>{{report.compensation_report.queryset_registration_office_tbp_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_tbp|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_tbp_checked_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_tbp_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_tbp|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_tbp_count|default_if_zero:"-"}}</td>
</tr>
<tr>
<td>{% trans 'Other registration office' %}</td>
<td>{{report.compensation_report.queryset_registration_office_other_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_other|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_other_checked_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_other_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.num_single_surfaces_total_other|default_if_zero:"-"}}</td>
<td>{{report.compensation_report.queryset_registration_office_other_count|default_if_zero:"-"}}</td>
</tr>
<tr>
<td><strong>{% trans 'Total' %}</strong></td>
<td><strong>{{report.compensation_report.queryset_count|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.compensation_report.num_single_surfaces_total|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.compensation_report.queryset_checked_count|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.compensation_report.queryset_recorded_count|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.compensation_report.num_single_surfaces_total|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.compensation_report.queryset_count|default_if_zero:"-"}}</strong></td>
</tr>
</tbody>
</table>

View File

@@ -10,14 +10,14 @@
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="w-25">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col">{% trans 'Total' %}</th>
<th scope="col" class="w-25">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{report.eco_account_report.queryset_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.eco_account_report.queryset_count|default_if_zero:"-"}}</td>
<td>{{report.eco_account_report.queryset_recorded_count|default_if_zero:"-"}}</td>
</tr>
</tbody>
</table>

View File

@@ -1,22 +1,37 @@
{% load i18n fontawesome_5 ksp_filters %}
<h3>{% trans 'Deductions' %}</h3>
<strong>
{% blocktrans %}
Recorded = Counts the deductions whose interventions have been recorded
{% endblocktrans %}
</strong>
<div class="table-container">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %} {% trans 'Surface' %}</th>
<th scope="col" class="w-25">{% trans 'Total' %}</th>
<th scope="col" class="w-25">{% trans 'Total' %} {% trans 'Surface' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %} {% trans 'Surface' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{report.eco_account_report.queryset_deductions_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.eco_account_report.recorded_deductions_sq_m|default_if_zero:"-"}}m²</td>
<td>{{report.eco_account_report.queryset_deductions_count|default_if_zero:"-"}}</td>
<td>{{report.eco_account_report.deductions_sq_m|default_if_zero:"-"}}m²</td>
<td>
{{report.eco_account_report.deductions_sq_m|default_if_zero:"-"}}
{% if report.eco_account_report.deductions_sq_m > 0 %}
{% endif %}
</td>
<td>{{report.eco_account_report.queryset_deductions_recorded_count|default_if_zero:"-"}}</td>
<td>
{{report.eco_account_report.recorded_deductions_sq_m|default_if_zero:"-"}}
{% if report.eco_account_report.recorded_deductions_sq_m > 0 %}
{% endif %}
</td>
</tr>
</tbody>
</table>

View File

@@ -14,16 +14,16 @@
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="w-25">{% trans 'Total' %}</th>
<th scope="col">{% fa5_icon 'star' %} {% trans 'Checked' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col" class="w-25">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{report.intervention_report.queryset_count|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.queryset_checked_count|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.queryset_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.queryset_count|default_if_zero:"-"}}</td>
</tr>
</tbody>
</table>

View File

@@ -5,29 +5,29 @@
<thead>
<tr>
<th class="w-25" scope="col">{% trans 'Compensation type' %}</th>
<th class="w-25" scope="col">{% trans 'Total' %}</th>
<th class="w-25" scope="col">{% fa5_icon 'star' %} {% trans 'Checked' %}</th>
<th class="w-25" scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th class="w-25" scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
<tr>
<th>{% trans 'Compensation' %}</th>
<td>{{report.intervention_report.compensation_sum|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.compensation_sum_checked|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.compensation_sum_recorded|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.compensation_sum|default_if_zero:"-"}}</td>
</tr>
<tr>
<th>{% trans 'Payment' %}</th>
<td>{{report.intervention_report.payment_sum|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.payment_sum_checked|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.payment_sum_recorded|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.payment_sum|default_if_zero:"-"}}</td>
</tr>
<tr>
<th>{% trans 'Deductions' %}</th>
<td>{{report.intervention_report.deduction_sum|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.deduction_sum_checked|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.deduction_sum_recorded|default_if_zero:"-"}}</td>
<td>{{report.intervention_report.deduction_sum|default_if_zero:"-"}}</td>
</tr>
</tbody>
</table>

View File

@@ -13,15 +13,15 @@
<th class="w-25" scope="col">
{% trans 'Law' %}
</th>
<th scope="col">
{% trans 'Total' %}
</th>
<th scope="col">
{% fa5_icon 'star' %} {% trans 'Checked' %}
</th>
<th scope="col">
{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}
</th>
<th scope="col">
{% trans 'Total' %}
</th>
</tr>
</thead>
<tbody>
@@ -34,16 +34,16 @@
{{law.long_name}}
</small>
</td>
<td>{{law.num|default_if_zero:"-"}}</td>
<td>{{law.num_checked|default_if_zero:"-"}}</td>
<td>{{law.num_recorded|default_if_zero:"-"}}</td>
<td>{{law.num|default_if_zero:"-"}}</td>
</tr>
{% endfor %}
<tr>
<td><strong>{% trans 'Total' %}</strong></td>
<td><strong>{{report.intervention_report.law_sum|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.intervention_report.law_sum_checked|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.intervention_report.law_sum_recorded|default_if_zero:"-"}}</strong></td>
<td><strong>{{report.intervention_report.law_sum|default_if_zero:"-"}}</strong></td>
</tr>
</tbody>
</table>

View File

@@ -14,26 +14,26 @@
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="w-25">{% fa5_icon 'star' %} {% trans 'Type' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
<th scope="col" class="w-25">{% trans 'Type' %}</th>
<th scope="col">{% trans 'Total' %}</th>
<th scope="col">{% fa5_icon 'bookmark' %} {% trans 'Recorded' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{% trans 'Intervention' %}</td>
<td>{{report.old_data_report.queryset_intervention_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_intervention_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_intervention_recorded_count|default_if_zero:"-"}}</td>
</tr>
<tr>
<td>{% trans 'Compensation' %}</td>
<td>{{report.old_data_report.queryset_comps_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_comps_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_comps_recorded_count|default_if_zero:"-"}}</td>
</tr>
<tr>
<td>{% trans 'Eco-account' %}</td>
<td>{{report.old_data_report.queryset_acc_recorded_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_acc_count|default_if_zero:"-"}}</td>
<td>{{report.old_data_report.queryset_acc_recorded_count|default_if_zero:"-"}}</td>
</tr>
</tbody>
</table>

View File

@@ -10,7 +10,7 @@
{% fa5_icon 'pencil-ruler' %}
{% trans 'Old interventions' %}
</h5>
<span>{% trans 'Before' %} 16.06.2018</span>
<span>{% trans 'Binding date before' %} 16.06.2018</span>
</div>
</div>
</div>

View File

@@ -108,7 +108,7 @@ class TempExcelFile:
for _iter_entry in _iter_obj:
j = 0
for _iter_attr in _attrs:
_new_cell = ws.cell(start_cell.row + i, start_cell.column + j, getattr(_iter_entry, _iter_attr))
_new_cell = ws.cell(start_cell.row + i, start_cell.column + j, _iter_entry.get(_iter_attr, "MISSING"))
_new_cell.border = border_style
j += 1
i += 1

View File

@@ -137,22 +137,36 @@ class TimespanReport:
).order_by(
"long_name"
)
# Fetch all law ids which are used by any .legal object of an intervention object
intervention_laws_total = self.queryset.values_list("legal__laws__id")
intervention_laws_checked = self.queryset.filter(checked__isnull=False).values_list("legal__laws__id")
intervention_laws_recorded = self.queryset.filter(recorded__isnull=False).values_list(
"legal__laws__id")
# Count how often which law id appears in the above list, return only the long_name of the law and the resulting
# count (here 'num'). This is for keeping the db fetch as small as possible
# Compute the sum for total, checked and recorded
self.evaluated_laws = laws.annotate(
num=Count("id", filter=Q(id__in=intervention_laws_total)),
num_checked=Count("id", filter=Q(id__in=intervention_laws_checked)),
num_recorded=Count("id", filter=Q(id__in=intervention_laws_recorded)),
).values_list("short_name", "long_name", "num_checked", "num_recorded", "num", named=True)
self.law_sum = self.evaluated_laws.aggregate(sum_num=Sum("num"))["sum_num"]
self.law_sum_checked = self.evaluated_laws.aggregate(sum_num_checked=Sum("num_checked"))["sum_num_checked"]
self.law_sum_recorded = self.evaluated_laws.aggregate(sum_num_recorded=Sum("num_recorded"))["sum_num_recorded"]
evaluated_laws = []
sum_num_checked = 0
sum_num_recorded = 0
sum_num = 0
for law in laws:
num = self.queryset.filter(
legal__laws__atom_id=law.atom_id
).count()
num_checked = self.queryset_checked.filter(
legal__laws__atom_id=law.atom_id
).count()
num_recorded = self.queryset_recorded.filter(
legal__laws__atom_id=law.atom_id
).count()
evaluated_laws.append({
"short_name": law.short_name,
"long_name": law.long_name,
"num": num,
"num_checked": num_checked,
"num_recorded": num_recorded,
})
sum_num += num
sum_num_checked += num_checked
sum_num_recorded += num_recorded
self.evaluated_laws = evaluated_laws
self.law_sum = sum_num
self.law_sum_checked = sum_num_checked
self.law_sum_recorded = sum_num_recorded
def _evaluate_compensations(self):
""" Analyzes the types of compensation distribution

View File

@@ -12,6 +12,7 @@ from django.contrib.gis import geos
from django.urls import reverse
from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
class APIV1UpdateTestCase(BaseAPIV1TestCase):
@@ -63,6 +64,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body))
put_geom.transform(DEFAULT_SRID_RLP)
self.assertEqual(put_geom, self.intervention.geometry.geom)
self.assertEqual(put_props["title"], self.intervention.title)
self.assertNotEqual(modified_on, self.intervention.modified)
@@ -92,6 +94,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body))
put_geom.transform(DEFAULT_SRID_RLP)
self.assertEqual(put_geom, self.compensation.geometry.geom)
self.assertEqual(put_props["title"], self.compensation.title)
self.assertNotEqual(modified_on, self.compensation.modified)
@@ -121,6 +124,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body))
put_geom.transform(DEFAULT_SRID_RLP)
self.assertEqual(put_geom, self.eco_account.geometry.geom)
self.assertEqual(put_props["title"], self.eco_account.title)
self.assertNotEqual(modified_on, self.eco_account.modified)
@@ -152,6 +156,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
put_props = put_body["properties"]
put_geom = geos.fromstr(json.dumps(put_body))
put_geom.transform(DEFAULT_SRID_RLP)
self.assertEqual(put_geom, self.ema.geometry.geom)
self.assertEqual(put_props["title"], self.ema.title)
self.assertNotEqual(modified_on, self.ema.modified)

View File

@@ -12,6 +12,7 @@ from django.contrib.gis import geos
from django.contrib.gis.geos import GEOSGeometry
from django.core.paginator import Paginator
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from konova.utils.message_templates import DATA_UNSHARED
@@ -133,6 +134,8 @@ class AbstractModelAPISerializer:
if isinstance(geojson, dict):
geojson = json.dumps(geojson)
geometry = geos.fromstr(geojson)
if geometry.srid != DEFAULT_SRID_RLP:
geometry.transform(DEFAULT_SRID_RLP)
if geometry.empty:
geometry = None
return geometry

View File

@@ -20,7 +20,7 @@ from compensation.models import CompensationDocument, EcoAccountDocument
from intervention.inputs import CompensationActionTreeCheckboxSelectMultiple, \
CompensationStateTreeRadioSelect
from konova.contexts import BaseContext
from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm
from konova.forms.modals import BaseModalForm, NewDocumentModalForm, RemoveModalForm
from konova.models import DeadlineType
from konova.utils.message_templates import FORM_INVALID, ADDED_COMPENSATION_STATE, \
ADDED_COMPENSATION_ACTION, PAYMENT_EDITED, COMPENSATION_STATE_EDITED, COMPENSATION_ACTION_EDITED, DEADLINE_EDITED

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('compensation', '0007_auto_20220531_1245'),
]
operations = [
migrations.AddField(
model_name='compensation',
name='resubmission',
field=models.ManyToManyField(blank=True, null=True, related_name='_compensation_resubmission_+', to='konova.Resubmission'),
),
migrations.AddField(
model_name='ecoaccount',
name='resubmission',
field=models.ManyToManyField(blank=True, null=True, related_name='_ecoaccount_resubmission_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('compensation', '0008_auto_20220815_0803'),
]
operations = [
migrations.RemoveField(
model_name='compensation',
name='resubmission',
),
migrations.RemoveField(
model_name='ecoaccount',
name='resubmission',
),
migrations.AddField(
model_name='compensation',
name='resubmissions',
field=models.ManyToManyField(blank=True, null=True, related_name='_compensation_resubmissions_+', to='konova.Resubmission'),
),
migrations.AddField(
model_name='ecoaccount',
name='resubmissions',
field=models.ManyToManyField(blank=True, null=True, related_name='_ecoaccount_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.1.3 on 2022-08-15 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('compensation', '0009_auto_20220815_0803'),
]
operations = [
migrations.AlterField(
model_name='compensation',
name='resubmissions',
field=models.ManyToManyField(blank=True, related_name='_compensation_resubmissions_+', to='konova.Resubmission'),
),
migrations.AlterField(
model_name='ecoaccount',
name='resubmissions',
field=models.ManyToManyField(blank=True, related_name='_ecoaccount_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -14,21 +14,22 @@ from user.models import User, Team
from django.db import models, transaction
from django.db.models import QuerySet, Sum
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from compensation.managers import CompensationManager
from compensation.models import CompensationState, CompensationAction
from compensation.utils.quality import CompensationQualityChecker
from konova.models import BaseObject, AbstractDocument, Deadline, generate_document_file_upload_path, \
GeoReferencedMixin
from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE
GeoReferencedMixin, DeadlineType, ResubmitableObjectMixin
from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION, COMPENSATION_REMOVED_TEMPLATE, \
DOCUMENT_REMOVED_TEMPLATE, COMPENSATION_EDITED_TEMPLATE, DEADLINE_REMOVED, ADDED_DEADLINE, \
DOCUMENT_REMOVED_TEMPLATE, DEADLINE_REMOVED, ADDED_DEADLINE, \
COMPENSATION_ACTION_REMOVED, COMPENSATION_STATE_REMOVED, INTERVENTION_HAS_REVOCATIONS_TEMPLATE
from user.models import UserActionLogEntry
class AbstractCompensation(BaseObject, GeoReferencedMixin):
class AbstractCompensation(BaseObject,
GeoReferencedMixin,
ResubmitableObjectMixin
):
"""
Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation,
EMA or EcoAccount.
@@ -226,6 +227,15 @@ class AbstractCompensation(BaseObject, GeoReferencedMixin):
request = self.set_geometry_conflict_message(request)
return request
def get_finished_deadlines(self):
""" Getter for FINISHED-deadlines
Returns:
queryset (QuerySet): The finished deadlines
"""
return self.deadlines.filter(
type=DeadlineType.FINISHED
)
class CEFMixin(models.Model):
""" Provides CEF flag as Mixin

View File

@@ -21,6 +21,7 @@ from compensation.models.compensation import AbstractCompensation, PikMixin
from compensation.utils.quality import EcoAccountQualityChecker
from konova.models import ShareableObjectMixin, RecordableObjectMixin, AbstractDocument, BaseResource, \
generate_document_file_upload_path
from konova.tasks import celery_send_mail_deduction_changed, celery_send_mail_deduction_changed_team
class EcoAccount(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin, PikMixin):
@@ -161,6 +162,25 @@ class EcoAccount(AbstractCompensation, ShareableObjectMixin, RecordableObjectMix
"""
return reverse("compensation:acc:share", args=(self.id, self.access_token))
def send_notification_mail_on_deduction_change(self, data_change: dict):
""" Sends notification mails for changes on the deduction
Args:
data_change ():
Returns:
"""
# Send mail
shared_users = self.shared_users.values_list("id", flat=True)
for user_id in shared_users:
celery_send_mail_deduction_changed.delay(self.identifier, self.title, user_id, data_change)
# Send mail
shared_teams = self.shared_teams.values_list("id", flat=True)
for team_id in shared_teams:
celery_send_mail_deduction_changed_team.delay(self.identifier, self.title, team_id, data_change)
class EcoAccountDocument(AbstractDocument):
"""
@@ -251,4 +271,4 @@ class EcoAccountDeduction(BaseResource):
if user is not None:
self.intervention.mark_as_edited(user, edit_comment=DEDUCTION_REMOVED)
self.account.mark_as_edited(user, edit_comment=DEDUCTION_REMOVED)
super().delete(*args, **kwargs)
super().delete(*args, **kwargs)

View File

@@ -12,6 +12,9 @@
</button>
</a>
{% if has_access %}
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Resubmission' %}" data-form-url="{% url 'compensation:resubmission-create' obj.id %}">
{% fa5_icon 'bell' %}
</button>
{% if is_default_member %}
<a href="{% url 'compensation:edit' obj.id %}" class="mr-2">
<button class="btn btn-default" title="{% trans 'Edit' %}">

View File

@@ -20,6 +20,11 @@
</div>
</div>
</div>
{% if not has_finished_deadlines %}
<div class="alert alert-danger mb-0">
{% trans 'Missing finished deadline ' %}
</div>
{% endif %}
<div class="card-body scroll-300 p-2">
<table class="table table-hover">
<thead>

View File

@@ -123,9 +123,16 @@
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.intervention.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% if has_access %}
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% else %}
<span title="{% trans 'The data must be shared with you, if you want to see which other users have shared access as well.' %}">
{% fa5_icon 'eye-slash' %}
{{obj.users.count}} {% trans 'other users' %}
</span>
{% endif %}
</td>
</tr>
</table>

View File

@@ -12,6 +12,9 @@
</button>
</a>
{% if has_access %}
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Resubmission' %}" data-form-url="{% url 'compensation:acc:resubmission-create' obj.id %}">
{% fa5_icon 'bell' %}
</button>
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Share' %}" data-form-url="{% url 'compensation:acc:share-create' obj.id %}">
{% fa5_icon 'share-alt' %}
</button>

View File

@@ -20,6 +20,11 @@
</div>
</div>
</div>
{% if not has_finished_deadlines %}
<div class="alert alert-danger mb-0">
{% trans 'Missing finished deadline ' %}
</div>
{% endif %}
<div class="card-body scroll-300 p-2">
<table class="table table-hover">
<thead>

View File

@@ -101,9 +101,16 @@
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% if has_access %}
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% else %}
<span title="{% trans 'The data must be shared with you, if you want to see which other users have shared access as well.' %}">
{% fa5_icon 'eye-slash' %}
{{obj.users.count}} {% trans 'other users' %}
</span>
{% endif %}
</td>
</tr>
</table>

View File

@@ -31,6 +31,7 @@ urlpatterns = [
path('<id>/deadline/<deadline_id>/edit', deadline_edit_view, name='deadline-edit'),
path('<id>/deadline/<deadline_id>/remove', deadline_remove_view, name='deadline-remove'),
path('<id>/report', report_view, name='report'),
path('<id>/resub', create_resubmission_view, name='resubmission-create'),
# Documents
path('<id>/document/new/', new_document_view, name='new-doc'),

View File

@@ -19,6 +19,7 @@ urlpatterns = [
path('<id>/report', report_view, name='report'),
path('<id>/edit', edit_view, name='edit'),
path('<id>/remove', remove_view, name='remove'),
path('<id>/resub', create_resubmission_view, name='resubmission-create'),
path('<id>/state/new', state_new_view, name='new-state'),
path('<id>/state/<state_id>/edit', state_edit_view, name='state-edit'),

View File

@@ -19,6 +19,7 @@ class CompensationQualityChecker(AbstractQualityChecker):
self._check_states()
self._check_actions()
self._check_geometry()
self._check_deadlines()
self.valid = len(self.messages) == 0
def _check_states(self):
@@ -47,6 +48,16 @@ class CompensationQualityChecker(AbstractQualityChecker):
if not self.obj.actions.all():
self._add_missing_attr_name(_con("Compensation", "Actions"))
def _check_deadlines(self):
""" Checks data quality for related Deadline objects
Returns:
"""
finished_deadlines = self.obj.get_finished_deadlines()
if not finished_deadlines.exists():
self._add_missing_attr_name(_("Finished deadlines"))
class EcoAccountQualityChecker(CompensationQualityChecker):
def run_check(self):

View File

@@ -14,7 +14,9 @@ from compensation.tables import CompensationTable
from intervention.models import Intervention
from konova.contexts import BaseContext
from konova.decorators import *
from konova.forms import RemoveModalForm, SimpleGeomForm, RemoveDeadlineModalForm, EditDocumentModalForm
from konova.forms.modals import RemoveModalForm,RemoveDeadlineModalForm, EditDocumentModalForm, \
ResubmissionModalForm
from konova.forms import SimpleGeomForm
from konova.models import Deadline
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
from konova.utils.documents import get_document, remove_document
@@ -240,6 +242,7 @@ def detail_view(request: HttpRequest, id: str):
"is_ets_member": in_group(_user, ETS_GROUP),
"LANIS_LINK": comp.get_LANIS_link(),
TAB_TITLE_IDENTIFIER: f"{comp.identifier} - {comp.title}",
"has_finished_deadlines": comp.get_finished_deadlines().exists(),
}
context = BaseContext(request, context).context
return render(request, template, context)
@@ -655,3 +658,26 @@ def report_view(request: HttpRequest, id: str):
}
context = BaseContext(request, context).context
return render(request, template, context)
@login_required
@default_group_required
@shared_access_required(Compensation, "id")
def create_resubmission_view(request: HttpRequest, id: str):
""" Renders resubmission form for a compensation
Args:
request (HttpRequest): The incoming request
id (str): Compensation's id
Returns:
"""
com = get_object_or_404(Compensation, id=id)
form = ResubmissionModalForm(request.POST or None, instance=com, request=request)
form.action_url = reverse("compensation:resubmission-create", args=(id,))
return form.process_request(
request,
msg_success=_("Resubmission set"),
redirect_url=reverse("compensation:detail", args=(id,))
)

View File

@@ -25,14 +25,15 @@ from intervention.forms.modalForms import NewDeductionModalForm, ShareModalForm,
from konova.contexts import BaseContext
from konova.decorators import any_group_check, default_group_required, conservation_office_group_required, \
shared_access_required
from konova.forms import RemoveModalForm, SimpleGeomForm, NewDocumentModalForm, RecordModalForm, \
RemoveDeadlineModalForm, EditDocumentModalForm
from konova.forms.modals import RemoveModalForm, RecordModalForm, \
RemoveDeadlineModalForm, EditDocumentModalForm, ResubmissionModalForm
from konova.forms import SimpleGeomForm
from konova.models import Deadline
from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
from konova.utils.documents import get_document, remove_document
from konova.utils.generators import generate_qr_code
from konova.utils.message_templates import IDENTIFIER_REPLACED, FORM_INVALID, DATA_UNSHARED, DATA_UNSHARED_EXPLANATION, \
from konova.utils.message_templates import IDENTIFIER_REPLACED, FORM_INVALID, \
CANCEL_ACC_RECORDED_OR_DEDUCTED, DEDUCTION_REMOVED, DEDUCTION_ADDED, DOCUMENT_ADDED, COMPENSATION_STATE_REMOVED, \
COMPENSATION_STATE_ADDED, COMPENSATION_ACTION_REMOVED, COMPENSATION_ACTION_ADDED, DEADLINE_ADDED, DEADLINE_REMOVED, \
DEDUCTION_EDITED, DOCUMENT_EDITED, COMPENSATION_STATE_EDITED, COMPENSATION_ACTION_EDITED, DEADLINE_EDITED, \
@@ -242,6 +243,7 @@ def detail_view(request: HttpRequest, id: str):
"deductions": deductions,
"actions": actions,
TAB_TITLE_IDENTIFIER: f"{acc.identifier} - {acc.title}",
"has_finished_deadlines": acc.get_finished_deadlines().exists(),
}
context = BaseContext(request, context).context
return render(request, template, context)
@@ -837,4 +839,27 @@ def create_share_view(request: HttpRequest, id: str):
return form.process_request(
request,
msg_success=_("Share settings updated")
)
@login_required
@default_group_required
@shared_access_required(EcoAccount, "id")
def create_resubmission_view(request: HttpRequest, id: str):
""" Renders resubmission form for an eco account
Args:
request (HttpRequest): The incoming request
id (str): EcoAccount's id
Returns:
"""
acc = get_object_or_404(EcoAccount, id=id)
form = ResubmissionModalForm(request.POST or None, instance=acc, request=request)
form.action_url = reverse("compensation:acc:resubmission-create", args=(id,))
return form.process_request(
request,
msg_success=_("Resubmission set"),
redirect_url=reverse("compensation:acc:detail", args=(id,))
)

View File

@@ -15,7 +15,6 @@ from compensation.forms.modalForms import NewPaymentForm, RemovePaymentModalForm
from compensation.models import Payment
from intervention.models import Intervention
from konova.decorators import default_group_required, shared_access_required
from konova.forms import RemoveModalForm
from konova.utils.message_templates import PAYMENT_ADDED, PAYMENT_REMOVED, PAYMENT_EDITED

View File

@@ -5,8 +5,6 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 06.10.21
"""
from dal import autocomplete
from django import forms
from user.models import User
from django.db import transaction
from django.urls import reverse, reverse_lazy
@@ -16,7 +14,8 @@ from compensation.forms.forms import AbstractCompensationForm, CompensationRespo
PikCompensationFormMixin
from ema.models import Ema, EmaDocument
from intervention.models import Responsibility, Handler
from konova.forms import SimpleGeomForm, NewDocumentModalForm
from konova.forms import SimpleGeomForm
from konova.forms.modals import NewDocumentModalForm
from user.models import UserActionLogEntry

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('ema', '0004_ema_is_pik'),
]
operations = [
migrations.AddField(
model_name='ema',
name='resubmission',
field=models.ManyToManyField(blank=True, null=True, related_name='_ema_resubmission_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('ema', '0005_ema_resubmission'),
]
operations = [
migrations.RemoveField(
model_name='ema',
name='resubmission',
),
migrations.AddField(
model_name='ema',
name='resubmissions',
field=models.ManyToManyField(blank=True, null=True, related_name='_ema_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-08-15 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('ema', '0006_auto_20220815_0803'),
]
operations = [
migrations.AlterField(
model_name='ema',
name='resubmissions',
field=models.ManyToManyField(blank=True, related_name='_ema_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -12,6 +12,9 @@
</button>
</a>
{% if has_access %}
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Resubmission' %}" data-form-url="{% url 'ema:resubmission-create' obj.id %}">
{% fa5_icon 'bell' %}
</button>
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Share' %}" data-form-url="{% url 'ema:share-create' obj.id %}">
{% fa5_icon 'share-alt' %}
</button>

View File

@@ -20,6 +20,11 @@
</div>
</div>
</div>
{% if not has_finished_deadlines %}
<div class="alert alert-danger mb-0">
{% trans 'Missing finished deadline ' %}
</div>
{% endif %}
<div class="card-body scroll-300 p-2">
<table class="table table-hover">
<thead>

View File

@@ -87,9 +87,16 @@
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.user.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% if has_access %}
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% else %}
<span title="{% trans 'The data must be shared with you, if you want to see which other users have shared access as well.' %}">
{% fa5_icon 'eye-slash' %}
{{obj.users.count}} {% trans 'other users' %}
</span>
{% endif %}
</td>
</tr>
</table>

View File

@@ -19,6 +19,7 @@ urlpatterns = [
path('<id>/remove', remove_view, name='remove'),
path('<id>/record', record_view, name='record'),
path('<id>/report', report_view, name='report'),
path('<id>/resub', create_resubmission_view, name='resubmission-create'),
path('<id>/state/new', state_new_view, name='new-state'),
path('<id>/state/<state_id>/remove', state_remove_view, name='state-remove'),

View File

@@ -16,8 +16,9 @@ from intervention.forms.modalForms import ShareModalForm
from konova.contexts import BaseContext
from konova.decorators import conservation_office_group_required, shared_access_required
from ema.models import Ema, EmaDocument
from konova.forms import RemoveModalForm, SimpleGeomForm, RecordModalForm, RemoveDeadlineModalForm, \
EditDocumentModalForm
from konova.forms.modals import RemoveModalForm, RecordModalForm, RemoveDeadlineModalForm, \
EditDocumentModalForm, ResubmissionModalForm
from konova.forms import SimpleGeomForm
from konova.models import Deadline
from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
@@ -166,6 +167,7 @@ def detail_view(request: HttpRequest, id: str):
"is_ets_member": in_group(_user, ETS_GROUP),
"LANIS_LINK": ema.get_LANIS_link(),
TAB_TITLE_IDENTIFIER: f"{ema.identifier} - {ema.title}",
"has_finished_deadlines": ema.get_finished_deadlines().exists(),
}
context = BaseContext(request, context).context
return render(request, template, context)
@@ -709,4 +711,27 @@ def deadline_remove_view(request: HttpRequest, id: str, deadline_id: str):
request,
msg_success=DEADLINE_REMOVED,
redirect_url=reverse("ema:detail", args=(id,)) + "#related_data"
)
)
@login_required
@conservation_office_group_required
@shared_access_required(Ema, "id")
def create_resubmission_view(request: HttpRequest, id: str):
""" Renders resubmission form for an EMA
Args:
request (HttpRequest): The incoming request
id (str): EMA's id
Returns:
"""
ema = get_object_or_404(Ema, id=id)
form = ResubmissionModalForm(request.POST or None, instance=ema, request=request)
form.action_url = reverse("ema:resubmission-create", args=(id,))
return form.process_request(
request,
msg_success=_("Resubmission set"),
redirect_url=reverse("ema:detail", args=(id,))
)

View File

@@ -8,6 +8,7 @@ Created on: 02.12.20
from dal import autocomplete
from django import forms
from konova.forms.base_form import BaseForm
from konova.utils.message_templates import EDITED_GENERAL_DATA
from user.models import User
from django.db import transaction
@@ -19,7 +20,7 @@ from codelist.settings import CODELIST_PROCESS_TYPE_ID, CODELIST_LAW_ID, \
CODELIST_REGISTRATION_OFFICE_ID, CODELIST_CONSERVATION_OFFICE_ID, CODELIST_HANDLER_ID
from intervention.inputs import GenerateInput
from intervention.models import Intervention, Legal, Responsibility, Handler
from konova.forms import BaseForm, SimpleGeomForm
from konova.forms.geometry_form import SimpleGeomForm
from user.models import UserActionLogEntry

View File

@@ -19,7 +19,8 @@ from django.utils.translation import gettext_lazy as _
from compensation.models import EcoAccount, EcoAccountDeduction
from intervention.inputs import TextToClipboardInput
from intervention.models import Intervention, InterventionDocument, RevocationDocument
from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm
from konova.forms.modals import BaseModalForm
from konova.forms.modals import NewDocumentModalForm, RemoveModalForm
from konova.utils.general import format_german_float
from konova.utils.user_checks import is_default_group_only
@@ -508,28 +509,44 @@ class EditEcoAccountDeductionModalForm(NewDeductionModalForm):
deduction = self.deduction
form_account = self.cleaned_data.get("account", None)
form_intervention = self.cleaned_data.get("intervention", None)
current_account = deduction.account
current_intervention = deduction.intervention
old_account = deduction.account
old_intervention = deduction.intervention
old_surface = deduction.surface
# If account or intervention has been changed, we put that change in the logs just as if the deduction has
# been removed for this entry. Act as if the deduction is newly created for the new entries
if current_account != form_account:
current_account.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_REMOVED)
if old_account != form_account:
old_account.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_REMOVED)
form_account.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_ADDED)
else:
current_account.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_EDITED)
old_account.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_EDITED)
if current_intervention != form_intervention:
current_intervention.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_REMOVED)
if old_intervention != form_intervention:
old_intervention.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_REMOVED)
form_intervention.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_ADDED)
else:
current_intervention.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_EDITED)
old_intervention.mark_as_edited(self.user, self.request, edit_comment=DEDUCTION_EDITED)
deduction.account = form_account
deduction.intervention = self.cleaned_data.get("intervention", None)
deduction.surface = self.cleaned_data.get("surface", None)
deduction.save()
data_changes = {
"surface": {
"old": old_surface,
"new": deduction.surface,
},
"intervention": {
"old": old_intervention.identifier,
"new": deduction.intervention.identifier,
},
"account": {
"old": old_account.identifier,
"new": deduction.account.identifier,
}
}
old_account.send_notification_mail_on_deduction_change(data_changes)
return deduction

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('intervention', '0004_auto_20220303_0956'),
]
operations = [
migrations.AddField(
model_name='intervention',
name='resubmission',
field=models.ManyToManyField(blank=True, null=True, related_name='_intervention_resubmission_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('intervention', '0005_intervention_resubmission'),
]
operations = [
migrations.RemoveField(
model_name='intervention',
name='resubmission',
),
migrations.AddField(
model_name='intervention',
name='resubmissions',
field=models.ManyToManyField(blank=True, null=True, related_name='_intervention_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-08-15 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('konova', '0014_resubmission'),
('intervention', '0006_auto_20220815_0803'),
]
operations = [
migrations.AlterField(
model_name='intervention',
name='resubmissions',
field=models.ManyToManyField(blank=True, related_name='_intervention_resubmissions_+', to='konova.Resubmission'),
),
]

View File

@@ -26,14 +26,19 @@ from intervention.models.revocation import RevocationDocument, Revocation
from intervention.utils.quality import InterventionQualityChecker
from konova.models import generate_document_file_upload_path, AbstractDocument, BaseObject, \
ShareableObjectMixin, \
RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin
from konova.settings import LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT, DEFAULT_SRID_RLP
RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin, ResubmitableObjectMixin
from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION, DOCUMENT_REMOVED_TEMPLATE, \
PAYMENT_REMOVED, PAYMENT_ADDED, REVOCATION_REMOVED, INTERVENTION_HAS_REVOCATIONS_TEMPLATE
from user.models import UserActionLogEntry
class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin):
class Intervention(BaseObject,
ShareableObjectMixin,
RecordableObjectMixin,
CheckableObjectMixin,
GeoReferencedMixin,
ResubmitableObjectMixin
):
"""
Interventions are e.g. construction sites where nature used to be.
"""

View File

@@ -12,6 +12,9 @@
</button>
</a>
{% if has_access %}
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Resubmission' %}" data-form-url="{% url 'intervention:resubmission-create' obj.id %}">
{% fa5_icon 'bell' %}
</button>
<button class="btn btn-default btn-modal mr-2" title="{% trans 'Share' %}" data-form-url="{% url 'intervention:share-create' obj.id %}">
{% fa5_icon 'share-alt' %}
</button>

View File

@@ -129,9 +129,16 @@
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% if has_access %}
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}
{% else %}
<span title="{% trans 'The data must be shared with you, if you want to see which other users have shared access as well.' %}">
{% fa5_icon 'eye-slash' %}
{{obj.users.count}} {% trans 'other users' %}
</span>
{% endif %}
</td>
</tr>
</table>

View File

@@ -10,7 +10,8 @@ from django.urls import path
from intervention.views import index_view, new_view, detail_view, edit_view, remove_view, new_document_view, share_view, \
create_share_view, remove_revocation_view, new_revocation_view, check_view, log_view, new_deduction_view, \
record_view, remove_document_view, get_document_view, get_revocation_view, new_id_view, report_view, \
remove_deduction_view, remove_compensation_view, edit_deduction_view, edit_revocation_view, edit_document_view
remove_deduction_view, remove_compensation_view, edit_deduction_view, edit_revocation_view, edit_document_view, \
create_resubmission_view
app_name = "intervention"
urlpatterns = [
@@ -26,6 +27,7 @@ urlpatterns = [
path('<id>/check', check_view, name='check'),
path('<id>/record', record_view, name='record'),
path('<id>/report', report_view, name='report'),
path('<id>/resub', create_resubmission_view, name='resubmission-create'),
# Compensations
path('<id>/compensation/<comp_id>/remove', remove_compensation_view, name='remove-compensation'),

View File

@@ -12,7 +12,8 @@ from intervention.models import Intervention, Revocation, InterventionDocument,
from intervention.tables import InterventionTable
from konova.contexts import BaseContext
from konova.decorators import *
from konova.forms import SimpleGeomForm, RemoveModalForm, RecordModalForm, EditDocumentModalForm
from konova.forms import SimpleGeomForm
from konova.forms.modals import RemoveModalForm, RecordModalForm, EditDocumentModalForm, ResubmissionModalForm
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
from konova.utils.documents import remove_document, get_document
from konova.utils.generators import generate_qr_code
@@ -475,6 +476,29 @@ def create_share_view(request: HttpRequest, id: str):
)
@login_required
@default_group_required
@shared_access_required(Intervention, "id")
def create_resubmission_view(request: HttpRequest, id: str):
""" Renders resubmission form for an intervention
Args:
request (HttpRequest): The incoming request
id (str): Intervention's id
Returns:
"""
intervention = get_object_or_404(Intervention, id=id)
form = ResubmissionModalForm(request.POST or None, instance=intervention, request=request)
form.action_url = reverse("intervention:resubmission-create", args=(id,))
return form.process_request(
request,
msg_success=_("Resubmission set"),
redirect_url=reverse("intervention:detail", args=(id,))
)
@login_required
@registration_office_group_required
@shared_access_required(Intervention, "id")

View File

@@ -7,7 +7,7 @@ Created on: 22.07.21
"""
from django.contrib import admin
from konova.models import Geometry, Deadline, GeometryConflict, Parcel, District, Municipal, ParcelGroup
from konova.models import Geometry, Deadline, GeometryConflict, Parcel, District, Municipal, ParcelGroup, Resubmission
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from konova.utils.message_templates import COMPENSATION_REMOVED_TEMPLATE
from user.models import UserAction
@@ -139,6 +139,16 @@ class BaseObjectAdmin(BaseResourceAdmin, DeletableObjectMixinAdmin):
]
class ResubmissionAdmin(BaseResourceAdmin):
list_display = [
"resubmit_on"
]
fields = [
"comment",
"resubmit_on",
"resubmission_sent",
]
# Outcommented for a cleaner admin backend on production
#admin.site.register(Geometry, GeometryAdmin)
@@ -148,3 +158,4 @@ class BaseObjectAdmin(BaseResourceAdmin, DeletableObjectMixinAdmin):
#admin.site.register(ParcelGroup, ParcelGroupAdmin)
#admin.site.register(GeometryConflict, GeometryConflictAdmin)
#admin.site.register(Deadline, DeadlineAdmin)
#admin.site.register(Resubmission, ResubmissionAdmin)

View File

@@ -7,7 +7,8 @@ Created on: 16.11.20
"""
from django.http import HttpRequest
from konova.sub_settings.context_settings import BASE_TITLE, HELP_LINK, BASE_FRONTEND_TITLE, TAB_TITLE_IDENTIFIER
from konova.sub_settings.context_settings import BASE_TITLE, HELP_LINK, BASE_FRONTEND_TITLE, TAB_TITLE_IDENTIFIER, \
IMPRESSUM_LINK
from konova.sub_settings.django_settings import EMAIL_REPLY_TO
@@ -25,6 +26,7 @@ class BaseContext:
"user": request.user,
"current_role": None,
"help_link": HELP_LINK,
"impressum_link": IMPRESSUM_LINK,
"CONTACT_MAIL": EMAIL_REPLY_TO,
}

View File

@@ -1,687 +0,0 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 16.11.20
"""
import json
from abc import abstractmethod
from bootstrap_modal_forms.forms import BSModalForm
from bootstrap_modal_forms.utils import is_ajax
from django import forms
from django.contrib import messages
from django.contrib.gis import gdal
from django.db.models.fields.files import FieldFile
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from user.models import User
from django.contrib.gis.forms import MultiPolygonField
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.db import transaction
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from konova.contexts import BaseContext
from konova.models import BaseObject, Geometry, RecordableObjectMixin, AbstractDocument
from konova.settings import DEFAULT_SRID
from konova.tasks import celery_update_parcels
from konova.utils.message_templates import FORM_INVALID, FILE_TYPE_UNSUPPORTED, FILE_SIZE_TOO_LARGE, DOCUMENT_EDITED
from user.models import UserActionLogEntry
class BaseForm(forms.Form):
"""
Basic form for that holds attributes needed in all other forms
"""
template = None
action_url = None
action_btn_label = _("Save")
form_title = None
cancel_redirect = None
form_caption = None
instance = None # The data holding model object
request = None
form_attrs = {} # Holds additional attributes, that can be used in the template
has_required_fields = False # Automatically set. Triggers hint rendering in templates
show_cancel_btn = True
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
if self.request is not None:
self.user = self.request.user
# Check for required fields
for _field_name, _field_val in self.fields.items():
if _field_val.required:
self.has_required_fields = True
break
self.check_for_recorded_instance()
@abstractmethod
def save(self):
# To be implemented in subclasses!
pass
def disable_form_field(self, field: str):
"""
Disables a form field for user editing
"""
self.fields[field].widget.attrs["readonly"] = True
self.fields[field].disabled = True
self.fields[field].widget.attrs["title"] = _("Not editable")
def initialize_form_field(self, field: str, val):
"""
Initializes a form field with a value
"""
self.fields[field].initial = val
def add_placeholder_for_field(self, field: str, val):
"""
Adds a placeholder to a field after initialization without the need to redefine the form widget
Args:
field (str): Field name
val (str): Placeholder
Returns:
"""
self.fields[field].widget.attrs["placeholder"] = val
def load_initial_data(self, form_data: dict, disabled_fields: list = None):
""" Initializes form data from instance
Inserts instance data into form and disables form fields
Returns:
"""
if self.instance is None:
return
for k, v in form_data.items():
self.initialize_form_field(k, v)
if disabled_fields:
for field in disabled_fields:
self.disable_form_field(field)
def add_widget_html_class(self, field: str, cls: str):
""" Adds a HTML class string to the widget of a field
Args:
field (str): The field's name
cls (str): The new class string
Returns:
"""
set_class = self.fields[field].widget.attrs.get("class", "")
if cls in set_class:
return
else:
set_class += " " + cls
self.fields[field].widget.attrs["class"] = set_class
def remove_widget_html_class(self, field: str, cls: str):
""" Removes a HTML class string from the widget of a field
Args:
field (str): The field's name
cls (str): The new class string
Returns:
"""
set_class = self.fields[field].widget.attrs.get("class", "")
set_class = set_class.replace(cls, "")
self.fields[field].widget.attrs["class"] = set_class
def check_for_recorded_instance(self):
""" Checks if the instance is recorded and runs some special logic if yes
If the instance is recorded, the form shall not display any possibility to
edit any data. Instead, the users should get some information about why they can not edit anything.
There are situations where the form should be rendered regularly,
e.g deduction forms for (recorded) eco accounts.
Returns:
"""
from intervention.forms.modalForms import NewDeductionModalForm, EditEcoAccountDeductionModalForm, \
RemoveEcoAccountDeductionModalForm
is_none = self.instance is None
is_other_data_type = not isinstance(self.instance, BaseObject)
is_deduction_form = isinstance(
self,
(
NewDeductionModalForm,
EditEcoAccountDeductionModalForm,
RemoveEcoAccountDeductionModalForm,
)
)
if is_none or is_other_data_type or is_deduction_form:
# Do nothing
return
if self.instance.is_recorded:
self.template = "form/recorded_no_edit.html"
class RemoveForm(BaseForm):
check = forms.BooleanField(
label=_("Confirm"),
label_suffix=_(""),
required=True,
)
def __init__(self, *args, **kwargs):
self.object_to_remove = kwargs.pop("object_to_remove", None)
self.remove_post_url = kwargs.pop("remove_post_url", "")
self.cancel_url = kwargs.pop("cancel_url", "")
super().__init__(*args, **kwargs)
self.form_title = _("Remove")
if self.object_to_remove is not None:
self.form_caption = _("You are about to remove {} {}").format(self.object_to_remove.__class__.__name__, self.object_to_remove)
self.action_url = self.remove_post_url
self.cancel_redirect = self.cancel_url
def is_checked(self) -> bool:
return self.cleaned_data.get("check", False)
def save(self, user: User):
""" Perform generic removing by running the form typical 'save()' method
Args:
user (User): The performing user
Returns:
"""
if self.object_to_remove is not None and self.is_checked():
with transaction.atomic():
self.object_to_remove.is_active = False
action = UserActionLogEntry.get_deleted_action(user)
self.object_to_remove.deleted = action
self.object_to_remove.save()
return self.object_to_remove
class BaseModalForm(BaseForm, BSModalForm):
""" A specialzed form class for modal form handling
"""
is_modal_form = True
render_submit = True
template = "modal/modal_form.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.action_btn_label = _("Continue")
def process_request(self, request: HttpRequest, msg_success: str = _("Object removed"), msg_error: str = FORM_INVALID, redirect_url: str = None):
""" Generic processing of request
Wraps the request processing logic, so we don't need the same code everywhere a RemoveModalForm is being used
Args:
request (HttpRequest): The incoming request
msg_success (str): The message in case of successful removing
msg_error (str): The message in case of an error
Returns:
"""
redirect_url = redirect_url if redirect_url is not None else request.META.get("HTTP_REFERER", "home")
template = self.template
if request.method == "POST":
if self.is_valid():
if not is_ajax(request.META):
# Modal forms send one POST for checking on data validity. This can be used to return possible errors
# on the form. A second POST (if no errors occured) is sent afterwards and needs to process the
# saving/commiting of the data to the database. is_ajax() performs this check. The first request is
# an ajax call, the second is a regular form POST.
self.save()
messages.success(
request,
msg_success
)
return HttpResponseRedirect(redirect_url)
else:
context = {
"form": self,
}
context = BaseContext(request, context).context
return render(request, template, context)
elif request.method == "GET":
context = {
"form": self,
}
context = BaseContext(request, context).context
return render(request, template, context)
else:
raise NotImplementedError
class SimpleGeomForm(BaseForm):
""" A geometry form for rendering geometry read-only using a widget
"""
read_only = True
geom = MultiPolygonField(
srid=DEFAULT_SRID_RLP,
label=_("Geometry"),
help_text=_(""),
label_suffix="",
required=False,
disabled=False,
)
def __init__(self, *args, **kwargs):
self.read_only = kwargs.pop("read_only", True)
super().__init__(*args, **kwargs)
# Initialize geometry
try:
geom = self.instance.geometry.geom
self.empty = geom.empty
if self.empty:
raise AttributeError
geojson = self.instance.geometry.as_feature_collection(srid=DEFAULT_SRID_RLP)
geom = json.dumps(geojson)
except AttributeError:
# If no geometry exists for this form, we simply set the value to None and zoom to the maximum level
geom = ""
self.empty = True
self.initialize_form_field("geom", geom)
def is_valid(self):
super().is_valid()
is_valid = True
# Get geojson from form
geom = self.data["geom"]
if geom is None or len(geom) == 0:
# empty geometry is a valid geometry
return is_valid
geom = json.loads(geom)
# Write submitted data back into form field to make sure invalid geometry
# will be rendered again on failed submit
self.initialize_form_field("geom", self.data["geom"])
# Read geojson into gdal geometry
# HINT: This can be simplified if the geojson format holds data in epsg:4326 (GDAL provides direct creation for
# this case)
features = []
features_json = geom.get("features", [])
for feature in features_json:
g = gdal.OGRGeometry(json.dumps(feature.get("geometry", feature)), srs=DEFAULT_SRID_RLP)
if g.geom_type not in ["Polygon", "MultiPolygon"]:
self.add_error("geom", _("Only surfaces allowed. Points or lines must be buffered."))
is_valid = False
return is_valid
polygon = Polygon.from_ewkt(g.ewkt)
is_valid = polygon.valid
if not is_valid:
self.add_error("geom", polygon.valid_reason)
return is_valid
features.append(polygon)
form_geom = MultiPolygon(srid=DEFAULT_SRID_RLP)
for feature in features:
form_geom = form_geom.union(feature)
# Make sure to convert into a MultiPolygon. Relevant if a single Polygon is provided.
if form_geom.geom_type != "MultiPolygon":
form_geom = MultiPolygon(form_geom, srid=DEFAULT_SRID_RLP)
# Write unioned Multipolygon into cleaned data
if self.cleaned_data is None:
self.cleaned_data = {}
self.cleaned_data["geom"] = form_geom.ewkt
return is_valid
def save(self, action: UserActionLogEntry):
""" Saves the form's geometry
Creates a new geometry entry if none is set, yet
Args:
action ():
Returns:
"""
try:
if self.instance is None or self.instance.geometry is None:
raise LookupError
geometry = self.instance.geometry
geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID))
geometry.modified = action
geometry.save()
except LookupError:
# No geometry or linked instance holding a geometry exist --> create a new one!
geometry = Geometry.objects.create(
geom=self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID)),
created=action,
)
# Start the parcel update procedure in a background process
celery_update_parcels.delay(geometry.id)
return geometry
class RemoveModalForm(BaseModalForm):
""" Generic removing modal form
Can be used for anything, where removing shall be confirmed by the user a second time.
"""
confirm = forms.BooleanField(
label=_("Confirm"),
label_suffix=_(""),
widget=forms.CheckboxInput(),
required=True,
)
def __init__(self, *args, **kwargs):
self.template = "modal/modal_form.html"
super().__init__(*args, **kwargs)
self.form_title = _("Remove")
self.form_caption = _("Are you sure?")
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
self.fields["confirm"].widget.attrs["class"] = ""
def save(self):
if isinstance(self.instance, BaseObject):
self.instance.mark_as_deleted(self.user)
else:
# If the class does not provide restorable delete functionality, we must delete the entry finally
self.instance.delete()
class RemoveDeadlineModalForm(RemoveModalForm):
""" Removing modal form for deadlines
Can be used for anything, where removing shall be confirmed by the user a second time.
"""
deadline = None
def __init__(self, *args, **kwargs):
deadline = kwargs.pop("deadline", None)
self.deadline = deadline
super().__init__(*args, **kwargs)
def save(self):
self.instance.remove_deadline(self)
class NewDocumentModalForm(BaseModalForm):
""" Modal form for new documents
"""
title = forms.CharField(
label=_("Title"),
label_suffix=_(""),
max_length=500,
widget=forms.TextInput(
attrs={
"class": "form-control",
}
)
)
creation_date = forms.DateField(
label=_("Created on"),
label_suffix=_(""),
help_text=_("When has this file been created? Important for photos."),
widget=forms.DateInput(
attrs={
"type": "date",
"data-provide": "datepicker",
"class": "form-control",
},
format="%d.%m.%Y"
)
)
file = forms.FileField(
label=_("File"),
label_suffix=_(""),
help_text=_("Allowed formats: pdf, jpg, png. Max size 15 MB."),
widget=forms.FileInput(
attrs={
"class": "form-control-file",
}
),
)
comment = forms.CharField(
required=False,
max_length=200,
label=_("Comment"),
label_suffix=_(""),
help_text=_("Additional comment, maximum {} letters").format(200),
widget=forms.Textarea(
attrs={
"cols": 30,
"rows": 5,
"class": "form-control",
}
)
)
document_model = None
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Add new document")
self.form_caption = _("")
self.form_attrs = {
"enctype": "multipart/form-data", # important for file upload
}
if not self.document_model:
raise NotImplementedError("Unsupported document type for {}".format(self.instance.__class__))
def is_valid(self):
super_valid = super().is_valid()
_file = self.cleaned_data.get("file", None)
if _file is None or isinstance(_file, FieldFile):
# FieldFile declares that no new file has been uploaded and we do not need to check on the file again
return super_valid
mime_type_valid = self.document_model.is_mime_type_valid(_file)
if not mime_type_valid:
self.add_error(
"file",
FILE_TYPE_UNSUPPORTED
)
file_size_valid = self.document_model.is_file_size_valid(_file)
if not file_size_valid:
self.add_error(
"file",
FILE_SIZE_TOO_LARGE
)
file_valid = mime_type_valid and file_size_valid
return super_valid and file_valid
def save(self):
with transaction.atomic():
action = UserActionLogEntry.get_created_action(self.user)
edited_action = UserActionLogEntry.get_edited_action(self.user, _("Added document"))
doc = self.document_model.objects.create(
created=action,
title=self.cleaned_data["title"],
comment=self.cleaned_data["comment"],
file=self.cleaned_data["file"],
date_of_creation=self.cleaned_data["creation_date"],
instance=self.instance,
)
self.instance.log.add(edited_action)
self.instance.modified = edited_action
self.instance.save()
return doc
class EditDocumentModalForm(NewDocumentModalForm):
document = None
document_model = AbstractDocument
def __init__(self, *args, **kwargs):
self.document = kwargs.pop("document", None)
super().__init__(*args, **kwargs)
self.form_title = _("Edit document")
form_data = {
"title": self.document.title,
"comment": self.document.comment,
"creation_date": str(self.document.date_of_creation),
"file": self.document.file,
}
self.load_initial_data(form_data)
def save(self):
with transaction.atomic():
document = self.document
file = self.cleaned_data.get("file", None)
document.title = self.cleaned_data.get("title", None)
document.comment = self.cleaned_data.get("comment", None)
document.date_of_creation = self.cleaned_data.get("creation_date", None)
if not isinstance(file, FieldFile):
document.replace_file(file)
document.save()
self.instance.mark_as_edited(self.user, self.request, edit_comment=DOCUMENT_EDITED)
return document
class RecordModalForm(BaseModalForm):
""" Modal form for recording data
"""
confirm = forms.BooleanField(
label=_("Confirm record"),
label_suffix="",
widget=forms.CheckboxInput(),
required=True,
)
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)
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
self.fields["confirm"].widget.attrs["class"] = ""
if self.instance.recorded:
# unrecord!
self.fields["confirm"].label = _("Confirm unrecord")
self.form_title = _("Unrecord data")
self.form_caption = _("I, {} {}, confirm that this data must be unrecorded.").format(self.user.first_name, self.user.last_name)
if not isinstance(self.instance, RecordableObjectMixin):
raise NotImplementedError
def is_valid(self):
""" Checks for instance's validity and data quality
Returns:
"""
from intervention.models import Intervention
super_val = super().is_valid()
if self.instance.recorded:
# If user wants to unrecord an already recorded dataset, we do not need to perform custom checks
return super_val
checker = self.instance.quality_check()
for msg in checker.messages:
self.add_error(
"confirm",
msg
)
valid = checker.valid
# Special case: Intervention
# Add direct checks for related compensations
if isinstance(self.instance, Intervention):
comps_valid = self._are_compensations_valid()
valid = valid and comps_valid
return super_val and valid
def _are_deductions_valid(self):
""" Performs validity checks on deductions and their eco-account
Returns:
"""
deductions = self.instance.deductions.all()
for deduction in deductions:
checker = deduction.account.quality_check()
for msg in checker.messages:
self.add_error(
"confirm",
f"{deduction.account.identifier}: {msg}"
)
return checker.valid
return True
def _are_compensations_valid(self):
""" Runs a special case for intervention-compensations validity
Returns:
"""
comps = self.instance.compensations.filter(
deleted=None,
)
comps_valid = True
for comp in comps:
checker = comp.quality_check()
comps_valid = comps_valid and checker.valid
for msg in checker.messages:
self.add_error(
"confirm",
f"{comp.identifier}: {msg}"
)
deductions_valid = self._are_deductions_valid()
return comps_valid and deductions_valid
def save(self):
with transaction.atomic():
if self.cleaned_data["confirm"]:
if self.instance.recorded:
self.instance.set_unrecorded(self.user)
else:
self.instance.set_recorded(self.user)
return self.instance
def check_for_recorded_instance(self):
""" Overwrite the check method for doing nothing on the RecordModalForm
Returns:
"""
pass

11
konova/forms/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from .base_form import *
from .geometry_form import *
from .remove_form import *

157
konova/forms/base_form.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from abc import abstractmethod
from django import forms
from django.utils.translation import gettext_lazy as _
from compensation.models import EcoAccount
from konova.models import BaseObject
class BaseForm(forms.Form):
"""
Basic form for that holds attributes needed in all other forms
"""
template = None
action_url = None
action_btn_label = _("Save")
form_title = None
cancel_redirect = None
form_caption = None
instance = None # The data holding model object
request = None
form_attrs = {} # Holds additional attributes, that can be used in the template
has_required_fields = False # Automatically set. Triggers hint rendering in templates
show_cancel_btn = True
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
if self.request is not None:
self.user = self.request.user
# Check for required fields
for _field_name, _field_val in self.fields.items():
if _field_val.required:
self.has_required_fields = True
break
self.check_for_recorded_instance()
@abstractmethod
def save(self):
# To be implemented in subclasses!
pass
def disable_form_field(self, field: str):
"""
Disables a form field for user editing
"""
self.fields[field].widget.attrs["readonly"] = True
self.fields[field].disabled = True
self.fields[field].widget.attrs["title"] = _("Not editable")
def initialize_form_field(self, field: str, val):
"""
Initializes a form field with a value
"""
self.fields[field].initial = val
def add_placeholder_for_field(self, field: str, val):
"""
Adds a placeholder to a field after initialization without the need to redefine the form widget
Args:
field (str): Field name
val (str): Placeholder
Returns:
"""
self.fields[field].widget.attrs["placeholder"] = val
def load_initial_data(self, form_data: dict, disabled_fields: list = None):
""" Initializes form data from instance
Inserts instance data into form and disables form fields
Returns:
"""
if self.instance is None:
return
for k, v in form_data.items():
self.initialize_form_field(k, v)
if disabled_fields:
for field in disabled_fields:
self.disable_form_field(field)
def add_widget_html_class(self, field: str, cls: str):
""" Adds a HTML class string to the widget of a field
Args:
field (str): The field's name
cls (str): The new class string
Returns:
"""
set_class = self.fields[field].widget.attrs.get("class", "")
if cls in set_class:
return
else:
set_class += " " + cls
self.fields[field].widget.attrs["class"] = set_class
def remove_widget_html_class(self, field: str, cls: str):
""" Removes a HTML class string from the widget of a field
Args:
field (str): The field's name
cls (str): The new class string
Returns:
"""
set_class = self.fields[field].widget.attrs.get("class", "")
set_class = set_class.replace(cls, "")
self.fields[field].widget.attrs["class"] = set_class
def check_for_recorded_instance(self):
""" Checks if the instance is recorded and runs some special logic if yes
If the instance is recorded, the form shall not display any possibility to
edit any data. Instead, the users should get some information about why they can not edit anything.
There are situations where the form should be rendered regularly,
e.g deduction forms for (recorded) eco accounts.
Returns:
"""
from intervention.forms.modalForms import NewDeductionModalForm, EditEcoAccountDeductionModalForm, \
RemoveEcoAccountDeductionModalForm
from konova.forms.modals.resubmission_form import ResubmissionModalForm
is_none = self.instance is None
is_other_data_type = not isinstance(self.instance, BaseObject)
is_deduction_form_from_account = isinstance(
self,
(
NewDeductionModalForm,
ResubmissionModalForm,
EditEcoAccountDeductionModalForm,
RemoveEcoAccountDeductionModalForm,
)
) and isinstance(self.instance, EcoAccount)
if is_none or is_other_data_type or is_deduction_form_from_account:
# Do nothing
return
if self.instance.is_recorded:
self.template = "form/recorded_no_edit.html"

View File

@@ -0,0 +1,133 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
import json
from django.contrib.gis import gdal
from django.contrib.gis.forms import MultiPolygonField
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.utils.translation import gettext_lazy as _
from konova.forms.base_form import BaseForm
from konova.models import Geometry
from konova.tasks import celery_update_parcels
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from user.models import UserActionLogEntry
class SimpleGeomForm(BaseForm):
""" A geometry form for rendering geometry read-only using a widget
"""
read_only = True
geom = MultiPolygonField(
srid=DEFAULT_SRID_RLP,
label=_("Geometry"),
help_text=_(""),
label_suffix="",
required=False,
disabled=False,
)
def __init__(self, *args, **kwargs):
self.read_only = kwargs.pop("read_only", True)
super().__init__(*args, **kwargs)
# Initialize geometry
try:
geom = self.instance.geometry.geom
self.empty = geom.empty
if self.empty:
raise AttributeError
geojson = self.instance.geometry.as_feature_collection(srid=DEFAULT_SRID_RLP)
geom = json.dumps(geojson)
except AttributeError:
# If no geometry exists for this form, we simply set the value to None and zoom to the maximum level
geom = ""
self.empty = True
self.initialize_form_field("geom", geom)
def is_valid(self):
super().is_valid()
is_valid = True
# Get geojson from form
geom = self.data["geom"]
if geom is None or len(geom) == 0:
# empty geometry is a valid geometry
return is_valid
geom = json.loads(geom)
# Write submitted data back into form field to make sure invalid geometry
# will be rendered again on failed submit
self.initialize_form_field("geom", self.data["geom"])
# Read geojson into gdal geometry
# HINT: This can be simplified if the geojson format holds data in epsg:4326 (GDAL provides direct creation for
# this case)
features = []
features_json = geom.get("features", [])
for feature in features_json:
g = gdal.OGRGeometry(json.dumps(feature.get("geometry", feature)), srs=DEFAULT_SRID_RLP)
if g.geom_type not in ["Polygon", "MultiPolygon"]:
self.add_error("geom", _("Only surfaces allowed. Points or lines must be buffered."))
is_valid = False
return is_valid
polygon = Polygon.from_ewkt(g.ewkt)
is_valid = polygon.valid
if not is_valid:
self.add_error("geom", polygon.valid_reason)
return is_valid
features.append(polygon)
form_geom = MultiPolygon(srid=DEFAULT_SRID_RLP)
for feature in features:
form_geom = form_geom.union(feature)
# Make sure to convert into a MultiPolygon. Relevant if a single Polygon is provided.
if form_geom.geom_type != "MultiPolygon":
form_geom = MultiPolygon(form_geom, srid=DEFAULT_SRID_RLP)
# Write unioned Multipolygon into cleaned data
if self.cleaned_data is None:
self.cleaned_data = {}
self.cleaned_data["geom"] = form_geom.ewkt
return is_valid
def save(self, action: UserActionLogEntry):
""" Saves the form's geometry
Creates a new geometry entry if none is set, yet
Args:
action ():
Returns:
"""
try:
if self.instance is None or self.instance.geometry is None:
raise LookupError
geometry = self.instance.geometry
geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID_RLP))
geometry.modified = action
geometry.save()
except LookupError:
# No geometry or linked instance holding a geometry exist --> create a new one!
geometry = Geometry.objects.create(
geom=self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID_RLP)),
created=action,
)
# Start the parcel update procedure in a background process
celery_update_parcels.delay(geometry.id)
return geometry

View File

@@ -0,0 +1,12 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from .base_form import *
from .document_form import *
from .record_form import *
from .remove_form import *
from .resubmission_form import *

View File

@@ -0,0 +1,73 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from bootstrap_modal_forms.forms import BSModalForm
from bootstrap_modal_forms.utils import is_ajax
from django.contrib import messages
from django.http import HttpResponseRedirect, HttpRequest
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from konova.contexts import BaseContext
from konova.forms.base_form import BaseForm
from konova.utils.message_templates import FORM_INVALID
class BaseModalForm(BaseForm, BSModalForm):
""" A specialzed form class for modal form handling
"""
is_modal_form = True
render_submit = True
template = "modal/modal_form.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.action_btn_label = _("Continue")
def process_request(self, request: HttpRequest, msg_success: str = _("Object removed"), msg_error: str = FORM_INVALID, redirect_url: str = None):
""" Generic processing of request
Wraps the request processing logic, so we don't need the same code everywhere a RemoveModalForm is being used
Args:
request (HttpRequest): The incoming request
msg_success (str): The message in case of successful removing
msg_error (str): The message in case of an error
Returns:
"""
redirect_url = redirect_url if redirect_url is not None else request.META.get("HTTP_REFERER", "home")
template = self.template
if request.method == "POST":
if self.is_valid():
if not is_ajax(request.META):
# Modal forms send one POST for checking on data validity. This can be used to return possible errors
# on the form. A second POST (if no errors occured) is sent afterwards and needs to process the
# saving/commiting of the data to the database. is_ajax() performs this check. The first request is
# an ajax call, the second is a regular form POST.
self.save()
messages.success(
request,
msg_success
)
return HttpResponseRedirect(redirect_url)
else:
context = {
"form": self,
}
context = BaseContext(request, context).context
return render(request, template, context)
elif request.method == "GET":
context = {
"form": self,
}
context = BaseContext(request, context).context
return render(request, template, context)
else:
raise NotImplementedError

View File

@@ -0,0 +1,163 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from django import forms
from django.db import transaction
from django.db.models.fields.files import FieldFile
from django.utils.translation import gettext_lazy as _
from konova.forms.modals.base_form import BaseModalForm
from konova.models import AbstractDocument
from konova.utils.message_templates import DOCUMENT_EDITED, FILE_SIZE_TOO_LARGE, FILE_TYPE_UNSUPPORTED
from user.models import UserActionLogEntry
class NewDocumentModalForm(BaseModalForm):
""" Modal form for new documents
"""
title = forms.CharField(
label=_("Title"),
label_suffix=_(""),
max_length=500,
widget=forms.TextInput(
attrs={
"class": "form-control",
}
)
)
creation_date = forms.DateField(
label=_("Created on"),
label_suffix=_(""),
help_text=_("When has this file been created? Important for photos."),
widget=forms.DateInput(
attrs={
"type": "date",
"data-provide": "datepicker",
"class": "form-control",
},
format="%d.%m.%Y"
)
)
file = forms.FileField(
label=_("File"),
label_suffix=_(""),
help_text=_("Allowed formats: pdf, jpg, png. Max size 15 MB."),
widget=forms.FileInput(
attrs={
"class": "form-control-file",
}
),
)
comment = forms.CharField(
required=False,
max_length=200,
label=_("Comment"),
label_suffix=_(""),
help_text=_("Additional comment, maximum {} letters").format(200),
widget=forms.Textarea(
attrs={
"cols": 30,
"rows": 5,
"class": "form-control",
}
)
)
document_model = None
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Add new document")
self.form_caption = _("")
self.form_attrs = {
"enctype": "multipart/form-data", # important for file upload
}
if not self.document_model:
raise NotImplementedError("Unsupported document type for {}".format(self.instance.__class__))
def is_valid(self):
super_valid = super().is_valid()
_file = self.cleaned_data.get("file", None)
if _file is None or isinstance(_file, FieldFile):
# FieldFile declares that no new file has been uploaded and we do not need to check on the file again
return super_valid
mime_type_valid = self.document_model.is_mime_type_valid(_file)
if not mime_type_valid:
self.add_error(
"file",
FILE_TYPE_UNSUPPORTED
)
file_size_valid = self.document_model.is_file_size_valid(_file)
if not file_size_valid:
self.add_error(
"file",
FILE_SIZE_TOO_LARGE
)
file_valid = mime_type_valid and file_size_valid
return super_valid and file_valid
def save(self):
with transaction.atomic():
action = UserActionLogEntry.get_created_action(self.user)
edited_action = UserActionLogEntry.get_edited_action(self.user, _("Added document"))
doc = self.document_model.objects.create(
created=action,
title=self.cleaned_data["title"],
comment=self.cleaned_data["comment"],
file=self.cleaned_data["file"],
date_of_creation=self.cleaned_data["creation_date"],
instance=self.instance,
)
self.instance.log.add(edited_action)
self.instance.modified = edited_action
self.instance.save()
return doc
class EditDocumentModalForm(NewDocumentModalForm):
document = None
document_model = AbstractDocument
def __init__(self, *args, **kwargs):
self.document = kwargs.pop("document", None)
super().__init__(*args, **kwargs)
self.form_title = _("Edit document")
form_data = {
"title": self.document.title,
"comment": self.document.comment,
"creation_date": str(self.document.date_of_creation),
"file": self.document.file,
}
self.load_initial_data(form_data)
def save(self):
with transaction.atomic():
document = self.document
file = self.cleaned_data.get("file", None)
document.title = self.cleaned_data.get("title", None)
document.comment = self.cleaned_data.get("comment", None)
document.date_of_creation = self.cleaned_data.get("creation_date", None)
if not isinstance(file, FieldFile):
document.replace_file(file)
document.save()
self.instance.mark_as_edited(self.user, self.request, edit_comment=DOCUMENT_EDITED)
return document

View File

@@ -0,0 +1,123 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from django import forms
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from konova.forms.modals.base_form import BaseModalForm
from konova.models import RecordableObjectMixin
class RecordModalForm(BaseModalForm):
""" Modal form for recording data
"""
confirm = forms.BooleanField(
label=_("Confirm record"),
label_suffix="",
widget=forms.CheckboxInput(),
required=True,
)
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)
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
self.fields["confirm"].widget.attrs["class"] = ""
if self.instance.recorded:
# unrecord!
self.fields["confirm"].label = _("Confirm unrecord")
self.form_title = _("Unrecord data")
self.form_caption = _("I, {} {}, confirm that this data must be unrecorded.").format(self.user.first_name, self.user.last_name)
if not isinstance(self.instance, RecordableObjectMixin):
raise NotImplementedError
def is_valid(self):
""" Checks for instance's validity and data quality
Returns:
"""
from intervention.models import Intervention
super_val = super().is_valid()
if self.instance.recorded:
# If user wants to unrecord an already recorded dataset, we do not need to perform custom checks
return super_val
checker = self.instance.quality_check()
for msg in checker.messages:
self.add_error(
"confirm",
msg
)
valid = checker.valid
# Special case: Intervention
# Add direct checks for related compensations
if isinstance(self.instance, Intervention):
comps_valid = self._are_compensations_valid()
valid = valid and comps_valid
return super_val and valid
def _are_deductions_valid(self):
""" Performs validity checks on deductions and their eco-account
Returns:
"""
deductions = self.instance.deductions.all()
for deduction in deductions:
checker = deduction.account.quality_check()
for msg in checker.messages:
self.add_error(
"confirm",
f"{deduction.account.identifier}: {msg}"
)
return checker.valid
return True
def _are_compensations_valid(self):
""" Runs a special case for intervention-compensations validity
Returns:
"""
comps = self.instance.compensations.filter(
deleted=None,
)
comps_valid = True
for comp in comps:
checker = comp.quality_check()
comps_valid = comps_valid and checker.valid
for msg in checker.messages:
self.add_error(
"confirm",
f"{comp.identifier}: {msg}"
)
deductions_valid = self._are_deductions_valid()
return comps_valid and deductions_valid
def save(self):
with transaction.atomic():
if self.cleaned_data["confirm"]:
if self.instance.recorded:
self.instance.set_unrecorded(self.user)
else:
self.instance.set_recorded(self.user)
return self.instance
def check_for_recorded_instance(self):
""" Overwrite the check method for doing nothing on the RecordModalForm
Returns:
"""
pass

View File

@@ -0,0 +1,58 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from konova.forms.modals.base_form import BaseModalForm
from konova.models import BaseObject
class RemoveModalForm(BaseModalForm):
""" Generic removing modal form
Can be used for anything, where removing shall be confirmed by the user a second time.
"""
confirm = forms.BooleanField(
label=_("Confirm"),
label_suffix=_(""),
widget=forms.CheckboxInput(),
required=True,
)
def __init__(self, *args, **kwargs):
self.template = "modal/modal_form.html"
super().__init__(*args, **kwargs)
self.form_title = _("Remove")
self.form_caption = _("Are you sure?")
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
self.fields["confirm"].widget.attrs["class"] = ""
def save(self):
if isinstance(self.instance, BaseObject):
self.instance.mark_as_deleted(self.user)
else:
# If the class does not provide restorable delete functionality, we must delete the entry finally
self.instance.delete()
class RemoveDeadlineModalForm(RemoveModalForm):
""" Removing modal form for deadlines
Can be used for anything, where removing shall be confirmed by the user a second time.
"""
deadline = None
def __init__(self, *args, **kwargs):
deadline = kwargs.pop("deadline", None)
self.deadline = deadline
super().__init__(*args, **kwargs)
def save(self):
self.instance.remove_deadline(self)

View File

@@ -0,0 +1,85 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
import datetime
from django import forms
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from konova.forms.modals.base_form import BaseModalForm
from konova.models import Resubmission
class ResubmissionModalForm(BaseModalForm):
date = forms.DateField(
label_suffix=_(""),
label=_("Date"),
help_text=_("When do you want to be reminded?"),
widget=forms.DateInput(
attrs={
"type": "date",
"data-provide": "datepicker",
"class": "form-control",
},
format="%d.%m.%Y"
)
)
comment = forms.CharField(
required=False,
label=_("Comment"),
label_suffix=_(""),
help_text=_("Additional comment"),
widget=forms.Textarea(
attrs={
"cols": 30,
"rows": 5,
"class": "form-control",
}
)
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Resubmission")
self.form_caption = _("Set your resubmission for this entry.")
self.action_url = None
try:
self.resubmission = self.instance.resubmissions.get(
user=self.user
)
self.initialize_form_field("date", str(self.resubmission.resubmit_on))
self.initialize_form_field("comment", self.resubmission.comment)
except ObjectDoesNotExist:
self.resubmission = Resubmission()
def is_valid(self):
super_valid = super().is_valid()
self_valid = True
date = self.cleaned_data.get("date")
today = datetime.date.today()
if date <= today:
self.add_error(
"date",
_("The date should be in the future")
)
self_valid = False
return super_valid and self_valid
def save(self):
with transaction.atomic():
self.resubmission.user = self.user
self.resubmission.resubmit_on = self.cleaned_data.get("date")
self.resubmission.comment = self.cleaned_data.get("comment")
self.resubmission.save()
self.instance.resubmissions.add(self.resubmission)
return self.resubmission

View File

@@ -0,0 +1,54 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from django import forms
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from konova.forms.base_form import BaseForm
from user.models import UserActionLogEntry, User
class RemoveForm(BaseForm):
check = forms.BooleanField(
label=_("Confirm"),
label_suffix=_(""),
required=True,
)
def __init__(self, *args, **kwargs):
self.object_to_remove = kwargs.pop("object_to_remove", None)
self.remove_post_url = kwargs.pop("remove_post_url", "")
self.cancel_url = kwargs.pop("cancel_url", "")
super().__init__(*args, **kwargs)
self.form_title = _("Remove")
if self.object_to_remove is not None:
self.form_caption = _("You are about to remove {} {}").format(self.object_to_remove.__class__.__name__, self.object_to_remove)
self.action_url = self.remove_post_url
self.cancel_redirect = self.cancel_url
def is_checked(self) -> bool:
return self.cleaned_data.get("check", False)
def save(self, user: User):
""" Perform generic removing by running the form typical 'save()' method
Args:
user (User): The performing user
Returns:
"""
if self.object_to_remove is not None and self.is_checked():
with transaction.atomic():
self.object_to_remove.is_active = False
action = UserActionLogEntry.get_deleted_action(user)
self.object_to_remove.deleted = action
self.object_to_remove.save()
return self.object_to_remove

View File

@@ -0,0 +1,46 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
import datetime
from compensation.models import Compensation, EcoAccount
from ema.models import Ema
from intervention.models import Intervention
from konova.management.commands.setup import BaseKonovaCommand
from konova.models import Resubmission
class Command(BaseKonovaCommand):
help = "Checks for resubmissions due now"
def handle(self, *args, **options):
try:
resubmitable_models = [
Intervention,
Compensation,
Ema,
EcoAccount,
]
today = datetime.date.today()
resubmissions = Resubmission.objects.filter(
resubmit_on__lte=today,
resubmission_sent=False,
)
self._write_warning(f"Found {resubmissions.count()} resubmission. Process now...")
for model in resubmitable_models:
all_objs = model.objects.filter(
resubmissions__in=resubmissions
)
self._write_warning(f"Process resubmissions for {all_objs.count()} {model.__name__} entries")
for obj in all_objs:
obj.resubmit()
self._write_success("Mails have been sent.")
resubmissions.delete()
self._write_success("Resubmissions have been deleted.")
except KeyboardInterrupt:
self._break_line()
exit(-1)

View File

@@ -28,4 +28,5 @@ USER_NOTIFICATIONS_NAMES = {
"NOTIFY_ON_SHARED_DATA_RECORDED": _("On shared data recorded"),
"NOTIFY_ON_SHARED_DATA_DELETED": _("On shared data deleted"),
"NOTIFY_ON_SHARED_DATA_CHECKED": _("On shared data checked"),
}
"NOTIFY_ON_DEDUCTION_CHANGES": _("On deduction changes"),
}

View File

@@ -33,6 +33,7 @@ class Migration(migrations.Migration):
dependencies = [
('konova', '0004_auto_20220209_0839'),
('compensation', '0002_auto_20220114_0936'),
]
operations = [

View File

@@ -0,0 +1,35 @@
# Generated by Django 3.1.3 on 2022-07-13 06:01
import django
from django.db import migrations
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
def migrate_geometry_srs(apps, schema_editor):
Geometry = apps.get_model("konova", "Geometry")
all_geoms = Geometry.objects.all()
# Transform all geoms and store in new geom field
for geometry in all_geoms:
geom = geometry.geom
if geom is None:
continue
geom.transform(DEFAULT_SRID_RLP)
geometry.geom_tmp = geom
geometry.save()
class Migration(migrations.Migration):
dependencies = [
('konova', '0011_auto_20220420_1101'),
]
operations = [
migrations.AddField(
model_name="geometry",
name="geom_tmp",
field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=DEFAULT_SRID_RLP)
),
migrations.RunPython(migrate_geometry_srs),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.1.3 on 2022-07-13 06:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('konova', '0012_auto_20220713_0801'),
]
operations = [
migrations.RemoveField(
model_name="geometry",
name="geom"
),
migrations.RenameField(
model_name="geometry",
old_name="geom_tmp",
new_name="geom"
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 3.1.3 on 2022-08-15 06:03
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('user', '0006_auto_20220815_0759'),
('konova', '0013_auto_20220713_0814'),
]
operations = [
migrations.CreateModel(
name='Resubmission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('resubmit_on', models.DateField(help_text='On which date the resubmission should be performed')),
('resubmission_sent', models.BooleanField(default=False, help_text='Whether a resubmission has been sent or not')),
('comment', models.TextField(blank=True, help_text='Optional comment for the user itself', null=True)),
('created', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='user.useractionlogentry')),
('modified', models.ForeignKey(blank=True, help_text='Last modified', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='user.useractionlogentry')),
('user', models.ForeignKey(help_text='The user who wants to be notifed', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@@ -10,3 +10,4 @@ from .deadline import *
from .document import *
from .geometry import *
from .parcel import *
from .resubmission import *

View File

@@ -21,8 +21,7 @@ class Geometry(BaseResource):
"""
Geometry model
"""
from konova.settings import DEFAULT_SRID
geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID)
geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID_RLP)
def __str__(self):
return str(self.id)

View File

@@ -743,4 +743,23 @@ class GeoReferencedMixin(models.Model):
zoom_lvl,
x,
y,
)
)
class ResubmitableObjectMixin(models.Model):
resubmissions = models.ManyToManyField(
"konova.Resubmission",
blank=True,
related_name="+",
)
class Meta:
abstract = True
def resubmit(self):
""" Run resubmit check and run for all related resubmissions
"""
resubmissions = self.resubmissions.all()
for resubmission in resubmissions:
resubmission.send_resubmission_mail(self.identifier)

View File

@@ -0,0 +1,46 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 15.08.22
"""
from dateutil.utils import today
from django.db import models
from konova.models import BaseResource
from konova.utils.mailer import Mailer
class Resubmission(BaseResource):
user = models.ForeignKey(
"user.User",
on_delete=models.CASCADE,
help_text="The user who wants to be notifed"
)
resubmit_on = models.DateField(
help_text="On which date the resubmission should be performed"
)
resubmission_sent = models.BooleanField(
default=False,
help_text="Whether a resubmission has been sent or not"
)
comment = models.TextField(
null=True,
blank=True,
help_text="Optional comment for the user itself"
)
def send_resubmission_mail(self, obj_identifier):
""" Sends a resubmission mail
"""
_today = today().date()
resubmission_handled = _today.__ge__(self.resubmit_on) and self.resubmission_sent
if resubmission_handled:
return
mailer = Mailer()
mailer.send_mail_resubmission(obj_identifier, self)
self.resubmission_sent = True
self.save()

View File

@@ -11,3 +11,4 @@ BASE_TITLE = "KSP - Kompensationsverzeichnis Service Portal"
BASE_FRONTEND_TITLE = "Kompensationsverzeichnis Service Portal"
TAB_TITLE_IDENTIFIER = "tab_title"
HELP_LINK = "https://dienste.naturschutz.rlp.de/doku/doku.php?id=ksp:start"
IMPRESSUM_LINK = "https://naturschutz.rlp.de/index.php?q=impressum"

View File

@@ -106,3 +106,17 @@ def celery_send_mail_shared_data_checked_team(obj_identifier, obj_title=None, te
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_data_checked(obj_identifier, obj_title)
@shared_task
def celery_send_mail_deduction_changed_team(obj_identifier, obj_title=None, team_id=None, data_changes=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_deduction_changed(obj_identifier, obj_title, data_changes)
@shared_task
def celery_send_mail_deduction_changed(obj_identifier, obj_title=None, user_id=None, data_changes=None):
from user.models import User
user = User.objects.get(id=user_id)
user.send_mail_deduction_changed(obj_identifier, obj_title, data_changes)

View File

@@ -22,7 +22,7 @@ from codelist.models import KonovaCode, KonovaCodeList
from compensation.models import Compensation, CompensationState, CompensationAction, EcoAccount, EcoAccountDeduction
from intervention.models import Legal, Responsibility, Intervention, Handler
from konova.management.commands.setup_data import GROUPS_DATA
from konova.models import Geometry
from konova.models import Geometry, Deadline, DeadlineType
from konova.settings import DEFAULT_GROUP
from konova.utils.generators import generate_random_string
from user.models import UserActionLogEntry
@@ -41,6 +41,7 @@ class BaseTestCase(TestCase):
eco_account = None
comp_state = None
comp_action = None
finished_deadline = None
codes = None
superuser_pw = "root"
@@ -69,6 +70,7 @@ class BaseTestCase(TestCase):
self.create_dummy_action()
self.codes = self.create_dummy_codes()
self.team = self.create_dummy_team()
self.finished_deadline = self.create_dummy_deadline()
# Set the default group as only group for the user
default_group = self.groups.get(name=DEFAULT_GROUP)
@@ -279,6 +281,20 @@ class BaseTestCase(TestCase):
return team
def create_dummy_deadline(self, type: DeadlineType = DeadlineType.FINISHED):
""" Creates a dummy deadline.
If type is not specified, it defaults to DeadlineType.FINISHED
Returns:
deadline (Deadline): A deadline
"""
deadline = Deadline.objects.create(
type=type,
date="1970-01-01"
)
return deadline
@staticmethod
def create_dummy_geometry() -> MultiPolygon:
""" Creates some geometry
@@ -361,6 +377,7 @@ class BaseTestCase(TestCase):
compensation.before_states.add(self.comp_state)
compensation.actions.add(self.comp_action)
compensation.geometry.geom = self.create_dummy_geometry()
compensation.deadlines.add(self.finished_deadline)
compensation.geometry.save()
return compensation
@@ -390,6 +407,7 @@ class BaseTestCase(TestCase):
ema.before_states.add(self.comp_state)
ema.actions.add(self.comp_action)
ema.geometry.geom = self.create_dummy_geometry()
ema.deadlines.add(self.finished_deadline)
ema.geometry.save()
return ema
@@ -410,6 +428,7 @@ class BaseTestCase(TestCase):
eco_account.geometry.geom = self.create_dummy_geometry()
eco_account.geometry.save()
eco_account.deductable_surface = eco_account.get_state_after_surface_sum()
eco_account.deadlines.add(self.finished_deadline)
eco_account.save()
return eco_account
@@ -430,15 +449,13 @@ class BaseTestCase(TestCase):
self.assertTrue(True)
return
tolerance = 0.001
if geom1.srid != geom2.srid:
tolerance = 0.001
# 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
geom1_t = geom1.transform(geom2.srid, clone=True)
geom2_t = geom2.transform(geom1.srid, clone=True)
self.assertTrue(geom1_t.equals_exact(geom2, tolerance) or geom2_t.equals_exact(geom1, tolerance))
else:
self.assertTrue(geom1.equals(geom2))
geom1.transform(geom2.srid)
geom2.transform(geom1.srid)
self.assertTrue(geom1.equals_exact(geom2, tolerance) or geom2.equals_exact(geom1, tolerance))
class BaseViewTestCase(BaseTestCase):

View File

@@ -5,10 +5,9 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 01.09.21
"""
from django.http import FileResponse, HttpRequest, HttpResponse, Http404
from django.utils.translation import gettext_lazy as _
from django.http import FileResponse, HttpRequest, Http404
from konova.forms import RemoveModalForm
from konova.forms.modals import RemoveModalForm
from konova.models import AbstractDocument
from konova.utils.message_templates import DOCUMENT_REMOVED_TEMPLATE

View File

@@ -207,6 +207,33 @@ class Mailer:
msg
)
def send_mail_deduction_changed_team(self, obj_identifier, obj_title, team, data_changes):
""" Send a mail if deduction has been changed
Args:
obj_identifier (str): Identifier of the main object
obj_title (str): Title of the main object
team (Team): Team to be notified
data_changes (dict): Contains the old|new changes of the deduction changes
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
"data_changes": data_changes,
}
msg = render_to_string("email/other/deduction_changed_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Deduction changed").format(obj_identifier),
msg
)
def send_mail_shared_data_deleted_team(self, obj_identifier, obj_title, team):
""" Send a mail if data has just been deleted
@@ -322,6 +349,34 @@ class Mailer:
msg
)
def send_mail_deduction_changed(self, obj_identifier, obj_title, user, data_changes):
""" Send a mail if deduction has been changed
Args:
obj_identifier (str): Identifier of the main object
obj_title (str): Title of the main object
user (User): User to be notified
data_changes (dict): Contains the old|new changes of the deduction changes
Returns:
"""
context = {
"user": user,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
"data_changes": data_changes,
}
msg = render_to_string("email/other/deduction_changed.html", context)
user_mail_address = [user.email]
self.send(
user_mail_address,
_("{} - Deduction changed").format(obj_identifier),
msg
)
def send_mail_verify_api_token(self, user):
""" Send a mail if a user creates a new token
@@ -343,3 +398,26 @@ class Mailer:
msg
)
def send_mail_resubmission(self, obj_identifier, resubmission):
""" Send a resubmission mail for a user
Args:
obj_identifier (str): The (resubmitted) object's identifier
resubmission (Resubmission): The resubmission
Returns:
"""
context = {
"obj_identifier": obj_identifier,
"resubmission": resubmission,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/resubmission/resubmission.html", context)
user_mail_address = [SUPPORT_MAIL_RECIPIENT]
self.send(
user_mail_address,
_("Resubmission - {}").format(obj_identifier),
msg
)

View File

@@ -102,7 +102,6 @@ def home_view(request: HttpRequest):
return render(request, template, context)
@login_required
def get_geom_parcels(request: HttpRequest, id: str):
""" Getter for HTMX
@@ -157,7 +156,6 @@ def get_geom_parcels(request: HttpRequest, id: str):
return HttpResponse(None, status=404)
@login_required
def get_geom_parcels_content(request: HttpRequest, id: str, page: int):
""" Getter for infinite scroll of HTMX

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@
</div>
{% endfor %}
</div>
<div class="container-fluid mt-3 px-4">
<div class="container-fluid mt-3 mb-5 px-4">
{% comment %}
The modal wrapper, which can be used on every view can stay on the base.html template
{% endcomment %}

View File

@@ -6,6 +6,7 @@
<article>
{% trans 'Hello support' %},
<br>
<br>
{% trans 'you need to verify the API token for user' %}:
<br>
<br>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'the following dataset has just been checked' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'the following dataset has just been checked' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'the following dataset has just been deleted' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'the following dataset has just been deleted' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -0,0 +1,50 @@
{% load i18n %}
<div>
<h2>{% translate 'Deduction changed' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% translate 'Hello ' %} {{user.username}},
<br>
<br>
{% translate 'a deduction of this eco account has changed:' %}
<br>
<br>
<table>
<tr>
<th scope="col">{% translate 'Attribute' %}</th>
<th scope="col">{% translate 'Old' %}</th>
<th scope="col">{% translate 'New' %}</th>
</tr>
<tr>
<td>{% translate 'EcoAccount' %}</td>
<td>{{data_changes.account.old}}</td>
<td>{{data_changes.account.new}}</td>
</tr>
<tr>
<td>{% translate 'Intervention' %}</td>
<td>{{data_changes.intervention.old}}</td>
<td>{{data_changes.intervention.new}}</td>
</tr>
<tr>
<td>{% translate 'Surface' %}</td>
<td>{{data_changes.surface.old}} m²</td>
<td>{{data_changes.surface.new}} m²</td>
</tr>
</table>
<br>
<br>
{% translate 'If this should not have been happened, please contact us. See the signature for details.' %}
<br>
<br>
{% translate 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

View File

@@ -0,0 +1,50 @@
{% load i18n %}
<div>
<h2>{% translate 'Deduction changed' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% translate 'a deduction of this eco account has changed:' %}
<br>
<br>
<table>
<tr>
<th scope="col">{% translate 'Attribute' %}</th>
<th scope="col">{% translate 'Old' %}</th>
<th scope="col">{% translate 'New' %}</th>
</tr>
<tr>
<td>{% translate 'EcoAccount' %}</td>
<td>{{data_changes.account.old}}</td>
<td>{{data_changes.account.new}}</td>
</tr>
<tr>
<td>{% translate 'Intervention' %}</td>
<td>{{data_changes.intervention.old}}</td>
<td>{{data_changes.intervention.new}}</td>
</tr>
<tr>
<td>{% translate 'Surface' %}</td>
<td>{{data_changes.surface.old}} m²</td>
<td>{{data_changes.surface.new}} m²</td>
</tr>
</table>
<br>
<br>
{% translate 'If this should not have been happened, please contact us. See the signature for details.' %}
<br>
<br>
{% translate 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'the following dataset has just been recorded' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'the following dataset has just been recorded' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'the following dataset has just been unrecorded' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'the following dataset has just been unrecorded' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -0,0 +1,29 @@
{% load i18n %}
<div>
<h2>{% trans 'Resubmission' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello ' %} {{resubmission.user.username}},
<br>
<br>
{% trans 'you wanted to be reminded on this entry.' %}
<br>
{% if resubmission.comment %}
<br>
{% trans 'Your personal comment:' %}
<br>
<article style="font: italic">"{{resubmission.comment}}"</article>
{% endif %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'the following dataset has just been shared with you' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'the following dataset has just been shared with your team' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello ' %} {{user.username}},
<br>
<br>
{% trans 'your shared access, including editing, has been revoked for the dataset ' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -7,6 +7,7 @@
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
<br>
{% trans 'your teams shared access, including editing, has been revoked for the dataset ' %}
<br>
<strong>{{obj_identifier}}</strong>

View File

@@ -6,7 +6,7 @@
<a href="{{ help_link }}" target="_blank">{% trans 'Help' %}</a>
</span>
<span class="col-sm-auto footer-link">
<a href="{% url 'home' %}">{% trans 'Impressum' %}</a>
<a href="{{ impressum_link }}" target="_blank">{% trans 'Impressum' %}</a>
</span>
<span class="col-sm-auto footer-link">
<a href="mailto:{{CONTACT_MAIL}}">{% trans 'Contact' %}</a>

Some files were not shown because too many files have changed in this diff Show More