diff --git a/README.md b/README.md index 45d4cffd..7b86fb7f 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 @@ -142,11 +142,17 @@ 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.) +The arguments `-e contenttypes -e auth.Permission -e admin.LogEntry -e sessions` exclude tables which are pre-filled +by django or during usage by django and 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.) ```python @@ -241,7 +247,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: @@ -251,10 +257,10 @@ 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 +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 ``` @@ -371,7 +377,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}* @@ -415,7 +421,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 +471,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 @@ -478,13 +484,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, @@ -495,6 +504,26 @@ 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. + +#### 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 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 +``` +#### 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. diff --git a/config/nginx/conf.d/nginx.conf b/config/nginx/conf.d/nginx.conf index 0a95f847..f287ed58 100644 --- a/config/nginx/conf.d/nginx.conf +++ b/config/nginx/conf.d/nginx.conf @@ -1,11 +1,16 @@ # nginx config for deployment on server, including SSL/TLS setup +server_tokens off; + server { listen 8080; server_name monitoring.localzero.net monitoring-test.localzero.net; 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 diff --git a/config/settings/base.py b/config/settings/base.py index c73519f9..70172062 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -47,6 +47,11 @@ def get_env(var: str) -> str: "django.contrib.staticfiles", "treebeard", "martor", + "rules.apps.AutodiscoverRulesConfig", + "allauth", + "allauth.account", + "allauth.socialaccount", + # "invitations", We do not use invitations.Invitation and therefore do not want its migrations. "cpmonitor.apps.CpmonitorConfig", ] @@ -60,12 +65,17 @@ 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 = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "cpmonitor" / "templates" / "overrides"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -157,3 +167,34 @@ def get_env(var: str) -> str: MARTOR_UPLOAD_PATH = "uploads/" MARTOR_UPLOAD_URL = "/api/uploader/" MAX_IMAGE_UPLOAD_SIZE = 104857600 # 100 MB + +# django-allauth configuration: +# https://django-allauth.readthedocs.io/en/latest/configuration.html +# Most customization is done in the adapter: +ACCOUNT_ADAPTER = "cpmonitor.adapters.AllauthInvitationsAdapter" +# django-allauth needs allauth.socialaccount to really work, but we don't use its OAuth parts +SOCIALACCOUNT_PROVIDERS = {} +ACCOUNT_EMAIL_VERIFICATION = "none" # Would need a working email config. + +# django-invitations configuration: +# 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, django-allauth +LOGIN_URL = "/admin/login/" +LOGIN_REDIRECT_URL = "/admin/" diff --git a/config/settings/container.py b/config/settings/container.py index 1d98ebcc..a2a73e46 100644 --- a/config/settings/container.py +++ b/config/settings/container.py @@ -11,3 +11,6 @@ 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") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True diff --git a/cpmonitor/adapters.py b/cpmonitor/adapters.py new file mode 100644 index 00000000..7519e2b9 --- /dev/null +++ b/cpmonitor/adapters.py @@ -0,0 +1,46 @@ +from allauth.account.adapter import DefaultAccountAdapter +from invitations.app_settings import app_settings + +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: + return False + else: + return True + + def save_user(self, request, user, form, commit=True): + """ + 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) + if not invitation: + self.add_error( + None, + "Die Registrierung ist nur möglich über einen gültigen Einladungslink.", + ) + 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 8517f051..dc9b2932 100644 --- a/cpmonitor/admin.py +++ b/cpmonitor/admin.py @@ -1,15 +1,27 @@ -from django.contrib import admin +from collections.abc import Sequence +from django.contrib import admin, messages 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 .models import Chart, City, Task, CapChecklist, AdministrationChecklist, LocalGroup +from rules.contrib.admin import ObjectPermissionsModelAdminMixin + +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.""" @@ -37,7 +49,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 +59,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,16 +78,48 @@ class LocalGroupInline(admin.StackedInline): } -class CityAdmin(admin.ModelAdmin): +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="Link") + 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:

+

{}

