From 9b7f576a4b917e4c993e5227efba208969540315 Mon Sep 17 00:00:00 2001 From: Struan Donald Date: Wed, 2 Oct 2024 18:10:36 +0100 Subject: [PATCH 1/4] add models to store arbitrary per session, stage and authority properties --- .../0057_sessionproperties_sessionproperty.py | 94 +++++++++++++++++++ ...lter_sessionproperties_options_and_more.py | 21 +++++ crowdsourcer/models.py | 42 +++++++++ 3 files changed, 157 insertions(+) create mode 100644 crowdsourcer/migrations/0057_sessionproperties_sessionproperty.py create mode 100644 crowdsourcer/migrations/0058_alter_sessionproperties_options_and_more.py diff --git a/crowdsourcer/migrations/0057_sessionproperties_sessionproperty.py b/crowdsourcer/migrations/0057_sessionproperties_sessionproperty.py new file mode 100644 index 0000000..b6232d4 --- /dev/null +++ b/crowdsourcer/migrations/0057_sessionproperties_sessionproperty.py @@ -0,0 +1,94 @@ +# Generated by Django 4.2.15 on 2024-10-07 09:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("crowdsourcer", "0056_assigned_created_assigned_last_update_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SessionProperties", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(help_text="Keyname in database", max_length=200), + ), + ("label", models.CharField(help_text="Form label", max_length=200)), + ( + "description", + models.TextField( + blank=True, + help_text="Displayed under field to describe content", + null=True, + ), + ), + ( + "property_type", + models.CharField( + choices=[("text", "Text"), ("url", "URL")], max_length=200 + ), + ), + ("active", models.BooleanField(default=True)), + ("order", models.IntegerField(default=0)), + ( + "marking_session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="crowdsourcer.markingsession", + ), + ), + ( + "stage", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="crowdsourcer.responsetype", + ), + ), + ], + ), + migrations.CreateModel( + name="SessionPropertyValues", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.TextField()), + ( + "authority", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="crowdsourcer.publicauthority", + ), + ), + ( + "property", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="crowdsourcer.sessionproperties", + ), + ), + ], + ), + ] diff --git a/crowdsourcer/migrations/0058_alter_sessionproperties_options_and_more.py b/crowdsourcer/migrations/0058_alter_sessionproperties_options_and_more.py new file mode 100644 index 0000000..f7ea7f0 --- /dev/null +++ b/crowdsourcer/migrations/0058_alter_sessionproperties_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.15 on 2024-10-16 09:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("crowdsourcer", "0057_sessionproperties_sessionproperty"), + ] + + operations = [ + migrations.AlterModelOptions( + name="sessionproperties", + options={"verbose_name_plural": "Session Properties"}, + ), + migrations.AlterModelOptions( + name="sessionpropertyvalues", + options={"verbose_name_plural": "Session Property Values"}, + ), + ] diff --git a/crowdsourcer/models.py b/crowdsourcer/models.py index 53f1160..87ef328 100644 --- a/crowdsourcer/models.py +++ b/crowdsourcer/models.py @@ -26,6 +26,48 @@ def __str__(self): return self.label +class SessionProperties(models.Model): + """Used to define extra properties that can be added as part of marking""" + + PROPERTY_TYPES = [ + ("text", "Text"), + ("url", "URL"), + ] + + marking_session = models.ForeignKey(MarkingSession, on_delete=models.CASCADE) + stage = models.ForeignKey("ResponseType", null=True, on_delete=models.SET_NULL) + name = models.CharField(max_length=200, help_text="Keyname in database") + label = models.CharField(max_length=200, help_text="Form label") + description = models.TextField( + help_text="Displayed under field to describe content", null=True, blank=True + ) + property_type = models.CharField(max_length=200, choices=PROPERTY_TYPES) + active = models.BooleanField(default=True) + order = models.IntegerField(default=0) + + def __str__(self): + return f"{self.label} ({self.marking_session}, {self.stage})" + + class Meta: + verbose_name_plural = "Session Properties" + + +class SessionPropertyValues(models.Model): + """For storing extra session properties""" + + authority = models.ForeignKey( + "PublicAuthority", null=True, on_delete=models.SET_NULL + ) + property = models.ForeignKey(SessionProperties, on_delete=models.CASCADE) + value = models.TextField() + + def __str__(self): + return f"{self.property} {self.authority} - {self.value}" + + class Meta: + verbose_name_plural = "Session Property Values" + + class Section(models.Model): """Used to group questions with a similar theme""" From 84c054f2a5bcda587eafd7866349ad868f47b561 Mon Sep 17 00:00:00 2001 From: Struan Donald Date: Wed, 2 Oct 2024 18:12:58 +0100 Subject: [PATCH 2/4] form to store section properties This is generic and will cope with any set of properties for a session, stage and authority --- ceuk-marking/urls.py | 6 + crowdsourcer/fixtures/session_properties.json | 28 +++ crowdsourcer/forms.py | 21 ++ .../crowdsourcer/authority_properties.html | 36 ++++ .../tests/test_session_properties_views.py | 198 ++++++++++++++++++ crowdsourcer/views/marking.py | 120 ++++++++++- 6 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 crowdsourcer/fixtures/session_properties.json create mode 100644 crowdsourcer/templates/crowdsourcer/authority_properties.html create mode 100644 crowdsourcer/tests/test_session_properties_views.py diff --git a/ceuk-marking/urls.py b/ceuk-marking/urls.py index b232e3e..319d2bb 100644 --- a/ceuk-marking/urls.py +++ b/ceuk-marking/urls.py @@ -119,6 +119,12 @@ progress.AuthorityContactCSVView.as_view(), name="authority_contacts_report", ), + # properties + path( + "authorities/properties///", + marking.SectionPropertiesView.as_view(), + name="authority_properties", + ), # stats path( "stats/", diff --git a/crowdsourcer/fixtures/session_properties.json b/crowdsourcer/fixtures/session_properties.json new file mode 100644 index 0000000..7558b87 --- /dev/null +++ b/crowdsourcer/fixtures/session_properties.json @@ -0,0 +1,28 @@ +[ +{ + "model": "crowdsourcer.sessionproperties", + "pk": 1, + "fields": { + "marking_session": 1, + "stage": 1, + "property_type": "text", + "name": "first_property", + "label": "First Mark Property", + "active": true, + "order": 1 + } +}, +{ + "model": "crowdsourcer.sessionproperties", + "pk": 2, + "fields": { + "marking_session": 1, + "stage": 2, + "property_type": "text", + "name": "ror_property", + "label": "Right of Reply Property", + "active": true, + "order": 1 + } +} +] diff --git a/crowdsourcer/forms.py b/crowdsourcer/forms.py index 87d8f93..a832822 100644 --- a/crowdsourcer/forms.py +++ b/crowdsourcer/forms.py @@ -17,6 +17,7 @@ Select, Textarea, TextInput, + URLField, formset_factory, inlineformset_factory, ) @@ -551,3 +552,23 @@ class VolunteerDeactivateForm(Form): def __init__(self, stage_choices, **kwargs): super().__init__(**kwargs) self.fields["stage"].choices = stage_choices + + +class SessionPropertyForm(Form): + def __init__(self, properties: {}, **kwargs): + super().__init__(**kwargs) + + for prop in properties: + if prop.property_type == "url": + self.fields[prop.name] = URLField( + label=prop.label, + help_text=prop.description, + required=False, + ) + else: + self.fields[prop.name] = CharField( + label=prop.label, + help_text=prop.description, + required=False, + widget=Textarea, + ) diff --git a/crowdsourcer/templates/crowdsourcer/authority_properties.html b/crowdsourcer/templates/crowdsourcer/authority_properties.html new file mode 100644 index 0000000..c7234d6 --- /dev/null +++ b/crowdsourcer/templates/crowdsourcer/authority_properties.html @@ -0,0 +1,36 @@ +{% extends 'crowdsourcer/base.html' %} + +{% load crowdsourcer_tags django_bootstrap5 %} + +{% block content %} +{% if show_login %} +

