From 7095517682bd744ecc43f8994a0b2bb8f1234214 Mon Sep 17 00:00:00 2001 From: Tudor Date: Tue, 5 Nov 2024 15:06:38 +0200 Subject: [PATCH] Improve the validation for a candidate proposal (#357) - make the proposal button more visible - check that the organization and candidates are complete --- backend/hub/forms.py | 4 +- backend/hub/models.py | 160 +++++++++++++----- .../hub/templates/hub/candidate/update.html | 71 +++++++- backend/hub/views.py | 53 +++++- backend/static_extras/css/hub.css | 13 ++ 5 files changed, 244 insertions(+), 57 deletions(-) diff --git a/backend/hub/forms.py b/backend/hub/forms.py index 67d7c5be..2348c2c6 100644 --- a/backend/hub/forms.py +++ b/backend/hub/forms.py @@ -156,7 +156,9 @@ def _set_fields_permissions(self): # All the required fields for a fully editable organization should be required in votong if self.instance.is_fully_editable: for field_name in self.fields: - if field_name in Organization.required_fields(): + mandatory_fields = Organization.required_fields() + mandatory_fields_names = [field.field.name for field in mandatory_fields] + if field_name in mandatory_fields_names: self.fields[field_name].required = True return diff --git a/backend/hub/models.py b/backend/hub/models.py index bed8ba35..e433d402 100644 --- a/backend/hub/models.py +++ b/backend/hub/models.py @@ -1,20 +1,22 @@ import logging +from typing import List -from django.contrib.auth import get_user_model -from tinymce.models import HTMLField from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.core.files.storage import storages from django.db import models +from django.db.models.query_utils import DeferredAttribute from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from guardian.shortcuts import assign_perm from model_utils import Choices from model_utils.models import StatusModel, TimeStampedModel +from tinymce.models import HTMLField -from accounts.models import User, STAFF_GROUP, COMMITTEE_GROUP, SUPPORT_GROUP, NGO_GROUP +from accounts.models import COMMITTEE_GROUP, NGO_GROUP, STAFF_GROUP, SUPPORT_GROUP, User from civil_society_vote.common.formatting import get_human_readable_size REPORTS_HELP_TEXT = ( @@ -225,7 +227,37 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class Organization(StatusModel, TimeStampedModel): +class BaseCompleteModel(models.Model): + class Meta: + abstract = True + + @classmethod + def required_fields(cls) -> List[DeferredAttribute]: + raise NotImplementedError + + def check_deferred_fields(self, deferred_required_fields): + missing_fields = [] + + for field in deferred_required_fields: + if not getattr(self, field.field.name): + missing_fields.append(field.field) + + return missing_fields + + def get_missing_fields(self): + deferred_required_fields = self.required_fields() + missing_fields = self.check_deferred_fields(deferred_required_fields) + + return missing_fields + + @property + def is_complete(self): + missing_fields = self.get_missing_fields() + + return not missing_fields + + +class Organization(StatusModel, TimeStampedModel, BaseCompleteModel): # DRAFT: empty organization created by us, it might be invalid (e.g., created for another user of an org # PENDING: the organization doesn't have all the necessary documents # ACCEPTED: the organization has all required documentation and can vote @@ -393,25 +425,54 @@ def is_fully_editable(self): return True @staticmethod - def required_fields(): + def get_required_reports() -> List[str]: + required_reports = [] + start_year = settings.CURRENT_EDITION_YEAR - settings.PREV_REPORTS_REQUIRED_FOR_PROPOSAL + + for year in range(start_year, settings.CURRENT_EDITION_YEAR): + required_reports.append(f"report_{year}") + + return required_reports + + @classmethod + def required_fields(cls) -> List[DeferredAttribute]: fields = [ - "name", - "county", - "city", - "address", - "registration_number", - "email", - "phone", - "description", - "legal_representative_name", - "legal_representative_email", - "board_council", - "last_balance_sheet", - "statute", + cls.name, + cls.county, + cls.city, + cls.address, + cls.registration_number, + cls.email, + cls.phone, + cls.description, + cls.legal_representative_name, + cls.legal_representative_email, + cls.board_council, + cls.last_balance_sheet, + cls.statute, + cls.statement_political, ] if FeatureFlag.flag_enabled(FLAG_CHOICES.enable_voting_domain): - fields.append("voting_domain") + fields.append(cls.voting_domain) + + return fields + + @classmethod + def required_fields_for_candidate(cls) -> List[DeferredAttribute]: + fields = cls.required_fields() + + # noinspection PyTypeChecker + fields.extend( + [ + cls.statement_discrimination, + cls.fiscal_certificate_anaf, + cls.fiscal_certificate_local, + ] + ) + + for report_name in cls.get_required_reports(): + fields.append(getattr(cls, report_name)) return fields @@ -437,16 +498,22 @@ def ngohub_fields(): "last_balance_sheet", ) + def get_missing_fields_for_candidate(self): + deferred_required_fields = self.required_fields_for_candidate() + missing_fields = self.check_deferred_fields(deferred_required_fields) + + return missing_fields + @property def is_complete(self): """ Validate that the Org uploaded all the requested info to propose a Candidate """ - required_reports = [] - for year in range( - settings.CURRENT_EDITION_YEAR - settings.PREV_REPORTS_REQUIRED_FOR_PROPOSAL, settings.CURRENT_EDITION_YEAR - ): - required_reports.append(getattr(self, f"report_{year}", None)) + if not super().is_complete: + return False + + required_reports_names = self.get_required_reports() + required_reports = [getattr(self, report_name, None) for report_name in required_reports_names] return all( [ @@ -458,9 +525,20 @@ def is_complete(self): self.fiscal_certificate_local, ] + required_reports - + list(map(lambda x: getattr(self, x), self.required_fields())) ) + @property + def is_complete_for_candidate(self): + """ + Validate that the Org uploaded all the requested info to propose a Candidate + """ + if not super().is_complete: + return False + + missing_fields = self.get_missing_fields_for_candidate() + + return not missing_fields + def is_elector(self, domain=None) -> bool: if self.status != self.STATUS.accepted: return False @@ -547,7 +625,7 @@ def get_queryset(self): return super().get_queryset().exclude(org=None).exclude(org__status=Organization.STATUS.draft) -class Candidate(StatusModel, TimeStampedModel): +class Candidate(StatusModel, TimeStampedModel, BaseCompleteModel): # PENDING: has been created/proposed and is waiting for support from organizations # ACCEPTED: has been accepted by the admins of the platform # CONFIRMED: has received confirmation from the electoral commission @@ -693,25 +771,27 @@ class Meta: def __str__(self): return f"{self.org} ({self.name})" + @classmethod + def required_fields(cls): + return [ + cls.photo, + cls.domain, + cls.name, + cls.role, + cls.statement, + cls.mandate, + cls.letter_of_intent, + cls.cv, + cls.declaration_of_interests, + cls.fiscal_record, + ] + @property def is_complete(self): """ Validate if the Org uploaded all the requested info to propose a Candidate """ - if not all( - [ - self.photo, - self.domain, - self.name, - self.role, - self.statement, - self.mandate, - self.letter_of_intent, - self.cv, - self.declaration_of_interests, - self.fiscal_record, - ] - ): + if not super().is_complete: return False if not FeatureFlag.flag_enabled("enable_voting_domain"): diff --git a/backend/hub/templates/hub/candidate/update.html b/backend/hub/templates/hub/candidate/update.html index 55035deb..dd4d3411 100644 --- a/backend/hub/templates/hub/candidate/update.html +++ b/backend/hub/templates/hub/candidate/update.html @@ -49,21 +49,70 @@



- {% if not user.organization.is_complete %} + {% if not user.organization.is_complete or not candidate.is_complete %}
-
+
+ +
+

Candidatura nu poate fi propusă încă

+
+
- Pentru a putea finaliza profilul candidaturii și pentru intra în cursă este important să completați - și ultima secțiune de documente solicitate din - Profilul organizației. - Pentru orice întrebare ne puteți scrie la - {{ contact_email }}. +

+ + Următoarele câmpuri trebuie completate pentru a putea propune candidatura: + +

+ +
    + + {% if organization_missing_fields %} +
  • + Organizație: + {{ organization_missing_fields }} +
  • + {% endif %} + + {% if candidate_missing_fields %} +
  • + Candidat: + {{ candidate_missing_fields }} +
  • + {% endif %} +
+ +
+ +

+ Pentru a putea finaliza profilul candidaturii și pentru intra în cursă este important: +

+ +
    +
  • + Să completați ultima secțiune de documente solicitate din + Profilul organizației +
  • +
  • + Să completați toate datele și documentele obligatorii din profilul candidatului. +
  • +
  • + Să propuneți candidatura (butonul va fi activat odată ce datele sunt completate). +
  • +
+ +
+ +

+ Pentru orice întrebare ne puteți scrie la + {{ contact_email }}. +

+
-

{% endif %} + {% if CANDIDATE_REGISTRATION_ENABLED and candidate.is_proposed %}
@@ -138,6 +187,12 @@

Propune candidatură

+ {% else %} +
+

+ Propune candidatură +

+
{% endif %} {% endblock %} diff --git a/backend/hub/views.py b/backend/hub/views.py index 53ac67aa..9a9f7152 100644 --- a/backend/hub/views.py +++ b/backend/hub/views.py @@ -747,7 +747,8 @@ class CandidateUpdateView(LoginRequiredMixin, PermissionRequiredMixin, HubUpdate def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["contact_email"] = settings.CONTACT_EMAIL - context["can_propose_candidate"] = False + + can_propose_candidate = False user = self.request.user user_org: Organization = user.organization @@ -756,11 +757,29 @@ def get_context_data(self, **kwargs): if ( FeatureFlag.flag_enabled("enable_candidate_registration") and user_org - and user_org.is_complete + and user_org.is_complete_for_candidate and candidate and candidate.is_complete ): - context["can_propose_candidate"] = True + can_propose_candidate = True + + context["can_propose_candidate"] = can_propose_candidate + + organization_missing_fields = [] + candidate_missing_fields = [] + + if not can_propose_candidate: + if user_org and not user_org.is_complete_for_candidate: + organization_missing_fields = user_org.get_missing_fields_for_candidate() + organization_missing_fields = [f"'{str(field.verbose_name)}'" for field in organization_missing_fields] + + if candidate and not candidate.is_complete: + candidate_missing_fields = candidate.get_missing_fields() + candidate_missing_fields = [f"'{str(field.verbose_name)}'" for field in candidate_missing_fields] + + if organization_missing_fields or candidate_missing_fields: + context["organization_missing_fields"] = ", ".join(organization_missing_fields) + context["candidate_missing_fields"] = ", ".join(candidate_missing_fields) return context @@ -773,11 +792,29 @@ def get_success_url(self): return reverse("candidate-update", args=(self.object.id,)) def post(self, request, *args, **kwargs): - if FeatureFlag.flag_enabled("enable_candidate_registration") or FeatureFlag.flag_enabled( - "enable_candidate_supporting" - ): - return super().post(request, *args, **kwargs) - raise PermissionDenied + if not FeatureFlag.flag_enabled("enable_candidate_registration"): + raise PermissionDenied + + if not FeatureFlag.flag_enabled("enable_candidate_supporting"): + raise PermissionDenied + + user = self.request.user + user_org: Organization = user.organization + candidate: Candidate = self.object + + if not user_org: + raise PermissionDenied + + if not user_org.is_complete_for_candidate: + raise PermissionDenied + + if not candidate: + raise PermissionDenied + + if not candidate.is_complete: + raise PermissionDenied + + return super().post(request, *args, **kwargs) @login_required diff --git a/backend/static_extras/css/hub.css b/backend/static_extras/css/hub.css index 9c55e178..69672214 100644 --- a/backend/static_extras/css/hub.css +++ b/backend/static_extras/css/hub.css @@ -656,3 +656,16 @@ hr.tight { .rules-section { font-size: 10px; } + +.fake-button { + cursor: initial; + + &:hover { + cursor: initial; + } +} + +.message.is-warning .message-body { + border-color: #ffdd57; + color: #5c4a00; +}