From dbc0022536c7b18e9e09de685c384c0041b88141 Mon Sep 17 00:00:00 2001 From: Antoine Augusti Date: Mon, 8 Jan 2024 23:51:34 +0100 Subject: [PATCH 1/4] =?UTF-8?q?UpdateContactsJob=20:=20g=C3=A8re=20cas=20o?= =?UTF-8?q?=C3=B9=20user=20datagouv=20a=20=C3=A9t=C3=A9=20supprim=C3=A9=20?= =?UTF-8?q?(#3694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UpdateContactsJob : gère cas où user datagouv a été supprimé * Test also for organizations --- .../transport/lib/jobs/update_contacts_job.ex | 23 ++++++++++++++----- .../jobs/update_contacts_job_test.exs | 22 ++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/transport/lib/jobs/update_contacts_job.ex b/apps/transport/lib/jobs/update_contacts_job.ex index 0202cc4e6a..9a2c8513f0 100644 --- a/apps/transport/lib/jobs/update_contacts_job.ex +++ b/apps/transport/lib/jobs/update_contacts_job.ex @@ -25,12 +25,23 @@ defmodule Transport.Jobs.UpdateContactsJob do DB.Contact.base_query() |> where([contact: c], c.datagouv_user_id in ^ids) |> DB.Repo.all() - |> Enum.each(fn %DB.Contact{datagouv_user_id: datagouv_user_id} = contact -> - {:ok, %{"organizations" => organizations}} = Datagouvfr.Client.User.get(datagouv_user_id) + |> Enum.each(&update_contact/1) + end + + defp update_contact(%DB.Contact{datagouv_user_id: datagouv_user_id} = contact) do + # https://doc.data.gouv.fr/api/reference/#/users/get_user + # 404 status code: User not found + # 410 status code: User is not active or has been deleted + case Datagouvfr.Client.User.get(datagouv_user_id) do + {:ok, %{"organizations" => organizations}} -> + contact + |> DB.Contact.changeset(%{organizations: organizations}) + |> DB.Repo.update!() - contact - |> DB.Contact.changeset(%{organizations: organizations}) - |> DB.Repo.update!() - end) + {:error, reason} when reason in [:not_found, :gone] -> + contact + |> DB.Contact.changeset(%{organizations: [], datagouv_user_id: nil}) + |> DB.Repo.update!() + end end end diff --git a/apps/transport/test/transport/jobs/update_contacts_job_test.exs b/apps/transport/test/transport/jobs/update_contacts_job_test.exs index bf0c748b12..9a4ea6a894 100644 --- a/apps/transport/test/transport/jobs/update_contacts_job_test.exs +++ b/apps/transport/test/transport/jobs/update_contacts_job_test.exs @@ -53,4 +53,26 @@ defmodule Transport.Test.Transport.Jobs.UpdateContactsJobTest do assert [%DB.Organization{name: ^org_name, id: ^org_id}] = contact.organizations assert %DB.Contact{organization: ^org_name} = contact end + + test "removes datagouv_user_id and organizations when API responds with 404 or 410" do + Enum.each([404, 410], fn status_code -> + contact = + insert_contact(%{ + datagouv_user_id: user_id = Ecto.UUID.generate(), + organizations: [:organization |> build() |> Map.from_struct()] + }) + + url = "https://demo.data.gouv.fr/api/1/users/#{user_id}/" + + Transport.HTTPoison.Mock + |> expect(:request, fn :get, ^url, "", [], [follow_redirect: true] -> + {:ok, %HTTPoison.Response{status_code: status_code, body: ""}} + end) + + assert :ok == perform_job(UpdateContactsJob, %{contact_ids: [user_id]}) + + assert %DB.Contact{datagouv_user_id: nil, organizations: []} = + contact |> DB.Repo.reload!() |> DB.Repo.preload([:organizations]) + end) + end end From 1d3f4d4f245fc7b9ff792de3938c4ae58a5b4a2f Mon Sep 17 00:00:00 2001 From: Antoine Augusti Date: Tue, 9 Jan 2024 08:43:09 +0100 Subject: [PATCH 2/4] Ajoute un lien vers l'Espace Producteur dans le menu quand pertinent (#3684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a link to the producer space in the dropdown menu * Refactor links in emails * Refresh is_producer when visiting espace producteur * Update comment * Add TransportWeb.Session --------- Co-authored-by: Thibaut Barrère --- apps/shared/lib/helpers.ex | 9 --- .../dataset_now_on_nap_notification_job.ex | 3 +- .../controllers/page_controller.ex | 5 +- .../controllers/session_controller.ex | 25 +++---- .../live/backoffice/jobs_live.ex | 5 +- .../live/backoffice/proxy_config_live.ex | 23 +------ apps/transport/lib/transport_web/router.ex | 10 +-- apps/transport/lib/transport_web/session.ex | 67 +++++++++++++++++++ .../dataset/_dataset_scores_chart.html.heex | 2 +- .../templates/dataset/details.html.heex | 2 +- .../templates/dataset/index.html.heex | 2 +- .../email/dataset_now_on_nap.html.md | 2 +- .../email/producer_with_subscriptions.html.md | 2 +- .../producer_without_subscriptions.html.md | 2 +- .../resource_unavailable_producer.html.md | 2 +- .../templates/layout/_header.html.heex | 7 +- .../page/infos_producteurs.html.heex | 2 +- .../lib/transport_web/views/email_view.ex | 11 +++ apps/transport/priv/gettext/default.pot | 4 ++ .../priv/gettext/en/LC_MESSAGES/default.po | 4 ++ .../priv/gettext/fr/LC_MESSAGES/default.po | 4 ++ apps/transport/test/support/conn_case.ex | 9 +-- ...taset_now_on_nap_notification_job_test.exs | 2 +- ...minder_producers_notification_job_test.exs | 2 +- ...urce_unavailable_notification_job_test.exs | 2 +- .../backoffice/backoffice_controller_test.exs | 12 +--- .../broken_urls_controller_test.exs | 15 +---- .../controllers/page_controller_test.exs | 34 ++++++++-- .../controllers/session_controller_test.exs | 30 +++++++-- .../test/transport_web/session_text.exs | 42 ++++++++++++ 30 files changed, 230 insertions(+), 111 deletions(-) create mode 100644 apps/transport/lib/transport_web/session.ex create mode 100644 apps/transport/test/transport_web/session_text.exs diff --git a/apps/shared/lib/helpers.ex b/apps/shared/lib/helpers.ex index fc224103e7..f1ba55eff4 100644 --- a/apps/shared/lib/helpers.ex +++ b/apps/shared/lib/helpers.ex @@ -57,13 +57,4 @@ defmodule Helpers do dates -> dates |> Enum.max(DateTime) |> DateTime.to_iso8601() end end - - @spec admin?(map | nil) :: boolean - def admin?(%{} = user) do - user - |> Map.get("organizations", []) - |> Enum.any?(fn org -> org["slug"] == "equipe-transport-data-gouv-fr" end) - end - - def admin?(nil), do: false end diff --git a/apps/transport/lib/jobs/dataset_now_on_nap_notification_job.ex b/apps/transport/lib/jobs/dataset_now_on_nap_notification_job.ex index c7b133f52f..67f57f5f0f 100644 --- a/apps/transport/lib/jobs/dataset_now_on_nap_notification_job.ex +++ b/apps/transport/lib/jobs/dataset_now_on_nap_notification_job.ex @@ -27,8 +27,7 @@ defmodule Transport.Jobs.DatasetNowOnNAPNotificationJob do Phoenix.View.render_to_string(TransportWeb.EmailView, "dataset_now_on_nap.html", dataset_url: TransportWeb.Router.Helpers.dataset_url(TransportWeb.Endpoint, :details, dataset.slug), dataset_custom_title: dataset.custom_title, - contact_email_address: Application.get_env(:transport, :contact_email), - espace_producteur_url: TransportWeb.Router.Helpers.page_url(TransportWeb.Endpoint, :espace_producteur) + contact_email_address: Application.get_env(:transport, :contact_email) ) ) diff --git a/apps/transport/lib/transport_web/controllers/page_controller.ex b/apps/transport/lib/transport_web/controllers/page_controller.ex index 7d2240185e..7cf023916a 100644 --- a/apps/transport/lib/transport_web/controllers/page_controller.ex +++ b/apps/transport/lib/transport_web/controllers/page_controller.ex @@ -186,7 +186,10 @@ defmodule TransportWeb.PageController do {conn, []} end - conn |> assign(:datasets, datasets) |> render("espace_producteur.html") + conn + |> assign(:datasets, datasets) + |> TransportWeb.Session.set_is_producer(datasets) + |> render("espace_producteur.html") end defp aoms_with_dataset do diff --git a/apps/transport/lib/transport_web/controllers/session_controller.ex b/apps/transport/lib/transport_web/controllers/session_controller.ex index 84a517089a..09338610da 100644 --- a/apps/transport/lib/transport_web/controllers/session_controller.ex +++ b/apps/transport/lib/transport_web/controllers/session_controller.ex @@ -127,24 +127,17 @@ defmodule TransportWeb.SessionController do end def save_current_user(%Plug.Conn{} = conn, %{} = user_params) do - conn |> put_session(:current_user, user_params |> user_params_for_session()) + conn + |> put_session(:current_user, user_params_for_session(user_params)) + |> TransportWeb.Session.set_is_producer(user_params) + |> TransportWeb.Session.set_is_admin(user_params) end - @doc """ - iex> pan_org = %{"slug" => "equipe-transport-data-gouv-fr", "name" => "PAN"} - iex> other_org = %{"slug" => "foo-inc", "name" => "Foo Inc"} - iex> user_params_for_session(%{"foo" => "bar", "organizations" => [pan_org, other_org]}) - %{"foo" => "bar", "organizations" => [pan_org]} - """ - def user_params_for_session(%{} = params) do - Map.put( - params, - "organizations", - Enum.filter( - params["organizations"], - &match?(%{"slug" => "equipe-transport-data-gouv-fr"}, &1) - ) - ) + defp user_params_for_session(%{} = params) do + # Remove the list of `organizations` from the final map: it's already stored in the database + # and maintained up-to-date by `Transport.Jobs.UpdateContactsJob` + # and it can be too big to be stored in a cookie + Map.delete(params, "organizations") end defp get_redirect_path(%Plug.Conn{} = conn) do diff --git a/apps/transport/lib/transport_web/live/backoffice/jobs_live.ex b/apps/transport/lib/transport_web/live/backoffice/jobs_live.ex index db0f7b4025..c8b2a46239 100644 --- a/apps/transport/lib/transport_web/live/backoffice/jobs_live.ex +++ b/apps/transport/lib/transport_web/live/backoffice/jobs_live.ex @@ -33,9 +33,10 @@ defmodule TransportWeb.Backoffice.JobsLive do # https://hexdocs.pm/phoenix_live_view/security-model.html#disconnecting-all-instances-of-a-given-live-user # def ensure_admin_auth_or_redirect(socket, current_user, func) do - if current_user && TransportWeb.Router.is_transport_data_gouv_member?(current_user) do + socket = assign(socket, current_user: current_user) + + if TransportWeb.Session.is_admin?(socket) do # We track down the current admin so that it can be used by next actions - socket = assign(socket, current_admin_user: current_user) # Then call the remaining code, which is expected to return the socket func.(socket) else diff --git a/apps/transport/lib/transport_web/live/backoffice/proxy_config_live.ex b/apps/transport/lib/transport_web/live/backoffice/proxy_config_live.ex index 23bcffdaa1..587988f655 100644 --- a/apps/transport/lib/transport_web/live/backoffice/proxy_config_live.ex +++ b/apps/transport/lib/transport_web/live/backoffice/proxy_config_live.ex @@ -4,6 +4,7 @@ defmodule TransportWeb.Backoffice.ProxyConfigLive do """ use Phoenix.LiveView alias Transport.Telemetry + import TransportWeb.Backoffice.JobsLive, only: [ensure_admin_auth_or_redirect: 3] import TransportWeb.Router.Helpers # The number of past days we want to report on (as a positive integer). @@ -20,25 +21,6 @@ defmodule TransportWeb.Backoffice.ProxyConfigLive do end)} end - # - # If one calls "redirect" and does not leave immediately, the remaining code will - # be executed, opening security issues. This method goal is to minimize this risk. - # See https://hexdocs.pm/phoenix_live_view/security-model.html for overall docs. - # - # Also, disconnect will have to be handled: - # https://hexdocs.pm/phoenix_live_view/security-model.html#disconnecting-all-instances-of-a-given-live-user - # - defp ensure_admin_auth_or_redirect(socket, current_user, func) do - if current_user && TransportWeb.Router.is_transport_data_gouv_member?(current_user) do - # We track down the current admin so that it can be used by next actions - socket = assign(socket, current_admin_user: current_user) - # Then call the remaining code, which is expected to return the socket - func.(socket) - else - redirect(socket, to: "/login") - end - end - defp schedule_next_update_data do Process.send_after(self(), :update_data, 1000) end @@ -57,8 +39,7 @@ defmodule TransportWeb.Backoffice.ProxyConfigLive do end def handle_event("refresh_proxy_config", _value, socket) do - if socket.assigns.current_admin_user, do: config_module().clear_config_cache!() - + config_module().clear_config_cache!() {:noreply, socket} end diff --git a/apps/transport/lib/transport_web/router.ex b/apps/transport/lib/transport_web/router.ex index fb968c2c3c..b9a0025a0f 100644 --- a/apps/transport/lib/transport_web/router.ex +++ b/apps/transport/lib/transport_web/router.ex @@ -314,6 +314,7 @@ defmodule TransportWeb.Router do end defp assign_current_user(conn, _) do + # `current_user` is set by TransportWeb.SessionController.user_params_for_session/1 assign(conn, :current_user, get_session(conn, :current_user)) end @@ -352,13 +353,6 @@ defmodule TransportWeb.Router do end end - # NOTE: method visibility set to public because we need to call the same logic from LiveView - def is_transport_data_gouv_member?(current_user) do - current_user - |> Map.get("organizations", []) - |> Enum.any?(fn org -> org["slug"] == "equipe-transport-data-gouv-fr" end) - end - # Check that a secret key is passed in the URL in the `export_key` query parameter defp check_export_secret_key(%Plug.Conn{params: params} = conn, _) do export_key_value = Map.get(params, "export_key", "") @@ -375,7 +369,7 @@ defmodule TransportWeb.Router do end defp transport_data_gouv_member(%Plug.Conn{} = conn, _) do - if is_transport_data_gouv_member?(conn.assigns[:current_user]) do + if TransportWeb.Session.is_admin?(conn) do conn else conn diff --git a/apps/transport/lib/transport_web/session.ex b/apps/transport/lib/transport_web/session.ex new file mode 100644 index 0000000000..e3e8fca929 --- /dev/null +++ b/apps/transport/lib/transport_web/session.ex @@ -0,0 +1,67 @@ +defmodule TransportWeb.Session do + @moduledoc """ + Web session getters and setters. + """ + import Ecto.Query + import Plug.Conn + + @is_admin_key_name "is_admin" + @is_producer_key_name "is_producer" + + @doc """ + Are you a data producer? + + You're a data producer if you're a member of an organization with an active dataset + on transport.data.gouv.fr. + This is set when you log in and refreshed when you visit your "Espace producteur". + """ + @spec set_is_producer(Plug.Conn.t(), map() | [DB.Dataset.t()]) :: Plug.Conn.t() + def set_is_producer(%Plug.Conn{} = conn, %{"organizations" => _} = params) do + set_session_attribute_attribute(conn, @is_producer_key_name, is_producer?(params)) + end + + def set_is_producer(%Plug.Conn{} = conn, [%DB.Dataset{}] = _datasets_for_user) do + set_session_attribute_attribute(conn, @is_producer_key_name, true) + end + + def set_is_producer(%Plug.Conn{} = conn, [] = _datasets_for_user) do + set_session_attribute_attribute(conn, @is_producer_key_name, false) + end + + @doc """ + Are you a transport.data.gouv.fr admin? + You're an admin if you're a member of the PAN organization on data.gouv.fr. + """ + def set_is_admin(%Plug.Conn{} = conn, %{"organizations" => _} = params) do + set_session_attribute_attribute(conn, @is_admin_key_name, is_admin?(params)) + end + + def is_admin?(%{"organizations" => orgs}) do + Enum.any?(orgs, &(&1["slug"] == "equipe-transport-data-gouv-fr")) + end + + def is_admin?(%Plug.Conn{} = conn) do + conn |> current_user() |> Map.get(@is_admin_key_name, false) + end + + def is_admin?(%Phoenix.LiveView.Socket{assigns: %{current_user: current_user}}) do + Map.get(current_user, @is_admin_key_name, false) + end + + def is_producer?(%Plug.Conn{} = conn) do + conn |> current_user() |> Map.get(@is_producer_key_name, false) + end + + def is_producer?(%{"organizations" => orgs}) do + org_ids = Enum.map(orgs, & &1["id"]) + DB.Dataset.base_query() |> where([dataset: d], d.organization_id in ^org_ids) |> DB.Repo.exists?() + end + + @spec set_session_attribute_attribute(Plug.Conn.t(), binary(), boolean()) :: Plug.Conn.t() + defp set_session_attribute_attribute(%Plug.Conn{} = conn, key, value) do + current_user = current_user(conn) + conn |> put_session(:current_user, Map.put(current_user, key, value)) + end + + defp current_user(%Plug.Conn{} = conn), do: get_session(conn, :current_user, %{}) +end diff --git a/apps/transport/lib/transport_web/templates/dataset/_dataset_scores_chart.html.heex b/apps/transport/lib/transport_web/templates/dataset/_dataset_scores_chart.html.heex index ecd59d75fa..1818a4a7f3 100644 --- a/apps/transport/lib/transport_web/templates/dataset/_dataset_scores_chart.html.heex +++ b/apps/transport/lib/transport_web/templates/dataset/_dataset_scores_chart.html.heex @@ -1,4 +1,4 @@ -
+

<%= dgettext("page-dataset-details", "Dataset scores") %>

diff --git a/apps/transport/lib/transport_web/templates/dataset/details.html.heex b/apps/transport/lib/transport_web/templates/dataset/details.html.heex index b974b4e859..60304c0b54 100644 --- a/apps/transport/lib/transport_web/templates/dataset/details.html.heex +++ b/apps/transport/lib/transport_web/templates/dataset/details.html.heex @@ -16,7 +16,7 @@

<%= @dataset.custom_title %>

- <%= if admin?(assigns[:current_user]) do %> + <%= if TransportWeb.Session.is_admin?(@conn) do %> <%= link("backoffice", to: backoffice_page_path(@conn, :edit, @dataset.id)) %> <%= render("_dataset_scores.html", dataset_scores: @dataset_scores, locale: locale) %> diff --git a/apps/transport/lib/transport_web/templates/dataset/index.html.heex b/apps/transport/lib/transport_web/templates/dataset/index.html.heex index b483ecb67c..ac240d3be1 100644 --- a/apps/transport/lib/transport_web/templates/dataset/index.html.heex +++ b/apps/transport/lib/transport_web/templates/dataset/index.html.heex @@ -183,7 +183,7 @@ <%= dataset.custom_title %> - <%= if admin?(assigns[:current_user]) do %> + <%= if TransportWeb.Session.is_admin?(@conn) do %> <%= link("backoffice", to: backoffice_page_path(@conn, :edit, dataset.id)) %> diff --git a/apps/transport/lib/transport_web/templates/email/dataset_now_on_nap.html.md b/apps/transport/lib/transport_web/templates/email/dataset_now_on_nap.html.md index 2a317c8305..b9dc325a5e 100644 --- a/apps/transport/lib/transport_web/templates/email/dataset_now_on_nap.html.md +++ b/apps/transport/lib/transport_web/templates/email/dataset_now_on_nap.html.md @@ -2,7 +2,7 @@ Bonjour, Votre jeu de données [<%= @dataset_custom_title %>](<%= @dataset_url %>) a bien été référencé sur le Point d’Accès National aux données de transport, [transport.data.gouv.fr](https://transport.data.gouv.fr). -Rendez-vous sur votre [Espace Producteur](<%= @espace_producteur_url %>) pour mettre à jour vos données ou vous inscrire à des notifications en cas de péremption, d’indisponibilité ou d’erreurs bloquantes sur votre jeu de données. +Rendez-vous sur votre <%= link_for_espace_producteur(:dataset_now_on_nap) %> pour mettre à jour vos données ou vous inscrire à des notifications en cas de péremption, d’indisponibilité ou d’erreurs bloquantes sur votre jeu de données. Si vous avez des questions, n’hésitez pas à contacter notre équipe à l’adresse : <%= @contact_email_address %>. diff --git a/apps/transport/lib/transport_web/templates/email/producer_with_subscriptions.html.md b/apps/transport/lib/transport_web/templates/email/producer_with_subscriptions.html.md index 7705861efd..5cca911d5e 100644 --- a/apps/transport/lib/transport_web/templates/email/producer_with_subscriptions.html.md +++ b/apps/transport/lib/transport_web/templates/email/producer_with_subscriptions.html.md @@ -11,7 +11,7 @@ Vous êtes inscrit à des notifications concernant les jeux de données suivants <% end %> -Les notifications vous permettent d’être alerté en cas d’expiration, d’indisponibilité et d’erreurs de vos données. Rendez-vous sur votre [Espace Producteur](<%= TransportWeb.Router.Helpers.page_url(TransportWeb.Endpoint, :espace_producteur) %>) pour les gérer de manière autonome. +Les notifications vous permettent d’être alerté en cas d’expiration, d’indisponibilité et d’erreurs de vos données. Rendez-vous sur votre <%= link_for_espace_producteur(:periodic_reminder_producer_with_subscriptions) %> pour les gérer de manière autonome. <%= if @has_other_producers_subscribers do %> Les autres personnes inscrites à ces notifications sont : <%= @other_producers_subscribers %>. diff --git a/apps/transport/lib/transport_web/templates/email/producer_without_subscriptions.html.md b/apps/transport/lib/transport_web/templates/email/producer_without_subscriptions.html.md index 8ab2bdc9d7..8218430aa6 100644 --- a/apps/transport/lib/transport_web/templates/email/producer_without_subscriptions.html.md +++ b/apps/transport/lib/transport_web/templates/email/producer_without_subscriptions.html.md @@ -13,7 +13,7 @@ Le saviez-vous ? Il est possible de vous inscrire à des notifications concernan Les notifications vous permettent d’être alerté en cas d’expiration, d’indisponibilité et d’erreurs de vos données. -Pour vous inscrire, rien de plus simple : rendez-vous sur votre [Espace Producteur](<%= TransportWeb.Router.Helpers.page_url(TransportWeb.Endpoint, :espace_producteur) %>) dans le menu “Recevoir des notifications”. +Pour vous inscrire, rien de plus simple : rendez-vous sur votre <%= link_for_espace_producteur(:periodic_reminder_producer_without_subscriptions) %> dans le menu “Recevoir des notifications”. Nous restons disponibles pour vous accompagner si besoin. diff --git a/apps/transport/lib/transport_web/templates/email/resource_unavailable_producer.html.md b/apps/transport/lib/transport_web/templates/email/resource_unavailable_producer.html.md index a564e000ef..8211f13122 100644 --- a/apps/transport/lib/transport_web/templates/email/resource_unavailable_producer.html.md +++ b/apps/transport/lib/transport_web/templates/email/resource_unavailable_producer.html.md @@ -7,7 +7,7 @@ Il semble que vous ayez supprimé puis créé une nouvelle ressource : l’URL d Pour les prochaines mises à jour, afin de garantir une URL stable, nous vous invitons à remplacer votre ressource obsolète par la nouvelle. -Pour cela, rendez-vous sur votre [Espace Producteur](<%= TransportWeb.Router.Helpers.page_url(TransportWeb.Endpoint, :espace_producteur) %>) à partir duquel vous pourrez procéder à ces mises à jour. +Pour cela, rendez-vous sur votre <%= link_for_espace_producteur(:resource_unavailable_producer) %> à partir duquel vous pourrez procéder à ces mises à jour. Retrouvez la procédure pas à pas [sur notre documentation](https://doc.transport.data.gouv.fr/producteurs/mettre-a-jour-des-donnees). <% else %> diff --git a/apps/transport/lib/transport_web/templates/layout/_header.html.heex b/apps/transport/lib/transport_web/templates/layout/_header.html.heex index 2600d62e83..7b1f6e1071 100644 --- a/apps/transport/lib/transport_web/templates/layout/_header.html.heex +++ b/apps/transport/lib/transport_web/templates/layout/_header.html.heex @@ -81,9 +81,14 @@ <% end %>