Sign in

+ Sign in +{% else %} +

Optional Information

+ + {% if message %} +

+ {{ message }} +

+ {% endif %} + +
+ {% if form.total_error_count > 0 %} +
+
+ Changes Not Saved. There were some errors which are highlighted in red below. +
+
+ {% endif %} + + {% csrf_token %} + {% bootstrap_form form %} + +
+ +
+
+ +{% endif %} +{% endblock %} diff --git a/crowdsourcer/tests/test_session_properties_views.py b/crowdsourcer/tests/test_session_properties_views.py new file mode 100644 index 0000000..126a5f9 --- /dev/null +++ b/crowdsourcer/tests/test_session_properties_views.py @@ -0,0 +1,198 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from crowdsourcer.models import ( + Marker, + MarkingSession, + PublicAuthority, + ResponseType, + SessionProperties, + SessionPropertyValues, +) + + +class BaseTestCase(TestCase): + fixtures = [ + "authorities.json", + "basics.json", + "users.json", + "assignments.json", + "session_properties.json", + ] + + def setUp(self): + u = User.objects.get(username="council") + self.client.force_login(u) + self.user = u + + +class TestLinkDisplayed(BaseTestCase): + fixtures = [ + "authorities.json", + "basics.json", + "users.json", + "assignments.json", + ] + + def test_ror_link_displayed(self): + url = reverse("authority_ror_sections", args=("Aberdeenshire Council",)) + response = self.client.get(url) + + context = response.context + self.assertFalse(context["has_properties"]) + + SessionProperties.objects.create( + marking_session=MarkingSession.objects.get(label="Default"), + stage=ResponseType.objects.get(type="Right of Reply"), + property_type="text", + name="a_property", + label="A Property", + ) + + response = self.client.get(url) + + context = response.context + self.assertTrue(context["has_properties"]) + + +class TestPropertyFormPermissions(BaseTestCase): + def test_404(self): + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "Bad Type", + ), + ) + + response = self.client.get(url) + self.assertEquals(404, response.status_code) + + url = reverse( + "authority_properties", + args=( + "Fakeshire Council", + "First Mark", + ), + ) + + def test_stage_permissions(self): + self.client.logout() + + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "First Mark", + ), + ) + + response = self.client.get(url) + self.assertEquals(403, response.status_code) + + for username in ["council", "auditor", "other_marker"]: + u = User.objects.get(username=username) + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(403, response.status_code) + + for username in ["admin", "marker"]: + u = User.objects.get(username=username) + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(200, response.status_code) + + def test_right_of_reply_permissions(self): + self.client.logout() + + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "Right of Reply", + ), + ) + + response = self.client.get(url) + self.assertEquals(403, response.status_code) + + u = User.objects.get(username="marker") + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(403, response.status_code) + + u = User.objects.get(username="admin") + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(200, response.status_code) + + u = User.objects.create(username="other_council", is_active=True) + m = Marker.objects.create( + user=u, + response_type=ResponseType.objects.get(type="Right of Reply"), + authority=PublicAuthority.objects.get(name="Aberdeen City Council"), + ) + m.marking_session.set([MarkingSession.objects.get(label="Default")]) + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(403, response.status_code) + + def test_properties_not_found(self): + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "Audit", + ), + ) + + u = User.objects.get(username="admin") + self.client.force_login(u) + response = self.client.get(url) + self.assertEquals(404, response.status_code) + + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "First Mark", + ), + ) + url = f"/Second%20Session{url}" + response = self.client.get(url) + self.assertEquals(404, response.status_code) + + +class TestPropertyForm(BaseTestCase): + def test_form(self): + url = reverse( + "authority_properties", + args=( + "Aberdeenshire Council", + "Right of Reply", + ), + ) + + properties = SessionPropertyValues.objects.filter( + property__marking_session__label="Default", + authority__name="Aberdeenshire Council", + property__stage__type="Right of Reply", + ) + + response = self.client.get(url) + self.assertEquals(200, response.status_code) + + self.assertEquals(0, properties.count()) + + response = self.client.post(url, data={"ror_property": "Property Data"}) + + self.assertEqual(response.status_code, 200) + msg = response.context.get("message", "") + self.assertEquals(msg, "Your answers have been saved.") + + self.assertEquals(1, properties.count()) + + p = properties.first() + self.assertEquals(p.property.name, "ror_property") + self.assertEquals(p.value, "Property Data") diff --git a/crowdsourcer/views/marking.py b/crowdsourcer/views/marking.py index a15f8a3..449ead5 100644 --- a/crowdsourcer/views/marking.py +++ b/crowdsourcer/views/marking.py @@ -1,9 +1,12 @@ import logging -from django.shortcuts import redirect +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.views.generic import ListView, TemplateView +from django.views.generic import FormView, ListView, TemplateView +from crowdsourcer.forms import SessionPropertyForm from crowdsourcer.models import ( Assigned, MarkingSession, @@ -11,6 +14,8 @@ Question, Response, ResponseType, + SessionProperties, + SessionPropertyValues, ) from crowdsourcer.views.base import ( BaseQuestionView, @@ -295,3 +300,114 @@ def process_form(self, form): class AuthoritySectionJSONQuestion(BaseResponseJSONView): pass + + +class SectionPropertiesView(FormView): + template_name = "crowdsourcer/authority_properties.html" + form = SessionPropertyForm + + def check_permissions(self): + denied = True + user = self.request.user + + if user.is_superuser: + return True + + if user.is_anonymous: + raise PermissionDenied + + if not user.marker.marking_session.filter( + pk=self.request.current_session.pk + ).exists(): + raise PermissionDenied + + stage = get_object_or_404(ResponseType, type=self.kwargs["stage"]) + authority = get_object_or_404(PublicAuthority, name=self.kwargs["name"]) + + if ( + user.marker.response_type == stage + and Assigned.objects.filter( + user=user, + response_type=stage, + marking_session=self.request.current_session, + authority=authority, + ).exists() + ): + denied = False + elif ( + stage.type == "Right of Reply" + and user.marker.response_type == stage + and user.marker.authority == authority + ): + denied = False + + if denied: + raise PermissionDenied + + def get_initial(self): + kwargs = super().get_initial() + stage = ResponseType.objects.get(type=self.kwargs["stage"]) + authority = PublicAuthority.objects.get(name=self.kwargs["name"]) + + properties = SessionProperties.objects.filter( + marking_session=self.request.current_session, + stage=stage, + active=True, + ) + if not properties.exists(): + raise Http404 + + properties = SessionPropertyValues.objects.filter( + property__in=properties, + authority=authority, + ).select_related("property") + + for prop in properties: + kwargs[prop.property.name] = prop.value + + return kwargs + + def get_form(self): + self.check_permissions() + + stage = self.kwargs["stage"] + properties = SessionProperties.objects.filter( + marking_session=self.request.current_session, + stage__type=stage, + active=True, + ).order_by("order") + + self.properties = properties + + form = self.form(properties=properties, **self.get_form_kwargs()) + + return form + + def form_valid(self, form): + authority = PublicAuthority.objects.get(name=self.kwargs["name"]) + + cleaned_data = form.cleaned_data + + for prop in self.properties: + if cleaned_data.get(prop.name): + SessionPropertyValues.objects.update_or_create( + authority=authority, + property=prop, + defaults={"value": cleaned_data[prop.name]}, + ) + + context = self.get_context_data() + context["message"] = "Your answers have been saved." + return self.render_to_response(context) + + def get_success_url(self): + stage = ResponseType.objects.get(type=self.kwargs["stage"]) + authority = PublicAuthority.objects.get(name=self.kwargs["authority"]) + return reverse( + "session_urls:authority_properties", + kwargs={ + "marking_session": self.request.current_session.label, + "stage": stage.type, + "authority": authority.name, + }, + ) From 40a8cb95291a65055204cf2c9ab247f6cb82c6b6 Mon Sep 17 00:00:00 2001 From: Struan Donald Date: Wed, 2 Oct 2024 18:13:52 +0100 Subject: [PATCH 3/4] add session properties to the admin --- crowdsourcer/admin.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crowdsourcer/admin.py b/crowdsourcer/admin.py index 36d706b..0ffc079 100644 --- a/crowdsourcer/admin.py +++ b/crowdsourcer/admin.py @@ -12,6 +12,8 @@ Response, ResponseType, Section, + SessionProperties, + SessionPropertyValues, ) @@ -144,3 +146,24 @@ class SectionAdmin(admin.ModelAdmin): @admin.register(MarkingSession) class MarkingSessionAdmin(admin.ModelAdmin): pass + + +@admin.register(SessionProperties) +class SessionPropertiesAdmin(admin.ModelAdmin): + list_display = ( + "label", + "marking_session", + "stage", + ) + + list_filter = ["marking_session", "stage"] + + +@admin.register(SessionPropertyValues) +class SessionPropertyValuesAdmin(admin.ModelAdmin): + list_display = ( + "property", + "authority", + ) + list_filter = ["property__marking_session", "property__stage"] + search_fields = ["authority__name"] From f4dc4f9c68d924120b1d137e13846ab08e9829cb Mon Sep 17 00:00:00 2001 From: Struan Donald Date: Wed, 2 Oct 2024 18:14:11 +0100 Subject: [PATCH 4/4] access session properties form from Right of Reply page --- .../crowdsourcer/authority_properties.html | 4 +++- .../crowdsourcer/authority_section_list.html | 9 ++++++++ crowdsourcer/views/marking.py | 23 +++++++++++++++++++ crowdsourcer/views/rightofreply.py | 4 ++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crowdsourcer/templates/crowdsourcer/authority_properties.html b/crowdsourcer/templates/crowdsourcer/authority_properties.html index c7234d6..10dec68 100644 --- a/crowdsourcer/templates/crowdsourcer/authority_properties.html +++ b/crowdsourcer/templates/crowdsourcer/authority_properties.html @@ -7,8 +7,10 @@

