diff --git a/.env.dev.sample b/.env.dev.sample index 0651ee5..81d9e97 100644 --- a/.env.dev.sample +++ b/.env.dev.sample @@ -1,2 +1,3 @@ export FRONTEND_URL=http://localhost:3000 -export KEPLER_API_URL=http://localhost:8000/api/v1 \ No newline at end of file +export KEPLER_API_URL=http://localhost:8000/api/v1 +export ATLAS_API_URL=http://localhost:4000 \ No newline at end of file diff --git a/config/runtime.exs b/config/runtime.exs index a54f170..4812810 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -24,6 +24,23 @@ config :atlas, :frontend_url, System.get_env("FRONTEND_URL", "http://localhost:3 config :atlas, :kepler_api_url, System.get_env("KEPLER_API_URL", "http://localhost:8000/api/v1") +atlas_api_url = + case config_env() do + :test -> + "http://localhost:4000" + + _ -> + System.get_env("ATLAS_API_URL") || + raise """ + environment variable ATLAS_API_URL is missing. + It should be the base URL of your Atlas API instance, e.g.: + http://localhost:4000 in dev + https://pombo.cesium.pt/api in prod + """ + end + +config :atlas, :api_url, atlas_api_url + config :atlas, from_email_name: System.get_env("FROM_EMAIL_NAME") || "Pombo", from_email_address: System.get_env("FROM_EMAIL_ADDRESS") || "no-reply@pombo.cesium.pt" @@ -110,6 +127,15 @@ if config_env() == :prod do config :atlas, :kepler_api_url, kepler_api_url + atlas_api_url = + System.get_env("ATLAS_API_URL") || + raise """ + environment variable ATLAS_API_URL is missing. + It should be the base URL of your Atlas API instance, e.g., http://localhost:4000/api + """ + + config :atlas, :api_url, atlas_api_url + # Configures CORS allowed origins config :atlas, :allowed_origins, frontend_url diff --git a/lib/atlas/calendar.ex b/lib/atlas/calendar.ex new file mode 100644 index 0000000..563d4c3 --- /dev/null +++ b/lib/atlas/calendar.ex @@ -0,0 +1,143 @@ +defmodule Atlas.Calendar do + @moduledoc """ + iCalendar (.ics) file generator for Atlas. + """ + + @prodid "-//Atlas//EN" + + alias Atlas.University.Degrees.Courses.Shifts.Shift + + defp format_datetime(%NaiveDateTime{} = dt) do + dt + |> NaiveDateTime.to_iso8601() + |> String.replace(~r/[-:]/, "") + |> String.replace(~r/\.\d+Z?/, "") + end + + @doc """ + Converts a list of shifts into `.ics` calendar content. + + Each shift should be preloaded with :timeslots and :course associations. + """ + def shifts_to_ics(shifts, opts \\ []) do + uid_prefix = Keyword.get(opts, :uid_prefix, "atlas") + cal_name = Keyword.get(opts, :calendar_name, "Atlas Calendar") + dtstamp = format_datetime(NaiveDateTime.utc_now()) + + events = + shifts + |> Enum.with_index() + |> Enum.flat_map(fn {shift, idx} -> + shift.timeslots + |> Enum.map(fn ts -> + uid = "#{uid_prefix}-#{shift_uid(shift, idx)}-#{ts.id || "ts"}" + {dtstart, dtend} = extract_start_end(ts) + + """ + BEGIN:VEVENT + UID:#{uid} + DTSTAMP:#{dtstamp} + DTSTART:#{format_datetime(dtstart)} + DTEND:#{format_datetime(dtend)} + SUMMARY:#{escape_text(build_summary(shift))} + DESCRIPTION:#{escape_text(build_description(shift, ts))} + LOCATION:#{escape_text(location_of(ts))} + RRULE:FREQ=WEEKLY;INTERVAL=1 + END:VEVENT + """ + end) + end) + |> Enum.join("\r\n") + + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:#{@prodid}", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:#{cal_name}", + events, + "END:VCALENDAR" + ] + |> Enum.join("\r\n") + end + + # --- Helpers --------------------------------------------------------------- + + # WIP: This function is a patch for the time being as the module does not allow for proper repetiveness atm + defp extract_start_end(%{start: start_time, end: end_time, weekday: weekday}) do + today = Date.utc_today() + # 1 = Monday ... 7 = Sunday + today_weekday = Date.day_of_week(today) + target_weekday = weekday_to_int(weekday) + + # Days until the next occurrence of the weekday + days_ahead = rem(target_weekday - today_weekday + 7, 7) + # always next week if today + days_ahead = if days_ahead == 0, do: 7, else: days_ahead + + event_date = Date.add(today, days_ahead) + + { + NaiveDateTime.new!(event_date, start_time), + NaiveDateTime.new!(event_date, end_time) + } + end + + defp weekday_to_int(:monday), do: 1 + defp weekday_to_int(:tuesday), do: 2 + defp weekday_to_int(:wednesday), do: 3 + defp weekday_to_int(:thursday), do: 4 + defp weekday_to_int(:friday), do: 5 + defp weekday_to_int(:saturday), do: 6 + defp weekday_to_int(:sunday), do: 7 + + defp shift_uid(shift, idx), do: Map.get(shift, :id, idx) + + defp build_summary(shift) do + course_name = + if shift.course && shift.course.name do + shift.course.name + else + "Course" + end + + "#{course_name} – #{Shift.short_name(shift)}" + end + + defp build_description(shift, ts) do + {st, en} = extract_start_end(ts) + + time_range = + "#{String.slice(Time.to_iso8601(NaiveDateTime.to_time(st)), 0, 5)} - " <> + String.slice(Time.to_iso8601(NaiveDateTime.to_time(en)), 0, 5) + + professor = + if is_binary(shift.professor), do: shift.professor, else: "" + + """ + Shift #{Shift.short_name(shift)} + Time: #{time_range} + Location: #{location_of(ts)} + Professor: #{professor} + """ + end + + defp location_of(ts) do + if ts.building && ts.room do + "#{ts.building} #{ts.room}" + else + "Unspecified location" + end + end + + defp escape_text(nil), do: "" + + defp escape_text(text) when is_binary(text) do + text + |> String.replace("\r\n", "\\n") + |> String.replace("\n", "\\n") + |> String.replace(",", "\\,") + |> String.replace(";", "\\;") + end +end diff --git a/lib/atlas/university.ex b/lib/atlas/university.ex index 2ddcbf6..47c211d 100644 --- a/lib/atlas/university.ex +++ b/lib/atlas/university.ex @@ -60,6 +60,23 @@ defmodule Atlas.University do Repo.get_by!(Student, number: number) end + @doc """ + Gets a single student by `user_id`. + + Returns `nil` if no student exists for the given user. + + ## Examples + + iex> get_student_by_user_id(123) + %Student{} + + iex> get_student_by_user_id(999) + nil + """ + def get_student_by_user_id(user_id) do + Repo.get_by(Student, user_id: user_id) + end + @doc """ Creates a student. diff --git a/lib/atlas/university/degrees/courses/shifts.ex b/lib/atlas/university/degrees/courses/shifts.ex index c7e2205..5b0d149 100644 --- a/lib/atlas/university/degrees/courses/shifts.ex +++ b/lib/atlas/university/degrees/courses/shifts.ex @@ -5,6 +5,7 @@ defmodule Atlas.University.Degrees.Courses.Shifts do use Atlas.Context alias Atlas.University.Degrees.Courses.Shifts.Shift + alias Atlas.University.ShiftEnrollment @doc """ Returns the list of shifts. @@ -21,6 +22,33 @@ defmodule Atlas.University.Degrees.Courses.Shifts do |> Repo.all() end + @doc """ + Returns the list of shifts a student is enrolled in. + + ## Examples + + iex> list_shifts_for_student(123) + [%Shift{}, ...] + + iex> list_shifts_for_student(456) + [] + + """ + def list_shifts_for_student(student_id, opts \\ []) do + shift_ids = + from(e in ShiftEnrollment, + where: e.student_id == ^student_id, + select: e.shift_id + ) + |> Repo.all() + + Shift + |> where([s], s.id in ^shift_ids) + |> apply_filters(opts) + |> Repo.all() + |> Repo.preload([:course, :timeslots]) + end + @doc """ Gets a single shift. diff --git a/lib/atlas_web/controllers/auth/auth_controller.ex b/lib/atlas_web/controllers/auth/auth_controller.ex index 448845b..a7a8b5b 100644 --- a/lib/atlas_web/controllers/auth/auth_controller.ex +++ b/lib/atlas_web/controllers/auth/auth_controller.ex @@ -209,7 +209,7 @@ defmodule AtlasWeb.AuthController do ) end - defp generate_token(user, session, :access) do + def generate_token(user, session, :access) do {:ok, token, _claims} = Guardian.encode_and_sign({user, session}, %{aud: @audience}, token_type: "access", @@ -219,7 +219,17 @@ defmodule AtlasWeb.AuthController do token end - defp generate_token(user, session, :refresh) do + def generate_token(user, session, :calendar) do + {:ok, token, _claims} = + Guardian.encode_and_sign({user, session}, %{aud: @audience}, + token_type: "calendar", + ttl: {10, :minute} + ) + + token + end + + def generate_token(user, session, :refresh) do {:ok, token, _claims} = Guardian.encode_and_sign({user, session}, %{aud: @audience}, token_type: "refresh", diff --git a/lib/atlas_web/controllers/calendar_export_controller.ex b/lib/atlas_web/controllers/calendar_export_controller.ex new file mode 100644 index 0000000..592a79f --- /dev/null +++ b/lib/atlas_web/controllers/calendar_export_controller.ex @@ -0,0 +1,63 @@ +defmodule AtlasWeb.CalendarExportController do + use AtlasWeb, :controller + + alias Atlas.Accounts.Guardian + alias Atlas.Calendar + alias Atlas.University + alias Atlas.University.Degrees.Courses.Shifts + alias AtlasWeb.AuthController + + @audience "astra" + + @doc """ + Returns a short-lived signed URL to export the current user's calendar. + """ + def calendar_url(conn, _params) do + {user, session} = Guardian.Plug.current_resource(conn) + + if is_nil(user) do + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + else + token = + AuthController.generate_token(user, session, :calendar) + + base_url = Application.get_env(:atlas, :api_url) + + url = "#{base_url}/v1/export/student/calendar.ics?token=#{token}" + + conn + |> json(%{calendar_url: url}) + end + end + + @doc """ + Exports the current user's schedule as an `.ics` file, given a valid calendar token. + """ + def student_calendar(conn, %{"token" => token}) do + with {:ok, claims} <- + Guardian.decode_and_verify(token, %{"typ" => "calendar", "aud" => @audience}), + {:ok, {user, _session}} <- Guardian.resource_from_claims(claims), + student <- University.get_student_by_user_id(user.id), + %{} = student <- student do + shifts = Shifts.list_shifts_for_student(student.id) + + ics_content = + Calendar.shifts_to_ics(shifts, calendar_name: "Student #{user.name} Schedule") + + conn + |> put_resp_content_type("text/calendar; charset=utf-8") + |> put_resp_header( + "content-disposition", + ~s[attachment; filename="student-#{user.name}-calendar.ics"] + ) + |> send_resp(200, ics_content) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid or expired calendar token"}) + end + end +end diff --git a/lib/atlas_web/router.ex b/lib/atlas_web/router.ex index 6cd37b5..ece4e16 100644 --- a/lib/atlas_web/router.ex +++ b/lib/atlas_web/router.ex @@ -39,6 +39,12 @@ defmodule AtlasWeb.Router do post "/reset_password", AuthController, :reset_password end + scope "/export" do + scope "/student" do + get "/calendar.ics", CalendarExportController, :student_calendar + end + end + # Authenticated routes pipe_through :auth @@ -83,6 +89,12 @@ defmodule AtlasWeb.Router do resources "/", ShiftExchangeRequestController, only: [:index, :create, :show, :delete] end + scope "/export" do + scope "/student" do + get "/calendar-url", CalendarExportController, :calendar_url + end + end + pipe_through :is_at_least_professor scope "/jobs" do diff --git a/mix.exs b/mix.exs index 41e0a50..af53e3d 100644 --- a/mix.exs +++ b/mix.exs @@ -64,6 +64,7 @@ defmodule Atlas.MixProject do {:igniter, "~> 0.5", only: [:dev]}, {:csv, "~> 3.2"}, {:libgraph, "~> 0.16.0"}, + {:icalendar, "~> 1.1.2"}, # monitoring {:telemetry_metrics, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index d54e4b2..fe753cd 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, @@ -23,17 +24,23 @@ "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, "guardian_db": {:hex, :guardian_db, "3.0.0", "c42902e3f1af1ba1e2d0c10913b926a1421f3a7e38eb4fc382b715c17489abdb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "9c2ec4278efa34f9f1cc6ba795e552d41fdc7ffba5319d67eeb533b89392d183"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "icalendar": {:hex, :icalendar, "1.1.2", "5d0afff5d0143c5bd43f18ae32a777bf0fb9a724543ab05229a460d368f0a5e7", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2060f8e353fdf3047e95a3f012583dc3c0bbd7ca1010e32ed9e9fc5760ad4292"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.6.27", "a7c01062db56f5c5ac0f36ff8ef3cce1d61cd6bf59e50c52f4a38dc926aa9728", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d1eda5271932dcb9f00f94936c3dc12a2b96466f895f4b3fb82a0caada6d6447"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, @@ -53,13 +60,17 @@ "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.17.10", "3bfce0e716f92c85579c8b7bb390f1d287f388e4961bfb9343fe191ec4214225", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "277f86c249089f4fc7d70944987151b76424fac1d348d40685008ba88e0a2717"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, + "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "ua_parser": {:hex, :ua_parser, "1.9.3", "1c3191ac62a6f3663b9c213ae5e1faef5dc03e29b6edbe34731a8f2f07802467", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "17e1b46cee8c2b49a4f9edec7ecb822846d4974cbd84ce02cbc169cdf1f58dfb"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "xlsx_reader": {:hex, :xlsx_reader, "0.8.8", "fbb29049548ff687f03a2873f2eb0d9057e47eb69cafb07f44988f030fb620b7", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:saxy, "~> 1.5", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "642d979a3a156b150bb76a89998a130483e1c399fa32e8d3a66abc1d9799dbd7"},