location_store.ex 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. defmodule Vaccins.LocationStore do
  2. defmodule Location do
  3. alias __MODULE__, as: Location
  4. @limit 60 * 60 * 24
  5. defstruct [
  6. :id,
  7. :name,
  8. :location,
  9. :geographic_area,
  10. :availability_query,
  11. :booking_page,
  12. :provider,
  13. availability_query_params: []
  14. ]
  15. def from_json(m = %{provider: "Elixir.Vaccins.Queries.Doctolib"}),
  16. do: struct(Location, m) |> Map.replace(:provider, Vaccins.Queries.Doctolib)
  17. def set_id(l = %Location{name: name}), do: %{l | id: name |> String.to_atom()}
  18. def build_query(l = %Location{availability_query_params: params, provider: provider})
  19. when params != [],
  20. do: %{l | availability_query: params |> provider.new}
  21. def query_availability(%Location{availability_query: q, provider: provider}) do
  22. with url <- q |> provider.to_url() |> URI.to_string(),
  23. {:ok, result} <- url |> Vaccins.Scraper.get_json() do
  24. case result |> provider.analyze_result() do
  25. {:ok, slots} ->
  26. case slots
  27. |> Enum.map(&DateTime.truncate(&1, :second))
  28. |> Enum.group_by(&(&1 |> DateTime.diff(DateTime.utc_now()) |> abs < @limit)) do
  29. grouped = %{true: before_limit} ->
  30. {:ok, before_limit |> sort, grouped |> Map.get(false, []) |> sort}
  31. %{false: after_limit} ->
  32. {:ok, after_limit |> sort}
  33. end
  34. error = {:error, reason} when reason in [:no_availability] ->
  35. error
  36. end
  37. end
  38. end
  39. defp sort(list) when is_list(list), do: list |> Enum.sort_by(& &1, {:asc, DateTime})
  40. end
  41. defmodule LocationRaw do
  42. use Ecto.Schema
  43. import Ecto.Changeset
  44. @primary_key {:id, :id, autogenerate: false}
  45. embedded_schema do
  46. field(:name, :string)
  47. field(:location, :string)
  48. field(:geographic_area, :string)
  49. field(:booking_page, :string)
  50. field(:raw_query, :string)
  51. end
  52. @doc false
  53. def changeset(location \\ %__MODULE__{}, attrs),
  54. do:
  55. location
  56. |> cast(attrs, [:name, :location, :booking_page, :geographic_area, :raw_query])
  57. |> validate_required([:name, :location, :booking_page, :geographic_area, :raw_query])
  58. def to_query_params(%__MODULE__{raw_query: raw}) do
  59. raw
  60. |> URI.parse()
  61. |> Map.get(:query)
  62. |> URI.decode_query()
  63. |> Map.new(fn {k, v} -> {k |> String.to_atom(), v} end)
  64. |> Map.take([:agenda_ids, :limit, :practice_ids, :visit_motive_ids])
  65. end
  66. end
  67. use GenServer
  68. require Ex2ms
  69. import Ecto.Changeset
  70. alias Vaccins.Queries.Doctolib
  71. @name Vaccins.LocationStore
  72. @file_path "./location_store.json"
  73. def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: @name)
  74. @impl true
  75. def init(_opts) do
  76. {:ok, load_state()}
  77. end
  78. def reload() do
  79. GenServer.call(@name, :reload)
  80. end
  81. def get_locations() do
  82. GenServer.call(@name, :get_locations)
  83. end
  84. def add_location(params) do
  85. GenServer.call(@name, {:add_location, params})
  86. end
  87. def delete_location(id) do
  88. GenServer.call(@name, {:delete_location, id})
  89. end
  90. @impl true
  91. def handle_call(:reload, _, _), do: {:reply, :ok, load_state()}
  92. @impl true
  93. def handle_call(:get_locations, _, state = %{locations: locations}),
  94. do: {:reply, locations |> Map.values() |> Enum.map(&Location.build_query/1), state}
  95. @impl true
  96. def handle_call({:add_location, params}, _, state = %{locations: locations}) do
  97. cs = params |> LocationRaw.changeset()
  98. with {:ok, raw_location} <- cs |> apply_action(:insert),
  99. processed <-
  100. %Location{
  101. name: raw_location.name,
  102. location: raw_location.location,
  103. booking_page: raw_location.booking_page,
  104. availability_query_params: raw_location |> LocationRaw.to_query_params(),
  105. provider: Doctolib
  106. }
  107. |> Location.set_id(),
  108. locations <- locations |> Map.put(processed.id, processed),
  109. new_state <- %{locations: locations},
  110. :ok <- new_state |> dump_state do
  111. :ok
  112. {:reply, :ok, new_state}
  113. else
  114. e = {:error, _} -> {:reply, e, state}
  115. end
  116. end
  117. @impl true
  118. def handle_call({:delete_location, id}, _, %{locations: locations}) do
  119. with locations <- locations |> Map.delete(id),
  120. new_state <- %{locations: locations},
  121. :ok <- new_state |> dump_state,
  122. do: {:reply, :ok, new_state}
  123. end
  124. defp load_state(), do: %{locations: read_file() |> Map.new(&{&1.id, &1})}
  125. defp read_file(),
  126. do:
  127. with(
  128. {:ok, content} <- File.read(@file_path),
  129. {:ok, decoded} <- Jason.decode(content, keys: :atoms),
  130. do: decoded |> Enum.map(&Location.from_json/1)
  131. )
  132. defp dump_state(%{locations: locations}), do: locations |> Map.values() |> write_file()
  133. defp write_file(locations),
  134. do:
  135. with(
  136. {:ok, encoded} <-
  137. Jason.encode(locations |> Enum.map(&(&1 |> Map.from_struct())), pretty: true),
  138. do: File.write(@file_path, encoded)
  139. )
  140. end