diff --git a/impact/impact/settings.py b/impact/impact/settings.py index 8903ff285..69ab86aba 100644 --- a/impact/impact/settings.py +++ b/impact/impact/settings.py @@ -271,6 +271,8 @@ "data:", # some images are defined this way # matomo : "stats.beta.gouv.fr", + # S3 scaleway : + "sites-faciles.s3.fr-par.scw.cloud", ], "style-src": [ SELF, diff --git a/impact/reglementations/enums.py b/impact/reglementations/enums.py index 35af3ca40..4e9bfd40e 100644 --- a/impact/reglementations/enums.py +++ b/impact/reglementations/enums.py @@ -390,6 +390,7 @@ class EtapeCSRD: "selection-enjeux", "analyse-materialite", "collection-donnees-entreprise", + "redaction-rapport-durabilite", ] @classmethod @@ -433,4 +434,8 @@ def get(cls, id_etape): id="collection-donnees-entreprise", nom="Collecter les données de son entreprise", ), + EtapeCSRD( + id="redaction-rapport-durabilite", + nom="Rédiger son rapport de durabilité", + ), ] diff --git a/impact/reglementations/forms/csrd.py b/impact/reglementations/forms/csrd.py index 6a390ba6a..2d2fbed89 100644 --- a/impact/reglementations/forms/csrd.py +++ b/impact/reglementations/forms/csrd.py @@ -25,13 +25,17 @@ def __init__(self, *args, esrs: str = None, **kwargs): if self.instance: qs = self.instance.enjeux_par_esrs(self.esrs) self.fields["enjeux"].queryset = qs - self.initial = {"enjeux": qs.filter(selection=True)} + self.initial = {"enjeux": qs.selectionnes()} def save(self, *args, **kwargs): super().save(*args, **kwargs) for enjeu in self.instance.enjeux.filter(esrs=self.esrs): enjeu.selection = enjeu in self.cleaned_data["enjeux"] + if not enjeu.selection: + # si un enjeu n'est pas sélectionné, il ne peut pas être analysé + # (raz éventuelle de l'analyse si on déselectionne l'enjeu) + enjeu.materiel = None enjeu.save() def sections(self): diff --git a/impact/reglementations/models/csrd.py b/impact/reglementations/models/csrd.py index 76cab6532..8db01772f 100644 --- a/impact/reglementations/models/csrd.py +++ b/impact/reglementations/models/csrd.py @@ -189,13 +189,16 @@ def modifiables(self): return self.filter(modifiable=True) def materiels(self): - return self.filter(materiel=True) + return self.selectionnes().filter(materiel=True) def non_materiels(self): - return self.filter(materiel=False) + return self.selectionnes().filter(materiel=False) + + def analyses(self): + return self.selectionnes().filter(materiel__isnull=False) def non_analyses(self): - return self.filter(materiel__isnull=True) + return self.selectionnes().filter(materiel__isnull=True) def environnement(self): return self.filter(esrs__startswith="ESRS_E") diff --git a/impact/reglementations/templates/reglementations/csrd/etape-collection-donnees-entreprise.html b/impact/reglementations/templates/reglementations/csrd/etape-collection-donnees-entreprise.html index e019c6d31..6f56cc3a0 100644 --- a/impact/reglementations/templates/reglementations/csrd/etape-collection-donnees-entreprise.html +++ b/impact/reglementations/templates/reglementations/csrd/etape-collection-donnees-entreprise.html @@ -12,9 +12,49 @@

{{ etape.nom }}

-

- Étape en préparation : vous pourrez bientôt télécharger vos données matérielles et non-matérielles. -

+

Analyse de la matérialité des informations élémentaires (points de données ou « data points »)

+ +

La phase 1 "analyse de double matérialité" vous a permis de déterminer vos enjeux de durabilité matériels. Cette phase 2 vous permet de déterminer les indicateurs pertinents sur lesquels vous allez reporter, puis collecter les données exigées.

+ +

La matérialité des informations s’appréhende en fonction des critères suivants : (i) l’importance de l’information élémentaire pour décrire l’enjeu ou (ii) son utilité pour répondre aux besoins des utilisateurs.

+ + +
+
+
+
Que faire ?
+ + +
+
+
+ +

Pour vous aidez, vous pouvez consulter le logigramme permettant de déterminer les informations à inclure au titre des ESRS.

+ +
+
+

+ +

+
+ logigramme +
+
+
+

En savoir plus sur les démarches de collecte des données

@@ -29,4 +69,7 @@

{{ etape.nom }}

+

+ {% include "snippets/csrd_submit.html" with prochaine_etape="redaction-rapport-durabilite" %} +

