diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex index cc65d529a..f61dff839 100644 --- a/lib/cadet/notifications.ex +++ b/lib/cadet/notifications.ex @@ -4,6 +4,7 @@ defmodule Cadet.Notifications do """ import Ecto.Query, warn: false + require Logger alias Cadet.Repo alias Cadet.Notifications.{ @@ -11,7 +12,8 @@ defmodule Cadet.Notifications do NotificationConfig, SentNotification, TimeOption, - NotificationPreference + NotificationPreference, + PreferableTime } @doc """ @@ -49,20 +51,94 @@ defmodule Cadet.Notifications do def get_notification_config!(notification_type_id, course_id, assconfig_id) do query = - from(n in Cadet.Notifications.NotificationConfig, - join: ntype in Cadet.Notifications.NotificationType, - on: n.notification_type_id == ntype.id, - where: n.notification_type_id == ^notification_type_id and n.course_id == ^course_id + Cadet.Notifications.NotificationConfig + |> join(:inner, [n], ntype in Cadet.Notifications.NotificationType, + on: n.notification_type_id == ntype.id ) + |> where([n], n.notification_type_id == ^notification_type_id and n.course_id == ^course_id) + |> filter_assconfig_id(assconfig_id) + |> Repo.one() + case query do + nil -> + Logger.error( + "No NotificationConfig found for Course #{course_id} and NotificationType #{notification_type_id}" + ) + + nil + + config -> + config + end + end + + defp filter_assconfig_id(query, nil) do + query |> where([c], is_nil(c.assessment_config_id)) + end + + defp filter_assconfig_id(query, assconfig_id) do + query |> where([c], c.assessment_config_id == ^assconfig_id) + end + + def get_notification_config!(id), do: Repo.get!(NotificationConfig, id) + + @doc """ + Gets all notification configs that belong to a course + """ + def get_notification_configs(course_id) do query = - if is_nil(assconfig_id) do - where(query, [c], is_nil(c.assessment_config_id)) - else - where(query, [c], c.assessment_config_id == ^assconfig_id) - end + Cadet.Notifications.NotificationConfig + |> where([n], n.course_id == ^course_id) + |> Repo.all() - Repo.one(query) + query + |> Repo.preload([:notification_type, :course, :assessment_config, :time_options]) + end + + @doc """ + Gets all notification configs with preferences that + 1. belongs to the course of the course reg, + 2. only notifications that it can configure based on course reg's role + """ + def get_configurable_notification_configs(cr_id) do + cr = Repo.get(Cadet.Accounts.CourseRegistration, cr_id) + + case cr do + nil -> + nil + + _ -> + is_staff = cr.role == :staff + + query = + Cadet.Notifications.NotificationConfig + |> join(:inner, [n], ntype in Cadet.Notifications.NotificationType, + on: n.notification_type_id == ntype.id + ) + |> join(:inner, [n], c in Cadet.Courses.Course, on: n.course_id == c.id) + |> join(:left, [n], ac in Cadet.Courses.AssessmentConfig, + on: n.assessment_config_id == ac.id + ) + |> join(:left, [n], p in Cadet.Notifications.NotificationPreference, + on: p.notification_config_id == n.id + ) + |> where( + [n, ntype, c, ac, p], + ntype.for_staff == ^is_staff and + n.course_id == ^cr.course_id and + (p.course_reg_id == ^cr.id or is_nil(p.course_reg_id)) + ) + |> Repo.all() + + query + |> Repo.preload([ + :notification_type, + :course, + :assessment_config, + :time_options, + :notification_preferences + ]) + end end @doc """ @@ -83,6 +159,17 @@ defmodule Cadet.Notifications do |> Repo.update() end + def update_many_noti_configs(noti_configs) when is_list(noti_configs) do + Repo.transaction(fn -> + for noti_config <- noti_configs do + case Repo.update(noti_config) do + {:ok, res} -> res + {:error, error} -> Repo.rollback(error) + end + end + end) + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking notification_config changes. @@ -112,6 +199,24 @@ defmodule Cadet.Notifications do """ def get_time_option!(id), do: Repo.get!(TimeOption, id) + @doc """ + Gets all time options for a notification config + """ + def get_time_options_for_config(notification_config_id) do + query = + Cadet.Notifications.TimeOption + |> join(:inner, [to], nc in Cadet.Notifications.NotificationConfig, + on: to.notification_config_id == nc.id + ) + |> where([to, nc], nc.id == ^notification_config_id) + |> Repo.all() + + query + end + + @doc """ + Gets all time options for an assessment config and notification type + """ def get_time_options_for_assessment(assessment_config_id, notification_type_id) do query = from(ac in Cadet.Courses.AssessmentConfig, @@ -126,6 +231,9 @@ defmodule Cadet.Notifications do Repo.all(query) end + @doc """ + Gets the default time options for an assessment config and notification type + """ def get_default_time_option_for_assessment!(assessment_config_id, notification_type_id) do query = from(ac in Cadet.Courses.AssessmentConfig, @@ -160,6 +268,34 @@ defmodule Cadet.Notifications do |> Repo.insert() end + def upsert_many_time_options(time_options) when is_list(time_options) do + Repo.transaction(fn -> + for to <- time_options do + case Repo.insert(to, + on_conflict: {:replace, [:is_default]}, + conflict_target: [:minutes, :notification_config_id] + ) do + {:ok, time_option} -> time_option + {:error, error} -> Repo.rollback(error) + end + end + end) + end + + def upsert_many_noti_preferences(noti_prefs) when is_list(noti_prefs) do + Repo.transaction(fn -> + for np <- noti_prefs do + case Repo.insert(np, + on_conflict: {:replace, [:is_enabled, :time_option_id]}, + conflict_target: [:course_reg_id, :notification_config_id] + ) do + {:ok, noti_pref} -> noti_pref + {:error, error} -> Repo.rollback(error) + end + end + end) + end + @doc """ Deletes a time_option. @@ -176,6 +312,42 @@ defmodule Cadet.Notifications do Repo.delete(time_option) end + def delete_many_time_options(to_ids) when is_list(to_ids) do + Repo.transaction(fn -> + for to_id <- to_ids do + time_option = Repo.get(TimeOption, to_id) + + case time_option do + nil -> + Repo.rollback("Time option does not exist") + + _ -> + case Repo.delete(time_option) do + {:ok, deleted_time_option} -> deleted_time_option + {:delete_error, error} -> Repo.rollback(error) + end + end + end + end) + end + + @doc """ + Gets the notification preference based from its id + """ + def get_notification_preference!(notification_preference_id) do + query = + NotificationPreference + |> join(:left, [np], to in TimeOption, on: to.id == np.time_option_id) + |> where([np, to], np.id == ^notification_preference_id) + |> preload(:time_option) + |> Repo.one!() + + query + end + + @doc """ + Gets the notification preference based from notification type and course reg + """ def get_notification_preference(notification_type_id, course_reg_id) do query = from(np in NotificationPreference, @@ -276,33 +448,84 @@ defmodule Cadet.Notifications do |> Repo.insert() end + # PreferableTime @doc """ - Returns the list of sent_notifications. - - ## Examples - - iex> list_sent_notifications() - [%SentNotification{}, ...] - + Gets the preferable times using id number. """ + def get_preferable_time!(id), do: Repo.get!(PreferableTime, id) - # def list_sent_notifications do - # Repo.all(SentNotification) - # end - - # @doc """ - # Gets a single sent_notification. + @spec get_preferable_times_for_preference(any) :: any + @doc """ + Gets all preferable times for a notification preference + """ + def get_preferable_times_for_preference(notification_preference_id) do + query = + from(pt in Cadet.Notifications.PreferableTime, + join: np in Cadet.Notifications.NotificationPreference, + on: pt.notification_preference_id == np.id, + where: np.id == ^notification_preference_id + ) - # Raises `Ecto.NoResultsError` if the Sent notification does not exist. + Repo.all(query) + end - # ## Examples + @spec create_preferable_time( + :invalid + | %{optional(:__struct__) => none, optional(atom | binary) => any} + ) :: any + @doc """ + Creates a preferable_time. + ## Examples + iex> create_preferable_time(%{field: value}) + {:ok, %TimeOption{}} + iex> create_preferable_time(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + def create_preferable_time(attrs \\ %{}) do + %PreferableTime{} + |> PreferableTime.changeset(attrs) + |> Repo.insert() + end - # iex> get_sent_notification!(123) - # %SentNotification{} + def upsert_many_preferable_times(preferable_times) when is_list(preferable_times) do + Repo.transaction(fn -> + for pt <- preferable_times do + case Repo.insert(pt, + conflict_target: [:minutes, :notification_config_id] + ) do + {:ok, preferable_time} -> preferable_time + {:error, error} -> Repo.rollback(error) + end + end + end) + end - # iex> get_sent_notification!(456) - # ** (Ecto.NoResultsError) + @doc """ + Deletes a preferable_time. + ## Examples + iex> delete_preferable_time(preferable_time) + {:ok, %PreferableTime{}} + iex> delete_preferable_time(preferable_time) + {:error, %Ecto.Changeset{}} + """ + def delete_preferable_time(preferable_time = %PreferableTime{}) do + Repo.delete(preferable_time) + end - # """ - # # def get_sent_notification!(id), do: Repo.get!(SentNotification, id) + def delete_many_preferable_times(pt_ids) when is_list(pt_ids) do + Repo.transaction(fn -> + for pt_id <- pt_ids do + preferable_time = Repo.get(PreferableTime, pt_id) + + if is_nil(preferable_time) do + Repo.rollback("Preferable Time do not exist") + else + case Repo.delete(preferable_time) do + {:ok, preferable_time} -> preferable_time + {:delete_error, error} -> Repo.rollback(error) + end + end + end + end) + end end diff --git a/lib/cadet/notifications/notification_config.ex b/lib/cadet/notifications/notification_config.ex index 2072b9f45..8a475af3b 100644 --- a/lib/cadet/notifications/notification_config.ex +++ b/lib/cadet/notifications/notification_config.ex @@ -5,7 +5,7 @@ defmodule Cadet.Notifications.NotificationConfig do use Ecto.Schema import Ecto.Changeset alias Cadet.Courses.{Course, AssessmentConfig} - alias Cadet.Notifications.NotificationType + alias Cadet.Notifications.{NotificationType, TimeOption, NotificationPreference} schema "notification_configs" do field(:is_enabled, :boolean, default: false) @@ -14,6 +14,9 @@ defmodule Cadet.Notifications.NotificationConfig do belongs_to(:course, Course) belongs_to(:assessment_config, AssessmentConfig) + has_many(:time_options, TimeOption) + has_many(:notification_preferences, NotificationPreference) + timestamps() end diff --git a/lib/cadet/notifications/notification_preference.ex b/lib/cadet/notifications/notification_preference.ex index aec18aa5e..404f1f250 100644 --- a/lib/cadet/notifications/notification_preference.ex +++ b/lib/cadet/notifications/notification_preference.ex @@ -20,8 +20,9 @@ defmodule Cadet.Notifications.NotificationPreference do @doc false def changeset(notification_preference, attrs) do notification_preference - |> cast(attrs, [:is_enabled, :notification_config_id, :course_reg_id]) + |> cast(attrs, [:is_enabled, :notification_config_id, :course_reg_id, :time_option_id]) |> validate_required([:notification_config_id, :course_reg_id]) + |> unique_constraint(:unique_course_reg_and_config, name: :single_preference_per_config) |> prevent_nil_is_enabled() end diff --git a/lib/cadet/notifications/notification_type.ex b/lib/cadet/notifications/notification_type.ex index 7f16df022..772d029c0 100644 --- a/lib/cadet/notifications/notification_type.ex +++ b/lib/cadet/notifications/notification_type.ex @@ -12,6 +12,7 @@ defmodule Cadet.Notifications.NotificationType do field(:is_enabled, :boolean, default: false) field(:name, :string) field(:template_file_name, :string) + field(:for_staff, :boolean) timestamps() end @@ -19,8 +20,8 @@ defmodule Cadet.Notifications.NotificationType do @doc false def changeset(notification_type, attrs) do notification_type - |> cast(attrs, [:name, :template_file_name, :is_enabled, :is_autopopulated]) - |> validate_required([:name, :template_file_name, :is_autopopulated]) + |> cast(attrs, [:name, :template_file_name, :is_enabled, :is_autopopulated, :for_staff]) + |> validate_required([:name, :template_file_name, :is_autopopulated, :for_staff]) |> unique_constraint(:name) |> prevent_nil_is_enabled() end diff --git a/lib/cadet/notifications/preferable_time.ex b/lib/cadet/notifications/preferable_time.ex new file mode 100644 index 000000000..2d0c0dc7a --- /dev/null +++ b/lib/cadet/notifications/preferable_time.ex @@ -0,0 +1,26 @@ +defmodule Cadet.Notifications.PreferableTime do + @moduledoc """ + PreferableTime entity for recipients to set their preferable notification times. + """ + use Ecto.Schema + import Ecto.Changeset + alias Cadet.Notifications.NotificationPreference + + schema ":preferable_times" do + field(:minutes, :integer) + + belongs_to(:notification_preferences, NotificationPreference) + + timestamps() + end + + @doc false + def changeset(preferable_time, attrs) do + preferable_time + |> cast(attrs, [:minutes, :notification_preference_id]) + |> validate_required([:minutes, :notification_preference_id]) + |> validate_number(:minutes, greater_than_or_equal_to: 0) + |> unique_constraint([:minutes, :notification_preference_id], name: :unique_preferable_times) + |> foreign_key_constraint(:notification_preference_id) + end +end diff --git a/lib/cadet/workers/NotificationWorker.ex b/lib/cadet/workers/NotificationWorker.ex index d96a3df22..598689cee 100644 --- a/lib/cadet/workers/NotificationWorker.ex +++ b/lib/cadet/workers/NotificationWorker.ex @@ -3,6 +3,7 @@ defmodule Cadet.Workers.NotificationWorker do Contain oban workers for sending notifications """ use Oban.Worker, queue: :notifications, max_attempts: 1 + require Logger alias Cadet.{Email, Notifications, Mailer} alias Cadet.Repo @@ -73,39 +74,43 @@ defmodule Cadet.Workers.NotificationWorker do for avenger_cr <- avengers_crs do avenger = Cadet.Accounts.get_user(avenger_cr.user_id) - ungraded_submissions = - Jason.decode!( - elem( - Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true), - 1 + if is_user_enabled(notification_type_id, avenger_cr.id) do + ungraded_submissions = + Jason.decode!( + elem( + Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true), + 1 + ) ) - ) - if length(ungraded_submissions) < ungraded_threshold do - IO.puts("[AVENGER_BACKLOG] below threshold!") - else - IO.puts("[AVENGER_BACKLOG] SENDING_OUT") + if length(ungraded_submissions) < ungraded_threshold do + Logger.info("[AVENGER_BACKLOG] below threshold!") + else + Logger.info("[AVENGER_BACKLOG] SENDING_OUT") - email = - Email.avenger_backlog_email( - ntype.template_file_name, - avenger, - ungraded_submissions - ) + email = + Email.avenger_backlog_email( + ntype.template_file_name, + avenger, + ungraded_submissions + ) - {status, email} = Mailer.deliver_now(email) + {status, email} = Mailer.deliver_now(email) - if status == :ok do - Notifications.create_sent_notification(avenger_cr.id, email.html_body) + if status == :ok do + Notifications.create_sent_notification(avenger_cr.id, email.html_body) + end end + else + Logger.info("[ASSESSMENT_SUBMISSION] user-level disabled") end end else - IO.puts("[AVENGER_BACKLOG] course-level disabled") + Logger.info("[AVENGER_BACKLOG] course-level disabled") end end else - IO.puts("[AVENGER_BACKLOG] system-level disabled!") + Logger.info("[AVENGER_BACKLOG] system-level disabled!") end :ok @@ -132,13 +137,13 @@ defmodule Cadet.Workers.NotificationWorker do cond do !is_course_enabled(notification_type.id, course_id, assessment_config_id) -> - IO.puts("[ASSESSMENT_SUBMISSION] course-level disabled") + Logger.info("[ASSESSMENT_SUBMISSION] course-level disabled") !is_user_enabled(notification_type.id, avenger_cr.id) -> - IO.puts("[ASSESSMENT_SUBMISSION] user-level disabled") + Logger.info("[ASSESSMENT_SUBMISSION] user-level disabled") true -> - IO.puts("[ASSESSMENT_SUBMISSION] SENDING_OUT") + Logger.info("[ASSESSMENT_SUBMISSION] SENDING_OUT") email = Email.assessment_submission_email( @@ -155,7 +160,7 @@ defmodule Cadet.Workers.NotificationWorker do end end else - IO.puts("[ASSESSMENT_SUBMISSION] system-level disabled!") + Logger.info("[ASSESSMENT_SUBMISSION] system-level disabled!") end end end diff --git a/lib/cadet_web/controllers/new_notifications_controller.ex b/lib/cadet_web/controllers/new_notifications_controller.ex new file mode 100644 index 000000000..325bf2dad --- /dev/null +++ b/lib/cadet_web/controllers/new_notifications_controller.ex @@ -0,0 +1,149 @@ +defmodule CadetWeb.NewNotificationsController do + use CadetWeb, :controller + + alias Cadet.{Repo, Notifications} + + alias Cadet.Notifications.{ + NotificationPreference, + NotificationConfig, + TimeOption, + PreferableTime + } + + # NOTIFICATION CONFIGS + + def all_noti_configs(conn, %{"course_id" => course_id}) do + configs = Notifications.get_notification_configs(course_id) + render(conn, "configs_full.json", configs: configs) + end + + def get_configurable_noti_configs(conn, %{"course_reg_id" => course_reg_id}) do + configs = Notifications.get_configurable_notification_configs(course_reg_id) + + case configs do + nil -> conn |> put_status(400) |> text("course_reg_id does not exist") + _ -> render(conn, "configs_full.json", configs: configs) + end + end + + def update_noti_configs(conn, params) do + changesets = + params["_json"] + |> snake_casify_string_keys_recursive() + |> Stream.map(fn noti_config -> + config = Repo.get(NotificationConfig, noti_config["id"]) + NotificationConfig.changeset(config, noti_config) + end) + |> Enum.to_list() + + case Notifications.update_many_noti_configs(changesets) do + {:ok, res} -> + render(conn, "configs_full.json", configs: res) + + {:error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end + + # NOTIFICATION PREFERENCES + + def upsert_noti_preferences(conn, params) do + changesets = + params["_json"] + |> snake_casify_string_keys_recursive() + |> Stream.map(fn noti_pref -> + if noti_pref["id"] < 0 do + Map.delete(noti_pref, "id") + end + + NotificationPreference.changeset(%NotificationPreference{}, noti_pref) + end) + |> Enum.to_list() + + case Notifications.upsert_many_noti_preferences(changesets) do + {:ok, res} -> + render(conn, "noti_prefs.json", noti_prefs: res) + + {:error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end + + # TIME OPTIONS + + def get_config_time_options(conn, %{"noti_config_id" => noti_config_id}) do + time_options = Notifications.get_time_options_for_config(noti_config_id) + + render(conn, "time_options.json", %{time_options: time_options}) + end + + def upsert_time_options(conn, params) do + changesets = + params["_json"] + |> snake_casify_string_keys_recursive() + |> Stream.map(fn time_option -> + if time_option["id"] < 0 do + Map.delete(time_option, "id") + end + + TimeOption.changeset(%TimeOption{}, time_option) + end) + |> Enum.to_list() + + case Notifications.upsert_many_time_options(changesets) do + {:ok, res} -> + render(conn, "time_options.json", time_options: res) + + {:error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end + + def delete_time_options(conn, params) do + case Notifications.delete_many_time_options(params["_json"]) do + {:ok, res} -> + render(conn, "time_options.json", time_options: res) + + {:error, message} -> + conn |> put_status(400) |> text(message) + + {:delete_error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end + + def upsert_preferable_times(conn, params) do + changesets = + params["_json"] + |> snake_casify_string_keys_recursive() + |> Stream.map(fn preferable_time -> + if preferable_time["id"] < 0 do + Map.delete(preferable_time, "id") + end + + PreferableTime.changeset(%PreferableTime{}, preferable_time) + end) + |> Enum.to_list() + + case Notifications.upsert_many_preferable_times(changesets) do + {:ok, res} -> + render(conn, "preferable_times", preferable_times: res) + + {:error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end + + def delete_preferable_times(conn, params) do + case Notifications.delete_many_preferable_times(params["_json"]) do + {:ok, res} -> + render(conn, "preferable_times.json", preferable_times: res) + + {:error, message} -> + conn |> put_status(400) |> text(message) + + {:delete_error, changeset} -> + conn |> put_status(400) |> text(changeset_error_to_string(changeset)) + end + end +end diff --git a/lib/cadet_web/helpers/controller_helper.ex b/lib/cadet_web/helpers/controller_helper.ex index faa157d07..b4081c1a5 100644 --- a/lib/cadet_web/helpers/controller_helper.ex +++ b/lib/cadet_web/helpers/controller_helper.ex @@ -42,4 +42,19 @@ defmodule CadetWeb.ControllerHelper do |> Map.merge(Enum.into(extra, %{})) } end + + def changeset_error_to_string(changeset) do + errors = + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + + errors + |> Enum.reduce("", fn {k, v}, acc -> + joined_errors = Enum.join(v, "; ") + "#{acc}#{k}: #{joined_errors}\n" + end) + end end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index d0a0fd275..64b5b5a6e 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -70,6 +70,25 @@ defmodule CadetWeb.Router do scope "/v2/courses/:course_id", CadetWeb do pipe_through([:api, :auth, :ensure_auth, :course]) + # notification routers + get( + "/notifications/config/user/:course_reg_id", + NewNotificationsController, + :get_configurable_noti_configs + ) + + put("/notifications/options", NewNotificationsController, :upsert_time_options) + put("/notifications/preferences", NewNotificationsController, :upsert_noti_preferences) + + get( + "/notifications/options/config/:noti_config_id", + NewNotificationsController, + :get_config_time_options + ) + + put("/notifications/preferabletime", NewNotificationsController, :upsert_preferable_times) + delete("/notifications/preferabletime", NewNotificationsController, :delete_preferable_times) + get("/sourcecast", SourcecastController, :index) get("/assessments", AssessmentsController, :index) @@ -100,6 +119,11 @@ defmodule CadetWeb.Router do resources("/sourcecast", AdminSourcecastController, only: [:create, :delete]) + # notification routers + get("/notifications/config", NewNotificationsController, :all_noti_configs) + put("/notifications/config", NewNotificationsController, :update_noti_configs) + delete("/notifications/options", NewNotificationsController, :delete_time_options) + get("/assets/:foldername", AdminAssetsController, :index) post("/assets/:foldername/*filename", AdminAssetsController, :upload) delete("/assets/:foldername/*filename", AdminAssetsController, :delete) diff --git a/lib/cadet_web/views/new_notifications_view.ex b/lib/cadet_web/views/new_notifications_view.ex new file mode 100644 index 000000000..ec26dcf82 --- /dev/null +++ b/lib/cadet_web/views/new_notifications_view.ex @@ -0,0 +1,166 @@ +defmodule CadetWeb.NewNotificationsView do + use CadetWeb, :view + + require IEx + + # Notification Type + def render("noti_types.json", %{noti_types: noti_types}) do + render_many(noti_types, CadetWeb.NewNotificationsView, "noti_type.json", as: :noti_type) + end + + def render("noti_type.json", %{noti_type: noti_type}) do + render_notification_type(noti_type) + end + + # Notification Config + def render("configs_full.json", %{configs: configs}) do + render_many(configs, CadetWeb.NewNotificationsView, "config_full.json", as: :config) + end + + def render("config_full.json", %{config: config}) do + transform_map_for_view(config, %{ + id: :id, + isEnabled: :is_enabled, + course: &render_course(&1.course), + notificationType: &render_notification_type(&1.notification_type), + assessmentConfig: &render_assessment_config(&1.assessment_config), + notificationPreference: &render_first_notification_preferences(&1.notification_preferences), + timeOptions: + &render( + "time_options.json", + %{time_options: &1.time_options} + ) + }) + end + + def render("config.json", %{config: config}) do + transform_map_for_view(config, %{ + id: :id, + isEnabled: :is_enabled + }) + end + + # Notification Preference + def render("noti_pref.json", %{noti_pref: noti_pref}) do + transform_map_for_view(noti_pref, %{ + id: :id, + isEnabled: :is_enabled, + timeOptionId: :time_option_id + }) + end + + def render("noti_prefs.json", %{noti_prefs: noti_prefs}) do + render_many(noti_prefs, CadetWeb.NewNotificationsView, "noti_pref.json", as: :noti_pref) + end + + # Time Options + def render("time_options.json", %{time_options: time_options}) do + case time_options do + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + render_many(time_options, CadetWeb.NewNotificationsView, "time_option.json", + as: :time_option + ) + end + end + + def render("time_option.json", %{time_option: time_option}) do + transform_map_for_view(time_option, %{ + id: :id, + minutes: :minutes, + isDefault: :is_default + }) + end + + # preferable_time + def render("preferable_times.json", %{preferable_times: preferable_times}) do + case preferable_times do + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + render_many(preferable_times, CadetWeb.NewNotificationsView, "preferable_time.json", + as: :preferable_time + ) + end + end + + def render("preferable_time.json", %{preferable_time: preferable_time}) do + transform_map_for_view(preferable_time, %{ + id: :id, + minutes: :minutes + }) + end + + # Helpers + defp render_notification_type(noti_type) do + case noti_type do + nil -> + nil + + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + transform_map_for_view(noti_type, %{ + id: :id, + name: :name, + forStaff: :for_staff, + isEnabled: :is_enabled + }) + end + end + + # query returns an array but there should be max 1 result + defp render_first_notification_preferences(noti_prefs) do + case noti_prefs do + nil -> + nil + + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + if Enum.empty?(noti_prefs) do + nil + else + render("noti_pref.json", %{noti_pref: Enum.at(noti_prefs, 0)}) + end + end + end + + defp render_assessment_config(ass_config) do + case ass_config do + nil -> + nil + + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + transform_map_for_view(ass_config, %{ + id: :id, + type: :type + }) + end + end + + defp render_course(course) do + case course do + nil -> + nil + + %Ecto.Association.NotLoaded{} -> + nil + + _ -> + transform_map_for_view(course, %{ + id: :id, + courseName: :course_name, + courseShortName: :course_short_name + }) + end + end +end diff --git a/mix.exs b/mix.exs index d127c998a..d821d4a38 100644 --- a/mix.exs +++ b/mix.exs @@ -7,6 +7,8 @@ defmodule Cadet.Mixfile do version: "0.0.1", elixir: "~> 1.10", elixirc_paths: elixirc_paths(Mix.env()), + # compilers: [:phoenix, :gettext] ++ Mix.compilers() ++ [:phoenix_swagger], + # compilers: [:phoenix] ++ Mix.compilers() ++ [:phoenix_swagger], compilers: Mix.compilers() ++ [:phoenix_swagger], start_permanent: Mix.env() == :prod, test_coverage: [tool: ExCoveralls], @@ -81,6 +83,7 @@ defmodule Cadet.Mixfile do {:sentry, "~> 8.0"}, {:sweet_xml, "~> 0.6"}, {:timex, "~> 3.7"}, + {:gettext, "~> 0.22.3"}, # notifiations system dependencies {:phoenix_html, "~> 3.0"}, diff --git a/mix.lock b/mix.lock index 138bb4fbd..0ed931323 100644 --- a/mix.lock +++ b/mix.lock @@ -41,12 +41,11 @@ "excoveralls": {:hex, :excoveralls, "0.17.1", "83fa7906ef23aa7fc8ad7ee469c357a63b1b3d55dd701ff5b9ce1f72442b2874", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "95bc6fda953e84c60f14da4a198880336205464e75383ec0f570180567985ae0"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, - "exvcr": {:hex, :exvcr, "0.14.4", "1aa5fe7d3f10b117251c158f8d28b39f7fc73d0a7628b2d0b75bf8cfb1111576", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4e600568c02ed29d46bc2e2c74927d172ba06658aa8b14705c0207363c44cc94"}, + "exvcr": {:hex, :exvcr, "0.13.3", "fcd5f54ea0ebd41db7fe16701f3c67871d1b51c3c104ab88f11135a173d47134", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "db61057447388b7adc4443a55047d11d09acc75eeb5548507c775a8402e02689"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, - "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, - "gettext": {:hex, :gettext, "0.22.2", "6bfca374de34ecc913a28ba391ca184d88d77810a3e427afa8454a71a51341ac", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "8a2d389673aea82d7eae387e6a2ccc12660610080ae7beb19452cfdc1ec30f60"}, + "gen_stage": {:hex, :gen_stage, "1.1.2", "b1656cd4ba431ed02c5656fe10cb5423820847113a07218da68eae5d6a260c23", [:mix], [], "hexpm", "9e39af23140f704e2b07a3e29d8f05fd21c2aaf4088ff43cb82be4b9e3148d02"}, + "gettext": {:hex, :gettext, "0.22.3", "c8273e78db4a0bb6fba7e9f0fd881112f349a3117f7f7c598fa18c66c888e524", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "935f23447713954a6866f1bb28c3a878c4c011e802bcd68a726f5e558e4b64bd"}, "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [: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", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, diff --git a/priv/repo/migrations/20230315053558_notification_types_add_for_staff_column.exs b/priv/repo/migrations/20230315053558_notification_types_add_for_staff_column.exs new file mode 100644 index 000000000..61f074f94 --- /dev/null +++ b/priv/repo/migrations/20230315053558_notification_types_add_for_staff_column.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.NotificationTypesAddForStaffColumn do + use Ecto.Migration + + def change do + alter table(:notification_types) do + add(:for_staff, :boolean, null: false, default: true) + end + end +end diff --git a/priv/repo/migrations/20230404082921_add_unique_constraint_notification_preferences.exs b/priv/repo/migrations/20230404082921_add_unique_constraint_notification_preferences.exs new file mode 100644 index 000000000..6e141e5b2 --- /dev/null +++ b/priv/repo/migrations/20230404082921_add_unique_constraint_notification_preferences.exs @@ -0,0 +1,11 @@ +defmodule Cadet.Repo.Migrations.AddUniqueConstraintNotificationPreferences do + use Ecto.Migration + + def change do + create( + unique_index(:notification_preferences, [:notification_config_id, :course_reg_id], + name: :single_preference_per_config + ) + ) + end +end diff --git a/priv/repo/migrations/20230714090532_change_avenger_backlog_notification_type.exs b/priv/repo/migrations/20230714090532_change_avenger_backlog_notification_type.exs new file mode 100644 index 000000000..9dc4a52ca --- /dev/null +++ b/priv/repo/migrations/20230714090532_change_avenger_backlog_notification_type.exs @@ -0,0 +1,15 @@ +defmodule Cadet.Repo.Migrations.ChangeAvengerBacklogNotificationType do + use Ecto.Migration + + def up do + execute( + "UPDATE notification_types SET is_autopopulated = FALSE WHERE name = 'AVENGER BACKLOG'" + ) + end + + def down do + execute( + "UPDATE notification_types SET is_autopopulated = TRUE WHERE name = 'AVENGER BACKLOG'" + ) + end +end diff --git a/priv/repo/migrations/20230720065038_create_table_preferable_times.exs b/priv/repo/migrations/20230720065038_create_table_preferable_times.exs new file mode 100644 index 000000000..78cf1ade1 --- /dev/null +++ b/priv/repo/migrations/20230720065038_create_table_preferable_times.exs @@ -0,0 +1,23 @@ +defmodule Cadet.Repo.Migrations.CreateTablePreferableTimes do + use Ecto.Migration + + def change do + create table(:preferable_times) do + add(:minutes, :integer, null: false) + + add( + :notification_preference_id, + references(:notification_preferences, on_delete: :delete_all), + null: false + ) + + timestamps() + end + + create( + unique_index(:preferable_times, [:minutes, :notification_preference_id], + name: :unique_preferable_times + ) + ) + end +end diff --git a/test/cadet/jobs/notification_worker/notification_worker_test.exs b/test/cadet/jobs/notification_worker/notification_worker_test.exs deleted file mode 100644 index 41606d4ce..000000000 --- a/test/cadet/jobs/notification_worker/notification_worker_test.exs +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Cadet.NotificationWorker.NotificationWorkerTest do - use ExUnit.Case, async: true - use Oban.Testing, repo: Cadet.Repo - use Cadet.DataCase - use Bamboo.Test - - alias Cadet.Repo - alias Cadet.Workers.NotificationWorker - alias Cadet.Notifications.{NotificationType, NotificationConfig} - - setup do - assessments = Cadet.Test.Seeds.assessments() - avenger_cr = assessments.course_regs.avenger1_cr - - # setup for assessment submission - asssub_ntype = Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") - {_name, data} = Enum.at(assessments.assessments, 0) - submission = List.first(List.first(data.mcq_answers)).submission - - # setup for avenger backlog - ungraded_submissions = - Jason.decode!( - elem(Cadet.Assessments.all_submissions_by_grader_for_index(avenger_cr, true, true), 1) - ) - - Repo.update_all(NotificationType, set: [is_enabled: true]) - Repo.update_all(NotificationConfig, set: [is_enabled: true]) - - {:ok, - %{ - avenger_user: avenger_cr.user, - ungraded_submissions: ungraded_submissions, - submission_id: submission.id - }} - end - - test "avenger backlog test", %{ - avenger_user: avenger_user - } do - perform_job(NotificationWorker, %{"notification_type" => "avenger_backlog"}) - - avenger_email = avenger_user.email - assert_delivered_email_matches(%{to: [{_, ^avenger_email}]}) - end - - test "assessment submission test", %{ - avenger_user: avenger_user, - submission_id: submission_id - } do - perform_job(NotificationWorker, %{ - "notification_type" => "assessment_submission", - submission_id: submission_id - }) - - avenger_email = avenger_user.email - assert_delivered_email_matches(%{to: [{_, ^avenger_email}]}) - end -end diff --git a/test/cadet/notifications/notification_type_test.exs b/test/cadet/notifications/notification_type_test.exs index 547795521..6bf63b0f7 100644 --- a/test/cadet/notifications/notification_type_test.exs +++ b/test/cadet/notifications/notification_type_test.exs @@ -10,7 +10,8 @@ defmodule Cadet.Notifications.NotificationTypeTest do name: "Notification Type 1", template_file_name: "template_file_1", is_enabled: true, - is_autopopulated: true + is_autopopulated: true, + for_staff: true }) {:ok, _noti_type1} = Repo.insert(changeset) @@ -25,7 +26,8 @@ defmodule Cadet.Notifications.NotificationTypeTest do name: "Notification Type 2", template_file_name: "template_file_2", is_enabled: false, - is_autopopulated: true + is_autopopulated: true, + for_staff: true }, :valid ) @@ -36,7 +38,8 @@ defmodule Cadet.Notifications.NotificationTypeTest do %{ template_file_name: "template_file_2", is_enabled: false, - is_autopopulated: true + is_autopopulated: true, + for_staff: true }, :invalid ) @@ -47,6 +50,19 @@ defmodule Cadet.Notifications.NotificationTypeTest do %{ name: "Notification Type 2", is_enabled: false, + is_autopopulated: true, + for_staff: false + }, + :invalid + ) + end + + test "invalid changesets missing for_staff" do + assert_changeset( + %{ + name: "Notification Type 2", + template_file_name: "template_file_2", + is_enabled: false, is_autopopulated: true }, :invalid @@ -70,7 +86,8 @@ defmodule Cadet.Notifications.NotificationTypeTest do %{ name: "Notification Type 0", is_enabled: nil, - is_autopopulated: true + is_autopopulated: true, + for_staff: true }, :invalid ) diff --git a/test/cadet/notifications/notifications_test.exs b/test/cadet/notifications/notifications_test.exs index 5640eeebd..cd91e5816 100644 --- a/test/cadet/notifications/notifications_test.exs +++ b/test/cadet/notifications/notifications_test.exs @@ -15,6 +15,13 @@ defmodule Cadet.NotificationsTest do describe "notification_configs" do @invalid_attrs %{is_enabled: nil} + test "get_notification_config!/1 returns the notification_config with given id" do + notification_config = insert(:notification_config) + + assert Notifications.get_notification_config!(notification_config.id).id == + notification_config.id + end + test "get_notification_config!/3 returns the notification_config with given id" do notification_config = insert(:notification_config) @@ -35,6 +42,16 @@ defmodule Cadet.NotificationsTest do ).id == notification_config.id end + test "get_notification_configs!/1 returns all inserted notification configs" do + course = insert(:course) + assessment_config = insert(:assessment_config, course: course) + notification_config_1 = insert(:notification_config, assessment_config: assessment_config) + notification_config_2 = insert(:notification_config, assessment_config: nil) + + result = Notifications.get_notification_configs(course.id) + assert length(result) == 2 + end + test "update_notification_config/2 with valid data updates the notification_config" do notification_config = insert(:notification_config) update_attrs = %{is_enabled: true} @@ -93,6 +110,19 @@ defmodule Cadet.NotificationsTest do ).id == time_option.id end + test "get_time_options_for_config/1 returns the time_options that belongs to the notification config" do + notification_config = insert(:notification_config) + + time_options = + insert_list(3, :time_option, %{ + :notification_config => notification_config, + :is_default => true + }) + + assert length(Notifications.get_time_options_for_config(notification_config.id)) == + length(time_options) + end + test "create_time_option/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Notifications.create_time_option(@invalid_attrs) end @@ -102,13 +132,42 @@ defmodule Cadet.NotificationsTest do assert {:ok, %TimeOption{}} = Notifications.delete_time_option(time_option) assert_raise Ecto.NoResultsError, fn -> Notifications.get_time_option!(time_option.id) end end + + test "delete_many_time_options/1 deletes all the time_options" do + time_options = insert_list(3, :time_option) + ids = Enum.map(time_options, fn to -> to.id end) + + Notifications.delete_many_time_options(ids) + + for to <- time_options do + assert_raise Ecto.NoResultsError, fn -> Notifications.get_time_option!(to.id) end + end + end + + test "delete_many_time_options/1 rollsback on failed deletion" do + time_options = insert_list(3, :time_option) + ids = Enum.map(time_options, fn to -> to.id end) ++ [-1] + + assert {:error, _} = Notifications.delete_many_time_options(ids) + + for to <- time_options do + assert %TimeOption{} = Notifications.get_time_option!(to.id) + end + end end describe "notification_preferences" do @invalid_attrs %{is_enabled: nil} test "get_notification_preference!/1 returns the notification_preference with given id" do - notification_type = insert(:notification_type, name: "get_notification_preference!/1") + notification_preference = insert(:notification_preference) + + assert Notifications.get_notification_preference!(notification_preference.id).id == + notification_preference.id + end + + test "get_notification_preference/2 returns the notification_preference with given values" do + notification_type = insert(:notification_type, name: "get_notification_preference/2") notification_config = insert(:notification_config, notification_type: notification_type) notification_preference = diff --git a/test/factories/notifications/notification_type_factory.ex b/test/factories/notifications/notification_type_factory.ex index 5c7995564..447746879 100644 --- a/test/factories/notifications/notification_type_factory.ex +++ b/test/factories/notifications/notification_type_factory.ex @@ -11,7 +11,7 @@ defmodule Cadet.Notifications.NotificationTypeFactory do %NotificationType{ is_autopopulated: false, is_enabled: false, - name: "Generic Notificaation Type", + name: Faker.Pokemon.name(), template_file_name: "generic_template_name" } end diff --git a/test/factories/notifications/time_option_factory.ex b/test/factories/notifications/time_option_factory.ex index d5aa1c898..c2c677cef 100644 --- a/test/factories/notifications/time_option_factory.ex +++ b/test/factories/notifications/time_option_factory.ex @@ -10,7 +10,7 @@ defmodule Cadet.Notifications.TimeOptionFactory do def time_option_factory do %TimeOption{ is_default: false, - minutes: 0, + minutes: :rand.uniform(500), notification_config: build(:notification_config) } end