diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae3ef9651..af479af94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b2563352a..c62623c26 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -3,7 +3,7 @@ name: Deploy Sphinx Documentation to GitHub Pages on: push: branches: - - main # Adjust this to your default branch name, if necessary + - main - sphynx jobs: @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/push-branch-main.yml b/.github/workflows/push-branch-main.yml index ab0bc34b1..d219a30e5 100644 --- a/.github/workflows/push-branch-main.yml +++ b/.github/workflows/push-branch-main.yml @@ -23,7 +23,7 @@ jobs: needs: test steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: kolok/deploy-to-scalingo@v1 with: ssh-private-key: ${{ secrets.SCALINGO_SSH_PRIVATE_KEY }} diff --git a/.github/workflows/push-branch-recette.yml b/.github/workflows/push-branch-recette.yml index a006c3ad2..1f0d8d1e5 100644 --- a/.github/workflows/push-branch-recette.yml +++ b/.github/workflows/push-branch-recette.yml @@ -17,13 +17,13 @@ jobs: test: uses: ./.github/workflows/run_tests.yml - deploy_to_siap_recette: + deploy_to_siap_recette: name: "Deploy to Siap Recette" runs-on: ubuntu-latest needs: test steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: kolok/deploy-to-scalingo@v1 with: ssh-private-key: ${{ secrets.SCALINGO_SSH_PRIVATE_KEY }} diff --git a/.github/workflows/push-pull-request.yml b/.github/workflows/push-pull-request.yml index 16d5d6b9d..9da8893ca 100644 --- a/.github/workflows/push-pull-request.yml +++ b/.github/workflows/push-pull-request.yml @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/push-version-tag.yml b/.github/workflows/push-version-tag.yml index 7bc473668..d28cb8152 100644 --- a/.github/workflows/push-version-tag.yml +++ b/.github/workflows/push-version-tag.yml @@ -23,7 +23,7 @@ jobs: needs: test steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -34,7 +34,7 @@ jobs: needs: test steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: kolok/deploy-to-scalingo@v1 with: ssh-private-key: ${{ secrets.SCALINGO_SSH_PRIVATE_KEY }} @@ -47,7 +47,7 @@ jobs: needs: test steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: kolok/deploy-to-scalingo@v1 with: ssh-private-key: ${{ secrets.SCALINGO_SSH_PRIVATE_KEY }} diff --git a/.github/workflows/run_check.yml b/.github/workflows/run_check.yml index 265506fbc..5c5ed0053 100644 --- a/.github/workflows/run_check.yml +++ b/.github/workflows/run_check.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ef179ae51..e456c06c7 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v4 - uses: actions/setup-python@v6 with: diff --git a/conventions/admin.py b/conventions/admin.py index 10c5e75e5..38e4de9e3 100644 --- a/conventions/admin.py +++ b/conventions/admin.py @@ -120,7 +120,7 @@ class ConventionAdmin(ApilosModelAdmin): "administration", "bailleur", "programme", - "lot", + "lots_list", "numero", "numero_pour_recherche", "date_fin_conventionnement", @@ -157,7 +157,7 @@ class ConventionAdmin(ApilosModelAdmin): "numero_pour_recherche", "cree_par", "cree_le", - "lot", + "lots_list", ) autocomplete_fields = ( "programme", diff --git a/conventions/forms/convention_form_annexes.py b/conventions/forms/convention_form_annexes.py index 8678a1f83..09d32e163 100644 --- a/conventions/forms/convention_form_annexes.py +++ b/conventions/forms/convention_form_annexes.py @@ -12,6 +12,7 @@ TypologieAnnexe, TypologieLogementClassique, ) +from programmes.models.choices import Financement class LotAnnexeForm(forms.Form): @@ -20,6 +21,9 @@ class LotAnnexeForm(forms.Form): """ uuid = forms.UUIDField(required=False) + financement = forms.TypedChoiceField( + required=False, label="", choices=Financement.choices + ) annexe_caves = forms.BooleanField( required=False, label="Caves", @@ -92,6 +96,9 @@ class AnnexeForm(forms.Form): "max_length": "La designation du logement ne doit pas excéder 255 caractères", }, ) + financement = forms.TypedChoiceField( + required=False, label="", choices=Financement.choices + ) logement_typologie = forms.TypedChoiceField( required=True, label="", choices=TypologieLogementClassique.choices ) @@ -123,6 +130,26 @@ class AnnexeForm(forms.Form): }, ) + def clean_financement(self): + """ + Validation du financement + """ + financement = self.cleaned_data.get("financement", None) + financement_exist = False + + if financement: + for choices in list(Financement.choices): + if financement in choices: + financement_exist = True + break + + if not financement_exist: + raise ValidationError( + "Vérifiez si le financement est pris en charge par Apilos ou s'il s'agit d'une erreur de saisie." + ) + + return financement + def clean_loyer(self): """ Validation du loyer : @@ -165,7 +192,7 @@ def manage_logement_exists_validation(self): - le logement doit exister dans le lot """ if self.convention: - lgts = self.convention.lot.logements.all() + lgts = Logement.objects.filter(lot__convention=self.convention) for form in self.forms: try: lgts.get(designation=form.cleaned_data.get("logement_designation")) @@ -176,3 +203,5 @@ def manage_logement_exists_validation(self): AnnexeFormSet = formset_factory(AnnexeForm, formset=BaseAnnexeFormSet, extra=0) + +LotAnnexeFormSet = formset_factory(LotAnnexeForm, extra=0) diff --git a/conventions/forms/convention_form_financement.py b/conventions/forms/convention_form_financement.py index dad50db13..ae192d115 100644 --- a/conventions/forms/convention_form_financement.py +++ b/conventions/forms/convention_form_financement.py @@ -11,6 +11,7 @@ from conventions.models import Preteur from programmes.models import TypeOperation +from programmes.models.choices import Financement class ConventionFinancementForm(forms.Form): @@ -61,14 +62,20 @@ def clean(self): and self.convention is not None and annee_fin_conventionnement is not None ): - if self.convention.programme.is_outre_mer: - self._outre_mer_end_date_validation(annee_fin_conventionnement) - elif self.convention.is_pls_financement_type: - self._pls_end_date_validation(annee_fin_conventionnement) - elif self.convention.programme.type_operation == TypeOperation.SANSTRAVAUX: - self._sans_travaux_end_date_validation(annee_fin_conventionnement) - else: + if self.convention.is_mixte: self._other_end_date_validation(annee_fin_conventionnement) + else: + if self.convention.programme.is_outre_mer: + self._outre_mer_end_date_validation(annee_fin_conventionnement) + elif self.convention.is_pls_financement_type: + self._pls_end_date_validation(annee_fin_conventionnement) + elif ( + self.convention.programme.type_operation + == TypeOperation.SANSTRAVAUX + ): + self._sans_travaux_end_date_validation(annee_fin_conventionnement) + else: + self._other_end_date_validation(annee_fin_conventionnement) def _outre_mer_end_date_validation(self, annee_fin_conventionnement): today = datetime.date.today() @@ -225,6 +232,9 @@ class PretForm(forms.Form): "max_length": "Le numero ne doit pas excéder 255 caractères", }, ) + financement = forms.TypedChoiceField( + required=False, label="", choices=Financement.choices + ) preteur = forms.TypedChoiceField(required=False, label="", choices=Preteur.choices) autre = forms.CharField( required=False, @@ -259,6 +269,11 @@ def clean(self): - si le prêteur est autre, le champ autre est obligatoire """ cleaned_data = super().clean() + if not cleaned_data.get("financement"): + self.add_error( + "financement", + "Le financement doit obligatoirement être fourni", + ) preteur = cleaned_data.get("preteur") if preteur in ["CDCF", "CDCL"]: @@ -298,15 +313,22 @@ def clean(self): self.manage_cdc_validation() def validate_initial_numero_unicity(self) -> bool: - is_valid = True - numeros = [form.initial.get("numero") for form in self.forms] - for form in self.forms: - num = form.initial.get("numero") - if numeros.count(num) > 1: - form.errors["numero"] = [ - f"Le numéro de financement {num} n'est pas unique." - ] - is_valid = False + financements = {form.initial.get("financement") for form in self.forms} + for financement in financements: + is_valid = True + forms_financement = [ + form + for form in self.forms + if form.initial.get("financement") == financement + ] + numeros = [form.initial.get("numero") for form in forms_financement] + for form in forms_financement: + num = form.initial.get("numero") + if numeros.count(num) > 1: + form.errors["numero"] = [ + f"Le numéro de financement {num} n'est pas unique pour le financement {financement}." + ] + is_valid = False return is_valid def manage_cdc_validation(self): @@ -316,17 +338,25 @@ def manage_cdc_validation(self): """ if ( self.convention is not None - and not self.convention.is_pls_financement_type and self.convention.programme.type_operation != TypeOperation.SANSTRAVAUX ): - for form in self.forms: - if form.cleaned_data.get("preteur") in ["CDCF", "CDCL"]: - return - error = ValidationError( - "Au moins un prêt à la Caisee des dépôts et consignations doit-être déclaré " - + "(CDC foncière, CDC locative)" - ) - self._non_form_errors.append(error) + for lot in self.convention.lots.all(): + if not lot.is_pls_financement_type: + exist_cdcf_cdcl = False + lot_exist = False + for form in self.forms: + if form.cleaned_data.get("financement") == lot.financement: + lot_exist = True + if form.cleaned_data.get("preteur") in ["CDCF", "CDCL"]: + exist_cdcf_cdcl = True + if exist_cdcf_cdcl or not lot_exist: + continue + + error = ValidationError( + "Au moins un prêt à la Caisee des dépôts et consignations doit-être déclaré " + + f"(CDC foncière, CDC locative) pour le financement {lot.financement}" + ) + self._non_form_errors.append(error) PretFormSet = formset_factory(PretForm, formset=BasePretFormSet, extra=0) diff --git a/conventions/forms/convention_form_logements.py b/conventions/forms/convention_form_logements.py index 379f4b43f..f1c67bb0e 100644 --- a/conventions/forms/convention_form_logements.py +++ b/conventions/forms/convention_form_logements.py @@ -16,6 +16,7 @@ TypologieLogementClassique, TypologieLogementFoyerResidence, ) +from programmes.models.choices import Financement class LotLgtsOptionForm(forms.Form): @@ -27,6 +28,9 @@ class LotLgtsOptionForm(forms.Form): required=False, label="Logement du programme", ) + financement = forms.TypedChoiceField( + required=False, label="", choices=Financement.choices + ) lgts_mixite_sociale_negocies = forms.IntegerField( required=False, label=( @@ -156,6 +160,10 @@ class BaseLogementForm(forms.Form): "max_length": "La designation du logement ne doit pas excéder 255 caractères", }, ) + financement = forms.TypedChoiceField( + required=False, label="", choices=Financement.choices + ) + typologie = forms.TypedChoiceField( required=True, label="", @@ -361,8 +369,8 @@ class BaseLogementFormSet(BaseFormSet): # ils sont initialisés avant la validation programme_id: int = None lot_id: int = None - nb_logements: int = None - total_nb_logements: int = None + nb_logements: dict[str, int] | None = None + total_nb_logements: dict[str, int] | None = None optional_errors: list = [] ignore_optional_errors = False @@ -407,71 +415,84 @@ def manage_designation_validation(self): def manage_same_loyer_par_metre_carre(self): """ - Validation: le loyer par mètre carré doit être le même pour tous les logements du lot + Validation: le loyer par mètre carré doit être le même pour tous les logements du lot/financement """ - lpmc = None - error = None - for form in self.forms: - if lpmc is None: - lpmc = form.cleaned_data.get("loyer_par_metre_carre") - elif ( - lpmc != form.cleaned_data.get("loyer_par_metre_carre") and error is None - ): - error = ValidationError( - "Le loyer par mètre carré doit être le même pour tous les logements du lot" - ) - self._non_form_errors.append(error) - if error is not None: - for form in self.forms: - form.add_error( - "loyer_par_metre_carre", - "Le loyer par mètre carré doit être le même pour tous les logements du lot", - ) + financements = {form.cleaned_data.get("financement") for form in self.forms} + + for financement in financements: + forms_financement = [ + form + for form in self.forms + if form.cleaned_data.get("financement") == financement + ] + lpmc = None + error = None + for form in forms_financement: + if lpmc is None: + lpmc = form.cleaned_data.get("loyer_par_metre_carre") + elif ( + lpmc != form.cleaned_data.get("loyer_par_metre_carre") + and error is None + ): + error = ValidationError( + f"Le loyer par mètre carré doit être le même pour tous les logements du lot {financement}" + ) + self._non_form_errors.append(error) + if error is not None: + for form in forms_financement: + form.add_error( + "loyer_par_metre_carre", + f"Le loyer par mètre carré doit être le même pour tous les logements du lot {financement}", + ) def manage_edd_consistency(self): """ Validation: les logements déclarés dans l'EDD simplifié pour le financement de la convention doivent être déclarés dans la convention """ - lgts_edd = LogementEDD.objects.filter(programme_id=self.programme_id) - lot = Lot.objects.get(id=self.lot_id) - - if lgts_edd.count() != 0: - for form in self.forms: - try: - lgt_edd = lgts_edd.get( - designation=form.cleaned_data.get("designation"), - financement=lot.financement, - ) - if lgt_edd.financement != lot.financement: + if self.lot_id: + lot = Lot.objects.get(id=self.lot_id) + lgts_edd = LogementEDD.objects.filter(programme_id=self.programme_id) + + if lgts_edd.count() != 0: + for form in self.forms: + try: + lgt_edd = lgts_edd.get( + designation=form.cleaned_data.get("designation") + ) + if lgt_edd.financement not in lot.convention.get_financements: + form.add_error( + "designation", + "Ce logement est déclaré comme " + + f"{lgt_edd.financement} dans l'EDD simplifié " + + "alors que la convention ne comporte aucun financement de ce type ", + ) + except LogementEDD.DoesNotExist: + form.add_error( + "designation", "Ce logement n'est pas dans l'EDD simplifié" + ) + except LogementEDD.MultipleObjectsReturned: form.add_error( "designation", - "Ce logement est déclaré comme " - + f"{lgt_edd.financement} dans l'EDD simplifié " - + "alors que vous déclarez un lot de type " - + f"{lot.financement}", + "Ce logement est présent plusieurs fois dans l'EDD simplifié", ) - except LogementEDD.DoesNotExist: - form.add_error( - "designation", "Ce logement n'est pas dans l'EDD simplifié" - ) - except LogementEDD.MultipleObjectsReturned: - form.add_error( - "designation", - "Ce logement est présent plusieurs fois dans l'EDD simplifié", - ) def manage_nb_logement_consistency(self): """ Validation: le nombre de logements déclarés pour cette convention à l'étape Opération doit correspondre au nombre de logements de la liste à l'étape Logements """ - if self.nb_logements != self.total_nb_logements: - error = ValidationError( - f"Le nombre de logement à conventionner ({self.nb_logements}) " - + f"ne correspond pas au nombre de logements déclaré ({self.total_nb_logements})" - ) - self.optional_errors.append(error) + if self.nb_logements and self.total_nb_logements: + for financement in self.total_nb_logements: + if ( + self.nb_logements[financement] + != self.total_nb_logements[financement] + ): + error = ValidationError( + f"Le nombre de logement à conventionner ({self.nb_logements[financement]}) " + + f"ne correspond pas au nombre de logements déclaré ({self.total_nb_logements[financement]})" + ) + self.optional_errors.append(error) def manage_coefficient_propre(self): """ @@ -489,17 +510,21 @@ def manage_coefficient_propre(self): return loyer_with_coef += coeficient * surface_utile * loyer_par_metre_carre loyer_without_coef += surface_utile * loyer_par_metre_carre - if ( - self.nb_logements is not None - and round_half_up(loyer_with_coef, 2) - > round_half_up(loyer_without_coef, 2) + self.nb_logements - ): - error = ValidationError( - "La somme des loyers après application des coefficients ne peut excéder " - + "la somme des loyers sans application des coefficients, c'est à dire " - + f"{round_half_up(loyer_without_coef,2)} € (tolérance de {self.nb_logements} €)" - ) - self._non_form_errors.append(error) + + if self.nb_logements is not None: + for financement in self.nb_logements: + if ( + self.nb_logements[financement] is not None + and round_half_up(loyer_with_coef, 2) + > round_half_up(loyer_without_coef, 2) + + self.nb_logements[financement] + ): + error = ValidationError( + f"{financement} : La somme des loyers après application des coefficients ne peut excéder " + + "la somme des loyers sans application des coefficients, c'est à dire " + + f"{round_half_up(loyer_without_coef,2)} € (tolérance de {self.nb_logements[financement]} €)" + ) + self._non_form_errors.append(error) LogementFormSet = formset_factory(LogementForm, formset=BaseLogementFormSet, extra=0) @@ -512,6 +537,7 @@ def manage_coefficient_propre(self): LogementCorrigeeSansLoyerFormSet = formset_factory( LogementCorrigeeSansLoyerForm, formset=BaseLogementFormSet, extra=0 ) +LotLgtsOptionFormSet = formset_factory(LotLgtsOptionForm, extra=0) class FoyerResidenceLogementForm(forms.Form): diff --git a/conventions/forms/convention_form_type_stationnements.py b/conventions/forms/convention_form_type_stationnements.py index a99b2daf7..bbc01c03b 100644 --- a/conventions/forms/convention_form_type_stationnements.py +++ b/conventions/forms/convention_form_type_stationnements.py @@ -8,6 +8,7 @@ from programmes.models import ( TypologieStationnement, ) +from programmes.models.choices import Financement class TypeStationnementForm(forms.Form): @@ -24,6 +25,14 @@ class TypeStationnementForm(forms.Form): "required": "La typologie des stationnement est obligatoire", }, ) + financement = forms.TypedChoiceField( + required=True, + label="", + choices=Financement.choices, + error_messages={ + "required": "Le financement est obligatoire", + }, + ) nb_stationnements = forms.IntegerField( label="", error_messages={ diff --git a/conventions/forms/convention_mixed_form_initialisation.py b/conventions/forms/convention_mixed_form_initialisation.py new file mode 100644 index 000000000..961b2f5e4 --- /dev/null +++ b/conventions/forms/convention_mixed_form_initialisation.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from django import forms + + +class UUIDListForm(forms.Form): + uuids = forms.CharField(required=False) + action = forms.CharField(required=True) + + def clean_uuids(self): + raw_uuids = self.data.getlist("uuids") + if not raw_uuids: + return [] + + uuids = [] + errors = [] + for u in raw_uuids: + try: + uuids.append(UUID(u)) + except ValueError: + errors.append(u) + + if errors: + raise forms.ValidationError(f"Invalid UUIDs: {', '.join(errors)}") + + return uuids + + def clean_action(self): + action = self.cleaned_data.get("action") + if action not in ("create", "dispatch"): + raise forms.ValidationError(f"Invalid action: {action}") + return action diff --git a/conventions/models/convention.py b/conventions/models/convention.py index a7965391b..90fce4c68 100644 --- a/conventions/models/convention.py +++ b/conventions/models/convention.py @@ -1,15 +1,19 @@ import datetime import logging import uuid +from collections import defaultdict from datetime import date +from itertools import chain from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model from django.db import models from django.db.models import Prefetch, Q from django.forms import model_to_dict from django.http import HttpRequest from django.utils.functional import cached_property +from waffle import switch_is_active from conventions.models import TypeEvenement from conventions.models.avenant_type import AvenantType @@ -22,6 +26,10 @@ logger = logging.getLogger(__name__) +class ConventionGroupingError(Exception): + pass + + class ConventionQuerySet(models.QuerySet): def avenants(self): return self.exclude(parent=None) @@ -38,6 +46,101 @@ def get_queryset(self): .prefetch_related(Prefetch("lots", queryset=Lot.objects.order_by("pk"))) ) + def _split_first_convention(self, conventions): + """Returns the first convention and the rest of the conventions separately.""" + if not switch_is_active(settings.SWITCH_CONVENTION_MIXTE_ON): + return + + all_conventions = list(conventions.all()) + if not all_conventions: + return None, [] + return all_conventions[0], all_conventions[1:] + + def group_conventions(self, uuids_conventions): + if not switch_is_active(settings.SWITCH_CONVENTION_MIXTE_ON): + return + + if not uuids_conventions: + raise ConventionGroupingError( + "Nous ne pouvons pas créer une convention mixte, une liste de conventions doit être fournie" + ) + + related_conventions = self.model.objects.filter(uuid__in=uuids_conventions) + + programme_ids = {conv.programme_id for conv in related_conventions} + if len(programme_ids) > 1: + raise ConventionGroupingError( + "Les conventions doivent appartenir au même programme" + ) + + statut_list = {conv.statut for conv in related_conventions} + if statut_list != {ConventionStatut.PROJET.label}: + raise ConventionGroupingError( + "Les conventions doivent avoir le même statut" + ) + + type_habitat_list = { + conv.lots.first().type_habitat for conv in related_conventions + } + if len(type_habitat_list) > 1: + raise ConventionGroupingError( + "Tous les lots des conventions doivent avoir le même type d'habitat" + ) + + for conv in related_conventions: + if conv.is_avenant(): + raise ConventionGroupingError( + "Les avenants ne peuvent pas être groupés" + ) + + convention, others_conventions = self._split_first_convention( + related_conventions + ) + convention.set_lots(others_conventions) + + return convention.programme, convention.lots, convention + + def _degroup_convention(self, convention): + if not switch_is_active(settings.SWITCH_CONVENTION_MIXTE_ON): + return + degrouped_conventions_ids = [] + if convention.is_mixte: + for lot in convention.lots.all(): + degrouped_convention = convention.clone_convention() + lot.convention = degrouped_convention + lot.save() + degrouped_conventions_ids.append(degrouped_convention.id) + if degrouped_conventions_ids: + convention.delete() + + return self.model.objects.filter(id__in=degrouped_conventions_ids) + + def degroup_conventions(self, list_of_uuids_conventions): + if not switch_is_active(settings.SWITCH_CONVENTION_MIXTE_ON): + return + + if not list_of_uuids_conventions: + raise ConventionGroupingError( + "Nous ne pouvons pas dégrouper la convention, une liste de conventions est requise" + ) + + related_conventions = self.model.objects.filter( + uuid__in=list_of_uuids_conventions + ) + + if any(conv.has_avenant for conv in related_conventions): + raise ConventionGroupingError( + "Nous ne pouvons pas dégrouper les conventions qui ont un avenant" + ) + + for conv in related_conventions: + if conv.is_avenant(): + raise ConventionGroupingError( + "Les avenants ne peuvent pas être dégroupés" + ) + + return self._degroup_convention(related_conventions.first()) + class Convention(models.Model): objects = ConventionManager.from_queryset(ConventionQuerySet)() @@ -252,19 +355,21 @@ class Meta: def lot(self) -> Lot: return self.lots.first() + @property + def lots(self) -> Lot: + return self.lots + + @property + def is_mixte(self) -> bool: + """ + Returns True if the convention has multiple lots (mixte), + False if it has only one lot (Simple). + """ + return self.lots.count() > 1 + @property def is_pls_financement_type(self) -> bool: - if lot := self.lot: - return lot.financement in [ - Financement.PLS, - Financement.PLS_DOM, - Financement.PALULOS, - Financement.PALU_AV_21, - Financement.PALUCOM, - Financement.PALU_COM, - Financement.PALU_RE, - ] - return False + return self.lot.is_pls_financement_type @property def attribution_type(self) -> str | None: @@ -299,6 +404,31 @@ def administration(self): def bailleur(self): return self.programme.bailleur + @property + def lots_list(self): + from django.utils.html import format_html_join + + lots = self.lots.all() # use related_name if set in Lot model + return format_html_join( + ", ", + '{}', + ((lot.pk, str(lot)) for lot in lots), + ) + + @property + def get_financement_display(self): + return ", ".join( + [str(lot.get_financement_display()) for lot in self.lots.all()] + ) + + @property + def get_financements(self): + return [lot.financement for lot in self.lots.all()] + + @property + def nb_logements(self): + return sum([lot.nb_logements for lot in self.lots.all() if lot.nb_logements]) + @cached_property def ecolo_reference(self) -> EcoloReference | None: if self.id is not None: @@ -313,10 +443,11 @@ def __str__(self): if programme := self.programme: str_compose.append(programme.ville) str_compose.append(programme.nom) - if lot := self.lot: - str_compose.append(f"{lot.nb_logements} lgts") - str_compose.append(lot.get_type_habitat_display()) - str_compose.append(lot.financement) + if lots := self.lots.all(): + for lot in lots: + str_compose.append(f"{lot.nb_logements} lgts") + str_compose.append(lot.get_type_habitat_display()) + str_compose.append(lot.financement) if not str_compose: str_compose.append(self.uuid) return " - ".join(str_compose) @@ -377,6 +508,10 @@ def get_last_avenant_or_parent(self): )[1] return last_avenant_or_parent + @property + def has_avenant(self): + return self.avenants.exists() + def get_email_bailleur_users(self): """ return the email of the bailleurs to send them an email following their email @@ -591,7 +726,10 @@ def mixity_option(self): with low revenu should be displayed in the interface and fill in the convention document Should be editable when it is a PLUS convention """ - return self.lot.financement in [Financement.PLUS, Financement.PLUS_CD] + return any( + lot.financement in [Financement.PLUS, Financement.PLUS_CD] + for lot in self.lots.all() + ) def display_not_validated_status(self): """ @@ -643,57 +781,60 @@ def clone(self, user, *, convention_origin): cloned_convention = Convention(**convention_fields) cloned_convention.save() - lot_fields = model_to_dict( - self.lot, - exclude=[ - "id", - "parent", - "convention", - "cree_le", - "mis_a_jour_le", - ], - ) | { - "parent_id": convention_origin.lot.id, - "convention": cloned_convention, - } - cloned_lot = Lot(**lot_fields) - cloned_lot.save() - - for logement in self.lot.logements.all(): - logement.clone(lot=cloned_lot) - - for pret in convention_origin.lot.prets.all(): - pret.clone(lot=cloned_lot) - - for type_stationnement in self.lot.type_stationnements.all(): - type_stationnement_fields = model_to_dict( - type_stationnement, - exclude=[ - "id", - "lot", - "cree_le", - "mis_a_jour_le", - ], - ) | { - "lot": cloned_lot, - } - cloned_type_stationnement = TypeStationnement(**type_stationnement_fields) - cloned_type_stationnement.save() - - for locaux_collectif in self.lot.locaux_collectifs.all(): - locaux_collectif_fields = model_to_dict( - locaux_collectif, + for lot in self.lots.all(): + lot_fields = model_to_dict( + lot, exclude=[ "id", - "lot", + "parent", + "convention", "cree_le", "mis_a_jour_le", ], ) | { - "lot": cloned_lot, + "parent_id": convention_origin.lot.id, + "convention": cloned_convention, } - cloned_locaux_collectif = LocauxCollectifs(**locaux_collectif_fields) - cloned_locaux_collectif.save() + cloned_lot = Lot(**lot_fields) + cloned_lot.save() + + for logement in lot.logements.all(): + logement.clone(lot=cloned_lot) + + for pret in convention_origin.lot.prets.all(): + pret.clone(lot=cloned_lot) + + for type_stationnement in lot.type_stationnements.all(): + type_stationnement_fields = model_to_dict( + type_stationnement, + exclude=[ + "id", + "lot", + "cree_le", + "mis_a_jour_le", + ], + ) | { + "lot": cloned_lot, + } + cloned_type_stationnement = TypeStationnement( + **type_stationnement_fields + ) + cloned_type_stationnement.save() + + for locaux_collectif in lot.locaux_collectifs.all(): + locaux_collectif_fields = model_to_dict( + locaux_collectif, + exclude=[ + "id", + "lot", + "cree_le", + "mis_a_jour_le", + ], + ) | { + "lot": cloned_lot, + } + cloned_locaux_collectif = LocauxCollectifs(**locaux_collectif_fields) + cloned_locaux_collectif.save() return cloned_convention @@ -790,3 +931,130 @@ def get_contributors(self): result["instructeurs"].append((user.first_name, user.last_name)) result["number"] = len(result["instructeurs"]) + len(result["bailleurs"]) return result + + def clone_convention(self) -> "Convention": + """ + Create a shallow clone of this Convention (excluding id and uuid). + Keeps the same name. + """ + fields = { + f.name: getattr(self, f.name) + for f in self._meta.fields + if f.name not in ["id", "uuid"] + } + return Convention.objects.create(**fields) + + def set_lots( + self, joined_conventions: list["Convention"], with_remove_joined_convention=True + ) -> list[Lot] | bool: + """ + Reassign lots from other conventions to this convention. + Returns the list of reassigned lots on success, or empty list on failure. + """ + reassigned_lots = [] + try: + for convention in joined_conventions: + for lot in convention.lots.all(): + if lot is not None: + lot.convention = self + lot.save() + lot.save(update_fields=["convention"]) + reassigned_lots.append(lot) + + if with_remove_joined_convention: + convention.delete() + + return reassigned_lots + except (ValueError, TypeError) as e: + logger.error(e) + return [] + + def get_lots(self) -> list[Lot]: + return self.lots + + def repartition_surfaces(self): + result = defaultdict(lambda: defaultdict(int)) + data = [lot.repartition_surfaces() for lot in self.lots.all()] + for entry in data: + for type_name, subtypes in entry.items(): # INDIVIDUEL / COLLECTIF + for subtype, value in subtypes.items(): # T1, T2, .. + result[type_name][subtype] += value + + # Convert back to normal dicts + return {t: dict(sub) for t, sub in result.items()} + + def lgts_mixite_sociale_negocies_display(self) -> str: + return sum( + [lot.lgts_mixite_sociale_negocies_display() for lot in self.lots.all()] + ) + + def get_lot_with_financement(self, financement): + return self.lots.filter( + financement=financement, + ).first() + + @property + def logements_import_ordered(self): + return list( + chain.from_iterable( + lot.logements.filter( + surface_corrigee__isnull=True, loyer__isnull=False + ).order_by("import_order") + for lot in self.lots.all() + ) + ) + + @property + def logements_sans_loyer_import_ordered(self): + return list( + chain.from_iterable( + lot.logements.filter( + surface_corrigee__isnull=True, loyer__isnull=True + ).order_by("import_order") + for lot in self.lots.all() + ) + ) + + @property + def logements_corrigee_import_ordered(self): + return list( + chain.from_iterable( + lot.logements.filter( + surface_corrigee__isnull=False, loyer__isnull=False + ).order_by("import_order") + for lot in self.lots.all() + ) + ) + + @property + def logements_corrigee_sans_loyer_import_ordered(self): + return list( + chain.from_iterable( + lot.logements.filter( + surface_corrigee__isnull=False, loyer__isnull=True + ).order_by("import_order") + for lot in self.lots.all() + ) + ) + + @property + def annexes(self): + return chain.from_iterable(lot.annexes.all() for lot in self.lots.all()) + + @property + def stationnements(self): + return list( + chain.from_iterable( + lot.type_stationnements.all() for lot in self.lots.all() + ) + ) + + @property + def locaux_collectifs(self): + return list( + chain.from_iterable(lot.locaux_collectifs.all() for lot in self.lots.all()) + ) + + @property + def prets(self): + return list(chain.from_iterable(lot.prets.all() for lot in self.lots.all())) diff --git a/conventions/models/pret.py b/conventions/models/pret.py index 4ddb8f0ea..5e4115e44 100644 --- a/conventions/models/pret.py +++ b/conventions/models/pret.py @@ -30,6 +30,7 @@ class Pret(models.Model): # Needed to import xlsx files import_mapping = { "Numéro\n(caractères alphanuméric)": "numero", + "Financement": "financement", "Date d'octroi\n(format dd/mm/yyyy)": "date_octroi", "Durée\n(en années)": "duree", "Montant\n(en €)": "montant", diff --git a/conventions/services/annexes.py b/conventions/services/annexes.py index 403a1cb48..7669eda1e 100644 --- a/conventions/services/annexes.py +++ b/conventions/services/annexes.py @@ -1,4 +1,4 @@ -from conventions.forms import AnnexeFormSet, LotAnnexeForm, UploadForm +from conventions.forms import AnnexeFormSet, LotAnnexeForm, LotAnnexeFormSet, UploadForm from conventions.services import upload_objects, utils from conventions.services.conventions import ConventionService from programmes.models import Annexe, Logement @@ -8,15 +8,19 @@ class ConventionAnnexesService(ConventionService): form: LotAnnexeForm formset: AnnexeFormSet upform: UploadForm = UploadForm() + formset_convention_mixte: LotAnnexeFormSet def get(self): initial = [] - annexes = Annexe.objects.filter(logement__lot_id=self.convention.lot.id) + annexes = Annexe.objects.filter( + logement__lot_id__in=self.convention.lots.values_list("id", flat=True) + ) for annexe in annexes: initial.append( { "uuid": annexe.uuid, "typologie": annexe.typologie, + "financement": annexe.logement.lot.financement, "logement_designation": annexe.logement.designation, "logement_typologie": annexe.logement.typologie, "surface_hors_surface_retenue": annexe.surface_hors_surface_retenue, @@ -25,22 +29,26 @@ def get(self): } ) self.formset = AnnexeFormSet(initial=initial) - lot = self.convention.lot - self.form = LotAnnexeForm( - initial={ - "uuid": lot.uuid, - "annexe_caves": lot.annexe_caves, - "annexe_soussols": lot.annexe_soussols, - "annexe_remises": lot.annexe_remises, - "annexe_ateliers": lot.annexe_ateliers, - "annexe_sechoirs": lot.annexe_sechoirs, - "annexe_celliers": lot.annexe_celliers, - "annexe_resserres": lot.annexe_resserres, - "annexe_combles": lot.annexe_combles, - "annexe_balcons": lot.annexe_balcons, - "annexe_loggias": lot.annexe_loggias, - "annexe_terrasses": lot.annexe_terrasses, - } + self.formset_convention_mixte = LotAnnexeFormSet( + initial=[ + { + "uuid": lot.uuid, + "financement": lot.financement, + "annexe_caves": lot.annexe_caves, + "annexe_soussols": lot.annexe_soussols, + "annexe_remises": lot.annexe_remises, + "annexe_ateliers": lot.annexe_ateliers, + "annexe_sechoirs": lot.annexe_sechoirs, + "annexe_celliers": lot.annexe_celliers, + "annexe_resserres": lot.annexe_resserres, + "annexe_combles": lot.annexe_combles, + "annexe_balcons": lot.annexe_balcons, + "annexe_loggias": lot.annexe_loggias, + "annexe_terrasses": lot.annexe_terrasses, + } + for lot in self.convention.lots.all() + ], + prefix="lots", ) def save(self): @@ -49,7 +57,9 @@ def save(self): ) if self.request.POST.get("Upload", False): - self.form = LotAnnexeForm(self.request.POST) + self.formset_convention_mixte = LotAnnexeFormSet( + self.request.POST, prefix="lots" + ) self._upload_annexes() # When the user cliked on "Enregistrer et Suivant" else: @@ -70,7 +80,9 @@ def _upload_annexes(self): annexes_by_designation = {} for annexe in Annexe.objects.prefetch_related("logement").filter( - logement__lot_id=self.convention.lot.id + logement__lot_id__in=self.convention.lots.values_list( + "id", flat=True + ) ): annexes_by_designation[ f"{annexe.logement.designation}_{annexe.typologie}" @@ -92,44 +104,51 @@ def _upload_annexes(self): self.editable_after_upload = True def _save_lot_annexes(self): - lot = self.convention.lot - lot.annexe_caves = self.form.cleaned_data["annexe_caves"] - lot.annexe_soussols = self.form.cleaned_data["annexe_soussols"] - lot.annexe_remises = self.form.cleaned_data["annexe_remises"] - lot.annexe_ateliers = self.form.cleaned_data["annexe_ateliers"] - lot.annexe_sechoirs = self.form.cleaned_data["annexe_sechoirs"] - lot.annexe_celliers = self.form.cleaned_data["annexe_celliers"] - lot.annexe_resserres = self.form.cleaned_data["annexe_resserres"] - lot.annexe_combles = self.form.cleaned_data["annexe_combles"] - lot.annexe_balcons = self.form.cleaned_data["annexe_balcons"] - lot.annexe_loggias = self.form.cleaned_data["annexe_loggias"] - lot.annexe_terrasses = self.form.cleaned_data["annexe_terrasses"] - lot.save() + for form in self.formset_convention_mixte: + lot = self.convention.lots.get(financement=form.cleaned_data["financement"]) + lot.annexe_caves = form.cleaned_data["annexe_caves"] + lot.annexe_soussols = form.cleaned_data["annexe_soussols"] + lot.annexe_remises = form.cleaned_data["annexe_remises"] + lot.annexe_ateliers = form.cleaned_data["annexe_ateliers"] + lot.annexe_sechoirs = form.cleaned_data["annexe_sechoirs"] + lot.annexe_celliers = form.cleaned_data["annexe_celliers"] + lot.annexe_resserres = form.cleaned_data["annexe_resserres"] + lot.annexe_combles = form.cleaned_data["annexe_combles"] + lot.annexe_balcons = form.cleaned_data["annexe_balcons"] + lot.annexe_loggias = form.cleaned_data["annexe_loggias"] + lot.annexe_terrasses = form.cleaned_data["annexe_terrasses"] + lot.save() def _annexes_atomic_update(self): - self.form = LotAnnexeForm( - { - "uuid": self.convention.lot.uuid, - **utils.build_partial_form( - self.request, - self.convention.lot, - [ - "annexe_caves", - "annexe_soussols", - "annexe_remises", - "annexe_ateliers", - "annexe_sechoirs", - "annexe_celliers", - "annexe_resserres", - "annexe_combles", - "annexe_balcons", - "annexe_loggias", - "annexe_terrasses", - ], - ), - } + self.formset_convention_mixte = LotAnnexeFormSet( + data=self.request.POST, + initial=[ + { + "uuid": lot.uuid, + **utils.build_partial_form( + self.request, + lot, + [ + "annexe_caves", + "financement", + "annexe_soussols", + "annexe_remises", + "annexe_ateliers", + "annexe_sechoirs", + "annexe_celliers", + "annexe_resserres", + "annexe_combles", + "annexe_balcons", + "annexe_loggias", + "annexe_terrasses", + ], + ), + } + for lot in self.convention.lots.all() + ], + prefix="lots", ) - form_is_valid = self.form.is_valid() + form_is_valid = self.formset_convention_mixte.is_valid() self.formset = AnnexeFormSet(self.request.POST) initformset = { @@ -154,6 +173,9 @@ def _annexes_atomic_update(self): if form_annexe["logement_designation"].value() is not None else annexe.logement.designation ), + f"form-{idx}-financement": utils.get_form_value( + form_annexe, annexe, "financement" + ), f"form-{idx}-logement_typologie": ( form_annexe["logement_typologie"].value() if form_annexe["logement_typologie"].value() is not None @@ -176,6 +198,7 @@ def _annexes_atomic_update(self): f"form-{idx}-logement_designation": form_annexe[ "logement_designation" ].value(), + f"form-{idx}-financement": form_annexe["financement"].value(), f"form-{idx}-logement_typologie": form_annexe[ "logement_typologie" ].value(), @@ -199,15 +222,17 @@ def _annexes_atomic_update(self): def _save_annexes(self): obj_uuids1 = list(map(lambda x: x.cleaned_data["uuid"], self.formset)) obj_uuids = list(filter(None, obj_uuids1)) - Annexe.objects.filter(logement__lot_id=self.convention.lot.id).exclude( - uuid__in=obj_uuids - ).delete() + Annexe.objects.filter( + logement__lot_id__in=self.convention.lots.values_list("id", flat=True) + ).exclude(uuid__in=obj_uuids).delete() for form_annexe in self.formset: if form_annexe.cleaned_data["uuid"]: annexe = Annexe.objects.get(uuid=form_annexe.cleaned_data["uuid"]) logement = Logement.objects.get( designation=form_annexe.cleaned_data["logement_designation"], - lot=self.convention.lot, + lot=self.convention.lots.get( + financement=form_annexe.cleaned_data["financement"] + ), ) annexe.logement = logement annexe.typologie = form_annexe.cleaned_data["typologie"] @@ -221,7 +246,9 @@ def _save_annexes(self): else: logement = Logement.objects.get( designation=form_annexe.cleaned_data["logement_designation"], - lot=self.convention.lot, + lot=self.convention.lots.get( + financement=form_annexe.cleaned_data["financement"] + ), ) annexe = Annexe.objects.create( logement=logement, diff --git a/conventions/services/convention_generator.py b/conventions/services/convention_generator.py index 3645544b4..7d68fb61a 100644 --- a/conventions/services/convention_generator.py +++ b/conventions/services/convention_generator.py @@ -4,6 +4,7 @@ import math import os import subprocess +from functools import reduce from pathlib import Path import jinja2 @@ -78,19 +79,26 @@ def _compute_total_logement(convention): "loyer_total": 0, } nb_logements_par_type = {} - for logement in convention.lot.logements.order_by("typologie").all(): - logements_totale["sh_totale"] += logement.surface_habitable or 0 - logements_totale["sa_totale"] += logement.surface_annexes or 0 - logements_totale["sar_totale"] += logement.surface_annexes_retenue or 0 - logements_totale["su_totale"] += logement.surface_utile or 0 - logements_totale["sc_totale"] += logement.surface_corrigee or 0 - logements_totale["loyer_total"] += logement.loyer or 0 - if logement.get_typologie_display() not in nb_logements_par_type: - nb_logements_par_type[logement.get_typologie_display()] = 0 - nb_logements_par_type[logement.get_typologie_display()] += 1 + for lot in convention.lots.all(): + for logement in lot.logements.order_by("typologie").all(): + logements_totale["sh_totale"] += logement.surface_habitable or 0 + logements_totale["sa_totale"] += logement.surface_annexes or 0 + logements_totale["sar_totale"] += logement.surface_annexes_retenue or 0 + logements_totale["su_totale"] += logement.surface_utile or 0 + logements_totale["sc_totale"] += logement.surface_corrigee or 0 + logements_totale["loyer_total"] += logement.loyer or 0 + if logement.get_typologie_display() not in nb_logements_par_type: + nb_logements_par_type[logement.get_typologie_display()] = 0 + nb_logements_par_type[logement.get_typologie_display()] += 1 return (logements_totale, nb_logements_par_type) +def _compute_surface_locaux_collectifs_residentiels(convention): + return sum( + lot.surface_locaux_collectifs_residentiels for lot in convention.lots.all() + ) + + def _compute_total_locaux_collectifs(convention): return sum( locaux_collectif.surface_habitable * locaux_collectif.nombre @@ -118,6 +126,17 @@ def get_or_generate_convention_doc( return generate_convention_doc(convention=convention, save_data=save_data) +def _compute_type_stationnements(convention): + stationnements = [lot.type_stationnements.all() for lot in convention.lots.all()] + if not stationnements: + return convention.lots.none() + return reduce(lambda s1, s2: s1.union(s2), stationnements) + + +def _compute_object_list_from_each_lot(object_list): + return reduce(lambda s1, s2: s1.union(s2), object_list) + + def generate_convention_doc(convention: Convention, save_data=False) -> DocxTemplate: annexes = ( Annexe.objects.prefetch_related("logement") @@ -142,30 +161,49 @@ def generate_convention_doc(convention: Convention, save_data=False) -> DocxTemp adresse = _get_adresse(convention) + lots = convention.lots.prefetch_related( + "locaux_collectifs", "type_stationnements" + ).all() + context = { **avenant_data, "convention": convention, "bailleur": convention.programme.bailleur, "outre_mer": convention.programme.is_outre_mer, "programme": convention.programme, - "lot": convention.lot, + "lots": lots, "administration": convention.programme.administration, "logement_edds": logement_edds, - "logements": convention.lot.logements_import_ordered, - "logements_sans_loyer": convention.lot.logements_sans_loyer_import_ordered, - "logements_corrigee": convention.lot.logements_corrigee_import_ordered, - "logements_corrigee_sans_loyer": convention.lot.logements_corrigee_sans_loyer_import_ordered, - "locaux_collectifs": convention.lot.locaux_collectifs.all(), + "logements": convention.logements_import_ordered, + "logements_sans_loyer": _compute_object_list_from_each_lot( + [lot.logements_sans_loyer_import_ordered for lot in lots] + ), + "logements_corrigee": _compute_object_list_from_each_lot( + [lot.logements_corrigee_import_ordered for lot in lots] + ), + "logements_corrigee_sans_loyer": _compute_object_list_from_each_lot( + [lot.logements_corrigee_sans_loyer_import_ordered for lot in lots] + ), + "locaux_collectifs": _compute_object_list_from_each_lot( + [lot.locaux_collectifs.all() for lot in lots] + ), "annexes": annexes, - "stationnements": convention.lot.type_stationnements.all(), - "prets_cdc": convention.lot.prets.filter(preteur__in=["CDCF", "CDCL"]), - "autres_prets": convention.lot.prets.exclude(preteur__in=["CDCF", "CDCL"]), + "stationnements": _compute_object_list_from_each_lot( + [lot.type_stationnements.all() for lot in lots] + ), + "prets_cdc": [p for p in convention.prets if p.preteur in ["CDCF", "CDCL"]], + "autres_prets": [ + p for p in convention.prets if p.preteur not in ["CDCF", "CDCL"] + ], "references_cadastrales": convention.programme.referencecadastrales.all(), "nb_logements_par_type": nb_logements_par_type, "lot_num": lot_num, + "surface_locaux_collectifs_residentiels": _compute_surface_locaux_collectifs_residentiels( + convention + ), "loyer_m2": _get_loyer_par_metre_carre(convention), "liste_des_annexes": _compute_liste_des_annexes( - convention.lot.type_stationnements.all(), annexes + convention.stationnements, annexes ), "lc_sh_totale": _compute_total_locaux_collectifs(convention), "nombre_annees_conventionnement": ( @@ -175,6 +213,19 @@ def generate_convention_doc(convention: Convention, save_data=False) -> DocxTemp ), "res_sh_totale": _compute_total_locaux_collectifs(convention) + logements_totale["sh_totale"], + "surface_habitable_totale": sum( + lot.surface_habitable_totale + for lot in lots + if lot.surface_habitable_totale is not None + ), + "nombre_garage": sum( + lot.foyer_residence_nb_garage_parking + for lot in lots + if lot.foyer_residence_nb_garage_parking is not None + ), + "loyer_max_associations_foncieres": max( + lot.loyer_associations_foncieres or 0 for lot in lots + ), } context.update(compute_mixte(convention)) context.update(logements_totale) @@ -536,7 +587,13 @@ def generate_pdf(doc: DocxTemplate, convention_uuid: str) -> None: def _to_fr_float(value, d=2): if value is None: return "" - return format(value, f",.{d}f").replace(",", " ").replace(".", ",") + try: + # Try to convert to float if it's not already a number + float_value = float(value) + return format(float_value, f",.{d}f").replace(",", " ").replace(".", ",") + except (ValueError, TypeError): + # Handle cases where value cannot be converted to float + return "" def pluralize(value): @@ -785,15 +842,16 @@ def fiche_caf_doc(convention): "loyer_total": 0, } nb_logements_par_type = {} - for logement in convention.lot.logements.order_by("typologie").all(): - logements_totale["sh_totale"] += logement.surface_habitable or 0 - logements_totale["sa_totale"] += logement.surface_annexes or 0 - logements_totale["sar_totale"] += logement.surface_annexes_retenue or 0 - logements_totale["su_totale"] += logement.surface_utile or 0 - logements_totale["loyer_total"] += logement.loyer or 0 - if logement.get_typologie_display() not in nb_logements_par_type: - nb_logements_par_type[logement.get_typologie_display()] = 0 - nb_logements_par_type[logement.get_typologie_display()] += 1 + for lot in convention.lots.all(): + for logement in lot.logements.order_by("typologie").all(): + logements_totale["sh_totale"] += logement.surface_habitable or 0 + logements_totale["sa_totale"] += logement.surface_annexes or 0 + logements_totale["sar_totale"] += logement.surface_annexes_retenue or 0 + logements_totale["su_totale"] += logement.surface_utile or 0 + logements_totale["loyer_total"] += logement.loyer or 0 + if logement.get_typologie_display() not in nb_logements_par_type: + nb_logements_par_type[logement.get_typologie_display()] = 0 + nb_logements_par_type[logement.get_typologie_display()] += 1 lot_num = _prepare_logement_edds(convention) # tester si le logement existe avant de commencer @@ -805,7 +863,7 @@ def fiche_caf_doc(convention): "convention": convention, "bailleur": convention.programme.bailleur, "programme": convention.programme, - "lot": convention.lot, + "lots": convention.lots.all(), "administration": convention.programme.administration, "logements": convention.lot.logements.order_by("import_order"), "nb_logements_par_type": nb_logements_par_type, diff --git a/conventions/services/conventions.py b/conventions/services/conventions.py index a5bf7297d..7c28c8590 100644 --- a/conventions/services/conventions.py +++ b/conventions/services/conventions.py @@ -27,6 +27,7 @@ class ConventionService(ABC): formset_sans_loyer = None formset_corrigee = None formset_corrigee_sans_loyer = None + formset_convention_mixte = None upform: Form | None = None extra_forms: dict[str, Form | None] | None = None diff --git a/conventions/services/edd.py b/conventions/services/edd.py index dedd1c097..450ba3fbf 100644 --- a/conventions/services/edd.py +++ b/conventions/services/edd.py @@ -32,7 +32,7 @@ def get(self): self.form = ProgrammeEDDForm( initial={ "uuid": self.convention.programme.uuid, - "lot_uuid": self.convention.lot.uuid, + "lot_uuid": self.convention.lots.first().uuid, **utils.get_text_and_files_from_field( "edd_volumetrique", self.convention.programme.edd_volumetrique, @@ -99,7 +99,7 @@ def _programme_edd_atomic_update(self): { "uuid": self.convention.programme.uuid, **utils.init_text_and_files_from_field( - self.request, self.convention.lot, "edd_volumetrique" + self.request, self.convention.lots.first(), "edd_volumetrique" ), "mention_publication_edd_volumetrique": ( self.request.POST.get( @@ -108,7 +108,7 @@ def _programme_edd_atomic_update(self): ) ), **utils.init_text_and_files_from_field( - self.request, self.convention.lot, "edd_classique" + self.request, self.convention.lots.first(), "edd_classique" ), "mention_publication_edd_classique": ( self.request.POST.get( diff --git a/conventions/services/financement.py b/conventions/services/financement.py index 0fbf0572b..acb3b2bfb 100644 --- a/conventions/services/financement.py +++ b/conventions/services/financement.py @@ -13,18 +13,20 @@ class ConventionFinancementService(ConventionService): def get(self): initial = [] - for pret in self.convention.lot.prets.all(): - initial.append( - { - "uuid": pret.uuid, - "numero": pret.numero, - "date_octroi": utils.format_date_for_form(pret.date_octroi), - "duree": pret.duree, - "montant": pret.montant, - "preteur": pret.preteur, - "autre": pret.autre, - } - ) + for lot in self.convention.lots.all(): + for pret in lot.prets.all(): + initial.append( + { + "uuid": pret.uuid, + "numero": pret.numero, + "financement": lot.financement, + "date_octroi": utils.format_date_for_form(pret.date_octroi), + "duree": pret.duree, + "montant": pret.montant, + "preteur": pret.preteur, + "autre": pret.autre, + } + ) self.formset = PretFormSet(initial=initial) self.form = ConventionFinancementForm( initial={ @@ -53,8 +55,9 @@ def save(self): def _add_uuid_to_prets(self, result): prets_by_numero = {} - for pret in self.convention.lot.prets.all(): - prets_by_numero[pret.numero] = pret.uuid + for lot in self.convention.lots.all(): + for pret in lot.prets.all(): + prets_by_numero[pret.numero] = pret.uuid for obj in result["objects"]: if "numero" in obj and obj["numero"] in prets_by_numero: obj["uuid"] = prets_by_numero[obj["numero"]] @@ -123,6 +126,9 @@ def _convention_financement_atomic_update(self): f"form-{idx}-numero": utils.get_form_value( form_pret, pret, "numero" ), + f"form-{idx}-financement": utils.get_form_value( + form_pret, pret, "financement" + ), f"form-{idx}-date_octroi": utils.get_form_date_value( form_pret, pret, "date_octroi" ), @@ -139,6 +145,7 @@ def _convention_financement_atomic_update(self): initformset = { **initformset, f"form-{idx}-numero": form_pret["numero"].value(), + f"form-{idx}-financement": form_pret["financement"].value(), f"form-{idx}-date_octroi": form_pret["date_octroi"].value(), f"form-{idx}-duree": form_pret["duree"].value(), f"form-{idx}-montant": form_pret["montant"].value(), @@ -173,22 +180,32 @@ def _save_convention_financement(self): self.convention.save() def _save_convention_financement_prets(self): - obj_uuids1 = list(map(lambda x: x.cleaned_data["uuid"], self.formset)) - obj_uuids = list(filter(None, obj_uuids1)) - self.convention.lot.prets.exclude(uuid__in=obj_uuids).delete() + # Collect all uuids from the formset + obj_uuids = [ + form.cleaned_data["uuid"] + for form in self.formset + if form.cleaned_data.get("uuid") + ] + Pret.objects.filter(lot__convention=self.convention).exclude( + uuid__in=obj_uuids + ).delete() for form_pret in self.formset: - if form_pret.cleaned_data["uuid"]: - pret = Pret.objects.get(uuid=form_pret.cleaned_data["uuid"]) + lot = self.convention.lots.get( + financement=form_pret.cleaned_data["financement"] + ) + + uuid = form_pret.cleaned_data.get("uuid") + if uuid: + pret = Pret.objects.get(uuid=uuid) pret.numero = form_pret.cleaned_data["numero"] pret.date_octroi = form_pret.cleaned_data["date_octroi"] pret.duree = form_pret.cleaned_data["duree"] pret.montant = form_pret.cleaned_data["montant"] pret.preteur = form_pret.cleaned_data["preteur"] pret.autre = form_pret.cleaned_data["autre"] - else: pret = Pret.objects.create( - lot=self.convention.lot, + lot=lot, numero=form_pret.cleaned_data["numero"], date_octroi=form_pret.cleaned_data["date_octroi"], duree=form_pret.cleaned_data["duree"], diff --git a/conventions/services/logements.py b/conventions/services/logements.py index f3b303f18..20441c336 100644 --- a/conventions/services/logements.py +++ b/conventions/services/logements.py @@ -1,3 +1,7 @@ +import logging + +from django.core.exceptions import ValidationError + from conventions.forms import ( FoyerResidenceLogementFormSet, LogementCorrigeeFormSet, @@ -6,6 +10,7 @@ LogementSansLoyerFormSet, LotFoyerResidenceLgtsDetailsForm, LotLgtsOptionForm, + LotLgtsOptionFormSet, UploadForm, ) from conventions.services import upload_objects, utils @@ -13,6 +18,8 @@ from programmes.models import Logement, LogementCorrigee, LogementCorrigeeSansLoyer from programmes.models.models import LogementSansLoyer +logger = logging.getLogger(__name__) + class ConventionLogementsService(ConventionService): form: LotLgtsOptionForm @@ -20,6 +27,7 @@ class ConventionLogementsService(ConventionService): formset_sans_loyer: LogementSansLoyerFormSet formset_corrigee: LogementCorrigeeFormSet formset_corrigee_sans_loyer: LogementCorrigeeSansLoyerFormSet + formset_convention_mixte: LotLgtsOptionFormSet upform: UploadForm = UploadForm() def initialize_formsets(self): @@ -42,64 +50,82 @@ def initialize_formsets(self): initial_sans_loyer = [] initial_corrigee = [] initial_corrigee_sans_loyer = [] - logements = self.convention.lot.logements.order_by("import_order") - for logement in logements: - common_params = { - "uuid": logement.uuid, - "designation": logement.designation, - "typologie": logement.typologie, - "surface_habitable": logement.surface_habitable, - "import_order": logement.import_order, - } - surface_annexes_params = { - "surface_annexes": logement.surface_annexes, - "surface_annexes_retenue": logement.surface_annexes_retenue, - } - loyer_params = { - "loyer_par_metre_carre": logement.loyer_par_metre_carre, - "coeficient": logement.coeficient, - "loyer": logement.loyer, - } - surface_utile_params = { - "surface_utile": logement.surface_utile, - } - surface_corrigee_params = { - "surface_corrigee": logement.surface_corrigee, - } - if logement.loyer: - if logement.surface_corrigee: - initial_corrigee.append( - { - **common_params, - **surface_corrigee_params, - **loyer_params, - } - ) - else: - initial.append( - { - **common_params, - **surface_annexes_params, - **surface_utile_params, - **loyer_params, - } - ) - else: - if logement.surface_corrigee: - initial_corrigee_sans_loyer.append( - { - **common_params, - **surface_corrigee_params, - } - ) + initial_convention_mixte = [] + lots = self.convention.lots.all() + for lot in lots: + initial_convention_mixte.append( + { + "uuid": lot.uuid, + "financement": lot.financement, + "lgts_mixite_sociale_negocies": lot.lgts_mixite_sociale_negocies, + "loyer_derogatoire": lot.loyer_derogatoire, + "surface_locaux_collectifs_residentiels": ( + lot.surface_locaux_collectifs_residentiels + ), + "loyer_associations_foncieres": lot.loyer_associations_foncieres, + "nb_logements": lot.nb_logements, + } + ) + + logements = lot.logements.order_by("import_order") + for logement in logements: + common_params = { + "uuid": logement.uuid, + "designation": logement.designation, + "typologie": logement.typologie, + "financement": lot.financement, + "surface_habitable": logement.surface_habitable, + "import_order": logement.import_order, + } + surface_annexes_params = { + "surface_annexes": logement.surface_annexes, + "surface_annexes_retenue": logement.surface_annexes_retenue, + } + loyer_params = { + "loyer_par_metre_carre": logement.loyer_par_metre_carre, + "coeficient": logement.coeficient, + "loyer": logement.loyer, + } + surface_utile_params = { + "surface_utile": logement.surface_utile, + } + surface_corrigee_params = { + "surface_corrigee": logement.surface_corrigee, + } + if logement.loyer: + if logement.surface_corrigee: + initial_corrigee.append( + { + **common_params, + **surface_corrigee_params, + **loyer_params, + } + ) + else: + initial.append( + { + **common_params, + **surface_annexes_params, + **surface_utile_params, + **loyer_params, + } + ) else: - initial_sans_loyer.append( - { - **common_params, - **surface_annexes_params, - **surface_utile_params, - } - ) + if logement.surface_corrigee: + initial_corrigee_sans_loyer.append( + { + **common_params, + **surface_corrigee_params, + } + ) + else: + initial_sans_loyer.append( + { + **common_params, + **surface_annexes_params, + **surface_utile_params, + } + ) self.formset = LogementFormSet(initial=initial, prefix="avec_loyer") self.formset_sans_loyer = LogementSansLoyerFormSet( initial=initial_sans_loyer, prefix="sans_loyer" @@ -111,19 +137,12 @@ def initialize_formsets(self): initial=initial_corrigee_sans_loyer, prefix="corrigee_sans_loyer" ) + return initial_convention_mixte + def get(self): - self.initialize_formsets() - self.form = LotLgtsOptionForm( - initial={ - "uuid": self.convention.lot.uuid, - "lgts_mixite_sociale_negocies": self.convention.lot.lgts_mixite_sociale_negocies, - "loyer_derogatoire": self.convention.lot.loyer_derogatoire, - "surface_locaux_collectifs_residentiels": ( - self.convention.lot.surface_locaux_collectifs_residentiels - ), - "loyer_associations_foncieres": self.convention.lot.loyer_associations_foncieres, - "nb_logements": self.convention.lot.nb_logements, - } + initial_convention_mixte = self.initialize_formsets() + self.formset_convention_mixte = LotLgtsOptionFormSet( + initial=initial_convention_mixte, prefix="lots" ) def save(self): @@ -131,7 +150,9 @@ def save(self): "editable_after_upload", False ) if self.request.POST.get("Upload", False): - self.form = LotLgtsOptionForm(self.request.POST) + self.formset_convention_mixte = LotLgtsOptionFormSet( + self.request.POST, prefix="lots" + ) if self.request.POST["Upload"] == "file_sans_loyer": self._upload_logements( prefix="sans_loyer", @@ -184,7 +205,7 @@ def _upload_logements( ) if result["success"] != utils.ReturnStatus.ERROR: lgts_by_designation = {} - for lgt in Logement.objects.filter(lot_id=self.convention.lot.id): + for lgt in Logement.objects.filter(lot__in=self.convention.lots.all()): lgts_by_designation[lgt.designation] = lgt.uuid for obj in result["objects"]: if ( @@ -220,6 +241,9 @@ def _add_logement_to_initformset( f"{prefix}-{idx}-designation": self._get_form_value( form_logement, "designation" ), + f"{prefix}-{idx}-financement": self._get_form_value( + form_logement, "financement" + ), f"{prefix}-{idx}-typologie": self._get_form_value( form_logement, "typologie" ), @@ -273,38 +297,68 @@ def _add_logement_to_initformset( def _logements_update(self, prefix, formset_name, formset_class, logement_class): setattr(self, formset_name, formset_class(self.request.POST, prefix=prefix)) initformset = {} - nb_logements = 0 + nb_logements = {} for idx, form_logement in enumerate(getattr(self, formset_name)): + financement = self._get_form_value(form_logement, "financement") + if financement not in initformset: + initformset[financement] = {} + nb_logements[financement] = 0 result = self._add_logement_to_initformset( - form_logement, idx, initformset, nb_logements, prefix=prefix + form_logement, + idx, + initformset[financement], + nb_logements[financement], + prefix=prefix, + ) + initformset[financement] = result[0] + nb_logements[financement] = result[1] + + for financement in initformset: + initformset[financement] = { + **initformset[financement], + f"{prefix}-TOTAL_FORMS": nb_logements[financement], + f"{prefix}-INITIAL_FORMS": nb_logements[financement], + } + post_data_for_financement = { + **initformset[financement], + **{k: v for k, v in self.request.POST.items() if k.startswith(prefix)}, + } + setattr( + self, + formset_name, + formset_class(post_data_for_financement, prefix=prefix), ) - initformset = result[0] - nb_logements = result[1] - initformset = { - **initformset, - f"{prefix}-TOTAL_FORMS": nb_logements, - f"{prefix}-INITIAL_FORMS": nb_logements, - } - setattr(self, formset_name, formset_class(initformset, prefix=prefix)) - getattr(self, formset_name).programme_id = self.convention.programme_id - getattr(self, formset_name).lot_id = self.convention.lot.id - getattr(self, formset_name).nb_logements = int( - self.request.POST.get("nb_logements") or 0 - ) - getattr(self, formset_name).ignore_optional_errors = self.request.POST.get( - "ignore_optional_errors", False - ) + getattr(self, formset_name).programme_id = self.convention.programme_id + try: + lot = self.convention.lots.get(financement=financement) + lot_id = lot.id + except self.convention.lots.model.DoesNotExist as err: + financements_disponibles = list( + self.convention.lots.values_list("financement", flat=True) + ) + raise ValidationError( + f"Le financement '{financement}' n'existe pas pour cette convention. " + f"Financements disponibles : {financements_disponibles}" + ) from err + assert lot_id is not None, f"Lot with financement {financement} not found" + getattr(self, formset_name).lot_id = lot_id + getattr(self, formset_name).nb_logements = int( + nb_logements[financement] or 0 + ) + getattr(self, formset_name).ignore_optional_errors = self.request.POST.get( + "ignore_optional_errors", False + ) return nb_logements def _logements_atomic_update(self): - self.form = LotLgtsOptionForm( + initail_post = [ { - "uuid": self.convention.lot.uuid, + "uuid": lot.uuid, **utils.build_partial_form( self.request, - self.convention.lot, + lot, [ "lgts_mixite_sociale_negocies", "loyer_derogatoire", @@ -320,8 +374,15 @@ def _logements_atomic_update(self): ], ), } + for lot in self.convention.lots.all() + ] + + self.formset_convention_mixte = LotLgtsOptionFormSet( + data=self.request.POST, + initial=initail_post, + prefix="lots", ) - form_is_valid = self.form.is_valid() + formset_convention_mixte_is_valid = self.formset_convention_mixte.is_valid() nb_logements = self._logements_update( prefix="avec_loyer", @@ -347,69 +408,122 @@ def _logements_atomic_update(self): formset_class=LogementCorrigeeSansLoyerFormSet, logement_class=LogementCorrigeeSansLoyer, ) - total_nb_logements = ( - nb_logements - + nb_logements_sans_loyer - + nb_logements_corrigee - + nb_logements_corrigee_sans_loyer - ) + total_nb_logements = {} + for financement in nb_logements: + total_nb_logements[financement] = ( + nb_logements[financement] + if nb_logements + else ( + 0 + nb_logements_sans_loyer[financement] + if nb_logements_sans_loyer + else ( + 0 + nb_logements_corrigee[financement] + if nb_logements_corrigee + else ( + 0 + nb_logements_corrigee_sans_loyer[financement] + if nb_logements_corrigee_sans_loyer + else 0 + ) + ) + ) + ) + logger.error( + f"financement : {financement}, total_nb_logements = {total_nb_logements[financement]}" + ) + + self.formset.nb_logements = {} + for form in self.formset_convention_mixte: + financement = form.cleaned_data.get("financement") + if financement: + self.formset.nb_logements[financement] = form.cleaned_data.get( + "nb_logements", 0 + ) + logger.error( + f"self.formset.nb_logements : {self.formset.nb_logements[financement]}" + ) + + # self.formset.nb_logements = int(self.request.POST.get("nb_logements") or 0) + logger.error(f"self.formset.nb_logements {self.formset.nb_logements}") self.formset.total_nb_logements = total_nb_logements self.formset_sans_loyer.total_nb_logements = total_nb_logements self.formset_corrigee.total_nb_logements = total_nb_logements self.formset_corrigee_sans_loyer.total_nb_logements = total_nb_logements - formset_is_valid = ( - self.formset.is_valid() or self.form.cleaned_data["formset_disabled"] - ) - formset_sans_loyer_is_valid = ( - self.formset_sans_loyer.is_valid() - or self.form.cleaned_data["formset_sans_loyer_disabled"] - ) - formset_corrigee_is_valid = ( - self.formset_corrigee.is_valid() - or self.form.cleaned_data["formset_corrigee_disabled"] - ) - formset_corrigee_sans_loyer_is_valid = ( - self.formset_corrigee_sans_loyer.is_valid() - or self.form.cleaned_data["formset_corrigee_sans_loyer_disabled"] + formset_is_valid = self.formset.is_valid() or all( + form.cleaned_data.get("formset_disabled") + for form in self.formset_convention_mixte ) + formset_sans_loyer_is_valid = False + formset_corrigee_is_valid = False + formset_corrigee_sans_loyer_is_valid = False + if self.convention.is_avenant(): + formset_sans_loyer_is_valid = self.formset_sans_loyer.is_valid() + formset_corrigee_is_valid = self.formset_corrigee.is_valid() + formset_corrigee_sans_loyer_is_valid = ( + self.formset_corrigee_sans_loyer.is_valid() + ) - if ( - form_is_valid - and formset_is_valid - and formset_sans_loyer_is_valid - and formset_corrigee_is_valid - and formset_corrigee_sans_loyer_is_valid - ): - self._save_logements() - self._save_logements_sans_loyer() - self._save_logements_corrigee() - self._save_logements_corrigee_sans_loyer() - self._save_lot_lgts_option() - self.return_status = utils.ReturnStatus.SUCCESS + for form_item in self.formset_convention_mixte: - def _save_lot_lgts_option(self): - lot = self.convention.lot + if ( + not self.convention.is_avenant() + and formset_convention_mixte_is_valid + and formset_is_valid + ): + self._save_logements(form_item) + self._save_lot_lgts_option(form_item) + self.return_status = utils.ReturnStatus.SUCCESS + + else: + formset_sans_loyer_is_valid = ( + formset_sans_loyer_is_valid + or form_item.cleaned_data["formset_sans_loyer_disabled"] + ) + formset_corrigee_is_valid = ( + formset_corrigee_is_valid + or form_item.cleaned_data["formset_corrigee_disabled"] + ) + formset_corrigee_sans_loyer_is_valid = ( + formset_corrigee_sans_loyer_is_valid + or form_item.cleaned_data["formset_corrigee_sans_loyer_disabled"] + ) + + if ( + formset_convention_mixte_is_valid + and formset_is_valid + and formset_sans_loyer_is_valid + and formset_corrigee_is_valid + and formset_corrigee_sans_loyer_is_valid + ): + self._save_logements(form_item) + self._save_logements_sans_loyer(form_item) + self._save_logements_corrigee(form_item) + self._save_logements_corrigee_sans_loyer(form_item) + self._save_lot_lgts_option(form_item) + self.return_status = utils.ReturnStatus.SUCCESS + + def _save_lot_lgts_option(self, form_item): + lot = self.convention.lots.get(uuid=form_item.cleaned_data["uuid"]) lot.lgts_mixite_sociale_negocies = ( - self.form.cleaned_data["lgts_mixite_sociale_negocies"] or 0 + form_item.cleaned_data["lgts_mixite_sociale_negocies"] or 0 ) - lot.loyer_derogatoire = self.form.cleaned_data["loyer_derogatoire"] - lot.nb_logements = self.form.cleaned_data["nb_logements"] + lot.loyer_derogatoire = form_item.cleaned_data["loyer_derogatoire"] + lot.nb_logements = form_item.cleaned_data["nb_logements"] lot.surface_locaux_collectifs_residentiels = ( - self.form.cleaned_data["surface_locaux_collectifs_residentiels"] or 0 + form_item.cleaned_data["surface_locaux_collectifs_residentiels"] or 0 ) - lot.loyer_associations_foncieres = self.form.cleaned_data[ + lot.loyer_associations_foncieres = form_item.cleaned_data[ "loyer_associations_foncieres" ] lot.save() - def _save_logements_sans_loyer(self): + def _save_logements_sans_loyer(self, form_item): lgt_uuids1 = list( map(lambda x: x.cleaned_data["uuid"], self.formset_sans_loyer) ) lgt_uuids = list(filter(None, lgt_uuids1)) - if self.form.cleaned_data["formset_sans_loyer_disabled"]: + if form_item.cleaned_data["formset_sans_loyer_disabled"]: # Clear all logements sans loyer self.convention.lot.logements.filter( surface_corrigee__isnull=True, loyer__isnull=True @@ -435,7 +549,9 @@ def _save_logements_sans_loyer(self): logement.import_order = form_logement.cleaned_data["import_order"] else: logement = Logement.objects.create( - lot=self.convention.lot, + lot=self.convention.lots.filter( + financement=form_logement.cleaned_data["financement"] + ).first(), designation=form_logement.cleaned_data["designation"], typologie=form_logement.cleaned_data["typologie"], surface_habitable=form_logement.cleaned_data["surface_habitable"], @@ -448,20 +564,21 @@ def _save_logements_sans_loyer(self): ) logement.save() - def _save_logements(self): - + def _save_logements(self, form_item): lgt_uuids1 = list(map(lambda x: x.cleaned_data["uuid"], self.formset)) lgt_uuids = list(filter(None, lgt_uuids1)) - if self.form.cleaned_data["formset_disabled"]: + if form_item.cleaned_data["formset_disabled"]: # Clear all logements avec loyer - self.convention.lot.logements.filter( - surface_corrigee__isnull=True, loyer__isnull=False - ).delete() + for lot in self.convention.lots.all(): + lot.logements.filter( + surface_corrigee__isnull=True, loyer__isnull=False + ).delete() return else: - self.convention.lot.logements.exclude(uuid__in=lgt_uuids).filter( - surface_corrigee__isnull=True, loyer__isnull=False - ).delete() + for lot in self.convention.lots.all(): + lot.logements.exclude(uuid__in=lgt_uuids).filter( + surface_corrigee__isnull=True, loyer__isnull=False + ).delete() for form_logement in self.formset: if form_logement.cleaned_data["uuid"]: logement = Logement.objects.get(uuid=form_logement.cleaned_data["uuid"]) @@ -483,7 +600,9 @@ def _save_logements(self): logement.import_order = form_logement.cleaned_data["import_order"] else: logement = Logement.objects.create( - lot=self.convention.lot, + lot=self.convention.lots.filter( + financement=form_logement.cleaned_data["financement"] + ).first(), designation=form_logement.cleaned_data["designation"], typologie=form_logement.cleaned_data["typologie"], surface_habitable=form_logement.cleaned_data["surface_habitable"], @@ -501,11 +620,11 @@ def _save_logements(self): ) logement.save() - def _save_logements_corrigee(self): + def _save_logements_corrigee(self, form_item): lgt_uuids1 = list(map(lambda x: x.cleaned_data["uuid"], self.formset_corrigee)) lgt_uuids = list(filter(None, lgt_uuids1)) - if self.form.cleaned_data["formset_corrigee_disabled"]: + if form_item.cleaned_data["formset_corrigee_disabled"]: # Clear all logements with surface corrigée and loyer self.convention.lot.logements.filter( surface_corrigee__isnull=False, loyer__isnull=False @@ -535,7 +654,9 @@ def _save_logements_corrigee(self): else: logement = Logement.objects.create( - lot=self.convention.lot, + lot=self.convention.lots.filter( + financement=form_logement.cleaned_data["financement"] + ).first(), designation=form_logement.cleaned_data["designation"], typologie=form_logement.cleaned_data["typologie"], surface_habitable=form_logement.cleaned_data["surface_habitable"], @@ -549,13 +670,13 @@ def _save_logements_corrigee(self): ) logement.save() - def _save_logements_corrigee_sans_loyer(self): + def _save_logements_corrigee_sans_loyer(self, form_item): lgt_uuids1 = list( map(lambda x: x.cleaned_data["uuid"], self.formset_corrigee_sans_loyer) ) lgt_uuids = list(filter(None, lgt_uuids1)) - if self.form.cleaned_data["formset_corrigee_sans_loyer_disabled"]: + if form_item.cleaned_data["formset_corrigee_sans_loyer_disabled"]: # Clear all logements with surface corrigée and loyer self.convention.lot.logements.filter( surface_corrigee__isnull=False, loyer__isnull=True @@ -579,7 +700,9 @@ def _save_logements_corrigee_sans_loyer(self): logement.import_order = form_logement.cleaned_data["import_order"] else: logement = Logement.objects.create( - lot=self.convention.lot, + lot=self.convention.lots.filter( + financement=form_logement.cleaned_data["financement"] + ).first(), designation=form_logement.cleaned_data["designation"], typologie=form_logement.cleaned_data["typologie"], surface_habitable=form_logement.cleaned_data["surface_habitable"], diff --git a/conventions/services/recapitulatif.py b/conventions/services/recapitulatif.py index 52305d75e..759723e20 100644 --- a/conventions/services/recapitulatif.py +++ b/conventions/services/recapitulatif.py @@ -145,14 +145,14 @@ def get_convention_recapitulatif( return { "opened_comments": opened_comments, "annexes": Annexe.objects.filter( - logement__lot_id=self.convention.lot.id + logement__lot_id__in=self.convention.lots.values_list("id", flat=True) ).all(), "notificationForm": NotificationForm(), "conventionNumberForm": convention_number_form, "complete_for_avenant_form": complete_for_avenant_form, "ConventionType1and2Form": convention_type1_and_2_form, "programmeNumberForm": programme_number_form, - "repartition_surfaces": self.convention.lot.repartition_surfaces(), + "repartition_surfaces": self.convention.repartition_surfaces(), } def uncheck_avenant_type(self, avenant_type, avenant_type_title): diff --git a/conventions/services/search.py b/conventions/services/search.py index e47ed908e..eba75d061 100644 --- a/conventions/services/search.py +++ b/conventions/services/search.py @@ -144,11 +144,17 @@ def __init__(self, user: User, search_filters: dict | None = None) -> None: self.anru = search_filters.get("anru") is not None self.anah = search_filters.get("anah") is not None self.avenant_seulement = search_filters.get("avenant_seulement") is not None - if search_filters.get("statuts"): - self.statuts = [ - ConventionStatut.get_by_label(s) - for s in search_filters.get("statuts").split(",") - ] + + if statuts_raw := search_filters.get("statuts"): + if isinstance(statuts_raw, list): + self.statuts = [ + ConventionStatut.get_by_label(s) for s in statuts_raw + ] + else: + self.statuts = [ + ConventionStatut.get_by_label(s) for s in statuts_raw.split(",") + ] + for name in ( "bailleur", "date_signature", @@ -173,8 +179,6 @@ def _build_filters(self, queryset: QuerySet) -> QuerySet: # noqa: C901 _statut_filters = Q(statut__in=[s.label for s in self.statuts]) if ConventionStatut.SIGNEE in self.statuts: - # Si on filtre sur les conventions signées, - # on inclut égaement les conventions en cours de resiliation ou de denonciation if ConventionStatut.RESILIEE not in self.statuts: _statut_filters |= Q( statut=ConventionStatut.RESILIEE.label, diff --git a/conventions/services/services_programmes.py b/conventions/services/services_programmes.py index b88d4f772..89875c422 100644 --- a/conventions/services/services_programmes.py +++ b/conventions/services/services_programmes.py @@ -16,7 +16,6 @@ class ConventionProgrammeService(ConventionService): def get(self): programme = self.convention.programme - lot = self.convention.lot self.form = ProgrammeForm( initial={ "uuid": programme.uuid, @@ -24,7 +23,7 @@ def get(self): "adresse": self.convention.adresse or programme.adresse, "code_postal": programme.code_postal, "ville": programme.ville, - "type_habitat": lot.type_habitat, + "type_habitat": self.convention.lots.first().type_habitat, "type_operation": programme.type_operation, "anru": programme.anru, "anah": programme.anah, @@ -53,7 +52,8 @@ def _programme_atomic_update(self): { "uuid": self.convention.programme.uuid, "type_habitat": self.request.POST.get( - "type_habitat", self.convention.lot.type_habitat + "type_habitat", + self.convention.lots.first().type_habitat, ), **utils.build_partial_form( self.request, @@ -76,12 +76,12 @@ def _programme_atomic_update(self): if self.form.is_valid(): _save_convention_adresse(self.convention, self.form) _save_programme_and_lot( - self.convention.programme, self.convention.lot, self.form + self.convention.programme, self.convention.lots.all(), self.form ) self.return_status = utils.ReturnStatus.SUCCESS -def _save_programme_and_lot(programme: Programme, lot: Lot, form: ProgrammeForm): +def _save_programme_and_lot(programme: Programme, lots: list[Lot], form: ProgrammeForm): programme.nom = form.cleaned_data["nom"] programme.code_postal = form.cleaned_data["code_postal"] programme.ville = form.cleaned_data["ville"] @@ -95,8 +95,11 @@ def _save_programme_and_lot(programme: Programme, lot: Lot, form: ProgrammeForm) programme.nb_locaux_commerciaux = form.cleaned_data["nb_locaux_commerciaux"] programme.nb_bureaux = form.cleaned_data["nb_bureaux"] programme.save() - lot.type_habitat = form.cleaned_data["type_habitat"] - lot.save() + + # Set the same type_habitat for all lots (case convention mixte) + for lot in lots: + lot.type_habitat = form.cleaned_data["type_habitat"] + lot.save() def _save_convention_adresse(convention: Convention, form: ProgrammeForm): diff --git a/conventions/services/type_stationnement.py b/conventions/services/type_stationnement.py index 27ccb6d6a..16b370eaa 100644 --- a/conventions/services/type_stationnement.py +++ b/conventions/services/type_stationnement.py @@ -27,16 +27,18 @@ def get(self): self.request.POST.get("editable_after_upload", False) ) initial = [] - stationnements = self.convention.lot.type_stationnements.all() - for stationnement in stationnements: - initial.append( - { - "uuid": stationnement.uuid, - "typologie": stationnement.typologie, - "nb_stationnements": stationnement.nb_stationnements, - "loyer": stationnement.loyer, - } - ) + for lot in self.convention.lots.all(): + stationnements = lot.type_stationnements.all() + for stationnement in stationnements: + initial.append( + { + "uuid": stationnement.uuid, + "typologie": stationnement.typologie, + "financement": lot.financement, + "nb_stationnements": stationnement.nb_stationnements, + "loyer": stationnement.loyer, + } + ) self.formset = TypeStationnementFormSet(initial=initial) def save(self): @@ -110,6 +112,9 @@ def _stationnements_atomic_update(self): f"form-{idx}-typologie": utils.get_form_value( form_stationnement, stationnement, "typologie" ), + f"form-{idx}-financement": utils.get_form_value( + form_stationnement, stationnement.lot, "financement" + ), f"form-{idx}-nb_stationnements": utils.get_form_value( form_stationnement, stationnement, "nb_stationnements" ), @@ -121,6 +126,9 @@ def _stationnements_atomic_update(self): initformset = { **initformset, f"form-{idx}-typologie": form_stationnement["typologie"].value(), + f"form-{idx}-financement": form_stationnement[ + "financement" + ].value(), f"form-{idx}-nb_stationnements": form_stationnement[ "nb_stationnements" ].value(), @@ -137,14 +145,17 @@ def _stationnements_atomic_update(self): def _save_stationnements(self): obj_uuids1 = list(map(lambda x: x.cleaned_data["uuid"], self.formset)) obj_uuids = list(filter(None, obj_uuids1)) - TypeStationnement.objects.filter(lot_id=self.convention.lot.id).exclude( - uuid__in=obj_uuids - ).delete() + TypeStationnement.objects.filter( + lot_id__in=self.convention.lots.values_list("id", flat=True) + ).exclude(uuid__in=obj_uuids).delete() for form_stationnement in self.formset: if form_stationnement.cleaned_data["uuid"]: stationnement = TypeStationnement.objects.get( uuid=form_stationnement.cleaned_data["uuid"] ) + stationnement.lot = self.convention.lots.get( + financement=form_stationnement.cleaned_data["financement"] + ) stationnement.typologie = form_stationnement.cleaned_data["typologie"] stationnement.nb_stationnements = form_stationnement.cleaned_data[ "nb_stationnements" @@ -152,7 +163,9 @@ def _save_stationnements(self): stationnement.loyer = form_stationnement.cleaned_data["loyer"] else: stationnement = TypeStationnement.objects.create( - lot=self.convention.lot, + lot=self.convention.lots.get( + financement=form_stationnement.cleaned_data["financement"] + ), typologie=form_stationnement.cleaned_data["typologie"], nb_stationnements=form_stationnement.cleaned_data[ "nb_stationnements" diff --git a/conventions/services/utils.py b/conventions/services/utils.py index 18a25b1c5..9a4107a59 100644 --- a/conventions/services/utils.py +++ b/conventions/services/utils.py @@ -1,5 +1,4 @@ import json -import logging from datetime import date, datetime from enum import Enum @@ -12,8 +11,6 @@ from core.utils import is_valid_uuid from upload.models import UploadedFile -logger = logging.getLogger(__name__) - CONVENTION_EXPORT_MAX_ROWS = 5000 diff --git a/conventions/templatetags/custom_filters.py b/conventions/templatetags/custom_filters.py index 86e1ae9a9..8dd4b3da6 100644 --- a/conventions/templatetags/custom_filters.py +++ b/conventions/templatetags/custom_filters.py @@ -236,7 +236,9 @@ def without_missing_files(files): @register.filter def with_financement(convention): - return convention.lot.financement != Financement.SANS_FINANCEMENT + return Financement.SANS_FINANCEMENT not in [ + lot.financement for lot in convention.lots.all() + ] @register.filter diff --git a/conventions/tests/fixtures.py b/conventions/tests/fixtures.py index 0bcc18cbd..76a4b3440 100644 --- a/conventions/tests/fixtures.py +++ b/conventions/tests/fixtures.py @@ -79,6 +79,7 @@ "avec_loyer-INITIAL_FORMS": "2", "avec_loyer-0-uuid": "", "avec_loyer-0-designation": "B1", + "avec_loyer-0-financement": "PLUS", "avec_loyer-0-typologie": "T1", "avec_loyer-0-surface_habitable": "12.12", "avec_loyer-0-surface_annexes": "45.57", @@ -90,6 +91,7 @@ "avec_loyer-0-import_order": "0", "avec_loyer-1-uuid": "", "avec_loyer-1-designation": "B2", + "avec_loyer-1-financement": "PLUS", "avec_loyer-1-typologie": "T1", "avec_loyer-1-surface_habitable": "30.00", "avec_loyer-1-surface_annexes": "0.00", @@ -105,8 +107,14 @@ "corrigee_avec_loyer-INITIAL_FORMS": "0", "corrigee_sans_loyer-TOTAL_FORMS": "0", "corrigee_sans_loyer-INITIAL_FORMS": "0", - "loyer_derogatoire": "10", - "surface_locaux_collectifs_residentiels": "25", - "lgts_mixite_sociale_negocies": "2", - "nb_logements": "2", + "lots-TOTAL_FORMS": "1", + "lots-INITIAL_FORMS": "1", + "lots-MIN_NUM_FORMS": "0", + "lots-MAX_NUM_FORMS": "1000", + "lots-0-loyer_derogatoire": "10", + "lots-0-financement": "PLUS", + "lots-0-loyer_associations_foncieres": "30", + "lots-0-surface_locaux_collectifs_residentiels": "25", + "lots-0-lgts_mixite_sociale_negocies": "2", + "lots-0-nb_logements": "2", } diff --git a/conventions/tests/forms/__init__.py b/conventions/tests/forms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conventions/tests/forms/test_convention_mixed_form_initialisation.py b/conventions/tests/forms/test_convention_mixed_form_initialisation.py new file mode 100644 index 000000000..341f41182 --- /dev/null +++ b/conventions/tests/forms/test_convention_mixed_form_initialisation.py @@ -0,0 +1,58 @@ +from uuid import uuid4 + +from django.http import QueryDict +from django.test import TestCase + +from conventions.forms.convention_mixed_form_initialisation import UUIDListForm + + +class UUIDListFormTests(TestCase): + def test_valid_uuids_and_action_create(self): + """Should validate correctly with valid UUIDs and 'create' action.""" + qd = QueryDict("", mutable=True) + qd.setlist("uuids", [str(uuid4()), str(uuid4())]) + qd.update({"action": "create"}) + + form = UUIDListForm(qd) + self.assertTrue(form.is_valid()) + cleaned = form.cleaned_data + self.assertEqual(cleaned["action"], "create") + self.assertTrue(all(isinstance(u, type(uuid4())) for u in cleaned["uuids"])) + + def test_valid_action_dispatch(self): + """Should also accept 'dispatch' as a valid action.""" + qd = QueryDict("", mutable=True) + qd.setlist("uuids", [str(uuid4())]) + qd.update({"action": "dispatch"}) + + form = UUIDListForm(qd) + self.assertTrue(form.is_valid()) + + def test_invalid_action_raises_error(self): + """Should reject any invalid action value.""" + qd = QueryDict("", mutable=True) + qd.setlist("uuids", [str(uuid4())]) + qd.update({"action": "delete"}) + + form = UUIDListForm(qd) + self.assertFalse(form.is_valid()) + self.assertIn("Invalid action", form.errors["action"][0]) + + def test_invalid_uuid_raises_error(self): + """Should raise a validation error for invalid UUID strings.""" + qd = QueryDict("", mutable=True) + qd.setlist("uuids", ["not-a-uuid", str(uuid4())]) + qd.update({"action": "create"}) + + form = UUIDListForm(qd) + self.assertFalse(form.is_valid()) + self.assertIn("Invalid UUIDs", form.errors["uuids"][0]) + + def test_empty_uuid_list_returns_empty_list(self): + """If no uuids are provided, should return an empty list.""" + qd = QueryDict("", mutable=True) + qd.update({"action": "create"}) + + form = UUIDListForm(qd) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data["uuids"], []) diff --git a/conventions/tests/services/test_annexes_service.py b/conventions/tests/services/test_annexes_service.py index 835123887..8816ddd30 100644 --- a/conventions/tests/services/test_annexes_service.py +++ b/conventions/tests/services/test_annexes_service.py @@ -4,7 +4,7 @@ from django.http import HttpRequest from django.test import TestCase -from conventions.forms import AnnexeFormSet, LotAnnexeForm, UploadForm +from conventions.forms import AnnexeFormSet, LotAnnexeFormSet, UploadForm from conventions.models import Convention from conventions.services import utils from conventions.services.annexes import ConventionAnnexesService @@ -16,6 +16,7 @@ "form-INITIAL_FORMS": "2", "form-0-uuid": "", "form-0-typologie": "TERRASSE", + "form-0-financement": "PLUS", "form-0-logement_designation": "B1", "form-0-logement_typologie": "T1", "form-0-surface_hors_surface_retenue": "5.00", @@ -23,22 +24,28 @@ "form-0-loyer": "1.00", "form-1-uuid": "", "form-1-typologie": "TERRASSE", + "form-1-financement": "PLUS", "form-1-logement_designation": "B2", "form-1-logement_typologie": "T1", "form-1-surface_hors_surface_retenue": "5.00", "form-1-loyer_par_metre_carre": "0.2", "form-1-loyer": "1.00", - "annexe_caves": "FALSE", - "annexe_soussols": "FALSE", - "annexe_remises": "FALSE", - "annexe_ateliers": "FALSE", - "annexe_sechoirs": "FALSE", - "annexe_celliers": "FALSE", - "annexe_resserres": "on", - "annexe_combles": "on", - "annexe_balcons": "FALSE", - "annexe_loggias": "FALSE", - "annexe_terrasses": "FALSE", + # LotAnnexeFormSet block + "lots-TOTAL_FORMS": "1", + "lots-INITIAL_FORMS": "1", + "lots-0-uuid": "", + "lots-0-financement": "PLUS", + "lots-0-annexe_caves": "FALSE", + "lots-0-annexe_soussols": "FALSE", + "lots-0-annexe_remises": "FALSE", + "lots-0-annexe_ateliers": "FALSE", + "lots-0-annexe_sechoirs": "FALSE", + "lots-0-annexe_celliers": "FALSE", + "lots-0-annexe_resserres": "on", + "lots-0-annexe_combles": "on", + "lots-0-annexe_balcons": "FALSE", + "lots-0-annexe_loggias": "FALSE", + "lots-0-annexe_terrasses": "FALSE", } @@ -76,39 +83,47 @@ def setUp(self): def test_get(self): self.service.get() self.assertEqual(self.service.return_status, utils.ReturnStatus.ERROR) - self.assertIsInstance(self.service.form, LotAnnexeForm) - for lot_field in [ - "uuid", - "annexe_caves", - "annexe_soussols", - "annexe_remises", - "annexe_ateliers", - "annexe_sechoirs", - "annexe_celliers", - "annexe_resserres", - "annexe_combles", - "annexe_balcons", - "annexe_loggias", - "annexe_terrasses", - ]: - self.assertEqual( - self.service.form.initial[lot_field], - getattr(self.service.convention.lot, lot_field), - ) + self.assertIsInstance(self.service.formset_convention_mixte, LotAnnexeFormSet) + for form in self.service.formset_convention_mixte: + for lot_field in [ + "uuid", + "annexe_caves", + "financement", + "annexe_soussols", + "annexe_remises", + "annexe_ateliers", + "annexe_sechoirs", + "annexe_celliers", + "annexe_resserres", + "annexe_combles", + "annexe_balcons", + "annexe_loggias", + "annexe_terrasses", + ]: + self.assertEqual( + form.initial[lot_field], + getattr( + self.service.convention.lots.get( + financement=form.initial["financement"] + ), + lot_field, + ), + ) self.assertIsInstance(self.service.formset, AnnexeFormSet) self.assertIsInstance(self.service.upform, UploadForm) def test_save(self): self.service.request.POST = { - "uuid": str(self.service.convention.lot.uuid), **post_fixture, } self.service.save() + self.service.convention.refresh_from_db() self.assertEqual(self.service.return_status, utils.ReturnStatus.SUCCESS) annexes_b1 = Annexe.objects.filter( - logement__lot=self.service.convention.lot, logement__designation="B1" + logement__lot__in=self.service.convention.lots.all(), + logement__designation="B1", ) self.assertEqual(annexes_b1.count(), 1) @@ -140,9 +155,11 @@ def test_save(self): "annexe_loggias", "annexe_terrasses", ]: - self.assertFalse(getattr(self.service.convention.lot, annexe)) + for lot in self.service.convention.lots.all(): + self.assertFalse(getattr(lot, annexe)) for annexe in ["annexe_resserres", "annexe_combles"]: - self.assertTrue(getattr(self.service.convention.lot, annexe)) + for lot in self.service.convention.lots.all(): + self.assertTrue(getattr(lot, annexe)) def test_save_ok_on_loyer(self): self.service.request.POST = { diff --git a/conventions/tests/services/test_convention_generator.py b/conventions/tests/services/test_convention_generator.py index 94530e481..3b4179600 100644 --- a/conventions/tests/services/test_convention_generator.py +++ b/conventions/tests/services/test_convention_generator.py @@ -79,8 +79,9 @@ def convention_context_keys(): "logements_sans_loyer", "logements", "lot_num", - "lot", + "lots", "loyer_m2", + "loyer_max_associations_foncieres", "loyer_total", "mixPLUS_10pc", "mixPLUS_30pc", @@ -89,6 +90,7 @@ def convention_context_keys(): "mixPLUSsup10_30pc", "nb_logements_par_type", "nombre_annees_conventionnement", + "nombre_garage", "outre_mer", "prets_cdc", "programme", @@ -106,6 +108,8 @@ def convention_context_keys(): "signataire_nom", "stationnements", "su_totale", + "surface_habitable_totale", + "surface_locaux_collectifs_residentiels", "vendeur_images", "ville", } @@ -632,7 +636,7 @@ def _fiche_caf_context(self): "convention", "foyer_attributions", "logements", - "lot", + "lots", "lot_num", "loyer_m2", "loyer_total", diff --git a/conventions/tests/services/test_financement_service.py b/conventions/tests/services/test_financement_service.py index 0ab1ffd27..06d37013b 100644 --- a/conventions/tests/services/test_financement_service.py +++ b/conventions/tests/services/test_financement_service.py @@ -16,6 +16,7 @@ "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-numero": "A", + "form-0-financement": "PLUS", "form-0-date_octroi": "2020-01-01", "form-0-duree": "50", "form-0-montant": "1000000.00", @@ -23,6 +24,7 @@ "form-0-autre": "", "form-1-uuid": "", "form-1-numero": "A", + "form-1-financement": "PLUS", "form-1-date_octroi": "2020-01-01", "form-1-duree": "20", "form-1-montant": "200000.00", @@ -63,6 +65,8 @@ def test_get(self): self.assertIsInstance(self.service.upform, UploadForm) def test_save_success(self): + financement_form["form-0-financement"] = "PLUS" + financement_form["form-1-financement"] = "PLUS" self.service.request.POST = financement_form self.service.save() @@ -218,7 +222,8 @@ def test_pls_avenant_date_fin_conventionnement(self): lot = self.service_avenant.convention.lot lot.financement = financement lot.save() - + financement_form["form-0-financement"] = financement + financement_form["form-1-financement"] = financement self.service_avenant.request.POST = { **financement_form, "annee_fin_conventionnement": 2065, @@ -232,11 +237,14 @@ def test_pls_avenant_date_fin_conventionnement(self): def test_formset_validate_numero_unicity_fail(): upload_result = { "objects": [ - {"numero": "1"}, - {"numero": "2"}, - {"numero": "1"}, - {"numero": "3"}, - {"numero": "3"}, + {"numero": "1", "financement": "PLAI"}, + {"numero": "2", "financement": "PLAI"}, + {"numero": "1", "financement": "PLAI"}, + {"numero": "3", "financement": "PLAI"}, + {"numero": "3", "financement": "PLAI"}, + {"numero": "1", "financement": "PLUS"}, + {"numero": "2", "financement": "PLUS"}, + {"numero": "1", "financement": "PLUS"}, ] } @@ -245,16 +253,34 @@ def test_formset_validate_numero_unicity_fail(): assert not is_valid assert formset.forms[0].errors == { - "numero": ["Le numéro de financement 1 n'est pas unique."] + "numero": [ + "Le numéro de financement 1 n'est pas unique pour le financement PLAI." + ] } assert formset.forms[1].errors == {} assert formset.forms[3].errors == { - "numero": ["Le numéro de financement 3 n'est pas unique."] + "numero": [ + "Le numéro de financement 3 n'est pas unique pour le financement PLAI." + ] + } + assert formset.forms[5].errors == { + "numero": [ + "Le numéro de financement 1 n'est pas unique pour le financement PLUS." + ] } def test_formset_validate_numero_unicity_success(): - upload_result = {"objects": [{"numero": "1"}, {"numero": "2"}, {"numero": "3"}]} + upload_result = { + "objects": [ + {"numero": "1", "financement": "PLAI"}, + {"numero": "2", "financement": "PLAI"}, + {"numero": "3", "financement": "PLAI"}, + {"numero": "1", "financement": "PLUS"}, + {"numero": "2", "financement": "PLUS"}, + {"numero": "3", "financement": "PLUS"}, + ] + } formset = PretFormSet(initial=upload_result["objects"]) is_valid = formset.validate_initial_numero_unicity() diff --git a/conventions/tests/services/test_logements_service.py b/conventions/tests/services/test_logements_service.py index 8ad7d57b6..ba6f271f1 100644 --- a/conventions/tests/services/test_logements_service.py +++ b/conventions/tests/services/test_logements_service.py @@ -8,7 +8,7 @@ FoyerResidenceLogementFormSet, LogementFormSet, LotFoyerResidenceLgtsDetailsForm, - LotLgtsOptionForm, + LotLgtsOptionFormSet, UploadForm, ) from conventions.models import Convention @@ -40,8 +40,9 @@ class ConventionLogementsServiceTests(TestCase): def setUp(self): request = HttpRequest() convention = Convention.objects.get(numero="0001") - convention.lot.nb_logements = 2 - + for lot in convention.lots.all(): + lot.nb_logements = 2 + lot.save() request.user = User.objects.get(username="fix") self.service = ConventionLogementsService( convention=convention, request=request @@ -55,26 +56,34 @@ def setUp(self): def test_get(self): self.service.get() self.assertEqual(self.service.return_status, utils.ReturnStatus.ERROR) - self.assertIsInstance(self.service.form, LotLgtsOptionForm) - for lot_field in [ - "uuid", - "lgts_mixite_sociale_negocies", - "loyer_derogatoire", - "surface_locaux_collectifs_residentiels", - "loyer_associations_foncieres", - "nb_logements", - ]: - self.assertEqual( - self.service.form.initial[lot_field], - getattr(self.service.convention.lot, lot_field), + self.assertIsInstance( + self.service.formset_convention_mixte, LotLgtsOptionFormSet + ) + + for form in self.service.formset_convention_mixte: + lot = self.service.convention.lots.get( + financement=form.initial["financement"] ) + for lot_field in [ + "uuid", + "financement", + "lgts_mixite_sociale_negocies", + "loyer_derogatoire", + "surface_locaux_collectifs_residentiels", + "loyer_associations_foncieres", + "nb_logements", + ]: + self.assertEqual( + form.initial[lot_field], + getattr(lot, lot_field), + ) self.assertIsInstance(self.service.formset, LogementFormSet) self.assertIsInstance(self.service.upform, UploadForm) def test_save(self): self.service.request.POST = { "nb_logements": "2", - "uuid": str(self.service.convention.lot.uuid), + "lots-0-uuid": str(self.service.convention.lot.uuid), **logement_success_payload, } @@ -82,7 +91,10 @@ def test_save(self): self.assertEqual(self.service.return_status, utils.ReturnStatus.SUCCESS) logement_b1 = Logement.objects.prefetch_related("lot").get( - lot=self.service.convention.lot, designation="B1" + lot=self.service.convention.lots.get( + financement=logement_success_payload["lots-0-financement"] + ), + designation="B1", ) self.assertEqual( @@ -133,10 +145,11 @@ def test_save_fails_on_loyer(self): def test_save_fails_on_nb_logements(self): self.service.request.POST = { - "uuid": str(self.service.convention.lot.uuid), + "lots-0-uuid": str(self.service.convention.lot.uuid), **logement_success_payload, - "nb_logements": "3", } + + self.service.request.POST["lots-0-nb_logements"] = "3" self.service.save() assert self.service.formset.optional_errors == [ ValidationError( @@ -149,8 +162,8 @@ def test_save_fails_on_nb_logements_avenants(self): self.service_avenant.request.POST = { "uuid": str(self.service_avenant.convention.lot.uuid), **logement_success_payload, - "nb_logements": "3", } + self.service_avenant.request.POST["lots-0-nb_logements"] = "3" self.service_avenant.save() assert self.service_avenant.formset.optional_errors == [ ValidationError( diff --git a/conventions/tests/services/test_type_stationnement_service.py b/conventions/tests/services/test_type_stationnement_service.py index b2c31504b..9e196c94b 100644 --- a/conventions/tests/services/test_type_stationnement_service.py +++ b/conventions/tests/services/test_type_stationnement_service.py @@ -39,10 +39,12 @@ def test_save(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-typologie": "GARAGE_AERIEN", + "form-0-financement": "PLUS", "form-0-nb_stationnements": 30, "form-0-loyer": "", "form-1-uuid": "", "form-1-typologie": "GARAGE_ENTERRE", + "form-1-financement": "PLUS", "form-1-nb_stationnements": "", "form-1-loyer": 100.00, } @@ -58,10 +60,12 @@ def test_save(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-typologie": "GARAGE_AERIEN", + "form-0-financement": "PLUS", "form-0-nb_stationnements": 30, "form-0-loyer": 12, "form-1-uuid": "", "form-1-typologie": "GARAGE_ENTERRE", + "form-1-financement": "PLUS", "form-1-nb_stationnements": 5, "form-1-loyer": 10.00, } diff --git a/conventions/tests/views/test_annexes_view.py b/conventions/tests/views/test_annexes_view.py index 7cd093d86..af33ce84b 100644 --- a/conventions/tests/views/test_annexes_view.py +++ b/conventions/tests/views/test_annexes_view.py @@ -42,7 +42,7 @@ def setUp(self): ) self.target_template = "conventions/annexes.html" self.error_payload = { - "uuid": self.convention_75.lot.uuid, + "lots-0-uuid": self.convention_75.lot.uuid, "form-TOTAL_FORMS": "2", "form-INITIAL_FORMS": "2", "form-0-uuid": "", @@ -59,25 +59,31 @@ def setUp(self): "form-1-surface_hors_surface_retenue": "5.00", "form-1-loyer_par_metre_carre": "0.2", "form-1-loyer": "10.00", - "annexe_caves": "FALSE", - "annexe_soussols": "FALSE", - "annexe_remises": "FALSE", - "annexe_ateliers": "FALSE", - "annexe_sechoirs": "FALSE", - "annexe_celliers": "FALSE", - "annexe_resserres": "on", - "annexe_combles": "on", - "annexe_balcons": "FALSE", - "annexe_loggias": "FALSE", - "annexe_terrasses": "FALSE", + "lots-TOTAL_FORMS": "1", + "lots-INITIAL_FORMS": "1", + "lots-MIN_NUM_FORMS": "0", + "lots-MAX_NUM_FORMS": "1000", + "lots-0-financement": "PLUS", + "lots-0-annexe_caves": "FALSE", + "lots-0-annexe_soussols": "FALSE", + "lots-0-annexe_remises": "FALSE", + "lots-0-annexe_ateliers": "FALSE", + "lots-0-annexe_sechoirs": "FALSE", + "lots-0-annexe_celliers": "FALSE", + "lots-0-annexe_resserres": "on", + "lots-0-annexe_combles": "on", + "lots-0-annexe_balcons": "FALSE", + "lots-0-annexe_loggias": "FALSE", + "lots-0-annexe_terrasses": "FALSE", } self.success_payload = { - "uuid": self.convention_75.lot.uuid, + "lots-0-uuid": self.convention_75.lot.uuid, "form-TOTAL_FORMS": "2", "form-INITIAL_FORMS": "2", "form-0-uuid": "", "form-0-typologie": "TERRASSE", "form-0-logement_designation": "B1", + "form-0-financement": "PLUS", "form-0-logement_typologie": "T1", "form-0-surface_hors_surface_retenue": "5.00", "form-0-loyer_par_metre_carre": "0.2", @@ -85,21 +91,27 @@ def setUp(self): "form-1-uuid": "", "form-1-typologie": "TERRASSE", "form-1-logement_designation": "B2", + "form-1-financement": "PLUS", "form-1-logement_typologie": "T1", "form-1-surface_hors_surface_retenue": "5.00", "form-1-loyer_par_metre_carre": "0.2", "form-1-loyer": "1.00", - "annexe_caves": "FALSE", - "annexe_soussols": "FALSE", - "annexe_remises": "FALSE", - "annexe_ateliers": "FALSE", - "annexe_sechoirs": "FALSE", - "annexe_celliers": "FALSE", - "annexe_resserres": "on", - "annexe_combles": "on", - "annexe_balcons": "FALSE", - "annexe_loggias": "FALSE", - "annexe_terrasses": "FALSE", + "lots-TOTAL_FORMS": "1", + "lots-INITIAL_FORMS": "1", + "lots-MIN_NUM_FORMS": "0", + "lots-MAX_NUM_FORMS": "1000", + "lots-0-financement": "PLUS", + "lots-0-annexe_caves": "FALSE", + "lots-0-annexe_soussols": "FALSE", + "lots-0-annexe_remises": "FALSE", + "lots-0-annexe_ateliers": "FALSE", + "lots-0-annexe_sechoirs": "FALSE", + "lots-0-annexe_celliers": "FALSE", + "lots-0-annexe_resserres": "on", + "lots-0-annexe_combles": "on", + "lots-0-annexe_balcons": "FALSE", + "lots-0-annexe_loggias": "FALSE", + "lots-0-annexe_terrasses": "FALSE", } self.msg_prefix = "[ConventionAnnexesViewTests] " diff --git a/conventions/tests/views/test_conventions_mix.py b/conventions/tests/views/test_conventions_mix.py new file mode 100644 index 000000000..5901dc236 --- /dev/null +++ b/conventions/tests/views/test_conventions_mix.py @@ -0,0 +1,363 @@ +from unittest import mock + +import pytest +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from conventions.models.choices import ConventionStatut +from conventions.models.convention import Convention, ConventionGroupingError +from conventions.views.conventions_mix import ConventionMix +from core.tests.factories import ( + AnnexeFactory, + ConventionFactory, + LogementFactory, + LotFactory, + ProgrammeFactory, +) +from instructeurs.tests.factories import AdministrationFactory +from programmes.models.choices import ( + Financement, + TypeHabitat, + TypologieAnnexe, + TypologieLogement, +) +from users.models import GroupProfile, User + + +class ConventionMixViewTests(TestCase): + fixtures = [ + "auth.json", + "departements.json", + "avenant_types.json", + "bailleurs_for_tests.json", + "instructeurs_for_tests.json", + "programmes_for_tests.json", + "conventions_for_tests.json", + "users_for_tests.json", + ] + + @mock.patch("conventions.models.convention.switch_is_active") + def setUp(self, mock_switch_model): + mock_switch_model.return_value = True + + get_response = mock.MagicMock() + self.request = RequestFactory().get("/") + middleware = SessionMiddleware(get_response) + middleware.process_request(self.request) + self.request.session.save() + + self.factory = RequestFactory() + programme_1 = ProgrammeFactory( + administration=AdministrationFactory(code="admin1"), + code_postal="10000", + ville="Troyes", + ) + self.programme_2 = ProgrammeFactory( + administration=AdministrationFactory(code="admin2"), + code_postal="10800", + ville="La Vendue-Mignot", + ) + self.convention_plai = ConventionFactory(programme=programme_1, numero="0001") + self.convention_plus = ConventionFactory(programme=programme_1, numero="0002") + lot_plai = LotFactory( + convention=self.convention_plai, + financement=Financement.PLAI, + type_habitat=TypeHabitat.COLLECTIF, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + lot_plus = LotFactory( + convention=self.convention_plus, + financement=Financement.PLUS, + type_habitat=TypeHabitat.COLLECTIF, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + + logement = LogementFactory( + lot=lot_plai, designation="PLAI 1", typologie=TypologieLogement.T1 + ) + + AnnexeFactory( + logement=logement, + typologie=TypologieAnnexe.COUR, + surface_hors_surface_retenue=5, + loyer_par_metre_carre=0.1, + ) + + AnnexeFactory( + logement=logement, + typologie=TypologieAnnexe.JARDIN, + surface_hors_surface_retenue=5, + loyer_par_metre_carre=0.1, + ) + + LogementFactory( + lot=lot_plai, designation="PLAI 2", typologie=TypologieLogement.T2 + ) + LogementFactory( + lot=lot_plai, designation="PLAI 3", typologie=TypologieLogement.T3 + ) + LogementFactory( + lot=lot_plus, designation="PLUS 1", typologie=TypologieLogement.T1 + ) + + self.convention_pls_programme_2 = ConventionFactory( + programme=self.programme_2, numero="0003" + ) + self.convention_plai_programme_2 = ConventionFactory( + programme=self.programme_2, numero="0004" + ) + + lot_plai = LotFactory( + convention=self.convention_pls_programme_2, + financement=Financement.PLS, + type_habitat=TypeHabitat.MIXTE, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + lot_plus = LotFactory( + convention=self.convention_plai_programme_2, + financement=Financement.PLAI, + type_habitat=TypeHabitat.MIXTE, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + logement = LogementFactory( + lot=lot_plai, designation="PLAI-PROG-2-1", typologie=TypologieLogement.T1 + ) + + AnnexeFactory( + logement=logement, + typologie=TypologieAnnexe.TERRASSE, + surface_hors_surface_retenue=5, + loyer_par_metre_carre=12, + ) + + AnnexeFactory( + logement=logement, + typologie=TypologieAnnexe.COUR, + surface_hors_surface_retenue=7, + loyer_par_metre_carre=10, + ) + + LogementFactory( + lot=lot_plai, designation="PLAI-PROG-2-2", typologie=TypologieLogement.T2 + ) + + LogementFactory( + lot=lot_plai, designation="PLAI-PROG-2-3", typologie=TypologieLogement.T3 + ) + + LogementFactory( + lot=lot_plus, designation="PLS-PROG-2-1", typologie=TypologieLogement.T1 + ) + + self.mixed_convention_Programmp, _, self.mixed_convention = ( + Convention.objects.group_conventions( + [ + str(self.convention_pls_programme_2.uuid), + str(self.convention_plai_programme_2.uuid), + ] + ) + ) + self.mixed_convention.refresh_from_db() + + self.convention_plai_habitat_mixte = ConventionFactory( + programme=programme_1, numero="0005", statut=ConventionStatut.A_SIGNER.label + ) + self.convention_plus_habitat_collectif = ConventionFactory( + programme=programme_1, + numero="0006", + statut=ConventionStatut.INSTRUCTION.label, + ) + lot_plai = LotFactory( + convention=self.convention_plai_habitat_mixte, + financement=Financement.PLAI, + type_habitat=TypeHabitat.MIXTE, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + lot_plus = LotFactory( + convention=self.convention_plus_habitat_collectif, + financement=Financement.PLUS, + type_habitat=TypeHabitat.COLLECTIF, + nb_logements=None, + make_upload_on_fields=["edd_volumetrique", "edd_classique"], + ) + + @mock.patch("conventions.views.conventions_mix.switch_is_active") + def test_redirects_to_search_when_switch_off(self, mock_switch): + mock_switch.return_value = False + request = self.factory.post(reverse("conventions:search"), data={}) + response = ConventionMix.as_view()(request) + # Should redirect to search when switch is off + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("conventions:search"), response["Location"]) + + @mock.patch("conventions.views.conventions_mix.switch_is_active") + @mock.patch("conventions.models.convention.switch_is_active") + def test_create_action_groups_and_redirects( + self, mock_switch_model, mock_switch_view + ): + mock_switch_view.return_value = True + mock_switch_model.return_value = True + self.assertEqual(self.convention_plai.lots.count(), 1) + self.assertEqual(self.convention_plus.lots.count(), 1) + + data = { + "uuids": [str(self.convention_plai.uuid), str(self.convention_plus.uuid)], + "action": "create", + } + + self.request = RequestFactory().post( + reverse("conventions:convention_mix_init"), data=data + ) + self.request.user = User.objects.get(username="raph") + get_response = mock.MagicMock() + middleware = SessionMiddleware(get_response) + middleware.process_request(self.request) + self.request.session.save() + self.request.session["currently"] = GroupProfile.BAILLEUR + response = ConventionMix.as_view()(self.request) + + self.assertEqual(response.status_code, 302) + expected_url = reverse( + "programmes:operation_conventions", + args=[self.convention_plai.programme.numero_operation], + ) + self.assertEqual(response.url, expected_url) + + self.convention_plai.refresh_from_db() + + self.assertEqual(self.convention_plai.lots.count(), 2) + + @mock.patch("conventions.views.conventions_mix.switch_is_active") + @mock.patch("conventions.models.convention.switch_is_active") + def test_degroup_action_degroups_and_redirects( + self, mock_switch_view, mock_switch_model + ): + mock_switch_view.return_value = True + mock_switch_model.return_value = True + self.assertEqual(self.mixed_convention.lots.count(), 2) + + data = { + "uuids": [str(self.mixed_convention.uuid)], + "action": "dispatch", + } + + self.request = RequestFactory().post( + reverse("conventions:convention_mix_init"), data=data + ) + self.request.user = User.objects.get(username="raph") + get_response = mock.MagicMock() + middleware = SessionMiddleware(get_response) + middleware.process_request(self.request) + self.request.session.save() + self.request.session["currently"] = GroupProfile.BAILLEUR + response = ConventionMix.as_view()(self.request) + + self.assertEqual(response.status_code, 302) + expected_url = reverse( + "programmes:operation_conventions", + args=[self.convention_pls_programme_2.programme.numero_operation], + ) + self.assertEqual(response.url, expected_url) + self.assertEqual(self.mixed_convention.lots.count(), 0) + + degrouped_conventions = Convention.objects.filter( + programme=self.programme_2, + ) + self.assertEqual(degrouped_conventions.count(), 2) + + for convention in degrouped_conventions: + self.assertEqual(convention.lots.all().count(), 1) + + self.assertEqual( + [convention.lot.financement for convention in degrouped_conventions], + [Financement.PLS.label, Financement.PLAI.label], + ) + + @mock.patch("conventions.views.conventions_mix.switch_is_active") + @mock.patch("conventions.models.convention.switch_is_active") + def test_create_action_groups_with_different_type_habitat( + self, mock_switch_model, mock_switch_view + ): + mock_switch_view.return_value = True + mock_switch_model.return_value = True + self.assertEqual(self.convention_plai_habitat_mixte.lots.count(), 1) + self.assertEqual(self.convention_plus_habitat_collectif.lots.count(), 1) + + data = { + "uuids": [ + str(self.convention_plai_habitat_mixte.uuid), + str(self.convention_plus_habitat_collectif.uuid), + ], + "action": "create", + } + + self.request = RequestFactory().post( + reverse("conventions:convention_mix_init"), data=data + ) + self.request.user = User.objects.get(username="raph") + get_response = mock.MagicMock() + middleware = SessionMiddleware(get_response) + middleware.process_request(self.request) + self.request.session.save() + self.request.session["currently"] = GroupProfile.BAILLEUR + with pytest.raises(ConventionGroupingError) as exc_info: + ConventionMix.as_view()(self.request) + + self.assertEqual( + str(exc_info.value), "Les conventions doivent avoir le même statut" + ) + + self.convention_plai_habitat_mixte.statut = ConventionStatut.PROJET.label + self.convention_plai_habitat_mixte.save(update_fields=["statut"]) + self.convention_plus_habitat_collectif.statut = ConventionStatut.PROJET.label + self.convention_plus_habitat_collectif.save(update_fields=["statut"]) + + with pytest.raises(ConventionGroupingError) as exc_info: + ConventionMix.as_view()(self.request) + + self.assertEqual( + str(exc_info.value), + "Tous les lots des conventions doivent avoir le même type d'habitat", + ) + + data = { + "uuids": [ + str(self.convention_plai.uuid), + str(self.convention_pls_programme_2.uuid), + ], + "action": "create", + } + + self.request = RequestFactory().post( + reverse("conventions:convention_mix_init"), data=data + ) + + with pytest.raises(ConventionGroupingError) as exc_info: + ConventionMix.as_view()(self.request) + + self.assertEqual( + str(exc_info.value), "Les conventions doivent appartenir au même programme" + ) + + data = { + "uuids": [], + "action": "create", + } + + self.request = RequestFactory().post( + reverse("conventions:convention_mix_init"), data=data + ) + + with pytest.raises(ConventionGroupingError) as exc_info: + ConventionMix.as_view()(self.request) + + self.assertEqual( + str(exc_info.value), + "Nous ne pouvons pas créer une convention mixte, une liste de conventions doit être fournie", + ) diff --git a/conventions/tests/views/test_financement_view.py b/conventions/tests/views/test_financement_view.py index e72af06ca..a06b2dfef 100644 --- a/conventions/tests/views/test_financement_view.py +++ b/conventions/tests/views/test_financement_view.py @@ -33,6 +33,7 @@ def setUp(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-numero": "A", + "form-0-financement": "PLUS", "form-0-date_octroi": "2020-01-01", "form-0-duree": "50", "form-0-montant": "1000000.00", @@ -40,6 +41,7 @@ def setUp(self): "form-0-autre": "", "form-1-uuid": "", "form-1-numero": "A", + "form-1-financement": "PLUS", "form-1-date_octroi": "2020-01-01", "form-1-duree": "", "form-1-montant": "200000.00", @@ -53,6 +55,7 @@ def setUp(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-numero": "A", + "form-0-financement": "PLUS", "form-0-date_octroi": "2020-01-01", "form-0-duree": "50", "form-0-montant": "1000000.00", @@ -60,6 +63,7 @@ def setUp(self): "form-0-autre": "", "form-1-uuid": "", "form-1-numero": "A", + "form-1-financement": "PLUS", "form-1-date_octroi": "2020-01-01", "form-1-duree": "20", "form-1-montant": "200000.00", diff --git a/conventions/tests/views/test_logements_view.py b/conventions/tests/views/test_logements_view.py index bb88bce5e..fb7e76bd0 100644 --- a/conventions/tests/views/test_logements_view.py +++ b/conventions/tests/views/test_logements_view.py @@ -39,6 +39,7 @@ def setUp(self): "uuid": str(self.convention_75.lot.uuid), **logement_success_payload, } + self.success_payload["lots-0-uuid"] = str(self.convention_75.lot.uuid) self.msg_prefix = "[ConventionLogementsViewTests] " def _test_data_integrity(self): @@ -62,6 +63,7 @@ def setUp(self): user = User.objects.get(username="fix") convention = Convention.objects.get(numero="0001") self.convention_75 = convention.clone(user, convention_origin=convention) + self.success_payload["lots-0-uuid"] = str(self.convention_75.lot.uuid) self.target_path = reverse( "conventions:avenant_logements", args=[self.convention_75.uuid] ) diff --git a/conventions/tests/views/test_type_stationnement_view.py b/conventions/tests/views/test_type_stationnement_view.py index 370f4be83..afe7d156a 100644 --- a/conventions/tests/views/test_type_stationnement_view.py +++ b/conventions/tests/views/test_type_stationnement_view.py @@ -30,10 +30,12 @@ def setUp(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-typologie": "GARAGE_AERIEN", + "form-0-financement": "PLUS", "form-0-nb_stationnements": 30, "form-0-loyer": "", "form-1-uuid": "", "form-1-typologie": "GARAGE_ENTERRE", + "form-1-financement": "PLUS", "form-1-nb_stationnements": "", "form-1-loyer": 100.00, } @@ -42,10 +44,12 @@ def setUp(self): "form-INITIAL_FORMS": 2, "form-0-uuid": "", "form-0-typologie": "GARAGE_AERIEN", + "form-0-financement": "PLUS", "form-0-nb_stationnements": 30, "form-0-loyer": 12, "form-1-uuid": "", "form-1-typologie": "GARAGE_ENTERRE", + "form-1-financement": "PLUS", "form-1-nb_stationnements": 5, "form-1-loyer": 10.00, } diff --git a/conventions/urls.py b/conventions/urls.py index 830a01160..762363de3 100644 --- a/conventions/urls.py +++ b/conventions/urls.py @@ -95,6 +95,11 @@ views.NewConventionAnruView.as_view(), name="new_convention_anru", ), + path( + "convention/mix/init", + views.ConventionMix.as_view(), + name="convention_mix_init", + ), path( "bailleur/", views.ConventionBailleurView.as_view(), diff --git a/conventions/views/__init__.py b/conventions/views/__init__.py index a1aefec3f..ab06ec854 100644 --- a/conventions/views/__init__.py +++ b/conventions/views/__init__.py @@ -20,3 +20,4 @@ from conventions.views.convention_form_type_stationnement import * from conventions.views.convention_form_variante import * from conventions.views.conventions import * +from conventions.views.conventions_mix import * diff --git a/conventions/views/convention_form.py b/conventions/views/convention_form.py index 6b19b14d2..afa12c361 100644 --- a/conventions/views/convention_form.py +++ b/conventions/views/convention_form.py @@ -426,9 +426,18 @@ def get(self, request, **kwargs): self.target_template, { **base_convention_response_error(request, service.convention), - **({"form": service.form} if service.form else {}), + **( + {"form": service.form} + if service.form and not service.formset_convention_mixte + else {} + ), **({"extra_forms": service.extra_forms} if service.extra_forms else {}), **({"formset": service.formset} if service.formset else {}), + **( + {"formset_convention_mixte": service.formset_convention_mixte} + if service.formset_convention_mixte + else {} + ), **( {"formset_sans_loyer": service.formset_sans_loyer} if service.formset_sans_loyer @@ -487,7 +496,16 @@ def post(self, request, convention_uuid): self.target_template, { **base_convention_response_error(request, self.service.convention), - **({"form": self.service.form} if self.service.form else {}), + **( + {"form": self.service.form} + if self.service.form and not self.service.formset_convention_mixte + else {} + ), + **( + {"formset_convention_mixte": self.service.formset_convention_mixte} + if self.service.formset_convention_mixte + else {} + ), **({"upform": getattr(self.service, "upform", {})}), **( {"extra_forms": self.service.extra_forms} diff --git a/conventions/views/convention_form_logements.py b/conventions/views/convention_form_logements.py index 868a367d1..5007befae 100644 --- a/conventions/views/convention_form_logements.py +++ b/conventions/views/convention_form_logements.py @@ -1,7 +1,9 @@ from django.shortcuts import get_object_or_404 from conventions.models import Convention -from conventions.services.logements import ConventionLogementsService +from conventions.services.logements import ( + ConventionLogementsService, +) from conventions.views.convention_form import ( ConventionView, avenant_annexes_step, diff --git a/conventions/views/conventions.py b/conventions/views/conventions.py index 05a792874..7a81eb091 100644 --- a/conventions/views/conventions.py +++ b/conventions/views/conventions.py @@ -155,6 +155,11 @@ def _get_non_empty_query_param(self, query_param: str, default=None) -> str | No return default + def _get_multi_value_query_param(self, query_param: str) -> list[str] | None: + """Récupère les valeurs multiples d'un paramètre GET""" + values = self.request.GET.getlist(query_param) + return values if values else None + def setup(self, *args, **kwargs) -> None: super().setup(*args, **kwargs) @@ -162,6 +167,12 @@ def setup(self, *args, **kwargs) -> None: arg: self._get_non_empty_query_param(query_param) for arg, query_param in self.get_search_filters_mapping() } + + # Traitement spécial pour les statuts (paramètre multiple) + if "statuts" in search_filters: + statuts = self._get_multi_value_query_param("cstatut") + search_filters["statuts"] = statuts + self.service = ConventionSearchService( user=self.request.user, search_filters=search_filters ) @@ -179,7 +190,7 @@ def get_search_filters_mapping(self) -> list[tuple[str, str]]: ("search_lieu", "search_lieu"), ("search_numero", "search_numero"), ("search_operation_nom", "search_operation_nom"), - ("statuts", "cstatut"), + ("statuts", "cstatut"), # 'statuts' sera géré spécialement dans setup() ] @@ -227,6 +238,7 @@ def get_context(self, request: AuthenticatedHttpRequest) -> dict[str, Any]: "bailleur_query": self.bailleurs_queryset, "debug_search_scoring": settings.DEBUG_SEARCH_SCORING, "convention_export_max_rows": CONVENTION_EXPORT_MAX_ROWS, + "selected_statuts": request.GET.getlist("cstatut"), } | { k: self._get_non_empty_query_param(k, default="") for k in ( diff --git a/conventions/views/conventions_mix.py b/conventions/views/conventions_mix.py new file mode 100644 index 000000000..448c1a618 --- /dev/null +++ b/conventions/views/conventions_mix.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.views import View +from waffle import switch_is_active + +from conventions.forms.convention_mixed_form_initialisation import UUIDListForm +from conventions.models.convention import Convention + + +class ConventionMix(View): + """ + View to handle grouping or degrouping of conventions + based on UUIDs and the requested action ('create' or 'dispatch'). + """ + + def post(self, request): + if not switch_is_active(settings.SWITCH_CONVENTION_MIXTE_ON): + return HttpResponseRedirect(reverse("conventions:search")) + form = UUIDListForm(request.POST) + if form.is_valid(): + uuids = form.cleaned_data.get("uuids", []) + action = form.cleaned_data.get("action") + + if action == "create": + _, _, convention_mixte = Convention.objects.group_conventions(uuids) + return HttpResponseRedirect( + reverse( + "programmes:operation_conventions", + args=[convention_mixte.programme.numero_operation], + ) + ) + if action == "dispatch": + conventions = Convention.objects.degroup_conventions(uuids) + first_convention = conventions.first() + if first_convention: + return HttpResponseRedirect( + reverse( + "programmes:operation_conventions", + args=[first_convention.programme.numero_operation], + ) + ) + + # If form is invalid, redirect to search + return HttpResponseRedirect(reverse("conventions:search")) diff --git a/core/settings.py b/core/settings.py index 8f5ddef68..ec0fc098c 100644 --- a/core/settings.py +++ b/core/settings.py @@ -584,6 +584,7 @@ def get_env_variable(name, cast=str, default=""): # Waffle SWITCH_SIAP_ALERTS_ON = "siap_alerte_on" SWITCH_TRANSACTIONAL_EMAILS_OFF = "transactional_emails_off" +SWITCH_CONVENTION_MIXTE_ON = "convention_mixte_on" WAFFLE_ENABLE_ADMIN_PAGES = False MIDDLEWARE += ["waffle.middleware.WaffleMiddleware"] diff --git a/docs/NPM-SECURITY.md b/docs/NPM-SECURITY.md new file mode 100644 index 000000000..d53b4ffcb --- /dev/null +++ b/docs/NPM-SECURITY.md @@ -0,0 +1,57 @@ +# Sécuriser nos projets npm – Guide pratique pour l'équipe + +## Préparer votre environnement + +Avant toute installation de dépendances : + +* **Ne laissez pas npm exécuter des scripts automatiquement** : certains packages peuvent contenir des scripts malveillants qui se lancent à l'installation. + Dans votre terminal, faites : + + ```bash + npm config set ignore-scripts true + ``` +* **Optionnel mais conseillé** : utilisez `npq`, un outil qui vérifie les packages avant installation : + + ```bash + npm install -g npq + npq install + ``` + +> Astuce : si vous avez besoin d'installer un package avec ses scripts, vous pouvez le faire explicitement avec `--ignore-scripts false`. + +--- + +## Installer les dépendances en toute sécurité + +* **Toujours utiliser le lockfile** pour être sûr que tout le monde installe exactement les mêmes versions : + + ```bash + npm ci + ``` +* Pour ceux qui utilisent Yarn ou pnpm : + + ```bash + yarn install --immutable + pnpm install --frozen-lockfile + ``` + +> Cela évite les surprises et protège contre les packages malveillants qui pourraient apparaître dans des versions plus récentes. + +--- + +## Vérifier vos dépendances et vos secrets + +* Regardez bien les nouveaux packages avant de les installer. +* Ne mettez jamais de mots de passe ou clés secrètes directement dans le code ou dans `.env`. +* Si possible, utilisez un **environnement isolé** comme un conteneur Docker ou un dev container pour protéger votre machine. + +--- + +## En résumé + +* Désactiver les scripts npm par défaut +* Installer via lockfile (`npm ci`) +* Vérifier les packages et sécuriser les secrets +* Travailler dans un environnement isolé si possible + +> En suivant ces étapes simples, on protège notre projet et nos machines contre des attaques via npm. \ No newline at end of file diff --git a/documents/Avenant-template.docx b/documents/Avenant-template.docx index d219f8ee0..7168dc806 100644 Binary files a/documents/Avenant-template.docx and b/documents/Avenant-template.docx differ diff --git a/documents/FicheCAF-template.docx b/documents/FicheCAF-template.docx index 78e7ca008..bf0c53722 100644 Binary files a/documents/FicheCAF-template.docx and b/documents/FicheCAF-template.docx differ diff --git a/documents/Foyer-template.docx b/documents/Foyer-template.docx index 295369911..3b27bc890 100644 Binary files a/documents/Foyer-template.docx and b/documents/Foyer-template.docx differ diff --git a/documents/FoyerResidence-Avenant-template.docx b/documents/FoyerResidence-Avenant-template.docx index 43a1eab35..c792e3526 100644 Binary files a/documents/FoyerResidence-Avenant-template.docx and b/documents/FoyerResidence-Avenant-template.docx differ diff --git a/documents/HLM-template.docx b/documents/HLM-template.docx index 5a0285b69..eb9b40862 100644 Binary files a/documents/HLM-template.docx and b/documents/HLM-template.docx differ diff --git a/documents/Residence-template.docx b/documents/Residence-template.docx index 1a00b69a7..f528a43be 100644 Binary files a/documents/Residence-template.docx and b/documents/Residence-template.docx differ diff --git a/documents/SEM-template.docx b/documents/SEM-template.docx index ff82c60c9..f92af8e14 100644 Binary files a/documents/SEM-template.docx and b/documents/SEM-template.docx differ diff --git a/documents/Type1-template.docx b/documents/Type1-template.docx index 49a45a21a..a77732932 100644 Binary files a/documents/Type1-template.docx and b/documents/Type1-template.docx differ diff --git a/documents/Type2-template.docx b/documents/Type2-template.docx index 6c3b43b90..e8a26e1bb 100644 Binary files a/documents/Type2-template.docx and b/documents/Type2-template.docx differ diff --git a/package-lock.json b/package-lock.json index 10390a9e4..27f55fc90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,7 @@ "": { "dependencies": { "@gouvfr/dsfr": "^1.14.2", - "@hotwired/turbo": "^8.0.20", - "virtual-select-plugin": "^1.1.0" + "@hotwired/turbo": "^8.0.20" } }, "node_modules/@gouvfr/dsfr": { @@ -27,19 +26,6 @@ "engines": { "node": ">= 18" } - }, - "node_modules/tooltip-plugin": { - "version": "1.0.16", - "license": "ISC" - }, - "node_modules/virtual-select-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/virtual-select-plugin/-/virtual-select-plugin-1.1.0.tgz", - "integrity": "sha512-JtqiH7FmfIt5VGGglhA9ZARMEX1Rj7LEqwVrdr+zUawJV/0fALZcX2/0fSfTWYob/n3lVFrdswHbEtc/U9XvpQ==", - "license": "ISC", - "dependencies": { - "tooltip-plugin": "^1.0.16" - } } } } diff --git a/package.json b/package.json index d517c4c9e..a5d08575e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ }, "dependencies": { "@gouvfr/dsfr": "^1.14.2", - "@hotwired/turbo": "^8.0.20", - "virtual-select-plugin": "^1.1.0" + "@hotwired/turbo": "^8.0.20" } } diff --git a/programmes/migrations/0129_remove_lot_unique_convention.py b/programmes/migrations/0129_remove_lot_unique_convention.py new file mode 100644 index 000000000..270fc362d --- /dev/null +++ b/programmes/migrations/0129_remove_lot_unique_convention.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-10-20 13:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("programmes", "0128_historicalprogramme_anah_programme_anah"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="lot", + name="unique_convention", + ), + ] diff --git a/programmes/migrations/0130_merge_20251103_1120.py b/programmes/migrations/0130_merge_20251103_1120.py new file mode 100644 index 000000000..e6d5986f4 --- /dev/null +++ b/programmes/migrations/0130_merge_20251103_1120.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.4 on 2025-11-03 10:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("programmes", "0129_remove_lot_unique_convention"), + ("programmes", "0129_update_programme_edd_values"), + ] + + operations = [] diff --git a/programmes/models/models.py b/programmes/models/models.py index 3b2660bd3..3b5aaeb02 100644 --- a/programmes/models/models.py +++ b/programmes/models/models.py @@ -547,13 +547,6 @@ class Meta: fields=["convention", "financement"], name="unique_convention_financement", ), - # TODO : quand on intégrera les convention mixte ou les conventions seconde - # vie il faudra supprimer cette contrainte et gérer plusieurs lots par - # convention - models.UniqueConstraint( - fields=["convention"], - name="unique_convention", - ), ] # Needed for admin @@ -667,9 +660,27 @@ def mixity_option(self): """ return self.financement in [Financement.PLUS, Financement.PLUS_CD] + @property + def is_pls_financement_type(self) -> bool: + return self.financement in [ + Financement.PLS, + Financement.PLS_DOM, + Financement.PALULOS, + Financement.PALU_AV_21, + Financement.PALUCOM, + Financement.PALU_COM, + Financement.PALU_RE, + ] + def __str__(self): return f"{self.convention.programme.nom} - {self.financement}" + def _get_loyer_par_metre_carre(self): + logement = self.logements.first() + if logement: + return self.logements.first().loyer_par_metre_carre + return 0 + class Logement(models.Model): id = models.AutoField(primary_key=True) @@ -737,6 +748,7 @@ class Logement(models.Model): import_mapping = { "Désignation des logements": "designation", "Type": "typologie", + "Financement": "financement", "Surface habitable\n(article": "surface_habitable", "Surface des annexes\nRéelle": "surface_annexes", "Surface des annexes\nRetenue dans la SU": "surface_annexes_retenue", @@ -745,7 +757,7 @@ class Logement(models.Model): ), "Loyer maximum en € par m² de surface utile": "loyer_par_metre_carre", "Coefficient propre au logement": "coeficient", - "Loyer maximum du logement en €\n(col 4 * col 5 * col 6)": "loyer", + "Loyer maximum du logement en €\n(col 5 * col 6 * col 7)": "loyer", } foyer_residence_import_mapping = { @@ -858,6 +870,7 @@ class Meta: import_mapping = { "Désignation des logements": "designation", "Type": "typologie", + "Financement": "financement", "Surface habitable\n(article": "surface_habitable", "Surface des annexes\nRéelle": "surface_annexes", "Surface des annexes\nRetenue dans la SU": "surface_annexes_retenue", @@ -881,6 +894,7 @@ class Meta: import_mapping = { "Désignation des logements": "designation", "Type": "typologie", + "Financement": "financement", "Surface habitable\n(article": "surface_habitable", "Surface corrigée": "surface_corrigee", "Loyer maximum en € par m² de surface corrigée": "loyer_par_metre_carre", @@ -905,6 +919,7 @@ class Meta: import_mapping = { "Désignation des logements": "designation", "Type": "typologie", + "Financement": "financement", "Surface habitable\n(article": "surface_habitable", "Surface corrigée": "surface_corrigee", } @@ -938,6 +953,7 @@ class Annexe(models.Model): import_mapping = { "Type d'annexe": "typologie", + "Financement": "financement", "Désignation des logements": "logement_designation", "Typologie des logements": "logement_typologie", "Surface de l'annexe": "surface_hors_surface_retenue", @@ -1045,6 +1061,7 @@ class TypeStationnement(models.Model): import_mapping = { "Type de stationnement": "typologie", + "Financement": "financement", "Nombre de stationnements": "nb_stationnements", "Loyer maximum en €": "loyer", } diff --git a/programmes/services.py b/programmes/services.py index 037003ab5..afd5d1445 100644 --- a/programmes/services.py +++ b/programmes/services.py @@ -105,10 +105,10 @@ def collect_conventions_by_financements(self): # group by financement conventions_by_financements = {} for convention in conventions: - financement = convention.lot.financement - if financement not in conventions_by_financements: - conventions_by_financements[financement] = [] - conventions_by_financements[financement].append(convention) + for financement in convention.lots.all(): + if financement not in conventions_by_financements: + conventions_by_financements[financement] = [] + conventions_by_financements[financement].append(convention) else: filtered_op_aides = get_filtered_aides(self.operation) conventions_by_financements = {} diff --git a/programmes/views.py b/programmes/views.py index 5890679ec..02d431fe3 100644 --- a/programmes/views.py +++ b/programmes/views.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect @@ -7,6 +8,7 @@ from django.urls import reverse from django.views.decorators.http import require_safe from django.views.generic import TemplateView +from waffle import switch_is_active from conventions.services.search import ( OperationConventionSearchService, @@ -92,6 +94,9 @@ def operation_conventions(request, numero_operation): service = OperationConventionSearchService(numero_operation) paginator = service.paginate() context = operation_service.get_context_list_conventions(paginator=paginator) + context["switch_convention_mixte_on"] = switch_is_active( + settings.SWITCH_CONVENTION_MIXTE_ON + ) return render( request, "operations/conventions.html", diff --git a/static/choices/choices.min.css b/static/choices/choices.min.css new file mode 100755 index 000000000..cf79ea915 --- /dev/null +++ b/static/choices/choices.min.css @@ -0,0 +1 @@ +.choices{position:relative;overflow:hidden;margin-bottom:24px;font-size:16px}.choices:focus{outline:0}.choices:last-child{margin-bottom:0}.choices.is-open{overflow:visible}.choices.is-disabled .choices__inner,.choices.is-disabled .choices__input{background-color:#eaeaea;cursor:not-allowed;-webkit-user-select:none;user-select:none}.choices.is-disabled .choices__item{cursor:not-allowed}.choices [hidden]{display:none!important}.choices[data-type*=select-one]{cursor:pointer}.choices[data-type*=select-one] .choices__inner{padding-bottom:7.5px}.choices[data-type*=select-one] .choices__input{display:block;width:100%;padding:10px;border-bottom:1px solid #ddd;background-color:#fff;margin:0}.choices[data-type*=select-one] .choices__button{background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);padding:0;background-size:8px;position:absolute;top:50%;right:0;margin-top:-10px;margin-right:25px;height:20px;width:20px;border-radius:10em;opacity:.25}.choices[data-type*=select-one] .choices__button:focus,.choices[data-type*=select-one] .choices__button:hover{opacity:1}.choices[data-type*=select-one] .choices__button:focus{box-shadow:0 0 0 2px #005f75}.choices[data-type*=select-one] .choices__item[data-placeholder] .choices__button{display:none}.choices[data-type*=select-one]::after{content:"";height:0;width:0;border-style:solid;border-color:#333 transparent transparent;border-width:5px;position:absolute;right:11.5px;top:50%;margin-top:-2.5px;pointer-events:none}.choices[data-type*=select-one].is-open::after{border-color:transparent transparent #333;margin-top:-7.5px}.choices[data-type*=select-one][dir=rtl]::after{left:11.5px;right:auto}.choices[data-type*=select-one][dir=rtl] .choices__button{right:auto;left:0;margin-left:25px;margin-right:0}.choices[data-type*=select-multiple] .choices__inner,.choices[data-type*=text] .choices__inner{cursor:text}.choices[data-type*=select-multiple] .choices__button,.choices[data-type*=text] .choices__button{position:relative;display:inline-block;margin:0-4px 0 8px;padding-left:16px;border-left:1px solid #003642;background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);background-size:8px;width:8px;line-height:1;opacity:.75;border-radius:0}.choices[data-type*=select-multiple] .choices__button:focus,.choices[data-type*=select-multiple] .choices__button:hover,.choices[data-type*=text] .choices__button:focus,.choices[data-type*=text] .choices__button:hover{opacity:1}.choices__inner{display:inline-block;vertical-align:top;width:100%;background-color:#f9f9f9;padding:7.5px 7.5px 3.75px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;min-height:44px;overflow:hidden}.is-focused .choices__inner,.is-open .choices__inner{border-color:#b7b7b7}.is-open .choices__inner{border-radius:2.5px 2.5px 0 0}.is-flipped.is-open .choices__inner{border-radius:0 0 2.5px 2.5px}.choices__list{margin:0;padding-left:0;list-style:none}.choices__list--single{display:inline-block;padding:4px 16px 4px 4px;width:100%}[dir=rtl] .choices__list--single{padding-right:4px;padding-left:16px}.choices__list--single .choices__item{width:100%}.choices__list--multiple{display:inline}.choices__list--multiple .choices__item{display:inline-block;vertical-align:middle;border-radius:20px;padding:4px 10px;font-size:12px;font-weight:500;margin-right:3.75px;margin-bottom:3.75px;background-color:#005f75;border:1px solid #004a5c;color:#fff;word-break:break-all;box-sizing:border-box}.choices__list--multiple .choices__item[data-deletable]{padding-right:5px}[dir=rtl] .choices__list--multiple .choices__item{margin-right:0;margin-left:3.75px}.choices__list--multiple .choices__item.is-highlighted{background-color:#004a5c;border:1px solid #003642}.is-disabled .choices__list--multiple .choices__item{background-color:#aaa;border:1px solid #919191}.choices__list--dropdown,.choices__list[aria-expanded]{display:none;z-index:1;position:absolute;width:100%;background-color:#fff;border:1px solid #ddd;top:100%;margin-top:-1px;border-bottom-left-radius:2.5px;border-bottom-right-radius:2.5px;overflow:hidden;word-break:break-all}.is-active.choices__list--dropdown,.is-active.choices__list[aria-expanded]{display:block}.is-open .choices__list--dropdown,.is-open .choices__list[aria-expanded]{border-color:#b7b7b7}.is-flipped .choices__list--dropdown,.is-flipped .choices__list[aria-expanded]{top:auto;bottom:100%;margin-top:0;margin-bottom:-1px;border-radius:.25rem .25rem 0 0}.choices__list--dropdown .choices__list,.choices__list[aria-expanded] .choices__list{position:relative;max-height:300px;overflow:auto;-webkit-overflow-scrolling:touch;will-change:scroll-position}.choices__list--dropdown .choices__item,.choices__list[aria-expanded] .choices__item{position:relative;padding:10px;font-size:14px}[dir=rtl] .choices__list--dropdown .choices__item,[dir=rtl] .choices__list[aria-expanded] .choices__item{text-align:right}@media (min-width:640px){.choices__list--dropdown .choices__item--selectable[data-select-text],.choices__list[aria-expanded] .choices__item--selectable[data-select-text]{padding-right:100px}.choices__list--dropdown .choices__item--selectable[data-select-text]::after,.choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after{content:attr(data-select-text);font-size:12px;opacity:0;position:absolute;right:10px;top:50%;transform:translateY(-50%)}[dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text],[dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text]{text-align:right;padding-left:100px;padding-right:10px}[dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text]::after,[dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after{right:auto;left:10px}}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:#f2f2f2}.choices__list--dropdown .choices__item--selectable.is-highlighted::after,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted::after{opacity:.5}.choices__item{cursor:default}.choices__item--selectable{cursor:pointer}.choices__item--disabled{cursor:not-allowed;-webkit-user-select:none;user-select:none;opacity:.5}.choices__heading{font-weight:600;font-size:12px;padding:10px;border-bottom:1px solid #f7f7f7;color:gray}.choices__button{text-indent:-9999px;appearance:none;border:0;background-color:transparent;background-repeat:no-repeat;background-position:center;cursor:pointer}.choices__button:focus,.choices__input:focus{outline:0}.choices__input{display:inline-block;vertical-align:baseline;background-color:#f9f9f9;font-size:14px;margin-bottom:5px;border:0;border-radius:0;max-width:100%;padding:4px 0 4px 2px}.choices__input::-webkit-search-cancel-button,.choices__input::-webkit-search-decoration,.choices__input::-webkit-search-results-button,.choices__input::-webkit-search-results-decoration{display:none}.choices__input::-ms-clear,.choices__input::-ms-reveal{display:none;width:0;height:0}[dir=rtl] .choices__input{padding-right:2px;padding-left:0}.choices__placeholder{opacity:.5} \ No newline at end of file diff --git a/static/choices/choices.min.js b/static/choices/choices.min.js new file mode 100755 index 000000000..cc5baa708 --- /dev/null +++ b/static/choices/choices.min.js @@ -0,0 +1,2 @@ +/*! choices.js v11.1.0 | © 2025 Josh Johnson | https://github.com/jshjohnson/Choices#readme */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Choices=t()}(this,(function(){"use strict";var e=function(t,i){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var i in t)Object.prototype.hasOwnProperty.call(t,i)&&(e[i]=t[i])},e(t,i)};function t(t,i){if("function"!=typeof i&&null!==i)throw new TypeError("Class extends value "+String(i)+" is not a constructor or null");function n(){this.constructor=t}e(t,i),t.prototype=null===i?Object.create(i):(n.prototype=i.prototype,new n)}var i=function(){return i=Object.assign||function(e){for(var t,i=1,n=arguments.length;i/g,">").replace(/=0&&!window.matchMedia("(min-height: ".concat(e+1,"px)")).matches:"top"===this.position&&(i=!0),i},e.prototype.setActiveDescendant=function(e){this.element.setAttribute("aria-activedescendant",e)},e.prototype.removeActiveDescendant=function(){this.element.removeAttribute("aria-activedescendant")},e.prototype.open=function(e,t){P(this.element,this.classNames.openState),this.element.setAttribute("aria-expanded","true"),this.isOpen=!0,this.shouldFlip(e,t)&&(P(this.element,this.classNames.flippedState),this.isFlipped=!0)},e.prototype.close=function(){j(this.element,this.classNames.openState),this.element.setAttribute("aria-expanded","false"),this.removeActiveDescendant(),this.isOpen=!1,this.isFlipped&&(j(this.element,this.classNames.flippedState),this.isFlipped=!1)},e.prototype.addFocusState=function(){P(this.element,this.classNames.focusState)},e.prototype.removeFocusState=function(){j(this.element,this.classNames.focusState)},e.prototype.enable=function(){j(this.element,this.classNames.disabledState),this.element.removeAttribute("aria-disabled"),this.type===_&&this.element.setAttribute("tabindex","0"),this.isDisabled=!1},e.prototype.disable=function(){P(this.element,this.classNames.disabledState),this.element.setAttribute("aria-disabled","true"),this.type===_&&this.element.setAttribute("tabindex","-1"),this.isDisabled=!0},e.prototype.wrap=function(e){var t=this.element,i=e.parentNode;i&&(e.nextSibling?i.insertBefore(t,e.nextSibling):i.appendChild(t)),t.appendChild(e)},e.prototype.unwrap=function(e){var t=this.element,i=t.parentNode;i&&(i.insertBefore(e,t),i.removeChild(t))},e.prototype.addLoadingState=function(){P(this.element,this.classNames.loadingState),this.element.setAttribute("aria-busy","true"),this.isLoading=!0},e.prototype.removeLoadingState=function(){j(this.element,this.classNames.loadingState),this.element.removeAttribute("aria-busy"),this.isLoading=!1},e}(),B=function(){function e(e){var t=e.element,i=e.type,n=e.classNames,s=e.preventPaste;this.element=t,this.type=i,this.classNames=n,this.preventPaste=s,this.isFocussed=this.element.isEqualNode(document.activeElement),this.isDisabled=t.disabled,this._onPaste=this._onPaste.bind(this),this._onInput=this._onInput.bind(this),this._onFocus=this._onFocus.bind(this),this._onBlur=this._onBlur.bind(this)}return Object.defineProperty(e.prototype,"placeholder",{set:function(e){this.element.placeholder=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"value",{get:function(){return this.element.value},set:function(e){this.element.value=e},enumerable:!1,configurable:!0}),e.prototype.addEventListeners=function(){var e=this.element;e.addEventListener("paste",this._onPaste),e.addEventListener("input",this._onInput,{passive:!0}),e.addEventListener("focus",this._onFocus,{passive:!0}),e.addEventListener("blur",this._onBlur,{passive:!0})},e.prototype.removeEventListeners=function(){var e=this.element;e.removeEventListener("input",this._onInput),e.removeEventListener("paste",this._onPaste),e.removeEventListener("focus",this._onFocus),e.removeEventListener("blur",this._onBlur)},e.prototype.enable=function(){this.element.removeAttribute("disabled"),this.isDisabled=!1},e.prototype.disable=function(){this.element.setAttribute("disabled",""),this.isDisabled=!0},e.prototype.focus=function(){this.isFocussed||this.element.focus()},e.prototype.blur=function(){this.isFocussed&&this.element.blur()},e.prototype.clear=function(e){return void 0===e&&(e=!0),this.element.value="",e&&this.setWidth(),this},e.prototype.setWidth=function(){var e=this.element;e.style.minWidth="".concat(e.placeholder.length+1,"ch"),e.style.width="".concat(e.value.length+1,"ch")},e.prototype.setActiveDescendant=function(e){this.element.setAttribute("aria-activedescendant",e)},e.prototype.removeActiveDescendant=function(){this.element.removeAttribute("aria-activedescendant")},e.prototype._onInput=function(){this.type!==_&&this.setWidth()},e.prototype._onPaste=function(e){this.preventPaste&&e.preventDefault()},e.prototype._onFocus=function(){this.isFocussed=!0},e.prototype._onBlur=function(){this.isFocussed=!1},e}(),H=function(){function e(e){this.element=e.element,this.scrollPos=this.element.scrollTop,this.height=this.element.offsetHeight}return e.prototype.prepend=function(e){var t=this.element.firstElementChild;t?this.element.insertBefore(e,t):this.element.append(e)},e.prototype.scrollToTop=function(){this.element.scrollTop=0},e.prototype.scrollToChildElement=function(e,t){var i=this;if(e){var n=t>0?this.element.scrollTop+(e.offsetTop+e.offsetHeight)-(this.element.scrollTop+this.element.offsetHeight):e.offsetTop;requestAnimationFrame((function(){i._animateScroll(n,t)}))}},e.prototype._scrollDown=function(e,t,i){var n=(i-e)/t;this.element.scrollTop=e+(n>1?n:1)},e.prototype._scrollUp=function(e,t,i){var n=(e-i)/t;this.element.scrollTop=e-(n>1?n:1)},e.prototype._animateScroll=function(e,t){var i=this,n=this.element.scrollTop,s=!1;t>0?(this._scrollDown(n,4,e),ne&&(s=!0)),s&&requestAnimationFrame((function(){i._animateScroll(e,t)}))},e}(),$=function(){function e(e){var t=e.classNames;this.element=e.element,this.classNames=t,this.isDisabled=!1}return Object.defineProperty(e.prototype,"isActive",{get:function(){return"active"===this.element.dataset.choice},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"dir",{get:function(){return this.element.dir},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"value",{get:function(){return this.element.value},set:function(e){this.element.setAttribute("value",e),this.element.value=e},enumerable:!1,configurable:!0}),e.prototype.conceal=function(){var e=this.element;P(e,this.classNames.input),e.hidden=!0,e.tabIndex=-1;var t=e.getAttribute("style");t&&e.setAttribute("data-choice-orig-style",t),e.setAttribute("data-choice","active")},e.prototype.reveal=function(){var e=this.element;j(e,this.classNames.input),e.hidden=!1,e.removeAttribute("tabindex");var t=e.getAttribute("data-choice-orig-style");t?(e.removeAttribute("data-choice-orig-style"),e.setAttribute("style",t)):e.removeAttribute("style"),e.removeAttribute("data-choice")},e.prototype.enable=function(){this.element.removeAttribute("disabled"),this.element.disabled=!1,this.isDisabled=!1},e.prototype.disable=function(){this.element.setAttribute("disabled",""),this.element.disabled=!0,this.isDisabled=!0},e.prototype.triggerEvent=function(e,t){var i;void 0===(i=t||{})&&(i=null),this.element.dispatchEvent(new CustomEvent(e,{detail:i,bubbles:!0,cancelable:!0}))},e}(),q=function(e){function i(){return null!==e&&e.apply(this,arguments)||this}return t(i,e),i}($),W=function(e,t){return void 0===t&&(t=!0),void 0===e?t:!!e},U=function(e){if("string"==typeof e&&(e=e.split(" ").filter((function(e){return e.length}))),Array.isArray(e)&&e.length)return e},G=function(e,t,i){if(void 0===i&&(i=!0),"string"==typeof e){var n=I(e);return G({value:e,label:i||n===e?e:{escaped:n,raw:e},selected:!0},!1)}var s=e;if("choices"in s){if(!t)throw new TypeError("optGroup is not allowed");var o=s,r=o.choices.map((function(e){return G(e,!1)}));return{id:0,label:L(o.label)||o.value,active:!!r.length,disabled:!!o.disabled,choices:r}}var c=s;return{id:0,group:null,score:0,rank:0,value:c.value,label:c.label||c.value,active:W(c.active),selected:W(c.selected,!1),disabled:W(c.disabled,!1),placeholder:W(c.placeholder,!1),highlighted:!1,labelClass:U(c.labelClass),labelDescription:c.labelDescription,customProperties:c.customProperties}},z=function(e){return"SELECT"===e.tagName},J=function(e){function i(t){var i=t.template,n=t.extractPlaceholder,s=e.call(this,{element:t.element,classNames:t.classNames})||this;return s.template=i,s.extractPlaceholder=n,s}return t(i,e),Object.defineProperty(i.prototype,"placeholderOption",{get:function(){return this.element.querySelector('option[value=""]')||this.element.querySelector("option[placeholder]")},enumerable:!1,configurable:!0}),i.prototype.addOptions=function(e){var t=this,i=document.createDocumentFragment();e.forEach((function(e){var n=e;if(!n.element){var s=t.template(n);i.appendChild(s),n.element=s}})),this.element.appendChild(i)},i.prototype.optionsAsChoices=function(){var e=this,t=[];return this.element.querySelectorAll(":scope > option, :scope > optgroup").forEach((function(i){!function(e){return"OPTION"===e.tagName}(i)?function(e){return"OPTGROUP"===e.tagName}(i)&&t.push(e._optgroupToChoice(i)):t.push(e._optionToChoice(i))})),t},i.prototype._optionToChoice=function(e){return!e.hasAttribute("value")&&e.hasAttribute("placeholder")&&(e.setAttribute("value",""),e.value=""),{id:0,group:null,score:0,rank:0,value:e.value,label:e.label,element:e,active:!0,selected:this.extractPlaceholder?e.selected:e.hasAttribute("selected"),disabled:e.disabled,highlighted:!1,placeholder:this.extractPlaceholder&&(!e.value||e.hasAttribute("placeholder")),labelClass:void 0!==e.dataset.labelClass?U(e.dataset.labelClass):void 0,labelDescription:void 0!==e.dataset.labelDescription?e.dataset.labelDescription:void 0,customProperties:R(e.dataset.customProperties)}},i.prototype._optgroupToChoice=function(e){var t=this,i=e.querySelectorAll("option"),n=Array.from(i).map((function(e){return t._optionToChoice(e)}));return{id:0,label:e.label||"",element:e,active:!!n.length,disabled:e.disabled,choices:n}},i}($),X={items:[],choices:[],silent:!1,renderChoiceLimit:-1,maxItemCount:-1,closeDropdownOnSelect:"auto",singleModeForMultiSelect:!1,addChoices:!1,addItems:!0,addItemFilter:function(e){return!!e&&""!==e},removeItems:!0,removeItemButton:!1,removeItemButtonAlignLeft:!1,editItems:!1,allowHTML:!1,allowHtmlUserInput:!1,duplicateItemsAllowed:!0,delimiter:",",paste:!0,searchEnabled:!0,searchChoices:!0,searchFloor:1,searchResultLimit:4,searchFields:["label","value"],position:"auto",resetScrollPosition:!0,shouldSort:!0,shouldSortItems:!1,sorter:function(e,t){var i=e.label,n=t.label,s=void 0===n?t.value:n;return L(void 0===i?e.value:i).localeCompare(L(s),[],{sensitivity:"base",ignorePunctuation:!0,numeric:!0})},shadowRoot:null,placeholder:!0,placeholderValue:null,searchPlaceholderValue:null,prependValue:null,appendValue:null,renderSelectedChoices:"auto",loadingText:"Loading...",noResultsText:"No results found",noChoicesText:"No choices to choose from",itemSelectText:"Press to select",uniqueItemText:"Only unique values can be added",customAddItemText:"Only values matching specific conditions can be added",addItemText:function(e){return'Press Enter to add "'.concat(e,'"')},removeItemIconText:function(){return"Remove item"},removeItemLabelText:function(e){return"Remove item: ".concat(e)},maxItemText:function(e){return"Only ".concat(e," values can be added")},valueComparer:function(e,t){return e===t},fuseOptions:{includeScore:!0},labelId:"",callbackOnInit:null,callbackOnCreateTemplates:null,classNames:{containerOuter:["choices"],containerInner:["choices__inner"],input:["choices__input"],inputCloned:["choices__input--cloned"],list:["choices__list"],listItems:["choices__list--multiple"],listSingle:["choices__list--single"],listDropdown:["choices__list--dropdown"],item:["choices__item"],itemSelectable:["choices__item--selectable"],itemDisabled:["choices__item--disabled"],itemChoice:["choices__item--choice"],description:["choices__description"],placeholder:["choices__placeholder"],group:["choices__group"],groupHeading:["choices__heading"],button:["choices__button"],activeState:["is-active"],focusState:["is-focused"],openState:["is-open"],disabledState:["is-disabled"],highlightedState:["is-highlighted"],selectedState:["is-selected"],flippedState:["is-flipped"],loadingState:["is-loading"],notice:["choices__notice"],addChoice:["choices__item--selectable","add-choice"],noResults:["has-no-results"],noChoices:["has-no-choices"]},appendGroupInSearch:!1},Q=function(e){var t=e.itemEl;t&&(t.remove(),e.itemEl=void 0)},Y={groups:function(e,t){var i=e,n=!0;switch(t.type){case l:i.push(t.group);break;case h:i=[];break;default:n=!1}return{state:i,update:n}},items:function(e,t,i){var n=e,s=!0;switch(t.type){case u:t.item.selected=!0,(o=t.item.element)&&(o.selected=!0,o.setAttribute("selected","")),n.push(t.item);break;case d:var o;if(t.item.selected=!1,o=t.item.element){o.selected=!1,o.removeAttribute("selected");var c=o.parentElement;c&&z(c)&&c.type===_&&(c.value="")}Q(t.item),n=n.filter((function(e){return e.id!==t.item.id}));break;case r:Q(t.choice),n=n.filter((function(e){return e.id!==t.choice.id}));break;case p:var a=t.highlighted,h=n.find((function(e){return e.id===t.item.id}));h&&h.highlighted!==a&&(h.highlighted=a,i&&function(e,t,i){var n=e.itemEl;n&&(j(n,i),P(n,t))}(h,a?i.classNames.highlightedState:i.classNames.selectedState,a?i.classNames.selectedState:i.classNames.highlightedState));break;default:s=!1}return{state:n,update:s}},choices:function(e,t,i){var n=e,s=!0;switch(t.type){case o:n.push(t.choice);break;case r:t.choice.choiceEl=void 0,t.choice.group&&(t.choice.group.choices=t.choice.group.choices.filter((function(e){return e.id!==t.choice.id}))),n=n.filter((function(e){return e.id!==t.choice.id}));break;case u:case d:t.item.choiceEl=void 0;break;case c:var l=[];t.results.forEach((function(e){l[e.item.id]=e})),n.forEach((function(e){var t=l[e.id];void 0!==t?(e.score=t.score,e.rank=t.rank,e.active=!0):(e.score=0,e.rank=0,e.active=!1),i&&i.appendGroupInSearch&&(e.choiceEl=void 0)}));break;case a:n.forEach((function(e){e.active=t.active,i&&i.appendGroupInSearch&&(e.choiceEl=void 0)}));break;case h:n=[];break;default:s=!1}return{state:n,update:s}}},Z=function(){function e(e){this._state=this.defaultState,this._listeners=[],this._txn=0,this._context=e}return Object.defineProperty(e.prototype,"defaultState",{get:function(){return{groups:[],items:[],choices:[]}},enumerable:!1,configurable:!0}),e.prototype.changeSet=function(e){return{groups:e,items:e,choices:e}},e.prototype.reset=function(){this._state=this.defaultState;var e=this.changeSet(!0);this._txn?this._changeSet=e:this._listeners.forEach((function(t){return t(e)}))},e.prototype.subscribe=function(e){return this._listeners.push(e),this},e.prototype.dispatch=function(e){var t=this,i=this._state,n=!1,s=this._changeSet||this.changeSet(!1);Object.keys(Y).forEach((function(o){var r=Y[o](i[o],e,t._context);r.update&&(n=!0,s[o]=!0,i[o]=r.state)})),n&&(this._txn?this._changeSet=s:this._listeners.forEach((function(e){return e(s)})))},e.prototype.withTxn=function(e){this._txn++;try{e()}finally{if(this._txn=Math.max(0,this._txn-1),!this._txn){var t=this._changeSet;t&&(this._changeSet=void 0,this._listeners.forEach((function(e){return e(t)})))}}},Object.defineProperty(e.prototype,"state",{get:function(){return this._state},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"items",{get:function(){return this.state.items},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"highlightedActiveItems",{get:function(){return this.items.filter((function(e){return e.active&&e.highlighted}))},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"choices",{get:function(){return this.state.choices},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"activeChoices",{get:function(){return this.choices.filter((function(e){return e.active}))},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"searchableChoices",{get:function(){return this.choices.filter((function(e){return!e.disabled&&!e.placeholder}))},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"groups",{get:function(){return this.state.groups},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"activeGroups",{get:function(){var e=this;return this.state.groups.filter((function(t){var i=t.active&&!t.disabled,n=e.state.choices.some((function(e){return e.active&&!e.disabled}));return i&&n}),[])},enumerable:!1,configurable:!0}),e.prototype.inTxn=function(){return this._txn>0},e.prototype.getChoiceById=function(e){return this.activeChoices.find((function(t){return t.id===e}))},e.prototype.getGroupById=function(e){return this.groups.find((function(t){return t.id===e}))},e}(),ee="no-choices",te="no-results",ie="add-choice";function ne(e,t,i){return(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var i=t.call(e,"string");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t))in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i,e}function se(e,t){var i=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),i.push.apply(i,n)}return i}function oe(e){for(var t=1;t`Missing ${e} property in key`,fe=e=>`Property 'weight' in key '${e}' must be a positive integer`,me=Object.prototype.hasOwnProperty;class ge{constructor(e){this._keys=[],this._keyMap={};let t=0;e.forEach((e=>{let i=ve(e);this._keys.push(i),this._keyMap[i.id]=i,t+=i.weight})),this._keys.forEach((e=>{e.weight/=t}))}get(e){return this._keyMap[e]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}}function ve(e){let t=null,i=null,n=null,s=1,o=null;if(ce(e)||re(e))n=e,t=_e(e),i=ye(e);else{if(!me.call(e,"name"))throw new Error(pe("name"));const r=e.name;if(n=r,me.call(e,"weight")&&(s=e.weight,s<=0))throw new Error(fe(r));t=_e(r),i=ye(r),o=e.getFn}return{path:t,id:i,weight:s,src:n,getFn:o}}function _e(e){return re(e)?e:e.split(".")}function ye(e){return re(e)?e.join("."):e}const be={useExtendedSearch:!1,getFn:function(e,t){let i=[],n=!1;const s=(e,t,o)=>{if(le(e))if(t[o]){const r=e[t[o]];if(!le(r))return;if(o===t.length-1&&(ce(r)||ae(r)||function(e){return!0===e||!1===e||function(e){return he(e)&&null!==e}(e)&&"[object Boolean]"==de(e)}(r)))i.push(function(e){return null==e?"":function(e){if("string"==typeof e)return e;let t=e+"";return"0"==t&&1/e==-1/0?"-0":t}(e)}(r));else if(re(r)){n=!0;for(let e=0,i=r.length;ee.score===t.score?e.idx{this._keysMap[e.id]=t}))}create(){!this.isCreated&&this.docs.length&&(this.isCreated=!0,ce(this.docs[0])?this.docs.forEach(((e,t)=>{this._addString(e,t)})):this.docs.forEach(((e,t)=>{this._addObject(e,t)})),this.norm.clear())}add(e){const t=this.size();ce(e)?this._addString(e,t):this._addObject(e,t)}removeAt(e){this.records.splice(e,1);for(let t=e,i=this.size();t{let s=t.getFn?t.getFn(e):this.getFn(e,t.path);if(le(s))if(re(s)){let e=[];const t=[{nestedArrIndex:-1,value:s}];for(;t.length;){const{nestedArrIndex:i,value:n}=t.pop();if(le(n))if(ce(n)&&!ue(n)){let t={v:n,i:i,n:this.norm.get(n)};e.push(t)}else re(n)&&n.forEach(((e,i)=>{t.push({nestedArrIndex:i,value:e})}))}i.$[n]=e}else if(ce(s)&&!ue(s)){let e={v:s,n:this.norm.get(s)};i.$[n]=e}})),this.records.push(i)}toJSON(){return{keys:this.keys,records:this.records}}}function we(e,t,{getFn:i=Ee.getFn,fieldNormWeight:n=Ee.fieldNormWeight}={}){const s=new Se({getFn:i,fieldNormWeight:n});return s.setKeys(e.map(ve)),s.setSources(t),s.create(),s}function Ie(e,{errors:t=0,currentLocation:i=0,expectedLocation:n=0,distance:s=Ee.distance,ignoreLocation:o=Ee.ignoreLocation}={}){const r=t/e.length;if(o)return r;const c=Math.abs(n-i);return s?r+c/s:c?1:r}const Ae=32;function xe(e){let t={};for(let i=0,n=e.length;i{this.chunks.push({pattern:e,alphabet:xe(e),startIndex:t})},l=this.pattern.length;if(l>Ae){let e=0;const t=l%Ae,i=l-t;for(;e{const{isMatch:f,score:m,indices:g}=function(e,t,i,{location:n=Ee.location,distance:s=Ee.distance,threshold:o=Ee.threshold,findAllMatches:r=Ee.findAllMatches,minMatchCharLength:c=Ee.minMatchCharLength,includeMatches:a=Ee.includeMatches,ignoreLocation:h=Ee.ignoreLocation}={}){if(t.length>Ae)throw new Error("Pattern length exceeds max of 32.");const l=t.length,u=e.length,d=Math.max(0,Math.min(n,u));let p=o,f=d;const m=c>1||a,g=m?Array(u):[];let v;for(;(v=e.indexOf(t,f))>-1;){let e=Ie(t,{currentLocation:v,expectedLocation:d,distance:s,ignoreLocation:h});if(p=Math.min(e,p),f=v+l,m){let e=0;for(;e=a;o-=1){let r=o-1,c=i[e.charAt(r)];if(m&&(g[r]=+!!c),C[o]=(C[o+1]<<1|1)&c,n&&(C[o]|=(_[o+1]|_[o])<<1|1|_[o+1]),C[o]&E&&(y=Ie(t,{errors:n,currentLocation:r,expectedLocation:d,distance:s,ignoreLocation:h}),y<=p)){if(p=y,f=r,f<=d)break;a=Math.max(1,2*d-f)}}if(Ie(t,{errors:n+1,currentLocation:d,expectedLocation:d,distance:s,ignoreLocation:h})>p)break;_=C}const C={isMatch:f>=0,score:Math.max(.001,y)};if(m){const e=function(e=[],t=Ee.minMatchCharLength){let i=[],n=-1,s=-1,o=0;for(let r=e.length;o=t&&i.push([n,s]),n=-1)}return e[o-1]&&o-n>=t&&i.push([n,o-1]),i}(g,c);e.length?a&&(C.indices=e):C.isMatch=!1}return C}(e,t,d,{location:n+p,distance:s,threshold:o,findAllMatches:r,minMatchCharLength:c,includeMatches:i,ignoreLocation:a});f&&(u=!0),l+=m,f&&g&&(h=[...h,...g])}));let d={isMatch:u,score:u?l/this.chunks.length:1};return u&&i&&(d.indices=h),d}}class Le{constructor(e){this.pattern=e}static isMultiMatch(e){return Me(e,this.multiRegex)}static isSingleMatch(e){return Me(e,this.singleRegex)}search(){}}function Me(e,t){const i=e.match(t);return i?i[1]:null}class Te extends Le{constructor(e,{location:t=Ee.location,threshold:i=Ee.threshold,distance:n=Ee.distance,includeMatches:s=Ee.includeMatches,findAllMatches:o=Ee.findAllMatches,minMatchCharLength:r=Ee.minMatchCharLength,isCaseSensitive:c=Ee.isCaseSensitive,ignoreLocation:a=Ee.ignoreLocation}={}){super(e),this._bitapSearch=new Oe(e,{location:t,threshold:i,distance:n,includeMatches:s,findAllMatches:o,minMatchCharLength:r,isCaseSensitive:c,ignoreLocation:a})}static get type(){return"fuzzy"}static get multiRegex(){return/^"(.*)"$/}static get singleRegex(){return/^(.*)$/}search(e){return this._bitapSearch.searchIn(e)}}class Ne extends Le{constructor(e){super(e)}static get type(){return"include"}static get multiRegex(){return/^'"(.*)"$/}static get singleRegex(){return/^'(.*)$/}search(e){let t,i=0;const n=[],s=this.pattern.length;for(;(t=e.indexOf(this.pattern,i))>-1;)i=t+s,n.push([t,i-1]);const o=!!n.length;return{isMatch:o,score:o?0:1,indices:n}}}const ke=[class extends Le{constructor(e){super(e)}static get type(){return"exact"}static get multiRegex(){return/^="(.*)"$/}static get singleRegex(){return/^=(.*)$/}search(e){const t=e===this.pattern;return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},Ne,class extends Le{constructor(e){super(e)}static get type(){return"prefix-exact"}static get multiRegex(){return/^\^"(.*)"$/}static get singleRegex(){return/^\^(.*)$/}search(e){const t=e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,this.pattern.length-1]}}},class extends Le{constructor(e){super(e)}static get type(){return"inverse-prefix-exact"}static get multiRegex(){return/^!\^"(.*)"$/}static get singleRegex(){return/^!\^(.*)$/}search(e){const t=!e.startsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},class extends Le{constructor(e){super(e)}static get type(){return"inverse-suffix-exact"}static get multiRegex(){return/^!"(.*)"\$$/}static get singleRegex(){return/^!(.*)\$$/}search(e){const t=!e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},class extends Le{constructor(e){super(e)}static get type(){return"suffix-exact"}static get multiRegex(){return/^"(.*)"\$$/}static get singleRegex(){return/^(.*)\$$/}search(e){const t=e.endsWith(this.pattern);return{isMatch:t,score:t?0:1,indices:[e.length-this.pattern.length,e.length-1]}}},class extends Le{constructor(e){super(e)}static get type(){return"inverse-exact"}static get multiRegex(){return/^!"(.*)"$/}static get singleRegex(){return/^!(.*)$/}search(e){const t=-1===e.indexOf(this.pattern);return{isMatch:t,score:t?0:1,indices:[0,e.length-1]}}},Te],Fe=ke.length,De=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/,Pe=new Set([Te.type,Ne.type]);const je=[];function Re(e,t){for(let i=0,n=je.length;i!(!e[Ke]&&!e.$or),He=e=>({[Ke]:Object.keys(e).map((t=>({[t]:e[t]})))});function $e(e,t,{auto:i=!0}={}){const n=e=>{let s=Object.keys(e);const o=(e=>!!e[Ve])(e);if(!o&&s.length>1&&!Be(e))return n(He(e));if((e=>!re(e)&&he(e)&&!Be(e))(e)){const n=o?e[Ve]:s[0],r=o?e.$val:e[n];if(!ce(r))throw new Error((e=>`Invalid value for key ${e}`)(n));const c={keyId:ye(n),pattern:r};return i&&(c.searcher=Re(r,t)),c}let r={children:[],operator:s[0]};return s.forEach((t=>{const i=e[t];re(i)&&i.forEach((e=>{r.children.push(n(e))}))})),r};return Be(e)||(e=He(e)),n(e)}function qe(e,t){const i=e.matches;t.matches=[],le(i)&&i.forEach((e=>{if(!le(e.indices)||!e.indices.length)return;const{indices:i,value:n}=e;let s={indices:i,value:n};e.key&&(s.key=e.key.src),e.idx>-1&&(s.refIndex=e.idx),t.matches.push(s)}))}function We(e,t){t.score=e.score}class Ue{constructor(e,t={},i){this.options=oe(oe({},Ee),t),this._keyStore=new ge(this.options.keys),this.setCollection(e,i)}setCollection(e,t){if(this._docs=e,t&&!(t instanceof Se))throw new Error("Incorrect 'index' type");this._myIndex=t||we(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}add(e){le(e)&&(this._docs.push(e),this._myIndex.add(e))}remove(e=()=>!1){const t=[];for(let i=0,n=this._docs.length;i{let i=1;e.matches.forEach((({key:e,norm:n,score:s})=>{const o=e?e.weight:null;i*=Math.pow(0===s&&o?Number.EPSILON:s,(o||1)*(t?1:n))})),e.score=i}))}(c,{ignoreFieldNorm:r}),s&&c.sort(o),ae(t)&&t>-1&&(c=c.slice(0,t)),function(e,t,{includeMatches:i=Ee.includeMatches,includeScore:n=Ee.includeScore}={}){const s=[];return i&&s.push(qe),n&&s.push(We),e.map((e=>{const{idx:i}=e,n={item:t[i],refIndex:i};return s.length&&s.forEach((t=>{t(e,n)})),n}))}(c,this._docs,{includeMatches:i,includeScore:n})}_searchStringList(e){const t=Re(e,this.options),{records:i}=this._myIndex,n=[];return i.forEach((({v:e,i:i,n:s})=>{if(!le(e))return;const{isMatch:o,score:r,indices:c}=t.searchIn(e);o&&n.push({item:e,idx:i,matches:[{score:r,value:e,norm:s,indices:c}]})})),n}_searchLogical(e){const t=$e(e,this.options),i=(e,t,n)=>{if(!e.children){const{keyId:i,searcher:s}=e,o=this._findMatches({key:this._keyStore.get(i),value:this._myIndex.getValueForItemAtKeyId(t,i),searcher:s});return o&&o.length?[{idx:n,item:t,matches:o}]:[]}const s=[];for(let o=0,r=e.children.length;o{if(le(e)){let r=i(t,e,o);r.length&&(n[o]||(n[o]={idx:o,item:e,matches:[]},s.push(n[o])),r.forEach((({matches:e})=>{n[o].matches.push(...e)})))}})),s}_searchObjectList(e){const t=Re(e,this.options),{keys:i,records:n}=this._myIndex,s=[];return n.forEach((({$:e,i:n})=>{if(!le(e))return;let o=[];i.forEach(((i,n)=>{o.push(...this._findMatches({key:i,value:e[n],searcher:t}))})),o.length&&s.push({idx:n,item:e,matches:o})})),s}_findMatches({key:e,value:t,searcher:i}){if(!le(t))return[];let n=[];if(re(t))t.forEach((({v:t,i:s,n:o})=>{if(!le(t))return;const{isMatch:r,score:c,indices:a}=i.searchIn(t);r&&n.push({score:c,key:e,value:t,idx:s,norm:o,indices:a})}));else{const{v:s,n:o}=t,{isMatch:r,score:c,indices:a}=i.searchIn(s);r&&n.push({score:c,key:e,value:s,norm:o,indices:a})}return n}}Ue.version="7.0.0",Ue.createIndex=we,Ue.parseIndex=function(e,{getFn:t=Ee.getFn,fieldNormWeight:i=Ee.fieldNormWeight}={}){const{keys:n,records:s}=e,o=new Se({getFn:t,fieldNormWeight:i});return o.setKeys(n),o.setIndexRecords(s),o},Ue.config=Ee,Ue.parseQuery=$e,function(...e){je.push(...e)}(class{constructor(e,{isCaseSensitive:t=Ee.isCaseSensitive,includeMatches:i=Ee.includeMatches,minMatchCharLength:n=Ee.minMatchCharLength,ignoreLocation:s=Ee.ignoreLocation,findAllMatches:o=Ee.findAllMatches,location:r=Ee.location,threshold:c=Ee.threshold,distance:a=Ee.distance}={}){this.query=null,this.options={isCaseSensitive:t,includeMatches:i,minMatchCharLength:n,findAllMatches:o,ignoreLocation:s,location:r,threshold:c,distance:a},this.pattern=t?e:e.toLowerCase(),this.query=function(e,t={}){return e.split("|").map((e=>{let i=e.trim().split(De).filter((e=>e&&!!e.trim())),n=[];for(let e=0,s=i.length;e element"),this)},e.prototype.removeChoice=function(e){var t=this._store.choices.find((function(t){return t.value===e}));return t?(this._clearNotice(),this._store.dispatch(function(e){return{type:r,choice:e}}(t)),this._searcher.reset(),t.selected&&this.passedElement.triggerEvent(m,this._getChoiceForOutput(t)),this):this},e.prototype.clearChoices=function(e,t){var i=this;return void 0===e&&(e=!0),void 0===t&&(t=!1),e&&(t?this.passedElement.element.replaceChildren(""):this.passedElement.element.querySelectorAll(":not([selected])").forEach((function(e){e.remove()}))),this.itemList.element.replaceChildren(""),this.choiceList.element.replaceChildren(""),this._clearNotice(),this._store.withTxn((function(){var e=t?[]:i._store.items;i._store.reset(),e.forEach((function(e){i._store.dispatch(b(e)),i._store.dispatch(E(e))}))})),this._searcher.reset(),this},e.prototype.clearStore=function(e){return void 0===e&&(e=!0),this.clearChoices(e,!0),this._stopSearch(),this._lastAddedChoiceId=0,this._lastAddedGroupId=0,this},e.prototype.clearInput=function(){return this.input.clear(!this._isSelectOneElement),this._stopSearch(),this},e.prototype._validateConfig=function(){var e,t,i,n=this.config,s=(e=X,t=Object.keys(n).sort(),i=Object.keys(e).sort(),t.filter((function(e){return i.indexOf(e)<0})));s.length&&console.warn("Unknown config option(s) passed",s.join(", ")),n.allowHTML&&n.allowHtmlUserInput&&(n.addItems&&console.warn("Warning: allowHTML/allowHtmlUserInput/addItems all being true is strongly not recommended and may lead to XSS attacks"),n.addChoices&&console.warn("Warning: allowHTML/allowHtmlUserInput/addChoices all being true is strongly not recommended and may lead to XSS attacks"))},e.prototype._render=function(e){void 0===e&&(e={choices:!0,groups:!0,items:!0}),this._store.inTxn()||(this._isSelectElement&&(e.choices||e.groups)&&this._renderChoices(),e.items&&this._renderItems())},e.prototype._renderChoices=function(){var e=this;if(this._canAddItems()){var t=this.config,i=this._isSearching,n=this._store,s=n.activeGroups,o=n.activeChoices,r=0;if(i&&t.searchResultLimit>0?r=t.searchResultLimit:t.renderChoiceLimit>0&&(r=t.renderChoiceLimit),this._isSelectElement){var c=o.filter((function(e){return!e.element}));c.length&&this.passedElement.addOptions(c)}var a=document.createDocumentFragment(),h=function(e){return e.filter((function(e){return!e.placeholder&&(i?!!e.rank:t.renderSelectedChoices||!e.selected)}))},l=!1,u=function(n,s,o){i?n.sort(k):t.shouldSort&&n.sort(t.sorter);var c=n.length;c=!s&&r&&c>r?r:c,c--,n.every((function(n,s){var r=n.choiceEl||e._templates.choice(t,n,t.itemSelectText,o);return n.choiceEl=r,a.appendChild(r),!i&&n.selected||(l=!0),s1){var h=i.querySelector(D(n.classNames.placeholder));h&&h.remove()}else c||a||!this._placeholderValue||(c=!0,r(G({selected:!0,value:"",label:this._placeholderValue,placeholder:!0},!1)))}c&&(i.append(s),n.shouldSortItems&&!this._isSelectOneElement&&(t.sort(n.sorter),t.forEach((function(e){var t=o(e);t&&(t.remove(),s.append(t))})),i.append(s))),this._isTextElement&&(this.passedElement.value=t.map((function(e){return e.value})).join(n.delimiter))},e.prototype._displayNotice=function(e,t,i){void 0===i&&(i=!0);var n=this._notice;n&&(n.type===t&&n.text===e||n.type===ie&&(t===te||t===ee))?i&&this.showDropdown(!0):(this._clearNotice(),this._notice=e?{text:e,type:t}:void 0,this._renderNotice(),i&&e&&this.showDropdown(!0))},e.prototype._clearNotice=function(){if(this._notice){var e=this.choiceList.element.querySelector(D(this.config.classNames.notice));e&&e.remove(),this._notice=void 0}},e.prototype._renderNotice=function(e){var t=this._notice;if(t){var i=this._templates.notice(this.config,t.text,t.type);e?e.append(i):this.choiceList.prepend(i)}},e.prototype._getChoiceForOutput=function(e,t){return{id:e.id,highlighted:e.highlighted,labelClass:e.labelClass,labelDescription:e.labelDescription,customProperties:e.customProperties,disabled:e.disabled,active:e.active,label:e.label,placeholder:e.placeholder,value:e.value,groupValue:e.group?e.group.label:void 0,element:e.element,keyCode:t}},e.prototype._triggerChange=function(e){null!=e&&this.passedElement.triggerEvent("change",{value:e})},e.prototype._handleButtonAction=function(e){var t=this,i=this._store.items;if(i.length&&this.config.removeItems&&this.config.removeItemButton){var n=e&&Ze(e.parentElement),s=n&&i.find((function(e){return e.id===n}));s&&this._store.withTxn((function(){if(t._removeItem(s),t._triggerChange(s.value),t._isSelectOneElement&&!t._hasNonChoicePlaceholder){var e=(t.config.shouldSort?t._store.choices.reverse():t._store.choices).find((function(e){return e.placeholder}));e&&(t._addItem(e),t.unhighlightAll(),e.value&&t._triggerChange(e.value))}}))}},e.prototype._handleItemAction=function(e,t){var i=this;void 0===t&&(t=!1);var n=this._store.items;if(n.length&&this.config.removeItems&&!this._isSelectOneElement){var s=Ze(e);s&&(n.forEach((function(e){e.id!==s||e.highlighted?!t&&e.highlighted&&i.unhighlightItem(e):i.highlightItem(e)})),this.input.focus())}},e.prototype._handleChoiceAction=function(e){var t=this,i=Ze(e),n=i&&this._store.getChoiceById(i);if(!n||n.disabled)return!1;var s=this.dropdown.isActive;if(!n.selected){if(!this._canAddItems())return!0;this._store.withTxn((function(){t._addItem(n,!0,!0),t.clearInput(),t.unhighlightAll()})),this._triggerChange(n.value)}return s&&this.config.closeDropdownOnSelect&&(this.hideDropdown(!0),this.containerOuter.element.focus()),!0},e.prototype._handleBackspace=function(e){var t=this.config;if(t.removeItems&&e.length){var i=e[e.length-1],n=e.some((function(e){return e.highlighted}));t.editItems&&!n&&i?(this.input.value=i.value,this.input.setWidth(),this._removeItem(i),this._triggerChange(i.value)):(n||this.highlightItem(i,!1),this.removeHighlightedItems(!0))}},e.prototype._loadChoices=function(){var e,t=this,i=this.config;if(this._isTextElement){if(this._presetChoices=i.items.map((function(e){return G(e,!1)})),this.passedElement.value){var n=this.passedElement.value.split(i.delimiter).map((function(e){return G(e,!1,t.config.allowHtmlUserInput)}));this._presetChoices=this._presetChoices.concat(n)}this._presetChoices.forEach((function(e){e.selected=!0}))}else if(this._isSelectElement){this._presetChoices=i.choices.map((function(e){return G(e,!0)}));var s=this.passedElement.optionsAsChoices();s&&(e=this._presetChoices).push.apply(e,s)}},e.prototype._handleLoadingState=function(e){void 0===e&&(e=!0);var t=this.itemList.element;e?(this.disable(),this.containerOuter.addLoadingState(),this._isSelectOneElement?t.replaceChildren(this._templates.placeholder(this.config,this.config.loadingText)):this.input.placeholder=this.config.loadingText):(this.enable(),this.containerOuter.removeLoadingState(),this._isSelectOneElement?(t.replaceChildren(""),this._render()):this.input.placeholder=this._placeholderValue||"")},e.prototype._handleSearch=function(e){if(this.input.isFocussed)if(null!=e&&e.length>=this.config.searchFloor){var t=this.config.searchChoices?this._searchChoices(e):0;null!==t&&this.passedElement.triggerEvent(f,{value:e,resultCount:t})}else this._store.choices.some((function(e){return!e.active}))&&this._stopSearch()},e.prototype._canAddItems=function(){var e=this.config,t=e.maxItemCount,i=e.maxItemText;return!e.singleModeForMultiSelect&&t>0&&t<=this._store.items.length?(this.choiceList.element.replaceChildren(""),this._notice=void 0,this._displayNotice("function"==typeof i?i(t):i,ie),!1):(this._notice&&this._notice.type===ie&&this._clearNotice(),!0)},e.prototype._canCreateItem=function(e){var t=this.config,i=!0,n="";if(i&&"function"==typeof t.addItemFilter&&!t.addItemFilter(e)&&(i=!1,n=x(t.customAddItemText,e)),i&&this._store.choices.find((function(i){return t.valueComparer(i.value,e)}))){if(this._isSelectElement)return this._displayNotice("",ie),!1;t.duplicateItemsAllowed||(i=!1,n=x(t.uniqueItemText,e))}return i&&(n=x(t.addItemText,e)),n&&this._displayNotice(n,ie),i},e.prototype._searchChoices=function(e){var t=e.trim().replace(/\s{2,}/," ");if(!t.length||t===this._currentValue)return null;var i=this._searcher;i.isEmptyIndex()&&i.index(this._store.searchableChoices);var n=i.search(t);this._currentValue=t,this._highlightPosition=0,this._isSearching=!0;var s=this._notice;return(s&&s.type)!==ie&&(n.length?this._clearNotice():this._displayNotice(O(this.config.noResultsText),te)),this._store.dispatch(function(e){return{type:c,results:e}}(n)),n.length},e.prototype._stopSearch=function(){this._isSearching&&(this._currentValue="",this._isSearching=!1,this._clearNotice(),this._store.dispatch({type:a,active:!0}),this.passedElement.triggerEvent(f,{value:"",resultCount:0}))},e.prototype._addEventListeners=function(){var e=this._docRoot,t=this.containerOuter.element,i=this.input.element;e.addEventListener("touchend",this._onTouchEnd,!0),t.addEventListener("keydown",this._onKeyDown,!0),t.addEventListener("mousedown",this._onMouseDown,!0),e.addEventListener("click",this._onClick,{passive:!0}),e.addEventListener("touchmove",this._onTouchMove,{passive:!0}),this.dropdown.element.addEventListener("mouseover",this._onMouseOver,{passive:!0}),this._isSelectOneElement&&(t.addEventListener("focus",this._onFocus,{passive:!0}),t.addEventListener("blur",this._onBlur,{passive:!0})),i.addEventListener("keyup",this._onKeyUp,{passive:!0}),i.addEventListener("input",this._onInput,{passive:!0}),i.addEventListener("focus",this._onFocus,{passive:!0}),i.addEventListener("blur",this._onBlur,{passive:!0}),i.form&&i.form.addEventListener("reset",this._onFormReset,{passive:!0}),this.input.addEventListeners()},e.prototype._removeEventListeners=function(){var e=this._docRoot,t=this.containerOuter.element,i=this.input.element;e.removeEventListener("touchend",this._onTouchEnd,!0),t.removeEventListener("keydown",this._onKeyDown,!0),t.removeEventListener("mousedown",this._onMouseDown,!0),e.removeEventListener("click",this._onClick),e.removeEventListener("touchmove",this._onTouchMove),this.dropdown.element.removeEventListener("mouseover",this._onMouseOver),this._isSelectOneElement&&(t.removeEventListener("focus",this._onFocus),t.removeEventListener("blur",this._onBlur)),i.removeEventListener("keyup",this._onKeyUp),i.removeEventListener("input",this._onInput),i.removeEventListener("focus",this._onFocus),i.removeEventListener("blur",this._onBlur),i.form&&i.form.removeEventListener("reset",this._onFormReset),this.input.removeEventListeners()},e.prototype._onKeyDown=function(e){var t=e.keyCode,i=this.dropdown.isActive,n=1===e.key.length||2===e.key.length&&e.key.charCodeAt(0)>=55296||"Unidentified"===e.key;switch(this._isTextElement||i||27===t||9===t||16===t||(this.showDropdown(),!this.input.isFocussed&&n&&(this.input.value+=e.key," "===e.key&&e.preventDefault())),t){case 65:return this._onSelectKey(e,this.itemList.element.hasChildNodes());case 13:return this._onEnterKey(e,i);case 27:return this._onEscapeKey(e,i);case 38:case 33:case 40:case 34:return this._onDirectionKey(e,i);case 8:case 46:return this._onDeleteKey(e,this._store.items,this.input.isFocussed)}},e.prototype._onKeyUp=function(){this._canSearch=this.config.searchEnabled},e.prototype._onInput=function(){var e=this.input.value;e?this._canAddItems()&&(this._canSearch&&this._handleSearch(e),this._canAddUserChoices&&(this._canCreateItem(e),this._isSelectElement&&(this._highlightPosition=0,this._highlightChoice()))):this._isTextElement?this.hideDropdown(!0):this._stopSearch()},e.prototype._onSelectKey=function(e,t){(e.ctrlKey||e.metaKey)&&t&&(this._canSearch=!1,this.config.removeItems&&!this.input.value&&this.input.element===document.activeElement&&this.highlightAll())},e.prototype._onEnterKey=function(e,t){var i=this,n=this.input.value,s=e.target;if(e.preventDefault(),s&&s.hasAttribute("data-button"))this._handleButtonAction(s);else if(t){var o=this.dropdown.element.querySelector(D(this.config.classNames.highlightedState));if(!o||!this._handleChoiceAction(o))if(s&&n){if(this._canAddItems()){var r=!1;this._store.withTxn((function(){if(!(r=i._findAndSelectChoiceByValue(n,!0))){if(!i._canAddUserChoices)return;if(!i._canCreateItem(n))return;i._addChoice(G(n,!1,i.config.allowHtmlUserInput),!0,!0),r=!0}i.clearInput(),i.unhighlightAll()})),r&&(this._triggerChange(n),this.config.closeDropdownOnSelect&&this.hideDropdown(!0))}}else this.hideDropdown(!0)}else(this._isSelectElement||this._notice)&&this.showDropdown()},e.prototype._onEscapeKey=function(e,t){t&&(e.stopPropagation(),this.hideDropdown(!0),this._stopSearch(),this.containerOuter.element.focus())},e.prototype._onDirectionKey=function(e,t){var i,n,s,o=e.keyCode;if(t||this._isSelectOneElement){this.showDropdown(),this._canSearch=!1;var r=40===o||34===o?1:-1,c=void 0;if(e.metaKey||34===o||33===o)c=this.dropdown.element.querySelector(r>0?"".concat(et,":last-of-type"):et);else{var a=this.dropdown.element.querySelector(D(this.config.classNames.highlightedState));c=a?function(e,t,i){void 0===i&&(i=1);for(var n="".concat(i>0?"next":"previous","ElementSibling"),s=e[n];s;){if(s.matches(t))return s;s=s[n]}return null}(a,et,r):this.dropdown.element.querySelector(et)}c&&(i=c,n=this.choiceList.element,void 0===(s=r)&&(s=1),(s>0?n.scrollTop+n.offsetHeight>=i.offsetTop+i.offsetHeight:i.offsetTop>=n.scrollTop)||this.choiceList.scrollToChildElement(c,r),this._highlightChoice(c)),e.preventDefault()}},e.prototype._onDeleteKey=function(e,t,i){this._isSelectOneElement||e.target.value||!i||(this._handleBackspace(t),e.preventDefault())},e.prototype._onTouchMove=function(){this._wasTap&&(this._wasTap=!1)},e.prototype._onTouchEnd=function(e){var t=(e||e.touches[0]).target;this._wasTap&&this.containerOuter.element.contains(t)&&((t===this.containerOuter.element||t===this.containerInner.element)&&(this._isTextElement?this.input.focus():this._isSelectMultipleElement&&this.showDropdown()),e.stopPropagation()),this._wasTap=!0},e.prototype._onMouseDown=function(e){var t=e.target;if(t instanceof HTMLElement){if(Qe&&this.choiceList.element.contains(t)){var i=this.choiceList.element.firstElementChild;this._isScrollingOnIe="ltr"===this._direction?e.offsetX>=i.offsetWidth:e.offsetXthis._highlightPosition?t[this._highlightPosition]:t[t.length-1])||(i=t[0]),P(i,n),i.setAttribute("aria-selected","true"),this.passedElement.triggerEvent("highlightChoice",{el:i}),this.dropdown.isActive&&(this.input.setActiveDescendant(i.id),this.containerOuter.setActiveDescendant(i.id))}},e.prototype._addItem=function(e,t,i){if(void 0===t&&(t=!0),void 0===i&&(i=!1),!e.id)throw new TypeError("item.id must be set before _addItem is called for a choice/item");(this.config.singleModeForMultiSelect||this._isSelectOneElement)&&this.removeActiveItems(e.id),this._store.dispatch(E(e)),t&&(this.passedElement.triggerEvent("addItem",this._getChoiceForOutput(e)),i&&this.passedElement.triggerEvent("choice",this._getChoiceForOutput(e)))},e.prototype._removeItem=function(e){if(e.id){this._store.dispatch(C(e));var t=this._notice;t&&t.type===ee&&this._clearNotice(),this.passedElement.triggerEvent(m,this._getChoiceForOutput(e))}},e.prototype._addChoice=function(e,t,i){if(void 0===t&&(t=!0),void 0===i&&(i=!1),e.id)throw new TypeError("Can not re-add a choice which has already been added");var n=this.config;if(n.duplicateItemsAllowed||!this._store.choices.find((function(t){return n.valueComparer(t.value,e.value)}))){this._lastAddedChoiceId++,e.id=this._lastAddedChoiceId,e.elementId="".concat(this._baseId,"-").concat(this._idNames.itemChoice,"-").concat(e.id);var s=n.prependValue,o=n.appendValue;s&&(e.value=s+e.value),o&&(e.value+=o.toString()),(s||o)&&e.element&&(e.element.value=e.value),this._clearNotice(),this._store.dispatch(b(e)),e.selected&&this._addItem(e,t,i)}},e.prototype._addGroup=function(e,t){var i=this;if(void 0===t&&(t=!0),e.id)throw new TypeError("Can not re-add a group which has already been added");this._store.dispatch(function(e){return{type:l,group:e}}(e)),e.choices&&(this._lastAddedGroupId++,e.id=this._lastAddedGroupId,e.choices.forEach((function(n){n.group=e,e.disabled&&(n.disabled=!0),i._addChoice(n,t)})))},e.prototype._createTemplates=function(){var e=this,t=this.config.callbackOnCreateTemplates,i={};"function"==typeof t&&(i=t.call(this,A,T,F));var n={};Object.keys(this._templates).forEach((function(t){n[t]=t in i?i[t].bind(e):e._templates[t].bind(e)})),this._templates=n},e.prototype._createElements=function(){var e=this._templates,t=this.config,i=this._isSelectOneElement,n=t.position,s=t.classNames,o=this._elementType;this.containerOuter=new V({element:e.containerOuter(t,this._direction,this._isSelectElement,i,t.searchEnabled,o,t.labelId),classNames:s,type:o,position:n}),this.containerInner=new V({element:e.containerInner(t),classNames:s,type:o,position:n}),this.input=new B({element:e.input(t,this._placeholderValue),classNames:s,type:o,preventPaste:!t.paste}),this.choiceList=new H({element:e.choiceList(t,i)}),this.itemList=new H({element:e.itemList(t,i)}),this.dropdown=new K({element:e.dropdown(t),classNames:s,type:o})},e.prototype._createStructure=function(){var e=this,t=e.containerInner,i=e.containerOuter,n=e.passedElement,s=this.dropdown.element;n.conceal(),t.wrap(n.element),i.wrap(t.element),this._isSelectOneElement?this.input.placeholder=this.config.searchPlaceholderValue||"":(this._placeholderValue&&(this.input.placeholder=this._placeholderValue),this.input.setWidth()),i.element.appendChild(t.element),i.element.appendChild(s),t.element.appendChild(this.itemList.element),s.appendChild(this.choiceList.element),this._isSelectOneElement?this.config.searchEnabled&&s.insertBefore(this.input.element,s.firstChild):t.element.appendChild(this.input.element),this._highlightPosition=0,this._isSearching=!1},e.prototype._initStore=function(){var e=this;this._store.subscribe(this._render).withTxn((function(){e._addPredefinedChoices(e._presetChoices,e._isSelectOneElement&&!e._hasNonChoicePlaceholder,!1)})),(!this._store.choices.length||this._isSelectOneElement&&this._hasNonChoicePlaceholder)&&this._render()},e.prototype._addPredefinedChoices=function(e,t,i){var n=this;void 0===t&&(t=!1),void 0===i&&(i=!0),t&&-1===e.findIndex((function(e){return e.selected}))&&e.some((function(e){return!e.disabled&&!("choices"in e)&&(e.selected=!0,!0)})),e.forEach((function(e){"choices"in e?n._isSelectElement&&n._addGroup(e,i):n._addChoice(e,i)}))},e.prototype._findAndSelectChoiceByValue=function(e,t){var i=this;void 0===t&&(t=!1);var n=this._store.choices.find((function(t){return i.config.valueComparer(t.value,e)}));return!(!n||n.disabled||n.selected||(this._addItem(n,!0,t),0))},e.prototype._generatePlaceholderValue=function(){var e=this.config;if(!e.placeholder)return null;if(this._hasNonChoicePlaceholder)return e.placeholderValue;if(this._isSelectElement){var t=this.passedElement.placeholderOption;return t?t.text:null}return null},e.prototype._warnChoicesInitFailed=function(e){if(!this.config.silent){if(!this.initialised)throw new TypeError("".concat(e," called on a non-initialised instance of Choices"));if(!this.initialisedOK)throw new TypeError("".concat(e," called for an element which has multiple instances of Choices initialised on it"))}},e.version="11.1.0",e}()})); diff --git a/static/css/main.css b/static/css/main.css index 259696a9a..0c407920d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -24,6 +24,8 @@ flex-direction: row; align-items: center; justify-content: left; + flex-wrap: wrap; + gap: 1rem; } .apilos--container_ellipsis { position: relative; @@ -59,11 +61,20 @@ .apilos--p-relative { position: relative; } -.apilos--right-top { - position: absolute; - top: 10px; - right: 0; - z-index: 1; + +.apilos--right-top-responsive { + position: static; + display: block; + margin-bottom: 1rem; +} + +@media (min-width: 768px) { + .apilos--right-top-responsive { + position: absolute; + top: 10px; + right: 0; + z-index: 1; + } } .insert-info { margin-top: -20px; @@ -114,6 +125,7 @@ #search_table .search-select.search-statut:has(.vscomp-wrapper.has-value) .fr-select { border: solid 1px var(--text-default-info); } + #search_table .search-select:has(.fr-select option[selected]) .select-label::after, #search_table .search-input:has(.fr-input:not([value=""])) .select-label::after , #search_table .search-select.search-bailleur:has(.vscomp-wrapper.has-value .vscomp-value:not([data-tooltip=""])) .select-label::after, @@ -519,7 +531,6 @@ a[href].apilos-block-faqlink:after { .content__icons { width: 32px; height: 32px; - border-radius: 4px; margin: 0rem 0.2rem; } @@ -544,24 +555,42 @@ a[href].apilos-block-faqlink:after { color: #161616; } -/* Add and orange #FF6F4C */ +.content__icons--opened, +.content__icons--resolved, +.content__icons--closed, +.content__icons--add { + border: 2px solid #000091; + cursor: pointer; +} + +.content__icons--opened:hover, +.content__icons--resolved:hover, +.content__icons--closed:hover, +.content__icons--add:hover { + background-color: #f2f2f2; +} + +.content__icons--opened:active, +.content__icons--resolved:active, +.content__icons--closed:active, +.content__icons--add:active { + background-color: #cecece; +} + .content__icons--opened { - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); } -/* Opened and green #00a34d*/ .content__icons--resolved { - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); } -/* Closed and grey #9C9C9C */ .content__icons--closed { - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); } -/* Add and blue #0762C8 */ .content__icons--add { - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); } .fr-btn--green { @@ -925,6 +954,10 @@ table .apilos-badge { text-decoration: none; } +.fr-btn--tertiary-outline{ + border: solid 1px #000091 !important; +} + .fr-btn--secondary:hover { color: #000091 !important; text-decoration: none; @@ -939,6 +972,14 @@ table .apilos-badge { color: #000091 !important; } +.fr-btn--tertiary-no-outline:hover { + color: #000091 !important; +} + +.fr-btn--close { + color: #000091 !important; +} + /* * End Adapt button color when it is a link 'a' @@ -954,18 +995,27 @@ table .apilos-badge { .apilos-form-label--strike { text-decoration: line-through; } + .apilos-sticky { - position: -webkit-sticky; /* Safari */ + position: -webkit-sticky; position: sticky; z-index: 100; background-color: var(--background-default-grey); top: 0; } + +@media (max-height: 500px), (max-width: 991px) { + .apilos-sticky { + position: static; + } +} + .apilos-sticky-2 { - top: 141px; + top: calc(var(--header-height, 208px)); } + .fr-container-fluid:has(.apilos-notice--warning) .apilos-sticky-2 { - top: 213px; + top: calc(var(--header-height, 208px) + 5px); } .recapitulatif .recapitulatif-link { display: none; @@ -1138,4 +1188,334 @@ table .apilos-badge { .apilos-recap-subtitle { font-size: 1.2rem; +} + +button.fr-toggle-wrapper, +a.fr-toggle-wrapper { + background: transparent; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + display: block; + cursor: pointer; + width: 100%; + text-decoration: none; +} + +button.fr-toggle-wrapper:hover, +a.fr-toggle-wrapper:hover { + background: transparent; + color: inherit; + text-decoration: none; +} + +button.fr-toggle-wrapper input[type="checkbox"], +a.fr-toggle-wrapper input[type="checkbox"] { + pointer-events: none; +} + +.accessible-combobox-wrapper { + position: relative; +} + +.combobox-container { + position: relative; + width: 100%; +} + +.combobox-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.combobox-input { + flex: 1; + padding: 0.5rem 70px 0.5rem 1rem; + background-color: var(--background-contrast-grey, #eeeeee); + background-image: none; + border: none; + border-radius: 0.25rem 0.25rem 0 0; + box-shadow: inset 0 -2px 0 0 var(--border-plain-grey, #ddd); + font-size: 1rem; + line-height: 1.5rem; + color: var(--text-default-grey, #161616); +} + +.combobox-input:focus { + outline: 2px solid var(--border-plain-blue-france, #0063cb); + outline-offset: 2px; + box-shadow: inset 0 -2px 0 0 var(--border-plain-blue-france, #0063cb); +} + +.combobox-clear, +.combobox-toggle { + position: absolute; + right: 0; + background: transparent; + border: none; + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-action-high-blue-france, #000091); + height: 100%; +} + +.combobox-clear { + right: 32px; + font-size: 1.5rem; + line-height: 1; + font-weight: bold; +} + +.combobox-clear:hover, +.combobox-toggle:hover { + background-color: var(--background-action-low-blue-france-hover, #f5f5fe); +} + +.combobox-clear:focus, +.combobox-toggle:focus { + outline: 2px solid var(--border-plain-blue-france, #0063cb); + outline-offset: -2px; +} + +.combobox-toggle { + width: 32px; +} + +.combobox-listbox { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 300px; + overflow-y: auto; + background: white; + border: 1px solid var(--border-plain-grey, #ddd); + border-top: none; + margin: 0; + padding: 0; + list-style: none; + z-index: 1000; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.combobox-option { + padding: 0.75rem 1rem; + cursor: pointer; + color: var(--text-default-grey, #161616); +} + +.combobox-option:hover, +.combobox-option.focused { + background-color: var(--background-action-low-blue-france, #e3e3fd); +} + +.combobox-option[aria-selected="true"] { + background-color: var(--background-action-high-blue-france, #000091); + color: white; + font-weight: bold; +} + +.combobox-option.focused[aria-selected="true"] { + background-color: var(--background-action-high-blue-france-hover, #1212ff); +} + +.combobox-no-results { + color: var(--text-mention-grey, #666); + font-style: italic; + cursor: default; +} + +.combobox-no-results:hover { + background-color: transparent; +} + +.header-with-action { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.header-with-action__title { + display: flex; + align-items: center; + flex-wrap: wrap; + flex-shrink: 1; + min-width: 0; +} + +.info-row-with-action { + display: flex !important; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + justify-content: space-between; +} + +.info-row-with-action > *:not(.info-row-with-action__link) { + flex-shrink: 1; + min-width: 0; +} + +.info-row-with-action__link { + flex-shrink: 0; + margin-left: auto !important; +} + +.convention_title { + white-space: normal !important; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +.header-with-action__action { + margin-left: auto; +} + +.apilos-text-wrap { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +.user-banner { + background-color: #F5F5FE; + color:#000091; + border: solid 1px #000091; +} + +.fr-checkbox-group input[type=checkbox]+label:before { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.choices__inner { + background-color: #eee !important; + border: none !important; + margin: 0 !important; + padding: 0 !important; + min-height: 0 !important; + font-size: 1rem ! important; + font-style: italic !important; + border-radius: 0px !important; +} + +.choices.is-focused, +.choices:focus-within { + outline: 2px solid #0a76f6; + outline-offset: 2px; + box-shadow: inset 0 -2px 0 0 #000091 !important; +} + +.choices__input { + position: absolute !important; + width: 0 !important; + height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + font-size: 0 !important; + line-height: 0 !important; + overflow: hidden !important; +} + +.choices__list--multiple { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0 !important; + padding: 0 !important; +} + +.choices__list--multiple .choices__item { + background-color: transparent !important; + color: #3a3a3a !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + font-size: 1rem !important; + font-weight: normal !important; + font-style: normal !important; + display: inline-flex !important; + align-items: center !important; +} + +.choices__button { + background: none !important; + background-image: none !important; + border: none !important; + border-left: 0 !important; + color: #000091 !important; + opacity: 1 !important; + padding: 0 0.25rem 0 0.25rem !important; + margin: 0 !important; + margin-right: 0.5rem !important; + cursor: pointer; + font-size: 0rem !important; + display: inline-flex !important; + align-items: center !important; + width: auto !important; + height: auto !important; + text-indent: 0 !important; + overflow: visible !important; +} + +.choices__button::before { + content: "×" !important; + font-size: 1rem !important; + display: inline-block !important; + color: #000091 !important; +} + +.choices__button:hover, +.choices__button:focus { + color: #1212ff !important; + outline: 2px solid #0a76f6 !important; + outline-offset: 2px !important; + background-color: transparent !important; +} + +.choices__button:hover::before, +.choices__button:focus::before { + color: #1212ff !important; +} + +.choices__item--choice { + padding: 0.75rem 1rem !important; + font-size: 1rem !important; + line-height: 1.5rem !important; + color: #161616 !important; + background-color: white !important; +} + +.choices__item--choice.is-highlighted { + background-color: #e3e3fd !important; + color: #161616 !important; +} + +.choices__placeholder { + opacity: 1 !important; + color: #161616 !important; + display: block !important; + font-size: 1rem !important; + line-height: 1.5rem !important; +} + +.choices[data-type*="select-multiple"] .choices__placeholder { + display: none; +} + +.choices[data-type*="select-multiple"] .choices__inner:empty::before, +.choices[data-type*="select-multiple"] .choices__list--multiple:empty::before { + content: "Sélectionner"; + color: #161616; + font-size: 1rem; + line-height: 1.5rem; + opacity: 0.7; } \ No newline at end of file diff --git a/static/dropzonejs/min/dropzone-amd-module.min.js b/static/dropzonejs/min/dropzone-amd-module.min.js index 073db5e3a..06d9d26aa 100644 --- a/static/dropzonejs/min/dropzone-amd-module.min.js +++ b/static/dropzonejs/min/dropzone-amd-module.min.js @@ -1 +1 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var n=t();for(var r in n)("object"==typeof exports?exports:e)[r]=n[r]}}(self,(function(){return function(){var e={3099:function(e){e.exports=function(e){if("function"!=typeof e)throw TypeError(String(e)+" is not a function");return e}},6077:function(e,t,n){var r=n(111);e.exports=function(e){if(!r(e)&&null!==e)throw TypeError("Can't set "+String(e)+" as a prototype");return e}},1223:function(e,t,n){var r=n(5112),i=n(30),o=n(3070),a=r("unscopables"),u=Array.prototype;null==u[a]&&o.f(u,a,{configurable:!0,value:i(null)}),e.exports=function(e){u[a][e]=!0}},1530:function(e,t,n){"use strict";var r=n(8710).charAt;e.exports=function(e,t,n){return t+(n?r(e,t).length:1)}},5787:function(e){e.exports=function(e,t,n){if(!(e instanceof t))throw TypeError("Incorrect "+(n?n+" ":"")+"invocation");return e}},9670:function(e,t,n){var r=n(111);e.exports=function(e){if(!r(e))throw TypeError(String(e)+" is not an object");return e}},4019:function(e){e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},260:function(e,t,n){"use strict";var r,i=n(4019),o=n(9781),a=n(7854),u=n(111),s=n(6656),l=n(648),c=n(8880),f=n(1320),p=n(3070).f,h=n(9518),d=n(7674),v=n(5112),y=n(9711),g=a.Int8Array,m=g&&g.prototype,b=a.Uint8ClampedArray,x=b&&b.prototype,w=g&&h(g),E=m&&h(m),k=Object.prototype,A=k.isPrototypeOf,S=v("toStringTag"),F=y("TYPED_ARRAY_TAG"),T=i&&!!d&&"Opera"!==l(a.opera),C=!1,L={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},R={BigInt64Array:8,BigUint64Array:8},I=function(e){if(!u(e))return!1;var t=l(e);return s(L,t)||s(R,t)};for(r in L)a[r]||(T=!1);if((!T||"function"!=typeof w||w===Function.prototype)&&(w=function(){throw TypeError("Incorrect invocation")},T))for(r in L)a[r]&&d(a[r],w);if((!T||!E||E===k)&&(E=w.prototype,T))for(r in L)a[r]&&d(a[r].prototype,E);if(T&&h(x)!==E&&d(x,E),o&&!s(E,S))for(r in C=!0,p(E,S,{get:function(){return u(this)?this[F]:void 0}}),L)a[r]&&c(a[r],F,r);e.exports={NATIVE_ARRAY_BUFFER_VIEWS:T,TYPED_ARRAY_TAG:C&&F,aTypedArray:function(e){if(I(e))return e;throw TypeError("Target is not a typed array")},aTypedArrayConstructor:function(e){if(d){if(A.call(w,e))return e}else for(var t in L)if(s(L,r)){var n=a[t];if(n&&(e===n||A.call(n,e)))return e}throw TypeError("Target is not a typed array constructor")},exportTypedArrayMethod:function(e,t,n){if(o){if(n)for(var r in L){var i=a[r];i&&s(i.prototype,e)&&delete i.prototype[e]}E[e]&&!n||f(E,e,n?t:T&&m[e]||t)}},exportTypedArrayStaticMethod:function(e,t,n){var r,i;if(o){if(d){if(n)for(r in L)(i=a[r])&&s(i,e)&&delete i[e];if(w[e]&&!n)return;try{return f(w,e,n?t:T&&g[e]||t)}catch(e){}}for(r in L)!(i=a[r])||i[e]&&!n||f(i,e,t)}},isView:function(e){if(!u(e))return!1;var t=l(e);return"DataView"===t||s(L,t)||s(R,t)},isTypedArray:I,TypedArray:w,TypedArrayPrototype:E}},3331:function(e,t,n){"use strict";var r=n(7854),i=n(9781),o=n(4019),a=n(8880),u=n(2248),s=n(7293),l=n(5787),c=n(9958),f=n(7466),p=n(7067),h=n(1179),d=n(9518),v=n(7674),y=n(8006).f,g=n(3070).f,m=n(1285),b=n(8003),x=n(9909),w=x.get,E=x.set,k="ArrayBuffer",A="DataView",S="Wrong index",F=r.ArrayBuffer,T=F,C=r.DataView,L=C&&C.prototype,R=Object.prototype,I=r.RangeError,U=h.pack,O=h.unpack,_=function(e){return[255&e]},M=function(e){return[255&e,e>>8&255]},z=function(e){return[255&e,e>>8&255,e>>16&255,e>>24&255]},P=function(e){return e[3]<<24|e[2]<<16|e[1]<<8|e[0]},j=function(e){return U(e,23,4)},D=function(e){return U(e,52,8)},N=function(e,t){g(e.prototype,t,{get:function(){return w(this)[t]}})},B=function(e,t,n,r){var i=p(n),o=w(e);if(i+t>o.byteLength)throw I(S);var a=w(o.buffer).bytes,u=i+o.byteOffset,s=a.slice(u,u+t);return r?s:s.reverse()},q=function(e,t,n,r,i,o){var a=p(n),u=w(e);if(a+t>u.byteLength)throw I(S);for(var s=w(u.buffer).bytes,l=a+u.byteOffset,c=r(+i),f=0;fG;)(W=Y[G++])in T||a(T,W,F[W]);H.constructor=T}v&&d(L)!==R&&v(L,R);var Q=new C(new T(2)),$=L.setInt8;Q.setInt8(0,2147483648),Q.setInt8(1,2147483649),!Q.getInt8(0)&&Q.getInt8(1)||u(L,{setInt8:function(e,t){$.call(this,e,t<<24>>24)},setUint8:function(e,t){$.call(this,e,t<<24>>24)}},{unsafe:!0})}else T=function(e){l(this,T,k);var t=p(e);E(this,{bytes:m.call(new Array(t),0),byteLength:t}),i||(this.byteLength=t)},C=function(e,t,n){l(this,C,A),l(e,T,A);var r=w(e).byteLength,o=c(t);if(o<0||o>r)throw I("Wrong offset");if(o+(n=void 0===n?r-o:f(n))>r)throw I("Wrong length");E(this,{buffer:e,byteLength:n,byteOffset:o}),i||(this.buffer=e,this.byteLength=n,this.byteOffset=o)},i&&(N(T,"byteLength"),N(C,"buffer"),N(C,"byteLength"),N(C,"byteOffset")),u(C.prototype,{getInt8:function(e){return B(this,1,e)[0]<<24>>24},getUint8:function(e){return B(this,1,e)[0]},getInt16:function(e){var t=B(this,2,e,arguments.length>1?arguments[1]:void 0);return(t[1]<<8|t[0])<<16>>16},getUint16:function(e){var t=B(this,2,e,arguments.length>1?arguments[1]:void 0);return t[1]<<8|t[0]},getInt32:function(e){return P(B(this,4,e,arguments.length>1?arguments[1]:void 0))},getUint32:function(e){return P(B(this,4,e,arguments.length>1?arguments[1]:void 0))>>>0},getFloat32:function(e){return O(B(this,4,e,arguments.length>1?arguments[1]:void 0),23)},getFloat64:function(e){return O(B(this,8,e,arguments.length>1?arguments[1]:void 0),52)},setInt8:function(e,t){q(this,1,e,_,t)},setUint8:function(e,t){q(this,1,e,_,t)},setInt16:function(e,t){q(this,2,e,M,t,arguments.length>2?arguments[2]:void 0)},setUint16:function(e,t){q(this,2,e,M,t,arguments.length>2?arguments[2]:void 0)},setInt32:function(e,t){q(this,4,e,z,t,arguments.length>2?arguments[2]:void 0)},setUint32:function(e,t){q(this,4,e,z,t,arguments.length>2?arguments[2]:void 0)},setFloat32:function(e,t){q(this,4,e,j,t,arguments.length>2?arguments[2]:void 0)},setFloat64:function(e,t){q(this,8,e,D,t,arguments.length>2?arguments[2]:void 0)}});b(T,k),b(C,A),e.exports={ArrayBuffer:T,DataView:C}},1048:function(e,t,n){"use strict";var r=n(7908),i=n(1400),o=n(7466),a=Math.min;e.exports=[].copyWithin||function(e,t){var n=r(this),u=o(n.length),s=i(e,u),l=i(t,u),c=arguments.length>2?arguments[2]:void 0,f=a((void 0===c?u:i(c,u))-l,u-s),p=1;for(l0;)l in n?n[s]=n[l]:delete n[s],s+=p,l+=p;return n}},1285:function(e,t,n){"use strict";var r=n(7908),i=n(1400),o=n(7466);e.exports=function(e){for(var t=r(this),n=o(t.length),a=arguments.length,u=i(a>1?arguments[1]:void 0,n),s=a>2?arguments[2]:void 0,l=void 0===s?n:i(s,n);l>u;)t[u++]=e;return t}},8533:function(e,t,n){"use strict";var r=n(2092).forEach,i=n(9341)("forEach");e.exports=i?[].forEach:function(e){return r(this,e,arguments.length>1?arguments[1]:void 0)}},8457:function(e,t,n){"use strict";var r=n(9974),i=n(7908),o=n(3411),a=n(7659),u=n(7466),s=n(6135),l=n(1246);e.exports=function(e){var t,n,c,f,p,h,d=i(e),v="function"==typeof this?this:Array,y=arguments.length,g=y>1?arguments[1]:void 0,m=void 0!==g,b=l(d),x=0;if(m&&(g=r(g,y>2?arguments[2]:void 0,2)),null==b||v==Array&&a(b))for(n=new v(t=u(d.length));t>x;x++)h=m?g(d[x],x):d[x],s(n,x,h);else for(p=(f=b.call(d)).next,n=new v;!(c=p.call(f)).done;x++)h=m?o(f,g,[c.value,x],!0):c.value,s(n,x,h);return n.length=x,n}},1318:function(e,t,n){var r=n(5656),i=n(7466),o=n(1400),a=function(e){return function(t,n,a){var u,s=r(t),l=i(s.length),c=o(a,l);if(e&&n!=n){for(;l>c;)if((u=s[c++])!=u)return!0}else for(;l>c;c++)if((e||c in s)&&s[c]===n)return e||c||0;return!e&&-1}};e.exports={includes:a(!0),indexOf:a(!1)}},2092:function(e,t,n){var r=n(9974),i=n(8361),o=n(7908),a=n(7466),u=n(5417),s=[].push,l=function(e){var t=1==e,n=2==e,l=3==e,c=4==e,f=6==e,p=7==e,h=5==e||f;return function(d,v,y,g){for(var m,b,x=o(d),w=i(x),E=r(v,y,3),k=a(w.length),A=0,S=g||u,F=t?S(d,k):n||p?S(d,0):void 0;k>A;A++)if((h||A in w)&&(b=E(m=w[A],A,x),e))if(t)F[A]=b;else if(b)switch(e){case 3:return!0;case 5:return m;case 6:return A;case 2:s.call(F,m)}else switch(e){case 4:return!1;case 7:s.call(F,m)}return f?-1:l||c?c:F}};e.exports={forEach:l(0),map:l(1),filter:l(2),some:l(3),every:l(4),find:l(5),findIndex:l(6),filterOut:l(7)}},6583:function(e,t,n){"use strict";var r=n(5656),i=n(9958),o=n(7466),a=n(9341),u=Math.min,s=[].lastIndexOf,l=!!s&&1/[1].lastIndexOf(1,-0)<0,c=a("lastIndexOf"),f=l||!c;e.exports=f?function(e){if(l)return s.apply(this,arguments)||0;var t=r(this),n=o(t.length),a=n-1;for(arguments.length>1&&(a=u(a,i(arguments[1]))),a<0&&(a=n+a);a>=0;a--)if(a in t&&t[a]===e)return a||0;return-1}:s},1194:function(e,t,n){var r=n(7293),i=n(5112),o=n(7392),a=i("species");e.exports=function(e){return o>=51||!r((function(){var t=[];return(t.constructor={})[a]=function(){return{foo:1}},1!==t[e](Boolean).foo}))}},9341:function(e,t,n){"use strict";var r=n(7293);e.exports=function(e,t){var n=[][e];return!!n&&r((function(){n.call(null,t||function(){throw 1},1)}))}},3671:function(e,t,n){var r=n(3099),i=n(7908),o=n(8361),a=n(7466),u=function(e){return function(t,n,u,s){r(n);var l=i(t),c=o(l),f=a(l.length),p=e?f-1:0,h=e?-1:1;if(u<2)for(;;){if(p in c){s=c[p],p+=h;break}if(p+=h,e?p<0:f<=p)throw TypeError("Reduce of empty array with no initial value")}for(;e?p>=0:f>p;p+=h)p in c&&(s=n(s,c[p],p,l));return s}};e.exports={left:u(!1),right:u(!0)}},5417:function(e,t,n){var r=n(111),i=n(3157),o=n(5112)("species");e.exports=function(e,t){var n;return i(e)&&("function"!=typeof(n=e.constructor)||n!==Array&&!i(n.prototype)?r(n)&&null===(n=n[o])&&(n=void 0):n=void 0),new(void 0===n?Array:n)(0===t?0:t)}},3411:function(e,t,n){var r=n(9670),i=n(9212);e.exports=function(e,t,n,o){try{return o?t(r(n)[0],n[1]):t(n)}catch(t){throw i(e),t}}},7072:function(e,t,n){var r=n(5112)("iterator"),i=!1;try{var o=0,a={next:function(){return{done:!!o++}},return:function(){i=!0}};a[r]=function(){return this},Array.from(a,(function(){throw 2}))}catch(e){}e.exports=function(e,t){if(!t&&!i)return!1;var n=!1;try{var o={};o[r]=function(){return{next:function(){return{done:n=!0}}}},e(o)}catch(e){}return n}},4326:function(e){var t={}.toString;e.exports=function(e){return t.call(e).slice(8,-1)}},648:function(e,t,n){var r=n(1694),i=n(4326),o=n(5112)("toStringTag"),a="Arguments"==i(function(){return arguments}());e.exports=r?i:function(e){var t,n,r;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=function(e,t){try{return e[t]}catch(e){}}(t=Object(e),o))?n:a?i(t):"Object"==(r=i(t))&&"function"==typeof t.callee?"Arguments":r}},9920:function(e,t,n){var r=n(6656),i=n(3887),o=n(1236),a=n(3070);e.exports=function(e,t){for(var n=i(t),u=a.f,s=o.f,l=0;l=74)&&(r=a.match(/Chrome\/(\d+)/))&&(i=r[1]),e.exports=i&&+i},748:function(e){e.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},2109:function(e,t,n){var r=n(7854),i=n(1236).f,o=n(8880),a=n(1320),u=n(3505),s=n(9920),l=n(4705);e.exports=function(e,t){var n,c,f,p,h,d=e.target,v=e.global,y=e.stat;if(n=v?r:y?r[d]||u(d,{}):(r[d]||{}).prototype)for(c in t){if(p=t[c],f=e.noTargetGet?(h=i(n,c))&&h.value:n[c],!l(v?c:d+(y?".":"#")+c,e.forced)&&void 0!==f){if(typeof p==typeof f)continue;s(p,f)}(e.sham||f&&f.sham)&&o(p,"sham",!0),a(n,c,p,e)}}},7293:function(e){e.exports=function(e){try{return!!e()}catch(e){return!0}}},7007:function(e,t,n){"use strict";n(4916);var r=n(1320),i=n(7293),o=n(5112),a=n(2261),u=n(8880),s=o("species"),l=!i((function(){var e=/./;return e.exec=function(){var e=[];return e.groups={a:"7"},e},"7"!=="".replace(e,"$")})),c="$0"==="a".replace(/./,"$0"),f=o("replace"),p=!!/./[f]&&""===/./[f]("a","$0"),h=!i((function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var n="ab".split(e);return 2!==n.length||"a"!==n[0]||"b"!==n[1]}));e.exports=function(e,t,n,f){var d=o(e),v=!i((function(){var t={};return t[d]=function(){return 7},7!=""[e](t)})),y=v&&!i((function(){var t=!1,n=/a/;return"split"===e&&((n={}).constructor={},n.constructor[s]=function(){return n},n.flags="",n[d]=/./[d]),n.exec=function(){return t=!0,null},n[d](""),!t}));if(!v||!y||"replace"===e&&(!l||!c||p)||"split"===e&&!h){var g=/./[d],m=n(d,""[e],(function(e,t,n,r,i){return t.exec===a?v&&!i?{done:!0,value:g.call(t,n,r)}:{done:!0,value:e.call(n,t,r)}:{done:!1}}),{REPLACE_KEEPS_$0:c,REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE:p}),b=m[0],x=m[1];r(String.prototype,e,b),r(RegExp.prototype,d,2==t?function(e,t){return x.call(e,this,t)}:function(e){return x.call(e,this)})}f&&u(RegExp.prototype[d],"sham",!0)}},9974:function(e,t,n){var r=n(3099);e.exports=function(e,t,n){if(r(e),void 0===t)return e;switch(n){case 0:return function(){return e.call(t)};case 1:return function(n){return e.call(t,n)};case 2:return function(n,r){return e.call(t,n,r)};case 3:return function(n,r,i){return e.call(t,n,r,i)}}return function(){return e.apply(t,arguments)}}},5005:function(e,t,n){var r=n(857),i=n(7854),o=function(e){return"function"==typeof e?e:void 0};e.exports=function(e,t){return arguments.length<2?o(r[e])||o(i[e]):r[e]&&r[e][t]||i[e]&&i[e][t]}},1246:function(e,t,n){var r=n(648),i=n(7497),o=n(5112)("iterator");e.exports=function(e){if(null!=e)return e[o]||e["@@iterator"]||i[r(e)]}},8554:function(e,t,n){var r=n(9670),i=n(1246);e.exports=function(e){var t=i(e);if("function"!=typeof t)throw TypeError(String(e)+" is not iterable");return r(t.call(e))}},647:function(e,t,n){var r=n(7908),i=Math.floor,o="".replace,a=/\$([$&'`]|\d\d?|<[^>]*>)/g,u=/\$([$&'`]|\d\d?)/g;e.exports=function(e,t,n,s,l,c){var f=n+e.length,p=s.length,h=u;return void 0!==l&&(l=r(l),h=a),o.call(c,h,(function(r,o){var a;switch(o.charAt(0)){case"$":return"$";case"&":return e;case"`":return t.slice(0,n);case"'":return t.slice(f);case"<":a=l[o.slice(1,-1)];break;default:var u=+o;if(0===u)return r;if(u>p){var c=i(u/10);return 0===c?r:c<=p?void 0===s[c-1]?o.charAt(1):s[c-1]+o.charAt(1):r}a=s[u-1]}return void 0===a?"":a}))}},7854:function(e,t,n){var r=function(e){return e&&e.Math==Math&&e};e.exports=r("object"==typeof globalThis&&globalThis)||r("object"==typeof window&&window)||r("object"==typeof self&&self)||r("object"==typeof n.g&&n.g)||function(){return this}()||Function("return this")()},6656:function(e){var t={}.hasOwnProperty;e.exports=function(e,n){return t.call(e,n)}},3501:function(e){e.exports={}},490:function(e,t,n){var r=n(5005);e.exports=r("document","documentElement")},4664:function(e,t,n){var r=n(9781),i=n(7293),o=n(317);e.exports=!r&&!i((function(){return 7!=Object.defineProperty(o("div"),"a",{get:function(){return 7}}).a}))},1179:function(e){var t=Math.abs,n=Math.pow,r=Math.floor,i=Math.log,o=Math.LN2;e.exports={pack:function(e,a,u){var s,l,c,f=new Array(u),p=8*u-a-1,h=(1<>1,v=23===a?n(2,-24)-n(2,-77):0,y=e<0||0===e&&1/e<0?1:0,g=0;for((e=t(e))!=e||e===1/0?(l=e!=e?1:0,s=h):(s=r(i(e)/o),e*(c=n(2,-s))<1&&(s--,c*=2),(e+=s+d>=1?v/c:v*n(2,1-d))*c>=2&&(s++,c/=2),s+d>=h?(l=0,s=h):s+d>=1?(l=(e*c-1)*n(2,a),s+=d):(l=e*n(2,d-1)*n(2,a),s=0));a>=8;f[g++]=255&l,l/=256,a-=8);for(s=s<0;f[g++]=255&s,s/=256,p-=8);return f[--g]|=128*y,f},unpack:function(e,t){var r,i=e.length,o=8*i-t-1,a=(1<>1,s=o-7,l=i-1,c=e[l--],f=127&c;for(c>>=7;s>0;f=256*f+e[l],l--,s-=8);for(r=f&(1<<-s)-1,f>>=-s,s+=t;s>0;r=256*r+e[l],l--,s-=8);if(0===f)f=1-u;else{if(f===a)return r?NaN:c?-1/0:1/0;r+=n(2,t),f-=u}return(c?-1:1)*r*n(2,f-t)}}},8361:function(e,t,n){var r=n(7293),i=n(4326),o="".split;e.exports=r((function(){return!Object("z").propertyIsEnumerable(0)}))?function(e){return"String"==i(e)?o.call(e,""):Object(e)}:Object},9587:function(e,t,n){var r=n(111),i=n(7674);e.exports=function(e,t,n){var o,a;return i&&"function"==typeof(o=t.constructor)&&o!==n&&r(a=o.prototype)&&a!==n.prototype&&i(e,a),e}},2788:function(e,t,n){var r=n(5465),i=Function.toString;"function"!=typeof r.inspectSource&&(r.inspectSource=function(e){return i.call(e)}),e.exports=r.inspectSource},9909:function(e,t,n){var r,i,o,a=n(8536),u=n(7854),s=n(111),l=n(8880),c=n(6656),f=n(5465),p=n(6200),h=n(3501),d=u.WeakMap;if(a){var v=f.state||(f.state=new d),y=v.get,g=v.has,m=v.set;r=function(e,t){return t.facade=e,m.call(v,e,t),t},i=function(e){return y.call(v,e)||{}},o=function(e){return g.call(v,e)}}else{var b=p("state");h[b]=!0,r=function(e,t){return t.facade=e,l(e,b,t),t},i=function(e){return c(e,b)?e[b]:{}},o=function(e){return c(e,b)}}e.exports={set:r,get:i,has:o,enforce:function(e){return o(e)?i(e):r(e,{})},getterFor:function(e){return function(t){var n;if(!s(t)||(n=i(t)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return n}}}},7659:function(e,t,n){var r=n(5112),i=n(7497),o=r("iterator"),a=Array.prototype;e.exports=function(e){return void 0!==e&&(i.Array===e||a[o]===e)}},3157:function(e,t,n){var r=n(4326);e.exports=Array.isArray||function(e){return"Array"==r(e)}},4705:function(e,t,n){var r=n(7293),i=/#|\.prototype\./,o=function(e,t){var n=u[a(e)];return n==l||n!=s&&("function"==typeof t?r(t):!!t)},a=o.normalize=function(e){return String(e).replace(i,".").toLowerCase()},u=o.data={},s=o.NATIVE="N",l=o.POLYFILL="P";e.exports=o},111:function(e){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},1913:function(e){e.exports=!1},7850:function(e,t,n){var r=n(111),i=n(4326),o=n(5112)("match");e.exports=function(e){var t;return r(e)&&(void 0!==(t=e[o])?!!t:"RegExp"==i(e))}},9212:function(e,t,n){var r=n(9670);e.exports=function(e){var t=e.return;if(void 0!==t)return r(t.call(e)).value}},3383:function(e,t,n){"use strict";var r,i,o,a=n(7293),u=n(9518),s=n(8880),l=n(6656),c=n(5112),f=n(1913),p=c("iterator"),h=!1;[].keys&&("next"in(o=[].keys())?(i=u(u(o)))!==Object.prototype&&(r=i):h=!0);var d=null==r||a((function(){var e={};return r[p].call(e)!==e}));d&&(r={}),f&&!d||l(r,p)||s(r,p,(function(){return this})),e.exports={IteratorPrototype:r,BUGGY_SAFARI_ITERATORS:h}},7497:function(e){e.exports={}},133:function(e,t,n){var r=n(7293);e.exports=!!Object.getOwnPropertySymbols&&!r((function(){return!String(Symbol())}))},590:function(e,t,n){var r=n(7293),i=n(5112),o=n(1913),a=i("iterator");e.exports=!r((function(){var e=new URL("b?a=1&b=2&c=3","http://a"),t=e.searchParams,n="";return e.pathname="c%20d",t.forEach((function(e,r){t.delete("b"),n+=r+e})),o&&!e.toJSON||!t.sort||"http://a/c%20d?a=1&c=3"!==e.href||"3"!==t.get("c")||"a=1"!==String(new URLSearchParams("?a=1"))||!t[a]||"a"!==new URL("https://a@b").username||"b"!==new URLSearchParams(new URLSearchParams("a=b")).get("a")||"xn--e1aybc"!==new URL("http://тест").host||"#%D0%B1"!==new URL("http://a#б").hash||"a1c3"!==n||"x"!==new URL("http://x",void 0).host}))},8536:function(e,t,n){var r=n(7854),i=n(2788),o=r.WeakMap;e.exports="function"==typeof o&&/native code/.test(i(o))},1574:function(e,t,n){"use strict";var r=n(9781),i=n(7293),o=n(1956),a=n(5181),u=n(5296),s=n(7908),l=n(8361),c=Object.assign,f=Object.defineProperty;e.exports=!c||i((function(){if(r&&1!==c({b:1},c(f({},"a",{enumerable:!0,get:function(){f(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var e={},t={},n=Symbol(),i="abcdefghijklmnopqrst";return e[n]=7,i.split("").forEach((function(e){t[e]=e})),7!=c({},e)[n]||o(c({},t)).join("")!=i}))?function(e,t){for(var n=s(e),i=arguments.length,c=1,f=a.f,p=u.f;i>c;)for(var h,d=l(arguments[c++]),v=f?o(d).concat(f(d)):o(d),y=v.length,g=0;y>g;)h=v[g++],r&&!p.call(d,h)||(n[h]=d[h]);return n}:c},30:function(e,t,n){var r,i=n(9670),o=n(6048),a=n(748),u=n(3501),s=n(490),l=n(317),c=n(6200)("IE_PROTO"),f=function(){},p=function(e){return" {% endif %} diff --git a/templates/common/display_checkbox.html b/templates/common/display_checkbox.html index 30bfd2a5e..95db87c81 100644 --- a/templates/common/display_checkbox.html +++ b/templates/common/display_checkbox.html @@ -2,9 +2,8 @@
-
- - Checkbox +
- {{ label }} +