{% endblock %} diff --git a/impact/reglementations/templates/reglementations/csrd/etape-redaction-rapport-durabilite.html b/impact/reglementations/templates/reglementations/csrd/etape-redaction-rapport-durabilite.html new file mode 100644 index 000000000..aaf6e6318 --- /dev/null +++ b/impact/reglementations/templates/reglementations/csrd/etape-redaction-rapport-durabilite.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Collection des données de l'entreprise{% endblock %} + +{% block content %} + + {% include "snippets/csrd_header.html" %} + +
+
+ {% include "snippets/gestion_csrd_menu.html" %} + +
+

{{ etape.nom }}

+

+ Étape en préparation : vous pourrez bientôt enregistrer le lien de téléchargement de votre Rapport de Durabilité. +

+
+ +
+ +{% endblock %} diff --git a/impact/reglementations/tests/test_csrd_views.py b/impact/reglementations/tests/test_csrd_views.py index 7808242a3..7fef9af27 100644 --- a/impact/reglementations/tests/test_csrd_views.py +++ b/impact/reglementations/tests/test_csrd_views.py @@ -1,9 +1,11 @@ from datetime import date from datetime import datetime +from io import BytesIO import pytest from django.contrib.messages import WARNING from django.urls import reverse +from openpyxl import load_workbook from pytest_django.asserts import assertTemplateUsed from habilitations.models import attach_user_to_entreprise @@ -128,6 +130,8 @@ def test_guide_de_la_csrd_par_etape(etape, client, alice, entreprise_factory): "/csrd/{siren}/etape-introduction", "/csrd/{siren}/etape-selection-enjeux", "/csrd/{siren}/etape-analyse-materialite", + "/csrd/{siren}/etape-collection-donnees-entreprise", + "/csrd/{siren}/etape-redaction-rapport-durabilite", ], ) def test_gestion_de_la_csrd(etape, client, alice, entreprise_factory): @@ -155,22 +159,38 @@ def test_gestion_de_la_csrd(etape, client, alice, entreprise_factory): assertTemplateUsed( response, "reglementations/csrd/etape-analyse-materialite.html" ) + elif etape.endswith("collection-donnees-entreprise"): + assertTemplateUsed( + response, "reglementations/csrd/etape-collection-donnees-entreprise.html" + ) + elif etape.endswith("redaction-rapport-durabilite"): + assertTemplateUsed( + response, "reglementations/csrd/etape-redaction-rapport-durabilite.html" + ) + rapport_csrd = RapportCSRD.objects.get(proprietaire=alice, entreprise=entreprise) + NOMBRE_ENJEUX = 103 + assert len(rapport_csrd.enjeux.all()) == NOMBRE_ENJEUX + + +def test_étape_inexistante_de_la_csrd(client, alice, entreprise_factory): + entreprise = entreprise_factory() + attach_user_to_entreprise(alice, entreprise, "Présidente") + client.force_login(alice) etape_inexistante = f"/csrd/{entreprise.siren}/etape-4" + response = client.get(etape_inexistante) assert response.status_code == 404 - rapport_csrd = RapportCSRD.objects.get(proprietaire=alice, entreprise=entreprise) - NOMBRE_ENJEUX = 103 - assert len(rapport_csrd.enjeux.all()) == NOMBRE_ENJEUX - @pytest.mark.parametrize( "etape", [ "introduction", "selection-enjeux", + "analyse-materialite", + "collection-donnees-entreprise", ], ) def test_enregistrement_de_l_étape_de_la_csrd(etape, client, alice, entreprise_factory): @@ -188,9 +208,6 @@ def test_enregistrement_de_l_étape_de_la_csrd(etape, client, alice, entreprise_ response = client.post(url, follow=True) - content = response.content.decode("utf-8") - assert "" in content - rapport_csrd = RapportCSRD.objects.get(proprietaire=alice, entreprise=entreprise) assert rapport_csrd.etape_validee == etape @@ -200,6 +217,8 @@ def test_enregistrement_de_l_étape_de_la_csrd(etape, client, alice, entreprise_ [ "introduction", "selection-enjeux", + "analyse-materialite", + "collection-donnees-entreprise", ], ) def test_enregistrement_de_l_étape_de_la_csrd_retourne_une_404_si_aucune_CSRD( @@ -377,3 +396,119 @@ def test_liste_des_enjeux_csrd(client, alice, entreprise_non_qualifiee): assert "" in response.content.decode( "utf-8" ) + + +def test_datapoints_pour_enjeux_materiels_au_format_xlsx( + client, alice, entreprise_non_qualifiee +): + attach_user_to_entreprise(alice, entreprise_non_qualifiee, "Présidente") + csrd = RapportCSRD.objects.create( + proprietaire=alice, + entreprise=entreprise_non_qualifiee, + annee=f"{datetime.now():%Y}", + ) + enjeux = csrd.enjeux.all() + enjeu_attenuation = enjeux[1] + enjeu_attenuation.selection = True + enjeu_attenuation.materiel = True + enjeu_attenuation.save() + esrs_materielle = enjeu_attenuation.esrs + client.force_login(alice) + + response = client.get( + f"/csrd/{entreprise_non_qualifiee.siren}/datapoints.xlsx", + ) + + assert ( + response["content-type"] + == "application/vnd.openxmlformatsofficedocument.spreadsheetml.sheet" + ) + workbook = load_workbook(filename=BytesIO(response.content)) + noms_onglet = workbook.get_sheet_names() + assert esrs_materielle.replace("_", " ") in noms_onglet + assert "Index" in noms_onglet + assert "ESRS 2" in noms_onglet + assert "ESRS2 MDR" in noms_onglet + assert "ESRS G1" not in noms_onglet + + +def test_datapoints_pour_enjeux_non_materiels_au_format_xlsx( + client, alice, entreprise_non_qualifiee +): + attach_user_to_entreprise(alice, entreprise_non_qualifiee, "Présidente") + csrd = RapportCSRD.objects.create( + proprietaire=alice, + entreprise=entreprise_non_qualifiee, + annee=f"{datetime.now():%Y}", + ) + enjeux = csrd.enjeux.all() + + # enjeu de l'ESRS_E1: + enjeu_attenuation = enjeux[1] + enjeu_attenuation.selection = True + enjeu_attenuation.materiel = True + enjeu_attenuation.save() + esrs_materielle = enjeu_attenuation.esrs + client.force_login(alice) + + # note : les enjeux affichés dans le fichier "non-matériels" + # doivent au préalable avoir été sélectionnés + + # enjeu de l'ESRS_G1: + enjeux_G1 = enjeux.filter(esrs="ESRS_G1").first() + enjeux_G1.selection = True + enjeux_G1.materiel = False # et pas None + enjeux_G1.save() + + response = client.get( + f"/csrd/{entreprise_non_qualifiee.siren}/datapoints.xlsx?materiel=false", + ) + + assert ( + response["content-type"] + == "application/vnd.openxmlformatsofficedocument.spreadsheetml.sheet" + ) + workbook = load_workbook(filename=BytesIO(response.content)) + noms_onglet = workbook.sheetnames + assert esrs_materielle.replace("_", " ") not in noms_onglet + assert "Index" in noms_onglet + assert "ESRS 2" in noms_onglet + assert "ESRS2 MDR" in noms_onglet + assert "ESRS G1" in noms_onglet + + +def test_datapoints_csrd__au_format_xlsx_retourne_une_404_si_entreprise_inexistante( + client, alice +): + client.force_login(alice) + + response = client.get( + f"/csrd/000000001/datapoints.xlsx", + ) + + assert response.status_code == 404 + + +def test_datapoints_csrd_au_format_xlsx_retourne_une_404_si_habilitation_inexistante( + client, alice, entreprise_non_qualifiee +): + client.force_login(alice) + + response = client.get( + f"/csrd/{entreprise_non_qualifiee.siren}/datapoints.xlsx", + ) + + assert response.status_code == 404 + + +def test_datapoints_csrd_au_format_xlsx_retourne_une_404_si_csrd_inexistante( + client, alice, entreprise_non_qualifiee +): + attach_user_to_entreprise(alice, entreprise_non_qualifiee, "Présidente") + client.force_login(alice) + + response = client.get( + f"/csrd/{entreprise_non_qualifiee.siren}/datapoints.xlsx", + ) + + assert response.status_code == 404 diff --git a/impact/reglementations/urls.py b/impact/reglementations/urls.py index a948b48f1..6cc0d29cc 100644 --- a/impact/reglementations/urls.py +++ b/impact/reglementations/urls.py @@ -114,6 +114,11 @@ views.csrd.enjeux_materiels_xlsx, name="enjeux_materiels_xlsx", ), + path( + "csrd//datapoints.xlsx", + views.csrd.datapoints_xlsx, + name="datapoints_xlsx", + ), ] # Fragments HTMX diff --git a/impact/reglementations/views/csrd.py b/impact/reglementations/views/csrd.py index c627c75d6..37b2d33cc 100644 --- a/impact/reglementations/views/csrd.py +++ b/impact/reglementations/views/csrd.py @@ -16,6 +16,7 @@ from django.template.loader import get_template from django.template.loader import TemplateDoesNotExist from django.urls import reverse_lazy +from openpyxl import load_workbook from openpyxl import Workbook from entreprises.models import CaracteristiquesAnnuelles @@ -581,7 +582,7 @@ def gestion_csrd(request, siren=None, id_etape="introduction"): match EtapeCSRD.get(id_etape).id: ## légèrement plus lisible qu'un `if` case "collection-donnees-entreprise": - nb_enjeux_non_analyses = csrd.enjeux.selectionnes().non_analyses().count() + nb_enjeux_non_analyses = csrd.enjeux.non_analyses().count() context |= { "can_download": nb_enjeux_non_analyses != csrd.enjeux.selectionnes().count(), @@ -659,6 +660,11 @@ def _build_xlsx(enjeux, csrd=None, materiels=False): numero_ligne += 1 + filename = "enjeux_csrd.xlsx" if not materiels else "enjeux_csrd_materiels.xlsx" + return _xlsx_response(workbook, filename) + + +def _xlsx_response(workbook, filename): with NamedTemporaryFile() as tmp: workbook.save(tmp.name) tmp.seek(0) @@ -668,7 +674,42 @@ def _build_xlsx(enjeux, csrd=None, materiels=False): xlsx_stream, content_type="application/vnd.openxmlformatsofficedocument.spreadsheetml.sheet", ) - filename = "enjeux_csrd.xlsx" if not materiels else "enjeux_csrd_materiels.xlsx" - response["Content-Disposition"] = f"filename='{filename}'" + response["Content-Disposition"] = f"filename={filename}" return response + + +@login_required +@csrd_required +def datapoints_xlsx(request, _, csrd=None): + materiel = request.GET.get("materiel", True) != "false" + esrs_a_supprimer = _esrs_materiel_a_supprimer(csrd, materiel) + workbook = load_workbook("impact/static/CSRD/ESRS_Data_Points_EFRAG.xlsx") + for esrs in esrs_a_supprimer: + titre_onglet = esrs.replace("_", " ") + workbook.remove(workbook[titre_onglet]) + + filename = ( + "datapoints_csrd_materiels.xlsx" + if materiel + else "datapoints_csrd_non_materiels.xlsx" + ) + return _xlsx_response(workbook, filename) + + +def _esrs_materiel_a_supprimer(csrd: RapportCSRD, materiel: bool): + tous_les_esrs = set(ESRS.values) + + if materiel: + enjeux_materiels = csrd.enjeux.materiels() + esrs_materiels = set((enjeu.esrs for enjeu in enjeux_materiels)) + esrs_a_supprimer = tous_les_esrs - esrs_materiels + else: + enjeux_non_materiels = csrd.enjeux.non_materiels() + esrs_non_materiels = set((enjeu.esrs for enjeu in enjeux_non_materiels)) + esrs_a_supprimer = tous_les_esrs - esrs_non_materiels + + # ne pas supprimer ESRS_1 et ESRS_2 car ils n'existent pas dans le fichier .xlsx + esrs_a_supprimer -= set(("ESRS_1", "ESRS_2")) + + return esrs_a_supprimer diff --git a/impact/reglementations/views/fragments/enjeux_materiels.py b/impact/reglementations/views/fragments/enjeux_materiels.py index 6556aab17..0b14de148 100644 --- a/impact/reglementations/views/fragments/enjeux_materiels.py +++ b/impact/reglementations/views/fragments/enjeux_materiels.py @@ -30,10 +30,10 @@ def _grouper_enjeux_par_esrs(enjeux): { "titre": TitreESRS[esrs].value, "esrs": esrs, - "analyses": len(enjeux.filter(esrs=esrs, materiel__isnull=False)), + "analyses": len(enjeux.analyses().filter(esrs=esrs)), "a_analyser": len(enjeux_), "enjeux": enjeux_, - "nb_materiels": len(enjeux.filter(esrs=esrs, materiel=True)), + "nb_materiels": len(enjeux.materiels().filter(esrs=esrs)), } ) @@ -46,7 +46,7 @@ def _grouper_enjeux_par_esrs(enjeux): def rafraichissement_enjeux_materiels(request, csrd_id): csrd = get_object_or_404(RapportCSRD, id=csrd_id) enjeux_selectionnes = csrd.enjeux.selectionnes() - enjeux_non_analyses = enjeux_selectionnes.non_analyses() + enjeux_non_analyses = csrd.enjeux.non_analyses() context = { "csrd": csrd, diff --git a/impact/static/CSRD/ESRS_Data_Points_EFRAG.xlsx b/impact/static/CSRD/ESRS_Data_Points_EFRAG.xlsx new file mode 100644 index 000000000..a9363df3f Binary files /dev/null and b/impact/static/CSRD/ESRS_Data_Points_EFRAG.xlsx differ