Sign in

Sign in {% else %} +

+ Return to section list +

Optional Information

- {% if message %}

{{ message }} diff --git a/crowdsourcer/templates/crowdsourcer/authority_section_list.html b/crowdsourcer/templates/crowdsourcer/authority_section_list.html index edbe88b..fac28b9 100644 --- a/crowdsourcer/templates/crowdsourcer/authority_section_list.html +++ b/crowdsourcer/templates/crowdsourcer/authority_section_list.html @@ -40,5 +40,14 @@

Sections

{% endfor %} + + {% if has_properties %} +
+

Help us by providing optional feedback

+

Help us improve the Scorecards process next year by answering a few optional questions. Your answers will have no effect on your council’s score.

+ Complete optional questions +
+ {% endif %} + {% endif %} {% endblock %} diff --git a/crowdsourcer/views/marking.py b/crowdsourcer/views/marking.py index 449ead5..f2aff19 100644 --- a/crowdsourcer/views/marking.py +++ b/crowdsourcer/views/marking.py @@ -383,6 +383,29 @@ def get_form(self): return form + def get_context_data(self, **kwargs): + context_data = super().get_context_data(**kwargs) + + stage = self.kwargs["stage"] + name = self.kwargs["name"] + url_kwargs = { + "marking_session": self.request.current_session.label, + "name": name, + } + + back_link = reverse( + "session_urls:home", + kwargs={"marking_session": self.request.current_session}, + ) + + if stage == "Right of Reply": + back_link = reverse( + "session_urls:authority_ror_sections", kwargs=url_kwargs + ) + + context_data["back_link"] = back_link + return context_data + def form_valid(self, form): authority = PublicAuthority.objects.get(name=self.kwargs["name"]) diff --git a/crowdsourcer/views/rightofreply.py b/crowdsourcer/views/rightofreply.py index a1d2920..beabb6a 100644 --- a/crowdsourcer/views/rightofreply.py +++ b/crowdsourcer/views/rightofreply.py @@ -14,6 +14,7 @@ Response, ResponseType, Section, + SessionProperties, ) from crowdsourcer.views.base import BaseQuestionView @@ -127,6 +128,9 @@ def get_context_data(self, **kwargs): section.complete = 0 context["ror_user"] = True + context["has_properties"] = SessionProperties.objects.filter( + marking_session=self.request.current_session, stage=response_type + ).exists() return context