From 800b845ac470cee4ce0ac2ba708ed007e4b4f2e8 Mon Sep 17 00:00:00 2001 From: Stefan Klein Date: Mon, 7 Aug 2023 15:28:32 +0200 Subject: [PATCH 01/31] [#62] make statusbar tooltips accessible on touch devices --- cpmonitor/static/js/main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cpmonitor/static/js/main.js b/cpmonitor/static/js/main.js index 093d0e53..cefe2bef 100644 --- a/cpmonitor/static/js/main.js +++ b/cpmonitor/static/js/main.js @@ -3,3 +3,5 @@ const progressbars = document.querySelectorAll("div.progress-bar"); progressbars.forEach(pb => { pb.style.width = pb.dataset.value + "%"; }); + +$('[data-bs-toggle="tooltip"]').click(function(e){e.preventDefault()}) From 0c165949e55b5422ba8cf944c9f86959d0f9b9c2 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 11 Aug 2023 22:01:15 +0200 Subject: [PATCH 02/31] [#240] enable custom order for execution status proportions in the progress bar --- cpmonitor/templates/city.html | 8 ++-- cpmonitor/templates/taskgroup.html | 8 ++-- cpmonitor/tests/example_test.py | 2 - cpmonitor/tests/views_test.py | 66 ++++++++++++++++++++++++++++++ cpmonitor/views.py | 33 +++++++++++---- 5 files changed, 100 insertions(+), 17 deletions(-) delete mode 100644 cpmonitor/tests/example_test.py create mode 100644 cpmonitor/tests/views_test.py diff --git a/cpmonitor/templates/city.html b/cpmonitor/templates/city.html index 5643d3dc..3d12724d 100644 --- a/cpmonitor/templates/city.html +++ b/cpmonitor/templates/city.html @@ -57,14 +57,14 @@

Monitoring für {{ city.name }}

- {% for value, label, name in city.status_proportions %} -
+ title="{{ value }}% {{ status.label }}" + aria-label="{{ status.label }}">
{% endfor %}
diff --git a/cpmonitor/templates/taskgroup.html b/cpmonitor/templates/taskgroup.html index 1106f202..ddfca1ca 100644 --- a/cpmonitor/templates/taskgroup.html +++ b/cpmonitor/templates/taskgroup.html @@ -45,14 +45,14 @@

Weitere Handlungsfelder

{{ group.title }}
{{ group.subtasks_count }} Maßnahmen
- {% for value, label, name in group.status_proportions %} -
+ title="{{ value }} % {{ status.label }}" + aria-label="{{ status.label }}">
{% endfor %}
diff --git a/cpmonitor/tests/example_test.py b/cpmonitor/tests/example_test.py deleted file mode 100644 index 0b748dd8..00000000 --- a/cpmonitor/tests/example_test.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_should_return_the_sum_when_given_two_numbers(): - assert 2 + 3 == 5 diff --git a/cpmonitor/tests/views_test.py b/cpmonitor/tests/views_test.py new file mode 100644 index 00000000..8365b46d --- /dev/null +++ b/cpmonitor/tests/views_test.py @@ -0,0 +1,66 @@ +from cpmonitor.models import ExecutionStatus +from cpmonitor.views import _sort_status_proportions + +SOME_NUMBER = 20 + + +def test_status_proportions_should_have_the_right_sorting_order_when_all_statuses_are_present(): + # given + status_proportions = { + ExecutionStatus.COMPLETE: SOME_NUMBER, + ExecutionStatus.FAILED: SOME_NUMBER, + ExecutionStatus.UNKNOWN: SOME_NUMBER, + ExecutionStatus.AS_PLANNED: SOME_NUMBER, + ExecutionStatus.DELAYED: SOME_NUMBER, + } + status_order = [ + ExecutionStatus.FAILED, + ExecutionStatus.DELAYED, + ExecutionStatus.AS_PLANNED, + ExecutionStatus.COMPLETE, + ExecutionStatus.UNKNOWN, + ] + expected_sorted_status_proportions = [ + (SOME_NUMBER, ExecutionStatus.FAILED), + (SOME_NUMBER, ExecutionStatus.DELAYED), + (SOME_NUMBER, ExecutionStatus.AS_PLANNED), + (SOME_NUMBER, ExecutionStatus.COMPLETE), + (SOME_NUMBER, ExecutionStatus.UNKNOWN), + ] + + # when + actual_sorted_status_proportions = _sort_status_proportions( + status_proportions, status_order + ) + + # then + assert expected_sorted_status_proportions == actual_sorted_status_proportions + + +def test_status_proportions_should_have_the_right_sorting_order_when_not_all_statuses_are_present(): + # given + status_proportions = { + ExecutionStatus.COMPLETE: SOME_NUMBER, + ExecutionStatus.AS_PLANNED: SOME_NUMBER, + ExecutionStatus.FAILED: SOME_NUMBER, + } + status_order = [ + ExecutionStatus.FAILED, + ExecutionStatus.DELAYED, + ExecutionStatus.AS_PLANNED, + ExecutionStatus.COMPLETE, + ExecutionStatus.UNKNOWN, + ] + expected_sorted_status_proportions = [ + (SOME_NUMBER, ExecutionStatus.FAILED), + (SOME_NUMBER, ExecutionStatus.AS_PLANNED), + (SOME_NUMBER, ExecutionStatus.COMPLETE), + ] + + # when + actual_sorted_status_proportions = _sort_status_proportions( + status_proportions, status_order + ) + + # then + assert expected_sorted_status_proportions == actual_sorted_status_proportions diff --git a/cpmonitor/views.py b/cpmonitor/views.py index acd0c517..a96876fa 100644 --- a/cpmonitor/views.py +++ b/cpmonitor/views.py @@ -24,6 +24,16 @@ AdministrationChecklist, ) +STATUS_ORDER = [ + ExecutionStatus.FAILED, + ExecutionStatus.DELAYED, + ExecutionStatus.AS_PLANNED, + ExecutionStatus.COMPLETE, + ExecutionStatus.UNKNOWN, +] + +TO_STATUS = {status.value: status for status in ExecutionStatus} + def _show_drafts(request): return request.user.is_authenticated @@ -32,7 +42,6 @@ def _show_drafts(request): def _calculate_summary(request, node): """calculate summarized status for a given node or a whole city""" - statuses = {s.value: s for s in ExecutionStatus} if isinstance(node, City): subtasks = Task.objects.filter(city=node, numchild=0) else: @@ -41,20 +50,30 @@ def _calculate_summary(request, node): subtasks = subtasks.filter(draft_mode=False) subtasks_count = len(subtasks) - status_counts = Counter([s.execution_status for s in subtasks]) + status_counts = Counter( + [TO_STATUS[subtask.execution_status] for subtask in subtasks] + ) status_proportions = { - s: round(c / subtasks_count * 100) for s, c in status_counts.items() + status: round(count / subtasks_count * 100) + for status, count in status_counts.items() } - node.status_proportions = [ - (v, statuses[k].label, statuses[k].name) - for k, v in sorted(status_proportions.items(), reverse=True) - ] + node.status_proportions = _sort_status_proportions(status_proportions, STATUS_ORDER) node.subtasks_count = subtasks_count node.complete_proportion = status_proportions.get(ExecutionStatus.COMPLETE, 0) node.incomplete_proportion = 100 - node.complete_proportion +def _sort_status_proportions( + status_proportions: dict[ExecutionStatus, int], order: list[ExecutionStatus] +) -> list[tuple[int, ExecutionStatus]]: + status_proportions_sorted = [] + for status in order: + if status in status_proportions.keys(): + status_proportions_sorted.append((status_proportions[status], status)) + return status_proportions_sorted + + def _get_frontpage_tasks(request, city): tasks = Task.objects.filter(city=city, numchild=0, frontpage=1) if not _show_drafts(request): From 31faea7f53e3544d9ebeb73682c026715f1ef8cf Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 18 Aug 2023 17:31:02 +0200 Subject: [PATCH 03/31] do not copy the outer folder (test_database_uploads) otherwise the path is wrong and the images are not found --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 45d4cffd..a6d48568 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ python manage.py migrate --settings=config.settings.local # (optional) install example data python manage.py loaddata --settings=config.settings.local e2e_tests/database/test_database.json -cp -r e2e_tests/database/test_database_uploads cpmonitor/images/uploads +cp -r e2e_tests/database/test_database_uploads/. cpmonitor/images/uploads # install css and javascript libraries yarn install @@ -119,7 +119,7 @@ pytest --ignore e2e_tests/test_deployed.py rm db/db.sqlite3 poetry run python manage.py migrate --settings=config.settings.local poetry run python manage.py loaddata --settings=config.settings.local e2e_tests/database/test_database.json -cp -r e2e_tests/database/test_database_uploads cpmonitor/images/uploads +cp -r e2e_tests/database/test_database_uploads/. cpmonitor/images/uploads docker compose up -d --build docker compose -f docker/reverseproxy/docker-compose.yml up -d --build pytest e2e_tests/test_deployed.py @@ -251,7 +251,7 @@ git checkout right-before-model-change rm db/db.sqlite3 python manage.py migrate --settings=config.settings.local python manage.py loaddata --settings=config.settings.local e2e_tests/database/test_database.json -cp -r e2e_tests/database/test_database_uploads cpmonitor/images/uploads +cp -r e2e_tests/database/test_database_uploads/. cpmonitor/images/uploads git checkout after-model-change-including-migration python manage.py migrate --settings=config.settings.local python -Xutf8 manage.py dumpdata -e contenttypes -e admin.logentry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/test_database.json From b8a2530b377af342ba669568de381f4cff826e73 Mon Sep 17 00:00:00 2001 From: Felix Lampe Date: Mon, 21 Aug 2023 21:58:15 +0200 Subject: [PATCH 04/31] [#268] Improve naming of cert script to prevent confusion --- README.md | 8 ++++---- crontab | 4 ++-- renew-cert.sh => reload-cert.sh | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename renew-cert.sh => reload-cert.sh (100%) diff --git a/README.md b/README.md index a6d48568..6a945a0f 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ docker compose up -d ``` 5. Copy the images, the compose files, the certificate renewal cron job and the reverse proxy settings to the server: ```sh - scp -C cpmonitor.tar klimaschutzmonitor-dbeaver.tar docker-compose.yml crontab renew-cert.sh docker/reverseproxy/ monitoring@monitoring.localzero.net:/tmp/ + scp -C cpmonitor.tar klimaschutzmonitor-dbeaver.tar docker-compose.yml crontab reload-cert.sh docker/reverseproxy/ monitoring@monitoring.localzero.net:/tmp/ ``` 6. Login to the server: ```sh @@ -465,8 +465,8 @@ docker compose up -d 11. Install certificate renewal cron job: ```sh crontab /tmp/crontab - cp /tmp/renew-cert.sh /home/monitoring/ - chmod +x /home/monitoring/renew-cert.sh + cp /tmp/reload-cert.sh /home/monitoring/ + chmod +x /home/monitoring/reload-cert.sh ``` ### Database Client @@ -495,6 +495,6 @@ A reload of the nginx config is independently triggered every four hours by our ```sh crontab -l ``` -This job runs [a script](renew-cert.sh) which applies the latest certificate that acme.sh has produced. This means there can be some delay between renewal and application of the certificate, but since acme.sh performs renewal a few days before expiry, there should be enough time for nginx to reload the certificate. +This job runs [a script](reload-cert.sh) which applies the latest certificate that acme.sh has produced. This means there can be some delay between renewal and application of the certificate, but since acme.sh performs renewal a few days before expiry, there should be enough time for nginx to reload the certificate. When running locally, we instead use a [certificate created for localhost](ssl_certificates_localhost). Since ownership of localhost cannot be certified, this is a single self-signed certificate instead of a full chain signed by a CA like on the server, and an exception must be added to your browser to trust it. diff --git a/crontab b/crontab index 555177a9..af96ad1b 100644 --- a/crontab +++ b/crontab @@ -1,2 +1,2 @@ -# renew certificate around every midnight and noon, if necessary -0 */4 * * * sleep 3109 && /home/monitoring/renew-cert.sh > /home/monitoring/last_cert_renewal.log 2>&1 +# reload SSL certificate around every four hours, in case it was renewed by acme.sh +0 */4 * * * /home/monitoring/reload-cert.sh > /home/monitoring/last_cert_reload.log 2>&1 diff --git a/renew-cert.sh b/reload-cert.sh similarity index 100% rename from renew-cert.sh rename to reload-cert.sh From dd50a6115301aebad8cdb7f5fa57664d300e8aab Mon Sep 17 00:00:00 2001 From: Felix Lampe Date: Mon, 21 Aug 2023 22:35:43 +0200 Subject: [PATCH 05/31] [#268] Fix certs not being reloaded after adding top level reverse proxy --- reload-cert.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reload-cert.sh b/reload-cert.sh index e64e6430..623be3ab 100644 --- a/reload-cert.sh +++ b/reload-cert.sh @@ -1,2 +1,2 @@ #!/bin/bash -docker exec nginx-testing nginx -s reload +docker exec reverse-proxy nginx -s reload From a0b4022c2f9295c181c29f930271dd32fbe3b410 Mon Sep 17 00:00:00 2001 From: Felix Lampe Date: Mon, 21 Aug 2023 23:30:54 +0200 Subject: [PATCH 06/31] [#268] Extend acme-sh config and debug docs --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 6a945a0f..0fca5b66 100644 --- a/README.md +++ b/README.md @@ -478,13 +478,16 @@ the environment) and the credentials can be found in the .env.local file. For te configured in the respective .env files on the server. ### TLS Certificate and Renewal +#### Overview We currently use a single TLS certificate for both monitoring.localzero.org and monitoring-test.localzero.org. The certificate is issued by letsencrypt.org and requesting and renewal is performed using [acme.sh](https://github.com/acmesh-official/acme.sh), which runs in a container. This solution allows us to have almost all necessary code and config in the repo instead of only on the server. +#### Initial Issuance The initial certificate was issued using the following command: ```sh docker exec acme-sh --issue -d monitoring-test.localzero.net -d monitoring.localzero.net --standalone --server https://acme-v02.api.letsencrypt.org/directory --fullchain-file /acme.sh/fullchain.cer --key-file /acme.sh/ssl-cert.key ``` +#### Renewal Renewal is performed automatically by acme.sh's internal cron job, which... - checks if a renewal is necessary, and if so: - requests a new certificate from letsencrypt, @@ -497,4 +500,19 @@ crontab -l ``` This job runs [a script](reload-cert.sh) which applies the latest certificate that acme.sh has produced. This means there can be some delay between renewal and application of the certificate, but since acme.sh performs renewal a few days before expiry, there should be enough time for nginx to reload the certificate. +#### acme-sh Configuration and Debugging + +The configuration used by acme-sh's cronjob (not our nginx reload cronjob!), e.g. renewal interval, can be changed in `reverseproxy/ssl_certificates/monitoring-test.localzero.net_ecc/`` on the server. + +The following commands might be useful to debug and test the acme-sh configuration: +```shell +# tell acme-sh to run its cronjob now, using letsencrypt's test environment (to bypass rate limiting) +docker exec acme-sh --cron --staging +# tell acme-sh to run its cronjob now, using letsencrypt's PROD environment (affected by rate limiting - 5 certs every couple weeks...) +docker exec acme-sh --cron +# force a renewal via letsencrypt's PROD environment, even if renewal time hasn't been reached yet +docker exec acme-sh --cron --force +``` + +#### TLS Certificates and Running Locally When running locally, we instead use a [certificate created for localhost](ssl_certificates_localhost). Since ownership of localhost cannot be certified, this is a single self-signed certificate instead of a full chain signed by a CA like on the server, and an exception must be added to your browser to trust it. From 9511de381a593f5b6cd4f8a78ade803dd0e3d6e0 Mon Sep 17 00:00:00 2001 From: Felix Lampe Date: Mon, 21 Aug 2023 23:40:36 +0200 Subject: [PATCH 07/31] [#268] Add acme-sh list cmd and improve layout --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0fca5b66..d659122d 100644 --- a/README.md +++ b/README.md @@ -504,12 +504,17 @@ This job runs [a script](reload-cert.sh) which applies the latest certificate th The configuration used by acme-sh's cronjob (not our nginx reload cronjob!), e.g. renewal interval, can be changed in `reverseproxy/ssl_certificates/monitoring-test.localzero.net_ecc/`` on the server. -The following commands might be useful to debug and test the acme-sh configuration: +The following commands might be executed on the server to debug and test the acme-sh configuration: ```shell +# view certificate creation date and next renew date +docker exec acme-sh --list + # tell acme-sh to run its cronjob now, using letsencrypt's test environment (to bypass rate limiting) docker exec acme-sh --cron --staging + # tell acme-sh to run its cronjob now, using letsencrypt's PROD environment (affected by rate limiting - 5 certs every couple weeks...) docker exec acme-sh --cron + # force a renewal via letsencrypt's PROD environment, even if renewal time hasn't been reached yet docker exec acme-sh --cron --force ``` From 9f0302f732f48d8454063b3fd97749dd41f914b0 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Sun, 6 Aug 2023 19:15:11 +0200 Subject: [PATCH 08/31] [#23] Model and admin with permissions for City using django-rules. --- config/settings/base.py | 6 + cpmonitor/admin.py | 49 +- cpmonitor/fixtures/permissions.json | 2243 +++++++++++++++++ ...0024_city_city_admins_city_city_editors.py | 36 + cpmonitor/models.py | 22 + cpmonitor/rules.py | 98 + cpmonitor/tests/permissions_test.py | 291 +++ e2e_tests/database/test_database.json | 107 +- poetry.lock | 14 +- pyproject.toml | 1 + 10 files changed, 2853 insertions(+), 14 deletions(-) create mode 100644 cpmonitor/fixtures/permissions.json create mode 100644 cpmonitor/migrations/0024_city_city_admins_city_city_editors.py create mode 100644 cpmonitor/rules.py create mode 100644 cpmonitor/tests/permissions_test.py diff --git a/config/settings/base.py b/config/settings/base.py index c73519f9..f346e89e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -47,6 +47,7 @@ def get_env(var: str) -> str: "django.contrib.staticfiles", "treebeard", "martor", + "rules.apps.AutodiscoverRulesConfig", "cpmonitor.apps.CpmonitorConfig", ] @@ -60,6 +61,11 @@ def get_env(var: str) -> str: "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +AUTHENTICATION_BACKENDS = ( + "rules.permissions.ObjectPermissionBackend", + "django.contrib.auth.backends.ModelBackend", +) + ROOT_URLCONF = "cpmonitor.urls" TEMPLATES = [ diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 8517f051..2a3c8613 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -1,14 +1,23 @@ -from django.contrib import admin +from collections.abc import Sequence +from django.contrib import admin, auth from django.db import models from django.forms import TextInput from django.forms.models import ErrorList from django.http import HttpRequest, HttpResponseRedirect, QueryDict +from django.http.request import HttpRequest from django.urls import reverse from django.utils.html import format_html from martor.widgets import AdminMartorWidget from treebeard.admin import TreeAdmin from treebeard.forms import movenodeform_factory, MoveNodeForm - +from rules.contrib.admin import ObjectPermissionsModelAdminMixin +from rules.permissions import perm_exists + +from .rules import ( + filter_editable, + is_allowed_to_change_site_admins, + is_allowed_to_change_site_editors, +) from .models import Chart, City, Task, CapChecklist, AdministrationChecklist, LocalGroup _city_filter_query = "city__id__exact" @@ -37,7 +46,7 @@ def _admin_url(model, type, city_id): return base -class ChartInline(admin.StackedInline): +class ChartInline(ObjectPermissionsModelAdminMixin, admin.StackedInline): model = Chart extra = 0 @@ -47,15 +56,17 @@ class ChartInline(admin.StackedInline): } -class CapChecklistInline(admin.TabularInline): +class CapChecklistInline(ObjectPermissionsModelAdminMixin, admin.TabularInline): model = CapChecklist -class AdministrationChecklistInline(admin.TabularInline): +class AdministrationChecklistInline( + ObjectPermissionsModelAdminMixin, admin.TabularInline +): model = AdministrationChecklist -class LocalGroupInline(admin.StackedInline): +class LocalGroupInline(ObjectPermissionsModelAdminMixin, admin.StackedInline): model = LocalGroup formfield_overrides = { @@ -64,12 +75,32 @@ class LocalGroupInline(admin.StackedInline): } -class CityAdmin(admin.ModelAdmin): +class CityAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): list_display = ("zipcode", "name", "teaser", "edit_tasks") list_display_links = ("name",) ordering = ("name",) search_fields = ["zipcode", "name"] + def get_queryset(self, request): + qs = super().get_queryset(request) + user = request.user + if user.is_superuser: + return qs + return filter_editable(user, qs) + + save_on_top = True + + filter_horizontal = ["city_editors", "city_admins"] + + def get_readonly_fields(self, request: HttpRequest, obj=None) -> Sequence[str]: + user = request.user + result = [] + if not is_allowed_to_change_site_editors(user, obj): + result.append("city_editors") + if not is_allowed_to_change_site_admins(user, obj): + result.append("city_admins") + return result + formfield_overrides = { models.CharField: {"widget": TextInput(attrs={"size": "170"})}, models.TextField: {"widget": AdminMartorWidget}, @@ -164,7 +195,7 @@ def clean(self): return super().clean() -class TaskAdmin(TreeAdmin): +class TaskAdmin(ObjectPermissionsModelAdminMixin, TreeAdmin): # ------ change list page ------ change_list_template = "admin/task_changelist.html" @@ -206,6 +237,8 @@ def changelist_view(self, request): search_help_text = "Suche im Titel" # ------ add and change task page ------ + save_on_top = True + fields = ( "city", "draft_mode", diff --git a/cpmonitor/fixtures/permissions.json b/cpmonitor/fixtures/permissions.json new file mode 100644 index 00000000..ea9c7770 --- /dev/null +++ b/cpmonitor/fixtures/permissions.json @@ -0,0 +1,2243 @@ +[ +{ + "model": "admin.logentry", + "pk": 1, + "fields": { + "action_time": "2023-02-26T18:26:19.109Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 2, + "fields": { + "action_time": "2023-02-26T18:26:29.897Z", + "user": 1, + "content_type": 8, + "object_id": "1", + "object_repr": "Verkehr", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 3, + "fields": { + "action_time": "2023-02-26T18:27:03.032Z", + "user": 1, + "content_type": 8, + "object_id": "2", + "object_repr": "Radwege ausbauen", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 4, + "fields": { + "action_time": "2023-02-26T18:27:36.579Z", + "user": 1, + "content_type": 8, + "object_id": "3", + "object_repr": "U-Bahn Strecke verlängern", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 5, + "fields": { + "action_time": "2023-03-05T17:03:22.852Z", + "user": 1, + "content_type": 8, + "object_id": "2", + "object_repr": "Radwege ausbauen", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"description\", \"Relative to\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 6, + "fields": { + "action_time": "2023-05-03T14:27:51.992Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: https://openverse.org/image/f6a4ca33-b1fc-488d-9f2a-dd6ef9f1426f\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 7, + "fields": { + "action_time": "2023-05-03T14:29:27.783Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Quelle\", \"Lizenz\", \"Bildunterschrift\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 8, + "fields": { + "action_time": "2023-05-03T14:50:43.496Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 9, + "fields": { + "action_time": "2023-05-03T14:56:40.061Z", + "user": 1, + "content_type": 7, + "object_id": "2", + "object_repr": "00000 Ohnenix", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 10, + "fields": { + "action_time": "2023-05-03T15:01:33.898Z", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "99999 Mitallem", + "action_flag": 1, + "change_message": "[{\"added\": {}}, {\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\"}}, {\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 11, + "fields": { + "action_time": "2023-05-03T15:01:51.380Z", + "user": 1, + "content_type": 8, + "object_id": "17", + "object_repr": "Energie", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 12, + "fields": { + "action_time": "2023-05-03T15:02:00.533Z", + "user": 1, + "content_type": 8, + "object_id": "18", + "object_repr": "Mobilität", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 13, + "fields": { + "action_time": "2023-05-03T15:02:09.646Z", + "user": 1, + "content_type": 8, + "object_id": "19", + "object_repr": "Gebäude", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 14, + "fields": { + "action_time": "2023-05-03T15:02:29.345Z", + "user": 1, + "content_type": 8, + "object_id": "20", + "object_repr": "Strukturen", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 15, + "fields": { + "action_time": "2023-05-03T15:02:42.740Z", + "user": 1, + "content_type": 8, + "object_id": "21", + "object_repr": "Landwirtschaft", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 16, + "fields": { + "action_time": "2023-05-03T15:03:00.256Z", + "user": 1, + "content_type": 8, + "object_id": "22", + "object_repr": "PF auf alle öffentlichen Dächer", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 17, + "fields": { + "action_time": "2023-05-03T15:03:15.759Z", + "user": 1, + "content_type": 8, + "object_id": "22", + "object_repr": "PV auf alle öffentlichen Dächer", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Titel\", \"Relative to\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 18, + "fields": { + "action_time": "2023-05-03T15:04:04.872Z", + "user": 1, + "content_type": 8, + "object_id": "23", + "object_repr": "Sanierungsprogramm: Dämmung", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 19, + "fields": { + "action_time": "2023-05-03T15:04:31.096Z", + "user": 1, + "content_type": 8, + "object_id": "22", + "object_repr": "PV auf alle öffentlichen Dächer", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Umsetzungsstand\", \"Relative to\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 20, + "fields": { + "action_time": "2023-05-03T15:05:33.401Z", + "user": 1, + "content_type": 8, + "object_id": "24", + "object_repr": "Ladesäulen flächendeckend", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 21, + "fields": { + "action_time": "2023-05-03T15:06:05.064Z", + "user": 1, + "content_type": 8, + "object_id": "25", + "object_repr": "Förderung Ökologischer Landbau", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 22, + "fields": { + "action_time": "2023-05-03T15:06:50.514Z", + "user": 1, + "content_type": 8, + "object_id": "26", + "object_repr": "Zukunftswerkstatt", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 23, + "fields": { + "action_time": "2023-05-03T15:07:13.155Z", + "user": 1, + "content_type": 8, + "object_id": "26", + "object_repr": "Zukunftswerkstatt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Umsetzungsstand\", \"Relative to\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 24, + "fields": { + "action_time": "2023-05-03T15:07:55.866Z", + "user": 1, + "content_type": 8, + "object_id": "27", + "object_repr": "Expertenrat", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 25, + "fields": { + "action_time": "2023-05-14T12:18:23.453Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \"cap checklist\", \"object\": \"CapChecklist object (1)\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 26, + "fields": { + "action_time": "2023-05-14T12:18:41.323Z", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "99999 Mitallem", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \"cap checklist\", \"object\": \"CapChecklist object (2)\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 27, + "fields": { + "action_time": "2023-06-02T17:23:41.654Z", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "99999 Mitallem", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \"Verwaltungsstrukturen Checkliste\", \"object\": \"AdministrationChecklist object (1)\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 28, + "fields": { + "action_time": "2023-06-02T17:24:04.672Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"added\": {\"name\": \"Verwaltungsstrukturen Checkliste\", \"object\": \"AdministrationChecklist object (2)\"}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 29, + "fields": { + "action_time": "2023-06-04T07:36:20.253Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Bewertung Verwaltung\", \"Bewertung Klimaaktionsplan\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 30, + "fields": { + "action_time": "2023-06-12T20:12:31.438Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Bewertung Klimaaktionsplan\", \"Bewertung Umsetzungsstand\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 31, + "fields": { + "action_time": "2023-06-16T14:07:01.361Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\", \"fields\": [\"Interne Informationen\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 32, + "fields": { + "action_time": "2023-06-16T14:11:13.945Z", + "user": 1, + "content_type": 7, + "object_id": "3", + "object_repr": "99999 Mitallem", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\", \"fields\": [\"Interne Informationen\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 33, + "fields": { + "action_time": "2023-08-01T06:50:55.709Z", + "user": 1, + "content_type": 4, + "object_id": "2", + "object_repr": "heinz", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 34, + "fields": { + "action_time": "2023-08-01T06:52:10.143Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\", \"Kommunen Admins\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 35, + "fields": { + "action_time": "2023-08-01T10:18:06.021Z", + "user": 1, + "content_type": 4, + "object_id": "2", + "object_repr": "heinz", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Staff status\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 36, + "fields": { + "action_time": "2023-08-01T10:19:26.402Z", + "user": 1, + "content_type": 4, + "object_id": "3", + "object_repr": "sarah", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 37, + "fields": { + "action_time": "2023-08-01T10:19:34.192Z", + "user": 1, + "content_type": 4, + "object_id": "3", + "object_repr": "sarah", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Staff status\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 38, + "fields": { + "action_time": "2023-08-01T10:20:33.306Z", + "user": 1, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\", \"Kommunen Admins\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 39, + "fields": { + "action_time": "2023-08-02T09:14:37.654Z", + "user": 1, + "content_type": 3, + "object_id": "1", + "object_repr": "Alle", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 40, + "fields": { + "action_time": "2023-08-02T09:14:50.503Z", + "user": 1, + "content_type": 4, + "object_id": "3", + "object_repr": "sarah", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Groups\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 41, + "fields": { + "action_time": "2023-08-02T09:14:59.278Z", + "user": 1, + "content_type": 4, + "object_id": "2", + "object_repr": "heinz", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Groups\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 42, + "fields": { + "action_time": "2023-08-02T09:19:45.137Z", + "user": 1, + "content_type": 3, + "object_id": "1", + "object_repr": "Alle", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Permissions\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 43, + "fields": { + "action_time": "2023-08-02T09:22:18.076Z", + "user": 1, + "content_type": 3, + "object_id": "1", + "object_repr": "Alle", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Permissions\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 44, + "fields": { + "action_time": "2023-08-02T09:23:24.966Z", + "user": 3, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 45, + "fields": { + "action_time": "2023-08-03T08:35:00.681Z", + "user": 3, + "content_type": 7, + "object_id": "1", + "object_repr": "12345 Beispielstadt", + "action_flag": 2, + "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\"]}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 46, + "fields": { + "action_time": "2023-08-03T10:39:16.799Z", + "user": 1, + "content_type": 4, + "object_id": "4", + "object_repr": "christian", + "action_flag": 1, + "change_message": "[{\"added\": {}}]" + } +}, +{ + "model": "admin.logentry", + "pk": 47, + "fields": { + "action_time": "2023-08-03T10:39:55.053Z", + "user": 1, + "content_type": 4, + "object_id": "4", + "object_repr": "christian", + "action_flag": 2, + "change_message": "[]" + } +}, +{ + "model": "auth.permission", + "pk": 1, + "fields": { + "name": "Can add log entry", + "content_type": 1, + "codename": "add_logentry" + } +}, +{ + "model": "auth.permission", + "pk": 2, + "fields": { + "name": "Can change log entry", + "content_type": 1, + "codename": "change_logentry" + } +}, +{ + "model": "auth.permission", + "pk": 3, + "fields": { + "name": "Can delete log entry", + "content_type": 1, + "codename": "delete_logentry" + } +}, +{ + "model": "auth.permission", + "pk": 4, + "fields": { + "name": "Can view log entry", + "content_type": 1, + "codename": "view_logentry" + } +}, +{ + "model": "auth.permission", + "pk": 5, + "fields": { + "name": "Can add permission", + "content_type": 2, + "codename": "add_permission" + } +}, +{ + "model": "auth.permission", + "pk": 6, + "fields": { + "name": "Can change permission", + "content_type": 2, + "codename": "change_permission" + } +}, +{ + "model": "auth.permission", + "pk": 7, + "fields": { + "name": "Can delete permission", + "content_type": 2, + "codename": "delete_permission" + } +}, +{ + "model": "auth.permission", + "pk": 8, + "fields": { + "name": "Can view permission", + "content_type": 2, + "codename": "view_permission" + } +}, +{ + "model": "auth.permission", + "pk": 9, + "fields": { + "name": "Can add group", + "content_type": 3, + "codename": "add_group" + } +}, +{ + "model": "auth.permission", + "pk": 10, + "fields": { + "name": "Can change group", + "content_type": 3, + "codename": "change_group" + } +}, +{ + "model": "auth.permission", + "pk": 11, + "fields": { + "name": "Can delete group", + "content_type": 3, + "codename": "delete_group" + } +}, +{ + "model": "auth.permission", + "pk": 12, + "fields": { + "name": "Can view group", + "content_type": 3, + "codename": "view_group" + } +}, +{ + "model": "auth.permission", + "pk": 13, + "fields": { + "name": "Can add user", + "content_type": 4, + "codename": "add_user" + } +}, +{ + "model": "auth.permission", + "pk": 14, + "fields": { + "name": "Can change user", + "content_type": 4, + "codename": "change_user" + } +}, +{ + "model": "auth.permission", + "pk": 15, + "fields": { + "name": "Can delete user", + "content_type": 4, + "codename": "delete_user" + } +}, +{ + "model": "auth.permission", + "pk": 16, + "fields": { + "name": "Can view user", + "content_type": 4, + "codename": "view_user" + } +}, +{ + "model": "auth.permission", + "pk": 17, + "fields": { + "name": "Can add content type", + "content_type": 5, + "codename": "add_contenttype" + } +}, +{ + "model": "auth.permission", + "pk": 18, + "fields": { + "name": "Can change content type", + "content_type": 5, + "codename": "change_contenttype" + } +}, +{ + "model": "auth.permission", + "pk": 19, + "fields": { + "name": "Can delete content type", + "content_type": 5, + "codename": "delete_contenttype" + } +}, +{ + "model": "auth.permission", + "pk": 20, + "fields": { + "name": "Can view content type", + "content_type": 5, + "codename": "view_contenttype" + } +}, +{ + "model": "auth.permission", + "pk": 21, + "fields": { + "name": "Can add session", + "content_type": 6, + "codename": "add_session" + } +}, +{ + "model": "auth.permission", + "pk": 22, + "fields": { + "name": "Can change session", + "content_type": 6, + "codename": "change_session" + } +}, +{ + "model": "auth.permission", + "pk": 23, + "fields": { + "name": "Can delete session", + "content_type": 6, + "codename": "delete_session" + } +}, +{ + "model": "auth.permission", + "pk": 24, + "fields": { + "name": "Can view session", + "content_type": 6, + "codename": "view_session" + } +}, +{ + "model": "auth.permission", + "pk": 25, + "fields": { + "name": "Can add city", + "content_type": 7, + "codename": "add_city" + } +}, +{ + "model": "auth.permission", + "pk": 26, + "fields": { + "name": "Can change city", + "content_type": 7, + "codename": "change_city" + } +}, +{ + "model": "auth.permission", + "pk": 27, + "fields": { + "name": "Can delete city", + "content_type": 7, + "codename": "delete_city" + } +}, +{ + "model": "auth.permission", + "pk": 28, + "fields": { + "name": "Can view city", + "content_type": 7, + "codename": "view_city" + } +}, +{ + "model": "auth.permission", + "pk": 29, + "fields": { + "name": "Can add task", + "content_type": 8, + "codename": "add_task" + } +}, +{ + "model": "auth.permission", + "pk": 30, + "fields": { + "name": "Can change task", + "content_type": 8, + "codename": "change_task" + } +}, +{ + "model": "auth.permission", + "pk": 31, + "fields": { + "name": "Can delete task", + "content_type": 8, + "codename": "delete_task" + } +}, +{ + "model": "auth.permission", + "pk": 32, + "fields": { + "name": "Can view task", + "content_type": 8, + "codename": "view_task" + } +}, +{ + "model": "auth.permission", + "pk": 33, + "fields": { + "name": "Can add KPI Graph", + "content_type": 9, + "codename": "add_chart" + } +}, +{ + "model": "auth.permission", + "pk": 34, + "fields": { + "name": "Can change KPI Graph", + "content_type": 9, + "codename": "change_chart" + } +}, +{ + "model": "auth.permission", + "pk": 35, + "fields": { + "name": "Can delete KPI Graph", + "content_type": 9, + "codename": "delete_chart" + } +}, +{ + "model": "auth.permission", + "pk": 36, + "fields": { + "name": "Can view KPI Graph", + "content_type": 9, + "codename": "view_chart" + } +}, +{ + "model": "auth.permission", + "pk": 37, + "fields": { + "name": "Can add cap checklist", + "content_type": 10, + "codename": "add_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 38, + "fields": { + "name": "Can change cap checklist", + "content_type": 10, + "codename": "change_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 39, + "fields": { + "name": "Can delete cap checklist", + "content_type": 10, + "codename": "delete_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 40, + "fields": { + "name": "Can view cap checklist", + "content_type": 10, + "codename": "view_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 41, + "fields": { + "name": "Can add Verwaltungsstrukturen Checkliste", + "content_type": 11, + "codename": "add_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 42, + "fields": { + "name": "Can change Verwaltungsstrukturen Checkliste", + "content_type": 11, + "codename": "change_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 43, + "fields": { + "name": "Can delete Verwaltungsstrukturen Checkliste", + "content_type": 11, + "codename": "delete_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 44, + "fields": { + "name": "Can view Verwaltungsstrukturen Checkliste", + "content_type": 11, + "codename": "view_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 45, + "fields": { + "name": "Can add Lokalgruppe", + "content_type": 12, + "codename": "add_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 46, + "fields": { + "name": "Can change Lokalgruppe", + "content_type": 12, + "codename": "change_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 47, + "fields": { + "name": "Can delete Lokalgruppe", + "content_type": 12, + "codename": "delete_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 48, + "fields": { + "name": "Can view Lokalgruppe", + "content_type": 12, + "codename": "view_localgroup" + } +}, +{ + "model": "auth.group", + "pk": 1, + "fields": { + "name": "Alle", + "permissions": [ + 26, + 30 + ] + } +}, +{ + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$390000$LhtVLCSacVFhgrmY76MEcl$kGjF/lJDI/u2ONODcz+sN+Gc71vgg2pcGMxGrXb+IqM=", + "last_login": "2023-08-02T08:49:58.725Z", + "is_superuser": true, + "username": "admin", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-02-26T18:25:59.346Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$390000$G4rrXlpXdRuKkawMKn9nmm$QYmR1/Jp16VNQRFLfDr0Kt2JPuicXZ1EGD+EsF2aFrE=", + "last_login": "2023-08-03T08:41:23.794Z", + "is_superuser": false, + "username": "heinz", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-08-01T06:50:55Z", + "groups": [ + 1 + ], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$390000$Y8L8lSp9YhsgH1ZZvchQE9$XHI/U2h4QwRn1ssNJoX623Ga0u4HXKurzRtw4f4DdXk=", + "last_login": "2023-08-03T08:41:08.520Z", + "is_superuser": false, + "username": "sarah", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-08-01T10:19:25Z", + "groups": [ + 1 + ], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$390000$bOR4wt2ycK5GSTLtsfpMgf$wvmE4imsaXkMDHlyvI/EYy1yu3mTWNCr9qUB9Y13QOc=", + "last_login": null, + "is_superuser": false, + "username": "christian", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2023-08-03T10:39:16Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 1, + "fields": { + "app_label": "admin", + "model": "logentry" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 2, + "fields": { + "app_label": "auth", + "model": "permission" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 3, + "fields": { + "app_label": "auth", + "model": "group" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 4, + "fields": { + "app_label": "auth", + "model": "user" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 5, + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 6, + "fields": { + "app_label": "sessions", + "model": "session" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 7, + "fields": { + "app_label": "cpmonitor", + "model": "city" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 8, + "fields": { + "app_label": "cpmonitor", + "model": "task" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 9, + "fields": { + "app_label": "cpmonitor", + "model": "chart" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 10, + "fields": { + "app_label": "cpmonitor", + "model": "capchecklist" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 11, + "fields": { + "app_label": "cpmonitor", + "model": "administrationchecklist" + } +}, +{ + "model": "contenttypes.contenttype", + "pk": 12, + "fields": { + "app_label": "cpmonitor", + "model": "localgroup" + } +}, +{ + "model": "sessions.session", + "pk": "3h80mfs0kjjxfectgn3a0x6c6zz3esme", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1pqsJm:hu2RvPJFpyb4Ol67nhIuVCNJNtMnKa2vY8HwkwCnWSk", + "expire_date": "2023-05-08T09:17:06.922Z" + } +}, +{ + "model": "sessions.session", + "pk": "9c6kfe113j1gjo81zp029vk6hcvmw0co", + "fields": { + "session_data": ".eJxVjEEOgjAQRe_StWkYasuMS_ecoRk6U4saSCisjHdXEha6_e-9_zKRt7XEreoSRzEXA-b0uw2cHjrtQO483Wab5mldxsHuij1otf0s-rwe7t9B4Vq-NYNnCtAGxyrUoIhLmIkzIkHIqGFISB7VN0ySOugAHPhW8lmcUjbvD-UtN_M:1pxOUA:X8ty0yL20MTQz29j3ydhkBZGCi3WcuAQ6lBFma4WP4o", + "expire_date": "2023-05-26T08:50:46.855Z" + } +}, +{ + "model": "sessions.session", + "pk": "dqcocxnefhypjhbl9ak7aw6vhsiqltaa", + "fields": { + "session_data": ".eJxVjMEOwiAQRP-FsyGFbqF49N5vIMsuSNVAUtqT8d9tkx50jvPezFt43NbstxYXP7O4CiUuv11AesZyAH5guVdJtazLHOShyJM2OVWOr9vp_h1kbHlfu9h3bCgZAyMnSmlgBKsGhQoBVEeWaNQ9sGXHzpBzGnSf9jgMwEZ8vvZWOHg:1q58Ul:HGNABZYGt5rapxcqgZ8bVDSAE4PAfvMmdFuJbVQNOQA", + "expire_date": "2023-06-16T17:23:23.347Z" + } +}, +{ + "model": "sessions.session", + "pk": "dtfi7hlq7jhyba82zvu8ap9xllap4scg", + "fields": { + "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qQmTx:Ew_qGu2uoSWNePBupqM7fp4UheMcO8w30IKxd17cUnM", + "expire_date": "2023-08-15T10:20:01.191Z" + } +}, +{ + "model": "sessions.session", + "pk": "fzptn3bl2yc60via2f5y7xsi8cjbw9je", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1qQjBV:hrn0LOs0ZWhi4CXkMe_Vt4Eb7Oxp8g_E8EaUrXkBQa4", + "expire_date": "2023-08-15T06:48:45.482Z" + } +}, +{ + "model": "sessions.session", + "pk": "ghzld6jkhbytx93agbto8lymgya7div0", + "fields": { + "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qRTtM:U-XGXteWUK_FiiVz32GoJpk0F3BKOw3aeQVHi5jGiAM", + "expire_date": "2023-08-17T08:41:08.527Z" + } +}, +{ + "model": "sessions.session", + "pk": "jopeqkcf9h72y3is4spa7501y6c437kd", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1qR7YM:TQ6WreWAQS6XFwHKMexaXcLkK6rhNNPyX46OBATGauo", + "expire_date": "2023-08-16T08:49:58.901Z" + } +}, +{ + "model": "sessions.session", + "pk": "la9ytgclqprfil35c6lp5f8k5ukpssbd", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pBCGbEu3fcMZIYZpGogKe3KeHfbpAvd_vfef6uA65LD2mQOE6urMur0uxHGp5Qd8APLvepYyzJPpHdFH7TpsbK8bof7d5Cx5a2GeBbp2HpIYAb0xgk7K8k6tJSM5f6CQw_EBphc7Hwv7C0BCaSNO_X5AvDnOIM:1q8nsg:EXnRO_tnvPQVApEuszmwxUyXfjcDdfsdKTuCTWg7wdw", + "expire_date": "2023-06-26T20:11:14.442Z" + } +}, +{ + "model": "sessions.session", + "pk": "wf24d94ueyo0r1i0362ql9fufmz16uxt", + "fields": { + "session_data": ".eJxVjMsOwiAQRf-FtSFAebp07zeQYRikaiAp7cr479qkC93ec859sQjbWuM2aIlzZmcm2el3S4APajvId2i3zrG3dZkT3xV-0MGvPdPzcrh_BxVG_dbWEeVp0kVIo8AqyiUZICpBIHpQWRQyqK3RXkAK1iavEUKhIMlNLrD3BwXLOKM:1q5iEw:z6fTxHGg-Ly6WLbbTwSL6jeo_-ak3b1mvII5bs4h_dQ", + "expire_date": "2023-06-18T07:33:26.326Z" + } +}, +{ + "model": "sessions.session", + "pk": "x4n2t2yjge37quhm0wd40iv4pzby38pw", + "fields": { + "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qR7Z6:O3X7EMM8GtMwc_DnNSIkhAZ0spLvOid40AnXRAs8McI", + "expire_date": "2023-08-16T08:50:44.825Z" + } +}, +{ + "model": "sessions.session", + "pk": "ys0nbg8n64hkh1qj8gzptt5udsanrk88", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2hBKgRlduu8ZyMCAVA1NSrsy3l1JutDdz38v7yU87Vvxe0urn1lchBan3y9QfKTaAd-p3hYZl7qtc5BdkQdtclo4Pa-H-xco1ErPMjgVAYOhnC0ohBC-y1DkrFUatcbRYj5DAI7aOGttTG4wA-YERCjeH_ZCOEM:1qRTtb:WZ4xw7esLCDjqSX56wKfO8IkmXosqkyLjDP5bsHnDIU", + "expire_date": "2023-08-17T08:41:23.805Z" + } +}, +{ + "model": "sessions.session", + "pk": "zkid91ej61wejh07li8fcfuu1qmod8ez", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1puDPA:Jguzk4JKh04XzB8JsWIvIuCgQmPgM298j2hqDWflYng", + "expire_date": "2023-05-17T14:24:28.097Z" + } +}, +{ + "model": "cpmonitor.city", + "pk": 1, + "fields": { + "draft_mode": false, + "name": "Beispielstadt", + "zipcode": "12345", + "url": "", + "resolution_date": "2021-03-18", + "target_year": 2035, + "teaser": "Teaser. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley", + "description": "Beschreibung. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type a", + "co2e_budget": 0, + "assessment_administration": "Wie bewertet ihr die **Nachhaltigkeitsarchitektur der Verwaltung**? Dieser Text fasst die wichtigsten Punkte zusammen.", + "assessment_action_plan": "Hier soll die Bewertung des **Klimaaktionsplans** stehen. Was haltet ihr von dem Plan?", + "assessment_status": "### Wie sieht es aus? \r\n\r\nEine einleitende Übersicht in die Bewertung des Umsetzungsstandes.\r\nHält die Kommune sich im Wesentlichen an ihren eigenen Plan?", + "last_update": "2023-08-03", + "contact_name": "", + "contact_email": "", + "internal_information": "Dies ist eine total wichtige interne Info!", + "slug": "beispielstadt", + "city_editors": [ + 2 + ], + "city_admins": [ + 3 + ] + } +}, +{ + "model": "cpmonitor.city", + "pk": 2, + "fields": { + "draft_mode": false, + "name": "Ohnenix", + "zipcode": "00000", + "url": "", + "resolution_date": null, + "target_year": null, + "teaser": "Eine Kommune ohne jegliche Infos. Muss ja auch getestet werden.", + "description": "", + "co2e_budget": 0, + "assessment_administration": "", + "assessment_action_plan": "", + "assessment_status": "", + "last_update": "2023-05-03", + "contact_name": "", + "contact_email": "", + "internal_information": "", + "slug": "ohnenix", + "city_editors": [], + "city_admins": [] + } +}, +{ + "model": "cpmonitor.city", + "pk": 3, + "fields": { + "draft_mode": false, + "name": "Mitallem", + "zipcode": "99999", + "url": "https://mitallem.de/", + "resolution_date": "2023-05-03", + "target_year": 2035, + "teaser": "Diese Kommune hat gaaanz viele Daten.", + "description": "", + "co2e_budget": 3, + "assessment_administration": "Eine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktive Verwaltung ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.", + "assessment_action_plan": "Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\nEine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Klimaaktionsplan ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.", + "assessment_status": "Eine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal. Eine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.\r\n\r\nEine fiktiver Umsetzungsstand ist schwer zu bewerten, deswegen steht hier nichts Sinnvolles. Und das wiederholt sich jetzt noch ein paar Mal.", + "last_update": "2023-06-16", + "contact_name": "Maxime Musterfrau", + "contact_email": "maxime@muster.frau", + "internal_information": "Dies ist eine total wichtige interne Info zu dieser Stadt!\r\n\r\nDies ist eine total wichtige interne Info zu dieser Stadt!\r\n\r\nDies ist eine total wichtige interne Info zu dieser Stadt!", + "slug": "mitallem", + "city_editors": [], + "city_admins": [] + } +}, +{ + "model": "cpmonitor.capchecklist", + "pk": 1, + "fields": { + "city": 1, + "cap_exists": true, + "target_date_exists": true, + "based_on_remaining_co2_budget": true, + "sectors_of_climate_vision_used": false, + "scenario_for_climate_neutrality_till_2035_exists": false, + "scenario_for_business_as_usual_exists": false, + "annual_costs_are_specified": false, + "tasks_are_planned_yearly": false, + "tasks_have_responsible_entity": false, + "annual_reduction_of_emissions_can_be_predicted": false, + "concept_for_participation_specified": true, + "sustainability_architecture_in_administration_exists": true, + "climate_council_exists": false + } +}, +{ + "model": "cpmonitor.capchecklist", + "pk": 2, + "fields": { + "city": 3, + "cap_exists": true, + "target_date_exists": true, + "based_on_remaining_co2_budget": true, + "sectors_of_climate_vision_used": true, + "scenario_for_climate_neutrality_till_2035_exists": true, + "scenario_for_business_as_usual_exists": true, + "annual_costs_are_specified": true, + "tasks_are_planned_yearly": true, + "tasks_have_responsible_entity": true, + "annual_reduction_of_emissions_can_be_predicted": true, + "concept_for_participation_specified": false, + "sustainability_architecture_in_administration_exists": false, + "climate_council_exists": true + } +}, +{ + "model": "cpmonitor.administrationchecklist", + "pk": 1, + "fields": { + "city": 3, + "climate_protection_management_exists": true, + "climate_technical_committee_exists": true, + "climate_relevance_check_exists": true, + "interdisciplinary_climate_protection_exists": true, + "climate_protection_monitoring_exists": true, + "intersectoral_concepts_exists": true, + "climate_protection_reports_are_continuously_published": true, + "guidelines_for_sustainable_procurement_exists": true, + "municipal_office_for_funding_management_exists": true, + "public_relation_with_local_actors_exists": true + } +}, +{ + "model": "cpmonitor.administrationchecklist", + "pk": 2, + "fields": { + "city": 1, + "climate_protection_management_exists": true, + "climate_technical_committee_exists": true, + "climate_relevance_check_exists": true, + "interdisciplinary_climate_protection_exists": true, + "climate_protection_monitoring_exists": true, + "intersectoral_concepts_exists": false, + "climate_protection_reports_are_continuously_published": false, + "guidelines_for_sustainable_procurement_exists": false, + "municipal_office_for_funding_management_exists": false, + "public_relation_with_local_actors_exists": false + } +}, +{ + "model": "cpmonitor.task", + "pk": 1, + "fields": { + "path": "0002", + "depth": 1, + "numchild": 5, + "city": 1, + "draft_mode": false, + "frontpage": false, + "title": "Mobilität", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": 0, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat" + } +}, +{ + "model": "cpmonitor.task", + "pk": 2, + "fields": { + "path": "00020003", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": false, + "frontpage": false, + "title": "Radwege ausbauen", + "teaser": "", + "description": "## Vorhaben\r\n\r\n* Radspuren mit einer Mindestbreite von 2,30 Metern\r\n* mehr Abstellanlagen für Fahrräder\r\n* optisch hervorgehobene Abschnitte mit Radvorrang \r\n* sicherere Kreuzungen für Radler", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 2, + "execution_justification": "", + "execution_completion": 0, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/radwege-ausbauen" + } +}, +{ + "model": "cpmonitor.task", + "pk": 3, + "fields": { + "path": "00020002", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": false, + "frontpage": false, + "title": "U-Bahn Strecke verlängern", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 8, + "execution_justification": "", + "execution_completion": 0, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/u-bahn-strecke-verlangern" + } +}, +{ + "model": "cpmonitor.task", + "pk": 4, + "fields": { + "path": "00020004", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": false, + "frontpage": false, + "title": "Carsharing einführen", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/carsharing-einfuhren" + } +}, +{ + "model": "cpmonitor.task", + "pk": 5, + "fields": { + "path": "00020005", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Parkraumbewirtschaftung", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/parkraumbewirtschaftung" + } +}, +{ + "model": "cpmonitor.task", + "pk": 6, + "fields": { + "path": "00020006", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Jobtickets für Verwaltung", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 4, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/jobtickets-fur-verwaltung" + } +}, +{ + "model": "cpmonitor.task", + "pk": 7, + "fields": { + "path": "0001", + "depth": 1, + "numchild": 6, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Strukturen", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen" + } +}, +{ + "model": "cpmonitor.task", + "pk": 8, + "fields": { + "path": "00010002", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Personal einstellen", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/personal-einstellen" + } +}, +{ + "model": "cpmonitor.task", + "pk": 9, + "fields": { + "path": "00010001", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Klimaaktionsplan", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/klimaaktionsplan" + } +}, +{ + "model": "cpmonitor.task", + "pk": 10, + "fields": { + "path": "00010003", + "depth": 2, + "numchild": 2, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Bürgerbeteiligung", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/burgerbeteiligung" + } +}, +{ + "model": "cpmonitor.task", + "pk": 11, + "fields": { + "path": "00010004", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Monitoring", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/monitoring" + } +}, +{ + "model": "cpmonitor.task", + "pk": 12, + "fields": { + "path": "00010005", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "European Energy Award", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 2, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/european-energy-award" + } +}, +{ + "model": "cpmonitor.task", + "pk": 13, + "fields": { + "path": "00010006", + "depth": 2, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Klimarelevanzprüfung", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/klimarelevanzprufung" + } +}, +{ + "model": "cpmonitor.task", + "pk": 14, + "fields": { + "path": "000100030001", + "depth": 3, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Beteiligungswerkstatt", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/burgerbeteiligung/beteiligungswerkstatt" + } +}, +{ + "model": "cpmonitor.task", + "pk": 15, + "fields": { + "path": "000100030002", + "depth": 3, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": false, + "title": "Marke schaffen", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/burgerbeteiligung/marke-schaffen" + } +}, +{ + "model": "cpmonitor.task", + "pk": 16, + "fields": { + "path": "0003", + "depth": 1, + "numchild": 0, + "city": 1, + "draft_mode": true, + "frontpage": true, + "title": "Umstellung Fernwärme auf Geothermie", + "teaser": "Der Kurztext der Maßnahme. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries,", + "description": "Die Beschreibung der Maßnahme. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem", + "planned_start": "2023-04-19", + "planned_completion": "2022-12-31", + "responsible_organ": "", + "plan_assessment": "Die Bewertung der geplanten Maßnahme. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries,", + "execution_status": 2, + "execution_justification": "", + "execution_completion": null, + "actual_start": "2023-04-19", + "actual_completion": null, + "internal_information": "", + "slugs": "umstellung-fernwarme-auf-geothermie" + } +}, +{ + "model": "cpmonitor.task", + "pk": 17, + "fields": { + "path": "0005", + "depth": 1, + "numchild": 1, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Energie", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "energie" + } +}, +{ + "model": "cpmonitor.task", + "pk": 18, + "fields": { + "path": "0006", + "depth": 1, + "numchild": 1, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Mobilität", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat" + } +}, +{ + "model": "cpmonitor.task", + "pk": 19, + "fields": { + "path": "0007", + "depth": 1, + "numchild": 1, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Gebäude", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "gebaude" + } +}, +{ + "model": "cpmonitor.task", + "pk": 20, + "fields": { + "path": "0004", + "depth": 1, + "numchild": 2, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Strukturen", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen" + } +}, +{ + "model": "cpmonitor.task", + "pk": 21, + "fields": { + "path": "0009", + "depth": 1, + "numchild": 1, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Landwirtschaft", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "landwirtschaft" + } +}, +{ + "model": "cpmonitor.task", + "pk": 22, + "fields": { + "path": "00050001", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "PV auf alle öffentlichen Dächer", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 2, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "energie/pv-auf-alle-offentlichen-dacher" + } +}, +{ + "model": "cpmonitor.task", + "pk": 23, + "fields": { + "path": "00070001", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Sanierungsprogramm: Dämmung", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 0, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "gebaude/sanierungsprogramm-dammung" + } +}, +{ + "model": "cpmonitor.task", + "pk": 24, + "fields": { + "path": "00060001", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Ladesäulen flächendeckend", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 4, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "mobilitat/ladesaulen-flachendeckend" + } +}, +{ + "model": "cpmonitor.task", + "pk": 25, + "fields": { + "path": "00090001", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Förderung Ökologischer Landbau", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 6, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "landwirtschaft/forderung-okologischer-landbau" + } +}, +{ + "model": "cpmonitor.task", + "pk": 26, + "fields": { + "path": "00040002", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Zukunftswerkstatt", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 8, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/zukunftswerkstatt" + } +}, +{ + "model": "cpmonitor.task", + "pk": 27, + "fields": { + "path": "00040001", + "depth": 2, + "numchild": 0, + "city": 3, + "draft_mode": true, + "frontpage": false, + "title": "Expertenrat", + "teaser": "", + "description": "", + "planned_start": null, + "planned_completion": null, + "responsible_organ": "", + "plan_assessment": "", + "execution_status": 2, + "execution_justification": "", + "execution_completion": null, + "actual_start": null, + "actual_completion": null, + "internal_information": "", + "slugs": "strukturen/expertenrat" + } +}, +{ + "model": "cpmonitor.chart", + "pk": 1, + "fields": { + "city": 1, + "image": "uploads/2023/05/03/3202552975_cdafe46620_o.jpg", + "alt_description": "Graph: Weltbevölkerung und CO2-Emissionen", + "source": "Matt Lemmon", + "license": "CC BY-SA 2.0", + "caption": "Dies ist nur ein **Beispiel** KPI Graph, weil es sich hier ja auch nur um eine Beispielkommune handelt. Wurde [hier](https://openverse.org/image/f6a4ca33-b1fc-488d-9f2a-dd6ef9f1426f) gefunden.", + "internal_information": "Dies ist eine total wichtige interne Info!" + } +}, +{ + "model": "cpmonitor.chart", + "pk": 2, + "fields": { + "city": 1, + "image": "uploads/2023/05/03/52563572761_b724d0fd88_o.jpg", + "alt_description": "Graph: Benötigte Leistung (in Anzahl Pferden) in Abhängigkeit von der Steigung", + "source": "Seattle Municipal Archives", + "license": "CC BY 2.0", + "caption": "Und dieses schöne Bild habe ich [hier](https://openverse.org/image/c5aaa76a-dd8f-4cd0-8b5d-30d874a747ff) gefunden.", + "internal_information": "Dies ist eine total wichtige interne Info!" + } +}, +{ + "model": "cpmonitor.chart", + "pk": 3, + "fields": { + "city": 3, + "image": "uploads/2023/05/03/3202552975_cdafe46620_o.jpg", + "alt_description": "Graph: Weltbevölkerung und CO2-Emissionen", + "source": "Matt Lemmon", + "license": "CC BY-SA 2.0", + "caption": "Dies ist nur ein **Beispiel** KPI Graph, weil es sich hier ja auch nur um eine Beispielkommune handelt. Wurde [hier](https://openverse.org/image/f6a4ca33-b1fc-488d-9f2a-dd6ef9f1426f) gefunden.", + "internal_information": "Dies ist eine total wichtige interne Info zu CO2-Emissionen!\r\n\r\nDies ist eine total wichtige interne Info zu CO2-Emissionen!\r\n\r\nDies ist eine total wichtige interne Info zu CO2-Emissionen!" + } +}, +{ + "model": "cpmonitor.chart", + "pk": 4, + "fields": { + "city": 3, + "image": "uploads/2023/05/03/52563572761_b724d0fd88_o.jpg", + "alt_description": "Graph: Benötigte Leistung (in Anzahl Pferden) in Abhängigkeit von der Steigung", + "source": "Seattle Municipal Archives", + "license": "CC BY 2.0", + "caption": "Und dieses schöne Bild habe ich [hier](https://openverse.org/image/c5aaa76a-dd8f-4cd0-8b5d-30d874a747ff) gefunden.", + "internal_information": "Dies ist eine total wichtige interne Info zu Pferden!\r\n\r\nDies ist eine total wichtige interne Info zu Pferden!\r\n\r\nDies ist eine total wichtige interne Info zu Pferden!" + } +}, +{ + "model": "cpmonitor.localgroup", + "pk": 1, + "fields": { + "city": 1, + "name": "BeispielLos", + "website": "https://www.beispiel-los.de", + "teaser": "Eine beispiellos gute Initiative für eine klimaneutrale Zukunft in unserer Stadt.", + "description": "Wenn Du mitmachen willst, wendest du Dich am besten an Maxi Mustermensch unter maxi@beispiel-los.de.\r\n\r\nUnser Monitoring basiert auf öffentlich zugänglichen Informationen aus dem lokalen BeispielBlatt, dere Website der Stadtverwaltung, dem Klimaaktionsplan, dem Ratsinformationssystem und dem Marktstammdatenregister. Und wir haben auch mit dem Bürgermeister gesprochen. Weitergehende Informationen zum Monitoring bekommst Du bei Dina Durchblick unter dina@beispiel-los.de.", + "featured_image": "uploads/local_groups/Fotos-alle-1024x683_SEEJ1JR.jpeg" + } +} +] diff --git a/cpmonitor/migrations/0024_city_city_admins_city_city_editors.py b/cpmonitor/migrations/0024_city_city_admins_city_city_editors.py new file mode 100644 index 00000000..6f72b1e6 --- /dev/null +++ b/cpmonitor/migrations/0024_city_city_admins_city_city_editors.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.7 on 2023-08-01 06:39 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("cpmonitor", "0023_task_frontpage"), + ] + + operations = [ + migrations.AddField( + model_name="city", + name="city_admins", + field=models.ManyToManyField( + blank=True, + help_text='\n

Diese Benutzer können zusätzlich andere Benutzter als Admins und Bearbeiter eintragen.

\n

Sie brauchen nicht als "Bearbeiter" eingetragen zu werden.

\n ', + related_name="administered_cities", + to=settings.AUTH_USER_MODEL, + verbose_name="Kommunen Admins", + ), + ), + migrations.AddField( + model_name="city", + name="city_editors", + field=models.ManyToManyField( + blank=True, + help_text="\n

Diese Benutzer können alle Inhalte der Kommune bearbeiten.

\n ", + related_name="edited_cities", + to=settings.AUTH_USER_MODEL, + verbose_name="Kommunen Bearbeiter", + ), + ), + ] diff --git a/cpmonitor/models.py b/cpmonitor/models.py index a976cbe0..c819d326 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -1,5 +1,6 @@ from datetime import date +from django.conf import settings from django.core.exceptions import ValidationError, NON_FIELD_ERRORS from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -35,6 +36,27 @@ class Meta: zipcode = models.CharField("PLZ", max_length=5) url = models.URLField("Homepage", blank=True) + city_editors = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + verbose_name="Kommunen Bearbeiter", + related_name="edited_cities", + help_text=""" +

Diese Benutzer können alle Inhalte der Kommune bearbeiten.

+ """, + ) + + city_admins = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + verbose_name="Kommunen Admins", + related_name="administered_cities", + help_text=""" +

Diese Benutzer können zusätzlich andere Benutzter als Admins und Bearbeiter eintragen.

+

Sie brauchen nicht als "Bearbeiter" eingetragen zu werden.

+ """, + ) + resolution_date = models.DateField( "Datum des Klimaneutralitäts-Beschlusses", blank=True, diff --git a/cpmonitor/rules.py b/cpmonitor/rules.py new file mode 100644 index 00000000..9c6dd3af --- /dev/null +++ b/cpmonitor/rules.py @@ -0,0 +1,98 @@ +from django.contrib.auth.models import User +from django.db.models import QuerySet, Q +import rules +from types import NoneType +from typing import TypeVar + +from .models import City, Task, Chart + +CityObject = City | Task | Chart | NoneType +T = TypeVar("T", City, Task) + + +def filter_editable(user: User, qs: QuerySet[T]) -> QuerySet[T]: + if qs.model == City: + return qs.filter(Q(city_editors=user) | Q(city_admins=user)) + else: + return qs.filter(Q(city__city_editors=user) | Q(city__city_admins=user)) + + +def _get_city(object: CityObject) -> City | NoneType: + # print(object) + if isinstance(object, City): + return object + else: + return getattr(object, "city", None) + + +@rules.predicate +def is_city_editor(user: User, object: CityObject) -> bool: + city = _get_city(object) + if isinstance(city, City): + return city.city_editors.filter(pk=user.pk).exists() + else: + return False + + +@rules.predicate +def is_city_admin(user: User, object: CityObject) -> bool: + city = _get_city(object) + if isinstance(city, City): + return city.city_admins.filter(pk=user.pk).exists() + else: + return False + + +@rules.predicate +def is_site_admin(user: User, object: CityObject) -> bool: + return user.is_superuser + + +@rules.predicate +def no_object(user: User, object: CityObject) -> bool: + if object is None: + return True + + +is_allowed_to_edit = is_city_editor | is_city_admin | is_site_admin +is_allowed_to_change_site_editors = is_city_admin | is_site_admin +is_allowed_to_change_site_admins = is_city_admin | is_site_admin + + +# The actual permissions: + +# City: +# Only add and change permissions are given to city editors and admins. +# Site admins are superusers and can change everything, anyway. +rules.add_perm("cpmonitor.view_city", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_city", is_allowed_to_edit) + +# Inlines in city mask: +# For some reason, "change" is requested with "None" once by inlines. +rules.add_perm("cpmonitor.add_chart", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_chart", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_chart", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_chart", is_allowed_to_edit | no_object) + +rules.add_perm("cpmonitor.add_localgroup", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_localgroup", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_localgroup", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_localgroup", is_allowed_to_edit | no_object) + +rules.add_perm("cpmonitor.add_administrationchecklist", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_administrationchecklist", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_administrationchecklist", is_allowed_to_edit) +rules.add_perm( + "cpmonitor.change_administrationchecklist", is_allowed_to_edit | no_object +) + +rules.add_perm("cpmonitor.add_capchecklist", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_capchecklist", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_capchecklist", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_capchecklist", is_allowed_to_edit | no_object) + +# TODO: This currently does not ensure that tasks are not added to other cities: +rules.add_perm("cpmonitor.add_task", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_task", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_task", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_task", is_allowed_to_edit) diff --git a/cpmonitor/tests/permissions_test.py b/cpmonitor/tests/permissions_test.py new file mode 100644 index 00000000..f61b7481 --- /dev/null +++ b/cpmonitor/tests/permissions_test.py @@ -0,0 +1,291 @@ +import pytest + +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test import Client +from pytest_django.asserts import ( + assertTemplateUsed, + assertTemplateNotUsed, + assertContains, + assertNotContains, +) + + +@pytest.fixture(scope="session") +def permissions_db(django_db_setup, django_db_blocker): + "Fixture to load the test data fixture for permissions tests." + with django_db_blocker.unblock(): + call_command("loaddata", "permissions") + + +# Some utilities for logging in + + +def _login(client: Client, django_user_model: User, username: str): + "Helper for logging in as an existing user present in the database." + user = django_user_model.objects.get(username=username) + client.force_login(user) + + +@pytest.fixture +def unprivileged_client(permissions_db, client: Client, django_user_model: User): + "Client fixture with data loaded and unprivileged user logged in." + _login(client, django_user_model, "christian") + return client + + +@pytest.fixture +def site_admin_client(permissions_db, client: Client, django_user_model: User): + "Client fixture with data loaded and site admin user logged in." + _login(client, django_user_model, "admin") + return client + + +@pytest.fixture +def city_admin_client(permissions_db, client: Client, django_user_model: User): + "Client fixture with data loaded and city admin user logged in." + _login(client, django_user_model, "sarah") + return client + + +@pytest.fixture +def city_editor_client(permissions_db, client: Client, django_user_model: User): + "Client fixture with data loaded and city editor user logged in." + _login(client, django_user_model, "heinz") + return client + + +# Tests for plain login to admin site + + +def test_admin_login_should_fail_fail_for_non_existing_user( + permissions_db, client: Client, django_user_model: User +): + client.login(username="asdf", password="ghjk") + response = client.get("/admin/") + assertTemplateNotUsed(response, "admin/index.html") + + +def test_admin_login_should_fail_for_unprivileged_user(unprivileged_client: Client): + response = unprivileged_client.get("/admin/") + assertTemplateNotUsed(response, "admin/index.html") + + +def test_admin_login_should_succeed_for_site_admin(site_admin_client: Client): + response = site_admin_client.get("/admin/") + assertTemplateUsed(response, "admin/index.html") + + +def test_admin_login_should_succeed_for_city_admin(city_admin_client: Client): + response = city_admin_client.get("/admin/") + assertTemplateUsed(response, "admin/index.html") + + +def test_admin_login_should_succeed_for_city_editor(city_editor_client: Client): + response = city_editor_client.get("/admin/") + assertTemplateUsed(response, "admin/index.html") + + +# City changelist + + +def test_city_changelist_should_contain_all_cities_and_add_for_site_admin( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/city/") + assertTemplateUsed(response, "admin/change_list.html") + result_list = response.context["results"] + assert isinstance(result_list, list) and len(result_list) == 3 + assertContains(response, "Beispielstadt") + assertContains(response, "Mitallem") + assertContains(response, "Ohnenix") + + assertContains(response, "Kommune hinzufügen") + + +def test_city_changelist_should_only_contain_one_city_and_no_add_for_city_admin( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/city/") + assertTemplateUsed(response, "admin/change_list.html") + result_list = response.context["results"] + assert isinstance(result_list, list) and len(result_list) == 1 + assertContains(response, "Beispielstadt") + assertNotContains(response, "Mitallem") + + assertNotContains(response, "Kommune hinzufügen") + + +def test_city_changelist_should_only_contain_one_city_and_no_add_for_city_editor( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/city/") + assertTemplateUsed(response, "admin/change_list.html") + result_list = response.context["results"] + assert isinstance(result_list, list) and len(result_list) == 1 + assertContains(response, "Beispielstadt") + assertNotContains(response, "Mitallem") + + assertNotContains(response, "Kommune hinzufügen") + + +# City change form + + +def test_city_editor_should_not_be_allowed_to_access_to_another_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/city/2/change/") + + assert response.status_code == 302 + assert response.url == "/admin/" + + +def test_city_admin_should_not_be_allowed_to_access_to_another_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/city/2/change/") + + assert response.status_code == 302 + assert response.url == "/admin/" + + +def test_site_admin_should_not_be_allowed_to_access_to_another_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/city/2/change/") + + assert response.status_code == 200 + assertTemplateUsed(response, "admin/change_form.html") + + +def test_city_editor_should_not_be_allowed_to_delete_add_and_change_editors_and_admins( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/city/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert not response.context["show_delete_link"] + assert not response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + # TODO: This assumes a single fieldset: + fields = adminform.fieldsets[0][1]["fields"] + assert "city_editors" in fields + assert "city_admins" in fields + assert "city_editors" in adminform.readonly_fields + assert "city_admins" in adminform.readonly_fields + + +def test_city_admin_should_not_be_allowed_to_delete_add_but_to_change_editors_and_admins( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/city/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert not response.context["show_delete_link"] + assert not response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + # TODO: This assumes a single fieldset: + fields = adminform.fieldsets[0][1]["fields"] + assert "city_editors" in fields + assert "city_admins" in fields + assert not "city_editors" in adminform.readonly_fields + assert not "city_admins" in adminform.readonly_fields + + +def test_site_admin_should_be_allowed_to_delete_add_and_to_change_editors_and_admins( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/city/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_delete_link"] + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + # TODO: This assumes a single fieldset: + fields = adminform.fieldsets[0][1]["fields"] + assert "city_editors" in fields + assert "city_admins" in fields + assert not "city_editors" in adminform.readonly_fields + assert not "city_admins" in adminform.readonly_fields + + +def test_city_editor_should_be_allowed_to_modify_inlines( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/city/1/change/") + + formsets = response.context["inline_admin_formsets"] + + verbose_names = map(lambda formset: formset.opts.verbose_name, formsets) + # This will fail when new inlines are added. + # If that happens, please extend the list below, accordingly. + assert len(formsets) == 4 + assert "Diagramm" in verbose_names + assert "Lokalgruppe" in verbose_names + assert "KAP Checkliste" in verbose_names + assert "Verwaltungsstrukturen Checkliste" in verbose_names + + for formset in formsets: + assert formset.has_view_permission + assert formset.has_add_permission + assert formset.has_change_permission + assert formset.has_delete_permission + + +def test_city_admin_should_be_allowed_to_modify_inlines( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/city/1/change/") + + formsets = response.context["inline_admin_formsets"] + + verbose_names = map(lambda formset: formset.opts.verbose_name, formsets) + # This will fail when new inlines are added. + # If that happens, please extend the list below, accordingly. + assert len(formsets) == 4 + assert "Diagramm" in verbose_names + assert "Lokalgruppe" in verbose_names + assert "KAP Checkliste" in verbose_names + assert "Verwaltungsstrukturen Checkliste" in verbose_names + + for formset in formsets: + assert formset.has_view_permission + assert formset.has_add_permission + assert formset.has_change_permission + assert formset.has_delete_permission + + +def test_site_admin_should_be_allowed_to_modify_inlines( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/city/1/change/") + + formsets = response.context["inline_admin_formsets"] + + verbose_names = map(lambda formset: formset.opts.verbose_name, formsets) + # This will fail when new inlines are added. + # If that happens, please extend the list below, accordingly. + assert len(formsets) == 4 + assert "Diagramm" in verbose_names + assert "Lokalgruppe" in verbose_names + assert "KAP Checkliste" in verbose_names + assert "Verwaltungsstrukturen Checkliste" in verbose_names + + for formset in formsets: + assert formset.has_view_permission + assert formset.has_add_permission + assert formset.has_change_permission + assert formset.has_delete_permission diff --git a/e2e_tests/database/test_database.json b/e2e_tests/database/test_database.json index 3b79bfe9..3f150132 100644 --- a/e2e_tests/database/test_database.json +++ b/e2e_tests/database/test_database.json @@ -395,12 +395,59 @@ "codename": "view_administrationchecklist" } }, +{ + "model": "auth.permission", + "pk": 45, + "fields": { + "name": "Can add Lokalgruppe", + "content_type": 12, + "codename": "add_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 46, + "fields": { + "name": "Can change Lokalgruppe", + "content_type": 12, + "codename": "change_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 47, + "fields": { + "name": "Can delete Lokalgruppe", + "content_type": 12, + "codename": "delete_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 48, + "fields": { + "name": "Can view Lokalgruppe", + "content_type": 12, + "codename": "view_localgroup" + } +}, +{ + "model": "auth.group", + "pk": 1, + "fields": { + "name": "Alle", + "permissions": [ + 26, + 30 + ] + } +}, { "model": "auth.user", "pk": 1, "fields": { "password": "pbkdf2_sha256$390000$LhtVLCSacVFhgrmY76MEcl$kGjF/lJDI/u2ONODcz+sN+Gc71vgg2pcGMxGrXb+IqM=", - "last_login": "2023-06-24T17:17:39.431Z", + "last_login": "2023-06-12T20:11:14.431Z", "is_superuser": true, "username": "admin", "first_name": "", @@ -413,6 +460,46 @@ "user_permissions": [] } }, +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$390000$G4rrXlpXdRuKkawMKn9nmm$QYmR1/Jp16VNQRFLfDr0Kt2JPuicXZ1EGD+EsF2aFrE=", + "last_login": "2023-08-03T08:41:23.794Z", + "is_superuser": false, + "username": "heinz", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-08-01T06:50:55Z", + "groups": [ + 1 + ], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$390000$Y8L8lSp9YhsgH1ZZvchQE9$XHI/U2h4QwRn1ssNJoX623Ga0u4HXKurzRtw4f4DdXk=", + "last_login": "2023-08-03T08:41:08.520Z", + "is_superuser": false, + "username": "sarah", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-08-01T10:19:25Z", + "groups": [ + 1 + ], + "user_permissions": [] + } +}, { "model": "cpmonitor.city", "pk": 1, @@ -429,11 +516,17 @@ "assessment_administration": "Wie bewertet ihr die **Nachhaltigkeitsarchitektur der Verwaltung**? Dieser Text fasst die wichtigsten Punkte zusammen.", "assessment_action_plan": "Hier soll die Bewertung des **Klimaaktionsplans** stehen. Was haltet ihr von dem Plan?", "assessment_status": "### Wie sieht es aus? \r\n\r\nEine einleitende Übersicht in die Bewertung des Umsetzungsstandes.\r\nHält die Kommune sich im Wesentlichen an ihren eigenen Plan?", - "last_update": "2023-06-16", + "last_update": "2023-08-03", "contact_name": "", "contact_email": "", "internal_information": "Dies ist eine total wichtige interne Info!", - "slug": "beispielstadt" + "slug": "beispielstadt", + "city_editors": [ + 2 + ], + "city_admins": [ + 3 + ] } }, { @@ -456,7 +549,9 @@ "contact_name": "", "contact_email": "", "internal_information": "", - "slug": "ohnenix" + "slug": "ohnenix", + "city_editors": [], + "city_admins": [] } }, { @@ -479,7 +574,9 @@ "contact_name": "Maxime Musterfrau", "contact_email": "maxime@muster.frau", "internal_information": "Dies ist eine total wichtige interne Info zu dieser Stadt!\r\n\r\nDies ist eine total wichtige interne Info zu dieser Stadt!\r\n\r\nDies ist eine total wichtige interne Info zu dieser Stadt!", - "slug": "mitallem" + "slug": "mitallem", + "city_editors": [], + "city_admins": [] } }, { diff --git a/poetry.lock b/poetry.lock index 70e170b7..883932af 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1139,6 +1139,18 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rules" +version = "3.3" +description = "Awesome Django authorization, without the database" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "rules-3.3-py2.py3-none-any.whl", hash = "sha256:12c8bbab5f54560e68528fcca7abc0e162c35ac882e3cc0daed40ac49c963070"}, + {file = "rules-3.3.tar.gz", hash = "sha256:bf7bea8b724b73c36a622714c1b3557620c187a2ee05321a2ac8ab7472dc4464"}, +] + [[package]] name = "setuptools" version = "67.4.0" @@ -1302,4 +1314,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "fb2a4a024580d14803a487168afbc42ce90bb5c59bf3d18c127e73aec75b65cf" +content-hash = "a9a55fdcf69558f05af6c1100583d1a89e38605d1348fd789be4be2f8d71f0c9" diff --git a/pyproject.toml b/pyproject.toml index b6a687d6..09c7204d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ gunicorn = "^20.1.0" django-treebeard = "^4.6.1" martor = "^1.6.19" pillow = "^9.5.0" +rules = "^3.3" [tool.pyright] venvPath = "." From 53d290af99e2052b0e7bd3661a16cd35136c3824 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Mon, 7 Aug 2023 10:48:37 +0200 Subject: [PATCH 09/31] Remove structure column from task tree. --- cpmonitor/admin.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 2a3c8613..599f2202 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -199,20 +199,7 @@ class TaskAdmin(ObjectPermissionsModelAdminMixin, TreeAdmin): # ------ change list page ------ change_list_template = "admin/task_changelist.html" - @admin.display(description="Struktur") - def structure(self, task: Task): - """Additional read-only field showing the tree structure.""" - - def add_parents(current: Task, substructure: str): - parent = current.get_parent() - if parent == None: - return substructure - else: - return add_parents(parent, "%s --> %s" % (parent.title, substructure)) - - return add_parents(task, task.title) - - @admin.display(description="Public page") + @admin.display(description="Öffentliche Seite") def slug_link(self, task: Task): """Additional link to the public page and also showing the slugs.""" url = reverse( @@ -220,7 +207,7 @@ def slug_link(self, task: Task): ) return format_html('{}', url, task.slugs) - list_display = ("title", "structure", "slug_link") + list_display = ("title", "slug_link") form = movenodeform_factory(Task, TaskForm) list_filter = ("city",) From f08b193e82e8088593e659ac2e28bd261802e3b7 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Wed, 9 Aug 2023 10:35:57 +0200 Subject: [PATCH 10/31] [#23] Permissions for Task. --- cpmonitor/admin.py | 59 +++- cpmonitor/rules.py | 52 ++-- cpmonitor/tests/permissions_test.py | 414 +++++++++++++++++++++++++++- 3 files changed, 484 insertions(+), 41 deletions(-) diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 599f2202..98c566a7 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -1,7 +1,7 @@ from collections.abc import Sequence -from django.contrib import admin, auth +from django.contrib import admin, auth, messages from django.db import models -from django.forms import TextInput +from django.forms import ModelChoiceField, TextInput from django.forms.models import ErrorList from django.http import HttpRequest, HttpResponseRedirect, QueryDict from django.http.request import HttpRequest @@ -11,13 +11,8 @@ from treebeard.admin import TreeAdmin from treebeard.forms import movenodeform_factory, MoveNodeForm from rules.contrib.admin import ObjectPermissionsModelAdminMixin -from rules.permissions import perm_exists -from .rules import ( - filter_editable, - is_allowed_to_change_site_admins, - is_allowed_to_change_site_editors, -) +from . import rules from .models import Chart, City, Task, CapChecklist, AdministrationChecklist, LocalGroup _city_filter_query = "city__id__exact" @@ -86,7 +81,7 @@ def get_queryset(self, request): user = request.user if user.is_superuser: return qs - return filter_editable(user, qs) + return qs.filter(rules.is_allowed_to_edit_q(user, City)) save_on_top = True @@ -95,9 +90,9 @@ def get_queryset(self, request): def get_readonly_fields(self, request: HttpRequest, obj=None) -> Sequence[str]: user = request.user result = [] - if not is_allowed_to_change_site_editors(user, obj): + if not rules.is_allowed_to_change_site_editors(user, obj): result.append("city_editors") - if not is_allowed_to_change_site_admins(user, obj): + if not rules.is_allowed_to_change_site_admins(user, obj): result.append("city_admins") return result @@ -195,6 +190,20 @@ def clean(self): return super().clean() +class CityPermissionFilter(admin.RelatedFieldListFilter): + def field_choices(self, field, request, model_admin): + "Limit to cities the user is allowed to edit." + return field.get_choices( + include_blank=False, + ordering=self.field_admin_ordering(field, request, model_admin), + limit_choices_to=rules.is_allowed_to_edit_q(request.user, City), + ) + + def has_output(self): + "Show even a single possibility. Otherwise, the tasks will not be filtered." + return len(self.lookup_choices) > 0 + + class TaskAdmin(ObjectPermissionsModelAdminMixin, TreeAdmin): # ------ change list page ------ change_list_template = "admin/task_changelist.html" @@ -210,16 +219,18 @@ def slug_link(self, task: Task): list_display = ("title", "slug_link") form = movenodeform_factory(Task, TaskForm) - list_filter = ("city",) + list_filter = (("city", CityPermissionFilter),) def changelist_view(self, request): """Redirect to city changelist if no city filter is given.""" city_id = request.GET.get(_city_filter_query) - if not city_id: - return HttpResponseRedirect(_admin_url(City, "changelist", None)) - else: + if city_id and rules.is_allowed_to_edit(request.user, int(city_id)): return super().changelist_view(request) + msg = "Bitte eine Stadt auswählen, für die Sektoren / Maßnahmen geändert werden sollen. Rechts davon 'KAP bearbeiten' wählen." + self.message_user(request, msg, messages.INFO) + return HttpResponseRedirect(_admin_url(City, "changelist", None)) + search_fields = ("title",) search_help_text = "Suche im Titel" @@ -258,6 +269,13 @@ def get_readonly_fields(self, request, obj=None): models.TextField: {"widget": AdminMartorWidget}, } + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "city": + kwargs["queryset"] = City.objects.filter( + rules.is_allowed_to_edit_q(request.user, City) + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + def get_changeform_initial_data(self, request: HttpRequest): """Prefill the city based on the filter preserved from the changelist view.""" query_string = self.get_preserved_filters(request) @@ -265,6 +283,17 @@ def get_changeform_initial_data(self, request: HttpRequest): city_id = QueryDict(filters).get(_city_filter_query) return {"city": city_id} + def add_view(self, request, form_url="", extra_context=None): + query_string = self.get_preserved_filters(request) + filters = QueryDict(query_string).get("_changelist_filters") + city_id = QueryDict(filters).get(_city_filter_query) + if city_id and rules.is_allowed_to_edit(request.user, int(city_id)): + return super().add_view(request, form_url, extra_context) + + msg = "Bitte eine Stadt auswählen, für die ein Sektor / eine Maßnahme hinzugefügt werden soll. Rechts davon 'KAP bearbeiten' wählen." + self.message_user(request, msg, messages.INFO) + return HttpResponseRedirect(_admin_url(City, "changelist", None)) + admin.site.site_header = "LocalZero Monitoring" admin.site.site_title = "LocalZero Monitoring" diff --git a/cpmonitor/rules.py b/cpmonitor/rules.py index 9c6dd3af..e3218af2 100644 --- a/cpmonitor/rules.py +++ b/cpmonitor/rules.py @@ -1,57 +1,69 @@ from django.contrib.auth.models import User -from django.db.models import QuerySet, Q +from django.db.models import Q, Model import rules from types import NoneType -from typing import TypeVar from .models import City, Task, Chart -CityObject = City | Task | Chart | NoneType -T = TypeVar("T", City, Task) +CityType = City | Task | Chart | int +CityOrNoneType = CityType | NoneType -def filter_editable(user: User, qs: QuerySet[T]) -> QuerySet[T]: - if qs.model == City: - return qs.filter(Q(city_editors=user) | Q(city_admins=user)) +def is_allowed_to_edit_q(user: User, model: Model) -> Q: + if not user.is_staff or not user.is_active: + return Q(pk__in=[]) # Always false -> Empty QuerySet + if user.is_superuser: + return ~Q(pk__in=[]) # Always true -> All objects + if model == City: + return Q(city_editors=user) | Q(city_admins=user) else: - return qs.filter(Q(city__city_editors=user) | Q(city__city_admins=user)) + return Q(city__city_editors=user) | Q(city__city_admins=user) -def _get_city(object: CityObject) -> City | NoneType: - # print(object) +def _get_city(object: CityOrNoneType) -> City | NoneType: if isinstance(object, City): return object + elif isinstance(object, int): + return City.objects.filter(id=object).first() else: return getattr(object, "city", None) @rules.predicate -def is_city_editor(user: User, object: CityObject) -> bool: +def is_city_editor(user: User, object: CityOrNoneType) -> bool: + if not user.is_staff or not user.is_active: + return False city = _get_city(object) if isinstance(city, City): return city.city_editors.filter(pk=user.pk).exists() - else: - return False + return False @rules.predicate -def is_city_admin(user: User, object: CityObject) -> bool: +def is_city_admin(user: User, object: CityOrNoneType) -> bool: + if not user.is_staff or not user.is_active: + return False city = _get_city(object) if isinstance(city, City): return city.city_admins.filter(pk=user.pk).exists() - else: - return False + return False @rules.predicate -def is_site_admin(user: User, object: CityObject) -> bool: - return user.is_superuser +def is_site_admin(user: User, object: CityOrNoneType) -> bool: + if not user.is_superuser or not user.is_active: + return False + city = _get_city(object) + if isinstance(city, City): + return True + return False @rules.predicate -def no_object(user: User, object: CityObject) -> bool: - if object is None: +def no_object(user: User, object: CityOrNoneType) -> bool: + if object is None and user.is_active and user.is_staff: return True + return False is_allowed_to_edit = is_city_editor | is_city_admin | is_site_admin diff --git a/cpmonitor/tests/permissions_test.py b/cpmonitor/tests/permissions_test.py index f61b7481..362fadea 100644 --- a/cpmonitor/tests/permissions_test.py +++ b/cpmonitor/tests/permissions_test.py @@ -1,8 +1,10 @@ import pytest +from django.contrib import admin from django.contrib.auth.models import User from django.core.management import call_command from django.test import Client +from itertools import chain from pytest_django.asserts import ( assertTemplateUsed, assertTemplateNotUsed, @@ -18,6 +20,12 @@ def permissions_db(django_db_setup, django_db_blocker): call_command("loaddata", "permissions") +def _fields_from_form(form): + "Retrieve the fields from all fieldsets of a form." + list_of_lists = list(map(lambda fieldset: fieldset[1]["fields"], form.fieldsets)) + return chain(*list_of_lists) + + # Some utilities for logging in @@ -172,8 +180,7 @@ def test_city_editor_should_not_be_allowed_to_delete_add_and_change_editors_and_ assert not response.context["show_save_and_add_another"] adminform = response.context["adminform"] - # TODO: This assumes a single fieldset: - fields = adminform.fieldsets[0][1]["fields"] + fields = _fields_from_form(adminform) assert "city_editors" in fields assert "city_admins" in fields assert "city_editors" in adminform.readonly_fields @@ -193,8 +200,7 @@ def test_city_admin_should_not_be_allowed_to_delete_add_but_to_change_editors_an assert not response.context["show_save_and_add_another"] adminform = response.context["adminform"] - # TODO: This assumes a single fieldset: - fields = adminform.fieldsets[0][1]["fields"] + fields = _fields_from_form(adminform) assert "city_editors" in fields assert "city_admins" in fields assert not "city_editors" in adminform.readonly_fields @@ -214,8 +220,7 @@ def test_site_admin_should_be_allowed_to_delete_add_and_to_change_editors_and_ad assert response.context["show_save_and_add_another"] adminform = response.context["adminform"] - # TODO: This assumes a single fieldset: - fields = adminform.fieldsets[0][1]["fields"] + fields = _fields_from_form(adminform) assert "city_editors" in fields assert "city_admins" in fields assert not "city_editors" in adminform.readonly_fields @@ -289,3 +294,400 @@ def test_site_admin_should_be_allowed_to_modify_inlines( assert formset.has_add_permission assert formset.has_change_permission assert formset.has_delete_permission + + +# Task changelist + + +def test_city_editor_should_not_be_allowed_to_view_tasks_of_nonexistent_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/?city__id__exact=9") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_admin_should_not_be_allowed_to_view_tasks_of_nonexistent_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/?city__id__exact=9") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_site_admin_should_not_be_allowed_to_view_tasks_of_nonexistent_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/?city__id__exact=9") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_editor_should_not_be_allowed_to_view_tasks_of_other_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/?city__id__exact=3") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_admin_should_not_be_allowed_to_view_tasks_of_other_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/?city__id__exact=3") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_site_admin_should_not_be_allowed_to_view_tasks_of_other_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/?city__id__exact=3") + + assert response.status_code == 200 + assertTemplateUsed(response, "admin/change_list.html") + + +def test_city_editor_should_be_allowed_to_view_and_add_tasks_of_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/?city__id__exact=1") + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_list.html") + + assertContains(response, "Maßnahme hinzufügen") + + assertContains(response, "/beispielstadt/massnahmen/") + assertNotContains(response, "/mitallem/massnahmen/") + + +def test_city_admin_should_be_allowed_to_view_and_add_tasks_of_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/?city__id__exact=1") + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_list.html") + + assertContains(response, "Maßnahme hinzufügen") + + assertContains(response, "/beispielstadt/massnahmen/") + assertNotContains(response, "/mitallem/massnahmen/") + + +def test_site_admin_should_be_allowed_to_view_and_add_tasks_of_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/?city__id__exact=1") + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_list.html") + + assertContains(response, "Maßnahme hinzufügen") + + assertContains(response, "/beispielstadt/massnahmen/") + assertNotContains(response, "/mitallem/massnahmen/") + + +def test_city_editor_should_only_see_his_city_in_filter_list( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/?city__id__exact=1") + + filter_choices = list( + map(lambda choice: choice["display"], response.context["choices"]) + ) + assert "12345 Beispielstadt" in filter_choices + assert not "99999 Mitallem" in filter_choices + + +def test_city_admin_should_only_see_his_city_in_filter_list( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/?city__id__exact=1") + + filter_choices = list( + map(lambda choice: choice["display"], response.context["choices"]) + ) + assert "12345 Beispielstadt" in filter_choices + assert not "99999 Mitallem" in filter_choices + + +def test_site_admin_should_see_all_cities_in_filter_list( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/?city__id__exact=1") + + filter_choices = list( + map(lambda choice: choice["display"], response.context["choices"]) + ) + assert "12345 Beispielstadt" in filter_choices + assert "99999 Mitallem" in filter_choices + + +# Task add form + + +def test_city_editor_should_not_be_allowed_to_add_task_without_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/add/") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_admin_should_not_be_allowed_to_add_task_without_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/add/") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_site_admin_should_not_be_allowed_to_add_task_without_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/add/") + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_editor_should_not_be_allowed_to_add_task_for_nonexistent_city( + city_editor_client: Client, +): + response = city_editor_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D9" + ) + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_admin_should_not_be_allowed_to_add_task_for_nonexistent_city( + city_admin_client: Client, +): + response = city_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D9" + ) + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_site_admin_should_not_be_allowed_to_add_task_for_nonexistent_city( + site_admin_client: Client, +): + response = site_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D9" + ) + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_editor_should_not_be_allowed_to_add_task_for_other_city( + city_editor_client: Client, +): + response = city_editor_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D3" + ) + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_city_admin_should_not_be_allowed_to_add_task_for_other_city( + city_admin_client: Client, +): + response = city_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D3" + ) + + assert response.status_code == 302 + assert response.url == "/admin/cpmonitor/city/" + + +def test_site_admin_should_be_allowed_to_add_task_for_other_city( + site_admin_client: Client, +): + response = site_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D3" + ) + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_save_and_add_another"] + + assertContains(response, "99999 Mitallem") + + +def city_choices(adminform): + for fieldset in adminform: + for fieldline in fieldset: + for field in fieldline: + field_form = field.field.field + if field_form.label == "City": + choices = list(field_form.choices) + for choice in choices: + yield choice[1] + + +def test_city_editor_should_be_allowed_to_add_task_only_for_his_city( + city_editor_client: Client, +): + response = city_editor_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D1" + ) + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + cities = list(city_choices(adminform)) + assert "12345 Beispielstadt" in cities + assert not "99999 Mitallem" in cities + + +def test_city_admin_should_be_allowed_to_add_task_only_for_his_city( + city_admin_client: Client, +): + response = city_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D1" + ) + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + cities = list(city_choices(adminform)) + assert "12345 Beispielstadt" in cities + assert not "99999 Mitallem" in cities + + +def test_site_admin_should_be_allowed_to_add_task_for_all_cities( + site_admin_client: Client, +): + response = site_admin_client.get( + "/admin/cpmonitor/task/add/?_changelist_filters=city__id__exact%3D1" + ) + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + cities = list(city_choices(adminform)) + assert "12345 Beispielstadt" in cities + assert "99999 Mitallem" in cities + + +# Task change form + + +def test_city_editor_should_be_allowed_to_change_task_of_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_delete_link"] + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + fields = _fields_from_form(adminform) + assert "city" in fields + assert "teaser" in fields + assert "city" in adminform.readonly_fields + assert not "teaser" in adminform.readonly_fields + + assertContains(response, "12345 Beispielstadt") + + +def test_city_admin_should_be_allowed_to_change_task_of_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_delete_link"] + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + fields = _fields_from_form(adminform) + assert "city" in fields + assert "teaser" in fields + assert "city" in adminform.readonly_fields + assert not "teaser" in adminform.readonly_fields + + assertContains(response, "12345 Beispielstadt") + + +def test_site_admin_should_be_allowed_to_change_task_of_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert response.context["show_delete_link"] + assert response.context["show_save_and_add_another"] + + adminform = response.context["adminform"] + fields = _fields_from_form(adminform) + assert "city" in fields + assert "teaser" in fields + assert "city" in adminform.readonly_fields + assert not "teaser" in adminform.readonly_fields + + assertContains(response, "12345 Beispielstadt") + + +def test_city_editor_should_not_be_allowed_to_change_task_of_other_city( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/task/27/change/") + + assert response.status_code == 403 + + +def test_city_admin_should_not_be_allowed_to_change_task_of_other_city( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/task/27/change/") + + assert response.status_code == 403 + + +def test_site_admin_should_not_be_allowed_to_change_task_of_other_city( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/task/27/change/") + + assert response.status_code == 200 From 2ec36538ac73381a71ce18f3235851b48ea3ca28 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Mon, 14 Aug 2023 14:35:26 +0200 Subject: [PATCH 11/31] [#23] Invitation link with django-invitations. --- config/settings/base.py | 8 ++ cpmonitor/admin.py | 43 +++++++-- cpmonitor/migrations/0025_invitation.py | 81 +++++++++++++++++ cpmonitor/models.py | 110 ++++++++++++++++++++++++ cpmonitor/urls.py | 1 + cpmonitor/utils.py | 50 +++++++++++ poetry.lock | 17 +++- pyproject.toml | 1 + 8 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 cpmonitor/migrations/0025_invitation.py create mode 100644 cpmonitor/utils.py diff --git a/config/settings/base.py b/config/settings/base.py index f346e89e..b2135bb8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -48,6 +48,7 @@ def get_env(var: str) -> str: "treebeard", "martor", "rules.apps.AutodiscoverRulesConfig", + "invitations", "cpmonitor.apps.CpmonitorConfig", ] @@ -163,3 +164,10 @@ def get_env(var: str) -> str: MARTOR_UPLOAD_PATH = "uploads/" MARTOR_UPLOAD_URL = "/api/uploader/" MAX_IMAGE_UPLOAD_SIZE = 104857600 # 100 MB + +LOGIN_URL = "/admin/login/" # Used by django-invitations for redirect + +# django-invitations configuration: +INVITATIONS_INVITATION_MODEL = "cpmonitor.Invitation" +INVITATIONS_GONE_ON_ACCEPT_ERROR = False +# INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 98c566a7..4da32b76 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -7,13 +7,22 @@ from django.http.request import HttpRequest from django.urls import reverse from django.utils.html import format_html +import invitations from martor.widgets import AdminMartorWidget from treebeard.admin import TreeAdmin from treebeard.forms import movenodeform_factory, MoveNodeForm from rules.contrib.admin import ObjectPermissionsModelAdminMixin -from . import rules -from .models import Chart, City, Task, CapChecklist, AdministrationChecklist, LocalGroup +from . import rules, utils +from .models import ( + Chart, + City, + Task, + CapChecklist, + AdministrationChecklist, + LocalGroup, + Invitation, +) _city_filter_query = "city__id__exact" """The query parameter used by the city filter.""" @@ -70,7 +79,22 @@ class LocalGroupInline(ObjectPermissionsModelAdminMixin, admin.StackedInline): } +class InvitationInline( + ObjectPermissionsModelAdminMixin, utils.ModelAdminRequestMixin, admin.StackedInline +): + model = Invitation + extra = 0 + fields = ("invitation_link",) + readonly_fields = ("invitation_link",) + + @admin.display(description="Einladungslink") + def invitation_link(self, invitation: Invitation): + url = invitation.get_invite_url(self.get_request()) + return format_html('{}', url, url) + + class CityAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): + # ------ change list page ------ list_display = ("zipcode", "name", "teaser", "edit_tasks") list_display_links = ("name",) ordering = ("name",) @@ -83,6 +107,13 @@ def get_queryset(self, request): return qs return qs.filter(rules.is_allowed_to_edit_q(user, City)) + @admin.display(description="") + def edit_tasks(self, city: City): + """For each city, a link to the list (tree, actually) of Tasks for just that city.""" + list_url = _admin_url(Task, "changelist", city.id) + return format_html('KAP bearbeiten', list_url) + + # ------ change / add page ------ save_on_top = True filter_horizontal = ["city_editors", "city_admins"] @@ -101,17 +132,12 @@ def get_readonly_fields(self, request: HttpRequest, obj=None) -> Sequence[str]: models.TextField: {"widget": AdminMartorWidget}, } - @admin.display(description="") - def edit_tasks(self, city: City): - """For each city, a link to the list (tree, actually) of Tasks for just that city.""" - list_url = _admin_url(Task, "changelist", city.id) - return format_html('KAP bearbeiten', list_url) - inlines = [ ChartInline, LocalGroupInline, CapChecklistInline, AdministrationChecklistInline, + InvitationInline, ] @@ -301,3 +327,4 @@ def add_view(self, request, form_url="", extra_context=None): admin.site.register(City, CityAdmin) admin.site.register(Task, TaskAdmin) +admin.site.unregister(Invitation) diff --git a/cpmonitor/migrations/0025_invitation.py b/cpmonitor/migrations/0025_invitation.py new file mode 100644 index 00000000..7c6a42a3 --- /dev/null +++ b/cpmonitor/migrations/0025_invitation.py @@ -0,0 +1,81 @@ +# Generated by Django 4.1.7 on 2023-08-14 11:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("cpmonitor", "0024_city_city_admins_city_city_editors"), + ] + + operations = [ + migrations.CreateModel( + name="Invitation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "accepted", + models.BooleanField(default=False, verbose_name="accepted"), + ), + ( + "key", + models.CharField(max_length=64, unique=True, verbose_name="key"), + ), + ("sent", models.DateTimeField(null=True, verbose_name="sent")), + ( + "access_right", + models.CharField( + choices=[ + ("city admin", "Kommunen Administrator"), + ("city editor", "Kommunen Bearbeiter"), + ], + default="city editor", + max_length=20, + verbose_name="Zugriffsrecht", + ), + ), + ( + "created", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Erstellungszeitpunkt", + ), + ), + ( + "city", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="cpmonitor.city", + verbose_name="Kommune", + ), + ), + ( + "inviter", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Einladungslink", + "verbose_name_plural": "Einladungslinks", + }, + ), + ] diff --git a/cpmonitor/models.py b/cpmonitor/models.py index c819d326..890daabf 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -5,6 +5,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.text import slugify +import invitations from treebeard.exceptions import InvalidPosition from treebeard.mp_tree import MP_Node @@ -186,6 +187,11 @@ def validate_unique(self, exclude=None): msgs[NON_FIELD_ERRORS].extend(slug_errors) raise ValidationError(msgs) + def save(self, *args, **kwargs): + """""" + super().save(*args, **kwargs) + Invitation.ensure_for_city(self) + class CapChecklist(models.Model): class Meta: @@ -709,6 +715,110 @@ class Meta: ) +import datetime +from invitations.base_invitation import AbstractBaseInvitation +from invitations.app_settings import app_settings as invitations_app_settings +from django.utils import timezone +from django.utils.crypto import get_random_string +from django.urls import reverse +from invitations import signals + + +class AccessRight(models.TextChoices): + CITY_ADMIN = "city admin", "Kommunen Administrator" + CITY_EDITOR = "city editor", "Kommunen Bearbeiter" + + +class Invitation(AbstractBaseInvitation): + class Meta: + verbose_name = "Einladungslink" + verbose_name_plural = "Einladungslinks" + + city = models.ForeignKey( + City, + verbose_name="Kommune", + on_delete=models.CASCADE, + related_name="invitations", + ) + access_right = models.CharField( + "Zugriffsrecht", + max_length=20, + choices=AccessRight.choices, + default=AccessRight.CITY_EDITOR, + ) + + created = models.DateTimeField( + verbose_name="Erstellungszeitpunkt", default=timezone.now + ) + + # Workaround for https://github.com/jazzband/django-invitations/issues/203: Add custom related_name + inviter = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="invitations", + ) + + @property + def email(self): + return f"{self.get_access_right_display()} von {self.city.name}" + + @classmethod + def create_for_keys(cls, city, access_right): + key = get_random_string(64).lower() + return cls._default_manager.create( + key=key, inviter=None, city=city, access_right=access_right + ) + + @classmethod + def ensure_for_keys(cls, city, access_right): + if not cls._default_manager.filter(city=city, access_right=access_right): + cls.create_for_keys(city, access_right) + + @classmethod + def ensure_for_city(cls, city): + cls.ensure_for_keys(city, AccessRight.CITY_EDITOR) + cls.ensure_for_keys(city, AccessRight.CITY_ADMIN) + + @classmethod + def create(cls, email, inviter=None, **kwargs): + "Implementation of required method. Not used." + key = get_random_string(64).lower() + return cls._default_manager.create( + email=email, key=key, inviter=inviter, **kwargs + ) + + def get_invite_url(self, request): + if not self.key: + return None + url = reverse(invitations_app_settings.CONFIRMATION_URL_NAME, args=[self.key]) + return request.build_absolute_uri(url) + + def key_expired(self): + "Implementation of required method. Never expired." + return False + # expiration_date = self.sent + datetime.timedelta( + # days=invitations_app_settings.INVITATION_EXPIRY, + # ) + # return expiration_date <= timezone.now() + + def send_invitation(self, request, **kwargs): + "Implementation of required method. Pretending to send an email." + self.sent = timezone.now() + self.save() + + signals.invite_url_sent.send( + sender=self.__class__, + instance=self, + invite_url_sent=self.get_invite_url(request), + inviter=self.inviter, + ) + + def __str__(self): + return f"Einladung für {self.get_access_right_display()} von {self.city.name}" + + # Tables for comparing and connecting the plans of all cities # Lookup-entities (shared among all cities, entered by admins) diff --git a/cpmonitor/urls.py b/cpmonitor/urls.py index d8e4e991..1d35dd65 100644 --- a/cpmonitor/urls.py +++ b/cpmonitor/urls.py @@ -20,6 +20,7 @@ ), path("admin/", admin.site.urls), path("martor/", include("martor.urls")), + path("invitations/", include("invitations.urls", namespace="invitations")), path("api/uploader/", views.markdown_uploader_view, name="markdown_uploader"), path("", views.index_view, name="index"), path(prefix_kommune + "/", views.city_view, name="city"), diff --git a/cpmonitor/utils.py b/cpmonitor/utils.py new file mode 100644 index 00000000..77203f9f --- /dev/null +++ b/cpmonitor/utils.py @@ -0,0 +1,50 @@ +import threading + + +# From https://stackoverflow.com/a/50380461/6159921 +class ModelAdminRequestMixin(object): + def __init__(self, *args, **kwargs): + # let's define this so there's no chance of AttributeErrors + self._request_local = threading.local() + self._request_local.request = None + super(ModelAdminRequestMixin, self).__init__(*args, **kwargs) + + def get_request(self): + return self._request_local.request + + def set_request(self, request): + self._request_local.request = request + + def changeform_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).changeform_view( + request, *args, **kwargs + ) + + def add_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).add_view(request, *args, **kwargs) + + def change_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).change_view(request, *args, **kwargs) + + def changelist_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).changelist_view( + request, *args, **kwargs + ) + + def delete_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).delete_view(request, *args, **kwargs) + + def history_view(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).history_view( + request, *args, **kwargs + ) + + def get_formset(self, request, *args, **kwargs): + self.set_request(request) + return super(ModelAdminRequestMixin, self).get_formset(request, *args, **kwargs) diff --git a/poetry.lock b/poetry.lock index 883932af..9a6edf68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -300,6 +300,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-invitations" +version = "2.0.0" +description = "Generic invitations app with support for django-allauth" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "django-invitations-2.0.0.tar.gz", hash = "sha256:49df44fc226234fbf08ad59a937864bc28fa044e0d508b3995a13505faa03a78"}, + {file = "django_invitations-2.0.0-py3-none-any.whl", hash = "sha256:7dae5ae2b2add311be5f2420d21b5853d4a63fb09f2b6414ee1b6bcb3e4fbf77"}, +] + +[package.dependencies] +django = ">=3.2" + [[package]] name = "django-test-migrations" version = "1.2.0" @@ -1314,4 +1329,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a9a55fdcf69558f05af6c1100583d1a89e38605d1348fd789be4be2f8d71f0c9" +content-hash = "d2cdee287baad2a183142adbfc90070662cb62221afe4743820e455ded4fbe0d" diff --git a/pyproject.toml b/pyproject.toml index 09c7204d..f0cb3214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ django-treebeard = "^4.6.1" martor = "^1.6.19" pillow = "^9.5.0" rules = "^3.3" +django-invitations = "^2.0.0" [tool.pyright] venvPath = "." From d3e09df2dddd8ee8c462edc670e4ddfd031d2d9d Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Sun, 20 Aug 2023 12:21:08 +0200 Subject: [PATCH 12/31] [#23] Register with django-allauth. --- README.md | 3 + config/settings/base.py | 25 +- cpmonitor/adapters.py | 50 ++ cpmonitor/admin.py | 3 +- cpmonitor/fixtures/permissions.json | 437 ++++++++++---- cpmonitor/forms.py | 16 + cpmonitor/migrations/0025_invitation.py | 23 + cpmonitor/rules.py | 13 +- .../templates/overrides/account/signup.html | 63 ++ cpmonitor/tests/permissions_test.py | 11 +- cpmonitor/urls.py | 10 +- cpmonitor/utils.py | 18 + cpmonitor/views.py | 95 +++ e2e_tests/database/test_database.json | 546 ++++++++++++++++++ poetry.lock | 243 +++++++- pyproject.toml | 1 + 16 files changed, 1432 insertions(+), 125 deletions(-) create mode 100644 cpmonitor/adapters.py create mode 100644 cpmonitor/forms.py create mode 100644 cpmonitor/templates/overrides/account/signup.html diff --git a/README.md b/README.md index a6d48568..7b8dc39b 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,9 @@ python -Xutf8 manage.py dumpdata cpmonitor -e contenttypes -e admin.logentry -e (The `-Xutf8` and `--indent 2` options ensure consistent and readable output on all platforms.) +Specifying `cpmonitor` restricts to data within the cpmonitor app. Depending on the test, data +form other apps might be needed. + This fixture may be loaded in a test with. (Similar in a pytest fixture.) ```python diff --git a/config/settings/base.py b/config/settings/base.py index b2135bb8..76b7d00f 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -45,9 +45,13 @@ def get_env(var: str) -> str: "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # "django.contrib.sites", "treebeard", "martor", "rules.apps.AutodiscoverRulesConfig", + "allauth", + "allauth.account", + "allauth.socialaccount", "invitations", "cpmonitor.apps.CpmonitorConfig", ] @@ -65,6 +69,7 @@ def get_env(var: str) -> str: AUTHENTICATION_BACKENDS = ( "rules.permissions.ObjectPermissionBackend", "django.contrib.auth.backends.ModelBackend", + # "allauth.account.auth_backends.AuthenticationBackend", ) ROOT_URLCONF = "cpmonitor.urls" @@ -72,7 +77,7 @@ def get_env(var: str) -> str: TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "cpmonitor" / "templates" / "overrides"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -146,6 +151,11 @@ def get_env(var: str) -> str: DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# The "sites" framework configuration +# https://docs.djangoproject.com/en/4.2/ref/contrib/sites/ +# Required by django-allauth and django-invitations +# SITE_ID = 1 + # Martor (markdown editor) MARTOR_ENABLE_CONFIGS = { "emoji": "true", @@ -165,9 +175,20 @@ def get_env(var: str) -> str: MARTOR_UPLOAD_URL = "/api/uploader/" MAX_IMAGE_UPLOAD_SIZE = 104857600 # 100 MB +# django-allauth configuration: +# https://django-allauth.readthedocs.io/en/latest/installation.html +ACCOUNT_ADAPTER = "cpmonitor.adapters.AllauthInvitationsAdapter" +SOCIALACCOUNT_PROVIDERS = {} +ACCOUNT_EMAIL_VERIFICATION = "none" # This would need a working email config. + LOGIN_URL = "/admin/login/" # Used by django-invitations for redirect +LOGIN_REDIRECT_URL = "/admin/" # django-invitations configuration: +# https://django-invitations.readthedocs.io/en/latest/installation.html +INVITATIONS_ADAPTER = "cpmonitor.adapters.AllauthInvitationsAdapter" INVITATIONS_INVITATION_MODEL = "cpmonitor.Invitation" INVITATIONS_GONE_ON_ACCEPT_ERROR = False -# INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True +INVITATIONS_INVITATION_ONLY = True +INVITATIONS_CONFIRMATION_URL_NAME = "accept-invite" +INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True diff --git a/cpmonitor/adapters.py b/cpmonitor/adapters.py new file mode 100644 index 00000000..5df26bda --- /dev/null +++ b/cpmonitor/adapters.py @@ -0,0 +1,50 @@ +from allauth.account.adapter import DefaultAccountAdapter +from allauth.account.signals import user_signed_up +from django.contrib import messages +from invitations.app_settings import app_settings + +from .models import AccessRight, City, Invitation +from .utils import get_invitation + + +class AllauthInvitationsAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + if get_invitation(request): + return True + elif app_settings.INVITATION_ONLY is True: + # Site is ONLY open for invites + return False + else: + # Site is open to signup + return True + + def get_user_signed_up_signal(self): + return user_signed_up + + def save_user(self, request, user, form, commit=True): + "Check there is an invitation and set the appropriate access rights. Swallow the user object, if not." + invitation = get_invitation(request) + print("Got invitation: " + str(invitation)) + if not invitation: + self.add_error( + None, + "Die Registrierung ist nur möglich über einen gültigen Einladungslink.1", + ) + messages.error( + request, + "Die Registrierung ist nur möglich über einen gültigen Einladungslink.2", + ) + return + + user.is_staff = True + user = super().save_user(request, user, form, commit) + + city: City = invitation.city + if invitation.access_right == AccessRight.CITY_EDITOR: + city.city_editors.add(user) + city.save() + elif invitation.access_right == AccessRight.CITY_ADMIN: + city.city_admins.add(user) + city.save() + + return user diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 4da32b76..c6d3d807 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -121,9 +121,8 @@ def edit_tasks(self, city: City): def get_readonly_fields(self, request: HttpRequest, obj=None) -> Sequence[str]: user = request.user result = [] - if not rules.is_allowed_to_change_site_editors(user, obj): + if not rules.is_allowed_to_change_city_users(user, obj): result.append("city_editors") - if not rules.is_allowed_to_change_site_admins(user, obj): result.append("city_admins") return result diff --git a/cpmonitor/fixtures/permissions.json b/cpmonitor/fixtures/permissions.json index ea9c7770..13f24f45 100644 --- a/cpmonitor/fixtures/permissions.json +++ b/cpmonitor/fixtures/permissions.json @@ -1043,14 +1043,255 @@ } }, { - "model": "auth.group", - "pk": 1, + "model": "auth.permission", + "pk": 49, "fields": { - "name": "Alle", - "permissions": [ - 26, - 30 - ] + "name": "Can add email address", + "content_type": 13, + "codename": "add_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 50, + "fields": { + "name": "Can change email address", + "content_type": 13, + "codename": "change_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 51, + "fields": { + "name": "Can delete email address", + "content_type": 13, + "codename": "delete_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 52, + "fields": { + "name": "Can view email address", + "content_type": 13, + "codename": "view_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 53, + "fields": { + "name": "Can add email confirmation", + "content_type": 14, + "codename": "add_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 54, + "fields": { + "name": "Can change email confirmation", + "content_type": 14, + "codename": "change_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 55, + "fields": { + "name": "Can delete email confirmation", + "content_type": 14, + "codename": "delete_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 56, + "fields": { + "name": "Can view email confirmation", + "content_type": 14, + "codename": "view_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 57, + "fields": { + "name": "Can add social account", + "content_type": 15, + "codename": "add_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 58, + "fields": { + "name": "Can change social account", + "content_type": 15, + "codename": "change_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 59, + "fields": { + "name": "Can delete social account", + "content_type": 15, + "codename": "delete_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 60, + "fields": { + "name": "Can view social account", + "content_type": 15, + "codename": "view_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 61, + "fields": { + "name": "Can add social application", + "content_type": 16, + "codename": "add_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 62, + "fields": { + "name": "Can change social application", + "content_type": 16, + "codename": "change_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 63, + "fields": { + "name": "Can delete social application", + "content_type": 16, + "codename": "delete_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 64, + "fields": { + "name": "Can view social application", + "content_type": 16, + "codename": "view_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 65, + "fields": { + "name": "Can add social application token", + "content_type": 17, + "codename": "add_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 66, + "fields": { + "name": "Can change social application token", + "content_type": 17, + "codename": "change_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 67, + "fields": { + "name": "Can delete social application token", + "content_type": 17, + "codename": "delete_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 68, + "fields": { + "name": "Can view social application token", + "content_type": 17, + "codename": "view_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 69, + "fields": { + "name": "Can add invitation", + "content_type": 18, + "codename": "add_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 70, + "fields": { + "name": "Can change invitation", + "content_type": 18, + "codename": "change_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 71, + "fields": { + "name": "Can delete invitation", + "content_type": 18, + "codename": "delete_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 72, + "fields": { + "name": "Can view invitation", + "content_type": 18, + "codename": "view_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 73, + "fields": { + "name": "Can add Einladungslink", + "content_type": 19, + "codename": "add_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 74, + "fields": { + "name": "Can change Einladungslink", + "content_type": 19, + "codename": "change_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 75, + "fields": { + "name": "Can delete Einladungslink", + "content_type": 19, + "codename": "delete_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 76, + "fields": { + "name": "Can view Einladungslink", + "content_type": 19, + "codename": "view_invitation" } }, { @@ -1085,9 +1326,7 @@ "is_staff": true, "is_active": true, "date_joined": "2023-08-01T06:50:55Z", - "groups": [ - 1 - ], + "groups": [], "user_permissions": [] } }, @@ -1105,9 +1344,7 @@ "is_staff": true, "is_active": true, "date_joined": "2023-08-01T10:19:25Z", - "groups": [ - 1 - ], + "groups": [], "user_permissions": [] } }, @@ -1129,102 +1366,6 @@ "user_permissions": [] } }, -{ - "model": "contenttypes.contenttype", - "pk": 1, - "fields": { - "app_label": "admin", - "model": "logentry" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 2, - "fields": { - "app_label": "auth", - "model": "permission" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 3, - "fields": { - "app_label": "auth", - "model": "group" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 4, - "fields": { - "app_label": "auth", - "model": "user" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 5, - "fields": { - "app_label": "contenttypes", - "model": "contenttype" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 6, - "fields": { - "app_label": "sessions", - "model": "session" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 7, - "fields": { - "app_label": "cpmonitor", - "model": "city" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 8, - "fields": { - "app_label": "cpmonitor", - "model": "task" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 9, - "fields": { - "app_label": "cpmonitor", - "model": "chart" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 10, - "fields": { - "app_label": "cpmonitor", - "model": "capchecklist" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 11, - "fields": { - "app_label": "cpmonitor", - "model": "administrationchecklist" - } -}, -{ - "model": "contenttypes.contenttype", - "pk": 12, - "fields": { - "app_label": "cpmonitor", - "model": "localgroup" - } -}, { "model": "sessions.session", "pk": "3h80mfs0kjjxfectgn3a0x6c6zz3esme", @@ -2239,5 +2380,83 @@ "description": "Wenn Du mitmachen willst, wendest du Dich am besten an Maxi Mustermensch unter maxi@beispiel-los.de.\r\n\r\nUnser Monitoring basiert auf öffentlich zugänglichen Informationen aus dem lokalen BeispielBlatt, dere Website der Stadtverwaltung, dem Klimaaktionsplan, dem Ratsinformationssystem und dem Marktstammdatenregister. Und wir haben auch mit dem Bürgermeister gesprochen. Weitergehende Informationen zum Monitoring bekommst Du bei Dina Durchblick unter dina@beispiel-los.de.", "featured_image": "uploads/local_groups/Fotos-alle-1024x683_SEEJ1JR.jpeg" } +}, +{ + "model": "cpmonitor.invitation", + "pk": 1, + "fields": { + "accepted": false, + "key": "ercizfqjtsqbv5xap4uvlkpswjivqnjiephfxdbhjett8jah0z0ynnfpqrqxjcjg", + "sent": null, + "city": 1, + "access_right": "city admin", + "created": "2023-08-20T10:36:55.384Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 2, + "fields": { + "accepted": false, + "key": "lypvs6fb6qxk8ylskkckwp3g3djilpsiiunm1fuz68rdwg1emuwhnsuxexpbgjel", + "sent": null, + "city": 1, + "access_right": "city editor", + "created": "2023-08-20T10:36:55.386Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 3, + "fields": { + "accepted": false, + "key": "aaadfye92pbjn2iswjgejj2q4uifhcgk8goeqqj55uan8qcvrle58om8oheybmcs", + "sent": null, + "city": 2, + "access_right": "city admin", + "created": "2023-08-20T10:36:55.388Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 4, + "fields": { + "accepted": false, + "key": "i3qzwfy8pqfk9gpirmgygovm6rr6yijsfvckbmhizzxcjtd482i7zaqcqyt4u1qq", + "sent": null, + "city": 2, + "access_right": "city editor", + "created": "2023-08-20T10:36:55.390Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 5, + "fields": { + "accepted": false, + "key": "a7vv8zugeonysvg4vt5hk7404u5s50plid5jywaifqr1ob0tbhcuxyvsem6lf7xu", + "sent": null, + "city": 3, + "access_right": "city admin", + "created": "2023-08-20T10:36:55.392Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 6, + "fields": { + "accepted": false, + "key": "vybe7t3qwc5vwca6punkbnu3t9qlxh5ysqrz7izy7farrwzbw0dpk8adlu43kdbj", + "sent": null, + "city": 3, + "access_right": "city editor", + "created": "2023-08-20T10:36:55.393Z", + "inviter": null + } } ] diff --git a/cpmonitor/forms.py b/cpmonitor/forms.py new file mode 100644 index 00000000..8993fc12 --- /dev/null +++ b/cpmonitor/forms.py @@ -0,0 +1,16 @@ +from allauth.account.forms import SignupForm + +from .models import Invitation +from .utils import get_invitation + + +class InvitationBasedSignupForm(SignupForm): + def save(self, request): + invitation = get_invitation(request) + + user = super(InvitationBasedSignupForm, self).save(request) + + # Add your own processing here. + + # You must return the original result. + return user diff --git a/cpmonitor/migrations/0025_invitation.py b/cpmonitor/migrations/0025_invitation.py index 7c6a42a3..acee926c 100644 --- a/cpmonitor/migrations/0025_invitation.py +++ b/cpmonitor/migrations/0025_invitation.py @@ -4,6 +4,28 @@ from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.utils.crypto import get_random_string + + +class AccessRight(models.TextChoices): + CITY_ADMIN = "city admin", "Kommunen Administrator" + CITY_EDITOR = "city editor", "Kommunen Bearbeiter" + + +def ensure_invitation(inv_mgr, city, access_right): + if not inv_mgr.filter(city=city, access_right=access_right): + key = get_random_string(64).lower() + inv_mgr.create(key=key, inviter=None, city=city, access_right=access_right) + + +def add_invitations(apps, schema_editor): + City = apps.get_model("cpmonitor", "City") + Invitation = apps.get_model("cpmonitor", "Invitation") + inv_mgr = Invitation._default_manager + db_alias = schema_editor.connection.alias + for city in City.objects.using(db_alias).all(): + ensure_invitation(inv_mgr, city, AccessRight.CITY_ADMIN) + ensure_invitation(inv_mgr, city, AccessRight.CITY_EDITOR) class Migration(migrations.Migration): @@ -78,4 +100,5 @@ class Migration(migrations.Migration): "verbose_name_plural": "Einladungslinks", }, ), + migrations.RunPython(add_invitations, migrations.RunPython.noop), ] diff --git a/cpmonitor/rules.py b/cpmonitor/rules.py index e3218af2..906fb6de 100644 --- a/cpmonitor/rules.py +++ b/cpmonitor/rules.py @@ -67,17 +67,18 @@ def no_object(user: User, object: CityOrNoneType) -> bool: is_allowed_to_edit = is_city_editor | is_city_admin | is_site_admin -is_allowed_to_change_site_editors = is_city_admin | is_site_admin -is_allowed_to_change_site_admins = is_city_admin | is_site_admin +is_allowed_to_change_city_users = is_city_admin | is_site_admin # The actual permissions: +rules.add_perm("cpmonitor", rules.always_true) + # City: # Only add and change permissions are given to city editors and admins. # Site admins are superusers and can change everything, anyway. rules.add_perm("cpmonitor.view_city", is_allowed_to_edit) -rules.add_perm("cpmonitor.change_city", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_city", is_allowed_to_edit | no_object) # Inlines in city mask: # For some reason, "change" is requested with "None" once by inlines. @@ -103,8 +104,10 @@ def no_object(user: User, object: CityOrNoneType) -> bool: rules.add_perm("cpmonitor.delete_capchecklist", is_allowed_to_edit) rules.add_perm("cpmonitor.change_capchecklist", is_allowed_to_edit | no_object) -# TODO: This currently does not ensure that tasks are not added to other cities: rules.add_perm("cpmonitor.add_task", is_allowed_to_edit | no_object) rules.add_perm("cpmonitor.view_task", is_allowed_to_edit) rules.add_perm("cpmonitor.delete_task", is_allowed_to_edit) -rules.add_perm("cpmonitor.change_task", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_task", is_allowed_to_edit | no_object) + +rules.add_perm("cpmonitor.view_invitation", is_allowed_to_change_city_users | no_object) +rules.add_perm("cpmonitor.delete_invitation", is_allowed_to_change_city_users) diff --git a/cpmonitor/templates/overrides/account/signup.html b/cpmonitor/templates/overrides/account/signup.html new file mode 100644 index 00000000..da50e859 --- /dev/null +++ b/cpmonitor/templates/overrides/account/signup.html @@ -0,0 +1,63 @@ +{% extends "admin/base.html" %} +{% load i18n static %} +{% block title %} + Registrierung | LocalZero Monitoring +{% endblock title %} +{% block branding %} +

+ LocalZero Monitoring +

+{% endblock branding %} +{% block extrastyle %} + {{ block.super }} + + {{ form.media }} +{% endblock extrastyle %} +{% block bodyclass %} + {{ block.super }} login +{% endblock bodyclass %} +{% block usertools %} +{% endblock usertools %} +{% block nav-global %} +{% endblock nav-global %} +{% block nav-sidebar %} +{% endblock nav-sidebar %} +{% block content_title %} +{% endblock content_title %} +{% block breadcrumbs %} +{% endblock breadcrumbs %} +{% block content %} +

{% trans "Sign Up" %}

+ {% if form.errors and not form.non_field_errors %} +

+ {% if form.errors.items|length == 1 %} + {% translate "Please correct the error below." %} + {% else %} + {% translate "Please correct the errors below." %} + {% endif %} +

+ {% endif %} + {% if form.non_field_errors %} + {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} + {% endif %} +
+

+ {% blocktrans %}Already have an account? Then please sign in.{% endblocktrans %} +

+ +
+{% endblock content %} diff --git a/cpmonitor/tests/permissions_test.py b/cpmonitor/tests/permissions_test.py index 362fadea..601640c9 100644 --- a/cpmonitor/tests/permissions_test.py +++ b/cpmonitor/tests/permissions_test.py @@ -260,16 +260,18 @@ def test_city_admin_should_be_allowed_to_modify_inlines( verbose_names = map(lambda formset: formset.opts.verbose_name, formsets) # This will fail when new inlines are added. # If that happens, please extend the list below, accordingly. - assert len(formsets) == 4 + assert len(formsets) == 5 assert "Diagramm" in verbose_names assert "Lokalgruppe" in verbose_names assert "KAP Checkliste" in verbose_names assert "Verwaltungsstrukturen Checkliste" in verbose_names + assert "Einladungslink" in verbose_names for formset in formsets: assert formset.has_view_permission - assert formset.has_add_permission - assert formset.has_change_permission + if formset.opts.verbose_name != "Einladungslink": + assert formset.has_add_permission + assert formset.has_change_permission assert formset.has_delete_permission @@ -283,11 +285,12 @@ def test_site_admin_should_be_allowed_to_modify_inlines( verbose_names = map(lambda formset: formset.opts.verbose_name, formsets) # This will fail when new inlines are added. # If that happens, please extend the list below, accordingly. - assert len(formsets) == 4 + assert len(formsets) == 5 assert "Diagramm" in verbose_names assert "Lokalgruppe" in verbose_names assert "KAP Checkliste" in verbose_names assert "Verwaltungsstrukturen Checkliste" in verbose_names + assert "Einladungslink" in verbose_names for formset in formsets: assert formset.has_view_permission diff --git a/cpmonitor/urls.py b/cpmonitor/urls.py index 1d35dd65..c07970b8 100644 --- a/cpmonitor/urls.py +++ b/cpmonitor/urls.py @@ -1,8 +1,9 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import path, include +from django.urls import path, re_path, include from django.views.generic import RedirectView +from invitations import views as iviews from . import views @@ -20,7 +21,12 @@ ), path("admin/", admin.site.urls), path("martor/", include("martor.urls")), - path("invitations/", include("invitations.urls", namespace="invitations")), + path("accounts/", include("allauth.urls")), + re_path( + r"^invitations/accept-invite/(?P\w+)/?$", + views.AcceptInvite.as_view(), + name="accept-invite", + ), path("api/uploader/", views.markdown_uploader_view, name="markdown_uploader"), path("", views.index_view, name="index"), path(prefix_kommune + "/", views.city_view, name="city"), diff --git a/cpmonitor/utils.py b/cpmonitor/utils.py index 77203f9f..4940907f 100644 --- a/cpmonitor/utils.py +++ b/cpmonitor/utils.py @@ -48,3 +48,21 @@ def history_view(self, request, *args, **kwargs): def get_formset(self, request, *args, **kwargs): self.set_request(request) return super(ModelAdminRequestMixin, self).get_formset(request, *args, **kwargs) + + +from types import NoneType +from django.http import HttpRequest + +from .models import Invitation + + +def get_invitation(request: HttpRequest) -> Invitation | NoneType: + if not hasattr(request, "session"): + return None + key = request.session.get("invitation_key") + if not key: + return None + invitation_qs = Invitation.objects.filter(key=key.lower()) + if not invitation_qs: + return None + return invitation_qs.first() diff --git a/cpmonitor/views.py b/cpmonitor/views.py index acd0c517..b67b9c28 100644 --- a/cpmonitor/views.py +++ b/cpmonitor/views.py @@ -430,3 +430,98 @@ def markdown_uploader_view(request): data = json.dumps({"status": 200, "link": img_url, "name": image.name}) return HttpResponse(data, content_type="application/json") + + +from django.contrib import messages +from django.shortcuts import redirect +from invitations import views as invitations_views +from invitations.app_settings import app_settings as invitations_settings +from invitations.adapters import get_invitations_adapter +from invitations.signals import invite_accepted + +from .utils import get_invitation +from .models import Invitation + + +class AcceptInvite(invitations_views.AcceptInvite): + # def get(self, *args, **kwargs): + # if invitations_settings.CONFIRM_INVITE_ON_GET: + # return self.post(*args, **kwargs) + # else: + # raise Http404() + + def post(self, *args, **kwargs): + "Identical to base implementation, except where noted." + self.object = invitation = self.get_object() + + if invitations_settings.GONE_ON_ACCEPT_ERROR and ( + not invitation + or (invitation and (invitation.accepted or invitation.key_expired())) + ): + return HttpResponse(status=410) + + if not invitation: + get_invitations_adapter().add_message( + self.request, + messages.ERROR, + "invitations/messages/invite_invalid.txt", + ) + return redirect(invitations_settings.LOGIN_REDIRECT) + + if invitation.accepted: + get_invitations_adapter().add_message( + self.request, + messages.ERROR, + "invitations/messages/invite_already_accepted.txt", + {"email": invitation.email}, + ) + return redirect(invitations_settings.LOGIN_REDIRECT) + + if invitation.key_expired(): + get_invitations_adapter().add_message( + self.request, + messages.ERROR, + "invitations/messages/invite_expired.txt", + {"email": invitation.email}, + ) + return redirect(self.get_signup_redirect()) + + # Difference 1 to base: Not calling accept_invitation(). + + # Difference 2 to base: Saving key and not email. + self.request.session["invitation_key"] = invitation.key + + return redirect(self.get_signup_redirect()) + + +def accept_invitation(invitation, request, signal_sender): + # Difference: Not setting accepted to True, here. + + invite_accepted.send( + sender=signal_sender, + email=invitation.email, + request=request, + invitation=invitation, + ) + + get_invitations_adapter().add_message( + request, + messages.SUCCESS, + "invitations/messages/invite_accepted.txt", + {"email": invitation.email}, + ) + + +def accept_invite_after_signup(sender, request, user, **kwargs): + invitation = get_invitation(request) + if invitation: + accept_invitation( + invitation=invitation, + request=request, + signal_sender=Invitation, + ) + + +if invitations_settings.ACCEPT_INVITE_AFTER_SIGNUP: + signed_up_signal = get_invitations_adapter().get_user_signed_up_signal() + signed_up_signal.connect(accept_invite_after_signup) diff --git a/e2e_tests/database/test_database.json b/e2e_tests/database/test_database.json index 3f150132..36843c8e 100644 --- a/e2e_tests/database/test_database.json +++ b/e2e_tests/database/test_database.json @@ -431,6 +431,474 @@ "codename": "view_localgroup" } }, +{ + "model": "auth.permission", + "pk": 49, + "fields": { + "name": "Can add Kommune", + "content_type": 13, + "codename": "add_city" + } +}, +{ + "model": "auth.permission", + "pk": 50, + "fields": { + "name": "Can change Kommune", + "content_type": 13, + "codename": "change_city" + } +}, +{ + "model": "auth.permission", + "pk": 51, + "fields": { + "name": "Can delete Kommune", + "content_type": 13, + "codename": "delete_city" + } +}, +{ + "model": "auth.permission", + "pk": 52, + "fields": { + "name": "Can view Kommune", + "content_type": 13, + "codename": "view_city" + } +}, +{ + "model": "auth.permission", + "pk": 53, + "fields": { + "name": "Can add Sektor / Maßnahme", + "content_type": 14, + "codename": "add_task" + } +}, +{ + "model": "auth.permission", + "pk": 54, + "fields": { + "name": "Can change Sektor / Maßnahme", + "content_type": 14, + "codename": "change_task" + } +}, +{ + "model": "auth.permission", + "pk": 55, + "fields": { + "name": "Can delete Sektor / Maßnahme", + "content_type": 14, + "codename": "delete_task" + } +}, +{ + "model": "auth.permission", + "pk": 56, + "fields": { + "name": "Can view Sektor / Maßnahme", + "content_type": 14, + "codename": "view_task" + } +}, +{ + "model": "auth.permission", + "pk": 57, + "fields": { + "name": "Can add Diagramm", + "content_type": 15, + "codename": "add_chart" + } +}, +{ + "model": "auth.permission", + "pk": 58, + "fields": { + "name": "Can change Diagramm", + "content_type": 15, + "codename": "change_chart" + } +}, +{ + "model": "auth.permission", + "pk": 59, + "fields": { + "name": "Can delete Diagramm", + "content_type": 15, + "codename": "delete_chart" + } +}, +{ + "model": "auth.permission", + "pk": 60, + "fields": { + "name": "Can view Diagramm", + "content_type": 15, + "codename": "view_chart" + } +}, +{ + "model": "auth.permission", + "pk": 61, + "fields": { + "name": "Can add KAP Checkliste", + "content_type": 16, + "codename": "add_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 62, + "fields": { + "name": "Can change KAP Checkliste", + "content_type": 16, + "codename": "change_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 63, + "fields": { + "name": "Can delete KAP Checkliste", + "content_type": 16, + "codename": "delete_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 64, + "fields": { + "name": "Can view KAP Checkliste", + "content_type": 16, + "codename": "view_capchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 65, + "fields": { + "name": "Can add Verwaltungsstrukturen Checkliste", + "content_type": 17, + "codename": "add_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 66, + "fields": { + "name": "Can change Verwaltungsstrukturen Checkliste", + "content_type": 17, + "codename": "change_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 67, + "fields": { + "name": "Can delete Verwaltungsstrukturen Checkliste", + "content_type": 17, + "codename": "delete_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 68, + "fields": { + "name": "Can view Verwaltungsstrukturen Checkliste", + "content_type": 17, + "codename": "view_administrationchecklist" + } +}, +{ + "model": "auth.permission", + "pk": 69, + "fields": { + "name": "Can add Lokalgruppe", + "content_type": 18, + "codename": "add_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 70, + "fields": { + "name": "Can change Lokalgruppe", + "content_type": 18, + "codename": "change_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 71, + "fields": { + "name": "Can delete Lokalgruppe", + "content_type": 18, + "codename": "delete_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 72, + "fields": { + "name": "Can view Lokalgruppe", + "content_type": 18, + "codename": "view_localgroup" + } +}, +{ + "model": "auth.permission", + "pk": 73, + "fields": { + "name": "Can add Einladungslink", + "content_type": 19, + "codename": "add_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 74, + "fields": { + "name": "Can change Einladungslink", + "content_type": 19, + "codename": "change_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 75, + "fields": { + "name": "Can delete Einladungslink", + "content_type": 19, + "codename": "delete_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 76, + "fields": { + "name": "Can view Einladungslink", + "content_type": 19, + "codename": "view_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 77, + "fields": { + "name": "Can add email address", + "content_type": 7, + "codename": "add_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 78, + "fields": { + "name": "Can change email address", + "content_type": 7, + "codename": "change_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 79, + "fields": { + "name": "Can delete email address", + "content_type": 7, + "codename": "delete_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 80, + "fields": { + "name": "Can view email address", + "content_type": 7, + "codename": "view_emailaddress" + } +}, +{ + "model": "auth.permission", + "pk": 81, + "fields": { + "name": "Can add email confirmation", + "content_type": 8, + "codename": "add_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 82, + "fields": { + "name": "Can change email confirmation", + "content_type": 8, + "codename": "change_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 83, + "fields": { + "name": "Can delete email confirmation", + "content_type": 8, + "codename": "delete_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 84, + "fields": { + "name": "Can view email confirmation", + "content_type": 8, + "codename": "view_emailconfirmation" + } +}, +{ + "model": "auth.permission", + "pk": 85, + "fields": { + "name": "Can add social account", + "content_type": 9, + "codename": "add_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 86, + "fields": { + "name": "Can change social account", + "content_type": 9, + "codename": "change_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 87, + "fields": { + "name": "Can delete social account", + "content_type": 9, + "codename": "delete_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 88, + "fields": { + "name": "Can view social account", + "content_type": 9, + "codename": "view_socialaccount" + } +}, +{ + "model": "auth.permission", + "pk": 89, + "fields": { + "name": "Can add social application", + "content_type": 10, + "codename": "add_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 90, + "fields": { + "name": "Can change social application", + "content_type": 10, + "codename": "change_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 91, + "fields": { + "name": "Can delete social application", + "content_type": 10, + "codename": "delete_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 92, + "fields": { + "name": "Can view social application", + "content_type": 10, + "codename": "view_socialapp" + } +}, +{ + "model": "auth.permission", + "pk": 93, + "fields": { + "name": "Can add social application token", + "content_type": 11, + "codename": "add_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 94, + "fields": { + "name": "Can change social application token", + "content_type": 11, + "codename": "change_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 95, + "fields": { + "name": "Can delete social application token", + "content_type": 11, + "codename": "delete_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 96, + "fields": { + "name": "Can view social application token", + "content_type": 11, + "codename": "view_socialtoken" + } +}, +{ + "model": "auth.permission", + "pk": 97, + "fields": { + "name": "Can add invitation", + "content_type": 12, + "codename": "add_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 98, + "fields": { + "name": "Can change invitation", + "content_type": 12, + "codename": "change_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 99, + "fields": { + "name": "Can delete invitation", + "content_type": 12, + "codename": "delete_invitation" + } +}, +{ + "model": "auth.permission", + "pk": 100, + "fields": { + "name": "Can view invitation", + "content_type": 12, + "codename": "view_invitation" + } +}, { "model": "auth.group", "pk": 1, @@ -1445,5 +1913,83 @@ "description": "Wenn Du mitmachen willst, wendest du Dich am besten an Maxi Mustermensch unter maxi@beispiel-los.de.\r\n\r\nUnser Monitoring basiert auf öffentlich zugänglichen Informationen aus dem lokalen BeispielBlatt, dere Website der Stadtverwaltung, dem Klimaaktionsplan, dem Ratsinformationssystem und dem Marktstammdatenregister. Und wir haben auch mit dem Bürgermeister gesprochen. Weitergehende Informationen zum Monitoring bekommst Du bei Dina Durchblick unter dina@beispiel-los.de.", "featured_image": "uploads/local_groups/Fotos-alle-1024x683_SEEJ1JR.jpeg" } +}, +{ + "model": "cpmonitor.invitation", + "pk": 2, + "fields": { + "accepted": false, + "key": "bkneutb8zck96tjknok7hpwwybuif0a1pgny8txer0dqixbexryfntojnhzlddwk", + "sent": null, + "city": 1, + "access_right": "city editor", + "created": "2023-08-20T10:45:21.211Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 3, + "fields": { + "accepted": false, + "key": "lpm5qyqcvyblh2bbjve7jgxnh7ezsutlab1dcqi4ogvseqnqj0qflfvxcct9hsmz", + "sent": null, + "city": 2, + "access_right": "city admin", + "created": "2023-08-20T10:45:21.213Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 4, + "fields": { + "accepted": false, + "key": "wr6ikslxkvhhoaqqjj7jb7qjqpgonfpolsgmgdvhp6ny9nm7qfgpjycokhucg3ow", + "sent": null, + "city": 2, + "access_right": "city editor", + "created": "2023-08-20T10:45:21.216Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 5, + "fields": { + "accepted": false, + "key": "qfdubrzfl6ddobcqlr1rlehwb0ay8rngl9m6bshazjtdiob3chr1qnh7fs4zos4f", + "sent": null, + "city": 3, + "access_right": "city admin", + "created": "2023-08-20T10:45:21.219Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 6, + "fields": { + "accepted": false, + "key": "x1ymexf6lnq0tow1nh8bandsurfz87wiy0vk6o6vjaoxny9hgk3oif13oyfmvicn", + "sent": null, + "city": 3, + "access_right": "city editor", + "created": "2023-08-20T10:45:21.220Z", + "inviter": null + } +}, +{ + "model": "cpmonitor.invitation", + "pk": 7, + "fields": { + "accepted": false, + "key": "o1gg3wkdtmv5nlccvdjeewqn3yrp3cw5rxweifquqbovrwiwvmnx6kgo2i4p0t0d", + "sent": null, + "city": 1, + "access_right": "city admin", + "created": "2023-08-20T10:51:54.210Z", + "inviter": null + } } ] diff --git a/poetry.lock b/poetry.lock index 9a6edf68..9c61ccc3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -114,6 +114,83 @@ files = [ {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -251,6 +328,52 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "41.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:652627a055cb52a84f8c448185922241dd5217443ca194d5739b44612c5e6507"}, + {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8f09daa483aedea50d249ef98ed500569841d6498aa9c9f4b0531b9964658922"}, + {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fd871184321100fb400d759ad0cddddf284c4b696568204d281c902fc7b0d81"}, + {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84537453d57f55a50a5b6835622ee405816999a7113267739a1b4581f83535bd"}, + {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3fb248989b6363906827284cd20cca63bb1a757e0a2864d4c1682a985e3dca47"}, + {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:42cb413e01a5d36da9929baa9d70ca90d90b969269e5a12d39c1e0d475010116"}, + {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:aeb57c421b34af8f9fe830e1955bf493a86a7996cc1338fe41b30047d16e962c"}, + {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6af1c6387c531cd364b72c28daa29232162010d952ceb7e5ca8e2827526aceae"}, + {file = "cryptography-41.0.3-cp37-abi3-win32.whl", hash = "sha256:0d09fb5356f975974dbcb595ad2d178305e5050656affb7890a1583f5e02a306"}, + {file = "cryptography-41.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:a983e441a00a9d57a4d7c91b3116a37ae602907a7618b882c8013b5762e80574"}, + {file = "cryptography-41.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5259cb659aa43005eb55a0e4ff2c825ca111a0da1814202c64d28a985d33b087"}, + {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:67e120e9a577c64fe1f611e53b30b3e69744e5910ff3b6e97e935aeb96005858"}, + {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7efe8041897fe7a50863e51b77789b657a133c75c3b094e51b5e4b5cec7bf906"}, + {file = "cryptography-41.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e"}, + {file = "cryptography-41.0.3-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:57a51b89f954f216a81c9d057bf1a24e2f36e764a1ca9a501a6964eb4a6800dd"}, + {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c2f0d35703d61002a2bbdcf15548ebb701cfdd83cdc12471d2bae80878a4207"}, + {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:23c2d778cf829f7d0ae180600b17e9fceea3c2ef8b31a99e3c694cbbf3a24b84"}, + {file = "cryptography-41.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95dd7f261bb76948b52a5330ba5202b91a26fbac13ad0e9fc8a3ac04752058c7"}, + {file = "cryptography-41.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:41d7aa7cdfded09b3d73a47f429c298e80796c8e825ddfadc84c8a7f12df212d"}, + {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de"}, + {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ab8de0d091acbf778f74286f4989cf3d1528336af1b59f3e5d2ebca8b5fe49e1"}, + {file = "cryptography-41.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a74fbcdb2a0d46fe00504f571a2a540532f4c188e6ccf26f1f178480117b33c4"}, + {file = "cryptography-41.0.3.tar.gz", hash = "sha256:6d192741113ef5e30d89dcb5b956ef4e1578f304708701b8b73d38e3e1461f34"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "cssbeautifier" version = "1.14.7" @@ -267,6 +390,18 @@ editorconfig = ">=0.12.2" jsbeautifier = "*" six = ">=1.13.0" +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "distlib" version = "0.3.6" @@ -300,6 +435,24 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-allauth" +version = "0.54.0" +description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "django-allauth-0.54.0.tar.gz", hash = "sha256:120e265f802b65738899c6cb627b827fde46a4d03067034c633f516c2adf3e3e"}, +] + +[package.dependencies] +Django = ">=2.0" +pyjwt = {version = ">=1.7", extras = ["crypto"]} +python3-openid = ">=3.0.8" +requests = "*" +requests-oauthlib = ">=0.3.0" + [[package]] name = "django-invitations" version = "2.0.0" @@ -661,6 +814,23 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "packaging" version = "23.0" @@ -837,6 +1007,18 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pyee" version = "9.0.4" @@ -852,6 +1034,27 @@ files = [ [package.dependencies] typing-extensions = "*" +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyright" version = "1.1.295" @@ -984,6 +1187,25 @@ text-unidecode = ">=1.3" [package.extras] unidecode = ["Unidecode (>=1.1.1)"] +[[package]] +name = "python3-openid" +version = "3.2.0" +description = "OpenID support for modern servers and consumers." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, + {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, +] + +[package.dependencies] +defusedxml = "*" + +[package.extras] +mysql = ["mysql-connector-python"] +postgresql = ["psycopg2"] + [[package]] name = "pyyaml" version = "6.0" @@ -1154,6 +1376,25 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "rules" version = "3.3" @@ -1329,4 +1570,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d2cdee287baad2a183142adbfc90070662cb62221afe4743820e455ded4fbe0d" +content-hash = "f193666a39ca47f6ce7ba8a08c1de6327f74eb21c9580f2bda6bc77c812079c4" diff --git a/pyproject.toml b/pyproject.toml index f0cb3214..a1eaf8a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ martor = "^1.6.19" pillow = "^9.5.0" rules = "^3.3" django-invitations = "^2.0.0" +django-allauth = "^0.54.0" [tool.pyright] venvPath = "." From 4af6760fd5824f8a13090280aad4c589603d3fcd Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Mon, 21 Aug 2023 11:46:18 +0200 Subject: [PATCH 13/31] [#23] Remove unused django-invitations model from DB to fix problem with missing migration. --- README.md | 14 +- config/settings/base.py | 2 +- cpmonitor/admin.py | 1 - cpmonitor/fixtures/permissions.json | 1391 ------------------------- e2e_tests/database/test_database.json | 905 +--------------- 5 files changed, 10 insertions(+), 2303 deletions(-) diff --git a/README.md b/README.md index 7b8dc39b..0b07b778 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,15 @@ pytest --headed From a local database filled with suitable data, generate a fixture named `example_fixture` with ```shell -python -Xutf8 manage.py dumpdata cpmonitor -e contenttypes -e admin.logentry -e sessions --indent 2 --settings=config.settings.local > cpmonitor/fixtures/example_fixture.json +python -Xutf8 manage.py dumpdata -e contenttypes -e auth.Permission -e admin.LogEntry -e sessions --indent 2 --settings=config.settings.local > cpmonitor/fixtures/example_fixture.json ``` (The `-Xutf8` and `--indent 2` options ensure consistent and readable output on all platforms.) -Specifying `cpmonitor` restricts to data within the cpmonitor app. Depending on the test, data -form other apps might be needed. +The arguments `-e contenttypes -e auth.Permission` exclude tables which are pre-filled by django and whose content may +changes depending on the models in the project. If they are included, everything works fine at first, since loaddata +will silently accept data already there. However, as soon as the data to load clashes with existing content, it will fail. +`-e admin.LogEntry` contains references to content types and is therefore also excluded. This fixture may be loaded in a test with. (Similar in a pytest fixture.) @@ -244,7 +246,7 @@ Afterwards the test database has to be updated as well. Use the dumpdata command currently running database: ```shell -python -Xutf8 manage.py dumpdata -e contenttypes -e admin.logentry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/test_database.json +python -Xutf8 manage.py dumpdata -e contenttypes -e auth.Permission -e admin.LogEntry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/test_database.json ``` Cheat-sheet to make sure the correct data is dumped: @@ -257,7 +259,7 @@ python manage.py loaddata --settings=config.settings.local e2e_tests/database/te cp -r e2e_tests/database/test_database_uploads/. cpmonitor/images/uploads git checkout after-model-change-including-migration python manage.py migrate --settings=config.settings.local -python -Xutf8 manage.py dumpdata -e contenttypes -e admin.logentry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/test_database.json +python -Xutf8 manage.py dumpdata -e contenttypes -e auth.Permission -e admin.LogEntry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/test_database.json # Only if additional images were uploaded: cp -r cpmonitor/images/uploads e2e_tests/database/test_database_uploads ``` @@ -374,7 +376,7 @@ Possibly migrate, test the data, and check that the size is reasonable. Then mak ```sh SNAPSHOT_NAME=prod_database_$(date -u +"%FT%H%M%SZ") -python -Xutf8 manage.py dumpdata -e contenttypes -e admin.logentry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/${SNAPSHOT_NAME}.json +python -Xutf8 manage.py dumpdata -e contenttypes -e auth.Permission -e admin.LogEntry -e sessions --indent 2 --settings=config.settings.local > e2e_tests/database/${SNAPSHOT_NAME}.json cp -r cpmonitor/images/uploads e2e_tests/database/${SNAPSHOT_NAME}_uploads echo "Some useful information, e.g. the migration state of the snapshot" > e2e_tests/database/${SNAPSHOT_NAME}.README du -hs e2e_tests/database/${SNAPSHOT_NAME}* diff --git a/config/settings/base.py b/config/settings/base.py index 76b7d00f..bad9e49d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -52,7 +52,7 @@ def get_env(var: str) -> str: "allauth", "allauth.account", "allauth.socialaccount", - "invitations", + # "invitations", We do not use its model and therefore do not want its migrations. "cpmonitor.apps.CpmonitorConfig", ] diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index c6d3d807..aff4a13f 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -326,4 +326,3 @@ def add_view(self, request, form_url="", extra_context=None): admin.site.register(City, CityAdmin) admin.site.register(Task, TaskAdmin) -admin.site.unregister(Invitation) diff --git a/cpmonitor/fixtures/permissions.json b/cpmonitor/fixtures/permissions.json index 13f24f45..1a9845e6 100644 --- a/cpmonitor/fixtures/permissions.json +++ b/cpmonitor/fixtures/permissions.json @@ -1,1299 +1,4 @@ [ -{ - "model": "admin.logentry", - "pk": 1, - "fields": { - "action_time": "2023-02-26T18:26:19.109Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 2, - "fields": { - "action_time": "2023-02-26T18:26:29.897Z", - "user": 1, - "content_type": 8, - "object_id": "1", - "object_repr": "Verkehr", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 3, - "fields": { - "action_time": "2023-02-26T18:27:03.032Z", - "user": 1, - "content_type": 8, - "object_id": "2", - "object_repr": "Radwege ausbauen", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 4, - "fields": { - "action_time": "2023-02-26T18:27:36.579Z", - "user": 1, - "content_type": 8, - "object_id": "3", - "object_repr": "U-Bahn Strecke verlängern", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 5, - "fields": { - "action_time": "2023-03-05T17:03:22.852Z", - "user": 1, - "content_type": 8, - "object_id": "2", - "object_repr": "Radwege ausbauen", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"description\", \"Relative to\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 6, - "fields": { - "action_time": "2023-05-03T14:27:51.992Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: https://openverse.org/image/f6a4ca33-b1fc-488d-9f2a-dd6ef9f1426f\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 7, - "fields": { - "action_time": "2023-05-03T14:29:27.783Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Quelle\", \"Lizenz\", \"Bildunterschrift\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 8, - "fields": { - "action_time": "2023-05-03T14:50:43.496Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 9, - "fields": { - "action_time": "2023-05-03T14:56:40.061Z", - "user": 1, - "content_type": 7, - "object_id": "2", - "object_repr": "00000 Ohnenix", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 10, - "fields": { - "action_time": "2023-05-03T15:01:33.898Z", - "user": 1, - "content_type": 7, - "object_id": "3", - "object_repr": "99999 Mitallem", - "action_flag": 1, - "change_message": "[{\"added\": {}}, {\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\"}}, {\"added\": {\"name\": \" KPI Graph\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 11, - "fields": { - "action_time": "2023-05-03T15:01:51.380Z", - "user": 1, - "content_type": 8, - "object_id": "17", - "object_repr": "Energie", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 12, - "fields": { - "action_time": "2023-05-03T15:02:00.533Z", - "user": 1, - "content_type": 8, - "object_id": "18", - "object_repr": "Mobilität", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 13, - "fields": { - "action_time": "2023-05-03T15:02:09.646Z", - "user": 1, - "content_type": 8, - "object_id": "19", - "object_repr": "Gebäude", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 14, - "fields": { - "action_time": "2023-05-03T15:02:29.345Z", - "user": 1, - "content_type": 8, - "object_id": "20", - "object_repr": "Strukturen", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 15, - "fields": { - "action_time": "2023-05-03T15:02:42.740Z", - "user": 1, - "content_type": 8, - "object_id": "21", - "object_repr": "Landwirtschaft", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 16, - "fields": { - "action_time": "2023-05-03T15:03:00.256Z", - "user": 1, - "content_type": 8, - "object_id": "22", - "object_repr": "PF auf alle öffentlichen Dächer", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 17, - "fields": { - "action_time": "2023-05-03T15:03:15.759Z", - "user": 1, - "content_type": 8, - "object_id": "22", - "object_repr": "PV auf alle öffentlichen Dächer", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Titel\", \"Relative to\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 18, - "fields": { - "action_time": "2023-05-03T15:04:04.872Z", - "user": 1, - "content_type": 8, - "object_id": "23", - "object_repr": "Sanierungsprogramm: Dämmung", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 19, - "fields": { - "action_time": "2023-05-03T15:04:31.096Z", - "user": 1, - "content_type": 8, - "object_id": "22", - "object_repr": "PV auf alle öffentlichen Dächer", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Umsetzungsstand\", \"Relative to\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 20, - "fields": { - "action_time": "2023-05-03T15:05:33.401Z", - "user": 1, - "content_type": 8, - "object_id": "24", - "object_repr": "Ladesäulen flächendeckend", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 21, - "fields": { - "action_time": "2023-05-03T15:06:05.064Z", - "user": 1, - "content_type": 8, - "object_id": "25", - "object_repr": "Förderung Ökologischer Landbau", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 22, - "fields": { - "action_time": "2023-05-03T15:06:50.514Z", - "user": 1, - "content_type": 8, - "object_id": "26", - "object_repr": "Zukunftswerkstatt", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 23, - "fields": { - "action_time": "2023-05-03T15:07:13.155Z", - "user": 1, - "content_type": 8, - "object_id": "26", - "object_repr": "Zukunftswerkstatt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Umsetzungsstand\", \"Relative to\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 24, - "fields": { - "action_time": "2023-05-03T15:07:55.866Z", - "user": 1, - "content_type": 8, - "object_id": "27", - "object_repr": "Expertenrat", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 25, - "fields": { - "action_time": "2023-05-14T12:18:23.453Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \"cap checklist\", \"object\": \"CapChecklist object (1)\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 26, - "fields": { - "action_time": "2023-05-14T12:18:41.323Z", - "user": 1, - "content_type": 7, - "object_id": "3", - "object_repr": "99999 Mitallem", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \"cap checklist\", \"object\": \"CapChecklist object (2)\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 27, - "fields": { - "action_time": "2023-06-02T17:23:41.654Z", - "user": 1, - "content_type": 7, - "object_id": "3", - "object_repr": "99999 Mitallem", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \"Verwaltungsstrukturen Checkliste\", \"object\": \"AdministrationChecklist object (1)\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 28, - "fields": { - "action_time": "2023-06-02T17:24:04.672Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"added\": {\"name\": \"Verwaltungsstrukturen Checkliste\", \"object\": \"AdministrationChecklist object (2)\"}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 29, - "fields": { - "action_time": "2023-06-04T07:36:20.253Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Bewertung Verwaltung\", \"Bewertung Klimaaktionsplan\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 30, - "fields": { - "action_time": "2023-06-12T20:12:31.438Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Bewertung Klimaaktionsplan\", \"Bewertung Umsetzungsstand\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 31, - "fields": { - "action_time": "2023-06-16T14:07:01.361Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\", \"fields\": [\"Interne Informationen\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 32, - "fields": { - "action_time": "2023-06-16T14:11:13.945Z", - "user": 1, - "content_type": 7, - "object_id": "3", - "object_repr": "99999 Mitallem", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Weltbev\\u00f6lkerung und CO2-Emissionen - Quelle: Matt Lemmon\", \"fields\": [\"Interne Informationen\"]}}, {\"changed\": {\"name\": \"Diagramm\", \"object\": \"Graph: Ben\\u00f6tigte Leistung (in Anzahl Pferden) in Abh\\u00e4ngigkeit von der Steigung - Quelle: Seattle Municipal Archives\", \"fields\": [\"Interne Informationen\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 33, - "fields": { - "action_time": "2023-08-01T06:50:55.709Z", - "user": 1, - "content_type": 4, - "object_id": "2", - "object_repr": "heinz", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 34, - "fields": { - "action_time": "2023-08-01T06:52:10.143Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\", \"Kommunen Admins\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 35, - "fields": { - "action_time": "2023-08-01T10:18:06.021Z", - "user": 1, - "content_type": 4, - "object_id": "2", - "object_repr": "heinz", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Staff status\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 36, - "fields": { - "action_time": "2023-08-01T10:19:26.402Z", - "user": 1, - "content_type": 4, - "object_id": "3", - "object_repr": "sarah", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 37, - "fields": { - "action_time": "2023-08-01T10:19:34.192Z", - "user": 1, - "content_type": 4, - "object_id": "3", - "object_repr": "sarah", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Staff status\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 38, - "fields": { - "action_time": "2023-08-01T10:20:33.306Z", - "user": 1, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\", \"Kommunen Admins\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 39, - "fields": { - "action_time": "2023-08-02T09:14:37.654Z", - "user": 1, - "content_type": 3, - "object_id": "1", - "object_repr": "Alle", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 40, - "fields": { - "action_time": "2023-08-02T09:14:50.503Z", - "user": 1, - "content_type": 4, - "object_id": "3", - "object_repr": "sarah", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Groups\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 41, - "fields": { - "action_time": "2023-08-02T09:14:59.278Z", - "user": 1, - "content_type": 4, - "object_id": "2", - "object_repr": "heinz", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Groups\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 42, - "fields": { - "action_time": "2023-08-02T09:19:45.137Z", - "user": 1, - "content_type": 3, - "object_id": "1", - "object_repr": "Alle", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Permissions\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 43, - "fields": { - "action_time": "2023-08-02T09:22:18.076Z", - "user": 1, - "content_type": 3, - "object_id": "1", - "object_repr": "Alle", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Permissions\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 44, - "fields": { - "action_time": "2023-08-02T09:23:24.966Z", - "user": 3, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 45, - "fields": { - "action_time": "2023-08-03T08:35:00.681Z", - "user": 3, - "content_type": 7, - "object_id": "1", - "object_repr": "12345 Beispielstadt", - "action_flag": 2, - "change_message": "[{\"changed\": {\"fields\": [\"Kommunen Bearbeiter\"]}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 46, - "fields": { - "action_time": "2023-08-03T10:39:16.799Z", - "user": 1, - "content_type": 4, - "object_id": "4", - "object_repr": "christian", - "action_flag": 1, - "change_message": "[{\"added\": {}}]" - } -}, -{ - "model": "admin.logentry", - "pk": 47, - "fields": { - "action_time": "2023-08-03T10:39:55.053Z", - "user": 1, - "content_type": 4, - "object_id": "4", - "object_repr": "christian", - "action_flag": 2, - "change_message": "[]" - } -}, -{ - "model": "auth.permission", - "pk": 1, - "fields": { - "name": "Can add log entry", - "content_type": 1, - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 2, - "fields": { - "name": "Can change log entry", - "content_type": 1, - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 3, - "fields": { - "name": "Can delete log entry", - "content_type": 1, - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 4, - "fields": { - "name": "Can view log entry", - "content_type": 1, - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 5, - "fields": { - "name": "Can add permission", - "content_type": 2, - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "pk": 6, - "fields": { - "name": "Can change permission", - "content_type": 2, - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "pk": 7, - "fields": { - "name": "Can delete permission", - "content_type": 2, - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "pk": 8, - "fields": { - "name": "Can view permission", - "content_type": 2, - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "pk": 9, - "fields": { - "name": "Can add group", - "content_type": 3, - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "pk": 10, - "fields": { - "name": "Can change group", - "content_type": 3, - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "pk": 11, - "fields": { - "name": "Can delete group", - "content_type": 3, - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "pk": 12, - "fields": { - "name": "Can view group", - "content_type": 3, - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "pk": 13, - "fields": { - "name": "Can add user", - "content_type": 4, - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "pk": 14, - "fields": { - "name": "Can change user", - "content_type": 4, - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "pk": 15, - "fields": { - "name": "Can delete user", - "content_type": 4, - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "pk": 16, - "fields": { - "name": "Can view user", - "content_type": 4, - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "pk": 17, - "fields": { - "name": "Can add content type", - "content_type": 5, - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 18, - "fields": { - "name": "Can change content type", - "content_type": 5, - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 19, - "fields": { - "name": "Can delete content type", - "content_type": 5, - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 20, - "fields": { - "name": "Can view content type", - "content_type": 5, - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 21, - "fields": { - "name": "Can add session", - "content_type": 6, - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "pk": 22, - "fields": { - "name": "Can change session", - "content_type": 6, - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "pk": 23, - "fields": { - "name": "Can delete session", - "content_type": 6, - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "pk": 24, - "fields": { - "name": "Can view session", - "content_type": 6, - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "pk": 25, - "fields": { - "name": "Can add city", - "content_type": 7, - "codename": "add_city" - } -}, -{ - "model": "auth.permission", - "pk": 26, - "fields": { - "name": "Can change city", - "content_type": 7, - "codename": "change_city" - } -}, -{ - "model": "auth.permission", - "pk": 27, - "fields": { - "name": "Can delete city", - "content_type": 7, - "codename": "delete_city" - } -}, -{ - "model": "auth.permission", - "pk": 28, - "fields": { - "name": "Can view city", - "content_type": 7, - "codename": "view_city" - } -}, -{ - "model": "auth.permission", - "pk": 29, - "fields": { - "name": "Can add task", - "content_type": 8, - "codename": "add_task" - } -}, -{ - "model": "auth.permission", - "pk": 30, - "fields": { - "name": "Can change task", - "content_type": 8, - "codename": "change_task" - } -}, -{ - "model": "auth.permission", - "pk": 31, - "fields": { - "name": "Can delete task", - "content_type": 8, - "codename": "delete_task" - } -}, -{ - "model": "auth.permission", - "pk": 32, - "fields": { - "name": "Can view task", - "content_type": 8, - "codename": "view_task" - } -}, -{ - "model": "auth.permission", - "pk": 33, - "fields": { - "name": "Can add KPI Graph", - "content_type": 9, - "codename": "add_chart" - } -}, -{ - "model": "auth.permission", - "pk": 34, - "fields": { - "name": "Can change KPI Graph", - "content_type": 9, - "codename": "change_chart" - } -}, -{ - "model": "auth.permission", - "pk": 35, - "fields": { - "name": "Can delete KPI Graph", - "content_type": 9, - "codename": "delete_chart" - } -}, -{ - "model": "auth.permission", - "pk": 36, - "fields": { - "name": "Can view KPI Graph", - "content_type": 9, - "codename": "view_chart" - } -}, -{ - "model": "auth.permission", - "pk": 37, - "fields": { - "name": "Can add cap checklist", - "content_type": 10, - "codename": "add_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 38, - "fields": { - "name": "Can change cap checklist", - "content_type": 10, - "codename": "change_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 39, - "fields": { - "name": "Can delete cap checklist", - "content_type": 10, - "codename": "delete_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 40, - "fields": { - "name": "Can view cap checklist", - "content_type": 10, - "codename": "view_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 41, - "fields": { - "name": "Can add Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "add_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 42, - "fields": { - "name": "Can change Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "change_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 43, - "fields": { - "name": "Can delete Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "delete_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 44, - "fields": { - "name": "Can view Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "view_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 45, - "fields": { - "name": "Can add Lokalgruppe", - "content_type": 12, - "codename": "add_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 46, - "fields": { - "name": "Can change Lokalgruppe", - "content_type": 12, - "codename": "change_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 47, - "fields": { - "name": "Can delete Lokalgruppe", - "content_type": 12, - "codename": "delete_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 48, - "fields": { - "name": "Can view Lokalgruppe", - "content_type": 12, - "codename": "view_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 49, - "fields": { - "name": "Can add email address", - "content_type": 13, - "codename": "add_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 50, - "fields": { - "name": "Can change email address", - "content_type": 13, - "codename": "change_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 51, - "fields": { - "name": "Can delete email address", - "content_type": 13, - "codename": "delete_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 52, - "fields": { - "name": "Can view email address", - "content_type": 13, - "codename": "view_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 53, - "fields": { - "name": "Can add email confirmation", - "content_type": 14, - "codename": "add_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 54, - "fields": { - "name": "Can change email confirmation", - "content_type": 14, - "codename": "change_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 55, - "fields": { - "name": "Can delete email confirmation", - "content_type": 14, - "codename": "delete_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 56, - "fields": { - "name": "Can view email confirmation", - "content_type": 14, - "codename": "view_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 57, - "fields": { - "name": "Can add social account", - "content_type": 15, - "codename": "add_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 58, - "fields": { - "name": "Can change social account", - "content_type": 15, - "codename": "change_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 59, - "fields": { - "name": "Can delete social account", - "content_type": 15, - "codename": "delete_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 60, - "fields": { - "name": "Can view social account", - "content_type": 15, - "codename": "view_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 61, - "fields": { - "name": "Can add social application", - "content_type": 16, - "codename": "add_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 62, - "fields": { - "name": "Can change social application", - "content_type": 16, - "codename": "change_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 63, - "fields": { - "name": "Can delete social application", - "content_type": 16, - "codename": "delete_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 64, - "fields": { - "name": "Can view social application", - "content_type": 16, - "codename": "view_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 65, - "fields": { - "name": "Can add social application token", - "content_type": 17, - "codename": "add_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 66, - "fields": { - "name": "Can change social application token", - "content_type": 17, - "codename": "change_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 67, - "fields": { - "name": "Can delete social application token", - "content_type": 17, - "codename": "delete_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 68, - "fields": { - "name": "Can view social application token", - "content_type": 17, - "codename": "view_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 69, - "fields": { - "name": "Can add invitation", - "content_type": 18, - "codename": "add_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 70, - "fields": { - "name": "Can change invitation", - "content_type": 18, - "codename": "change_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 71, - "fields": { - "name": "Can delete invitation", - "content_type": 18, - "codename": "delete_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 72, - "fields": { - "name": "Can view invitation", - "content_type": 18, - "codename": "view_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 73, - "fields": { - "name": "Can add Einladungslink", - "content_type": 19, - "codename": "add_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 74, - "fields": { - "name": "Can change Einladungslink", - "content_type": 19, - "codename": "change_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 75, - "fields": { - "name": "Can delete Einladungslink", - "content_type": 19, - "codename": "delete_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 76, - "fields": { - "name": "Can view Einladungslink", - "content_type": 19, - "codename": "view_invitation" - } -}, { "model": "auth.user", "pk": 1, @@ -1366,102 +71,6 @@ "user_permissions": [] } }, -{ - "model": "sessions.session", - "pk": "3h80mfs0kjjxfectgn3a0x6c6zz3esme", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1pqsJm:hu2RvPJFpyb4Ol67nhIuVCNJNtMnKa2vY8HwkwCnWSk", - "expire_date": "2023-05-08T09:17:06.922Z" - } -}, -{ - "model": "sessions.session", - "pk": "9c6kfe113j1gjo81zp029vk6hcvmw0co", - "fields": { - "session_data": ".eJxVjEEOgjAQRe_StWkYasuMS_ecoRk6U4saSCisjHdXEha6_e-9_zKRt7XEreoSRzEXA-b0uw2cHjrtQO483Wab5mldxsHuij1otf0s-rwe7t9B4Vq-NYNnCtAGxyrUoIhLmIkzIkHIqGFISB7VN0ySOugAHPhW8lmcUjbvD-UtN_M:1pxOUA:X8ty0yL20MTQz29j3ydhkBZGCi3WcuAQ6lBFma4WP4o", - "expire_date": "2023-05-26T08:50:46.855Z" - } -}, -{ - "model": "sessions.session", - "pk": "dqcocxnefhypjhbl9ak7aw6vhsiqltaa", - "fields": { - "session_data": ".eJxVjMEOwiAQRP-FsyGFbqF49N5vIMsuSNVAUtqT8d9tkx50jvPezFt43NbstxYXP7O4CiUuv11AesZyAH5guVdJtazLHOShyJM2OVWOr9vp_h1kbHlfu9h3bCgZAyMnSmlgBKsGhQoBVEeWaNQ9sGXHzpBzGnSf9jgMwEZ8vvZWOHg:1q58Ul:HGNABZYGt5rapxcqgZ8bVDSAE4PAfvMmdFuJbVQNOQA", - "expire_date": "2023-06-16T17:23:23.347Z" - } -}, -{ - "model": "sessions.session", - "pk": "dtfi7hlq7jhyba82zvu8ap9xllap4scg", - "fields": { - "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qQmTx:Ew_qGu2uoSWNePBupqM7fp4UheMcO8w30IKxd17cUnM", - "expire_date": "2023-08-15T10:20:01.191Z" - } -}, -{ - "model": "sessions.session", - "pk": "fzptn3bl2yc60via2f5y7xsi8cjbw9je", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1qQjBV:hrn0LOs0ZWhi4CXkMe_Vt4Eb7Oxp8g_E8EaUrXkBQa4", - "expire_date": "2023-08-15T06:48:45.482Z" - } -}, -{ - "model": "sessions.session", - "pk": "ghzld6jkhbytx93agbto8lymgya7div0", - "fields": { - "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qRTtM:U-XGXteWUK_FiiVz32GoJpk0F3BKOw3aeQVHi5jGiAM", - "expire_date": "2023-08-17T08:41:08.527Z" - } -}, -{ - "model": "sessions.session", - "pk": "jopeqkcf9h72y3is4spa7501y6c437kd", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1qR7YM:TQ6WreWAQS6XFwHKMexaXcLkK6rhNNPyX46OBATGauo", - "expire_date": "2023-08-16T08:49:58.901Z" - } -}, -{ - "model": "sessions.session", - "pk": "la9ytgclqprfil35c6lp5f8k5ukpssbd", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2pBCGbEu3fcMZIYZpGogKe3KeHfbpAvd_vfef6uA65LD2mQOE6urMur0uxHGp5Qd8APLvepYyzJPpHdFH7TpsbK8bof7d5Cx5a2GeBbp2HpIYAb0xgk7K8k6tJSM5f6CQw_EBphc7Hwv7C0BCaSNO_X5AvDnOIM:1q8nsg:EXnRO_tnvPQVApEuszmwxUyXfjcDdfsdKTuCTWg7wdw", - "expire_date": "2023-06-26T20:11:14.442Z" - } -}, -{ - "model": "sessions.session", - "pk": "wf24d94ueyo0r1i0362ql9fufmz16uxt", - "fields": { - "session_data": ".eJxVjMsOwiAQRf-FtSFAebp07zeQYRikaiAp7cr479qkC93ec859sQjbWuM2aIlzZmcm2el3S4APajvId2i3zrG3dZkT3xV-0MGvPdPzcrh_BxVG_dbWEeVp0kVIo8AqyiUZICpBIHpQWRQyqK3RXkAK1iavEUKhIMlNLrD3BwXLOKM:1q5iEw:z6fTxHGg-Ly6WLbbTwSL6jeo_-ak3b1mvII5bs4h_dQ", - "expire_date": "2023-06-18T07:33:26.326Z" - } -}, -{ - "model": "sessions.session", - "pk": "x4n2t2yjge37quhm0wd40iv4pzby38pw", - "fields": { - "session_data": ".eJxVjMEOwiAQRP-FsyHQZSl49O43kF1AqRqalPZk_Hdp0oOeJpn3Zt4i0LaWsLW8hCmJswBx-u2Y4jPXHaQH1fss41zXZWK5K_KgTV7nlF-Xw_07KNRKX0dnnIpg0atBW7LWQI-oEgJg1smNo2fDZkDqmjEIrBUqf-NI4AjF5wuqpTad:1qR7Z6:O3X7EMM8GtMwc_DnNSIkhAZ0spLvOid40AnXRAs8McI", - "expire_date": "2023-08-16T08:50:44.825Z" - } -}, -{ - "model": "sessions.session", - "pk": "ys0nbg8n64hkh1qj8gzptt5udsanrk88", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2hBKgRlduu8ZyMCAVA1NSrsy3l1JutDdz38v7yU87Vvxe0urn1lchBan3y9QfKTaAd-p3hYZl7qtc5BdkQdtclo4Pa-H-xco1ErPMjgVAYOhnC0ohBC-y1DkrFUatcbRYj5DAI7aOGttTG4wA-YERCjeH_ZCOEM:1qRTtb:WZ4xw7esLCDjqSX56wKfO8IkmXosqkyLjDP5bsHnDIU", - "expire_date": "2023-08-17T08:41:23.805Z" - } -}, -{ - "model": "sessions.session", - "pk": "zkid91ej61wejh07li8fcfuu1qmod8ez", - "fields": { - "session_data": ".eJxVjEEOwiAQRe_C2pACHQGX7j0DGWZAqgaS0q6Md7dNutDtf-_9twi4LiWsPc1hYnERSpx-t4j0THUH_MB6b5JaXeYpyl2RB-3y1ji9rof7d1Cwl612SGDGDOAoe6MjoyIdk0WVrbcugydDzKAhjUOmtGns7BlsdISD8-LzBQBbOLM:1puDPA:Jguzk4JKh04XzB8JsWIvIuCgQmPgM298j2hqDWflYng", - "expire_date": "2023-05-17T14:24:28.097Z" - } -}, { "model": "cpmonitor.city", "pk": 1, diff --git a/e2e_tests/database/test_database.json b/e2e_tests/database/test_database.json index 36843c8e..756b2dda 100644 --- a/e2e_tests/database/test_database.json +++ b/e2e_tests/database/test_database.json @@ -1,913 +1,10 @@ [ -{ - "model": "auth.permission", - "pk": 1, - "fields": { - "name": "Can add log entry", - "content_type": 1, - "codename": "add_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 2, - "fields": { - "name": "Can change log entry", - "content_type": 1, - "codename": "change_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 3, - "fields": { - "name": "Can delete log entry", - "content_type": 1, - "codename": "delete_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 4, - "fields": { - "name": "Can view log entry", - "content_type": 1, - "codename": "view_logentry" - } -}, -{ - "model": "auth.permission", - "pk": 5, - "fields": { - "name": "Can add permission", - "content_type": 2, - "codename": "add_permission" - } -}, -{ - "model": "auth.permission", - "pk": 6, - "fields": { - "name": "Can change permission", - "content_type": 2, - "codename": "change_permission" - } -}, -{ - "model": "auth.permission", - "pk": 7, - "fields": { - "name": "Can delete permission", - "content_type": 2, - "codename": "delete_permission" - } -}, -{ - "model": "auth.permission", - "pk": 8, - "fields": { - "name": "Can view permission", - "content_type": 2, - "codename": "view_permission" - } -}, -{ - "model": "auth.permission", - "pk": 9, - "fields": { - "name": "Can add group", - "content_type": 3, - "codename": "add_group" - } -}, -{ - "model": "auth.permission", - "pk": 10, - "fields": { - "name": "Can change group", - "content_type": 3, - "codename": "change_group" - } -}, -{ - "model": "auth.permission", - "pk": 11, - "fields": { - "name": "Can delete group", - "content_type": 3, - "codename": "delete_group" - } -}, -{ - "model": "auth.permission", - "pk": 12, - "fields": { - "name": "Can view group", - "content_type": 3, - "codename": "view_group" - } -}, -{ - "model": "auth.permission", - "pk": 13, - "fields": { - "name": "Can add user", - "content_type": 4, - "codename": "add_user" - } -}, -{ - "model": "auth.permission", - "pk": 14, - "fields": { - "name": "Can change user", - "content_type": 4, - "codename": "change_user" - } -}, -{ - "model": "auth.permission", - "pk": 15, - "fields": { - "name": "Can delete user", - "content_type": 4, - "codename": "delete_user" - } -}, -{ - "model": "auth.permission", - "pk": 16, - "fields": { - "name": "Can view user", - "content_type": 4, - "codename": "view_user" - } -}, -{ - "model": "auth.permission", - "pk": 17, - "fields": { - "name": "Can add content type", - "content_type": 5, - "codename": "add_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 18, - "fields": { - "name": "Can change content type", - "content_type": 5, - "codename": "change_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 19, - "fields": { - "name": "Can delete content type", - "content_type": 5, - "codename": "delete_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 20, - "fields": { - "name": "Can view content type", - "content_type": 5, - "codename": "view_contenttype" - } -}, -{ - "model": "auth.permission", - "pk": 21, - "fields": { - "name": "Can add session", - "content_type": 6, - "codename": "add_session" - } -}, -{ - "model": "auth.permission", - "pk": 22, - "fields": { - "name": "Can change session", - "content_type": 6, - "codename": "change_session" - } -}, -{ - "model": "auth.permission", - "pk": 23, - "fields": { - "name": "Can delete session", - "content_type": 6, - "codename": "delete_session" - } -}, -{ - "model": "auth.permission", - "pk": 24, - "fields": { - "name": "Can view session", - "content_type": 6, - "codename": "view_session" - } -}, -{ - "model": "auth.permission", - "pk": 25, - "fields": { - "name": "Can add city", - "content_type": 7, - "codename": "add_city" - } -}, -{ - "model": "auth.permission", - "pk": 26, - "fields": { - "name": "Can change city", - "content_type": 7, - "codename": "change_city" - } -}, -{ - "model": "auth.permission", - "pk": 27, - "fields": { - "name": "Can delete city", - "content_type": 7, - "codename": "delete_city" - } -}, -{ - "model": "auth.permission", - "pk": 28, - "fields": { - "name": "Can view city", - "content_type": 7, - "codename": "view_city" - } -}, -{ - "model": "auth.permission", - "pk": 29, - "fields": { - "name": "Can add task", - "content_type": 8, - "codename": "add_task" - } -}, -{ - "model": "auth.permission", - "pk": 30, - "fields": { - "name": "Can change task", - "content_type": 8, - "codename": "change_task" - } -}, -{ - "model": "auth.permission", - "pk": 31, - "fields": { - "name": "Can delete task", - "content_type": 8, - "codename": "delete_task" - } -}, -{ - "model": "auth.permission", - "pk": 32, - "fields": { - "name": "Can view task", - "content_type": 8, - "codename": "view_task" - } -}, -{ - "model": "auth.permission", - "pk": 33, - "fields": { - "name": "Can add KPI Graph", - "content_type": 9, - "codename": "add_chart" - } -}, -{ - "model": "auth.permission", - "pk": 34, - "fields": { - "name": "Can change KPI Graph", - "content_type": 9, - "codename": "change_chart" - } -}, -{ - "model": "auth.permission", - "pk": 35, - "fields": { - "name": "Can delete KPI Graph", - "content_type": 9, - "codename": "delete_chart" - } -}, -{ - "model": "auth.permission", - "pk": 36, - "fields": { - "name": "Can view KPI Graph", - "content_type": 9, - "codename": "view_chart" - } -}, -{ - "model": "auth.permission", - "pk": 37, - "fields": { - "name": "Can add cap checklist", - "content_type": 10, - "codename": "add_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 38, - "fields": { - "name": "Can change cap checklist", - "content_type": 10, - "codename": "change_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 39, - "fields": { - "name": "Can delete cap checklist", - "content_type": 10, - "codename": "delete_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 40, - "fields": { - "name": "Can view cap checklist", - "content_type": 10, - "codename": "view_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 41, - "fields": { - "name": "Can add Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "add_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 42, - "fields": { - "name": "Can change Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "change_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 43, - "fields": { - "name": "Can delete Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "delete_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 44, - "fields": { - "name": "Can view Verwaltungsstrukturen Checkliste", - "content_type": 11, - "codename": "view_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 45, - "fields": { - "name": "Can add Lokalgruppe", - "content_type": 12, - "codename": "add_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 46, - "fields": { - "name": "Can change Lokalgruppe", - "content_type": 12, - "codename": "change_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 47, - "fields": { - "name": "Can delete Lokalgruppe", - "content_type": 12, - "codename": "delete_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 48, - "fields": { - "name": "Can view Lokalgruppe", - "content_type": 12, - "codename": "view_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 49, - "fields": { - "name": "Can add Kommune", - "content_type": 13, - "codename": "add_city" - } -}, -{ - "model": "auth.permission", - "pk": 50, - "fields": { - "name": "Can change Kommune", - "content_type": 13, - "codename": "change_city" - } -}, -{ - "model": "auth.permission", - "pk": 51, - "fields": { - "name": "Can delete Kommune", - "content_type": 13, - "codename": "delete_city" - } -}, -{ - "model": "auth.permission", - "pk": 52, - "fields": { - "name": "Can view Kommune", - "content_type": 13, - "codename": "view_city" - } -}, -{ - "model": "auth.permission", - "pk": 53, - "fields": { - "name": "Can add Sektor / Maßnahme", - "content_type": 14, - "codename": "add_task" - } -}, -{ - "model": "auth.permission", - "pk": 54, - "fields": { - "name": "Can change Sektor / Maßnahme", - "content_type": 14, - "codename": "change_task" - } -}, -{ - "model": "auth.permission", - "pk": 55, - "fields": { - "name": "Can delete Sektor / Maßnahme", - "content_type": 14, - "codename": "delete_task" - } -}, -{ - "model": "auth.permission", - "pk": 56, - "fields": { - "name": "Can view Sektor / Maßnahme", - "content_type": 14, - "codename": "view_task" - } -}, -{ - "model": "auth.permission", - "pk": 57, - "fields": { - "name": "Can add Diagramm", - "content_type": 15, - "codename": "add_chart" - } -}, -{ - "model": "auth.permission", - "pk": 58, - "fields": { - "name": "Can change Diagramm", - "content_type": 15, - "codename": "change_chart" - } -}, -{ - "model": "auth.permission", - "pk": 59, - "fields": { - "name": "Can delete Diagramm", - "content_type": 15, - "codename": "delete_chart" - } -}, -{ - "model": "auth.permission", - "pk": 60, - "fields": { - "name": "Can view Diagramm", - "content_type": 15, - "codename": "view_chart" - } -}, -{ - "model": "auth.permission", - "pk": 61, - "fields": { - "name": "Can add KAP Checkliste", - "content_type": 16, - "codename": "add_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 62, - "fields": { - "name": "Can change KAP Checkliste", - "content_type": 16, - "codename": "change_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 63, - "fields": { - "name": "Can delete KAP Checkliste", - "content_type": 16, - "codename": "delete_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 64, - "fields": { - "name": "Can view KAP Checkliste", - "content_type": 16, - "codename": "view_capchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 65, - "fields": { - "name": "Can add Verwaltungsstrukturen Checkliste", - "content_type": 17, - "codename": "add_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 66, - "fields": { - "name": "Can change Verwaltungsstrukturen Checkliste", - "content_type": 17, - "codename": "change_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 67, - "fields": { - "name": "Can delete Verwaltungsstrukturen Checkliste", - "content_type": 17, - "codename": "delete_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 68, - "fields": { - "name": "Can view Verwaltungsstrukturen Checkliste", - "content_type": 17, - "codename": "view_administrationchecklist" - } -}, -{ - "model": "auth.permission", - "pk": 69, - "fields": { - "name": "Can add Lokalgruppe", - "content_type": 18, - "codename": "add_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 70, - "fields": { - "name": "Can change Lokalgruppe", - "content_type": 18, - "codename": "change_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 71, - "fields": { - "name": "Can delete Lokalgruppe", - "content_type": 18, - "codename": "delete_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 72, - "fields": { - "name": "Can view Lokalgruppe", - "content_type": 18, - "codename": "view_localgroup" - } -}, -{ - "model": "auth.permission", - "pk": 73, - "fields": { - "name": "Can add Einladungslink", - "content_type": 19, - "codename": "add_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 74, - "fields": { - "name": "Can change Einladungslink", - "content_type": 19, - "codename": "change_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 75, - "fields": { - "name": "Can delete Einladungslink", - "content_type": 19, - "codename": "delete_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 76, - "fields": { - "name": "Can view Einladungslink", - "content_type": 19, - "codename": "view_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 77, - "fields": { - "name": "Can add email address", - "content_type": 7, - "codename": "add_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 78, - "fields": { - "name": "Can change email address", - "content_type": 7, - "codename": "change_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 79, - "fields": { - "name": "Can delete email address", - "content_type": 7, - "codename": "delete_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 80, - "fields": { - "name": "Can view email address", - "content_type": 7, - "codename": "view_emailaddress" - } -}, -{ - "model": "auth.permission", - "pk": 81, - "fields": { - "name": "Can add email confirmation", - "content_type": 8, - "codename": "add_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 82, - "fields": { - "name": "Can change email confirmation", - "content_type": 8, - "codename": "change_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 83, - "fields": { - "name": "Can delete email confirmation", - "content_type": 8, - "codename": "delete_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 84, - "fields": { - "name": "Can view email confirmation", - "content_type": 8, - "codename": "view_emailconfirmation" - } -}, -{ - "model": "auth.permission", - "pk": 85, - "fields": { - "name": "Can add social account", - "content_type": 9, - "codename": "add_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 86, - "fields": { - "name": "Can change social account", - "content_type": 9, - "codename": "change_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 87, - "fields": { - "name": "Can delete social account", - "content_type": 9, - "codename": "delete_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 88, - "fields": { - "name": "Can view social account", - "content_type": 9, - "codename": "view_socialaccount" - } -}, -{ - "model": "auth.permission", - "pk": 89, - "fields": { - "name": "Can add social application", - "content_type": 10, - "codename": "add_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 90, - "fields": { - "name": "Can change social application", - "content_type": 10, - "codename": "change_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 91, - "fields": { - "name": "Can delete social application", - "content_type": 10, - "codename": "delete_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 92, - "fields": { - "name": "Can view social application", - "content_type": 10, - "codename": "view_socialapp" - } -}, -{ - "model": "auth.permission", - "pk": 93, - "fields": { - "name": "Can add social application token", - "content_type": 11, - "codename": "add_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 94, - "fields": { - "name": "Can change social application token", - "content_type": 11, - "codename": "change_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 95, - "fields": { - "name": "Can delete social application token", - "content_type": 11, - "codename": "delete_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 96, - "fields": { - "name": "Can view social application token", - "content_type": 11, - "codename": "view_socialtoken" - } -}, -{ - "model": "auth.permission", - "pk": 97, - "fields": { - "name": "Can add invitation", - "content_type": 12, - "codename": "add_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 98, - "fields": { - "name": "Can change invitation", - "content_type": 12, - "codename": "change_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 99, - "fields": { - "name": "Can delete invitation", - "content_type": 12, - "codename": "delete_invitation" - } -}, -{ - "model": "auth.permission", - "pk": 100, - "fields": { - "name": "Can view invitation", - "content_type": 12, - "codename": "view_invitation" - } -}, { "model": "auth.group", "pk": 1, "fields": { "name": "Alle", - "permissions": [ - 26, - 30 - ] + "permissions": [] } }, { From 28829e78fbca3c07251d55c0f09f7901bc558521 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Mon, 21 Aug 2023 23:32:44 +0200 Subject: [PATCH 14/31] {#23] cleanup; tests for invitation links. --- config/settings/base.py | 2 +- cpmonitor/tests/permissions_test.py | 168 +++++++++++++++++++++++++++- cpmonitor/views.py | 26 ++--- 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index bad9e49d..d14bb88d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -191,4 +191,4 @@ def get_env(var: str) -> str: INVITATIONS_GONE_ON_ACCEPT_ERROR = False INVITATIONS_INVITATION_ONLY = True INVITATIONS_CONFIRMATION_URL_NAME = "accept-invite" -INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = True +INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = False diff --git a/cpmonitor/tests/permissions_test.py b/cpmonitor/tests/permissions_test.py index 601640c9..b4b638b8 100644 --- a/cpmonitor/tests/permissions_test.py +++ b/cpmonitor/tests/permissions_test.py @@ -38,28 +38,28 @@ def _login(client: Client, django_user_model: User, username: str): @pytest.fixture def unprivileged_client(permissions_db, client: Client, django_user_model: User): "Client fixture with data loaded and unprivileged user logged in." - _login(client, django_user_model, "christian") + _login(client, django_user_model, "christian") # password: derehring return client @pytest.fixture def site_admin_client(permissions_db, client: Client, django_user_model: User): "Client fixture with data loaded and site admin user logged in." - _login(client, django_user_model, "admin") + _login(client, django_user_model, "admin") # password: password return client @pytest.fixture def city_admin_client(permissions_db, client: Client, django_user_model: User): "Client fixture with data loaded and city admin user logged in." - _login(client, django_user_model, "sarah") + _login(client, django_user_model, "sarah") # passowrd: diebrosetti return client @pytest.fixture def city_editor_client(permissions_db, client: Client, django_user_model: User): "Client fixture with data loaded and city editor user logged in." - _login(client, django_user_model, "heinz") + _login(client, django_user_model, "heinz") # password: derstrunk return client @@ -67,7 +67,7 @@ def city_editor_client(permissions_db, client: Client, django_user_model: User): def test_admin_login_should_fail_fail_for_non_existing_user( - permissions_db, client: Client, django_user_model: User + permissions_db, client: Client, db ): client.login(username="asdf", password="ghjk") response = client.get("/admin/") @@ -694,3 +694,161 @@ def test_site_admin_should_not_be_allowed_to_change_task_of_other_city( response = site_admin_client.get("/admin/cpmonitor/task/27/change/") assert response.status_code == 200 + + +# Invitation links + +city_admin_key = "ercizfqjtsqbv5xap4uvlkpswjivqnjiephfxdbhjett8jah0z0ynnfpqrqxjcjg" +city_editor_key = "lypvs6fb6qxk8ylskkckwp3g3djilpsiiunm1fuz68rdwg1emuwhnsuxexpbgjel" + + +def test_city_editor_should_be_able_to_see_invitation_links( + city_editor_client: Client, +): + response = city_editor_client.get("/admin/cpmonitor/city/1/change/") + + assertNotContains(response, city_admin_key) + assertNotContains(response, city_editor_key) + + +def test_city_admin_should_be_able_to_see_invitation_links( + city_admin_client: Client, +): + response = city_admin_client.get("/admin/cpmonitor/city/1/change/") + + assertContains(response, city_admin_key) + assertContains(response, city_editor_key) + + +def test_site_admin_should_be_able_to_see_invitation_links( + site_admin_client: Client, +): + response = site_admin_client.get("/admin/cpmonitor/city/1/change/") + + assertContains(response, city_admin_key) + assertContains(response, city_editor_key) + + +# This is poor-mans parametrization, since pytest-django does not support it: + + +def _assert_not_logged_in(client: Client): + response = client.get("/admin/", follow=True) + assertTemplateNotUsed(response, "admin/index.html") + + +def _assert_logged_in(client: Client): + response = client.get("/admin/", follow=True) + assertTemplateUsed(response, "admin/index.html") + + +def _register_with_registration_link(key, client: Client): + _assert_not_logged_in(client) + + response = client.get(f"/invitations/accept-invite/{key}") + assert response.status_code == 302 + assert response.url == "/accounts/signup/" + + response = client.get(f"/invitations/accept-invite/{key}", follow=True) + assert response.status_code == 200 + assertTemplateUsed(response, "account/signup.html") + assertNotContains(response, "Fehler") + + response = client.post( + "/accounts/signup/", + { + "username": "kirstin", + "email": "", + "password1": "diewarnke", + "password2": "diewarnkr", + }, + ) + assert response.status_code == 200 + assertTemplateUsed(response, "account/signup.html") + assertContains(response, "Fehler") + + response = client.post( + "/accounts/signup/", + { + "username": "kirstin", + "email": "", + "password1": "diewarnke", + "password2": "diewarnke", + }, + ) + assert response.status_code == 302 + assert response.url == "/admin/" + + _assert_logged_in(client) + + client.logout() + + +def _login_with_new_account(client: Client): + _assert_not_logged_in(client) + + client.login(username="kirstin", password="diewarnke") + + _assert_logged_in(client) + + +def _assert_city_changelist_contains_only_allowed(client: Client): + response = client.get("/admin/cpmonitor/city/") + assertTemplateUsed(response, "admin/change_list.html") + result_list = response.context["results"] + assert isinstance(result_list, list) and len(result_list) == 1 + assertContains(response, "Beispielstadt") + assertNotContains(response, "Mitallem") + + assertNotContains(response, "Kommune hinzufügen") + + +def _assert_other_city_cannot_be_changed(client: Client): + response = client.get("/admin/cpmonitor/city/2/change/") + assert response.status_code == 302 + assert response.url == "/admin/" + + +def _assert_city_can_be_changed(client: Client): + response = client.get("/admin/cpmonitor/city/1/change/") + + assert response.status_code == 200 + + assertTemplateUsed(response, "admin/change_form.html") + + assert not response.context["show_delete_link"] + assert not response.context["show_save_and_add_another"] + + +def test_can_register_with_city_editor_registration_link( + permissions_db, db, client: Client +): + _register_with_registration_link(city_editor_key, client) + _login_with_new_account(client) + _assert_city_changelist_contains_only_allowed(client) + _assert_other_city_cannot_be_changed(client) + _assert_city_can_be_changed(client) + + response = client.get("/admin/cpmonitor/city/1/change/") + adminform = response.context["adminform"] + assert "city_editors" in adminform.readonly_fields + assert "city_admins" in adminform.readonly_fields + assertNotContains(response, city_admin_key) + assertNotContains(response, city_editor_key) + + +def test_can_register_with_city_admin_registration_link( + permissions_db, db, client: Client +): + _register_with_registration_link(city_admin_key, client) + _login_with_new_account(client) + _assert_city_changelist_contains_only_allowed(client) + _assert_other_city_cannot_be_changed(client) + _assert_city_can_be_changed(client) + + response = client.get("/admin/cpmonitor/city/1/change/") + adminform = response.context["adminform"] + assert not "city_editors" in adminform.readonly_fields + assert not "city_admins" in adminform.readonly_fields + assertContains(response, city_admin_key) + assertContains(response, city_editor_key) diff --git a/cpmonitor/views.py b/cpmonitor/views.py index b67b9c28..0d77e2e0 100644 --- a/cpmonitor/views.py +++ b/cpmonitor/views.py @@ -486,16 +486,21 @@ def post(self, *args, **kwargs): ) return redirect(self.get_signup_redirect()) - # Difference 1 to base: Not calling accept_invitation(). + if not invitations_settings.ACCEPT_INVITE_AFTER_SIGNUP: + accept_invitation( + invitation=invitation, + request=self.request, + signal_sender=self.__class__, + ) - # Difference 2 to base: Saving key and not email. + # Difference to base: Saving key and not email. self.request.session["invitation_key"] = invitation.key return redirect(self.get_signup_redirect()) def accept_invitation(invitation, request, signal_sender): - # Difference: Not setting accepted to True, here. + # Difference to base: Not setting accepted to True, here. invite_accepted.send( sender=signal_sender, @@ -510,18 +515,3 @@ def accept_invitation(invitation, request, signal_sender): "invitations/messages/invite_accepted.txt", {"email": invitation.email}, ) - - -def accept_invite_after_signup(sender, request, user, **kwargs): - invitation = get_invitation(request) - if invitation: - accept_invitation( - invitation=invitation, - request=request, - signal_sender=Invitation, - ) - - -if invitations_settings.ACCEPT_INVITE_AFTER_SIGNUP: - signed_up_signal = get_invitations_adapter().get_user_signed_up_signal() - signed_up_signal.connect(accept_invite_after_signup) From 0e271b891e6068ce95f4204a810af5e70d1e15e3 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Tue, 22 Aug 2023 13:19:38 +0200 Subject: [PATCH 15/31] [#23] Cleanup; add some doc; fix migrations. --- README.md | 9 +-- config/settings/base.py | 33 +++++----- cpmonitor/adapters.py | 30 ++++----- cpmonitor/admin.py | 28 +++++++-- cpmonitor/forms.py | 16 ----- ...026_city_city_admins_city_city_editors.py} | 2 +- ...{0025_invitation.py => 0027_invitation.py} | 19 +++--- cpmonitor/models.py | 61 ++++++++++++------- cpmonitor/rules.py | 34 ++++++++--- .../templates/overrides/account/signup.html | 6 +- cpmonitor/urls.py | 1 - cpmonitor/utils.py | 28 +++------ cpmonitor/views.py | 21 +++---- 13 files changed, 162 insertions(+), 126 deletions(-) delete mode 100644 cpmonitor/forms.py rename cpmonitor/migrations/{0024_city_city_admins_city_city_editors.py => 0026_city_city_admins_city_city_editors.py} (94%) rename cpmonitor/migrations/{0025_invitation.py => 0027_invitation.py} (83%) diff --git a/README.md b/README.md index 0b07b778..2e951906 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,11 @@ python -Xutf8 manage.py dumpdata -e contenttypes -e auth.Permission -e admin.Log (The `-Xutf8` and `--indent 2` options ensure consistent and readable output on all platforms.) -The arguments `-e contenttypes -e auth.Permission` exclude tables which are pre-filled by django and whose content may -changes depending on the models in the project. If they are included, everything works fine at first, since loaddata -will silently accept data already there. However, as soon as the data to load clashes with existing content, it will fail. -`-e admin.LogEntry` contains references to content types and is therefore also excluded. +The arguments `-e contenttypes -e auth.Permission -e admin.LogEntry -e sessions` exclude tables which are pre-filled +by django or during usage by djangoand whose content may change depending on the models in the project. If they are +included, everything works fine at first, since loaddata will silently accept data already there. However, as soon as +the data to load clashes with existing content, it will fail. `-e admin.LogEntry` excludes references to content types +which may otherwise be inconsistent.`-e sessions` excludes unneeded data which otherwise would clog the JSON file. This fixture may be loaded in a test with. (Similar in a pytest fixture.) diff --git a/config/settings/base.py b/config/settings/base.py index d14bb88d..486edb39 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -45,14 +45,13 @@ def get_env(var: str) -> str: "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # "django.contrib.sites", "treebeard", "martor", "rules.apps.AutodiscoverRulesConfig", "allauth", "allauth.account", "allauth.socialaccount", - # "invitations", We do not use its model and therefore do not want its migrations. + # "invitations", We do not use invitations.Invitatoin and therefore do not want its migrations. "cpmonitor.apps.CpmonitorConfig", ] @@ -69,7 +68,6 @@ def get_env(var: str) -> str: AUTHENTICATION_BACKENDS = ( "rules.permissions.ObjectPermissionBackend", "django.contrib.auth.backends.ModelBackend", - # "allauth.account.auth_backends.AuthenticationBackend", ) ROOT_URLCONF = "cpmonitor.urls" @@ -151,11 +149,6 @@ def get_env(var: str) -> str: DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# The "sites" framework configuration -# https://docs.djangoproject.com/en/4.2/ref/contrib/sites/ -# Required by django-allauth and django-invitations -# SITE_ID = 1 - # Martor (markdown editor) MARTOR_ENABLE_CONFIGS = { "emoji": "true", @@ -176,19 +169,31 @@ def get_env(var: str) -> str: MAX_IMAGE_UPLOAD_SIZE = 104857600 # 100 MB # django-allauth configuration: -# https://django-allauth.readthedocs.io/en/latest/installation.html +# https://django-allauth.readthedocs.io/en/latest/configuration.html +# Most customization is done in the adapter: ACCOUNT_ADAPTER = "cpmonitor.adapters.AllauthInvitationsAdapter" SOCIALACCOUNT_PROVIDERS = {} -ACCOUNT_EMAIL_VERIFICATION = "none" # This would need a working email config. - -LOGIN_URL = "/admin/login/" # Used by django-invitations for redirect -LOGIN_REDIRECT_URL = "/admin/" +ACCOUNT_EMAIL_VERIFICATION = "none" # Would need a working email config. # django-invitations configuration: -# https://django-invitations.readthedocs.io/en/latest/installation.html +# https://django-invitations.readthedocs.io/en/latest/configuration.html +# django-invitations is closely coupled to django-allauth and uses +# the same adapter. INVITATIONS_ADAPTER = "cpmonitor.adapters.AllauthInvitationsAdapter" +# To couple an invitation to a city and access right (either admin or editor) +# a custom model is needed. To prevent `invitations.Invitation` from being +# used, django-invitations does not appear in `INSTALLED_APPS` above. INVITATIONS_INVITATION_MODEL = "cpmonitor.Invitation" INVITATIONS_GONE_ON_ACCEPT_ERROR = False INVITATIONS_INVITATION_ONLY = True +# In order to use our custom view instead of the one from django-invitations, +# `invitations.urls` could not be used. This parameters default value points +# to that and had to be replaced by our own: INVITATIONS_CONFIRMATION_URL_NAME = "accept-invite" +# Setting this to true would cause a signal handler to be installed, which +# would try to access the email field of an invitation and fail on the custom model. INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = False + +# django core configuration used by django-invitations +LOGIN_URL = "/admin/login/" +LOGIN_REDIRECT_URL = "/admin/" diff --git a/cpmonitor/adapters.py b/cpmonitor/adapters.py index 5df26bda..7519e2b9 100644 --- a/cpmonitor/adapters.py +++ b/cpmonitor/adapters.py @@ -1,38 +1,34 @@ from allauth.account.adapter import DefaultAccountAdapter -from allauth.account.signals import user_signed_up -from django.contrib import messages from invitations.app_settings import app_settings -from .models import AccessRight, City, Invitation -from .utils import get_invitation +from .models import AccessRight, City, get_invitation class AllauthInvitationsAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): + """ + Overwrites django-invitations. + Checks that there exists an invitation instead of email. + """ if get_invitation(request): return True - elif app_settings.INVITATION_ONLY is True: - # Site is ONLY open for invites + elif app_settings.INVITATION_ONLY: return False else: - # Site is open to signup return True - def get_user_signed_up_signal(self): - return user_signed_up - def save_user(self, request, user, form, commit=True): - "Check there is an invitation and set the appropriate access rights. Swallow the user object, if not." + """ + Overwrites django-allauth. + Check there is an invitation and set the appropriate access rights. + Swallow the user object already created, if not. + Otherwise, set access rights according to invitation. + """ invitation = get_invitation(request) - print("Got invitation: " + str(invitation)) if not invitation: self.add_error( None, - "Die Registrierung ist nur möglich über einen gültigen Einladungslink.1", - ) - messages.error( - request, - "Die Registrierung ist nur möglich über einen gültigen Einladungslink.2", + "Die Registrierung ist nur möglich über einen gültigen Einladungslink.", ) return diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index aff4a13f..306ec058 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -1,13 +1,12 @@ from collections.abc import Sequence -from django.contrib import admin, auth, messages +from django.contrib import admin, messages from django.db import models -from django.forms import ModelChoiceField, TextInput +from django.forms import TextInput from django.forms.models import ErrorList from django.http import HttpRequest, HttpResponseRedirect, QueryDict from django.http.request import HttpRequest from django.urls import reverse from django.utils.html import format_html -import invitations from martor.widgets import AdminMartorWidget from treebeard.admin import TreeAdmin from treebeard.forms import movenodeform_factory, MoveNodeForm @@ -82,15 +81,22 @@ class LocalGroupInline(ObjectPermissionsModelAdminMixin, admin.StackedInline): class InvitationInline( ObjectPermissionsModelAdminMixin, utils.ModelAdminRequestMixin, admin.StackedInline ): + "Only show the invitation link and offer to delete them. Will be recreated upon next save." model = Invitation extra = 0 fields = ("invitation_link",) readonly_fields = ("invitation_link",) - @admin.display(description="Einladungslink") + @admin.display(description="Link") def invitation_link(self, invitation: Invitation): url = invitation.get_invite_url(self.get_request()) - return format_html('{}', url, url) + role = str(invitation) + return format_html( + '{}:
Diesen Link bitte nur an Menschen schicken, die mit dieser Rolle mitarbeiten sollen:
{}', + role, + url, + url, + ) class CityAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): @@ -101,6 +107,7 @@ class CityAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): search_fields = ["zipcode", "name"] def get_queryset(self, request): + "Restrict cities shown in changelist to those the user has access to." qs = super().get_queryset(request) user = request.user if user.is_superuser: @@ -216,8 +223,15 @@ def clean(self): class CityPermissionFilter(admin.RelatedFieldListFilter): + """ + In the Task changelist (tree of tasks) on the right in the filter settings + show only the cities for which the user has permission. + By default, for a `list_filter` on a ForeignKey field, a RelatedFieldListFilter + would be used. + """ + def field_choices(self, field, request, model_admin): - "Limit to cities the user is allowed to edit." + "Limit to cities the user is allowed to edit by using `limit_choices_to`." return field.get_choices( include_blank=False, ordering=self.field_admin_ordering(field, request, model_admin), @@ -295,6 +309,7 @@ def get_readonly_fields(self, request, obj=None): } def formfield_for_foreignkey(self, db_field, request, **kwargs): + "In the add form show only the cities the user has access to for the city the task belongs to." if db_field.name == "city": kwargs["queryset"] = City.objects.filter( rules.is_allowed_to_edit_q(request.user, City) @@ -309,6 +324,7 @@ def get_changeform_initial_data(self, request: HttpRequest): return {"city": city_id} def add_view(self, request, form_url="", extra_context=None): + "Only show the add form if a city is selected to which the user has access." query_string = self.get_preserved_filters(request) filters = QueryDict(query_string).get("_changelist_filters") city_id = QueryDict(filters).get(_city_filter_query) diff --git a/cpmonitor/forms.py b/cpmonitor/forms.py deleted file mode 100644 index 8993fc12..00000000 --- a/cpmonitor/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -from allauth.account.forms import SignupForm - -from .models import Invitation -from .utils import get_invitation - - -class InvitationBasedSignupForm(SignupForm): - def save(self, request): - invitation = get_invitation(request) - - user = super(InvitationBasedSignupForm, self).save(request) - - # Add your own processing here. - - # You must return the original result. - return user diff --git a/cpmonitor/migrations/0024_city_city_admins_city_city_editors.py b/cpmonitor/migrations/0026_city_city_admins_city_city_editors.py similarity index 94% rename from cpmonitor/migrations/0024_city_city_admins_city_city_editors.py rename to cpmonitor/migrations/0026_city_city_admins_city_city_editors.py index 6f72b1e6..7937a201 100644 --- a/cpmonitor/migrations/0024_city_city_admins_city_city_editors.py +++ b/cpmonitor/migrations/0026_city_city_admins_city_city_editors.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("cpmonitor", "0023_task_frontpage"), + ("cpmonitor", "0025_alter_task_responsible_organ_and_more"), ] operations = [ diff --git a/cpmonitor/migrations/0025_invitation.py b/cpmonitor/migrations/0027_invitation.py similarity index 83% rename from cpmonitor/migrations/0025_invitation.py rename to cpmonitor/migrations/0027_invitation.py index acee926c..e39f5daf 100644 --- a/cpmonitor/migrations/0025_invitation.py +++ b/cpmonitor/migrations/0027_invitation.py @@ -8,30 +8,35 @@ class AccessRight(models.TextChoices): + "Copy from state matching this migration." CITY_ADMIN = "city admin", "Kommunen Administrator" CITY_EDITOR = "city editor", "Kommunen Bearbeiter" -def ensure_invitation(inv_mgr, city, access_right): - if not inv_mgr.filter(city=city, access_right=access_right): +def ensure_invitation(invitation_manager, city, access_right): + "Check if invitation exists and create it, if not." + if not invitation_manager.filter(city=city, access_right=access_right): key = get_random_string(64).lower() - inv_mgr.create(key=key, inviter=None, city=city, access_right=access_right) + invitation_manager.create( + key=key, inviter=None, city=city, access_right=access_right + ) def add_invitations(apps, schema_editor): + "Add missing invitations to all cities." City = apps.get_model("cpmonitor", "City") Invitation = apps.get_model("cpmonitor", "Invitation") - inv_mgr = Invitation._default_manager + invitation_manager = Invitation._default_manager db_alias = schema_editor.connection.alias for city in City.objects.using(db_alias).all(): - ensure_invitation(inv_mgr, city, AccessRight.CITY_ADMIN) - ensure_invitation(inv_mgr, city, AccessRight.CITY_EDITOR) + ensure_invitation(invitation_manager, city, AccessRight.CITY_ADMIN) + ensure_invitation(invitation_manager, city, AccessRight.CITY_EDITOR) class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("cpmonitor", "0024_city_city_admins_city_city_editors"), + ("cpmonitor", "0026_city_city_admins_city_city_editors"), ] operations = [ diff --git a/cpmonitor/models.py b/cpmonitor/models.py index 890daabf..aee67c68 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -1,14 +1,19 @@ from datetime import date - from django.conf import settings from django.core.exceptions import ValidationError, NON_FIELD_ERRORS from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.http import HttpRequest +from django.urls import reverse +from django.utils.crypto import get_random_string from django.utils.text import slugify -import invitations +from django.utils import timezone +from invitations.app_settings import app_settings as invitations_app_settings +from invitations.base_invitation import AbstractBaseInvitation +from invitations import signals from treebeard.exceptions import InvalidPosition from treebeard.mp_tree import MP_Node - +from types import NoneType # Note PEP-8 naming conventions for class names apply. So use the singular and CamelCase @@ -188,7 +193,7 @@ def validate_unique(self, exclude=None): raise ValidationError(msgs) def save(self, *args, **kwargs): - """""" + "Ensure there are all needed invitation links for the city." super().save(*args, **kwargs) Invitation.ensure_for_city(self) @@ -715,21 +720,19 @@ class Meta: ) -import datetime -from invitations.base_invitation import AbstractBaseInvitation -from invitations.app_settings import app_settings as invitations_app_settings -from django.utils import timezone -from django.utils.crypto import get_random_string -from django.urls import reverse -from invitations import signals - - class AccessRight(models.TextChoices): CITY_ADMIN = "city admin", "Kommunen Administrator" CITY_EDITOR = "city editor", "Kommunen Bearbeiter" class Invitation(AbstractBaseInvitation): + """ + Invitation suitable to be send as link without email, but with rights attached. + Invitations will be created automatically, whenever a city is saved. No user will + have to add invitations by hand. They can only be deleted to invalidate links. + New links will be created upon the next save of the city. + """ + class Meta: verbose_name = "Einladungslink" verbose_name_plural = "Einladungslinks" @@ -762,24 +765,28 @@ class Meta: @property def email(self): + "Satisfy expected interface." return f"{self.get_access_right_display()} von {self.city.name}" @classmethod - def create_for_keys(cls, city, access_right): + def create_for_right(cls, city, access_right): + "Create a new invitation for a city with a given right." key = get_random_string(64).lower() return cls._default_manager.create( key=key, inviter=None, city=city, access_right=access_right ) @classmethod - def ensure_for_keys(cls, city, access_right): + def ensure_for_right(cls, city, access_right): + "Ensure there exists an invitation for a city with a given right." if not cls._default_manager.filter(city=city, access_right=access_right): - cls.create_for_keys(city, access_right) + cls.create_for_right(city, access_right) @classmethod def ensure_for_city(cls, city): - cls.ensure_for_keys(city, AccessRight.CITY_EDITOR) - cls.ensure_for_keys(city, AccessRight.CITY_ADMIN) + "Ensure there exist the needed invitations for a city." + cls.ensure_for_right(city, AccessRight.CITY_EDITOR) + cls.ensure_for_right(city, AccessRight.CITY_ADMIN) @classmethod def create(cls, email, inviter=None, **kwargs): @@ -790,6 +797,7 @@ def create(cls, email, inviter=None, **kwargs): ) def get_invite_url(self, request): + "Build correct URL to be sent to invited users. Extracted from django-invitations." if not self.key: return None url = reverse(invitations_app_settings.CONFIRMATION_URL_NAME, args=[self.key]) @@ -798,10 +806,6 @@ def get_invite_url(self, request): def key_expired(self): "Implementation of required method. Never expired." return False - # expiration_date = self.sent + datetime.timedelta( - # days=invitations_app_settings.INVITATION_EXPIRY, - # ) - # return expiration_date <= timezone.now() def send_invitation(self, request, **kwargs): "Implementation of required method. Pretending to send an email." @@ -819,6 +823,19 @@ def __str__(self): return f"Einladung für {self.get_access_right_display()} von {self.city.name}" +def get_invitation(request: HttpRequest) -> Invitation | NoneType: + "Retrieve an invitation based on the key in the current session." + if not hasattr(request, "session"): + return None + key = request.session.get("invitation_key") + if not key: + return None + invitation_qs = Invitation.objects.filter(key=key.lower()) + if not invitation_qs: + return None + return invitation_qs.first() + + # Tables for comparing and connecting the plans of all cities # Lookup-entities (shared among all cities, entered by admins) diff --git a/cpmonitor/rules.py b/cpmonitor/rules.py index 906fb6de..7e3ba132 100644 --- a/cpmonitor/rules.py +++ b/cpmonitor/rules.py @@ -3,13 +3,20 @@ import rules from types import NoneType -from .models import City, Task, Chart +from .models import City, Task, Chart, CapChecklist, AdministrationChecklist, LocalGroup -CityType = City | Task | Chart | int +# All classes attached to a city (except Invitation) or a city ID. +CityType = ( + City | Task | Chart | CapChecklist | AdministrationChecklist | LocalGroup | int +) CityOrNoneType = CityType | NoneType def is_allowed_to_edit_q(user: User, model: Model) -> Q: + """ + Return a Q object to filter objects to which a user has access. + This may be used with `QuerySet.filter`, but also with `limit_choices_to`. + """ if not user.is_staff or not user.is_active: return Q(pk__in=[]) # Always false -> Empty QuerySet if user.is_superuser: @@ -21,6 +28,7 @@ def is_allowed_to_edit_q(user: User, model: Model) -> Q: def _get_city(object: CityOrNoneType) -> City | NoneType: + "Helper to retrieve city from any object belonging to a city or a city ID." if isinstance(object, City): return object elif isinstance(object, int): @@ -31,6 +39,7 @@ def _get_city(object: CityOrNoneType) -> City | NoneType: @rules.predicate def is_city_editor(user: User, object: CityOrNoneType) -> bool: + "True, if user is city editor of the city object belongs to. False, if no object specified." if not user.is_staff or not user.is_active: return False city = _get_city(object) @@ -41,6 +50,7 @@ def is_city_editor(user: User, object: CityOrNoneType) -> bool: @rules.predicate def is_city_admin(user: User, object: CityOrNoneType) -> bool: + "True, if user is city admin of the city object belongs to. False, if no object specified." if not user.is_staff or not user.is_active: return False city = _get_city(object) @@ -51,6 +61,7 @@ def is_city_admin(user: User, object: CityOrNoneType) -> bool: @rules.predicate def is_site_admin(user: User, object: CityOrNoneType) -> bool: + "True, if user is site admin of the city object belongs to. False, if no object specified." if not user.is_superuser or not user.is_active: return False city = _get_city(object) @@ -61,17 +72,25 @@ def is_site_admin(user: User, object: CityOrNoneType) -> bool: @rules.predicate def no_object(user: User, object: CityOrNoneType) -> bool: + "True if no object is specified and the user has access to the admin." if object is None and user.is_active and user.is_staff: return True return False +# Composed predicates: is_allowed_to_edit = is_city_editor | is_city_admin | is_site_admin is_allowed_to_change_city_users = is_city_admin | is_site_admin # The actual permissions: +# Unfortunately, the admin sometimes asks for change permissions without specifying +# an object. Therefore, this case is handled with `no_object` below. Since the admin +# uses additional checks with the object, this is no breach of the restrictions. +# Note, that at some places the permissions have to be checked manually. See admin.py. + +# Allow to view the admin at all: rules.add_perm("cpmonitor", rules.always_true) # City: @@ -80,6 +99,12 @@ def no_object(user: User, object: CityOrNoneType) -> bool: rules.add_perm("cpmonitor.view_city", is_allowed_to_edit) rules.add_perm("cpmonitor.change_city", is_allowed_to_edit | no_object) +# Task: +rules.add_perm("cpmonitor.add_task", is_allowed_to_edit | no_object) +rules.add_perm("cpmonitor.view_task", is_allowed_to_edit) +rules.add_perm("cpmonitor.delete_task", is_allowed_to_edit) +rules.add_perm("cpmonitor.change_task", is_allowed_to_edit | no_object) + # Inlines in city mask: # For some reason, "change" is requested with "None" once by inlines. rules.add_perm("cpmonitor.add_chart", is_allowed_to_edit | no_object) @@ -104,10 +129,5 @@ def no_object(user: User, object: CityOrNoneType) -> bool: rules.add_perm("cpmonitor.delete_capchecklist", is_allowed_to_edit) rules.add_perm("cpmonitor.change_capchecklist", is_allowed_to_edit | no_object) -rules.add_perm("cpmonitor.add_task", is_allowed_to_edit | no_object) -rules.add_perm("cpmonitor.view_task", is_allowed_to_edit) -rules.add_perm("cpmonitor.delete_task", is_allowed_to_edit) -rules.add_perm("cpmonitor.change_task", is_allowed_to_edit | no_object) - rules.add_perm("cpmonitor.view_invitation", is_allowed_to_change_city_users | no_object) rules.add_perm("cpmonitor.delete_invitation", is_allowed_to_change_city_users) diff --git a/cpmonitor/templates/overrides/account/signup.html b/cpmonitor/templates/overrides/account/signup.html index da50e859..871b4c92 100644 --- a/cpmonitor/templates/overrides/account/signup.html +++ b/cpmonitor/templates/overrides/account/signup.html @@ -1,3 +1,5 @@ +{# This is based on the admin login screen #} +{# and mixes in the signup form of django-allauth. #} {% extends "admin/base.html" %} {% load i18n static %} {% block title %} @@ -49,7 +51,9 @@

{% trans "Sign Up" %}

method="post" action="{% url 'account_signup' %}"> {% csrf_token %} - {{ form.as_p }} + + {{ form.as_table }} +
{% if redirect_field_value %} Invitation | NoneType: - if not hasattr(request, "session"): - return None - key = request.session.get("invitation_key") - if not key: - return None - invitation_qs = Invitation.objects.filter(key=key.lower()) - if not invitation_qs: - return None - return invitation_qs.first() diff --git a/cpmonitor/views.py b/cpmonitor/views.py index 0d77e2e0..14ec5d12 100644 --- a/cpmonitor/views.py +++ b/cpmonitor/views.py @@ -439,19 +439,15 @@ def markdown_uploader_view(request): from invitations.adapters import get_invitations_adapter from invitations.signals import invite_accepted -from .utils import get_invitation -from .models import Invitation - class AcceptInvite(invitations_views.AcceptInvite): - # def get(self, *args, **kwargs): - # if invitations_settings.CONFIRM_INVITE_ON_GET: - # return self.post(*args, **kwargs) - # else: - # raise Http404() + "Overwrite handling of invitation link." def post(self, *args, **kwargs): - "Identical to base implementation, except where noted." + """ + Unfortunately, the whole method had to be copied. + Identical to base implementation, except where noted. + """ self.object = invitation = self.get_object() if invitations_settings.GONE_ON_ACCEPT_ERROR and ( @@ -487,20 +483,23 @@ def post(self, *args, **kwargs): return redirect(self.get_signup_redirect()) if not invitations_settings.ACCEPT_INVITE_AFTER_SIGNUP: + # Difference: Calling own function. accept_invitation( invitation=invitation, request=self.request, signal_sender=self.__class__, ) - # Difference to base: Saving key and not email. + # Difference: Saving key and not email. self.request.session["invitation_key"] = invitation.key return redirect(self.get_signup_redirect()) def accept_invitation(invitation, request, signal_sender): - # Difference to base: Not setting accepted to True, here. + "Copy of function from django-invitations. Identical, except where noted." + + # Difference: Not setting accepted to True, here. invite_accepted.send( sender=signal_sender, From 0f3d0dc36db27eba2dc8f36ba1106ca19c6226c8 Mon Sep 17 00:00:00 2001 From: Stefan Klein Date: Wed, 23 Aug 2023 12:31:01 +0200 Subject: [PATCH 16/31] [#246] refactor background color --- cpmonitor/static/css/main.css | 62 ++++++++++++------ cpmonitor/static/css/main.css.map | 2 +- cpmonitor/static/css/main.scss | 64 +++++++++++++------ .../templates/administration_checklist.html | 62 ++++++++---------- cpmonitor/templates/base.html | 24 +++++-- cpmonitor/templates/cap_checklist.html | 62 +++++++++--------- cpmonitor/templates/city.html | 23 +++---- cpmonitor/templates/datenschutz.html | 6 +- cpmonitor/templates/impressum.html | 6 +- cpmonitor/templates/index.html | 6 +- cpmonitor/templates/jetzt-spenden.html | 6 +- cpmonitor/templates/project.html | 10 +-- cpmonitor/templates/snippets/taskcard.html | 4 +- cpmonitor/templates/task.html | 6 +- cpmonitor/templates/taskgroup.html | 50 ++++++++------- cpmonitor/templates/ueber-uns.html | 6 +- 16 files changed, 227 insertions(+), 172 deletions(-) diff --git a/cpmonitor/static/css/main.css b/cpmonitor/static/css/main.css index 06e6debb..c6d95010 100644 --- a/cpmonitor/static/css/main.css +++ b/cpmonitor/static/css/main.css @@ -1,5 +1,5 @@ :root { - --tblr-body-bg: #fff9d5; + --tblr-body-bg: white; --tblr-font-weight-bold: 700; --tblr-primary: #ffc80c; --tblr-primary-rgb: 255, @@ -14,6 +14,10 @@ --lz-color-failed: black; } +.card { + --tblr-card-border-color: #777; +} + .progress { --tblr-progress-height: 0.85rem; } @@ -21,6 +25,10 @@ background-color: #011633; } +header.page-header { + margin-bottom: 1rem !important; +} + .lz-breadcrumbs-bar { padding-top: 20px; } @@ -30,25 +38,6 @@ color: var(--tblr-muted); } -.card-title { - font-size: 2rem; -} - -.card-details { - position: absolute; - background: rgba(1, 1, 1, 0.2); - border: 0; - bottom: 0; - right: 0; - padding: 3px 1px 1px 6px; - border-radius: 30px 0 0 0; -} -.card-details svg { - width: 18px; - height: 18px; - opacity: 1; -} - .lz-date-missed { color: red !important; } @@ -76,6 +65,14 @@ color: var(--lz-color-unknown); } +.lz-cards-background { + background: #f7efd2; + padding-top: 2rem !important; + padding-bottom: 2rem !important; + margin-top: 2rem !important; + margin-bottom: 2rem !important; +} + figure.lz-chart { height: 400px; display: flex; @@ -101,6 +98,21 @@ figure.lz-chart img { -webkit-line-clamp: 8; } +.lz-card-details { + position: absolute; + background: rgba(1, 1, 1, 0.2); + border: 0; + bottom: 0; + right: 0; + padding: 3px 1px 1px 6px; + border-radius: 30px 0 0 0; +} +.lz-card-details svg { + width: 18px; + height: 18px; + opacity: 1; +} + .lz-construction-banner { background: #ffc80c; font-weight: bold; @@ -108,4 +120,14 @@ figure.lz-chart img { padding-bottom: 3px; } +.lz-teaser { + font-weight: bold; +} + +.hr-text { + font-size: 0.8rem !important; + margin-top: 1rem; + margin-bottom: 1rem; +} + /*# sourceMappingURL=main.css.map */ diff --git a/cpmonitor/static/css/main.css.map b/cpmonitor/static/css/main.css.map index 340ad853..f9dcdc2a 100644 --- a/cpmonitor/static/css/main.css.map +++ b/cpmonitor/static/css/main.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAEA;EACE;EACA;EACA;EACA;AAAA;AAAA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;AAEA;EACE;;;AAIJ;EACE;;AAEA;AAAA;EAEE;EACA;;;AAIJ;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;;;AAIA;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;;AAKF;EACE;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;EACA;;AAEA;EACE;;AAGF;EACE;;;AAIJ;EACE,YAzHU;EA0HV;EACA;EACA","file":"main.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAEA;EACE;EACA;EACA;EACA;AAAA;AAAA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;;;AAIJ;EACE;;;AAGF;EACE;;AAEA;AAAA;EAEE;EACA;;;AAIJ;EACE;;;AAIA;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;;AAKF;EACE;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;EACA;;AAEA;EACE;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE,YArIU;EAsIV;EACA;EACA;;;AAGF;EACE;;;AAGF;EACI;EACA;EACA","file":"main.css"} \ No newline at end of file diff --git a/cpmonitor/static/css/main.scss b/cpmonitor/static/css/main.scss index 2b3ffd6b..2baf4313 100644 --- a/cpmonitor/static/css/main.scss +++ b/cpmonitor/static/css/main.scss @@ -1,7 +1,7 @@ $lz-yellow: #ffc80c; :root { - --tblr-body-bg: #fff9d5; + --tblr-body-bg: white; --tblr-font-weight-bold: 700; --tblr-primary: #{$lz-yellow}; --tblr-primary-rgb: #{red($lz-yellow)}, @@ -16,6 +16,10 @@ $lz-yellow: #ffc80c; --lz-color-failed: black; } +.card { + --tblr-card-border-color: #777; +} + .progress { --tblr-progress-height: 0.85rem; @@ -24,6 +28,10 @@ $lz-yellow: #ffc80c; } } +header.page-header { + margin-bottom: 1rem !important; +} + .lz-breadcrumbs-bar { padding-top: 20px; @@ -34,26 +42,6 @@ $lz-yellow: #ffc80c; } } -.card-title { - font-size: 2rem; -} - -.card-details { - position:absolute; - background:rgba(1,1,1,.2); - border:0; - bottom:0; - right:0; - padding: 3px 1px 1px 6px; - border-radius:30px 0 0 0; - - svg { - width: 18px; - height: 18px; - opacity: 1 - } -} - .lz-date-missed { color: red !important; } @@ -90,6 +78,14 @@ $lz-yellow: #ffc80c; } } +.lz-cards-background { + background: #f7efd2; + padding-top: 2rem !important; + padding-bottom: 2rem !important; + margin-top: 2rem !important; + margin-bottom: 2rem !important; +} + figure.lz-chart { height: 400px; display: flex; @@ -118,9 +114,35 @@ figure.lz-chart { } } +.lz-card-details { + position:absolute; + background:rgba(1,1,1,.2); + border:0; + bottom:0; + right:0; + padding: 3px 1px 1px 6px; + border-radius:30px 0 0 0; + + svg { + width: 18px; + height: 18px; + opacity: 1 + } +} + .lz-construction-banner { background: $lz-yellow; font-weight: bold; padding-top: 3px; padding-bottom: 3px; } + +.lz-teaser { + font-weight:bold; +} + +.hr-text { + font-size: 0.8rem !important; + margin-top: 1rem; + margin-bottom: 1rem; +} diff --git a/cpmonitor/templates/administration_checklist.html b/cpmonitor/templates/administration_checklist.html index b968b2dd..c403c53d 100644 --- a/cpmonitor/templates/administration_checklist.html +++ b/cpmonitor/templates/administration_checklist.html @@ -4,39 +4,33 @@ {% block title %} LocalZero Monitoring - {{ city.name }} {% endblock title %} -{% block content %} -
-
-

Nachhaltigkeitsarchitektur in der Verwaltung Checkliste

- {% if city.assessment_administration %} -
-
{{ city.assessment_administration | safe_markdown }}
-
- {% endif %} -
-
- - - {% for key, value in administration_checklist.items %} - - - - - {% endfor %} - -
{{ key }} - {% if value %} - - - - {% else %} - - - - {% endif %} -
-
-
+{% block text-content %} +

Nachhaltigkeitsarchitektur in der Verwaltung

+ {% if city.assessment_administration %}

{{ city.assessment_administration | safe_markdown }}

{% endif %} +{% endblock text-content %} +{% block cards %} +
+
+ + + {% for key, value in administration_checklist.items %} + + + + + {% endfor %} + +
{{ key }} + {% if value %} + + + + {% else %} + + + + {% endif %} +
-{% endblock content %} +{% endblock cards %} diff --git a/cpmonitor/templates/base.html b/cpmonitor/templates/base.html index e718d146..3a9779d9 100644 --- a/cpmonitor/templates/base.html +++ b/cpmonitor/templates/base.html @@ -85,12 +85,24 @@ {% endblock breadcrumbs %}
-
-
- {% block content %} - {% endblock content %} -
-
+ {% block content %} + {% block text-content-container %} +
+ {% block text-content %} + {% endblock text-content %} +
+ {% endblock text-content-container %} + {% block cards-container %} +
+
+
+ {% block cards %} + {% endblock cards %} +
+
+
+ {% endblock cards-container %} + {% endblock content %}
diff --git a/cpmonitor/templates/cap_checklist.html b/cpmonitor/templates/cap_checklist.html index c7bc50b2..ead2ee6c 100644 --- a/cpmonitor/templates/cap_checklist.html +++ b/cpmonitor/templates/cap_checklist.html @@ -4,39 +4,35 @@ {% block title %} LocalZero Monitoring - {{ city.name }} {% endblock title %} -{% block content %} -
-
-

Klimaaktionsplan (KAP) Checkliste

- {% if city.assessment_action_plan %} -
-
{{ city.assessment_action_plan | safe_markdown }}
-
- {% endif %} -
-
- - - {% for key, value in cap_checklist.items %} - - - - - {% endfor %} - -
{{ key }} - {% if value %} - - - - {% else %} - - - - {% endif %} -
-
+{% block text-content %} +

Klimaaktionsplan (KAP)

+ {% if city.assessment_action_plan %}

{{ city.assessment_action_plan | safe_markdown }}

{% endif %} +{% endblock text-content %} +{% block cards %} +
+
+
+ + + {% for key, value in cap_checklist.items %} + + + + + {% endfor %} + +
{{ key }} + {% if value %} + + + + {% else %} + + + + {% endif %} +
-{% endblock content %} +{% endblock cards %} diff --git a/cpmonitor/templates/city.html b/cpmonitor/templates/city.html index 5643d3dc..c1c23180 100644 --- a/cpmonitor/templates/city.html +++ b/cpmonitor/templates/city.html @@ -4,7 +4,7 @@ {% block title %} {{ city.name }} - Monitoring LocalZero {% endblock title %} -{% block content %} +{% block text-content %}

Monitoring für {{ city.name }}

@@ -15,6 +15,10 @@

Monitoring für {{ city.name }}

{% endif %}
+ {% if city.teaser %}

{{ city.teaser }}

{% endif %} +

{{ city.description | safe_markdown }}

+{% endblock text-content %} +{% block cards %}
{% if days_gone %}
@@ -69,7 +73,7 @@

Monitoring für {{ city.name }}

{% endfor %}
-
+
@@ -100,7 +104,7 @@

Monitoring für {{ city.name }}

-
+
@@ -131,7 +135,7 @@

Monitoring für {{ city.name }}

-
+
@@ -140,13 +144,6 @@

Monitoring für {{ city.name }}

{% endif %}
-
-
-
- {% if city.teaser %}{{ city.teaser }}{% endif %} - {{ city.description | safe_markdown }} -
-
{% if tasks %}
{% for task in tasks %} @@ -177,6 +174,4 @@

Monitoring für {{ city.name }}

{% endfor %}
{% endif %} -
-
-{% endblock content %} +{% endblock cards %} diff --git a/cpmonitor/templates/datenschutz.html b/cpmonitor/templates/datenschutz.html index 6079f731..bb7cdd54 100644 --- a/cpmonitor/templates/datenschutz.html +++ b/cpmonitor/templates/datenschutz.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} +{% block text-content %} @@ -12,4 +12,6 @@

Datenschutzerklärung

Es gelten die Datenschutzbestimmungen gemäß https://www.germanzero.de/datenschutz.

-{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} diff --git a/cpmonitor/templates/impressum.html b/cpmonitor/templates/impressum.html index ca961740..c2c485b5 100644 --- a/cpmonitor/templates/impressum.html +++ b/cpmonitor/templates/impressum.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} +{% block text-content %} @@ -65,4 +65,6 @@

Urheberrecht

auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.

-{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} diff --git a/cpmonitor/templates/index.html b/cpmonitor/templates/index.html index 72125a6f..672f0eaa 100644 --- a/cpmonitor/templates/index.html +++ b/cpmonitor/templates/index.html @@ -1,13 +1,15 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} +{% block text-content %}

Willkommen

LocalZero Monitoring ist eine Initiative von GermanZero, um mehr Transparenz zum Fortschritt der Klimaneutralität deutscher Kommunen zu schaffen. Der Fortschritt wird von ehrenamtlichen Lokalteams in den jeweiligen Kommunen regelmäßig aktualisiert.

+{% endblock text-content %} +{% block cards %}
{% for city in cities %}
@@ -22,4 +24,4 @@

Willkommen

{% endfor %}
-{% endblock content %} +{% endblock cards %} diff --git a/cpmonitor/templates/jetzt-spenden.html b/cpmonitor/templates/jetzt-spenden.html index 3c98acf4..9f7d67a9 100644 --- a/cpmonitor/templates/jetzt-spenden.html +++ b/cpmonitor/templates/jetzt-spenden.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} +{% block text-content %} @@ -9,4 +9,6 @@

Spende jetzt!

Du kannst einfach online spenden oder eine Überweisung tätigen. Wir freuen uns besonders über Dauerspenden, weil wir damit in die Zukunft planen können. Jetzt zur Spendenseite gehen. -{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} diff --git a/cpmonitor/templates/project.html b/cpmonitor/templates/project.html index f8e5da94..bbf21f0e 100644 --- a/cpmonitor/templates/project.html +++ b/cpmonitor/templates/project.html @@ -1,10 +1,8 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} - +{% block text-content %} +

LocalZero Monitoring

Städte und Kommunen leisten einen wichtigen Beitrag zur Klimaneutralität.

LocalZero Monitoring ist eine Initiative von Localzero, um mehr Transparenz @@ -14,4 +12,6 @@

LocalZero Monitoring

Du findest deine Kommune nicht? Dann finde Mitstreiter:innen und gründe ein neues Team bei dir vor Ort!

-{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} diff --git a/cpmonitor/templates/snippets/taskcard.html b/cpmonitor/templates/snippets/taskcard.html index e18cd981..1ac6b2ee 100644 --- a/cpmonitor/templates/snippets/taskcard.html +++ b/cpmonitor/templates/snippets/taskcard.html @@ -3,10 +3,10 @@ class="card card-link card-link-popup">
 
-
{{ task.title }}
+
{{ task.title }}
{{ task.teaser }}
-
+
diff --git a/cpmonitor/templates/task.html b/cpmonitor/templates/task.html index ef6fd745..8124f594 100644 --- a/cpmonitor/templates/task.html +++ b/cpmonitor/templates/task.html @@ -3,7 +3,7 @@ {% block title %} LocalZero Monitoring - {{ city.name }} {% endblock title %} -{% block content %} +{% block text-content %}
@@ -69,4 +69,6 @@

Bewertung der geplanten Maßnahme

{% endif %}
-{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} diff --git a/cpmonitor/templates/taskgroup.html b/cpmonitor/templates/taskgroup.html index 1106f202..d51ccb2e 100644 --- a/cpmonitor/templates/taskgroup.html +++ b/cpmonitor/templates/taskgroup.html @@ -4,29 +4,26 @@ {% block title %} LocalZero Monitoring - {{ city.name }} {% endblock title %} -{% block content %} - {% if not node and city.assessment_status %} -
-
{{ city.assessment_status | safe_markdown }}
-
+{% block text-content %} + {% if not node %} +

Maßnahmen

+ {% if city.assessment_status %} +

+ {{ city.assessment_status | safe_markdown }} + {% endif %} +

{% endif %} {% if node %} -
- -
Handlungsfeld
-

{{ node.title }}

-
-
-
-
- {% if node.teaser %}{{ node.teaser }}{% endif %} -
- {{ node.description | safe_markdown | default:"Dieses Handlungsfeld hat keine Beschreibung" }} -
-
-
-
+ +
Handlungsfeld
+

{{ node.title }}

+ {% if node.teaser %}

{{ node.teaser }}

{% endif %} +

+ {{ node.description | safe_markdown | default:"Dieses Handlungsfeld hat keine Beschreibung" }} +

{% endif %} +{% endblock text-content %} +{% block cards %} {% if tasks %}
{% for task in tasks %} @@ -35,14 +32,19 @@

{{ node.title }}

{% endif %} {% if groups %} -

Weitere Handlungsfelder

+ {% if node or tasks %} +
+ {% if node %}Weitere{% endif %} + Handlungsfelder +
+ {% endif %}
{% for group in groups %}
-
{{ group.title }}
+
{{ group.title }}
{{ group.subtasks_count }} Maßnahmen
{% for value, label, name in group.status_proportions %} @@ -57,7 +59,7 @@

Weitere Handlungsfelder

{% endfor %}
-
+
@@ -67,4 +69,4 @@

Weitere Handlungsfelder

{% endfor %}
{% endif %} -{% endblock content %} +{% endblock cards %} diff --git a/cpmonitor/templates/ueber-uns.html b/cpmonitor/templates/ueber-uns.html index ca20d801..4e5b5e70 100644 --- a/cpmonitor/templates/ueber-uns.html +++ b/cpmonitor/templates/ueber-uns.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load martortags %} -{% block content %} +{% block text-content %} @@ -14,4 +14,6 @@

Über uns

Alles Details erfahren Sie unter LocalZero.net.

-{% endblock content %} +{% endblock text-content %} +{% block cards-container %} +{% endblock cards-container %} From 76c6c444ba3f894564b13b5194b5ef571250b7f8 Mon Sep 17 00:00:00 2001 From: Stefan Klein Date: Wed, 23 Aug 2023 13:03:28 +0200 Subject: [PATCH 17/31] [#246] fix tests --- e2e_tests/database/test_database.json | 2 +- e2e_tests/test_administration_checklist_page.py | 2 +- e2e_tests/test_cap_checklist_page.py | 2 +- e2e_tests/test_task_page.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e_tests/database/test_database.json b/e2e_tests/database/test_database.json index 3b79bfe9..e2b98fc4 100644 --- a/e2e_tests/database/test_database.json +++ b/e2e_tests/database/test_database.json @@ -428,7 +428,7 @@ "co2e_budget": 0, "assessment_administration": "Wie bewertet ihr die **Nachhaltigkeitsarchitektur der Verwaltung**? Dieser Text fasst die wichtigsten Punkte zusammen.", "assessment_action_plan": "Hier soll die Bewertung des **Klimaaktionsplans** stehen. Was haltet ihr von dem Plan?", - "assessment_status": "### Wie sieht es aus? \r\n\r\nEine einleitende Übersicht in die Bewertung des Umsetzungsstandes.\r\nHält die Kommune sich im Wesentlichen an ihren eigenen Plan?", + "assessment_status": "Eine einleitende Übersicht in die Bewertung des Umsetzungsstandes.\r\nHält die Kommune sich im Wesentlichen an ihren eigenen Plan?", "last_update": "2023-06-16", "contact_name": "", "contact_email": "", diff --git a/e2e_tests/test_administration_checklist_page.py b/e2e_tests/test_administration_checklist_page.py index a995e699..51622a3d 100644 --- a/e2e_tests/test_administration_checklist_page.py +++ b/e2e_tests/test_administration_checklist_page.py @@ -8,7 +8,7 @@ def test_should_show_the_administration_assessment_and_the_checklist( expect( page.locator( - ".card", + "p", has_text="Wie bewertet ihr die Nachhaltigkeitsarchitektur der Verwaltung? Dieser Text fasst die wichtigsten Punkte zusammen.", ) ).to_be_visible() diff --git a/e2e_tests/test_cap_checklist_page.py b/e2e_tests/test_cap_checklist_page.py index 0fe68e5f..9bd4ecee 100644 --- a/e2e_tests/test_cap_checklist_page.py +++ b/e2e_tests/test_cap_checklist_page.py @@ -6,7 +6,7 @@ def test_should_show_the_cap_assessment_and_the_checklist(live_server, page: Pag expect( page.locator( - ".card", + "p", has_text="Hier soll die Bewertung des Klimaaktionsplans stehen. Was haltet ihr von dem Plan?", ) ).to_be_visible() diff --git a/e2e_tests/test_task_page.py b/e2e_tests/test_task_page.py index b89664e7..30183787 100644 --- a/e2e_tests/test_task_page.py +++ b/e2e_tests/test_task_page.py @@ -12,8 +12,8 @@ def test_should_show_the_task_assessment(live_server, page: Page): expect( page.locator( - ".card", - has_text="Wie sieht es aus? Eine einleitende Übersicht in die Bewertung des Umsetzungsstandes. Hält die Kommune sich im Wesentlichen an ihren eigenen Plan?", + "p", + has_text="Eine einleitende Übersicht in die Bewertung des Umsetzungsstandes. Hält die Kommune sich im Wesentlichen an ihren eigenen Plan?", ) ).to_be_visible() From 5436f4fb6a6433e066e73c407643ed2384eb79ca Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Thu, 24 Aug 2023 20:58:44 +0200 Subject: [PATCH 18/31] [#23] Fixes after review. --- config/settings/base.py | 4 +-- cpmonitor/models.py | 5 +++- cpmonitor/rules.py | 8 +++-- cpmonitor/templates/overrides/README.md | 1 + cpmonitor/views.py | 39 +++++++------------------ 5 files changed, 23 insertions(+), 34 deletions(-) create mode 100644 cpmonitor/templates/overrides/README.md diff --git a/config/settings/base.py b/config/settings/base.py index 486edb39..83f836da 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -51,7 +51,7 @@ def get_env(var: str) -> str: "allauth", "allauth.account", "allauth.socialaccount", - # "invitations", We do not use invitations.Invitatoin and therefore do not want its migrations. + # "invitations", We do not use invitations.Invitation and therefore do not want its migrations. "cpmonitor.apps.CpmonitorConfig", ] @@ -194,6 +194,6 @@ def get_env(var: str) -> str: # would try to access the email field of an invitation and fail on the custom model. INVITATIONS_ACCEPT_INVITE_AFTER_SIGNUP = False -# django core configuration used by django-invitations +# django core configuration used by django-invitations, django-allauth LOGIN_URL = "/admin/login/" LOGIN_REDIRECT_URL = "/admin/" diff --git a/cpmonitor/models.py b/cpmonitor/models.py index aee67c68..a40488c1 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -797,7 +797,10 @@ def create(cls, email, inviter=None, **kwargs): ) def get_invite_url(self, request): - "Build correct URL to be sent to invited users. Extracted from django-invitations." + """ + Build correct URL to be sent to invited users. + Extracted from django-invitations, which generates it for the email and forgets it. + """ if not self.key: return None url = reverse(invitations_app_settings.CONFIRMATION_URL_NAME, args=[self.key]) diff --git a/cpmonitor/rules.py b/cpmonitor/rules.py index 7e3ba132..b7e2371e 100644 --- a/cpmonitor/rules.py +++ b/cpmonitor/rules.py @@ -12,15 +12,19 @@ CityOrNoneType = CityType | NoneType +_always_false_q: Q = Q(pk__in=[]) +_always_true_q: Q = ~_always_false_q + + def is_allowed_to_edit_q(user: User, model: Model) -> Q: """ Return a Q object to filter objects to which a user has access. This may be used with `QuerySet.filter`, but also with `limit_choices_to`. """ if not user.is_staff or not user.is_active: - return Q(pk__in=[]) # Always false -> Empty QuerySet + return _always_false_q if user.is_superuser: - return ~Q(pk__in=[]) # Always true -> All objects + return _always_true_q if model == City: return Q(city_editors=user) | Q(city_admins=user) else: diff --git a/cpmonitor/templates/overrides/README.md b/cpmonitor/templates/overrides/README.md new file mode 100644 index 00000000..7778e78d --- /dev/null +++ b/cpmonitor/templates/overrides/README.md @@ -0,0 +1 @@ +These templates override default templates from external django apps. \ No newline at end of file diff --git a/cpmonitor/views.py b/cpmonitor/views.py index 14ec5d12..217e7894 100644 --- a/cpmonitor/views.py +++ b/cpmonitor/views.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required +from django.contrib import messages from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.http import Http404 @@ -14,6 +15,12 @@ from django.shortcuts import render from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from django.shortcuts import redirect +from invitations import views as invitations_views +from invitations.app_settings import app_settings as invitations_settings +from invitations.adapters import get_invitations_adapter +from invitations.signals import invite_accepted +from invitations.views import accept_invitation from martor.utils import LazyEncoder from .models import ( @@ -432,14 +439,6 @@ def markdown_uploader_view(request): return HttpResponse(data, content_type="application/json") -from django.contrib import messages -from django.shortcuts import redirect -from invitations import views as invitations_views -from invitations.app_settings import app_settings as invitations_settings -from invitations.adapters import get_invitations_adapter -from invitations.signals import invite_accepted - - class AcceptInvite(invitations_views.AcceptInvite): "Overwrite handling of invitation link." @@ -483,34 +482,16 @@ def post(self, *args, **kwargs): return redirect(self.get_signup_redirect()) if not invitations_settings.ACCEPT_INVITE_AFTER_SIGNUP: - # Difference: Calling own function. accept_invitation( invitation=invitation, request=self.request, signal_sender=self.__class__, ) + # Difference: Revert accepted to allow reuse of link. + invitation.accepted = False + invitation.save() # Difference: Saving key and not email. self.request.session["invitation_key"] = invitation.key return redirect(self.get_signup_redirect()) - - -def accept_invitation(invitation, request, signal_sender): - "Copy of function from django-invitations. Identical, except where noted." - - # Difference: Not setting accepted to True, here. - - invite_accepted.send( - sender=signal_sender, - email=invitation.email, - request=request, - invitation=invitation, - ) - - get_invitations_adapter().add_message( - request, - messages.SUCCESS, - "invitations/messages/invite_accepted.txt", - {"email": invitation.email}, - ) From da1ec9098324bdbd83d5e0a09915573578b66245 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Fri, 25 Aug 2023 09:14:44 +0200 Subject: [PATCH 19/31] [#23] Remove unneeded workaround for `Invitation.inviter`. --- cpmonitor/migrations/0027_invitation.py | 1 - cpmonitor/models.py | 9 --------- 2 files changed, 10 deletions(-) diff --git a/cpmonitor/migrations/0027_invitation.py b/cpmonitor/migrations/0027_invitation.py index e39f5daf..fa37ebeb 100644 --- a/cpmonitor/migrations/0027_invitation.py +++ b/cpmonitor/migrations/0027_invitation.py @@ -95,7 +95,6 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="invitations", to=settings.AUTH_USER_MODEL, ), ), diff --git a/cpmonitor/models.py b/cpmonitor/models.py index a40488c1..08251251 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -754,15 +754,6 @@ class Meta: verbose_name="Erstellungszeitpunkt", default=timezone.now ) - # Workaround for https://github.com/jazzband/django-invitations/issues/203: Add custom related_name - inviter = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.CASCADE, - related_name="invitations", - ) - @property def email(self): "Satisfy expected interface." From 9e9a40d13d19c114b2108fcc744cfa9c9596eb71 Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Fri, 25 Aug 2023 14:22:42 +0200 Subject: [PATCH 20/31] [#23] Additional help text for invitation links. --- cpmonitor/admin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 306ec058..4efc6435 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -92,7 +92,14 @@ def invitation_link(self, invitation: Invitation): url = invitation.get_invite_url(self.get_request()) role = str(invitation) return format_html( - '{}:
Diesen Link bitte nur an Menschen schicken, die mit dieser Rolle mitarbeiten sollen:
{}', + """ +

Diesen Link bitte nur an Menschen schicken, die mit dieser Rolle mitarbeiten sollen:

+

{}

+

Der Einladungslink ist so lange gültig, bis er gelöscht wird.

+

Nach dem Löschen wird automatisch ein neuer Link erzeugt und hier angezeigt, wenn die + Stadt das nächste Mal gespeichert wird.

+

Sollte er nicht gleich sichtbar sein, bitte ein weiteres Mal speichern.

+ """, role, url, url, From deb60ff690ce268fd11b9c720eb50ad7089e44ee Mon Sep 17 00:00:00 2001 From: Mathias de Riese Date: Fri, 25 Aug 2023 14:29:28 +0200 Subject: [PATCH 21/31] [#23] Draft mode cannot be changed by city editors. --- cpmonitor/admin.py | 1 + cpmonitor/tests/permissions_test.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/cpmonitor/admin.py b/cpmonitor/admin.py index 4efc6435..dc9b2932 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -136,6 +136,7 @@ def get_readonly_fields(self, request: HttpRequest, obj=None) -> Sequence[str]: user = request.user result = [] if not rules.is_allowed_to_change_city_users(user, obj): + result.append("draft_mode") result.append("city_editors") result.append("city_admins") return result diff --git a/cpmonitor/tests/permissions_test.py b/cpmonitor/tests/permissions_test.py index b4b638b8..2498bb99 100644 --- a/cpmonitor/tests/permissions_test.py +++ b/cpmonitor/tests/permissions_test.py @@ -181,8 +181,10 @@ def test_city_editor_should_not_be_allowed_to_delete_add_and_change_editors_and_ adminform = response.context["adminform"] fields = _fields_from_form(adminform) + assert "draft_mode" in fields assert "city_editors" in fields assert "city_admins" in fields + assert "draft_mode" in adminform.readonly_fields assert "city_editors" in adminform.readonly_fields assert "city_admins" in adminform.readonly_fields @@ -201,8 +203,10 @@ def test_city_admin_should_not_be_allowed_to_delete_add_but_to_change_editors_an adminform = response.context["adminform"] fields = _fields_from_form(adminform) + assert "draft_mode" in fields assert "city_editors" in fields assert "city_admins" in fields + assert not "draft_mode" in adminform.readonly_fields assert not "city_editors" in adminform.readonly_fields assert not "city_admins" in adminform.readonly_fields @@ -221,8 +225,10 @@ def test_site_admin_should_be_allowed_to_delete_add_and_to_change_editors_and_ad adminform = response.context["adminform"] fields = _fields_from_form(adminform) + assert "draft_mode" in fields assert "city_editors" in fields assert "city_admins" in fields + assert not "draft_mode" in adminform.readonly_fields assert not "city_editors" in adminform.readonly_fields assert not "city_admins" in adminform.readonly_fields @@ -831,6 +837,7 @@ def test_can_register_with_city_editor_registration_link( response = client.get("/admin/cpmonitor/city/1/change/") adminform = response.context["adminform"] + assert "draft_mode" in adminform.readonly_fields assert "city_editors" in adminform.readonly_fields assert "city_admins" in adminform.readonly_fields assertNotContains(response, city_admin_key) @@ -848,6 +855,7 @@ def test_can_register_with_city_admin_registration_link( response = client.get("/admin/cpmonitor/city/1/change/") adminform = response.context["adminform"] + assert not "draft_mode" in adminform.readonly_fields assert not "city_editors" in adminform.readonly_fields assert not "city_admins" in adminform.readonly_fields assertContains(response, city_admin_key) From 46bff1cb9438a60f0ce999785873ea9fc1200d40 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Mon, 14 Aug 2023 16:49:14 +0200 Subject: [PATCH 22/31] [#255] Try to disclose less server information (removes the version info from the server header) --- config/nginx/conf.d/nginx.conf | 2 ++ docker/reverseproxy/conf.d/nginx.conf | 1 + 2 files changed, 3 insertions(+) diff --git a/config/nginx/conf.d/nginx.conf b/config/nginx/conf.d/nginx.conf index 0a95f847..9de10126 100644 --- a/config/nginx/conf.d/nginx.conf +++ b/config/nginx/conf.d/nginx.conf @@ -1,5 +1,7 @@ # nginx config for deployment on server, including SSL/TLS setup +server_tokens off; + server { listen 8080; diff --git a/docker/reverseproxy/conf.d/nginx.conf b/docker/reverseproxy/conf.d/nginx.conf index 2cf0a17e..054b7111 100644 --- a/docker/reverseproxy/conf.d/nginx.conf +++ b/docker/reverseproxy/conf.d/nginx.conf @@ -1,4 +1,5 @@ server_names_hash_bucket_size 64; +server_tokens off; server { listen 443 ssl; From d54036002ad353cba9f8cf2a3933017faea0e850 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 18 Aug 2023 16:13:10 +0200 Subject: [PATCH 23/31] [#255] add header for Content-Security-Policy --- config/nginx/conf.d/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/config/nginx/conf.d/nginx.conf b/config/nginx/conf.d/nginx.conf index 9de10126..cae3ea25 100644 --- a/config/nginx/conf.d/nginx.conf +++ b/config/nginx/conf.d/nginx.conf @@ -8,6 +8,7 @@ server { server_name monitoring.localzero.net monitoring-test.localzero.net; client_max_body_size 100m; + add_header Content-Security-Policy "default-src 'self'"; location / { # pass requests for dynamic content to gunicorn From 3e61b306732369e71c3085f8fa57b7082224d5e5 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 18 Aug 2023 18:32:52 +0200 Subject: [PATCH 24/31] [#255] add HSTS header so that people access our site over https --- config/nginx/conf.d/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/config/nginx/conf.d/nginx.conf b/config/nginx/conf.d/nginx.conf index cae3ea25..d152be50 100644 --- a/config/nginx/conf.d/nginx.conf +++ b/config/nginx/conf.d/nginx.conf @@ -9,6 +9,7 @@ server { client_max_body_size 100m; add_header Content-Security-Policy "default-src 'self'"; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; location / { # pass requests for dynamic content to gunicorn From 44ff62c812fc7a135f102ca9a852c6d68ade2c50 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Sat, 19 Aug 2023 11:55:04 +0200 Subject: [PATCH 25/31] [#255] We want that our users get the newest information --- config/nginx/conf.d/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/config/nginx/conf.d/nginx.conf b/config/nginx/conf.d/nginx.conf index d152be50..f287ed58 100644 --- a/config/nginx/conf.d/nginx.conf +++ b/config/nginx/conf.d/nginx.conf @@ -10,6 +10,7 @@ server { client_max_body_size 100m; add_header Content-Security-Policy "default-src 'self'"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; + add_header Cache-Control "no-store"; location / { # pass requests for dynamic content to gunicorn From 4312ca93486537cbd0ea314497ffbfdf8242cef0 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Tue, 22 Aug 2023 18:29:38 +0200 Subject: [PATCH 26/31] [#255] add configuration for ssl header so that Django's CSRF protection etc. works see https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-SECURE_PROXY_SSL_HEADER --- config/settings/container.py | 1 + docker/reverseproxy/conf.d/nginx.conf | 2 ++ 2 files changed, 3 insertions(+) diff --git a/config/settings/container.py b/config/settings/container.py index 1d98ebcc..a755dc81 100644 --- a/config/settings/container.py +++ b/config/settings/container.py @@ -11,3 +11,4 @@ CSRF_TRUSTED_ORIGINS = get_env("DJANGO_CSRF_TRUSTED_ORIGINS").split(",") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = get_env("DJANGO_DEBUG") == "True" +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/docker/reverseproxy/conf.d/nginx.conf b/docker/reverseproxy/conf.d/nginx.conf index 054b7111..6dccbb26 100644 --- a/docker/reverseproxy/conf.d/nginx.conf +++ b/docker/reverseproxy/conf.d/nginx.conf @@ -14,6 +14,7 @@ server { proxy_pass http://$nginx:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } include /etc/nginx/conf.d/ssl.conf; @@ -32,6 +33,7 @@ server { proxy_pass http://$nginx:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } include /etc/nginx/conf.d/ssl.conf; From 310e28f06276ae293cfcdf4b9a41bef0285807d7 Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 25 Aug 2023 10:39:58 +0200 Subject: [PATCH 27/31] [#255] only sent cookies over https connections --- config/settings/container.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/settings/container.py b/config/settings/container.py index a755dc81..a2a73e46 100644 --- a/config/settings/container.py +++ b/config/settings/container.py @@ -12,3 +12,5 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = get_env("DJANGO_DEBUG") == "True" SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True From 32dff34c3a8734947f26f677aded02eb7977e36b Mon Sep 17 00:00:00 2001 From: Caroline Fischer Date: Fri, 25 Aug 2023 14:41:46 +0200 Subject: [PATCH 28/31] [#255] Don't use inline javascript to prevent cross-site scripting attacks the change was needed as the Content-Security_policy header blocked the inline javascript --- cpmonitor/static/js/localzero.js | 3 +++ cpmonitor/templates/base.html | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cpmonitor/static/js/localzero.js b/cpmonitor/static/js/localzero.js index c9049c3a..f8d6b7f9 100644 --- a/cpmonitor/static/js/localzero.js +++ b/cpmonitor/static/js/localzero.js @@ -1,3 +1,6 @@ +document.getElementById("nav-burger").addEventListener("click", toggleMobile); +document.getElementById("menu-item-toggleable").addEventListener("click", toggleSubmenu); + function toggleMobile() { document.getElementById('site-menu').classList.toggle('shownow'); } diff --git a/cpmonitor/templates/base.html b/cpmonitor/templates/base.html index e718d146..514b945a 100644 --- a/cpmonitor/templates/base.html +++ b/cpmonitor/templates/base.html @@ -15,7 +15,6 @@ - @@ -42,7 +41,7 @@ -
-