+ Case + {{ label }} + {% if value %} + Coché + {% else %} + Non coché + {% endif %} +

@@ -20,7 +27,7 @@
{% else %}
-
+
- {{ label }} +

+ Case + {{ label }} + {% if value %} + Coché + {% else %} + Non coché + {% endif %} +

{% endif %} diff --git a/templates/common/form/_input_search_select_core.html b/templates/common/form/_input_search_select_core.html new file mode 100644 index 000000000..f8e701bea --- /dev/null +++ b/templates/common/form/_input_search_select_core.html @@ -0,0 +1,579 @@ +
+ {% if label %} + + {% endif %} + +
+ {# Champ caché pour la soumission du formulaire #} + + +
+
+ + + + + +
+ + + +
+
+
+ + {% if form_input.errors %} + {% for error in form_input.errors %} +

+ {{ error }} +

+ {% endfor %} + {% endif %} +
+ + diff --git a/templates/common/form/download_upload_form.html b/templates/common/form/download_upload_form.html index dbc52e8c9..b18f98c98 100644 --- a/templates/common/form/download_upload_form.html +++ b/templates/common/form/download_upload_form.html @@ -15,8 +15,15 @@ name="{{ upform.file.html_name }}" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
- + Télécharger le modèle des {{ what }} + {% if hidden_what %} + {{ hidden_what }} + {% endif %}
Puis
@@ -24,6 +31,9 @@
{% for error in upform.file.errors %}

@@ -45,6 +55,14 @@ input.setAttribute('value', {% if file_field_label %}"{{ file_field_label }}"{% else %}'True'{% endif %});//set the value this.form.appendChild(input) this.form.submit() - } + }; + document.querySelectorAll('a[role="button"]').forEach(function(btn) { + btn.addEventListener('keydown', function(event) { + if (event.key === ' ' || event.key === 'Spacebar') { + event.preventDefault(); + this.click(); + } + }); + }); {% endif %} diff --git a/templates/common/form/input_financement.html b/templates/common/form/input_financement.html new file mode 100644 index 000000000..d29a9e68d --- /dev/null +++ b/templates/common/form/input_financement.html @@ -0,0 +1,29 @@ +{% load static %} + +

+ {% if form_input.label %} + + {% endif %} + {{ form_input.html_name }} +
+ + {% include "common/utils/comments_field.html" %} +
+ {% for error in form_input.errors %} +

+ {{ error }} +

+ {% endfor %} +
diff --git a/templates/common/form/input_multiselect.html b/templates/common/form/input_multiselect.html deleted file mode 100644 index 72bbc5432..000000000 --- a/templates/common/form/input_multiselect.html +++ /dev/null @@ -1,40 +0,0 @@ -
- {% if form_input.label %} - - {% endif %} - -
- - {% include "common/utils/comments_field.html" %} -
- - - {% for error in form_input.errors %} -

- {{ error }} -

- {% endfor %} -
diff --git a/templates/common/form/input_multiselect_choices.html b/templates/common/form/input_multiselect_choices.html new file mode 100644 index 000000000..0f05b7445 --- /dev/null +++ b/templates/common/form/input_multiselect_choices.html @@ -0,0 +1,61 @@ +
+ + +
+ + diff --git a/templates/common/form/input_number.html b/templates/common/form/input_number.html index 6b128372f..a5e8199c3 100644 --- a/templates/common/form/input_number.html +++ b/templates/common/form/input_number.html @@ -5,6 +5,9 @@ {% endif %} diff --git a/templates/common/form/input_search_select.html b/templates/common/form/input_search_select.html deleted file mode 100644 index 44cd4a47b..000000000 --- a/templates/common/form/input_search_select.html +++ /dev/null @@ -1,42 +0,0 @@ -
- {% if form_input.label %} - - {% endif %} - -
- - {% include "common/utils/comments_field.html" %} -
- - {% for error in form_input.errors %} -

- {{ error }} -

- {% endfor %} -
diff --git a/templates/common/form/input_search_select_unified.html b/templates/common/form/input_search_select_unified.html new file mode 100644 index 000000000..b1499a880 --- /dev/null +++ b/templates/common/form/input_search_select_unified.html @@ -0,0 +1,11 @@ +{# Composant unifié pour search select - supporte à la fois form_input et paramètres natifs #} + +{% if form_input %} + {# Mode form : extraire les valeurs du form_input #} + {% with field_id=form_input.id_for_label field_name=form_input.html_name label=form_input.label mandatory_input=form_input.field.required help_text=form_input.field.help_text %} + {% include "common/form/_input_search_select_core.html" %} + {% endwith %} +{% else %} + {# Mode natif : utiliser les paramètres passés directement #} + {% include "common/form/_input_search_select_core.html" %} +{% endif %} diff --git a/templates/common/form/input_upload.html b/templates/common/form/input_upload.html index 97e4055e0..4eef1921d 100644 --- a/templates/common/form/input_upload.html +++ b/templates/common/form/input_upload.html @@ -49,11 +49,16 @@ et/ou {% endif %} - @@ -67,7 +72,8 @@
+ id="{{ form_input_files.id_for_label }}_dropzone" + >
Votre navigateur ne permet pas de déposer des documents. Nous vous conseillons de modifier vos paramètres concernant diff --git a/templates/common/help_field.html b/templates/common/help_field.html index c94ccda0c..3746f2577 100644 --- a/templates/common/help_field.html +++ b/templates/common/help_field.html @@ -1,3 +1,7 @@ {% if form_input.help_text %} - {{ form_input.help_text|safe|linebreaksbr }} +

+ + {{ form_input.help_text|safe|linebreaksbr }} + +

{% endif %} diff --git a/templates/common/nav_bar.html b/templates/common/nav_bar.html index 3cdd2ef23..e13fb48b2 100644 --- a/templates/common/nav_bar.html +++ b/templates/common/nav_bar.html @@ -3,7 +3,7 @@ {% if convention|display_is_validated %}
-
{% endif %} diff --git a/templates/common/table/pagination.html b/templates/common/table/pagination.html index 73b299890..910be53ab 100644 --- a/templates/common/table/pagination.html +++ b/templates/common/table/pagination.html @@ -70,7 +70,7 @@ {% endif %}
  • -
  • diff --git a/templates/conventions/_bailleur_administration.html b/templates/conventions/_bailleur_administration.html index fc7439d20..199baafc9 100644 --- a/templates/conventions/_bailleur_administration.html +++ b/templates/conventions/_bailleur_administration.html @@ -13,19 +13,24 @@
    - Ce n'est pas la bonne administration ? +

    Ce n'est pas la bonne administration ?

    - Vous pouvez changer l'administration de cette convention.
    - cette opération est irréversible et vous fera perdre l'accès à cette convention. + Vous pouvez changer l'administration de cette convention.
    + Cette opération est irréversible et vous fera perdre l'accès à cette convention.

    -
    - +
    + + {% with convention_uuid_str=convention.uuid|stringformat:"s" %} {% url 'users:search_administration' as search_administration_url %} - {% include "common/form/input_search_select.html" with form_input=form.administration url=search_administration_url %} + {% include "common/form/input_search_select_unified.html" with form_input=form.administration url=search_administration_url %} {% endwith %}
    {% if bailleur.adresse %} -
    {{ bailleur.adresse }}
    +
    +
    {{ bailleur.adresse }}
    +
    {% endif %}
    - {% if bailleur.code_postal %}{{ bailleur.code_postal }}{% endif %}  - {% if bailleur.ville %}{{ bailleur.ville }}{% endif %} +
    + {% if bailleur.code_postal %}{{ bailleur.code_postal }}{% endif %}  + {% if bailleur.ville %}{{ bailleur.ville }}{% endif %} +
    @@ -30,16 +34,25 @@
    -
    Ce n'est pas le bon bailleur ?
    -
    Vous pouvez demander l'attribution de cette convention à un autre bailleur.
    +

    + Ce n'est pas le bon bailleur ? +

    +

    + Vous pouvez demander l'attribution de cette convention à un autre bailleur. +

    -
    - +
    + +
    {% url 'users:search_bailleur' as search_bailleur_url %} - {% include "common/form/input_search_select.html" with form_input=form.bailleur url=search_bailleur_url %} + {% include "common/form/input_search_select_unified.html" with form_input=form.bailleur url=search_bailleur_url %} @@ -48,17 +61,38 @@
    {% endif %} -
    - Vous pouvez{% if form.bailleur.field.choices %} également{% endif %} mettre à jour les informations de votre bailleur, si celles-ci sont erronées, dans - - votre espace d'administration - - +
    +

    + Vous pouvez{% if form.bailleur.field.choices %} également{% endif %} mettre à jour les informations de votre bailleur, si celles-ci sont erronées, dans + + votre espace d'administration + + +

    {% endwith %} {% endwith %} {% endblock content %} + + diff --git a/templates/conventions/_bailleur_signataire.html b/templates/conventions/_bailleur_signataire.html index bf93ed59d..d8d78998c 100644 --- a/templates/conventions/_bailleur_signataire.html +++ b/templates/conventions/_bailleur_signataire.html @@ -22,6 +22,8 @@ {% endwith %} {% endif %} + {% include "common/required_fields_info.html" %} +
    {% include "common/form/input_text.html" with mandatory_input=True form_input=form.signataire_nom object_field="bailleur__signataire_nom__"|add:form.uuid.value %} @@ -95,5 +97,4 @@

    Gestionnaire des logements conventionnés

    } {% endif %} - {% include "common/required_fields_info.html" %} {% endblock content %} diff --git a/templates/conventions/_convention_redirect_script.html b/templates/conventions/_convention_redirect_script.html new file mode 100644 index 000000000..8c1822287 --- /dev/null +++ b/templates/conventions/_convention_redirect_script.html @@ -0,0 +1,38 @@ + diff --git a/templates/conventions/actions/back_to_instruction.html b/templates/conventions/actions/back_to_instruction.html index 2494f39de..f85ab21ef 100644 --- a/templates/conventions/actions/back_to_instruction.html +++ b/templates/conventions/actions/back_to_instruction.html @@ -5,16 +5,16 @@

    - Repasser {{ convention|display_kind_with_demonstratif }} au statut "A instruire" + Repasser {{ convention|display_kind_with_demonstratif }} au statut "À instruire"

    {{ convention|display_kind_with_demonstratif }} est {% if convention|display_redirect_sent %}en attente de signature{% else %}déjà signé{{ convention|display_gender_terminaison }}{% endif %}. Cependant, - s'il reste des modifications à apporter, en cas de refus du Service de la publicité foncière par exemple, vous avez la possibilité de {{ convention|display_pronom }} repasser en statut "A instruire" + s'il reste des modifications à apporter, en cas de refus du Service de la publicité foncière par exemple, vous avez la possibilité de {{ convention|display_pronom }} repasser en statut "À instruire" en cliquant sur le bouton ci-dessous

    - Une fois {{ convention|display_kind_with_pronom }} en statut "A instruire", c'est l'instructeur qui aura la main sur {{ convention|display_kind_with_pronom }} (c'est à dire vous-même). + Une fois {{ convention|display_kind_with_pronom }} en statut "À instruire", c'est l'instructeur qui aura la main sur {{ convention|display_kind_with_pronom }} (c'est à dire vous-même). Si l'intervention du bailleur est nécessaire, vous devrez alors lui renvoyer en cliquant sur le bouton "Demander des corrections au bailleur"

    diff --git a/templates/conventions/actions/bailleur_notification.html b/templates/conventions/actions/bailleur_notification.html index 907b74d52..f65c68c0c 100644 --- a/templates/conventions/actions/bailleur_notification.html +++ b/templates/conventions/actions/bailleur_notification.html @@ -1,11 +1,11 @@ {% load custom_filters %} {% if convention|display_notification_new_convention_instructeur_to_bailleur:request %} -
    +
    -
    {% endif %} {% if convention|display_notification_instructeur_to_bailleur:request %} -
    +
    -
    {% endif %} diff --git a/templates/conventions/actions/cancel_convention.html b/templates/conventions/actions/cancel_convention.html index ce30f603a..05cdc361b 100644 --- a/templates/conventions/actions/cancel_convention.html +++ b/templates/conventions/actions/cancel_convention.html @@ -2,7 +2,7 @@ {% if convention|display_cancel_convention %}
    -

    Annuler {{ convention|display_kind_with_pronom }}

    +

    Annuler {{ convention|display_kind_with_pronom }}

    diff --git a/templates/conventions/actions/delete.html b/templates/conventions/actions/delete.html index eeaa3691a..13a101ef6 100644 --- a/templates/conventions/actions/delete.html +++ b/templates/conventions/actions/delete.html @@ -3,9 +3,9 @@ {% if convention|display_delete_convention %}

    -

    +

    Supprimer ce projet {{ convention|display_kind_with_preposition }} -

    +

    {{ convention|display_kind_with_demonstratif|capfirst }} est annulé{{ convention|display_gender_terminaison }}, il vous est possible de {{ convention|display_pronom }} supprimer définitivement. Vous perdrez alors tous les informations liées à {{ convention|display_kind_with_demonstratif }}.

    diff --git a/templates/conventions/actions/display_resiliation_info.html b/templates/conventions/actions/display_resiliation_info.html index 1a7641195..d8afc3b17 100644 --- a/templates/conventions/actions/display_resiliation_info.html +++ b/templates/conventions/actions/display_resiliation_info.html @@ -1,7 +1,7 @@ {% if convention|display_is_resiliated %}

    diff --git a/templates/conventions/actions/display_spf_info.html b/templates/conventions/actions/display_spf_info.html index 619fa2412..f6f785e30 100644 --- a/templates/conventions/actions/display_spf_info.html +++ b/templates/conventions/actions/display_spf_info.html @@ -3,16 +3,16 @@ {% if convention|display_spf_info %}
    diff --git a/templates/conventions/actions/fiche_caf.html b/templates/conventions/actions/fiche_caf.html index 2d7b8b2e7..553be4c8b 100644 --- a/templates/conventions/actions/fiche_caf.html +++ b/templates/conventions/actions/fiche_caf.html @@ -1,9 +1,9 @@
    -

    +

    Générer la fiche CAF -

    +

    La fiche CAF sera générée en format docx et téléchargée automatiquement.

    diff --git a/templates/conventions/actions/generate_convention.html b/templates/conventions/actions/generate_convention.html index dd563839c..2facad031 100644 --- a/templates/conventions/actions/generate_convention.html +++ b/templates/conventions/actions/generate_convention.html @@ -2,9 +2,9 @@ {% if convention|display_convention_form_progressbar %}
    -

    +

    Télécharger le projet {{ convention|display_kind_with_preposition }} (en docx) -

    +

    {% if convention|display_deactivated_because_type1and2_config_is_needed %}
    @@ -14,6 +14,7 @@
    {% else %}
    @@ -26,6 +27,7 @@ {% csrf_token %} {% endif %} diff --git a/templates/conventions/actions/goto.html b/templates/conventions/actions/goto.html index 71a45a4a1..35eb07744 100644 --- a/templates/conventions/actions/goto.html +++ b/templates/conventions/actions/goto.html @@ -1,5 +1,5 @@ {% if convention|display_convention_form_progressbar or request.session.is_expert and convention.statut == CONVENTION_STATUT.SIGNEE %} - + Aller à cette étape {% endif %} diff --git a/templates/conventions/actions/instructeur_validation.html b/templates/conventions/actions/instructeur_validation.html index c736557d3..0aa9e9870 100644 --- a/templates/conventions/actions/instructeur_validation.html +++ b/templates/conventions/actions/instructeur_validation.html @@ -4,7 +4,7 @@ diff --git a/templates/conventions/actions/validate_denonciation.html b/templates/conventions/actions/validate_denonciation.html index 2d68abe70..02d769a8d 100644 --- a/templates/conventions/actions/validate_denonciation.html +++ b/templates/conventions/actions/validate_denonciation.html @@ -1,7 +1,7 @@ {% load custom_filters %} {% if convention|display_validation:request %}