diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex index cc65d529a..a3afc7aac 100644 --- a/lib/cadet/notifications.ex +++ b/lib/cadet/notifications.ex @@ -62,7 +62,76 @@ defmodule Cadet.Notifications do where(query, [c], c.assessment_config_id == ^assconfig_id) end - Repo.one(query) + config = Repo.one(query) + + if config != nil do + config + else + IO.puts( + "No NotificationConfig found for Course #{course_id} and NotificationType #{notification_type_id}" + ) + + nil + end + 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 = + from(n in Cadet.Notifications.NotificationConfig, + where: n.course_id == ^course_id + ) + + query + |> Repo.all() + |> 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 = + from(n in Cadet.Notifications.NotificationConfig, + join: ntype in Cadet.Notifications.NotificationType, + on: n.notification_type_id == ntype.id, + join: c in Cadet.Courses.Course, + on: n.course_id == c.id, + left_join: ac in Cadet.Courses.AssessmentConfig, + on: n.assessment_config_id == ac.id, + left_join: p in Cadet.Notifications.NotificationPreference, + on: p.notification_config_id == n.id, + where: + 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)) + ) + + query + |> Repo.all() + |> Repo.preload([ + :notification_type, + :course, + :assessment_config, + :time_options, + :notification_preferences + ]) + end end @doc """ @@ -83,6 +152,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 +192,23 @@ 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 = + from(to in Cadet.Notifications.TimeOption, + join: nc in Cadet.Notifications.NotificationConfig, + on: to.notification_config_id == nc.id, + where: 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 +223,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 +260,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 +304,41 @@ 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) + + if is_nil(time_option) do + Repo.rollback("Time option do not exist") + else + case Repo.delete(time_option) do + {:ok, time_option} -> 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 = + from(np in NotificationPreference, + left_join: to in TimeOption, + on: to.id == np.time_option_id, + where: 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, 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/workers/NotificationWorker.ex b/lib/cadet/workers/NotificationWorker.ex index d96a3df22..779e97cff 100644 --- a/lib/cadet/workers/NotificationWorker.ex +++ b/lib/cadet/workers/NotificationWorker.ex @@ -73,31 +73,35 @@ 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 + IO.puts("[AVENGER_BACKLOG] below threshold!") + else + IO.puts("[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 + IO.puts("[ASSESSMENT_SUBMISSION] user-level disabled") end end else 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..dfcd63942 --- /dev/null +++ b/lib/cadet_web/controllers/new_notifications_controller.ex @@ -0,0 +1,116 @@ +defmodule CadetWeb.NewNotificationsController do + use CadetWeb, :controller + + alias Cadet.{Repo, Notifications} + alias Cadet.Notifications.{NotificationPreference, NotificationConfig, TimeOption} + + # 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 + # time_option = Repo.get(TimeOption, time_option_id) + + # if is_nil(time_option) do + # conn |> put_status(404) |> text("Time option of given ID not found") + # end + + # case Repo.delete(time_option) do + # JUNYI AND SANTOSH: NOT DEFINED??? + 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 +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 cbd3fb755..437f75d3b 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -169,6 +169,19 @@ defmodule CadetWeb.Router do ) end + # Notifications endpoints + scope "/v2/notifications/", CadetWeb do + get("/config/:course_id", NewNotificationsController, :all_noti_configs) + get("/config/user/:course_reg_id", NewNotificationsController, :get_configurable_noti_configs) + put("/config/", NewNotificationsController, :update_noti_configs) + + put("/preferences", NewNotificationsController, :upsert_noti_preferences) + + get("/options/config/:noti_config_id", NewNotificationsController, :get_config_time_options) + put("/options", NewNotificationsController, :upsert_time_options) + delete("/options", NewNotificationsController, :delete_time_options) + end + # Other scopes may use custom stacks. # scope "/api", CadetWeb do # pipe_through :api 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..e5ef34aa8 --- /dev/null +++ b/lib/cadet_web/views/new_notifications_view.ex @@ -0,0 +1,146 @@ +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 + + # 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/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/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/cadet_web/controllers/new_notifications_controller_test.exs b/test/cadet_web/controllers/new_notifications_controller_test.exs new file mode 100644 index 000000000..a5a217e76 --- /dev/null +++ b/test/cadet_web/controllers/new_notifications_controller_test.exs @@ -0,0 +1,231 @@ +# Results of tests depends on the number of notifications implemented in Source Academy, +# test expected values have to be updated as more notification types are introduced +defmodule CadetWeb.NewNotificationsControllerTest do + use CadetWeb.ConnCase + + import Ecto.Query, warn: false + + alias Cadet.Notifications.{NotificationConfig, NotificationType} + + setup do + course = insert(:course) + assessment_config = insert(:assessment_config, %{course: course}) + + assessment = + insert(:assessment, %{ + is_published: true, + course: course, + config: assessment_config + }) + + avenger = insert(:course_registration, %{role: :staff, course: course}) + student = insert(:course_registration, %{role: :student, course: course}) + submission = insert(:submission, %{student: student, assessment: assessment}) + + Ecto.Adapters.SQL.Sandbox.checkout(Cadet.Repo) + + course_noticonfig_query = + from( + nc in NotificationConfig, + join: ntype in NotificationType, + on: nc.notification_type_id == ntype.id, + where: + nc.course_id == ^course.id and is_nil(nc.assessment_config_id) and + ntype.for_staff == true, + limit: 1 + ) + + course_noticonfig = Cadet.Repo.one(course_noticonfig_query) + + # insert a notification preference for the avenger + avenger_preference = + insert(:notification_preference, %{ + notification_config: course_noticonfig, + course_reg: avenger, + is_enabled: false + }) + + # insert 2 time options for the notification config + time_options = insert_list(2, :time_option, %{notification_config: course_noticonfig}) + + {:ok, + %{ + course: course, + assessment_config: assessment_config, + assessment: assessment, + avenger: avenger, + student: student, + submission: submission, + course_noticonfig: course_noticonfig, + avenger_preference: avenger_preference, + time_options: time_options + }} + end + + describe "GET /v2/notifications/config/:course_id" do + test "200 suceeds", %{course: course, conn: conn} do + conn = get(conn, "/v2/notifications/config/#{course.id}") + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 2 + end + end + + describe "GET /v2/notifications/config/user/:course_reg_id" do + test "200 succeeds for avenger", %{avenger: avenger, conn: conn} do + conn = get(conn, "/v2/notifications/config/user/#{avenger.id}") + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 2 + end + + test "200 succeeds for student", %{student: student, conn: conn} do + conn = get(conn, "/v2/notifications/config/user/#{student.id}") + result = Jason.decode!(response(conn, 200)) + + assert Enum.empty?(result) + end + + test "400 fails, user does not exist", %{conn: conn} do + conn = get(conn, "/v2/notifications/config/user/-1") + assert response(conn, 400) + end + end + + describe "PUT /v2/notifications/config" do + test "200 succeeds", %{course_noticonfig: course_noticonfig, conn: conn} do + conn = + put(conn, "/v2/notifications/config", %{ + "_json" => [%{:id => course_noticonfig.id, :isEnabled => true}] + }) + + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 1 + assert List.first(result)["isEnabled"] == true + end + end + + describe "PUT /v2/notifications/preferences" do + test "200 succeeds, update", %{ + avenger_preference: avenger_preference, + avenger: avenger, + course_noticonfig: course_noticonfig, + conn: conn + } do + conn = + put(conn, "/v2/notifications/preferences", %{ + "_json" => [ + %{ + :id => avenger_preference.id, + :courseRegId => avenger.id, + :notificationConfigId => course_noticonfig.id, + :isEnabled => true + } + ] + }) + + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 1 + assert List.first(result)["isEnabled"] == true + end + end + + describe "GET /options/config/:noti_config_id" do + test "200 succeeds", %{ + course_noticonfig: course_noticonfig, + time_options: time_options, + conn: conn + } do + conn = get(conn, "/v2/notifications/options/config/#{course_noticonfig.id}") + result = Jason.decode!(response(conn, 200)) + + assert length(result) == length(time_options) + + for {retrieved_to, to} <- Enum.zip(result, time_options) do + assert retrieved_to["minutes"] == to.minutes + end + end + + test "200 succeeds, empty array as notification config record not found", %{conn: conn} do + conn = get(conn, "/v2/notifications/options/config/-1") + result = Jason.decode!(response(conn, 200)) + + assert Enum.empty?(result) + end + end + + # Due to unique constraint on the column 'minutes', + # test cases may fail if the generator produces the same number + describe "PUT /v2/notifications/options" do + test "200 succeeds, update", %{ + time_options: time_options, + course_noticonfig: course_noticonfig, + conn: conn + } do + time_option = List.first(time_options) + new_minutes = :rand.uniform(200) + + conn = + put(conn, "/v2/notifications/options", %{ + "_json" => [ + %{ + :id => time_option.id, + :notificationConfigId => course_noticonfig.id, + :minutes => new_minutes + } + ] + }) + + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 1 + assert List.first(result)["minutes"] == new_minutes + end + + test "200 succeeds, insert", %{ + course_noticonfig: course_noticonfig, + conn: conn + } do + minutes = :rand.uniform(500) + + conn = + put(conn, "/v2/notifications/options", %{ + "_json" => [%{:notificationConfigId => course_noticonfig.id, :minutes => minutes}] + }) + + result = Jason.decode!(response(conn, 200)) + + assert length(result) == 1 + assert List.first(result)["minutes"] == minutes + end + end + + describe "DELETE /v2/notifications/options" do + test "200 succeeds", %{ + time_options: time_options, + conn: conn + } do + time_option = List.first(time_options) + + conn = + delete(conn, "/v2/notifications/options", %{ + "_json" => [time_option.id] + }) + + assert response(conn, 200) + end + end + + test "400 fails, no such time option", %{ + conn: conn + } do + conn = + delete(conn, "/v2/notifications/options", %{ + "_json" => [-1] + }) + + assert response(conn, 400) + end +end 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