├── project ├── __init__.py ├── templates │ ├── snippets │ │ └── meta.html │ ├── header_reduced.html │ ├── header.html │ ├── cms │ │ └── page.html │ └── base.html ├── asgi.py ├── wsgi.py ├── urls.py └── settings.py ├── froide_govplan ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── import_govplan.py ├── migrations │ ├── __init__.py │ ├── 0014_remove_governmentplansection_content_placeholder.py │ ├── 0008_governmentplan_proposals.py │ ├── 0011_governmentplan_properties.py │ ├── 0010_governmentplanscmsplugin_extra_classes.py │ ├── 0013_government_active.py │ ├── 0007_auto_20220314_1422.py │ ├── 0006_sections_cms_plugin.py │ ├── 0009_governmentplanupdatescmsplugin.py │ ├── 0002_auto_20220215_2113.py │ ├── 0005_governmentplansection.py │ ├── 0012_government_georegion_alter_government_jurisdiction_and_more.py │ ├── 0004_auto_20220311_2330.py │ ├── 0001_initial.py │ └── 0003_auto_20220228_1051.py ├── templatetags │ ├── __init__.py │ └── govplan.py ├── __init__.py ├── conf.py ├── templates │ ├── froide_govplan │ │ ├── plugins │ │ │ ├── default.html │ │ │ ├── time_used.html │ │ │ ├── progress.html │ │ │ ├── progress_row.html │ │ │ ├── search.html │ │ │ ├── sections.html │ │ │ ├── card_cols.html │ │ │ └── updates.html │ │ ├── base.html │ │ ├── admin │ │ │ ├── accept_proposal.html │ │ │ └── _proposal.html │ │ ├── section.html │ │ └── detail.html │ └── admin │ │ └── froide_govplan │ │ ├── governmentplanupdate │ │ └── change_form.html │ │ └── governmentplan │ │ └── change_form.html ├── cms_apps.py ├── apps.py ├── urls.py ├── auth.py ├── utils.py ├── cms_toolbars.py ├── cms_plugins.py ├── api_views.py ├── configuration.py ├── plan_importer.py ├── views.py ├── forms.py ├── admin.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ └── django.po └── models.py ├── .gitignore ├── MANIFEST.in ├── .pre-commit-config.yaml ├── docker-compose.yml ├── manage.py ├── pyproject.toml ├── LICENSE └── README.md /project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /froide_govplan/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /froide_govplan/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/templates/snippets/meta.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /froide_govplan/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /froide_govplan/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /froide_govplan/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "froide_govplan.apps.FroideGovPlanConfig" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.mo 3 | *.egg-info/ 4 | __pycache__ 5 | .vscode/ 6 | .venv/ 7 | postgres_data/ 8 | .ruff_cache/ 9 | -------------------------------------------------------------------------------- /project/templates/header_reduced.html: -------------------------------------------------------------------------------- 1 | {% extends "header.html" %} 2 | 3 | {% block nav_search %}{% endblock %} 4 | 5 | {% block nav %}{% endblock nav %} 6 | -------------------------------------------------------------------------------- /project/templates/header.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | recursive-include froide_govplan/templates * 3 | recursive-include froide_govplan/static * 4 | recursive-include froide_govplan/locale *.po 5 | -------------------------------------------------------------------------------- /froide_govplan/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | GOVPLAN_ENABLE_FOIREQUEST = getattr(settings, "GOVPLAN_ENABLE_FOIREQUEST", True) 4 | GOVPLAN_NAME = getattr(settings, "GOVPLAN_NAME", "GovPlan") 5 | -------------------------------------------------------------------------------- /project/templates/cms/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load cms_tags %} 4 | 5 | {% block body %} 6 | {% block app_body %} 7 | {% placeholder "content" %} 8 | {% endblock %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/default.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.3 4 | hooks: 5 | - id: ruff 6 | args: [--fix] 7 | - id: ruff-format 8 | 9 | - repo: https://github.com/Riverside-Healthcare/djLint 10 | rev: v1.36.4 11 | hooks: 12 | - id: djlint-reformat-django 13 | - id: djlint-django 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgis/postgis:14-3.3-alpine 6 | volumes: 7 | - ./postgres_data:/var/lib/postgresql/data/ 8 | ports: 9 | - "127.0.0.1:5432:5432" 10 | environment: 11 | POSTGRES_USER: govplan 12 | POSTGRES_DB: govplan 13 | POSTGRES_PASSWORD: govplan 14 | 15 | volumes: 16 | postgres_data: {} 17 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/base.html: -------------------------------------------------------------------------------- 1 | {% extends "cms/page.html" %} 2 | {% load cms_tags %} 3 | {% load djangocms_alias_tags %} 4 | {% load menu_tags %} 5 | {% block body %} 6 | {% static_alias "govplan_header" %} 7 | {% block app_body %} 8 | {% placeholder "content" %} 9 | {% endblock app_body %} 10 | {% static_alias "govplan_footer" %} 11 | {% endblock body %} 12 | -------------------------------------------------------------------------------- /froide_govplan/templates/admin/froide_govplan/governmentplanupdate/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | 3 | {% block after_field_sets %} 4 | {% if not original.pk %} 5 | 10 | {% endif %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /froide_govplan/cms_apps.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from cms.app_base import CMSApp 4 | from cms.apphook_pool import apphook_pool 5 | 6 | 7 | @apphook_pool.register 8 | class GovPlanCMSApp(CMSApp): 9 | name = _("GovPlan CMS App") 10 | app_name = "govplan" 11 | 12 | def get_urls(self, page=None, language=None, **kwargs): 13 | return ["froide_govplan.urls"] 14 | -------------------------------------------------------------------------------- /froide_govplan/templates/admin/froide_govplan/governmentplan/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n %} 3 | 4 | {% block object-tools-items %} 5 | {% if original.pk and original.proposals %} 6 |
  • 7 | 8 | {% trans "See update proposals" %} 9 | 10 |
  • 11 | {% endif %} 12 | {{ block.super }} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0014_remove_governmentplansection_content_placeholder.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.1 on 2025-05-24 05:52 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("froide_govplan", "0013_government_active"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="governmentplansection", 14 | name="content_placeholder", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0008_governmentplan_proposals.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-16 10:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("froide_govplan", "0007_auto_20220314_1422"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="governmentplan", 15 | name="proposals", 16 | field=models.JSONField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0011_governmentplan_properties.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-05-13 15:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("froide_govplan", "0010_governmentplanscmsplugin_extra_classes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="governmentplan", 15 | name="properties", 16 | field=models.JSONField(blank=True, default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0010_governmentplanscmsplugin_extra_classes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-17 17:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("froide_govplan", "0009_governmentplanupdatescmsplugin"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="governmentplanscmsplugin", 15 | name="extra_classes", 16 | field=models.CharField(blank=True, max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0013_government_active.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2025-01-27 11:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "froide_govplan", 10 | "0012_government_georegion_alter_government_jurisdiction_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="government", 17 | name="active", 18 | field=models.BooleanField(default=True, verbose_name="active"), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/admin/accept_proposal.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{{ object.title }} - {{ block.super }}{% endblock %} 5 | 6 | 7 | {% block content %}
    8 |

    {{ object.title }}

    9 | 10 |
    11 | {% csrf_token %} 12 |
    13 | {% include "froide_govplan/admin/_proposal.html" %} 14 |
    15 |
    16 | 17 |
    18 |
    19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /froide_govplan/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class FroideGovPlanConfig(AppConfig): 6 | name = "froide_govplan" 7 | verbose_name = _("GovPlan App") 8 | 9 | def ready(self): 10 | from froide.api import api_router 11 | from froide.follow.configuration import follow_registry 12 | 13 | from .api_views import GovernmentPlanViewSet 14 | from .configuration import GovernmentPlanFollowConfiguration 15 | 16 | follow_registry.register(GovernmentPlanFollowConfiguration()) 17 | 18 | api_router.register( 19 | r"governmentplan", GovernmentPlanViewSet, basename="governmentplan" 20 | ) 21 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /froide_govplan/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.utils.translation import pgettext_lazy 3 | 4 | from .views import ( 5 | GovPlanDetailView, 6 | GovPlanProposeUpdateView, 7 | GovPlanSectionDetailView, 8 | search, 9 | ) 10 | 11 | app_name = "govplan" 12 | 13 | urlpatterns = [ 14 | path("search/", search, name="search"), 15 | path( 16 | pgettext_lazy("url part", "/plan//"), 17 | GovPlanDetailView.as_view(), 18 | name="plan", 19 | ), 20 | path( 21 | pgettext_lazy("url part", "/plan//propose-update/"), 22 | GovPlanProposeUpdateView.as_view(), 23 | name="propose_planupdate", 24 | ), 25 | path( 26 | pgettext_lazy("url part", "//"), 27 | GovPlanSectionDetailView.as_view(), 28 | name="section", 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/time_used.html: -------------------------------------------------------------------------------- 1 | {% load govplan %} 2 | 3 | {% load i18n %} 4 | 5 |
    6 | {% with gov=instance.government %} 7 |
    8 | 9 | {% blocktranslate count days=gov.days_left %}One day left{% plural %}{{ days }} days left{% endblocktranslate %} 10 | 11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 | {% endwith %} 19 |
    20 | -------------------------------------------------------------------------------- /froide_govplan/auth.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | 3 | from .models import GovernmentPlan 4 | 5 | 6 | def has_limited_access(user): 7 | if not user.is_authenticated: 8 | return True 9 | return not user.has_perm("froide_govplan.add_governmentplan") 10 | 11 | 12 | def get_allowed_plans(request): 13 | if not has_limited_access(request.user): 14 | return GovernmentPlan.objects.all() 15 | groups = request.user.groups.all() 16 | return GovernmentPlan.objects.filter(group__in=groups).distinct() 17 | 18 | 19 | def get_visible_plans(request): 20 | if not has_limited_access(request.user): 21 | return GovernmentPlan.objects.all() 22 | if request.user.is_authenticated: 23 | groups = request.user.groups.all() 24 | return GovernmentPlan.objects.filter( 25 | Q(public=True) | Q(group__in=groups) 26 | ).distinct() 27 | return GovernmentPlan.objects.filter(public=True) 28 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/progress.html: -------------------------------------------------------------------------------- 1 | {% load govplan %} 2 | 3 | {% get_plan_progress object_list as progress %} 4 | 5 |
    6 | {{ progress.count }} Vorhaben 7 |
    8 |
    9 | {% for section in progress.sections %} 10 |
    11 |
    12 | {% endfor %} 13 |
    14 |
    15 |
    16 | 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "froide_govplan" 3 | readme = "README.md" 4 | description = "" 5 | license = { file = "LICENSE" } 6 | requires-python = ">=3.10" 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Framework :: Django", 10 | "Intended Audience :: Developers", 11 | "License :: OSI Approved :: MIT License", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.12", 16 | "Topic :: Utilities", 17 | ] 18 | version = "0.0.1" 19 | dependencies = [ 20 | "django-cms", 21 | "djangocms-alias", 22 | "django-filer", 23 | "psycopg[binary]", 24 | "django-admin-sortable2", 25 | "nh3", 26 | "django-tinymce", 27 | "django-oauth-toolkit", 28 | "django-mfa3", 29 | ] 30 | 31 | [build-system] 32 | requires = ["setuptools"] 33 | build-backend = "setuptools.build_meta" 34 | 35 | [tool.setuptools.packages.find] 36 | include = ["froide_govplan*"] 37 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/progress_row.html: -------------------------------------------------------------------------------- 1 | {% load govplan %} 2 | 3 | {% get_plan_progress object_list as progress %} 4 | 5 |
    6 |
    7 | {{ progress.count }} Vorhaben 8 |
    9 |
    10 |
    11 | {% for section in progress.sections %} 12 |
    13 |
    14 | {% endfor %} 15 |
    16 |
    17 |
    18 | 19 | -------------------------------------------------------------------------------- /froide_govplan/management/commands/import_govplan.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from ...models import Government 7 | from ...plan_importer import PlanImporter 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Loads public bodies" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("government", type=str) 15 | parser.add_argument("json_mapping", type=str) 16 | parser.add_argument("filename", type=str) 17 | 18 | def handle(self, *args, **options): 19 | government = Government.objects.get(slug=options["government"]) 20 | 21 | with open(options["json_mapping"]) as f: 22 | col_mapping = json.load(f) 23 | 24 | importer = PlanImporter(government, col_mapping=col_mapping) 25 | 26 | filename = options["filename"] 27 | with open(filename) as csv_file: 28 | reader = csv.DictReader(csv_file) 29 | importer.import_rows(reader) 30 | 31 | self.stdout.write("Import done.\n") 32 | -------------------------------------------------------------------------------- /froide_govplan/utils.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote, urlencode 2 | 3 | from django.conf import settings 4 | from django.urls import reverse 5 | 6 | from . import conf 7 | 8 | 9 | def make_request_url(plan, publicbody): 10 | pb_slug = publicbody.slug 11 | url = reverse("foirequest-make_request", kwargs={"publicbody_slug": pb_slug}) 12 | subject = "Stand des Regierungsvorhabens „{}“".format(plan.title) 13 | if len(subject) > 250: 14 | subject = subject[:250] + "..." 15 | body = "Dokumente, die den Stand des Regierungsvorhabens zum Thema {} (siehe Koalitionsvertrag), dokumentieren.".format( 16 | plan.title 17 | ) 18 | query = { 19 | "subject": subject.encode("utf-8"), 20 | "body": body, 21 | "ref": plan.get_foirequest_reference(), 22 | "tags": conf.GOVPLAN_NAME, 23 | } 24 | 25 | hide_features = ["hide_public", "hide_similar", "hide_draft"] 26 | 27 | query.update({f: b"1" for f in hide_features}) 28 | query = urlencode(query, quote_via=quote) 29 | return "%s%s?%s" % (settings.SITE_URL, url, query) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2022 FragDenStaat.de / Stefan Wehrmeyer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /project/urls.py: -------------------------------------------------------------------------------- 1 | """project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import include, path 20 | 21 | urlpatterns = [ 22 | path( 23 | "follow/", 24 | include("froide.follow.urls", namespace="follow"), 25 | ), 26 | path("admin/", admin.site.urls), 27 | path("", include("cms.urls")), 28 | ] 29 | 30 | if settings.DEBUG: 31 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 32 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0007_auto_20220314_1422.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-14 13:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("froide_govplan", "0006_sections_cms_plugin"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="governmentplanscmsplugin", 15 | name="template", 16 | field=models.CharField( 17 | blank=True, 18 | choices=[ 19 | ("froide_govplan/plugins/default.html", "Normal"), 20 | ("froide_govplan/plugins/progress.html", "Progress"), 21 | ("froide_govplan/plugins/card_cols.html", "Card columns"), 22 | ("froide_govplan/plugins/search.html", "Search"), 23 | ], 24 | help_text="template used to display the plugin", 25 | max_length=250, 26 | verbose_name="template", 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name="governmentplanupdate", 31 | name="url", 32 | field=models.URLField(blank=True, max_length=1024, verbose_name="URL"), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /froide_govplan/cms_toolbars.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from cms.toolbar_base import CMSToolbar 5 | from cms.toolbar_pool import toolbar_pool 6 | 7 | from . import conf 8 | 9 | 10 | class GovPlanToolbar(CMSToolbar): 11 | def populate(self): 12 | if not self.is_current_app: 13 | return 14 | menu = self.toolbar.get_or_create_menu("govplan-menu", conf.GOVPLAN_NAME) 15 | 16 | url = reverse( 17 | "admin:app_list", 18 | kwargs={"app_label": "froide_govplan"}, 19 | current_app="govplanadmin", 20 | ) 21 | menu.add_link_item(_("Edit plans and updates"), url=url) 22 | 23 | if hasattr(self.request, "govplan"): 24 | govplan = self.request.govplan 25 | url = reverse( 26 | "admin:froide_govplan_governmentplan_change", 27 | args=(govplan.pk,), 28 | current_app="govplanadmin", 29 | ) 30 | menu.add_link_item(_("Edit government plan"), url=url) 31 | url = reverse( 32 | "admin:froide_govplan_governmentplanupdate_add", 33 | current_app="govplanadmin", 34 | ) 35 | url = "{}?plan={}".format(url, govplan.id) 36 | menu.add_link_item(_("Add update"), url=url) 37 | 38 | 39 | toolbar_pool.register(GovPlanToolbar) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Froide GovPlan 2 | 3 | A Django app that allows tracking government plans. Deployed at: https://fragdenstaat.de/koalitionstracker/ 4 | 5 | 6 | ## Install stand-alone 7 | 8 | Requires [GDAL/Geos for GeoDjango](https://docs.djangoproject.com/en/4.1/ref/contrib/gis/install/geolibs/). 9 | 10 | ```bash 11 | # Start a Postgres server with Postgis 12 | docker compose up -d 13 | # Setup virtualenv 14 | python3 -m venv .venv 15 | source .venv/bin/activate 16 | # Install dependencies 17 | pip install -e git+https://github.com/okfde/froide.git@main#egg=froide 18 | pip install -e . 19 | # Setup initial database 20 | ./manage.py migrate 21 | # Create admin user 22 | ./manage.py createsuperuser 23 | # Start development server 24 | ./manage.py runserver 25 | ``` 26 | 27 | 28 | 1. Go to http://localhost:8000/admin/ 29 | 2. Setup a homepage in the CMS: http://localhost:8000/admin/cms/page/ 30 | 3. Setup a page (could be the homepage) and then choose under advanced setting the Govplan app as application. 31 | 4. Publish that page 32 | 5. Setup a government and plans via the admin. 33 | 34 | ## Possible next steps 35 | 36 | - Use the `project` directory as a blueprint for an app that uses this repo as a depdency. 37 | - Setup [djangocms-text-ckeditor](https://github.com/django-cms/djangocms-text-ckeditor), [djangocms-frontend](https://github.com/django-cms/djangocms-frontend) and other CMS components/ 38 | - Use Django apps for social authentication. 39 | - Override templates in your custom project. 40 | -------------------------------------------------------------------------------- /froide_govplan/templatetags/govplan.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from froide_govplan.models import STATUS_CSS, PlanStatus 4 | 5 | register = template.Library() 6 | 7 | 8 | PROGRESS_ORDER = [ 9 | PlanStatus.IMPLEMENTED, 10 | PlanStatus.PARTIALLY_IMPLEMENTED, 11 | PlanStatus.STARTED, 12 | PlanStatus.NOT_STARTED, 13 | PlanStatus.DEFERRED, 14 | ] 15 | 16 | 17 | @register.simple_tag 18 | def get_plan_progress(object_list): 19 | sections = [] 20 | for value in PROGRESS_ORDER: 21 | label = value.label 22 | value = str(value) 23 | status_count = len([x for x in object_list if x.status == value]) 24 | percentage = ( 25 | 0 if len(object_list) == 0 else status_count / len(object_list) * 100 26 | ) 27 | sections.append( 28 | { 29 | "count": status_count, 30 | "name": str(value), 31 | "label": label, 32 | "css_class": STATUS_CSS[value], 33 | "percentage": round(percentage), 34 | "css_percentage": str(percentage), 35 | } 36 | ) 37 | 38 | value, label 39 | return {"count": len(object_list), "sections": sections} 40 | 41 | 42 | @register.inclusion_tag("froide_govplan/plugins/progress.html") 43 | def get_section_progress(section): 44 | return {"object_list": section.get_plans()} 45 | 46 | 47 | @register.filter 48 | def addquotes(text): 49 | return f"„{str.strip(text)}“" 50 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/search.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    3 |
    4 | 5 | 11 | 14 |
    15 | {% if instance.government_id %} 16 | 17 | {% endif %} 18 |
    19 | 20 | 34 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0006_sections_cms_plugin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-14 12:10 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("cms", "0022_auto_20180620_1551"), 11 | ("froide_govplan", "0005_governmentplansection"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="GovernmentPlanSectionsCMSPlugin", 17 | fields=[ 18 | ( 19 | "cmsplugin_ptr", 20 | models.OneToOneField( 21 | auto_created=True, 22 | on_delete=django.db.models.deletion.CASCADE, 23 | parent_link=True, 24 | primary_key=True, 25 | related_name="froide_govplan_governmentplansectionscmsplugin", 26 | serialize=False, 27 | to="cms.cmsplugin", 28 | ), 29 | ), 30 | ( 31 | "government", 32 | models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | to="froide_govplan.government", 37 | ), 38 | ), 39 | ], 40 | options={ 41 | "abstract": False, 42 | }, 43 | bases=("cms.cmsplugin",), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/sections.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | {% load govplan %} 3 | 12 | 37 | -------------------------------------------------------------------------------- /project/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% load static %}{% load cms_tags %}{% load sekizai_tags %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block title %}{% page_attribute "page_title" %} - {{ SITE_NAME }}{% endblock %} 9 | 10 | {% block header_font %}{% endblock %} 11 | 12 | {% block css %} 13 | 14 | {% block extra_css %} 15 | {% endblock %} 16 | {% endblock %} 17 | {% render_block "css" %} 18 | 19 | {% block meta %} 20 | {# Translators: meta description #} 21 | {% endblock %} 22 | 23 | {% block extra_head %} 24 | {% endblock %} 25 | 26 | 27 | {% cms_toolbar %} 28 | {% block body_tag %}{% block body %}{% block main %}{% endblock %}{% endblock %}{% endblock %} 29 | {% block extra_footer %}{% endblock %} 30 | 31 | {% block scripts %} 32 | 33 | {% endblock %} 34 | {% render_block "js" %} 35 | 36 | {% block below_scripts %}{% endblock %} 37 | 38 | 39 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/card_cols.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | {% load i18n %} 3 | {% load govplan %} 4 | {% if object_list|length == 0 %} 5 |

    {% trans "Could not find any results. Try different keywords or browse the categories." %}

    6 | {% else %} 7 | 35 | {% endif %} 36 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/admin/_proposal.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load form_helper %} 3 | {% load permission_helper %} 4 | 5 |
    6 | 7 | 8 | 9 | 10 | {% for key, proposal in proposals.items %} 11 | 20 | {% endfor %} 21 | 22 | 23 | 24 | {% if proposals %} 25 | 26 | 29 | {% for key, proposal in proposals.items %} 30 | 36 | {% endfor %} 37 | 38 | {% endif %} 39 | {% for field in form %} 40 | 41 | 42 | {% for key, proposal in proposals.items %} 43 | {% with val=proposal.data|get_item_by_key:field.name %} 44 | 53 | {% endwith %} 54 | {% endfor %} 55 | 56 | {{ form.field }} 57 | {% endfor %} 58 | 59 | {% if proposals %} 60 | 61 | 64 | {% for key, proposal in proposals.items %} 65 | 71 | {% endfor %} 72 | 73 | {% endif %} 74 | 75 | 76 |
    {% trans "Field" %} 12 | 13 | {% if request.user.is_staff and request.user|has_perm:"account.view_user" %} 14 | {{ proposal.user.get_full_name }} ({{ proposal.user.email }}) 15 | {% else %} 16 | {{ proposal.timestamp | date:"SHORT_DATETIME" }} 17 | {% endif %} 18 | 19 |
    27 | {% trans "Proposal" %} 28 | 31 | 35 |
    {{ field.label }} 45 | {% with label_name=field.name|add:"_label" %} 46 | {% if proposal.data|get_item_by_key:label_name %} 47 | {{ proposal.data|get_item_by_key:label_name }} 48 | {% else %} 49 | {{ val|urlize|linebreaksbr }} 50 | {% endif %} 51 | {% endwith %} 52 |
    62 | {% trans "Delete proposal" %} 63 | 66 | 70 |
    77 |
    78 | -------------------------------------------------------------------------------- /froide_govplan/cms_plugins.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from cms.plugin_base import CMSPluginBase 4 | from cms.plugin_pool import plugin_pool 5 | 6 | from .models import ( 7 | PLUGIN_TEMPLATES, 8 | GovernmentPlansCMSPlugin, 9 | GovernmentPlanSection, 10 | GovernmentPlanSectionsCMSPlugin, 11 | GovernmentPlanUpdatesCMSPlugin, 12 | PlanStatus, 13 | ) 14 | 15 | 16 | @plugin_pool.register_plugin 17 | class GovernmentPlansPlugin(CMSPluginBase): 18 | name = _("Government plans") 19 | model = GovernmentPlansCMSPlugin 20 | filter_horizontal = ("categories",) 21 | cache = True 22 | 23 | def get_render_template(self, context, instance, placeholder): 24 | return instance.template or PLUGIN_TEMPLATES[0][0] 25 | 26 | def render(self, context, instance, placeholder): 27 | context = super().render(context, instance, placeholder) 28 | context["plugin"] = instance 29 | context["status_list"] = PlanStatus.choices 30 | context["object_list"] = instance.get_plans( 31 | context["request"], published_only=False 32 | ) 33 | return context 34 | 35 | 36 | @plugin_pool.register_plugin 37 | class GovernmentPlanSectionsPlugin(CMSPluginBase): 38 | name = _("Government plan sections") 39 | model = GovernmentPlanSectionsCMSPlugin 40 | render_template = "froide_govplan/plugins/sections.html" 41 | 42 | def render(self, context, instance, placeholder): 43 | context = super().render(context, instance, placeholder) 44 | 45 | if instance.government_id: 46 | sections = GovernmentPlanSection.objects.filter( 47 | government_id=instance.government_id 48 | ) 49 | else: 50 | sections = GovernmentPlanSection.objects.all() 51 | 52 | sections = sections.select_related("government") 53 | 54 | context["sections"] = sections 55 | 56 | return context 57 | 58 | 59 | @plugin_pool.register_plugin 60 | class GovernmentPlanUpdatesPlugin(CMSPluginBase): 61 | name = _("Government plan updates") 62 | model = GovernmentPlanUpdatesCMSPlugin 63 | render_template = "froide_govplan/plugins/updates.html" 64 | 65 | def render(self, context, instance, placeholder): 66 | context = super().render(context, instance, placeholder) 67 | context["updates"] = instance.get_updates( 68 | context["request"], published_only=False 69 | ) 70 | context["show_context"] = True 71 | return context 72 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0009_governmentplanupdatescmsplugin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-17 14:54 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("cms", "0022_auto_20180620_1551"), 11 | ("publicbody", "0039_publicbody_alternative_emails"), 12 | ("froide_govplan", "0008_governmentplan_proposals"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="GovernmentPlanUpdatesCMSPlugin", 18 | fields=[ 19 | ( 20 | "cmsplugin_ptr", 21 | models.OneToOneField( 22 | auto_created=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | parent_link=True, 25 | primary_key=True, 26 | related_name="froide_govplan_governmentplanupdatescmsplugin", 27 | serialize=False, 28 | to="cms.cmsplugin", 29 | ), 30 | ), 31 | ( 32 | "count", 33 | models.PositiveIntegerField( 34 | default=1, 35 | help_text="0 means all the updates", 36 | verbose_name="number of updates", 37 | ), 38 | ), 39 | ( 40 | "offset", 41 | models.PositiveIntegerField( 42 | default=0, 43 | help_text="number of updates to skip from top of list", 44 | verbose_name="offset", 45 | ), 46 | ), 47 | ( 48 | "categories", 49 | models.ManyToManyField( 50 | blank=True, to="publicbody.Category", verbose_name="categories" 51 | ), 52 | ), 53 | ( 54 | "government", 55 | models.ForeignKey( 56 | blank=True, 57 | null=True, 58 | on_delete=django.db.models.deletion.SET_NULL, 59 | to="froide_govplan.government", 60 | ), 61 | ), 62 | ], 63 | options={ 64 | "abstract": False, 65 | }, 66 | bases=("cms.cmsplugin",), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /froide_govplan/api_views.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as filters 2 | from rest_framework import serializers, viewsets 3 | 4 | from .models import Government, GovernmentPlan, GovernmentPlanUpdate 5 | 6 | 7 | class GovernmentSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = Government 10 | fields = ("id", "name", "slug", "start_date", "end_date", "planning_document") 11 | 12 | 13 | class GovernmentPlanSerializer(serializers.ModelSerializer): 14 | site_url = serializers.CharField(source="get_absolute_domain_url") 15 | updates = serializers.SerializerMethodField() 16 | 17 | class Meta: 18 | model = GovernmentPlan 19 | fields = ( 20 | "id", 21 | "site_url", 22 | "government", 23 | "title", 24 | "slug", 25 | "description", 26 | "quote", 27 | "due_date", 28 | "measure", 29 | "status", 30 | "rating", 31 | "properties", 32 | "updates", 33 | ) 34 | 35 | def get_updates(self, obj): 36 | return GovernmentPlanUpdateSerializer( 37 | obj.updates.all(), read_only=True, many=True, context=self.context 38 | ).data 39 | 40 | 41 | class GovernmentPlanUpdateSerializer(serializers.ModelSerializer): 42 | site_url = serializers.CharField(source="get_absolute_domain_url") 43 | 44 | class Meta: 45 | model = GovernmentPlanUpdate 46 | fields = ( 47 | "timestamp", 48 | "title", 49 | "content", 50 | "site_url", 51 | "url", 52 | "status", 53 | "rating", 54 | ) 55 | 56 | 57 | class GovernmentPlanFilter(filters.FilterSet): 58 | government = filters.ModelChoiceFilter( 59 | queryset=Government.objects.filter(public=True) 60 | ) 61 | properties = filters.CharFilter(method="properties_filter") 62 | 63 | class Meta: 64 | model = GovernmentPlan 65 | fields = ( 66 | "government", 67 | "status", 68 | "rating", 69 | "properties", 70 | ) 71 | 72 | def properties_filter(self, queryset, name, value): 73 | try: 74 | key, value = value.split(":", 1) 75 | except ValueError: 76 | return queryset.filter(properties__has_key=value) 77 | 78 | return queryset.filter(**{"properties__%s__contains" % key: value}) 79 | 80 | 81 | class GovernmentPlanViewSet(viewsets.ReadOnlyModelViewSet): 82 | serializer_class = GovernmentPlanSerializer 83 | filterset_class = GovernmentPlanFilter 84 | 85 | def get_queryset(self): 86 | return ( 87 | GovernmentPlan.objects.filter(public=True) 88 | .select_related("government") 89 | .prefetch_related("updates") 90 | ) 91 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/section.html: -------------------------------------------------------------------------------- 1 | {% extends "froide_govplan/base.html" %} 2 | {% load i18n %} 3 | {% load markup %} 4 | {% load cms_tags %} 5 | {% load follow_tags %} 6 | {% load thumbnail %} 7 | {% block title %} 8 | {{ object.title }} 9 | {% endblock title %} 10 | {% block meta %} 11 | {% include "snippets/meta.html" %} 12 | {% endblock meta %} 13 | {% block app_body %} 14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 | {% if not government.active %} 21 |
    22 | Dieser Themenbereich gehörte zur {{ government.name }} und wird nicht mehr aktualisiert. 23 |
    24 | {% endif %} 25 |

    {{ object.title }}

    26 |

    27 | {% blocktranslate with section=object.title %} 28 | Here you can find all plans of the section “{{ section }}”, which the coalition constituted in their agreement. On the curresponding detail pages, you'll get more information, stay up to date with news or submit changes. 29 | {% endblocktranslate %} 30 |

    31 |
    32 |
    33 |
    34 | {% include "froide_govplan/plugins/progress_row.html" with object_list=plans %} 35 |

    36 |  nicht begonnen 37 |  begonnen 38 |  teilweise umgesetzt 39 |  umgesetzt 40 |  verschoben 41 |

    42 | {% include "froide_govplan/plugins/time_used.html" with instance=object %} 43 |
    44 |
    45 |
    46 |
    47 | {% include "froide_govplan/plugins/card_cols.html" with object_list=plans %} 48 |
    49 | {% endblock app_body %} 50 | -------------------------------------------------------------------------------- /froide_govplan/configuration.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Iterator 3 | 4 | from django.utils.translation import gettext_lazy as _ 5 | from froide.follow.configuration import FollowConfiguration 6 | from froide.helper.notifications import Notification, TemplatedEvent 7 | 8 | from .admin import has_limited_access 9 | from .models import GovernmentPlan, GovernmentPlanFollower, GovernmentPlanUpdate 10 | 11 | 12 | class GovernmentPlanFollowConfiguration(FollowConfiguration): 13 | model = GovernmentPlanFollower 14 | title: str = _("Government plans") 15 | slug: str = "govplan" 16 | follow_message: str = _("You are now following this plan.") 17 | unfollow_message: str = _("You are not following this plan anymore.") 18 | confirm_email_message: str = _( 19 | "Check your emails and click the confirmation link in order to follow this government plan." 20 | ) 21 | action_labels = { 22 | "follow": _("Follow plan"), 23 | "follow_q": _("Follow plan?"), 24 | "unfollow": _("Unfollow plan"), 25 | "following": _("Following plan"), 26 | "follow_description": _( 27 | "You will get notifications via email when something new happens with this plan. You can unsubscribe anytime." 28 | ), 29 | } 30 | 31 | def get_content_object_queryset(self, request): 32 | if has_limited_access(request.user): 33 | return GovernmentPlan.objects.filter(public=True) 34 | return GovernmentPlan.objects.all() 35 | 36 | def can_follow(self, content_object, user, request=None): 37 | return ( 38 | content_object.government.active 39 | and content_object.public 40 | or not has_limited_access(user) 41 | ) 42 | 43 | def get_batch_updates( 44 | self, start: datetime, end: datetime 45 | ) -> Iterator[Notification]: 46 | yield from get_plan_updates(start, end) 47 | 48 | def get_confirm_follow_message(self, content_object): 49 | return _( 50 | "please confirm that you want to follow the plan “{title}” by clicking this link:" 51 | ).format(title=content_object.title) 52 | 53 | def email_changed(self, user): 54 | # Move all confirmed email subscriptions of new email 55 | # to user except own requests 56 | self.model.objects.filter(email=user.email, confirmed=True).update( 57 | email="", user=user 58 | ) 59 | 60 | 61 | def get_plan_updates(start: datetime, end: datetime): 62 | plan_updates = GovernmentPlanUpdate.objects.filter( 63 | public=True, timestamp__gte=start, timestamp__lt=end 64 | ).select_related("plan") 65 | 66 | for plan_update in plan_updates: 67 | yield Notification( 68 | section=_("Government Plans"), 69 | event_type="planupdate", 70 | object=plan_update.plan, 71 | object_label=plan_update.plan.title, 72 | timestamp=plan_update.timestamp, 73 | event=make_plan_event(plan_update.plan), 74 | user_id=None, 75 | ) 76 | 77 | 78 | def make_plan_event(plan): 79 | return TemplatedEvent( 80 | _("An update was posted for the government plan “{title}”."), 81 | title=plan.title, 82 | ) 83 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/plugins/updates.html: -------------------------------------------------------------------------------- 1 | {% load markup %} 2 | {% for update in updates %} 3 | {% if wrapper_classes %}
    {% endif %} 4 |
    6 | 8 |
    9 |
    10 | {% if show_context %} 11 | {{ update.plan.get_section }} 12 | {% endif %} 13 | {% if show_context %} 14 |

    {{ update.plan }}

    15 | {% else %} 16 |

    {{ update.title }}

    17 | {% endif %} 18 |
    19 |
    20 | 21 | {% if update.user or update.organization %} 22 | 23 | von {{ update.user.get_full_name }} 24 | {% if update.user and update.organization %},{% endif %} 25 | {{ update.organization.name }} 26 | 27 | {% endif %} 28 |
    29 |
    30 |
    31 | {% if update.content or show_context %} 32 |
    33 | {% if show_context %}

    {{ update.title }}

    {% endif %} 34 | {% with update.content|markdown as content %} 35 | {% if show_context %} 36 | {{ content|truncatewords_html:50 }} 37 | {% else %} 38 | {{ content }} 39 | {% endif %} 40 | {% endwith %} 41 |
    42 | {% endif %} 43 | {% if update.url or update.foirequest or show_context %} 44 |
    45 | {% if show_context %} 46 | → zum Vorhaben 47 | {% else %} 48 | {% if update.url %} 49 | → mehr auf {{ update.get_url_domain }} lesen… 53 | {% endif %} 54 | {% if update.foirequest %} 55 | → zur Anfrage 56 | {% endif %} 57 | {% endif %} 58 |
    59 | {% endif %} 60 |
    61 | {% if wrapper_classes %}
    {% endif %} 62 | {% endfor %} 63 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0002_auto_20220215_2113.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2022-02-15 20:13 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("publicbody", "0039_publicbody_alternative_emails"), 11 | ("organization", "0001_initial"), 12 | ("cms", "0022_auto_20180620_1551"), 13 | ("froide_govplan", "0001_initial"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name="governmentplan", 19 | name="organization", 20 | field=models.ForeignKey( 21 | blank=True, 22 | null=True, 23 | on_delete=django.db.models.deletion.SET_NULL, 24 | to="organization.organization", 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="governmentplanupdate", 29 | name="url", 30 | field=models.URLField(blank=True), 31 | ), 32 | migrations.CreateModel( 33 | name="GovernmentPlansCMSPlugin", 34 | fields=[ 35 | ( 36 | "cmsplugin_ptr", 37 | models.OneToOneField( 38 | auto_created=True, 39 | on_delete=django.db.models.deletion.CASCADE, 40 | parent_link=True, 41 | primary_key=True, 42 | related_name="froide_govplan_governmentplanscmsplugin", 43 | serialize=False, 44 | to="cms.cmsplugin", 45 | ), 46 | ), 47 | ( 48 | "count", 49 | models.PositiveIntegerField( 50 | default=1, 51 | help_text="0 means all the plans", 52 | verbose_name="number of plans", 53 | ), 54 | ), 55 | ( 56 | "offset", 57 | models.PositiveIntegerField( 58 | default=0, 59 | help_text="number of plans to skip from top of list", 60 | verbose_name="offset", 61 | ), 62 | ), 63 | ( 64 | "template", 65 | models.CharField( 66 | blank=True, 67 | choices=[("froide_govplan/plugins/default.html", "Normal")], 68 | help_text="template used to display the plugin", 69 | max_length=250, 70 | verbose_name="template", 71 | ), 72 | ), 73 | ( 74 | "categories", 75 | models.ManyToManyField( 76 | blank=True, to="publicbody.Category", verbose_name="categories" 77 | ), 78 | ), 79 | ( 80 | "government", 81 | models.ForeignKey( 82 | blank=True, 83 | null=True, 84 | on_delete=django.db.models.deletion.SET_NULL, 85 | to="froide_govplan.government", 86 | ), 87 | ), 88 | ], 89 | options={ 90 | "abstract": False, 91 | }, 92 | bases=("cms.cmsplugin",), 93 | ), 94 | ] 95 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0005_governmentplansection.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-14 10:14 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import cms.models.fields 8 | import filer.fields.image 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ("publicbody", "0039_publicbody_alternative_emails"), 15 | migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), 16 | ("cms", "0022_auto_20180620_1551"), 17 | ("froide_govplan", "0004_auto_20220311_2330"), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="GovernmentPlanSection", 23 | fields=[ 24 | ( 25 | "id", 26 | models.AutoField( 27 | auto_created=True, 28 | primary_key=True, 29 | serialize=False, 30 | verbose_name="ID", 31 | ), 32 | ), 33 | ("title", models.CharField(max_length=255, verbose_name="title")), 34 | ( 35 | "slug", 36 | models.SlugField(max_length=255, unique=True, verbose_name="slug"), 37 | ), 38 | ( 39 | "description", 40 | models.TextField(blank=True, verbose_name="description"), 41 | ), 42 | ( 43 | "icon", 44 | models.CharField( 45 | blank=True, 46 | help_text='Enter an icon name from the FontAwesome 4 icon set', 47 | max_length=50, 48 | verbose_name="Icon", 49 | ), 50 | ), 51 | ("order", models.PositiveIntegerField(default=0)), 52 | ("featured", models.DateTimeField(blank=True, null=True)), 53 | ( 54 | "categories", 55 | models.ManyToManyField(blank=True, to="publicbody.Category"), 56 | ), 57 | ( 58 | "content_placeholder", 59 | cms.models.fields.PlaceholderField( 60 | editable=False, 61 | null=True, 62 | on_delete=django.db.models.deletion.CASCADE, 63 | slotname="content", 64 | to="cms.placeholder", 65 | ), 66 | ), 67 | ( 68 | "government", 69 | models.ForeignKey( 70 | on_delete=django.db.models.deletion.CASCADE, 71 | to="froide_govplan.government", 72 | verbose_name="government", 73 | ), 74 | ), 75 | ( 76 | "image", 77 | filer.fields.image.FilerImageField( 78 | blank=True, 79 | default=None, 80 | null=True, 81 | on_delete=django.db.models.deletion.SET_NULL, 82 | to=settings.FILER_IMAGE_MODEL, 83 | verbose_name="image", 84 | ), 85 | ), 86 | ], 87 | options={ 88 | "verbose_name": "Government plan section", 89 | "verbose_name_plural": "Government plan sections", 90 | "ordering": ("order", "title"), 91 | }, 92 | ), 93 | ] 94 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0012_government_georegion_alter_government_jurisdiction_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-03 10:00 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("cms", "0022_auto_20180620_1551"), 11 | ("publicbody", "0043_merge_20221019_1020"), 12 | ("georegion", "0011_georegion_invalid_on"), 13 | ("froide_govplan", "0011_governmentplan_properties"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name="government", 19 | name="georegion", 20 | field=models.ForeignKey( 21 | blank=True, 22 | null=True, 23 | on_delete=django.db.models.deletion.SET_NULL, 24 | to="georegion.georegion", 25 | verbose_name="georegion", 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="government", 30 | name="jurisdiction", 31 | field=models.ForeignKey( 32 | blank=True, 33 | null=True, 34 | on_delete=django.db.models.deletion.SET_NULL, 35 | to="publicbody.jurisdiction", 36 | verbose_name="jurisdiction", 37 | ), 38 | ), 39 | migrations.AlterField( 40 | model_name="governmentplanscmsplugin", 41 | name="cmsplugin_ptr", 42 | field=models.OneToOneField( 43 | auto_created=True, 44 | on_delete=django.db.models.deletion.CASCADE, 45 | parent_link=True, 46 | primary_key=True, 47 | related_name="%(app_label)s_%(class)s", 48 | serialize=False, 49 | to="cms.cmsplugin", 50 | ), 51 | ), 52 | migrations.AlterField( 53 | model_name="governmentplanscmsplugin", 54 | name="template", 55 | field=models.CharField( 56 | blank=True, 57 | choices=[ 58 | ("froide_govplan/plugins/default.html", "Normal"), 59 | ("froide_govplan/plugins/progress.html", "Progress"), 60 | ("froide_govplan/plugins/progress_row.html", "Progress Row"), 61 | ("froide_govplan/plugins/time_used.html", "Time used"), 62 | ("froide_govplan/plugins/card_cols.html", "Card columns"), 63 | ("froide_govplan/plugins/search.html", "Search"), 64 | ], 65 | help_text="template used to display the plugin", 66 | max_length=250, 67 | verbose_name="template", 68 | ), 69 | ), 70 | migrations.AlterField( 71 | model_name="governmentplansectionscmsplugin", 72 | name="cmsplugin_ptr", 73 | field=models.OneToOneField( 74 | auto_created=True, 75 | on_delete=django.db.models.deletion.CASCADE, 76 | parent_link=True, 77 | primary_key=True, 78 | related_name="%(app_label)s_%(class)s", 79 | serialize=False, 80 | to="cms.cmsplugin", 81 | ), 82 | ), 83 | migrations.AlterField( 84 | model_name="governmentplanupdatescmsplugin", 85 | name="cmsplugin_ptr", 86 | field=models.OneToOneField( 87 | auto_created=True, 88 | on_delete=django.db.models.deletion.CASCADE, 89 | parent_link=True, 90 | primary_key=True, 91 | related_name="%(app_label)s_%(class)s", 92 | serialize=False, 93 | to="cms.cmsplugin", 94 | ), 95 | ), 96 | ] 97 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0004_auto_20220311_2330.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-11 22:30 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ("froide_govplan", "0003_auto_20220228_1051"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="governmentplanscmsplugin", 19 | name="template", 20 | field=models.CharField( 21 | blank=True, 22 | choices=[ 23 | ("froide_govplan/plugins/default.html", "Normal"), 24 | ("froide_govplan/plugins/progress.html", "Progress"), 25 | ("froide_govplan/plugins/card_cols.html", "Card columns"), 26 | ], 27 | help_text="template used to display the plugin", 28 | max_length=250, 29 | verbose_name="template", 30 | ), 31 | ), 32 | migrations.CreateModel( 33 | name="GovernmentPlanFollower", 34 | fields=[ 35 | ( 36 | "id", 37 | models.AutoField( 38 | auto_created=True, 39 | primary_key=True, 40 | serialize=False, 41 | verbose_name="ID", 42 | ), 43 | ), 44 | ("email", models.CharField(blank=True, max_length=255)), 45 | ("confirmed", models.BooleanField(default=False)), 46 | ( 47 | "timestamp", 48 | models.DateTimeField( 49 | default=django.utils.timezone.now, 50 | verbose_name="Timestamp of Following", 51 | ), 52 | ), 53 | ("context", models.JSONField(blank=True, null=True)), 54 | ( 55 | "content_object", 56 | models.ForeignKey( 57 | on_delete=django.db.models.deletion.CASCADE, 58 | related_name="followers", 59 | to="froide_govplan.governmentplan", 60 | verbose_name="Government plan", 61 | ), 62 | ), 63 | ( 64 | "user", 65 | models.ForeignKey( 66 | blank=True, 67 | null=True, 68 | on_delete=django.db.models.deletion.CASCADE, 69 | to=settings.AUTH_USER_MODEL, 70 | verbose_name="User", 71 | ), 72 | ), 73 | ], 74 | options={ 75 | "verbose_name": "Government plan follower", 76 | "verbose_name_plural": "Government plan followers", 77 | "ordering": ("-timestamp",), 78 | "get_latest_by": "timestamp", 79 | "abstract": False, 80 | }, 81 | ), 82 | migrations.AddConstraint( 83 | model_name="governmentplanfollower", 84 | constraint=models.UniqueConstraint( 85 | condition=models.Q(("user__isnull", False)), 86 | fields=("content_object", "user"), 87 | name="unique_user_follower_froide_govplan_governmentplanfollower", 88 | ), 89 | ), 90 | migrations.AddConstraint( 91 | model_name="governmentplanfollower", 92 | constraint=models.UniqueConstraint( 93 | condition=models.Q(("user__isnull", True)), 94 | fields=("content_object", "email"), 95 | name="unique_email_follower_froide_govplan_governmentplanfollower", 96 | ), 97 | ), 98 | ] 99 | -------------------------------------------------------------------------------- /froide_govplan/plan_importer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | from django.template.defaultfilters import slugify 5 | 6 | from froide.publicbody.models import Category, PublicBody 7 | 8 | from .models import GovernmentPlan, GovernmentPlanSection 9 | 10 | 11 | class PlanImporter(object): 12 | def __init__(self, government, col_mapping=None): 13 | if col_mapping is None: 14 | col_mapping = {} 15 | self.col_mapping = col_mapping 16 | self.government = government 17 | self.post_save_list = [] 18 | 19 | def import_rows(self, reader): 20 | for row in reader: 21 | self.import_row(row) 22 | 23 | def import_row(self, row): 24 | print("importing", row) 25 | title = row[self.col_mapping["title"]] 26 | if not title: 27 | return 28 | plan = GovernmentPlan.objects.filter( 29 | government=self.government, title=title 30 | ).first() 31 | 32 | if not plan: 33 | plan = GovernmentPlan(government=self.government) 34 | 35 | self.post_save_list = [] 36 | for col, row_col in self.col_mapping.items(): 37 | method_name = "handle_{}".format(col) 38 | if hasattr(self, method_name): 39 | getattr(self, method_name)(plan, row[row_col]) 40 | else: 41 | setattr(plan, col, row[row_col]) 42 | plan.save() 43 | for func in self.post_save_list: 44 | func(plan) 45 | 46 | def handle_title(self, plan, title): 47 | plan.title = title 48 | plan.slug = slugify(title) 49 | 50 | def handle_categories(self, plan, category_name): 51 | categories = [ 52 | x.strip() for x in re.split(r", | & | und ", category_name) if x.strip() 53 | ] 54 | self.make_section(category_name, "-".join(categories), categories) 55 | if categories: 56 | self.post_save_list.append(lambda p: p.categories.set(*categories)) 57 | 58 | def make_section(self, section_name, section_slug, categories): 59 | slug = slugify(section_slug) 60 | section, _created = GovernmentPlanSection.objects.get_or_create( 61 | slug=slug, 62 | defaults={ 63 | "government": self.government, 64 | "title": section_name, 65 | }, 66 | ) 67 | section.categories.set([self.get_category(c) for c in categories]) 68 | 69 | def get_category(self, cat_name): 70 | return Category.objects.get(name=cat_name) 71 | 72 | def handle_reference(self, plan, reference): 73 | plan.reference = ", ".join(re.split(r"\s*[,/]\s*", reference)) 74 | 75 | def handle_responsible_publicbody(self, plan, pb): 76 | if not pb.strip(): 77 | return 78 | pb = PublicBody.objects.get( 79 | jurisdiction=self.government.jurisdiction, 80 | other_names__iregex=r"(\W|^){}(\W|$)".format(pb), 81 | ) 82 | plan.responsible_publicbody = pb 83 | 84 | def handle_due_date(self, plan, date_descr): 85 | if not date_descr.strip(): 86 | return 87 | 88 | def parse_date(date_descr): 89 | match = re.search(r"(\d{4})", date_descr) 90 | if not match: 91 | return 92 | year = int(match.group(1)) 93 | if "Mitte" in date_descr: 94 | return datetime.date(year, 7, 1) 95 | if "Ende" in date_descr: 96 | return datetime.date(year, 12, 1) 97 | if "Anfang" in date_descr: 98 | return datetime.date(year, 3, 1) 99 | if "Innerhalb" in date_descr: 100 | return datetime.date(year, 12, 31) 101 | if "Juni" in date_descr: 102 | return datetime.date(year, 6, 1) 103 | return datetime.date(year, 1, 1) 104 | 105 | plan.due_date = parse_date(date_descr) 106 | 107 | def handle_status(self, plan, status): 108 | status = status.strip() 109 | if not status or status == "noch nicht umgesetzt": 110 | status = "not_started" 111 | if status == "umgesetzt": 112 | status = "implemented" 113 | if status == "begonnen": 114 | status = "started" 115 | plan.status = status 116 | -------------------------------------------------------------------------------- /froide_govplan/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.mixins import LoginRequiredMixin 3 | from django.shortcuts import get_object_or_404, redirect, render 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.views.generic import DetailView, UpdateView 6 | from froide.helper.breadcrumbs import BreadcrumbView 7 | 8 | from .auth import get_visible_plans 9 | from .forms import GovernmentPlanUpdateProposalForm 10 | from .models import Government, GovernmentPlan, GovernmentPlanSection 11 | 12 | 13 | class GovernmentMixin(BreadcrumbView): 14 | def dispatch(self, *args, **kwargs): 15 | self.get_government() 16 | return super().dispatch(*args, **kwargs) 17 | 18 | def get_government(self): 19 | filter_kwarg = {} 20 | if not self.request.user.is_authenticated or not self.request.user.is_staff: 21 | filter_kwarg["public"] = True 22 | self.government = get_object_or_404( 23 | Government, slug=self.kwargs["gov"], **filter_kwarg 24 | ) 25 | 26 | def get_context_data(self, **kwargs): 27 | context = super().get_context_data(**kwargs) 28 | context["government"] = self.government 29 | return context 30 | 31 | def get_breadcrumbs(self, context): 32 | breadcrumbs = [] 33 | if "request" in context: 34 | request = context["request"] 35 | 36 | title = request.current_page.get_title() 37 | url = request.current_page.get_absolute_url() 38 | breadcrumbs.append((title, url)) 39 | 40 | breadcrumbs.append((self.government.name, self.government.get_absolute_url())) 41 | return breadcrumbs 42 | 43 | 44 | class GovPlanSectionDetailView(GovernmentMixin, DetailView): 45 | slug_url_kwarg = "section" 46 | template_name = "froide_govplan/section.html" 47 | 48 | def get_queryset(self): 49 | return GovernmentPlanSection.objects.filter( 50 | government=self.government 51 | ).select_related("government") 52 | 53 | def get_context_data(self, **kwargs): 54 | context = super().get_context_data(**kwargs) 55 | queryset = get_visible_plans(self.request) 56 | context["plans"] = context["object"].get_plans(queryset=queryset) 57 | return context 58 | 59 | def get_breadcrumbs(self, context): 60 | return super().get_breadcrumbs(context) + [ 61 | (self.object.title, self.object.get_absolute_url()) 62 | ] 63 | 64 | 65 | class GovPlanDetailView(GovernmentMixin, DetailView): 66 | slug_url_kwarg = "plan" 67 | template_name = "froide_govplan/detail.html" 68 | 69 | def get_queryset(self): 70 | qs = GovernmentPlan.objects.filter(government=self.government) 71 | if self.request.user.is_authenticated and self.request.user.is_staff: 72 | return qs 73 | return qs.filter(public=True).select_related( 74 | "responsible_publicbody", "organization" 75 | ) 76 | 77 | def get_context_data(self, **kwargs): 78 | context = super().get_context_data(**kwargs) 79 | context["updates"] = self.object.updates.filter(public=True).order_by( 80 | "-timestamp" 81 | ) 82 | context["section"] = self.object.get_section() 83 | if self.request.user.is_authenticated: 84 | context["update_proposal_form"] = GovernmentPlanUpdateProposalForm() 85 | # For CMS toolbar 86 | self.request.govplan = self.object 87 | return context 88 | 89 | def get_breadcrumbs(self, context): 90 | obj = context["object"] 91 | section = context["section"] 92 | 93 | breadcrumbs = super().get_breadcrumbs(context) 94 | 95 | if section: 96 | breadcrumbs.append((section.title, section.get_absolute_url())) 97 | 98 | return breadcrumbs + [ 99 | (obj.title, obj.get_absolute_url()), 100 | ] 101 | 102 | 103 | class GovPlanProposeUpdateView(GovernmentMixin, LoginRequiredMixin, UpdateView): 104 | slug_url_kwarg = "plan" 105 | form_class = GovernmentPlanUpdateProposalForm 106 | 107 | def get(self, request, *args, **kwargs): 108 | self.object = self.get_object() 109 | return redirect(self.object) 110 | 111 | def get_queryset(self): 112 | qs = GovernmentPlan.objects.filter( 113 | government=self.government, government__active=True 114 | ) 115 | if self.request.user.is_authenticated and self.request.user.is_staff: 116 | return qs 117 | return qs.filter(public=True) 118 | 119 | def get_success_url(self): 120 | return self.object.get_absolute_url() 121 | 122 | def form_valid(self, form): 123 | form.save(self.object, self.request.user) 124 | messages.add_message( 125 | self.request, 126 | messages.INFO, 127 | _( 128 | "Thank you for your proposal. We will send you an email when it has been approved." 129 | ), 130 | ) 131 | return redirect(self.object) 132 | 133 | def form_invalid(self, form): 134 | messages.add_message( 135 | self.request, 136 | messages.ERROR, 137 | _("There's been an error with your form submission."), 138 | ) 139 | return redirect(self.object) 140 | 141 | 142 | def search(request): 143 | q = request.GET.get("q", "") 144 | plans = GovernmentPlan.objects.filter(public=True) 145 | if q: 146 | plans = GovernmentPlan.objects.search(q, qs=plans) 147 | 148 | if request.GET.get("government"): 149 | try: 150 | gov_id = int(request.GET["government"]) 151 | plans = plans.filter(government_id=gov_id) 152 | except ValueError: 153 | pass 154 | if request.GET.get("status"): 155 | try: 156 | status = request.GET["status"] 157 | plans = plans.filter(status=status) 158 | except ValueError: 159 | pass 160 | 161 | if q: 162 | # limit when there's a search 163 | plans = plans[:20] 164 | return render( 165 | request, "froide_govplan/plugins/card_cols.html", {"object_list": plans} 166 | ) 167 | -------------------------------------------------------------------------------- /project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | from django.utils.translation import gettext_lazy as _ 17 | 18 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | PROJECT_DIR = Path(__file__).resolve().parent 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = "django-insecure-8qh(ha_fw#3mc#y+rtd^se+e$-+n5%1o*d3%3p0d379q1zxypu" 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | # "djangocms_admin_style", 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.sites", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | "django.contrib.humanize", 46 | "django.contrib.gis", 47 | # Froide apps 48 | "froide_govplan.apps.FroideGovPlanConfig", 49 | "froide.georegion", 50 | "froide.publicbody", 51 | "froide.follow", 52 | "froide.organization", 53 | "froide.team", 54 | "froide.helper", 55 | # Third party apps 56 | "easy_thumbnails", 57 | "filer", 58 | "sekizai", 59 | "cms", 60 | "djangocms_alias", 61 | "menus", 62 | "treebeard", 63 | "taggit", 64 | "tinymce", 65 | ] 66 | 67 | MIDDLEWARE = [ 68 | "django.middleware.security.SecurityMiddleware", 69 | "django.middleware.locale.LocaleMiddleware", # needs to be before CommonMiddleware 70 | "django.contrib.sessions.middleware.SessionMiddleware", 71 | "django.middleware.common.CommonMiddleware", 72 | "django.middleware.csrf.CsrfViewMiddleware", 73 | "django.contrib.auth.middleware.AuthenticationMiddleware", 74 | "django.contrib.messages.middleware.MessageMiddleware", 75 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 76 | "cms.middleware.user.CurrentUserMiddleware", 77 | "cms.middleware.page.CurrentPageMiddleware", 78 | "cms.middleware.toolbar.ToolbarMiddleware", 79 | "cms.middleware.language.LanguageCookieMiddleware", 80 | ] 81 | 82 | ROOT_URLCONF = "project.urls" 83 | 84 | TEMPLATES = [ 85 | { 86 | "BACKEND": "django.template.backends.django.DjangoTemplates", 87 | "DIRS": [ 88 | PROJECT_DIR / "templates", 89 | ], 90 | "APP_DIRS": True, 91 | "OPTIONS": { 92 | "context_processors": [ 93 | "django.template.context_processors.debug", 94 | "django.template.context_processors.request", 95 | "django.contrib.auth.context_processors.auth", 96 | "django.contrib.messages.context_processors.messages", 97 | "sekizai.context_processors.sekizai", 98 | "cms.context_processors.cms_settings", 99 | "froide.helper.context_processors.site_settings", 100 | ], 101 | }, 102 | }, 103 | ] 104 | 105 | WSGI_APPLICATION = "project.wsgi.application" 106 | 107 | 108 | # Database 109 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 110 | 111 | DATABASES = { 112 | "default": { 113 | "ENGINE": "django.contrib.gis.db.backends.postgis", # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 114 | # "NAME": "fragdenstaat_de", 115 | "NAME": "govplan", 116 | "USER": "govplan", 117 | "PASSWORD": "govplan", 118 | "HOST": "localhost", # Set to empty string for localhost. Not used with sqlite3. 119 | "PORT": "5432", # Set to empty string for default. Not used with sqlite3. 120 | } 121 | } 122 | GDAL_LIBRARY_PATH = os.environ.get("GDAL_LIBRARY_PATH") 123 | GEOS_LIBRARY_PATH = os.environ.get("GEOS_LIBRARY_PATH") 124 | 125 | 126 | # Password validation 127 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 128 | 129 | AUTH_PASSWORD_VALIDATORS = [ 130 | { 131 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 132 | }, 133 | { 134 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 135 | }, 136 | { 137 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 138 | }, 139 | { 140 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 141 | }, 142 | ] 143 | 144 | 145 | # Internationalization 146 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 147 | 148 | LANGUAGE_CODE = "de" 149 | LANGUAGES = [("de", _("German"))] 150 | 151 | 152 | TIME_ZONE = "UTC" 153 | 154 | USE_I18N = True 155 | 156 | USE_TZ = True 157 | 158 | 159 | # Static files (CSS, JavaScript, Images) 160 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 161 | 162 | STATIC_URL = "static/" 163 | 164 | # Default primary key field type 165 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 166 | 167 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 168 | # AUTH_USER_MODEL = "account.User" 169 | 170 | TAGGIT_CASE_INSENSITIVE = True 171 | TAGGIT_STRIP_UNICODE_WHEN_SLUGIFYING = True 172 | 173 | LOGIN_URL = "admin:login" 174 | X_FRAME_OPTIONS = "SAMEORIGIN" 175 | 176 | # Django CMS 177 | 178 | CMS_TEMPLATES = [ 179 | ("froide_govplan/base.html", "Govplan base template"), 180 | ] 181 | 182 | # Froide 183 | 184 | SITE_ID = 1 185 | SITE_NAME = "GovPlan" 186 | SITE_URL = "http://localhost:8000" 187 | SITE_EMAIL = "info@example.com" 188 | SITE_LOGO = "" 189 | 190 | FROIDE_CONFIG = { 191 | "bounce_enabled": False, 192 | "bounce_format": "bounce+{token}@example.com", 193 | "bounce_max_age": 60 * 60 * 24 * 14, # 14 days 194 | "unsubscribe_enabled": False, 195 | "unsubscribe_format": "unsub+{token}@example.com", 196 | } 197 | 198 | ELASTICSEARCH_INDEX_PREFIX = "govplan" 199 | CMS_CONFIRM_VERSION4 = True 200 | 201 | # Govplan settings 202 | 203 | GOVPLAN_NAME = "GovPlan" 204 | GOVPLAN_ENABLE_FOIREQUEST = False 205 | -------------------------------------------------------------------------------- /froide_govplan/forms.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import nh3 4 | from django import forms 5 | from django.conf import settings 6 | from django.contrib.auth import get_user_model 7 | from django.utils import timezone 8 | from django.utils.safestring import mark_safe 9 | from django.utils.translation import gettext_lazy as _ 10 | from froide.helper.widgets import BootstrapSelect 11 | from tinymce.widgets import TinyMCE 12 | 13 | from .models import GovernmentPlan, GovernmentPlanUpdate, PlanRating, PlanStatus 14 | 15 | ALLOWED_TAGS = { 16 | "a", 17 | "strong", 18 | "b", 19 | "i", 20 | "em", 21 | "ul", 22 | "ol", 23 | "li", 24 | "p", 25 | "h3", 26 | "h4", 27 | "h5", 28 | "blockquote", 29 | } 30 | 31 | 32 | class BleachField(forms.CharField): 33 | """Bleach form field""" 34 | 35 | def to_python(self, value): 36 | """ 37 | Strips any dodgy HTML tags from the input. 38 | Mark the return value as template safe. 39 | """ 40 | if value in self.empty_values: 41 | return self.empty_value 42 | cleaned_html = nh3.clean(value, tags=ALLOWED_TAGS, link_rel="noopener") 43 | return mark_safe(cleaned_html) 44 | 45 | 46 | class GovernmentPlanForm(forms.ModelForm): 47 | description = BleachField( 48 | required=False, widget=TinyMCE(attrs={"cols": 80, "rows": 30}) 49 | ) 50 | 51 | class Meta: 52 | model = GovernmentPlan 53 | fields = "__all__" 54 | 55 | 56 | class GovernmentPlanUpdateForm(forms.ModelForm): 57 | content = BleachField( 58 | required=False, widget=TinyMCE(attrs={"cols": 80, "rows": 30}) 59 | ) 60 | 61 | class Meta: 62 | model = GovernmentPlanUpdate 63 | fields = "__all__" 64 | 65 | 66 | class GovernmentPlanUpdateProposalForm(forms.ModelForm): 67 | title = forms.CharField( 68 | label=_("title"), 69 | help_text=_("Summarize the update in a title."), 70 | widget=forms.TextInput( 71 | attrs={ 72 | "class": "form-control", 73 | } 74 | ), 75 | ) 76 | content = forms.CharField( 77 | required=False, 78 | label=_("details"), 79 | help_text=_("Optionally give more details."), 80 | widget=forms.Textarea(attrs={"class": "form-control", "rows": "3"}), 81 | ) 82 | url = forms.URLField( 83 | label=_("source URL"), 84 | help_text=_("Please give provide a link."), 85 | widget=forms.URLInput( 86 | attrs={"class": "form-control", "placeholder": "https://"} 87 | ), 88 | ) 89 | status = forms.ChoiceField( 90 | label=_("status"), 91 | help_text=_("Has the status of the plan changed?"), 92 | choices=[("", "---")] + PlanStatus.choices, 93 | required=False, 94 | widget=BootstrapSelect, 95 | ) 96 | rating = forms.TypedChoiceField( 97 | label=_("rating"), 98 | help_text=_("What's your rating of the current implementation?"), 99 | choices=[("", "---")] + PlanRating.choices, 100 | coerce=int, 101 | empty_value=None, 102 | required=False, 103 | widget=BootstrapSelect, 104 | ) 105 | 106 | class Meta: 107 | model = GovernmentPlanUpdate 108 | fields = ( 109 | "title", 110 | "content", 111 | "url", 112 | "status", 113 | "rating", 114 | ) 115 | 116 | def __init__(self, *args, **kwargs): 117 | super().__init__(*args, **kwargs) 118 | 119 | def save(self, plan, user): 120 | """ 121 | This doesn't save instance, but saves 122 | the change proposal. 123 | """ 124 | data = self.cleaned_data 125 | plan.proposals = plan.proposals or {} 126 | plan.proposals[user.id] = { 127 | "data": data, 128 | "timestamp": timezone.now().isoformat(), 129 | } 130 | plan.save(update_fields=["proposals"]) 131 | return plan 132 | 133 | 134 | class GovernmentPlanUpdateAcceptProposalForm(GovernmentPlanUpdateProposalForm): 135 | def __init__(self, *args, **kwargs): 136 | self.plan = kwargs.pop("plan") 137 | super().__init__(*args, **kwargs) 138 | 139 | def get_proposals(self): 140 | data = copy.deepcopy(self.plan.proposals) 141 | user_ids = self.plan.proposals.keys() 142 | user_map = { 143 | str(u.id): u for u in get_user_model().objects.filter(id__in=user_ids) 144 | } 145 | status_dict = dict(PlanStatus.choices) 146 | rating_dict = dict(PlanRating.choices) 147 | for user_id, v in data.items(): 148 | v["user"] = user_map[user_id] 149 | data[user_id]["data"]["rating_label"] = rating_dict.get( 150 | data[user_id]["data"]["rating"] 151 | ) 152 | data[user_id]["data"]["status_label"] = status_dict.get( 153 | data[user_id]["data"]["status"] 154 | ) 155 | return data 156 | 157 | def save( 158 | self, 159 | proposal_id=None, 160 | delete_proposals=None, 161 | ): 162 | update = super(forms.ModelForm, self).save(commit=False) 163 | update.plan = self.plan 164 | 165 | if delete_proposals is None: 166 | delete_proposals = [] 167 | if proposal_id: 168 | proposals = self.get_proposals() 169 | proposal_user = proposals[proposal_id]["user"] 170 | proposal_user.send_mail( 171 | _("Your proposal for the plan “%s” was accepted") % self.plan.title, 172 | _( 173 | "Hello,\n\nA moderator has accepted your proposal for an update to the plan " 174 | "“{title}”. An update will be published soon.\n\nAll the Best,\n{site_name}" 175 | ).format(title=self.plan.title, site_name=settings.SITE_NAME), 176 | priority=False, 177 | ) 178 | user_org = proposal_user.organization_set.all().first() 179 | if user_org: 180 | update.organization = user_org 181 | delete_proposals.append(proposal_id) 182 | 183 | self.delete_proposals(delete_proposals) 184 | update.save() 185 | return update 186 | 187 | def delete_proposals(self, delete_proposals): 188 | for pid in delete_proposals: 189 | if pid in self.plan.proposals: 190 | del self.plan.proposals[pid] 191 | if not self.plan.proposals: 192 | self.plan.proposals = None 193 | self.plan.save(update_fields=["proposals"]) 194 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2022-02-15 19:55 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | import filer.fields.image 9 | import taggit.managers 10 | 11 | from .. import conf 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | initial = True 17 | 18 | dependencies = ( 19 | [ 20 | ("publicbody", "0039_publicbody_alternative_emails"), 21 | ("auth", "0012_alter_user_first_name_max_length"), 22 | ] 23 | + ( 24 | [ 25 | ("foirequest", "0054_alter_foirequest_options"), 26 | ] 27 | if conf.GOVPLAN_ENABLE_FOIREQUEST 28 | else [] 29 | ) 30 | + [ 31 | migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), 32 | ("organization", "0001_initial"), 33 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 34 | ] 35 | ) 36 | 37 | operations = [ 38 | migrations.CreateModel( 39 | name="CategorizedGovernmentPlan", 40 | fields=[ 41 | ( 42 | "id", 43 | models.AutoField( 44 | auto_created=True, 45 | primary_key=True, 46 | serialize=False, 47 | verbose_name="ID", 48 | ), 49 | ), 50 | ], 51 | options={ 52 | "verbose_name": "Categorized Government Plan", 53 | "verbose_name_plural": "Categorized Government Plans", 54 | }, 55 | ), 56 | migrations.CreateModel( 57 | name="Government", 58 | fields=[ 59 | ( 60 | "id", 61 | models.AutoField( 62 | auto_created=True, 63 | primary_key=True, 64 | serialize=False, 65 | verbose_name="ID", 66 | ), 67 | ), 68 | ("name", models.CharField(max_length=255)), 69 | ("slug", models.SlugField(max_length=255, unique=True)), 70 | ("public", models.BooleanField(default=False)), 71 | ("description", models.TextField(blank=True)), 72 | ("start_date", models.DateField(blank=True, null=True)), 73 | ("end_date", models.DateField(blank=True, null=True)), 74 | ("planning_document", models.URLField(blank=True)), 75 | ( 76 | "jurisdiction", 77 | models.ForeignKey( 78 | null=True, 79 | on_delete=django.db.models.deletion.SET_NULL, 80 | to="publicbody.jurisdiction", 81 | ), 82 | ), 83 | ], 84 | options={ 85 | "verbose_name": "Government", 86 | "verbose_name_plural": "Governments", 87 | }, 88 | ), 89 | migrations.CreateModel( 90 | name="GovernmentPlan", 91 | fields=[ 92 | ( 93 | "id", 94 | models.AutoField( 95 | auto_created=True, 96 | primary_key=True, 97 | serialize=False, 98 | verbose_name="ID", 99 | ), 100 | ), 101 | ("title", models.CharField(max_length=255)), 102 | ("slug", models.SlugField(max_length=255, unique=True)), 103 | ("description", models.TextField(blank=True)), 104 | ("public", models.BooleanField(default=False)), 105 | ( 106 | "status", 107 | models.CharField( 108 | choices=[ 109 | ("not_started", "not started"), 110 | ("started", "started"), 111 | ("partially_implemented", "partially implemented"), 112 | ("implemented", "implemented"), 113 | ("deferred", "deferred"), 114 | ], 115 | default="needs_approval", 116 | max_length=25, 117 | ), 118 | ), 119 | ( 120 | "rating", 121 | models.IntegerField( 122 | blank=True, 123 | choices=[ 124 | (1, "terrible"), 125 | (2, "bad"), 126 | (3, "OK"), 127 | (4, "good"), 128 | (5, "excellent"), 129 | ], 130 | null=True, 131 | ), 132 | ), 133 | ("reference", models.CharField(blank=True, max_length=255)), 134 | ( 135 | "categories", 136 | taggit.managers.TaggableManager( 137 | blank=True, 138 | help_text="A comma-separated list of tags.", 139 | through="froide_govplan.CategorizedGovernmentPlan", 140 | to="publicbody.Category", 141 | verbose_name="categories", 142 | ), 143 | ), 144 | ( 145 | "government", 146 | models.ForeignKey( 147 | on_delete=django.db.models.deletion.CASCADE, 148 | to="froide_govplan.government", 149 | ), 150 | ), 151 | ( 152 | "group", 153 | models.ForeignKey( 154 | blank=True, 155 | null=True, 156 | on_delete=django.db.models.deletion.SET_NULL, 157 | to="auth.group", 158 | ), 159 | ), 160 | ( 161 | "image", 162 | filer.fields.image.FilerImageField( 163 | blank=True, 164 | default=None, 165 | null=True, 166 | on_delete=django.db.models.deletion.SET_NULL, 167 | to=settings.FILER_IMAGE_MODEL, 168 | verbose_name="image", 169 | ), 170 | ), 171 | ( 172 | "responsible_publicbody", 173 | models.ForeignKey( 174 | blank=True, 175 | null=True, 176 | on_delete=django.db.models.deletion.SET_NULL, 177 | to="publicbody.publicbody", 178 | ), 179 | ), 180 | ], 181 | options={ 182 | "verbose_name": "Government plan", 183 | "verbose_name_plural": "Government plans", 184 | "ordering": ("reference", "title"), 185 | }, 186 | ), 187 | migrations.CreateModel( 188 | name="GovernmentPlanUpdate", 189 | fields=[ 190 | ( 191 | "id", 192 | models.AutoField( 193 | auto_created=True, 194 | primary_key=True, 195 | serialize=False, 196 | verbose_name="ID", 197 | ), 198 | ), 199 | ("timestamp", models.DateTimeField(default=django.utils.timezone.now)), 200 | ("title", models.CharField(blank=True, max_length=1024)), 201 | ("content", models.TextField(blank=True)), 202 | ( 203 | "status", 204 | models.CharField( 205 | blank=True, 206 | choices=[ 207 | ("not_started", "not started"), 208 | ("started", "started"), 209 | ("partially_implemented", "partially implemented"), 210 | ("implemented", "implemented"), 211 | ("deferred", "deferred"), 212 | ], 213 | default="", 214 | max_length=25, 215 | ), 216 | ), 217 | ( 218 | "rating", 219 | models.IntegerField( 220 | blank=True, 221 | choices=[ 222 | (1, "terrible"), 223 | (2, "bad"), 224 | (3, "OK"), 225 | (4, "good"), 226 | (5, "excellent"), 227 | ], 228 | null=True, 229 | ), 230 | ), 231 | ("public", models.BooleanField(default=False)), 232 | ] 233 | + ( 234 | [ 235 | ( 236 | "foirequest", 237 | models.ForeignKey( 238 | blank=True, 239 | null=True, 240 | on_delete=django.db.models.deletion.SET_NULL, 241 | to="foirequest.foirequest", 242 | ), 243 | ) 244 | ] 245 | if conf.GOVPLAN_ENABLE_FOIREQUEST 246 | else [] 247 | ) 248 | + [ 249 | ( 250 | "organization", 251 | models.ForeignKey( 252 | blank=True, 253 | null=True, 254 | on_delete=django.db.models.deletion.SET_NULL, 255 | to="organization.organization", 256 | ), 257 | ), 258 | ( 259 | "plan", 260 | models.ForeignKey( 261 | on_delete=django.db.models.deletion.CASCADE, 262 | related_name="updates", 263 | to="froide_govplan.governmentplan", 264 | ), 265 | ), 266 | ( 267 | "user", 268 | models.ForeignKey( 269 | blank=True, 270 | null=True, 271 | on_delete=django.db.models.deletion.SET_NULL, 272 | to=settings.AUTH_USER_MODEL, 273 | ), 274 | ), 275 | ], 276 | options={ 277 | "verbose_name": "Plan update", 278 | "verbose_name_plural": "Plan updates", 279 | "ordering": ("-timestamp",), 280 | "get_latest_by": "timestamp", 281 | }, 282 | ), 283 | migrations.AddField( 284 | model_name="categorizedgovernmentplan", 285 | name="content_object", 286 | field=models.ForeignKey( 287 | on_delete=django.db.models.deletion.CASCADE, 288 | to="froide_govplan.governmentplan", 289 | ), 290 | ), 291 | migrations.AddField( 292 | model_name="categorizedgovernmentplan", 293 | name="tag", 294 | field=models.ForeignKey( 295 | on_delete=django.db.models.deletion.CASCADE, 296 | related_name="categorized_governmentplan", 297 | to="publicbody.category", 298 | ), 299 | ), 300 | ] 301 | -------------------------------------------------------------------------------- /froide_govplan/admin.py: -------------------------------------------------------------------------------- 1 | from adminsortable2.admin import SortableAdminMixin 2 | from django.contrib import admin, auth 3 | from django.contrib.auth.models import Group 4 | from django.shortcuts import get_object_or_404, redirect, render 5 | from django.urls import path, reverse, reverse_lazy 6 | from django.utils.translation import gettext_lazy as _ 7 | from froide.follow.admin import FollowerAdmin 8 | from froide.helper.admin_utils import make_choose_object_action, make_emptyfilter 9 | from froide.helper.widgets import TagAutocompleteWidget 10 | from froide.organization.models import Organization 11 | 12 | from . import conf 13 | from .auth import get_allowed_plans, has_limited_access 14 | from .forms import ( 15 | GovernmentPlanForm, 16 | GovernmentPlanUpdateAcceptProposalForm, 17 | GovernmentPlanUpdateForm, 18 | ) 19 | from .models import ( 20 | Government, 21 | GovernmentPlan, 22 | GovernmentPlanFollower, 23 | GovernmentPlanSection, 24 | GovernmentPlanUpdate, 25 | ) 26 | 27 | User = auth.get_user_model() 28 | 29 | 30 | class GovPlanAdminSite(admin.AdminSite): 31 | site_header = conf.GOVPLAN_NAME 32 | site_url = None 33 | 34 | 35 | class GovernmentPlanAdminForm(GovernmentPlanForm): 36 | class Meta: 37 | model = GovernmentPlan 38 | fields = "__all__" 39 | widgets = { 40 | "categories": TagAutocompleteWidget( 41 | autocomplete_url=reverse_lazy("api:category-autocomplete") 42 | ), 43 | } 44 | 45 | 46 | class GovernmentAdmin(admin.ModelAdmin): 47 | prepopulated_fields = {"slug": ("name",)} 48 | list_display = ("name", "public", "start_date", "end_date") 49 | list_filter = ("public",) 50 | raw_id_fields = ("georegion",) 51 | 52 | 53 | def execute_assign_organization(admin, request, queryset, action_obj): 54 | queryset.update(organization=action_obj) 55 | 56 | 57 | def execute_assign_group(admin, request, queryset, action_obj): 58 | queryset.update(group=action_obj) 59 | 60 | 61 | PLAN_ACTIONS = { 62 | "assign_organization": make_choose_object_action( 63 | Organization, execute_assign_organization, _("Assign organization...") 64 | ), 65 | "assign_group": make_choose_object_action( 66 | Group, execute_assign_group, _("Assign permission group...") 67 | ), 68 | } 69 | 70 | 71 | class GovernmentPlanAdmin(admin.ModelAdmin): 72 | form = GovernmentPlanForm 73 | 74 | save_on_top = True 75 | prepopulated_fields = {"slug": ("title",)} 76 | search_fields = ("title",) 77 | raw_id_fields = ("responsible_publicbody",) 78 | 79 | actions = ["make_public"] 80 | 81 | def get_queryset(self, request): 82 | qs = get_allowed_plans(request) 83 | qs = qs.prefetch_related( 84 | "categories", 85 | "organization", 86 | "group", 87 | ) 88 | return qs 89 | 90 | def get_actions(self, request): 91 | actions = super().get_actions(request) 92 | if not has_limited_access(request.user): 93 | admin_actions = { 94 | action: ( 95 | func, 96 | action, 97 | func.short_description, 98 | ) 99 | for action, func in PLAN_ACTIONS.items() 100 | } 101 | actions.update(admin_actions) 102 | return actions 103 | 104 | def get_urls(self): 105 | urls = super().get_urls() 106 | my_urls = [ 107 | path( 108 | "/accept-proposal/", 109 | self.admin_site.admin_view(self.accept_proposal), 110 | name="froide_govplan-plan_accept_proposal", 111 | ), 112 | ] 113 | return my_urls + urls 114 | 115 | def get_list_display(self, request): 116 | list_display = [ 117 | "title", 118 | "public", 119 | "status", 120 | "rating", 121 | "organization", 122 | "get_categories", 123 | ] 124 | if not has_limited_access(request.user): 125 | list_display.append("group") 126 | return list_display 127 | 128 | def get_list_filter(self, request): 129 | list_filter = [ 130 | "status", 131 | "rating", 132 | "public", 133 | ] 134 | if not has_limited_access(request.user): 135 | list_filter.extend( 136 | [ 137 | make_emptyfilter( 138 | "proposals", _("Has change proposals"), empty_value=None 139 | ), 140 | "organization", 141 | "group", 142 | "government", 143 | "categories", 144 | ] 145 | ) 146 | return list_filter 147 | 148 | def get_fields(self, request, obj=None): 149 | if has_limited_access(request.user): 150 | return ( 151 | "title", 152 | "slug", 153 | "description", 154 | "quote", 155 | "public", 156 | "due_date", 157 | "measure", 158 | "status", 159 | "rating", 160 | "reference", 161 | ) 162 | return super().get_fields(request, obj=obj) 163 | 164 | def get_categories(self, obj): 165 | """ 166 | Return the categories linked in HTML. 167 | """ 168 | categories = [category.name for category in obj.categories.all()] 169 | return ", ".join(categories) 170 | 171 | get_categories.short_description = _("category(s)") 172 | 173 | def make_public(self, request, queryset): 174 | queryset.update(public=True) 175 | 176 | make_public.short_description = _("Make public") 177 | 178 | def accept_proposal(self, request, pk): 179 | obj = get_object_or_404(self.get_queryset(request), pk=pk) 180 | plan_url = reverse( 181 | "admin:froide_govplan_governmentplan_change", 182 | args=(obj.pk,), 183 | current_app=self.admin_site.name, 184 | ) 185 | if not obj.proposals: 186 | return redirect(plan_url) 187 | if request.method == "POST": 188 | proposals = obj.proposals or {} 189 | proposal_id = request.POST.get("proposal_id") 190 | delete_proposals = request.POST.getlist("proposal_delete") 191 | update = None 192 | if proposal_id: 193 | data = proposals[proposal_id]["data"] 194 | form = GovernmentPlanUpdateAcceptProposalForm(data=data, plan=obj) 195 | if form.is_valid(): 196 | update = form.save( 197 | proposal_id=proposal_id, 198 | delete_proposals=delete_proposals, 199 | ) 200 | else: 201 | form = GovernmentPlanUpdateAcceptProposalForm(data={}, plan=obj) 202 | form.delete_proposals(delete_proposals) 203 | 204 | if update is None: 205 | self.message_user(request, _("The proposal has been deleted.")) 206 | 207 | return redirect(plan_url) 208 | 209 | self.message_user( 210 | request, 211 | _("An unpublished update has been created."), 212 | ) 213 | update_url = reverse( 214 | "admin:froide_govplan_governmentplanupdate_change", 215 | args=(update.pk,), 216 | current_app=self.admin_site.name, 217 | ) 218 | return redirect(update_url) 219 | else: 220 | form = GovernmentPlanUpdateAcceptProposalForm(plan=obj) 221 | 222 | opts = self.model._meta 223 | context = { 224 | "form": form, 225 | "proposals": form.get_proposals(), 226 | "object": obj, 227 | "app_label": opts.app_label, 228 | "opts": opts, 229 | } 230 | return render( 231 | request, 232 | "froide_govplan/admin/accept_proposal.html", 233 | context, 234 | ) 235 | 236 | 237 | class GovernmentPlanUpdateAdmin(admin.ModelAdmin): 238 | form = GovernmentPlanUpdateForm 239 | save_on_top = True 240 | raw_id_fields = ["user"] + ( 241 | ["foirequest"] if conf.GOVPLAN_ENABLE_FOIREQUEST else [] 242 | ) 243 | date_hierarchy = "timestamp" 244 | search_fields = ("title", "content") 245 | list_display = ( 246 | "title", 247 | "timestamp", 248 | "plan", 249 | "user", 250 | "status", 251 | "rating", 252 | "public", 253 | ) 254 | list_filter = ( 255 | "status", 256 | "public", 257 | "organization", 258 | ) 259 | search_fields = ( 260 | "title", 261 | "plan__title", 262 | ) 263 | date_hierarchy = "timestamp" 264 | 265 | def get_queryset(self, request): 266 | qs = super().get_queryset(request) 267 | qs = qs.prefetch_related( 268 | "plan", 269 | "user", 270 | ) 271 | if has_limited_access(request.user): 272 | qs = qs.filter(plan__in=get_allowed_plans(request)) 273 | return qs 274 | 275 | def save_model(self, request, obj, form, change): 276 | limited = has_limited_access(request.user) 277 | if not change and limited: 278 | # When added by a limited user, 279 | # autofill user and organization 280 | obj.user = request.user 281 | if obj.plan.organization: 282 | user_has_org = request.user.organization_set.all().filter(pk=1).exists() 283 | if user_has_org: 284 | obj.organization = obj.plan.organization 285 | 286 | res = super().save_model(request, obj, form, change) 287 | 288 | obj.plan.update_from_updates() 289 | 290 | return res 291 | 292 | def get_fields(self, request, obj=None): 293 | if has_limited_access(request.user): 294 | return ( 295 | "plan", 296 | "title", 297 | "timestamp", 298 | "content", 299 | "url", 300 | "status", 301 | "rating", 302 | "public", 303 | ) 304 | return super().get_fields(request, obj=obj) 305 | 306 | def formfield_for_foreignkey(self, db_field, request, **kwargs): 307 | if db_field.name == "plan": 308 | if has_limited_access(request.user): 309 | kwargs["queryset"] = get_allowed_plans(request) 310 | return super().formfield_for_foreignkey(db_field, request, **kwargs) 311 | 312 | def user_in_obj_group(self, request, obj): 313 | if not obj.plan.group_id: 314 | return False 315 | user = request.user 316 | return User.objects.filter(pk=user.pk, groups=obj.plan.group_id).exists() 317 | 318 | def has_view_permission(self, request, obj=None): 319 | if obj and self.user_in_obj_group(request, obj): 320 | return True 321 | return super().has_view_permission(request, obj=obj) 322 | 323 | def has_add_permission(self, request): 324 | return super().has_add_permission(request) 325 | 326 | def has_change_permission(self, request, obj=None): 327 | if obj and self.user_in_obj_group(request, obj): 328 | return True 329 | return super().has_change_permission(request, obj=obj) 330 | 331 | 332 | class GovernmentPlanSectionAdmin(SortableAdminMixin, admin.ModelAdmin): 333 | save_on_top = True 334 | prepopulated_fields = {"slug": ("title",)} 335 | search_fields = ("title",) 336 | raw_id_fields = ("categories",) 337 | list_display = ( 338 | "title", 339 | "featured", 340 | ) 341 | list_filter = ( 342 | "featured", 343 | "categories", 344 | "government", 345 | ) 346 | 347 | 348 | admin.site.register(Government, GovernmentAdmin) 349 | admin.site.register(GovernmentPlan, GovernmentPlanAdmin) 350 | admin.site.register(GovernmentPlanUpdate, GovernmentPlanUpdateAdmin) 351 | admin.site.register(GovernmentPlanSection, GovernmentPlanSectionAdmin) 352 | admin.site.register(GovernmentPlanFollower, FollowerAdmin) 353 | 354 | govplan_admin_site = GovPlanAdminSite(name="govplanadmin") 355 | govplan_admin_site.register(GovernmentPlan, GovernmentPlanAdmin) 356 | govplan_admin_site.register(GovernmentPlanUpdate, GovernmentPlanUpdateAdmin) 357 | -------------------------------------------------------------------------------- /froide_govplan/templates/froide_govplan/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "froide_govplan/base.html" %} 2 | {% load i18n %} 3 | {# TODO: i18n for all strings #} 4 | {% load markup %} 5 | {% load cms_tags %} 6 | {% load follow_tags %} 7 | {% load govplan %} 8 | {% load form_helper %} 9 | {% load content_helper %} 10 | {% load thumbnail %} 11 | {% block title %} 12 | {{ object.title }} 13 | {% endblock title %} 14 | {% block meta %} 15 | {% include "snippets/meta.html" %} 16 | {% endblock meta %} 17 | {% block app_body %} 18 |
    19 |
    20 |
    21 |
    22 | {% if not government.active %} 23 |
    Dieses Vorhaben gehörte zur {{ government.name }} und wird nicht mehr aktualisiert.
    24 | {% endif %} 25 |

    {{ object.title }}

    26 |
    27 |
      28 |
    • 29 | {{ object.get_status_display }} 30 |
    • 31 | {% for cat in object.categories.all %} 32 |
    • 33 | {{ cat.name }} 35 |
    • 36 | {% endfor %} 37 |
    38 | {% if government.active %} 39 |
    {% show_follow "govplan" object %}
    40 | {% endif %} 41 |
    42 |
    43 |
    44 |
    45 | {% if object.quote %} 46 |
    Ausschnitt aus dem Koalitionsvertrag
    47 |
    48 |
    49 | {{ object.quote | addquotes | markdown }} 50 |
    51 | {% with refs=object.get_reference_links %} 52 | {% if refs %} 53 |

    54 | 55 | {% if refs|length > 1 %} 56 | Quellen: 57 | {% else %} 58 | Quelle: 59 | {% endif %} 60 | 61 | {% for ref in refs %} 62 | {{ forloop.counter }} 63 | {% endfor %} 64 |

    65 | {% endif %} 66 | {% endwith %} 67 | {% endif %} 68 |
    69 | {% if object.description %} 70 |
    71 |
    Unsere Einschätzung
    72 |
    73 | {{ object.description | safe }} 74 |
    75 |
    76 | {% endif %} 77 |
    78 |
    79 |
    80 |
    81 | {% if object.rating %} 82 |
    Bewertung
    83 |
    84 | {{ object.get_rating_display }} 85 |
    86 | {% endif %} 87 | {% if object.measure %} 88 |
    Art der Umsetzung
    89 |
    90 | {{ object.measure }} 91 |
    92 | {% endif %} 93 | {% if object.due_date %} 94 |
    Frist
    95 |
    96 | {{ object.due_date|date:"SHORT_DATE_FORMAT" }} 97 |
    98 | {% endif %} 99 | {% if object.responsible_publicbody %} 100 |
    Federführung
    101 |
    102 | {{ object.responsible_publicbody.name }} 103 |
    104 | {% endif %} 105 | {% if object.responsible_publicbody %} 106 | {% if not object.has_recent_foirequest and government.active %} 107 |

    108 | Anfrage zum Vorhaben stellen 111 |

    112 | {% elif object.has_recent_foirequest %} 113 | {% with foirequest=object.get_recent_foirequest %} 114 |
    Anfrage
    115 |
    116 | {% include "foirequest/snippets/request_item_mini.html" with object=foirequest %} 117 |
    118 | {% endwith %} 119 | {% endif %} 120 | {% endif %} 121 | {% if object.organization %} 122 |
    Beobachtet von
    123 |
    124 | 125 | {% if object.organization.logo %} 126 | {% if object.organization.logo.url.lower|slice:"-4:" == ".svg" %} 127 | {# djlint:off H006 #} 128 | {{ object.organization.name }} 131 | {% else %} 132 | {{ object.organization.name }} 135 | {% endif %} 136 | {% else %} 137 | {{ object.organization.name }} 138 | {% endif %} 139 | 140 |
    141 | {% endif %} 142 |
    143 |
    144 |
    145 |
    146 |
    147 |
    148 |
    149 | {% include "froide_govplan/plugins/updates.html" with wrapper_classes="col col-12 col-lg-6 d-flex mb-4" %} 150 | {% if government.active %} 151 |
    152 |
    153 |
    154 |
    155 |

    Neue Entwicklung melden

    156 |
    157 |
    158 | {% if request.user.is_authenticated %} 159 |

    Gibt es Neuigkeiten zu diesem Vorhaben, die wir noch nicht erfasst haben?

    160 | 164 | 189 | {% else %} 190 | Bitte melden Sie sich an, um einen Änderungsvorschlag einzureichen. 191 | {% endif %} 192 |
    193 |
    194 |
    195 |
    196 | {% endif %} 197 |
    198 |
    199 | {% endblock app_body %} 200 | -------------------------------------------------------------------------------- /froide_govplan/migrations/0003_auto_20220228_1051.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-02-28 09:51 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | from .. import conf 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ("publicbody", "0039_publicbody_alternative_emails"), 16 | ("organization", "0001_initial"), 17 | ("auth", "0012_alter_user_first_name_max_length"), 18 | ("froide_govplan", "0002_auto_20220215_2113"), 19 | ] + ( 20 | [("foirequest", "0054_alter_foirequest_options")] 21 | if conf.GOVPLAN_ENABLE_FOIREQUEST 22 | else [] 23 | ) 24 | 25 | operations = ( 26 | [ 27 | migrations.AddField( 28 | model_name="governmentplan", 29 | name="due_date", 30 | field=models.DateField(blank=True, null=True, verbose_name="due date"), 31 | ), 32 | migrations.AddField( 33 | model_name="governmentplan", 34 | name="measure", 35 | field=models.CharField( 36 | blank=True, max_length=255, verbose_name="measure" 37 | ), 38 | ), 39 | migrations.AddField( 40 | model_name="governmentplan", 41 | name="quote", 42 | field=models.TextField(blank=True, verbose_name="quote"), 43 | ), 44 | migrations.AlterField( 45 | model_name="government", 46 | name="description", 47 | field=models.TextField(blank=True, verbose_name="description"), 48 | ), 49 | migrations.AlterField( 50 | model_name="government", 51 | name="end_date", 52 | field=models.DateField(blank=True, null=True, verbose_name="end date"), 53 | ), 54 | migrations.AlterField( 55 | model_name="government", 56 | name="jurisdiction", 57 | field=models.ForeignKey( 58 | null=True, 59 | on_delete=django.db.models.deletion.SET_NULL, 60 | to="publicbody.jurisdiction", 61 | verbose_name="jurisdiction", 62 | ), 63 | ), 64 | migrations.AlterField( 65 | model_name="government", 66 | name="name", 67 | field=models.CharField(max_length=255, verbose_name="name"), 68 | ), 69 | migrations.AlterField( 70 | model_name="government", 71 | name="planning_document", 72 | field=models.URLField(blank=True, verbose_name="planning document"), 73 | ), 74 | migrations.AlterField( 75 | model_name="government", 76 | name="public", 77 | field=models.BooleanField(default=False, verbose_name="is public?"), 78 | ), 79 | migrations.AlterField( 80 | model_name="government", 81 | name="slug", 82 | field=models.SlugField( 83 | max_length=255, unique=True, verbose_name="slug" 84 | ), 85 | ), 86 | migrations.AlterField( 87 | model_name="government", 88 | name="start_date", 89 | field=models.DateField( 90 | blank=True, null=True, verbose_name="start date" 91 | ), 92 | ), 93 | migrations.AlterField( 94 | model_name="governmentplan", 95 | name="description", 96 | field=models.TextField(blank=True, verbose_name="description"), 97 | ), 98 | migrations.AlterField( 99 | model_name="governmentplan", 100 | name="government", 101 | field=models.ForeignKey( 102 | on_delete=django.db.models.deletion.CASCADE, 103 | to="froide_govplan.government", 104 | verbose_name="government", 105 | ), 106 | ), 107 | migrations.AlterField( 108 | model_name="governmentplan", 109 | name="group", 110 | field=models.ForeignKey( 111 | blank=True, 112 | null=True, 113 | on_delete=django.db.models.deletion.SET_NULL, 114 | to="auth.group", 115 | verbose_name="group", 116 | ), 117 | ), 118 | migrations.AlterField( 119 | model_name="governmentplan", 120 | name="organization", 121 | field=models.ForeignKey( 122 | blank=True, 123 | null=True, 124 | on_delete=django.db.models.deletion.SET_NULL, 125 | to="organization.organization", 126 | verbose_name="organization", 127 | ), 128 | ), 129 | migrations.AlterField( 130 | model_name="governmentplan", 131 | name="public", 132 | field=models.BooleanField(default=False, verbose_name="is public?"), 133 | ), 134 | migrations.AlterField( 135 | model_name="governmentplan", 136 | name="rating", 137 | field=models.IntegerField( 138 | blank=True, 139 | choices=[ 140 | (1, "terrible"), 141 | (2, "bad"), 142 | (3, "OK"), 143 | (4, "good"), 144 | (5, "excellent"), 145 | ], 146 | null=True, 147 | verbose_name="rating", 148 | ), 149 | ), 150 | migrations.AlterField( 151 | model_name="governmentplan", 152 | name="reference", 153 | field=models.CharField( 154 | blank=True, max_length=255, verbose_name="reference" 155 | ), 156 | ), 157 | migrations.AlterField( 158 | model_name="governmentplan", 159 | name="responsible_publicbody", 160 | field=models.ForeignKey( 161 | blank=True, 162 | null=True, 163 | on_delete=django.db.models.deletion.SET_NULL, 164 | to="publicbody.publicbody", 165 | verbose_name="responsible public body", 166 | ), 167 | ), 168 | migrations.AlterField( 169 | model_name="governmentplan", 170 | name="slug", 171 | field=models.SlugField( 172 | max_length=255, unique=True, verbose_name="slug" 173 | ), 174 | ), 175 | migrations.AlterField( 176 | model_name="governmentplan", 177 | name="status", 178 | field=models.CharField( 179 | choices=[ 180 | ("not_started", "not started"), 181 | ("started", "started"), 182 | ("partially_implemented", "partially implemented"), 183 | ("implemented", "implemented"), 184 | ("deferred", "deferred"), 185 | ], 186 | default="not_started", 187 | max_length=25, 188 | verbose_name="status", 189 | ), 190 | ), 191 | migrations.AlterField( 192 | model_name="governmentplan", 193 | name="title", 194 | field=models.CharField(max_length=255, verbose_name="title"), 195 | ), 196 | migrations.AlterField( 197 | model_name="governmentplanupdate", 198 | name="content", 199 | field=models.TextField(blank=True, verbose_name="content"), 200 | ), 201 | ] 202 | + ( 203 | [ 204 | migrations.AlterField( 205 | model_name="governmentplanupdate", 206 | name="foirequest", 207 | field=models.ForeignKey( 208 | blank=True, 209 | null=True, 210 | on_delete=django.db.models.deletion.SET_NULL, 211 | to="foirequest.foirequest", 212 | verbose_name="FOI request", 213 | ), 214 | ), 215 | ] 216 | if conf.GOVPLAN_ENABLE_FOIREQUEST 217 | else [] 218 | ) 219 | + [ 220 | migrations.AlterField( 221 | model_name="governmentplanupdate", 222 | name="organization", 223 | field=models.ForeignKey( 224 | blank=True, 225 | null=True, 226 | on_delete=django.db.models.deletion.SET_NULL, 227 | to="organization.organization", 228 | verbose_name="organization", 229 | ), 230 | ), 231 | migrations.AlterField( 232 | model_name="governmentplanupdate", 233 | name="plan", 234 | field=models.ForeignKey( 235 | on_delete=django.db.models.deletion.CASCADE, 236 | related_name="updates", 237 | to="froide_govplan.governmentplan", 238 | verbose_name="plan", 239 | ), 240 | ), 241 | migrations.AlterField( 242 | model_name="governmentplanupdate", 243 | name="public", 244 | field=models.BooleanField(default=False, verbose_name="is public?"), 245 | ), 246 | migrations.AlterField( 247 | model_name="governmentplanupdate", 248 | name="rating", 249 | field=models.IntegerField( 250 | blank=True, 251 | choices=[ 252 | (1, "terrible"), 253 | (2, "bad"), 254 | (3, "OK"), 255 | (4, "good"), 256 | (5, "excellent"), 257 | ], 258 | null=True, 259 | verbose_name="rating", 260 | ), 261 | ), 262 | migrations.AlterField( 263 | model_name="governmentplanupdate", 264 | name="status", 265 | field=models.CharField( 266 | blank=True, 267 | choices=[ 268 | ("not_started", "not started"), 269 | ("started", "started"), 270 | ("partially_implemented", "partially implemented"), 271 | ("implemented", "implemented"), 272 | ("deferred", "deferred"), 273 | ], 274 | default="", 275 | max_length=25, 276 | verbose_name="status", 277 | ), 278 | ), 279 | migrations.AlterField( 280 | model_name="governmentplanupdate", 281 | name="timestamp", 282 | field=models.DateTimeField( 283 | default=django.utils.timezone.now, verbose_name="timestamp" 284 | ), 285 | ), 286 | migrations.AlterField( 287 | model_name="governmentplanupdate", 288 | name="title", 289 | field=models.CharField( 290 | blank=True, max_length=1024, verbose_name="title" 291 | ), 292 | ), 293 | migrations.AlterField( 294 | model_name="governmentplanupdate", 295 | name="url", 296 | field=models.URLField(blank=True, verbose_name="URL"), 297 | ), 298 | migrations.AlterField( 299 | model_name="governmentplanupdate", 300 | name="user", 301 | field=models.ForeignKey( 302 | blank=True, 303 | null=True, 304 | on_delete=django.db.models.deletion.SET_NULL, 305 | to=settings.AUTH_USER_MODEL, 306 | verbose_name="user", 307 | ), 308 | ), 309 | ] 310 | ) 311 | -------------------------------------------------------------------------------- /froide_govplan/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-11-23 13:50+0100\n" 11 | "PO-Revision-Date: 2022-11-23 13:51+0100\n" 12 | "Last-Translator: Stefan Wehrmeyer \n" 13 | "Language-Team: \n" 14 | "Language: de\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 3.1.1\n" 20 | 21 | #: froide_govplan/admin.py 22 | msgid "Assign organization..." 23 | msgstr "Organisation zuweisen..." 24 | 25 | #: froide_govplan/admin.py 26 | msgid "Assign permission group..." 27 | msgstr "Berechtigungsgruppe zuweisen..." 28 | 29 | #: froide_govplan/admin.py 30 | msgid "Has change proposals" 31 | msgstr "Hat Update-Vorschläge" 32 | 33 | #: froide_govplan/admin.py 34 | msgid "category(s)" 35 | msgstr "Kategorie(n)" 36 | 37 | #: froide_govplan/admin.py 38 | msgid "Make public" 39 | msgstr "Veröffentlichen" 40 | 41 | #: froide_govplan/admin.py 42 | msgid "The proposal has been deleted." 43 | msgstr "Der Vorschlag wurde gelöscht." 44 | 45 | #: froide_govplan/admin.py 46 | msgid "An unpublished update has been created." 47 | msgstr "Eine unveröffentlichtes Update wurde erstellt." 48 | 49 | #: froide_govplan/apps.py 50 | msgid "GovPlan App" 51 | msgstr "Regierungsvorhaben-App" 52 | 53 | #: froide_govplan/cms_apps.py 54 | msgid "GovPlan CMS App" 55 | msgstr "Regierungsvorhaben-CMS-App" 56 | 57 | #: froide_govplan/cms_plugins.py froide_govplan/configuration.py 58 | #: froide_govplan/models.py 59 | msgid "Government plans" 60 | msgstr "Regierungsvorhaben" 61 | 62 | #: froide_govplan/cms_plugins.py froide_govplan/models.py 63 | msgid "Government plan sections" 64 | msgstr "Regierungsvorhaben-Themenbereiche" 65 | 66 | #: froide_govplan/cms_plugins.py 67 | msgid "Government plan updates" 68 | msgstr "Entwicklungen bei Regierungsvorhaben" 69 | 70 | #: froide_govplan/cms_toolbars.py 71 | msgid "Edit plans and updates" 72 | msgstr "Übersicht öffnen" 73 | 74 | #: froide_govplan/cms_toolbars.py 75 | msgid "Edit government plan" 76 | msgstr "Bearbeite Regierungsvorhaben" 77 | 78 | #: froide_govplan/cms_toolbars.py 79 | msgid "Add update" 80 | msgstr "Neue Entwicklung hinzufügen" 81 | 82 | #: froide_govplan/configuration.py 83 | msgid "You are now following this plan." 84 | msgstr "Sie folgen jetzt diesem Vorhaben." 85 | 86 | #: froide_govplan/configuration.py 87 | msgid "You are not following this plan anymore." 88 | msgstr "Sie folgen diesem Vorhaben nicht mehr." 89 | 90 | #: froide_govplan/configuration.py 91 | msgid "" 92 | "Check your emails and click the confirmation link in order to follow this " 93 | "government plan." 94 | msgstr "" 95 | "Rufen Sie Ihre E-Mails ab und klicken Sie auf den Bestätigungs-Link um " 96 | "Benachrichtigungen für dieses Regierungsvorhaben zu erhalten." 97 | 98 | #: froide_govplan/configuration.py 99 | msgid "Follow plan" 100 | msgstr "Vorhaben folgen" 101 | 102 | #: froide_govplan/configuration.py 103 | msgid "Follow plan?" 104 | msgstr "Vorhaben folgen?" 105 | 106 | #: froide_govplan/configuration.py 107 | msgid "Unfollow plan" 108 | msgstr "Vorhaben entfolgen" 109 | 110 | #: froide_govplan/configuration.py 111 | msgid "Following plan" 112 | msgstr "Vorhaben folgend" 113 | 114 | #: froide_govplan/configuration.py 115 | msgid "" 116 | "You will get notifications via email when something new happens with this " 117 | "plan. You can unsubscribe anytime." 118 | msgstr "" 119 | "Sie erhalten Benachrichtigungen per E-Mail, sobald es Neuigkeiten zu diesem " 120 | "Vorhaben gibt. Sie können die Benachrichtigungen jederzeit abbestellen." 121 | 122 | #: froide_govplan/configuration.py 123 | #, python-brace-format 124 | msgid "" 125 | "please confirm that you want to follow the plan “{title}” by clicking this " 126 | "link:" 127 | msgstr "" 128 | "Bitte bestätige, dass du dem Regierungsvorhaben „{title}“ folgen willst, in " 129 | "dem du diesen Link anklickst:" 130 | 131 | #: froide_govplan/configuration.py 132 | msgid "Government Plans" 133 | msgstr "Regierungsvorhaben" 134 | 135 | #: froide_govplan/configuration.py 136 | #, python-brace-format 137 | msgid "An update was posted for the government plan “{title}”." 138 | msgstr "Es gibt Neuigkeiten zum Regierungsvorhaben „{title}”." 139 | 140 | #: froide_govplan/forms.py froide_govplan/models.py 141 | msgid "title" 142 | msgstr "Titel" 143 | 144 | #: froide_govplan/forms.py 145 | msgid "Summarize the update in a title." 146 | msgstr "Fasse die Entwicklung in einem Titel zusammen." 147 | 148 | #: froide_govplan/forms.py 149 | msgid "details" 150 | msgstr "Details" 151 | 152 | #: froide_govplan/forms.py 153 | msgid "Optionally give more details." 154 | msgstr "Optional kannst du noch mehr Details angeben." 155 | 156 | #: froide_govplan/forms.py 157 | msgid "source URL" 158 | msgstr "Beleg-URL" 159 | 160 | #: froide_govplan/forms.py 161 | msgid "Please give provide a link." 162 | msgstr "Bitte gib einen Link an." 163 | 164 | #: froide_govplan/forms.py froide_govplan/models.py 165 | msgid "status" 166 | msgstr "Status" 167 | 168 | #: froide_govplan/forms.py 169 | msgid "Has the status of the plan changed?" 170 | msgstr "Hat sich der Status des Vorhabens geändert?" 171 | 172 | #: froide_govplan/forms.py froide_govplan/models.py 173 | msgid "rating" 174 | msgstr "Bewertung" 175 | 176 | #: froide_govplan/forms.py 177 | msgid "What's your rating of the current implementation?" 178 | msgstr "Wie würdest du die aktuelle Umsetzung bewerten?" 179 | 180 | #: froide_govplan/forms.py 181 | #, python-format 182 | msgid "Your proposal for the plan “%s” was accepted" 183 | msgstr "Ihr Vorschlag für das Vorhaben „%s“ wurde angenommen" 184 | 185 | #: froide_govplan/forms.py 186 | #, python-brace-format 187 | msgid "" 188 | "Hello,\n" 189 | "\n" 190 | "A moderator has accepted your proposal for an update to the plan “{title}”. " 191 | "An update will be published soon.\n" 192 | "\n" 193 | "All the Best,\n" 194 | "{site_name}" 195 | msgstr "" 196 | "Hallo,\n" 197 | "\n" 198 | "eine Moderationsperson hat Ihren Vorschlag für eine Aktualisierung des " 199 | "Regierungsvorhabens „{title}“ angenommen. Die neue Entwicklung wird " 200 | "demnächst veröffentlicht.\n" 201 | "\n" 202 | "Beste Grüße\n" 203 | "{site_name}" 204 | 205 | #: froide_govplan/models.py 206 | msgid "not started" 207 | msgstr "nicht begonnen" 208 | 209 | #: froide_govplan/models.py 210 | msgid "started" 211 | msgstr "begonnen" 212 | 213 | #: froide_govplan/models.py 214 | msgid "partially implemented" 215 | msgstr "teilweise umgesetzt" 216 | 217 | #: froide_govplan/models.py 218 | msgid "implemented" 219 | msgstr "umgesetzt" 220 | 221 | #: froide_govplan/models.py 222 | msgid "deferred" 223 | msgstr "verschoben" 224 | 225 | #: froide_govplan/models.py 226 | msgid "terrible" 227 | msgstr "sehr schlecht" 228 | 229 | #: froide_govplan/models.py 230 | msgid "bad" 231 | msgstr "schlecht" 232 | 233 | #: froide_govplan/models.py 234 | msgid "OK" 235 | msgstr "mittelmäßig" 236 | 237 | #: froide_govplan/models.py 238 | msgid "good" 239 | msgstr "gut" 240 | 241 | #: froide_govplan/models.py 242 | msgid "excellent" 243 | msgstr "sehr gut" 244 | 245 | #: froide_govplan/models.py 246 | msgid "name" 247 | msgstr "Name" 248 | 249 | #: froide_govplan/models.py 250 | msgid "slug" 251 | msgstr "URL-Kürzel" 252 | 253 | #: froide_govplan/models.py 254 | msgid "is public?" 255 | msgstr "Ist öffentlich?" 256 | 257 | #: froide_govplan/models.py 258 | msgid "jurisdiction" 259 | msgstr "Zuständigkeit" 260 | 261 | #: froide_govplan/models.py 262 | msgid "description" 263 | msgstr "Beschreibung" 264 | 265 | #: froide_govplan/models.py 266 | msgid "start date" 267 | msgstr "Startdatum" 268 | 269 | #: froide_govplan/models.py 270 | msgid "end date" 271 | msgstr "Enddatum" 272 | 273 | #: froide_govplan/models.py 274 | msgid "planning document" 275 | msgstr "Planungsdokument" 276 | 277 | #: froide_govplan/models.py 278 | msgid "Government" 279 | msgstr "Regierung" 280 | 281 | #: froide_govplan/models.py 282 | msgid "Governments" 283 | msgstr "Regierungen" 284 | 285 | #: froide_govplan/models.py 286 | msgid "Categorized Government Plan" 287 | msgstr "Kategorisiertes Regierungsvorhaben" 288 | 289 | #: froide_govplan/models.py 290 | msgid "Categorized Government Plans" 291 | msgstr "Kategorisierte Regierungsvorhaben" 292 | 293 | #: froide_govplan/models.py 294 | msgid "government" 295 | msgstr "Regierung" 296 | 297 | #: froide_govplan/models.py 298 | msgid "image" 299 | msgstr "Bild" 300 | 301 | #: froide_govplan/models.py 302 | msgid "quote" 303 | msgstr "Zitat" 304 | 305 | #: froide_govplan/models.py 306 | msgid "due date" 307 | msgstr "Frist" 308 | 309 | #: froide_govplan/models.py 310 | msgid "measure" 311 | msgstr "Art des Vorhabens" 312 | 313 | #: froide_govplan/models.py 314 | msgid "reference" 315 | msgstr "Fundstelle" 316 | 317 | #: froide_govplan/models.py 318 | msgid "categories" 319 | msgstr "Kategorien" 320 | 321 | #: froide_govplan/models.py 322 | msgid "responsible public body" 323 | msgstr "verantwortliche Behörde" 324 | 325 | #: froide_govplan/models.py 326 | msgid "organization" 327 | msgstr "Organisation" 328 | 329 | #: froide_govplan/models.py 330 | msgid "group" 331 | msgstr "Gruppe" 332 | 333 | #: froide_govplan/models.py 334 | msgid "Government plan" 335 | msgstr "Regierungsvorhaben" 336 | 337 | #: froide_govplan/models.py 338 | msgid "plan" 339 | msgstr "Vorhaben" 340 | 341 | #: froide_govplan/models.py 342 | msgid "user" 343 | msgstr "Nutzer:in" 344 | 345 | #: froide_govplan/models.py 346 | msgid "timestamp" 347 | msgstr "Zeitpunkt" 348 | 349 | #: froide_govplan/models.py 350 | msgid "content" 351 | msgstr "Inhalt" 352 | 353 | #: froide_govplan/models.py 354 | msgid "URL" 355 | msgstr "URL" 356 | 357 | #: froide_govplan/models.py 358 | msgid "FOI request" 359 | msgstr "IFG-Anfrage" 360 | 361 | #: froide_govplan/models.py 362 | msgid "Plan update" 363 | msgstr "Entwicklung bei Vorhaben" 364 | 365 | #: froide_govplan/models.py 366 | msgid "Plan updates" 367 | msgstr "Entwicklungen bei Vorhaben" 368 | 369 | #: froide_govplan/models.py 370 | msgid "Government plan follower" 371 | msgstr "Regierungsvorhaben-Follower:in" 372 | 373 | #: froide_govplan/models.py 374 | msgid "Government plan followers" 375 | msgstr "Regierungsvorhaben-Follower:innen" 376 | 377 | #: froide_govplan/models.py 378 | msgid "Icon" 379 | msgstr "Icon" 380 | 381 | #: froide_govplan/models.py 382 | msgid "" 383 | "Enter an icon name from the FontAwesome 4 icon set" 385 | msgstr "" 386 | "Gib ein Icon-Namen aus dem FontAwesome 4 Icon-Set an" 388 | 389 | #: froide_govplan/models.py 390 | msgid "Government plan section" 391 | msgstr "Regierungsvorhaben-Themenbereich" 392 | 393 | #: froide_govplan/models.py 394 | msgid "Normal" 395 | msgstr "Standard" 396 | 397 | #: froide_govplan/models.py 398 | msgid "Progress" 399 | msgstr "Fortschritt" 400 | 401 | #: froide_govplan/models.py 402 | msgid "Progress Row" 403 | msgstr "Fortschritt Zeile" 404 | 405 | #: froide_govplan/models.py 406 | msgid "Time used" 407 | msgstr "Vergangene Zeit" 408 | 409 | #: froide_govplan/models.py 410 | msgid "Card columns" 411 | msgstr "Karten-Spalten" 412 | 413 | #: froide_govplan/models.py 414 | #: froide_govplan/templates/froide_govplan/plugins/search.html 415 | msgid "Search" 416 | msgstr "Suchen" 417 | 418 | #: froide_govplan/models.py 419 | msgid "number of plans" 420 | msgstr "Anzahl der Vorhaben" 421 | 422 | #: froide_govplan/models.py 423 | msgid "0 means all the plans" 424 | msgstr "0 bedeutet alle Vorhaben" 425 | 426 | #: froide_govplan/models.py 427 | msgid "offset" 428 | msgstr "Start" 429 | 430 | #: froide_govplan/models.py 431 | msgid "number of plans to skip from top of list" 432 | msgstr "Anzahl der Vorhaben, die ausgelassen werden" 433 | 434 | #: froide_govplan/models.py 435 | msgid "template" 436 | msgstr "Template" 437 | 438 | #: froide_govplan/models.py 439 | msgid "template used to display the plugin" 440 | msgstr "Template, mit dem das Plugin angezeigt wird" 441 | 442 | #: froide_govplan/models.py 443 | msgid "All matching plans" 444 | msgstr "Alle zutreffenden Vorhaben" 445 | 446 | #: froide_govplan/models.py 447 | #, python-format 448 | msgid "%s matching plans" 449 | msgstr "%s zutreffenden Vorhaben" 450 | 451 | #: froide_govplan/models.py 452 | msgid "number of updates" 453 | msgstr "Anzahl der Entwicklungen" 454 | 455 | #: froide_govplan/models.py 456 | msgid "0 means all the updates" 457 | msgstr "0 bedeutet alle Entwicklungen" 458 | 459 | #: froide_govplan/models.py 460 | msgid "number of updates to skip from top of list" 461 | msgstr "Anzahl der Entwicklungen, die ausgelassen werden" 462 | 463 | #: froide_govplan/models.py 464 | msgid "All matching updates" 465 | msgstr "Alle zutreffenden Entwicklungen" 466 | 467 | #: froide_govplan/models.py 468 | #, python-format 469 | msgid "%s matching updates" 470 | msgstr "%s zutreffende Entwicklungen" 471 | 472 | #: froide_govplan/templates/admin/froide_govplan/governmentplan/change_form.html 473 | msgid "See update proposals" 474 | msgstr "Update-Vorschläge ansehen" 475 | 476 | #: froide_govplan/templates/froide_govplan/admin/_proposal.html 477 | msgid "Field" 478 | msgstr "Feld" 479 | 480 | #: froide_govplan/templates/froide_govplan/admin/_proposal.html 481 | msgid "Proposal" 482 | msgstr "Vorschlag" 483 | 484 | #: froide_govplan/templates/froide_govplan/admin/_proposal.html 485 | msgid "Turn into update draft" 486 | msgstr "In Update-Entwurf umwandeln" 487 | 488 | #: froide_govplan/templates/froide_govplan/admin/_proposal.html 489 | msgid "Delete proposal" 490 | msgstr "Vorschlag löschen" 491 | 492 | #: froide_govplan/templates/froide_govplan/admin/_proposal.html 493 | msgid "Delete" 494 | msgstr "Löschen" 495 | 496 | #: froide_govplan/templates/froide_govplan/admin/accept_proposal.html 497 | msgid "Create update draft" 498 | msgstr "Update-Entwurf anlegen" 499 | 500 | #: froide_govplan/templates/froide_govplan/detail.html 501 | #: froide_govplan/templates/froide_govplan/plugins/search.html 502 | msgid "Close" 503 | msgstr "Schließen" 504 | 505 | #: froide_govplan/templates/froide_govplan/plugins/card_cols.html 506 | msgid "" 507 | "Could not find any results. Try different keywords or browse the categories." 508 | msgstr "" 509 | "Keine Ergebnisse gefunden. Versuchen Sie es mit anderen Stichworten oder " 510 | "durchsuchen Sie passende Kategorien." 511 | 512 | #: froide_govplan/templates/froide_govplan/plugins/search.html 513 | msgid "Search query" 514 | msgstr "Suchbegriff" 515 | 516 | #: froide_govplan/templates/froide_govplan/plugins/search.html 517 | msgid "Filter status" 518 | msgstr "Status filtern" 519 | 520 | #: froide_govplan/templates/froide_govplan/plugins/search.html 521 | msgid "Search results" 522 | msgstr "Suchergebnisse" 523 | 524 | #: froide_govplan/templates/froide_govplan/plugins/time_used.html 525 | #, python-format 526 | msgid "One day left" 527 | msgid_plural "%(days)s days left" 528 | msgstr[0] "Ein Tag übrig" 529 | msgstr[1] "%(days)s Tage übrig" 530 | 531 | #: froide_govplan/templates/froide_govplan/section.html 532 | #, python-format 533 | msgid "" 534 | "\n" 535 | " Here you can find all plans of the section “%(section)s”, " 536 | "which the coalition constituted in their agreement. On the curresponding " 537 | "detail pages, you'll get more information, stay up to date with news or " 538 | "submit changes.\n" 539 | " " 540 | msgstr "" 541 | "\n" 542 | " Hier finden Sie alle Vorhaben aus dem Bereich „%(section)s“, welche die " 543 | "Regierungskoalition im Koalitionsvertrag festgelegt hat. Auf den jeweiligen " 544 | "Detailseiten erhalten Sie mehr Informationen, können Neuigkeiten abonnieren " 545 | "oder Änderungen einreichen. " 546 | 547 | #: froide_govplan/urls.py 548 | msgctxt "url part" 549 | msgid "/plan//" 550 | msgstr "/vorhaben//" 551 | 552 | #: froide_govplan/urls.py 553 | msgctxt "url part" 554 | msgid "/plan//_og/" 555 | msgstr "/vorhaben//_og/" 556 | 557 | #: froide_govplan/urls.py 558 | msgctxt "url part" 559 | msgid "/plan//propose-update/" 560 | msgstr "/vorhaben//entwicklung-melden/" 561 | 562 | #: froide_govplan/urls.py 563 | msgctxt "url part" 564 | msgid "//" 565 | msgstr "//" 566 | 567 | #: froide_govplan/urls.py 568 | msgctxt "url part" 569 | msgid "//_og/" 570 | msgstr "//_og/" 571 | 572 | #: froide_govplan/views.py 573 | msgid "" 574 | "Thank you for your proposal. We will send you an email when it has been " 575 | "approved." 576 | msgstr "" 577 | "Danke für Deinen Vorschlag. Wir senden Dir eine E-Mail, sobald dein Eintrag " 578 | "veröffentlicht wird." 579 | 580 | #: froide_govplan/views.py 581 | msgid "There's been an error with your form submission." 582 | msgstr "Es gab einen Fehler in Ihrer Formulareingabe." 583 | 584 | #~ msgid "" 585 | #~ "Please give a reason for not accepting this proposal. It will be send to " 586 | #~ "the user who made the proposal." 587 | #~ msgstr "" 588 | #~ "Bitte geben Sie einen Grund an, warum Sie diese Einreichung nicht " 589 | #~ "angenommen haben. Der/die Einreicher/in wird darüber informiert." 590 | 591 | #~ msgid "We could not accept this update because..." 592 | #~ msgstr "Wir konnten diese Einreichung nicht annehmen, weil..." 593 | 594 | #~ msgid "Delete proposed update" 595 | #~ msgstr "Vorgeschlagene Entwicklung löschen" 596 | 597 | #~ msgid "Search all government plans…" 598 | #~ msgstr "Durchsuche alle Regierungsvorhaben…" 599 | 600 | #~ msgid "GovPlan" 601 | #~ msgstr "Regierungsvorhaben" 602 | -------------------------------------------------------------------------------- /froide_govplan/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import re 3 | from datetime import timedelta 4 | from urllib.parse import urlparse 5 | 6 | from django.conf import settings 7 | from django.contrib.auth.models import Group 8 | from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector 9 | from django.db import models 10 | from django.urls import reverse 11 | from django.utils import timezone 12 | from django.utils.functional import cached_property 13 | from django.utils.translation import gettext_lazy as _ 14 | from filer.fields.image import FilerImageField 15 | from froide.follow.models import Follower 16 | from froide.georegion.models import GeoRegion 17 | from froide.organization.models import Organization 18 | from froide.publicbody.models import Category, Jurisdiction, PublicBody 19 | from taggit.managers import TaggableManager 20 | from taggit.models import TaggedItemBase 21 | 22 | from . import conf 23 | from .utils import make_request_url 24 | 25 | try: 26 | from cms.models.fields import PlaceholderRelationField 27 | from cms.utils.placeholder import get_placeholder_from_slot 28 | from cms.models.pluginmodel import CMSPlugin 29 | except ImportError: 30 | CMSPlugin = None 31 | PlaceholderRelationField = None 32 | 33 | 34 | if conf.GOVPLAN_ENABLE_FOIREQUEST: 35 | from froide.foirequest.models import FoiRequest 36 | else: 37 | FoiRequest = None 38 | 39 | 40 | class PlanStatus(models.TextChoices): 41 | NOT_STARTED = ("not_started", _("not started")) 42 | STARTED = ("started", _("started")) 43 | PARTIALLY_IMPLEMENTED = ("partially_implemented", _("partially implemented")) 44 | IMPLEMENTED = ("implemented", _("implemented")) 45 | DEFERRED = ("deferred", _("deferred")) 46 | 47 | 48 | STATUS_CSS = { 49 | PlanStatus.NOT_STARTED: "light", 50 | PlanStatus.STARTED: "primary", 51 | PlanStatus.PARTIALLY_IMPLEMENTED: "warning", 52 | PlanStatus.IMPLEMENTED: "success", 53 | PlanStatus.DEFERRED: "danger", 54 | } 55 | 56 | 57 | class PlanRating(models.IntegerChoices): 58 | TERRIBLE = 1, _("terrible") 59 | BAD = 2, _("bad") 60 | OK = 3, _("OK") 61 | GOOD = 4, _("good") 62 | EXCELLENT = 5, _("excellent") 63 | 64 | 65 | class Government(models.Model): 66 | name = models.CharField(max_length=255, verbose_name=_("name")) 67 | slug = models.SlugField(max_length=255, unique=True, verbose_name=_("slug")) 68 | 69 | public = models.BooleanField(default=False, verbose_name=_("is public?")) 70 | jurisdiction = models.ForeignKey( 71 | Jurisdiction, 72 | blank=True, 73 | null=True, 74 | on_delete=models.SET_NULL, 75 | verbose_name=_("jurisdiction"), 76 | ) 77 | georegion = models.ForeignKey( 78 | GeoRegion, 79 | blank=True, 80 | null=True, 81 | on_delete=models.SET_NULL, 82 | verbose_name=_("georegion"), 83 | ) 84 | description = models.TextField(blank=True, verbose_name=_("description")) 85 | 86 | start_date = models.DateField(null=True, blank=True, verbose_name=_("start date")) 87 | end_date = models.DateField(null=True, blank=True, verbose_name=_("end date")) 88 | active = models.BooleanField(default=True, verbose_name=_("active")) 89 | 90 | planning_document = models.URLField(blank=True, verbose_name=_("planning document")) 91 | 92 | class Meta: 93 | verbose_name = _("Government") 94 | verbose_name_plural = _("Governments") 95 | 96 | def __str__(self): 97 | return self.name 98 | 99 | def get_absolute_url(self): 100 | if self.planning_document: 101 | return self.planning_document 102 | # No planning document? Just return the base URL 103 | url = reverse("govplan:search") 104 | return url.rsplit("/", 2)[0] 105 | 106 | @property 107 | def days_available(self): 108 | if self.start_date is None: 109 | return 0 110 | if self.end_date is None: 111 | return 0 112 | return (self.end_date - self.start_date).days 113 | 114 | @property 115 | def days_left(self): 116 | if not self.end_date: 117 | return 0 118 | now = timezone.now().date() 119 | if now > self.end_date: 120 | return 0 121 | return (self.end_date - now).days 122 | 123 | def days_used_percentage(self) -> int: 124 | total_days = self.days_available 125 | if not total_days: 126 | return 0 127 | now = timezone.now().date() 128 | days_used = (now - self.start_date).days 129 | return int(days_used / total_days * 100) 130 | 131 | 132 | class CategorizedGovernmentPlan(TaggedItemBase): 133 | tag = models.ForeignKey( 134 | Category, on_delete=models.CASCADE, related_name="categorized_governmentplan" 135 | ) 136 | content_object = models.ForeignKey("GovernmentPlan", on_delete=models.CASCADE) 137 | 138 | class Meta: 139 | verbose_name = _("Categorized Government Plan") 140 | verbose_name_plural = _("Categorized Government Plans") 141 | 142 | 143 | WORD_RE = re.compile(r"^\w+$", re.IGNORECASE) 144 | 145 | 146 | class GovernmentPlanManager(models.Manager): 147 | SEARCH_LANG = "german" 148 | 149 | def get_search_vector(self): 150 | fields = [ 151 | ("title", "A"), 152 | ("description", "B"), 153 | ("quote", "B"), 154 | ] 155 | return functools.reduce( 156 | lambda a, b: a + b, 157 | [SearchVector(f, weight=w, config=self.SEARCH_LANG) for f, w in fields], 158 | ) 159 | 160 | def search(self, query, qs=None): 161 | if not qs: 162 | qs = self.get_queryset() 163 | query = query.strip() 164 | if not query: 165 | return qs 166 | search_queries = [] 167 | for q in query.split(): 168 | q = q.strip() 169 | if not q: 170 | continue 171 | if WORD_RE.match(q): 172 | sq = SearchQuery( 173 | "{}:*".format(q), search_type="raw", config=self.SEARCH_LANG 174 | ) 175 | else: 176 | sq = SearchQuery(q, search_type="plain", config=self.SEARCH_LANG) 177 | search_queries.append(sq) 178 | 179 | if not search_queries: 180 | return qs 181 | search_query = functools.reduce(lambda a, b: a & b, search_queries) 182 | 183 | search_vector = self.get_search_vector() 184 | qs = ( 185 | qs.annotate(rank=SearchRank(search_vector, search_query)) 186 | .filter(rank__gte=0.1) 187 | .order_by("-rank") 188 | ) 189 | return qs 190 | 191 | 192 | class GovernmentPlan(models.Model): 193 | government = models.ForeignKey( 194 | Government, on_delete=models.CASCADE, verbose_name=_("government") 195 | ) 196 | title = models.CharField(max_length=255, verbose_name=_("title")) 197 | slug = models.SlugField(max_length=255, unique=True, verbose_name=_("slug")) 198 | 199 | image = FilerImageField( 200 | null=True, 201 | blank=True, 202 | default=None, 203 | verbose_name=_("image"), 204 | on_delete=models.SET_NULL, 205 | ) 206 | 207 | description = models.TextField(blank=True, verbose_name=_("description")) 208 | quote = models.TextField(blank=True, verbose_name=_("quote")) 209 | public = models.BooleanField(default=False, verbose_name=_("is public?")) 210 | due_date = models.DateField(null=True, blank=True, verbose_name=_("due date")) 211 | measure = models.CharField(max_length=255, blank=True, verbose_name=_("measure")) 212 | 213 | status = models.CharField( 214 | max_length=25, 215 | choices=PlanStatus.choices, 216 | default="not_started", 217 | verbose_name=_("status"), 218 | ) 219 | rating = models.IntegerField( 220 | choices=PlanRating.choices, null=True, blank=True, verbose_name=_("rating") 221 | ) 222 | 223 | reference = models.CharField( 224 | max_length=255, blank=True, verbose_name=_("reference") 225 | ) 226 | 227 | categories = TaggableManager( 228 | through=CategorizedGovernmentPlan, verbose_name=_("categories"), blank=True 229 | ) 230 | responsible_publicbody = models.ForeignKey( 231 | PublicBody, 232 | null=True, 233 | blank=True, 234 | on_delete=models.SET_NULL, 235 | verbose_name=_("responsible public body"), 236 | ) 237 | 238 | organization = models.ForeignKey( 239 | Organization, 240 | null=True, 241 | blank=True, 242 | on_delete=models.SET_NULL, 243 | verbose_name=_("organization"), 244 | ) 245 | 246 | group = models.ForeignKey( 247 | Group, null=True, blank=True, on_delete=models.SET_NULL, verbose_name=_("group") 248 | ) 249 | 250 | proposals = models.JSONField(blank=True, null=True) 251 | properties = models.JSONField(blank=True, default=dict) 252 | 253 | objects = GovernmentPlanManager() 254 | 255 | class Meta: 256 | ordering = ("reference", "title") 257 | verbose_name = _("Government plan") 258 | verbose_name_plural = _("Government plans") 259 | 260 | def __str__(self): 261 | return self.title 262 | 263 | def get_absolute_url(self): 264 | return reverse( 265 | "govplan:plan", kwargs={"gov": self.government.slug, "plan": self.slug} 266 | ) 267 | 268 | def get_absolute_domain_url(self): 269 | return settings.SITE_URL + self.get_absolute_url() 270 | 271 | def get_absolute_short_url(self): # Return long url as short url for update emails 272 | return self.get_absolute_url() 273 | 274 | def get_absolute_domain_short_url(self): 275 | return settings.SITE_URL + self.get_absolute_short_url() 276 | 277 | def get_reference_links(self): 278 | if self.reference.startswith("https://"): 279 | return [self.reference] 280 | refs = [x.strip() for x in self.reference.split(",")] 281 | return [ 282 | "{}#p-{}".format(self.government.planning_document, ref) for ref in refs 283 | ] 284 | 285 | def get_section(self): 286 | return GovernmentPlanSection.objects.filter( 287 | categories__in=self.categories.all() 288 | ).first() 289 | 290 | def update_from_updates(self): 291 | last_status_update = ( 292 | self.updates.all().filter(public=True).exclude(status="").first() 293 | ) 294 | if last_status_update: 295 | self.status = last_status_update.status 296 | last_rating_update = ( 297 | self.updates.all().filter(public=True).exclude(rating=None).first() 298 | ) 299 | if last_rating_update: 300 | self.rating = last_rating_update.rating 301 | if last_status_update or last_rating_update: 302 | self.save() 303 | 304 | def get_status_css(self): 305 | return STATUS_CSS.get(self.status, "") 306 | 307 | def make_request_url(self): 308 | if not self.responsible_publicbody: 309 | return [] 310 | return make_request_url(self, self.responsible_publicbody) 311 | 312 | def has_recent_foirequest(self): 313 | frs = self.get_related_foirequests() 314 | ago = timezone.now() - timedelta(days=90) 315 | return any(fr.created_at > ago for fr in frs) 316 | 317 | def get_recent_foirequest(self): 318 | return self.get_related_foirequests()[0] 319 | 320 | def get_foirequest_reference(self): 321 | return "govplan:plan@{}".format(self.pk) 322 | 323 | def get_related_foirequests(self): 324 | if FoiRequest is None: 325 | return [] 326 | if not self.responsible_publicbody: 327 | return [] 328 | if hasattr(self, "_related_foirequests"): 329 | return self._related_foirequests 330 | 331 | self._related_foirequests = ( 332 | FoiRequest.objects.filter( 333 | visibility=FoiRequest.VISIBILITY.VISIBLE_TO_PUBLIC, 334 | public_body=self.responsible_publicbody, 335 | ) 336 | .filter(tags__name=conf.GOVPLAN_NAME) 337 | .filter(reference=self.get_foirequest_reference()) 338 | .order_by("-created_at") 339 | ) 340 | return self._related_foirequests 341 | 342 | 343 | class GovernmentPlanUpdate(models.Model): 344 | plan = models.ForeignKey( 345 | GovernmentPlan, 346 | on_delete=models.CASCADE, 347 | related_name="updates", 348 | verbose_name=_("plan"), 349 | ) 350 | user = models.ForeignKey( 351 | settings.AUTH_USER_MODEL, 352 | null=True, 353 | blank=True, 354 | on_delete=models.SET_NULL, 355 | verbose_name=_("user"), 356 | ) 357 | organization = models.ForeignKey( 358 | Organization, 359 | null=True, 360 | blank=True, 361 | on_delete=models.SET_NULL, 362 | verbose_name=_("organization"), 363 | ) 364 | timestamp = models.DateTimeField(default=timezone.now, verbose_name=_("timestamp")) 365 | title = models.CharField(max_length=1024, blank=True, verbose_name=_("title")) 366 | content = models.TextField(blank=True, verbose_name=_("content")) 367 | url = models.URLField(blank=True, max_length=1024, verbose_name=_("URL")) 368 | 369 | status = models.CharField( 370 | max_length=25, 371 | choices=PlanStatus.choices, 372 | default="", 373 | blank=True, 374 | verbose_name=_("status"), 375 | ) 376 | rating = models.IntegerField( 377 | choices=PlanRating.choices, null=True, blank=True, verbose_name=_("rating") 378 | ) 379 | public = models.BooleanField(default=False, verbose_name=_("is public?")) 380 | 381 | if FoiRequest: 382 | foirequest = models.ForeignKey( 383 | FoiRequest, 384 | null=True, 385 | blank=True, 386 | on_delete=models.SET_NULL, 387 | verbose_name=_("FOI request"), 388 | ) 389 | 390 | class Meta: 391 | ordering = ("-timestamp",) 392 | get_latest_by = "timestamp" 393 | verbose_name = _("Plan update") 394 | verbose_name_plural = _("Plan updates") 395 | 396 | def __str__(self): 397 | return "{} - {} ({})".format(self.title, self.timestamp, self.plan) 398 | 399 | def get_absolute_url(self): 400 | return "{}#update-{}".format( 401 | reverse( 402 | "govplan:plan", 403 | kwargs={"gov": self.plan.government.slug, "plan": self.plan.slug}, 404 | ), 405 | self.id, 406 | ) 407 | 408 | def get_absolute_domain_url(self): 409 | return settings.SITE_URL + self.get_absolute_url() 410 | 411 | def get_url_domain(self): 412 | return urlparse(self.url).netloc or None 413 | 414 | 415 | class GovernmentPlanFollower(Follower): 416 | content_object = models.ForeignKey( 417 | GovernmentPlan, 418 | on_delete=models.CASCADE, 419 | related_name="followers", 420 | verbose_name=_("Government plan"), 421 | ) 422 | 423 | class Meta(Follower.Meta): 424 | verbose_name = _("Government plan follower") 425 | verbose_name_plural = _("Government plan followers") 426 | 427 | 428 | class GovernmentPlanSection(models.Model): 429 | government = models.ForeignKey( 430 | Government, on_delete=models.CASCADE, verbose_name=_("government") 431 | ) 432 | 433 | title = models.CharField(max_length=255, verbose_name=_("title")) 434 | slug = models.SlugField(max_length=255, unique=True, verbose_name=_("slug")) 435 | 436 | categories = models.ManyToManyField(Category, blank=True) 437 | 438 | description = models.TextField(blank=True, verbose_name=_("description")) 439 | image = FilerImageField( 440 | null=True, 441 | blank=True, 442 | default=None, 443 | verbose_name=_("image"), 444 | on_delete=models.SET_NULL, 445 | ) 446 | 447 | icon = models.CharField( 448 | _("Icon"), 449 | max_length=50, 450 | blank=True, 451 | help_text=_( 452 | """Enter an icon name from the FontAwesome 4 icon set""" 453 | ), 454 | ) 455 | order = models.PositiveIntegerField(default=0) 456 | featured = models.DateTimeField(null=True, blank=True) 457 | 458 | if PlaceholderRelationField: 459 | placeholders = PlaceholderRelationField() 460 | 461 | class Meta: 462 | verbose_name = _("Government plan section") 463 | verbose_name_plural = _("Government plan sections") 464 | ordering = ( 465 | "order", 466 | "title", 467 | ) 468 | 469 | def __str__(self): 470 | return self.title 471 | 472 | def get_absolute_url(self): 473 | return reverse( 474 | "govplan:section", 475 | kwargs={"gov": self.government.slug, "section": self.slug}, 476 | ) 477 | 478 | def get_absolute_domain_url(self): 479 | return settings.SITE_URL + self.get_absolute_url() 480 | 481 | @cached_property 482 | def content_placeholder(self): 483 | return get_placeholder_from_slot(self.placeholders, "content") 484 | 485 | def get_plans(self, queryset=None): 486 | if queryset is None: 487 | queryset = GovernmentPlan.objects.filter(public=True) 488 | 489 | queryset = queryset.filter( 490 | categories__in=self.categories.all(), government_id=self.government_id 491 | ) 492 | return queryset.distinct().order_by("title") 493 | 494 | 495 | if CMSPlugin: 496 | PLUGIN_TEMPLATES = [ 497 | ("froide_govplan/plugins/default.html", _("Normal")), 498 | ("froide_govplan/plugins/progress.html", _("Progress")), 499 | ("froide_govplan/plugins/progress_row.html", _("Progress Row")), 500 | ("froide_govplan/plugins/time_used.html", _("Time used")), 501 | ("froide_govplan/plugins/card_cols.html", _("Card columns")), 502 | ("froide_govplan/plugins/search.html", _("Search")), 503 | ] 504 | 505 | class GovernmentPlansCMSPlugin(CMSPlugin): 506 | """ 507 | CMS Plugin for displaying latest articles 508 | """ 509 | 510 | government = models.ForeignKey( 511 | Government, null=True, blank=True, on_delete=models.SET_NULL 512 | ) 513 | categories = models.ManyToManyField( 514 | Category, verbose_name=_("categories"), blank=True 515 | ) 516 | 517 | count = models.PositiveIntegerField( 518 | _("number of plans"), default=1, help_text=_("0 means all the plans") 519 | ) 520 | offset = models.PositiveIntegerField( 521 | _("offset"), 522 | default=0, 523 | help_text=_("number of plans to skip from top of list"), 524 | ) 525 | template = models.CharField( 526 | _("template"), 527 | blank=True, 528 | max_length=250, 529 | choices=PLUGIN_TEMPLATES, 530 | help_text=_("template used to display the plugin"), 531 | ) 532 | extra_classes = models.CharField(max_length=255, blank=True) 533 | 534 | @property 535 | def render_template(self): 536 | """ 537 | Override render_template to use 538 | the template_to_render attribute 539 | """ 540 | return self.template_to_render 541 | 542 | def copy_relations(self, old_instance): 543 | """ 544 | Duplicate ManyToMany relations on plugin copy 545 | """ 546 | self.categories.set(old_instance.categories.all()) 547 | 548 | def __str__(self): 549 | if self.count == 0: 550 | return str(_("All matching plans")) 551 | return _("%s matching plans") % self.count 552 | 553 | def get_plans(self, request, published_only=True): 554 | if ( 555 | published_only 556 | or not request 557 | or not getattr(request, "toolbar", False) 558 | or not request.toolbar.edit_mode_active 559 | ): 560 | plans = GovernmentPlan.objects.filter(public=True) 561 | else: 562 | plans = GovernmentPlan.objects.all() 563 | 564 | filters = {} 565 | if self.government_id: 566 | filters["government_id"] = self.government_id 567 | 568 | cat_list = self.categories.all().values_list("id", flat=True) 569 | if cat_list: 570 | filters["categories__in"] = cat_list 571 | 572 | plans = plans.filter(**filters).distinct() 573 | plans = plans.prefetch_related("categories", "government", "organization") 574 | if self.count == 0: 575 | return plans[self.offset :] 576 | return plans[self.offset : self.offset + self.count] 577 | 578 | class GovernmentPlanSectionsCMSPlugin(CMSPlugin): 579 | """ 580 | CMS Plugin for displaying plan sections 581 | """ 582 | 583 | government = models.ForeignKey( 584 | Government, null=True, blank=True, on_delete=models.SET_NULL 585 | ) 586 | 587 | class GovernmentPlanUpdatesCMSPlugin(CMSPlugin): 588 | """ 589 | CMS Plugin for displaying plan updates 590 | """ 591 | 592 | government = models.ForeignKey( 593 | Government, null=True, blank=True, on_delete=models.SET_NULL 594 | ) 595 | categories = models.ManyToManyField( 596 | Category, verbose_name=_("categories"), blank=True 597 | ) 598 | 599 | count = models.PositiveIntegerField( 600 | _("number of updates"), default=1, help_text=_("0 means all the updates") 601 | ) 602 | offset = models.PositiveIntegerField( 603 | _("offset"), 604 | default=0, 605 | help_text=_("number of updates to skip from top of list"), 606 | ) 607 | 608 | def copy_relations(self, old_instance): 609 | """ 610 | Duplicate ManyToMany relations on plugin copy 611 | """ 612 | self.categories.set(old_instance.categories.all()) 613 | 614 | def get_updates(self, request, published_only=True): 615 | # TODO: remove duplication with GovernmentPlansCMSPlugin.get_plans 616 | if ( 617 | published_only 618 | or not request 619 | or not getattr(request, "toolbar", False) 620 | or not request.toolbar.edit_mode_active 621 | ): 622 | updates = GovernmentPlanUpdate.objects.filter(public=True) 623 | else: 624 | updates = GovernmentPlanUpdate.objects.all() 625 | 626 | updates = updates.order_by("-timestamp").prefetch_related( 627 | "plan", "plan__categories" 628 | ) 629 | 630 | filters = {} 631 | if self.government_id: 632 | filters["plan__government_id"] = self.government_id 633 | 634 | cat_list = self.categories.all().values_list("id", flat=True) 635 | if cat_list: 636 | filters["plan__categories__in"] = cat_list 637 | 638 | updates = updates.filter(**filters).distinct() 639 | if self.count == 0: 640 | return updates[self.offset :] 641 | return updates[self.offset : self.offset + self.count] 642 | 643 | def __str__(self): 644 | if self.count == 0: 645 | return str(_("All matching updates")) 646 | return _("%s matching updates") % self.count 647 | --------------------------------------------------------------------------------