+

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, + ) + + +class CityAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): + # ------ change list page ------ list_display = ("zipcode", "name", "teaser", "edit_tasks") list_display_links = ("name",) ordering = ("name",) search_fields = ["zipcode", "name"] - formfield_overrides = { - models.CharField: {"widget": TextInput(attrs={"size": "170"})}, - models.TextField: {"widget": AdminMartorWidget}, - } + 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: + return qs + return qs.filter(rules.is_allowed_to_edit_q(user, City)) @admin.display(description="") def edit_tasks(self, city: City): @@ -81,11 +127,31 @@ def edit_tasks(self, city: 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"] + + 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 + + formfield_overrides = { + models.CharField: {"widget": TextInput(attrs={"size": "170"})}, + models.TextField: {"widget": AdminMartorWidget}, + } + inlines = [ ChartInline, LocalGroupInline, CapChecklistInline, AdministrationChecklistInline, + InvitationInline, ] @@ -164,24 +230,32 @@ def clean(self): return super().clean() -class TaskAdmin(TreeAdmin): - # ------ change list page ------ - change_list_template = "admin/task_changelist.html" +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. + """ - @admin.display(description="Struktur") - def structure(self, task: Task): - """Additional read-only field showing the tree structure.""" + def field_choices(self, field, request, model_admin): + "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), + limit_choices_to=rules.is_allowed_to_edit_q(request.user, City), + ) - 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)) + def has_output(self): + "Show even a single possibility. Otherwise, the tasks will not be filtered." + return len(self.lookup_choices) > 0 - return add_parents(task, task.title) - @admin.display(description="Public page") +class TaskAdmin(ObjectPermissionsModelAdminMixin, TreeAdmin): + # ------ change list page ------ + change_list_template = "admin/task_changelist.html" + + @admin.display(description="Öffentliche Seite") def slug_link(self, task: Task): """Additional link to the public page and also showing the slugs.""" url = reverse( @@ -189,23 +263,27 @@ 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",) + 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" # ------ add and change task page ------ + save_on_top = True + fields = ( "city", "draft_mode", @@ -238,6 +316,14 @@ def get_readonly_fields(self, request, obj=None): models.TextField: {"widget": AdminMartorWidget}, } + 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) + ) + 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) @@ -245,6 +331,18 @@ 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): + "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) + 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/fixtures/permissions.json b/cpmonitor/fixtures/permissions.json new file mode 100644 index 00000000..1a9845e6 --- /dev/null +++ b/cpmonitor/fixtures/permissions.json @@ -0,0 +1,1071 @@ +[ +{ + "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": [], + "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": [], + "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": "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" + } +}, +{ + "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/migrations/0026_city_city_admins_city_city_editors.py b/cpmonitor/migrations/0026_city_city_admins_city_city_editors.py new file mode 100644 index 00000000..dad8c4b4 --- /dev/null +++ b/cpmonitor/migrations/0026_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", "0025_alter_task_responsible_organ_and_more"), + ] + + 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/migrations/0027_invitation.py b/cpmonitor/migrations/0027_invitation.py new file mode 100644 index 00000000..43e754a2 --- /dev/null +++ b/cpmonitor/migrations/0027_invitation.py @@ -0,0 +1,108 @@ +# 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 +from django.utils.crypto import get_random_string + + +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(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() + 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") + invitation_manager = Invitation._default_manager + db_alias = schema_editor.connection.alias + for city in City.objects.using(db_alias).all(): + 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", "0026_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, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Einladungslink", + "verbose_name_plural": "Einladungslinks", + }, + ), + migrations.RunPython(add_invitations, migrations.RunPython.noop), + ] diff --git a/cpmonitor/models.py b/cpmonitor/models.py index 4385945c..81ac54de 100644 --- a/cpmonitor/models.py +++ b/cpmonitor/models.py @@ -1,12 +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 +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 @@ -35,6 +42,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, @@ -164,6 +192,11 @@ def validate_unique(self, exclude=None): msgs[NON_FIELD_ERRORS].extend(slug_errors) 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) + class CapChecklist(models.Model): class Meta: @@ -687,6 +720,116 @@ class Meta: ) +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" + + 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 + ) + + @property + def email(self): + "Satisfy expected interface." + return f"{self.get_access_right_display()} von {self.city.name}" + + @classmethod + 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_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_right(city, access_right) + + @classmethod + def ensure_for_city(cls, city): + "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): + "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): + """ + 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]) + return request.build_absolute_uri(url) + + def key_expired(self): + "Implementation of required method. Never expired." + return False + + 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}" + + +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 new file mode 100644 index 00000000..b7e2371e --- /dev/null +++ b/cpmonitor/rules.py @@ -0,0 +1,137 @@ +from django.contrib.auth.models import User +from django.db.models import Q, Model +import rules +from types import NoneType + +from .models import City, Task, Chart, CapChecklist, AdministrationChecklist, LocalGroup + +# All classes attached to a city (except Invitation) or a city ID. +CityType = ( + City | Task | Chart | CapChecklist | AdministrationChecklist | LocalGroup | int +) +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 _always_false_q + if user.is_superuser: + return _always_true_q + if model == City: + return Q(city_editors=user) | Q(city_admins=user) + else: + return Q(city__city_editors=user) | Q(city__city_admins=user) + + +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): + return City.objects.filter(id=object).first() + else: + return getattr(object, "city", None) + + +@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) + if isinstance(city, City): + return city.city_editors.filter(pk=user.pk).exists() + return False + + +@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) + if isinstance(city, City): + return city.city_admins.filter(pk=user.pk).exists() + return False + + +@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) + if isinstance(city, City): + return True + return False + + +@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: +# 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 | 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) +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) + +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/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/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/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()}) 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..224e0dd1 100644 --- a/cpmonitor/templates/base.html +++ b/cpmonitor/templates/base.html @@ -15,7 +15,6 @@ - @@ -42,7 +41,7 @@ -
-