diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ecf569a1d..342c5f530 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -90,7 +90,6 @@ jobs: - 6543:5432 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 with: node-version-file: ".tool-versions" @@ -136,8 +135,6 @@ jobs: - name: Run Playwright tests run: npx playwright test --reporter=list --update-snapshots --shard=${{ matrix.shard }} - - - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.gitignore b/.gitignore index 147882b1d..79bcd2068 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ airflow-pgdata/ media _build +.obsidian diff --git a/.secrets.baseline b/.secrets.baseline index c43b96127..bafc8019b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -151,7 +151,7 @@ "filename": "core/settings.py", "hashed_secret": "1ee34e26aeaf89c64ecc2c85efe6a961b75a50e9", "is_verified": false, - "line_number": 219 + "line_number": 220 } ], "docker-compose.yml": [ @@ -160,7 +160,7 @@ "filename": "docker-compose.yml", "hashed_secret": "3cf2012487b086bba2adb3386d69c2ab67a268b6", "is_verified": false, - "line_number": 41 + "line_number": 49 } ], "iframe_without_js.html": [ @@ -207,5 +207,5 @@ } ] }, - "generated_at": "2025-01-22T18:36:09Z" + "generated_at": "2025-01-28T14:22:35Z" } diff --git a/core/context_processors.py b/core/context_processors.py index ae3ec21cb..433ff7aea 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -23,7 +23,8 @@ def assistant(request) -> dict: return { "assistant": { "is_home": request.path == reverse("qfdmd:home"), - "is_iframe": request.session.get("iframe"), + "is_iframe": request.COOKIES.get("iframe") == "1" + or "iframe" in request.GET, "POSTHOG_KEY": settings.ASSISTANT["POSTHOG_KEY"], "MATOMO_ID": settings.ASSISTANT["MATOMO_ID"], }, diff --git a/core/settings.py b/core/settings.py index f818a73cc..3005e8011 100644 --- a/core/settings.py +++ b/core/settings.py @@ -76,6 +76,7 @@ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", + "qfdmd.middleware.AssistantMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", diff --git a/docker-compose.yml b/docker-compose.yml index 3e3c7684d..6eae08b36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,14 @@ services: ports: - 6543:5432 profiles: [lvao, airflow] + lvao-proxy: + image: nginx:latest + volumes: + - ./nginx/servers.conf:/etc/nginx/servers.conf + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + profiles: [proxy] + ports: + - "8001:80" airflow-db: image: postgres:15 environment: @@ -63,7 +71,7 @@ services: context: . dockerfile: airflow-webserver.Dockerfile ports: - - "8080:8080" + - "8001:8080" healthcheck: test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] interval: 30s diff --git a/docs/README.md b/docs/README.md index 2429be034..e8dfcbab9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,12 +56,13 @@ Décrit les apprentissages pas à pas de l'utilisation des outils, par exemple - [🧐 REFERENCE](./reference/README.md) - [Règles de codage](./reference/101-coding-guidelines.md) - - [Frontend et templating](./reference/201-frontend.md) + - [Frontend](./reference/201-frontend.md) - [Règle de codage de la base de données](./reference/301-db-guidelines.md) - [Architecture des fichiers de code de la partie data](./reference/302-organisations-des-fichiers-data.md) - [Guide de la docmentation technique](./reference/901-documentation-technique.md) - [❓ EXPLICATIONS](./explications/README.md) + - [Frontend](./explications/201-frontend.md) - [🤔 COMMENT FAIRE ?](./comment-faire/README.md) - [Commandes Django utiles](./comment-faire/101-commandes-django.md) diff --git a/docs/explications/201-frontend.md b/docs/explications/201-frontend.md new file mode 100644 index 000000000..6c987088d --- /dev/null +++ b/docs/explications/201-frontend.md @@ -0,0 +1,24 @@ +# 🎨 Frontend + +Le projet dispose de deux frontend principaux +- **Longue Vie Aux Objets** : https://lvao.ademe.fr +- L'**assistant** : https://quefairedemesobjets.ademe.fr + +Ils ont été développés en silo, et ne partagent à date qu'un minimum de code. +L'objectif à terme est d'en mutualiser une majeure partie au travers de refactorisations. + +Longue vie aux objet est quant à lui découpé en deux briques fonctionnelles : +- Le **formulaire**, aussi appelé *version épargnons* +- La **carte**, intégrée au sein de l'assistant et par de nombreuses collectivités sous forme d'iframe +Les différences sont [détaillées ici](https://www.notion.so/accelerateur-transition-ecologique-ademe/Sp-cifications-de-la-carte-170dcd6cdaee4a62b9f70c2040b363e2?pvs=4) + +Comprendre l'**historique** du projet permet de comprendre comment s'imbriquent ces différentes applications +- **2023** : Longue vie aux objets : développée initialement, dans sa version *carte* +- **2024** : *Formulaire* : développée dans le cadre d'une campagne de communication [Épargnons nos ressources](https://epargnonsnosressources.gouv.fr) +- **2025** : *Assistant* : développé en remplacement d'un outil précédemment développé sous Gatsby + +**Les technologies employées dans l'assistant sont à prendre comme référence pour l'intégralité des développements futurs sur le frontend**, à savoir : +- [**templating Django**](https://docs.djangoproject.com/en/5.1/topics/templates/) + Jinja est encore utilisé dans la carte mais est progressivement déprévié +- **Parcel** pour la compilation des fichiers JS/CSS +- **Tailwind** diff --git a/docs/explications/301-routing-nginx-cache.md b/docs/explications/301-routing-nginx-cache.md new file mode 100644 index 000000000..a880b6d61 --- /dev/null +++ b/docs/explications/301-routing-nginx-cache.md @@ -0,0 +1,53 @@ +# 🚏 Routing, nginx et cache +Le projet est actuellement déployé sur **Scalingo**, Scalingo qui impose une limite de 50 requêtes/seconde sur un worker. +Nous avons décidé d'ajouter **Nginx** en janvier 2025 afin d'agir comme serveur de cache et anticiper sur l'atteinte de cette limite. +# nginx +Dans l'hypothèse d'un pic de charge, +- Le **cache** de certaines **vues Django** (déchet / produit, page d'accueil de l'assistant...) +- Le **cache** des **fichiers statiques** (CSS, JS...) + +Des images valant mille mots, ci-dessous un schéma résumant le parcours d'une requête lorsqu'elle atteint `lvao.ademe.fr` ou `quefairedemesdechets.ademe.fr` + + +```mermaid +sequenceDiagram + +participant Client +participant Nginx +participant Django + + + +Client->>Nginx: Request lvao.ademe.fr + +alt Cache Hit + +Nginx-->>Client: Return cached response + +else Cache Miss + +Nginx->>Django: Forward request to Django + +Django-->>Nginx: Generate response + +alt Authenticated User + +Django->>Nginx: Add 'logged_in' cookie + +end + +alt iframe query param present + +Django->>Nginx: Add iframe cookie + +end + +Nginx-->>Client: Return Django response + +end +``` + +Les cookies définis expirent à la fin de la session, cela veut dire qu'ils seront re-générés si l'utilisateur ferme son navigateur. + +# whitenoise +# middleware \ No newline at end of file diff --git a/docs/explications/README.md b/docs/explications/README.md index cf1a19263..8cb97eccb 100644 --- a/docs/explications/README.md +++ b/docs/explications/README.md @@ -1 +1,9 @@ # ❓ EXPLICATIONS + +```{toctree} +:hidden: + +101-ingestion-de-source.md +201-frontend.md +301-routing-nginx-cache.md +``` diff --git a/docs/reference/201-frontend.md b/docs/reference/201-frontend.md index 298261d07..f1f9ef060 100644 --- a/docs/reference/201-frontend.md +++ b/docs/reference/201-frontend.md @@ -1,42 +1,23 @@ -# Frontend et templating - -Le projet utilise le templating Django pour développer le frontend. - -- Jinja vs templating Django -- Parcel -- Tailwind -- Organisation et découpage des templates -- Utilisation des formulaires -- Django DSFR - -## Jinja et templating Django - -Le projet utilise Jinja et le templating Django. -:warning: **Ces deux approches cohabitent mais il est envisagé d'abandonner Jinja à terme.** -Quand bien même les templates continuent d'être placé dans le dossier `jinja2` afin de garantir une rétrocompatibilité, tous les futurs développement doivent s'efforcer de s'affranchir de Jinja. - +# Frontend ## Parcel - -[Parcel](https://parceljs.org) est utilisé pour compiler les fichiers statiques : CSS, JS notamment. +[Parcel](https://parceljs.org) est utilisé pour compiler les fichiers statiques : **CSS, JS** notamment. Les sources sont configurées dans le fichier package.json` ### Transformers - Le projet utilise des _transformers_, ceux-ci sont principalement utilisés pour assainir le DSFR aujourd'hui. +⚠️ Ils sont pour le moment désactivés, ils le seront dans un futur proche (écrit le 28/1/2025) ### PostCSS - Parcel embarque PostCSS, celui-ci est étendu dans le projet pour supporter Tailwind et le [_CSS nesting_](https://www.w3.org/TR/css-nesting-1/) - ## Organisation et découpage des templates On considère que le découpage de templates peut avoir lieu dans plusieurs situations : -- Rendre un élément de design réutilisable (composant du DSFR, composant custom) -- Rendre un template moins lourd et le découper en petites unités fonctionnelles +- Rendre un **élément de design réutilisable** (composant du DSFR, composant custom) +- Rendre un **template moins lourd** et le découper en petites unités fonctionnelles -Un template de composant étant considéré comme réutilisable, il sera nommé `nom_du_composant.html` -Un fragment de template étant considéré local au template qu'il compose, il sera nommé `_nom_du_fragment.html` et placé dans un dossier portant le même nom que le template où il est utilisé. +Un **template de composant** étant considéré comme réutilisable, il sera nommé `nom_du_composant.html` +Un **fragment de template** étant considéré local au template qu'il compose, il sera nommé `_nom_du_fragment.html` et placé dans un dossier portant le même nom que le template où il est utilisé. L'organisation précise des templates est laissée au jugement du développement, mais l'idée est de faire remonter à proprement parler au niveau le plus haut possible les fichiers de templates. Par exemple, si on travaille sur le template de la fiche détaillée d'un acteur, qui utilise un composant tag, on peut retrouver l'arborescence suivante @@ -62,7 +43,6 @@ Avec par exemple Note : cette convention a été adoptée en cours de projet et il est possible qu'une partie des templates legacy ne la respecte pas encore. ## Tailwind - Le projet utilise Tailwind. Les classes Tailwind sont préfixées de `qfdmo` afin de les *namespacer* par rapport aux autres dépendances utilisées (DSFR notamment). diff --git a/docs/tutoriels/101-push-to-prod.md b/docs/tutoriels/101-push-to-prod.md new file mode 100644 index 000000000..1244ff003 --- /dev/null +++ b/docs/tutoriels/101-push-to-prod.md @@ -0,0 +1,14 @@ +# 🚀 Mise en production + +1. Créer un tag depuis l'interface GitHub ou la ligne de commande +``` +git checkout main +git reset --hard origin/main +git tag v1.x.y +git push --tags +``` +On en profite au passage pour s'assurer que notre branche main est bien iso avec celle du dépôt distant. + +2. Cela va déclencher une action GitHub, qui va automatiquement créer la release +3. Une fois la release créée, celle-ci peut être modifiée pour adapter son contenu à la teneur des changements déployés +4. L'équipe doit être prévenue sur le [canal équipe](https://mattermost.incubateur.net/betagouv/channels/longuevieauxobjets-equipe) \ No newline at end of file diff --git a/docs/tutoriels/README.md b/docs/tutoriels/README.md index 32e408953..1977e3443 100644 --- a/docs/tutoriels/README.md +++ b/docs/tutoriels/README.md @@ -1 +1,7 @@ # 🙌 TUTORIELS + +```{toctree} +:hidden: + +101-push-to-prod +``` diff --git a/e2e_tests/iframe.spec.ts b/e2e_tests/iframe.spec.ts index e2c133d42..7ac0d6601 100644 --- a/e2e_tests/iframe.spec.ts +++ b/e2e_tests/iframe.spec.ts @@ -65,15 +65,15 @@ test("Desktop | iframe with 0px parent height displays correctly", async ({ page }); test("Desktop | iframe cannot read the referrer when referrerPolicy is set to no-referrer", async ({ page }) => { - await page.goto("http://localhost:8000/test_iframe?carte=1", { waitUntil: "networkidle" }); + await page.goto("http://localhost:8000/test_iframe?carte=1&noreferrer", { waitUntil: "networkidle" }); // Get the content frame of the iframe const iframeElement = await page.$("iframe[referrerpolicy='no-referrer']"); const iframe = await iframeElement?.contentFrame(); - expect(iframe).not.toBeNull(); + expect(iframe).toBeTruthy(); // Evaluate the referrer inside the iframe - const referrer = await iframe.evaluate(() => document.referrer); + const referrer = await iframe?.evaluate(() => document.referrer); // Assert that the referrer is set and not undefined expect(referrer).toBe(''); @@ -93,3 +93,24 @@ test("iframe can read the referrer when referrerPolicy is not set", async ({ pag // Assert that the referrer is set and not undefined expect(referrer).toBe('http://localhost:8000/test_iframe?carte=1'); }); + +// Need to be run locally with nginx running +// test("Desktop | iframe mode is kept during navigation", async ({ browser, page }) => { +// await page.goto("http://localhost:8001/dechet/chaussures?iframe", { waitUntil: "networkidle" }); +// page.getByTestId("header-logo-link").click() +// await expect(page).toHaveURL("http://localhost:8001/dechet/") +// expect(await page.$("body > footer")).toBeFalsy() +// await page.close() + +// const newPage = await browser.newPage() +// await newPage.goto("http://localhost:8001/dechet/chaussures", { waitUntil: "networkidle" }); +// expect(browser.contexts) +// expect(await newPage.$("body > footer")).toBeTruthy() +// await newPage.close() + +// const yetAnotherPage = await browser.newPage() +// await yetAnotherPage.goto("http://localhost:8001/dechet/chaussures?iframe", { waitUntil: "networkidle" }); +// await yetAnotherPage.goto("http://localhost:8001/dechet/", { waitUntil: "networkidle" }); +// await yetAnotherPage.goto("http://localhost:8001/dechet/chaussures", { waitUntil: "networkidle" }); +// expect(await yetAnotherPage.$("body > footer")).not.toBeTruthy() +// }); diff --git a/jinja2/tests/iframe.html b/jinja2/tests/iframe.html index e168b4ba7..6701afe5e 100644 --- a/jinja2/tests/iframe.html +++ b/jinja2/tests/iframe.html @@ -47,10 +47,14 @@

LE FORMULAIRE


ASSISTANT

+ + {% if "noreferrer" in request.GET %} +

Avec referrerpolicy="noreferrer"

+ + {% else %}

Par défaut

-

Avec referrerpolicy="noreferrer"

- + {% endif %}
Proin ut a fames amet mi a feugiat mus quam risus iaculis egestas dignissim maecenas scelerisque tristique a leo a diam a cursus consectetur sem ac litora aliquam a. Felis vestibulum vestibulum vitae a vestibulum aliquam id egestas a proin posuere aliquam bibendum facilisis adipiscing tempor aptent nibh feugiat felis rutrum integer condimentum tincidunt. Ipsum purus nulla habitant condimentum tempor vestibulum elementum scelerisque dui at condimentum gravida a scelerisque scelerisque a adipiscing parturient ornare enim sodales vestibulum. Nec egestas est habitant tempus tristique parturient conubia consectetur parturient vestibulum quam nam platea senectus suspendisse a varius accumsan auctor vehicula velit vel. Ullamcorper a et nulla et inceptos himenaeos in hac consectetur facilisis scelerisque condimentum pharetra facilisi porttitor ut feugiat suspendisse lectus quisque fermentum nibh platea. diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 000000000..d1223ef2c --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,5 @@ +events {} + +http { + include /etc/nginx/servers.conf; +} diff --git a/nginx/servers.conf b/nginx/servers.conf new file mode 100644 index 000000000..aba61ed87 --- /dev/null +++ b/nginx/servers.conf @@ -0,0 +1,27 @@ +proxy_cache_path /tmp/nginx_assistant_cache levels=1:2 keys_zone=assistant:10m max_size=1g inactive=60m use_temp_path=off; +# Define a variable to disable cache if the logged_in cookie is set +map $http_cookie $no_cache { + default 0; + "~*logged_in=.*" 1; +} + +server { + listen 80; + server_name _; + + location / { + proxy_pass http://host.docker.internal:8000; + + proxy_cache_bypass $no_cache; + proxy_no_cache $no_cache; + + proxy_cache assistant; + proxy_set_header Host $http_host; + proxy_cache_key $request_method$request_uri$is_args$args; + proxy_cache_lock on; + proxy_cache_lock_timeout 10s; + proxy_cache_use_stale error timeout updating; + proxy_cache_background_update on; + add_header X-Cache-Status $upstream_cache_status; + } +} diff --git a/qfdmd/middleware.py b/qfdmd/middleware.py new file mode 100644 index 000000000..85372a8c7 --- /dev/null +++ b/qfdmd/middleware.py @@ -0,0 +1,44 @@ +class AssistantMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + self._set_logged_in_cookie(request, response) + self._handle_iframe_cookie(request, response) + self._cleanup_vary_header(response) + + return response + + def _set_logged_in_cookie(self, request, response): + """Set or update the 'logged-in' header based on authentication.""" + cookie_name = "logged_in" + if request.user.is_authenticated: + response.set_cookie(cookie_name, "1") + elif request.COOKIES.get(cookie_name): + response.delete_cookie(cookie_name) + + def _handle_iframe_cookie(self, request, response): + """Manage iframe-related headers and cookies.""" + iframe_in_request = "iframe" in request.GET + iframe_cookie = response.cookies.get("iframe") + + if iframe_in_request: + response.set_cookie("iframe", "1") + response.headers["iframe"] = "1" + elif iframe_cookie and iframe_cookie.value == "1": + response.headers["iframe"] = "1" + else: + # Ensure the iframe header is not lingering + response.headers.pop("iframe", None) + + @staticmethod + def _cleanup_vary_header(response): + """Helper to parse and return the Vary header as a list.""" + vary_header = response.headers.get("Vary", "") + return [ + v.strip() + for v in vary_header.split(",") + if v.strip() and v.strip().lower() != "cookie" + ] diff --git a/qfdmd/views.py b/qfdmd/views.py index 2db0bb93a..52167c753 100644 --- a/qfdmd/views.py +++ b/qfdmd/views.py @@ -7,7 +7,8 @@ from django.shortcuts import render from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_page +from django.views.decorators.cache import cache_control, cache_page +from django.views.decorators.vary import vary_on_headers from django.views.generic import DetailView, FormView, ListView from core.notion import create_new_row_in_notion_table @@ -91,7 +92,8 @@ def inner_function(request, *args, **kwargs): return inner_decorator -@method_decorator(cache_page_for_guests(60 * 15), name="dispatch") +@method_decorator(cache_control(max_age=60 * 15), name="dispatch") +@method_decorator(vary_on_headers("logged-in", "iframe"), name="dispatch") class HomeView(BaseView, ListView): template_name = "qfdmd/home.html" model = Suggestion @@ -115,11 +117,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return context -@method_decorator(cache_page_for_guests(60 * 15), name="dispatch") class SynonymeDetailView(BaseView, DetailView): model = Synonyme -@method_decorator(cache_page_for_guests(60 * 15), name="dispatch") class CMSPageDetailView(BaseView, DetailView): model = CMSPage diff --git a/servers.conf.erb b/servers.conf.erb index af6805223..1813ceb5e 100644 --- a/servers.conf.erb +++ b/servers.conf.erb @@ -1,5 +1,12 @@ proxy_cache_path /tmp/nginx_assistant_cache levels=1:2 keys_zone=assistant:10m max_size=1g inactive=60m use_temp_path=off; +# Define a variable to disable cache if the specific cookie is set +map $http_cookie $no_cache { + default 0; # Enable caching by default + "~*logged_in=.*" 1; # Disable caching if 'logged_in' is set +} + + server { listen <%= ENV["PORT"] %>; server_name _; @@ -12,8 +19,13 @@ server { location / { proxy_pass http://unix:/tmp/gunicorn.sock; + + proxy_cache_bypass $no_cache; + proxy_no_cache $no_cache; + proxy_cache assistant; - proxy_cache_key $request_method$request_uri; + proxy_set_header Host $http_host; + proxy_cache_key $request_method$request_uri$is_args$args; proxy_cache_lock on; proxy_cache_lock_timeout 10s; proxy_cache_use_stale error timeout updating; diff --git a/static/to_compile/entrypoints/assistant/script-to-iframe.ts b/static/to_compile/entrypoints/assistant/script-to-iframe.ts index 5c62c7418..d2d31608e 100644 --- a/static/to_compile/entrypoints/assistant/script-to-iframe.ts +++ b/static/to_compile/entrypoints/assistant/script-to-iframe.ts @@ -4,7 +4,7 @@ const script = document.currentScript as HTMLScriptElement const slug = script?.dataset?.objet; const origin = new URL(script?.getAttribute("src")).origin // TODO: mise en prod, remplacer dechet ci-dessous par une string vide -const src = `${origin}/${slug || 'dechet'}?iframe`; +const src = `${origin}/dechet/${slug || ''}?iframe`; const iframe = document.createElement("iframe"); const iframeAttributes = { diff --git a/static/to_compile/js/controllers/assistant/analytics.ts b/static/to_compile/js/controllers/assistant/analytics.ts index ec784a8a8..a490cb244 100644 --- a/static/to_compile/js/controllers/assistant/analytics.ts +++ b/static/to_compile/js/controllers/assistant/analytics.ts @@ -117,6 +117,11 @@ export default class extends Controller { conversionScore, }, }) + + const posthogBannerConversionScore = document.querySelector("#posthog-banner-conversion-score") + if (posthogBannerConversionScore) { + posthogBannerConversionScore.textContent = conversionScore.toString() + } }, 1000) } diff --git a/templates/components/footer/footer.html b/templates/components/footer/footer.html index 9a6e3e17c..233309060 100644 --- a/templates/components/footer/footer.html +++ b/templates/components/footer/footer.html @@ -1,11 +1,11 @@
- {% if "iframe" in request.GET %} + {% if assistant.is_iframe %} {% include "./_embedded.html" %} {% else %} {% include "./_standalone.html" %} {% endif %}
-{% if "iframe" not in request.GET %} +{% if not assistant.is_iframe %} {% include "./_dsfr_footer.html" %} {% endif %} diff --git a/templates/components/header/header.html b/templates/components/header/header.html index 65885d501..219e768d4 100644 --- a/templates/components/header/header.html +++ b/templates/components/header/header.html @@ -3,6 +3,7 @@