diff --git a/apps/transport/lib/db/resource.ex b/apps/transport/lib/db/resource.ex
index ec4ec4b388..dbea9b1696 100644
--- a/apps/transport/lib/db/resource.ex
+++ b/apps/transport/lib/db/resource.ex
@@ -275,14 +275,27 @@ defmodule DB.Resource do
end
end
+ @doc """
+ iex> hosted_on_datagouv?(%DB.Resource{url: "https://static.data.gouv.fr/file.zip"})
+ true
+ iex> hosted_on_datagouv?("https://static.data.gouv.fr/file.zip")
+ true
+ iex> hosted_on_datagouv?(%DB.Resource{url: "https://example.com/file.zip"})
+ false
+ """
+ @spec hosted_on_datagouv?(__MODULE__.t() | binary()) :: boolean()
+ def hosted_on_datagouv?(url) when is_binary(url), do: hosted_on_datagouv?(%__MODULE__{url: url})
+
+ def hosted_on_datagouv?(%__MODULE__{url: url}) do
+ host = url |> URI.parse() |> Map.fetch!(:host)
+ Enum.member?(Application.fetch_env!(:transport, :datagouv_static_hosts), host)
+ end
+
defp needs_stable_url?(%__MODULE__{latest_url: nil}), do: false
- defp needs_stable_url?(%__MODULE__{url: url}) do
+ defp needs_stable_url?(%__MODULE__{url: url} = resource) do
parsed_url = URI.parse(url)
- hosted_on_static_datagouv =
- Enum.member?(Application.fetch_env!(:transport, :datagouv_static_hosts), parsed_url.host)
-
object_storage_regex =
~r{(https://.*\.blob\.core\.windows\.net)|(https://.*\.s3\..*\.amazonaws\.com)|(https://.*\.s3\..*\.scw\.cloud)|(https://.*\.cellar-c2\.services\.clever-cloud\.com)|(https://s3\..*\.cloud\.ovh\.net)}
@@ -290,14 +303,12 @@ defmodule DB.Resource do
cond do
hosted_on_bison_fute -> is_link_to_folder?(parsed_url)
- hosted_on_static_datagouv -> true
+ hosted_on_datagouv?(resource) -> true
String.match?(url, object_storage_regex) -> true
true -> false
end
end
- defp needs_stable_url?(%__MODULE__{}), do: false
-
defp is_link_to_folder?(%URI{path: path}) do
path |> Path.basename() |> :filename.extension() == ""
end
diff --git a/apps/transport/lib/jobs/resource_unavailable_notification_job.ex b/apps/transport/lib/jobs/resource_unavailable_notification_job.ex
index cbe2f2af0e..eae5dd97e1 100644
--- a/apps/transport/lib/jobs/resource_unavailable_notification_job.ex
+++ b/apps/transport/lib/jobs/resource_unavailable_notification_job.ex
@@ -31,24 +31,13 @@ defmodule Transport.Jobs.ResourceUnavailableNotificationJob do
email,
Application.get_env(:transport, :contact_email),
"Ressources indisponibles dans le jeu de données #{dataset.custom_title}",
- """
- Bonjour,
-
- Les ressources #{Enum.map_join(unavailabilities, ", ", &resource_title/1)} dans votre jeu de données #{dataset_url(dataset)} ne sont plus disponibles au téléchargement depuis plus de #{@hours_consecutive_downtime}h.
-
- Ces erreurs empêchent la réutilisation de vos données.
-
- Nous vous invitons à corriger l'accès de vos données dès que possible.
-
- Nous restons disponible pour vous accompagner si besoin.
-
- Merci par avance pour votre action,
-
- À bientôt,
-
- L'équipe du PAN
- """,
- ""
+ "",
+ Phoenix.View.render_to_string(TransportWeb.EmailView, "resource_unavailable.html",
+ dataset: dataset,
+ hours_consecutive_downtime: @hours_consecutive_downtime,
+ deleted_recreated_on_datagouv: deleted_and_recreated_resource_hosted_on_datagouv(dataset, unavailabilities),
+ resource_titles: Enum.map_join(unavailabilities, ", ", &resource_title/1)
+ )
)
save_notification(dataset, email)
@@ -68,6 +57,39 @@ defmodule Transport.Jobs.ResourceUnavailableNotificationJob do
|> MapSet.new()
end
+ @doc """
+ Detects when the producer deleted and recreated just after a resource hosted on data.gouv.fr.
+ Best practice: upload a new version of the file, keep the same datagouv's resource.
+
+ Detected if:
+ - a resource hosted on datagouv is unavailable (ie it was deleted)
+ - call the API now and see that a resource hosted on datagouv has been created recently
+ """
+ def deleted_and_recreated_resource_hosted_on_datagouv(%DB.Dataset{} = dataset, unavailabilities) do
+ hosted_on_datagouv = Enum.any?(unavailabilities, &DB.Resource.hosted_on_datagouv?(&1.resource))
+ hosted_on_datagouv and created_resource_hosted_on_datagouv_recently?(dataset)
+ end
+
+ def created_resource_hosted_on_datagouv_recently?(%DB.Dataset{datagouv_id: datagouv_id}) do
+ case Datagouvfr.Client.Datasets.get(datagouv_id) do
+ {:ok, %{"resources" => resources}} ->
+ dt_limit = DateTime.utc_now() |> DateTime.add(-12, :hour)
+
+ Enum.any?(resources, fn %{"created_at" => created_at, "url" => url} ->
+ is_recent = created_at |> parse_datetime() |> DateTime.compare(dt_limit) == :gt
+ is_recent and DB.Resource.hosted_on_datagouv?(url)
+ end)
+
+ _ ->
+ false
+ end
+ end
+
+ defp parse_datetime(value) do
+ {:ok, datetime, 0} = DateTime.from_iso8601(value)
+ datetime
+ end
+
def relevant_unavailabilities(%DateTime{} = inserted_at) do
datetime_lower_limit = inserted_at |> DateTime.add(-@hours_consecutive_downtime * 60 - 30, :minute)
datetime_upper_limit = inserted_at |> DateTime.add(-@hours_consecutive_downtime, :hour)
@@ -90,12 +112,6 @@ defmodule Transport.Jobs.ResourceUnavailableNotificationJob do
|> MapSet.new()
end
- defp dataset_url(%DB.Dataset{slug: slug, custom_title: custom_title}) do
- url = TransportWeb.Router.Helpers.dataset_url(TransportWeb.Endpoint, :details, slug)
-
- "#{custom_title} — #{url}"
- end
-
defp resource_title(%DB.ResourceUnavailability{resource: %DB.Resource{title: title}}) do
title
end
diff --git a/apps/transport/lib/transport_web/templates/email/resource_unavailable.html.md b/apps/transport/lib/transport_web/templates/email/resource_unavailable.html.md
new file mode 100644
index 0000000000..008d76f82f
--- /dev/null
+++ b/apps/transport/lib/transport_web/templates/email/resource_unavailable.html.md
@@ -0,0 +1,22 @@
+Bonjour,
+
+Les ressources <%= @resource_titles %> dans votre jeu de données <%= link_for_dataset(@dataset, :heex) %> ne sont plus disponibles au téléchargement depuis plus de <%= @hours_consecutive_downtime %>h.
+
+<%= if @deleted_recreated_on_datagouv do %>
+Il semble que vous ayez supprimé et créé une nouvelle ressource. Lors de la mise à jour de vos données, remplacez plutôt le fichier au sein de la ressource existante. Retrouvez la procédure pas à pas [sur notre documentation](https://doc.transport.data.gouv.fr/producteurs/mettre-a-jour-des-donnees).
+
+Pour corriger le problème pour cette fois-ci :
+1. Mettez à jour l’ancienne ressource avec les nouvelles données en [suivant notre documentation](https://doc.transport.data.gouv.fr/producteurs/mettre-a-jour-des-donnees) ;
+2. Supprimez la ressource nouvellement créée qui sera alors en doublon.
+
+<% else %>
+Ces erreurs provoquent des difficultés pour les réutilisateurs. Nous vous invitons à corriger l’accès de vos données dès que possible.
+<% end %>
+
+Nous restons disponible pour vous accompagner si besoin.
+
+Merci par avance pour votre action,
+
+À bientôt,
+
+L’équipe du PAN
diff --git a/apps/transport/test/transport/jobs/resource_unavailable_notification_job_test.exs b/apps/transport/test/transport/jobs/resource_unavailable_notification_job_test.exs
index 3058a52043..8de6267a3f 100644
--- a/apps/transport/test/transport/jobs/resource_unavailable_notification_job_test.exs
+++ b/apps/transport/test/transport/jobs/resource_unavailable_notification_job_test.exs
@@ -49,9 +49,23 @@ defmodule Transport.Test.Transport.Jobs.ResourceUnavailableNotificationJobTest d
%{id: gtfs_dataset_id} =
gtfs_dataset = insert(:dataset, slug: Ecto.UUID.generate(), is_active: true, custom_title: "Dataset GTFS")
- resource_1 = insert(:resource, dataset: dataset, format: "geojson", title: "GeoJSON 1")
- resource_2 = insert(:resource, dataset: dataset, format: "geojson", title: "GeoJSON 2")
- resource_gtfs = insert(:resource, dataset: gtfs_dataset, format: "GTFS")
+ resource_1 =
+ insert(:resource,
+ dataset: dataset,
+ format: "geojson",
+ title: "GeoJSON 1",
+ url: "https://static.data.gouv.fr/file.geojson"
+ )
+
+ resource_2 =
+ insert(:resource,
+ dataset: dataset,
+ format: "geojson",
+ title: "GeoJSON 2",
+ url: "https://static.data.gouv.fr/other_file.geojson"
+ )
+
+ resource_gtfs = insert(:resource, dataset: gtfs_dataset, format: "GTFS", url: "https://example/file.zip")
insert(:resource_unavailability,
start: DateTime.add(DateTime.utc_now(), -6 * 60 - 29, :minute),
@@ -83,6 +97,8 @@ defmodule Transport.Test.Transport.Jobs.ResourceUnavailableNotificationJobTest d
|> Ecto.Changeset.change(%{inserted_at: DateTime.utc_now() |> DateTime.add(-20, :day)})
|> DB.Repo.update!()
+ setup_dataset_response(dataset, resource_1.url, DateTime.utc_now() |> DateTime.add(-6, :hour))
+
%DB.Contact{id: already_sent_contact_id} = insert_contact(%{email: already_sent_email})
%DB.Contact{id: foo_contact_id} = insert_contact(%{email: "foo@example.com"})
@@ -118,12 +134,15 @@ defmodule Transport.Test.Transport.Jobs.ResourceUnavailableNotificationJobTest d
"foo@example.com" = _to,
"contact@transport.beta.gouv.fr",
subject,
- plain_text_body,
- "" = _html_part ->
+ _plain_text_body,
+ html_part ->
assert subject == "Ressources indisponibles dans le jeu de données #{dataset.custom_title}"
- assert plain_text_body =~
- "Les ressources #{resource_1.title}, #{resource_2.title} dans votre jeu de données #{dataset.custom_title} — http://127.0.0.1:5100/datasets/#{dataset.slug} ne sont plus disponibles au téléchargement depuis plus de 6h."
+ assert html_part =~
+ ~s(Les ressources #{resource_1.title}, #{resource_2.title} dans votre jeu de données #{dataset.custom_title} ne sont plus disponibles au téléchargement depuis plus de 6h.)
+
+ assert html_part =~
+ "Il semble que vous ayez supprimé et créé une nouvelle ressource. Lors de la mise à jour de vos données, remplacez plutôt le fichier au sein de la ressource existante."
:ok
end)
@@ -134,12 +153,15 @@ defmodule Transport.Test.Transport.Jobs.ResourceUnavailableNotificationJobTest d
"bar@example.com" = _to,
"contact@transport.beta.gouv.fr",
subject,
- plain_text_body,
- "" = _html_part ->
+ _plain_text_body,
+ html_part ->
assert subject == "Ressources indisponibles dans le jeu de données #{gtfs_dataset.custom_title}"
- assert plain_text_body =~
- "Les ressources #{resource_gtfs.title} dans votre jeu de données #{gtfs_dataset.custom_title} — http://127.0.0.1:5100/datasets/#{gtfs_dataset.slug} ne sont plus disponibles au téléchargement depuis plus de 6h."
+ assert html_part =~
+ ~s(Les ressources #{resource_gtfs.title} dans votre jeu de données #{gtfs_dataset.custom_title} ne sont plus disponibles au téléchargement depuis plus de 6h.)
+
+ refute html_part =~ "Il semble que vous ayez supprimé et créé une nouvelle ressource."
+ assert html_part =~ "Ces erreurs provoquent des difficultés pour les réutilisateurs."
:ok
end)
@@ -165,4 +187,43 @@ defmodule Transport.Test.Transport.Jobs.ResourceUnavailableNotificationJobTest d
)
|> DB.Repo.exists?()
end
+
+ describe "created_resource_hosted_on_datagouv_recently?" do
+ test "base case" do
+ dataset = %DB.Dataset{datagouv_id: Ecto.UUID.generate()}
+ file_url = "https://static.data.gouv.fr/file.zip"
+ assert DB.Resource.hosted_on_datagouv?(file_url)
+ setup_dataset_response(dataset, file_url, DateTime.utc_now() |> DateTime.add(-6, :hour))
+ assert ResourceUnavailableNotificationJob.created_resource_hosted_on_datagouv_recently?(dataset)
+ end
+
+ test "resource on datagouv has been created a long time ago" do
+ dataset = %DB.Dataset{datagouv_id: Ecto.UUID.generate()}
+ file_url = "https://static.data.gouv.fr/file.zip"
+ assert DB.Resource.hosted_on_datagouv?(file_url)
+ setup_dataset_response(dataset, file_url, DateTime.utc_now() |> DateTime.add(-24, :hour))
+ refute ResourceUnavailableNotificationJob.created_resource_hosted_on_datagouv_recently?(dataset)
+ end
+
+ test "recent resource is not hosted on datagouv" do
+ dataset = %DB.Dataset{datagouv_id: Ecto.UUID.generate()}
+ file_url = "https://example.com/file.zip"
+ refute DB.Resource.hosted_on_datagouv?(file_url)
+ setup_dataset_response(dataset, file_url, DateTime.utc_now() |> DateTime.add(-6, :hour))
+ refute ResourceUnavailableNotificationJob.created_resource_hosted_on_datagouv_recently?(dataset)
+ end
+ end
+
+ defp setup_dataset_response(%DB.Dataset{datagouv_id: datagouv_id}, resource_url, created_at) do
+ url = "https://demo.data.gouv.fr/api/1/datasets/#{datagouv_id}/"
+
+ Transport.HTTPoison.Mock
+ |> expect(:request, fn :get, ^url, "", [], [follow_redirect: true] ->
+ {:ok,
+ %HTTPoison.Response{
+ status_code: 200,
+ body: Jason.encode!(%{"resources" => [%{"url" => resource_url, "created_at" => created_at}]})
+ }}
+ end)
+ end
end