diff --git a/config/config.exs b/config/config.exs index d09152802..244fe1e2e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -72,10 +72,6 @@ config :sentry, root_source_code_path: File.cwd!(), context_lines: 5 -# Import environment specific config. This must remain at the bottom -# of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" - # Configure Phoenix Swagger config :cadet, :phoenix_swagger, swagger_files: %{ @@ -93,3 +89,22 @@ config :guardian, Guardian.DB, token_types: ["refresh"], # default: 60 minute sweep_interval: 180 + +config :cadet, Oban, + repo: Cadet.Repo, + plugins: [ + # keep + {Oban.Plugins.Pruner, max_age: 60}, + {Oban.Plugins.Cron, + crontab: [ + {"@daily", Cadet.Workers.NotificationWorker, + args: %{"notification_type" => "avenger_backlog"}} + ]} + ], + queues: [default: 10, notifications: 1] + +config :cadet, Cadet.Mailer, adapter: Bamboo.LocalAdapter + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" diff --git a/config/prod.exs b/config/prod.exs index 5c2491d71..c41ba0cb1 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -44,3 +44,5 @@ config :logger, level: :info config :ex_aws, access_key_id: [:instance_role], secret_access_key: [:instance_role] + +config :cadet, Cadet.Mailer, adapter: Bamboo.SesAdapter diff --git a/config/test.exs b/config/test.exs index eec4fc18f..05ca74630 100644 --- a/config/test.exs +++ b/config/test.exs @@ -88,3 +88,9 @@ config :arc, storage: Arc.Storage.Local if "test.secrets.exs" |> Path.expand(__DIR__) |> File.exists?(), do: import_config("test.secrets.exs") + +config :cadet, Oban, + repo: Cadet.Repo, + testing: :manual + +config :cadet, Cadet.Mailer, adapter: Bamboo.TestAdapter diff --git a/lib/cadet/accounts/course_registrations.ex b/lib/cadet/accounts/course_registrations.ex index 56a125527..c45549199 100644 --- a/lib/cadet/accounts/course_registrations.ex +++ b/lib/cadet/accounts/course_registrations.ex @@ -63,6 +63,13 @@ defmodule Cadet.Accounts.CourseRegistrations do |> Repo.all() end + def get_staffs(course_id) do + CourseRegistration + |> where(course_id: ^course_id) + |> where(role: :staff) + |> Repo.all() + end + def get_users(course_id, group_id) when is_ecto_id(group_id) and is_ecto_id(course_id) do CourseRegistration |> where([cr], cr.course_id == ^course_id) @@ -200,4 +207,23 @@ defmodule Cadet.Accounts.CourseRegistrations do {:error, changeset} -> {:error, {:bad_request, full_error_messages(changeset)}} end end + + def get_avenger_of(student_id) when is_ecto_id(student_id) do + CourseRegistration + |> Repo.get_by(id: student_id) + |> Repo.preload(:group) + |> Map.get(:group) + |> case do + nil -> + nil + + group -> + avenger_id = Map.get(group, :leader_id) + + CourseRegistration + |> where([cr], cr.id == ^avenger_id) + |> preload(:user) + |> Repo.one() + end + end end diff --git a/lib/cadet/accounts/user.ex b/lib/cadet/accounts/user.ex index aa1b8813b..d3f3c6026 100644 --- a/lib/cadet/accounts/user.ex +++ b/lib/cadet/accounts/user.ex @@ -20,6 +20,7 @@ defmodule Cadet.Accounts.User do field(:username, :string) field(:provider, :string) field(:super_admin, :boolean) + field(:email, :string) belongs_to(:latest_viewed_course, Course) has_many(:courses, CourseRegistration) diff --git a/lib/cadet/application.ex b/lib/cadet/application.ex index b825375bf..f2249418a 100644 --- a/lib/cadet/application.ex +++ b/lib/cadet/application.ex @@ -20,7 +20,9 @@ defmodule Cadet.Application do # Start the GuardianDB sweeper worker(Guardian.DB.Token.SweeperServer, []), # Start the Quantum scheduler - worker(Cadet.Jobs.Scheduler, []) + worker(Cadet.Jobs.Scheduler, []), + # Start the Oban instance + {Oban, Application.fetch_env!(:cadet, Oban)} ] children = diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index dec65663d..6412ada2a 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -721,11 +721,24 @@ defmodule Cadet.Assessments do |> Repo.one() end + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + def finalise_submission(submission = %Submission{}) do with {:status, :attempted} <- {:status, submission.status}, {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do # Couple with update_submission_status_and_xp_bonus to ensure notification is sent Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + # Begin autograding job GradingJob.force_grade_individual_submission(updated_submission) @@ -1151,7 +1164,8 @@ defmodule Cadet.Assessments do {:ok, String.t()} def all_submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, - group_only \\ false + group_only \\ false, + ungraded_only \\ false ) do show_all = not group_only @@ -1161,6 +1175,11 @@ defmodule Cadet.Assessments do else: "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + params = if show_all, do: [course_id], else: [course_id, grader.id] # We bypass Ecto here and use a raw query to generate JSON directly from @@ -1200,7 +1219,7 @@ defmodule Cadet.Assessments do group by s.id) s inner join (select - a.id, to_json(a) as jsn + a.id, a."questionCount", to_json(a) as jsn from (select a.id, @@ -1240,6 +1259,7 @@ defmodule Cadet.Assessments do from course_registrations cr inner join users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} ) q """, params diff --git a/lib/cadet/courses/courses.ex b/lib/cadet/courses/courses.ex index e400c709c..5c0464fae 100644 --- a/lib/cadet/courses/courses.ex +++ b/lib/cadet/courses/courses.ex @@ -82,6 +82,12 @@ defmodule Cadet.Courses do end end + def get_all_course_ids do + Course + |> select([c], c.id) + |> Repo.all() + end + defp retrieve_course(course_id) when is_ecto_id(course_id) do Course |> where(id: ^course_id) @@ -233,8 +239,8 @@ defmodule Cadet.Courses do ) |> where(course_id: ^course_id) |> Repo.one()} do - # It is ok to assume that user course registions already exist, as they would have been created - # in the admin_user_controller before calling this function + # It is ok to assume that user course registions already exist, as they would + # have been created in the admin_user_controller before calling this function case role do # If student, update his course registration :student -> diff --git a/lib/cadet/email.ex b/lib/cadet/email.ex new file mode 100644 index 000000000..b7ff08101 --- /dev/null +++ b/lib/cadet/email.ex @@ -0,0 +1,40 @@ +defmodule Cadet.Email do + @moduledoc """ + Contains methods for sending email notifications. + """ + use Bamboo.Phoenix, view: CadetWeb.EmailView + import Bamboo.Email + + def avenger_backlog_email(template_file_name, avenger, ungraded_submissions) do + if is_nil(avenger.email) do + nil + else + base_email() + |> to(avenger.email) + |> assign(:avenger_name, avenger.name) + |> assign(:submissions, ungraded_submissions) + |> subject("Backlog for #{avenger.name}") + |> render("#{template_file_name}.html") + end + end + + def assessment_submission_email(template_file_name, avenger, student, submission) do + if is_nil(avenger.email) do + nil + else + base_email() + |> to(avenger.email) + |> assign(:avenger_name, avenger.name) + |> assign(:student_name, student.name) + |> assign(:assessment_title, submission.assessment.title) + |> subject("New submission for #{submission.assessment.title}") + |> render("#{template_file_name}.html") + end + end + + defp base_email do + new_email() + |> from("noreply@sourceacademy.org") + |> put_html_layout({CadetWeb.LayoutView, "email.html"}) + end +end diff --git a/lib/cadet/jobs/scheduler.ex b/lib/cadet/jobs/scheduler.ex index 06a10ae68..dd09922dc 100644 --- a/lib/cadet/jobs/scheduler.ex +++ b/lib/cadet/jobs/scheduler.ex @@ -1,5 +1,8 @@ # credo:disable-for-this-file Credo.Check.Readability.ModuleDoc # @moduledoc is actually generated by a macro inside Quantum defmodule Cadet.Jobs.Scheduler do + @moduledoc """ + Quantum is used for scheduling jobs with cron jobs. + """ use Quantum, otp_app: :cadet end diff --git a/lib/cadet/mailer.ex b/lib/cadet/mailer.ex new file mode 100644 index 000000000..f88cfd706 --- /dev/null +++ b/lib/cadet/mailer.ex @@ -0,0 +1,6 @@ +defmodule Cadet.Mailer do + @moduledoc """ + Mailer used to sent notification emails. + """ + use Bamboo.Mailer, otp_app: :cadet +end diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex new file mode 100644 index 000000000..cc65d529a --- /dev/null +++ b/lib/cadet/notifications.ex @@ -0,0 +1,308 @@ +defmodule Cadet.Notifications do + @moduledoc """ + The Notifications context. + """ + + import Ecto.Query, warn: false + alias Cadet.Repo + + alias Cadet.Notifications.{ + NotificationType, + NotificationConfig, + SentNotification, + TimeOption, + NotificationPreference + } + + @doc """ + Gets a single notification_type. + + Raises `Ecto.NoResultsError` if the Notification type does not exist. + + ## Examples + + iex> get_notification_type!(123) + %NotificationType{} + + iex> get_notification_type!(456) + ** (Ecto.NoResultsError) + + """ + def get_notification_type!(id), do: Repo.get!(NotificationType, id) + + @doc """ + Gets a single notification_type by name.any() + + Raises `Ecto.NoResultsError` if the Notification type does not exist. + + ## Examples + + iex> get_notification_type_by_name!("AVENGER BACKLOG") + %NotificationType{} + + iex> get_notification_type_by_name!("AVENGER BACKLOG") + ** (Ecto.NoResultsError) + """ + def get_notification_type_by_name!(name) do + Repo.one!(from(nt in NotificationType, where: nt.name == ^name)) + end + + 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 + ) + + 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 + + Repo.one(query) + end + + @doc """ + Updates a notification_config. + + ## Examples + + iex> update_notification_config(notification_config, %{field: new_value}) + {:ok, %NotificationConfig{}} + + iex> update_notification_config(notification_config, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_notification_config(notification_config = %NotificationConfig{}, attrs) do + notification_config + |> NotificationConfig.changeset(attrs) + |> Repo.update() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking notification_config changes. + + ## Examples + + iex> change_notification_config(notification_config) + %Ecto.Changeset{data: %NotificationConfig{}} + + """ + def change_notification_config(notification_config = %NotificationConfig{}, attrs \\ %{}) do + NotificationConfig.changeset(notification_config, attrs) + end + + @doc """ + Gets a single time_option. + + Raises `Ecto.NoResultsError` if the Time option does not exist. + + ## Examples + + iex> get_time_option!(123) + %TimeOption{} + + iex> get_time_option!(456) + ** (Ecto.NoResultsError) + + """ + def get_time_option!(id), do: Repo.get!(TimeOption, id) + + def get_time_options_for_assessment(assessment_config_id, notification_type_id) do + query = + from(ac in Cadet.Courses.AssessmentConfig, + join: n in Cadet.Notifications.NotificationConfig, + on: n.assessment_config_id == ac.id, + join: to in Cadet.Notifications.TimeOption, + on: to.notification_config_id == n.id, + where: ac.id == ^assessment_config_id and n.notification_type_id == ^notification_type_id, + select: to + ) + + Repo.all(query) + end + + def get_default_time_option_for_assessment!(assessment_config_id, notification_type_id) do + query = + from(ac in Cadet.Courses.AssessmentConfig, + join: n in Cadet.Notifications.NotificationConfig, + on: n.assessment_config_id == ac.id, + join: to in Cadet.Notifications.TimeOption, + on: to.notification_config_id == n.id, + where: + ac.id == ^assessment_config_id and n.notification_type_id == ^notification_type_id and + to.is_default == true, + select: to + ) + + Repo.one!(query) + end + + @doc """ + Creates a time_option. + + ## Examples + + iex> create_time_option(%{field: value}) + {:ok, %TimeOption{}} + + iex> create_time_option(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_time_option(attrs \\ %{}) do + %TimeOption{} + |> TimeOption.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes a time_option. + + ## Examples + + iex> delete_time_option(time_option) + {:ok, %TimeOption{}} + + iex> delete_time_option(time_option) + {:error, %Ecto.Changeset{}} + + """ + def delete_time_option(time_option = %TimeOption{}) do + Repo.delete(time_option) + end + + def get_notification_preference(notification_type_id, course_reg_id) do + query = + from(np in NotificationPreference, + join: noti in Cadet.Notifications.NotificationConfig, + on: np.notification_config_id == noti.id, + join: ntype in NotificationType, + on: noti.notification_type_id == ntype.id, + where: ntype.id == ^notification_type_id and np.course_reg_id == ^course_reg_id, + preload: :time_option + ) + + Repo.one(query) + end + + @doc """ + Creates a notification_preference. + + ## Examples + + iex> create_notification_preference(%{field: value}) + {:ok, %NotificationPreference{}} + + iex> create_notification_preference(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_notification_preference(attrs \\ %{}) do + %NotificationPreference{} + |> NotificationPreference.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a notification_preference. + + ## Examples + + iex> update_notification_preference(notification_preference, %{field: new_value}) + {:ok, %NotificationPreference{}} + + iex> update_notification_preference(notification_preference, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_notification_preference(notification_preference = %NotificationPreference{}, attrs) do + notification_preference + |> NotificationPreference.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a notification_preference. + + ## Examples + + iex> delete_notification_preference(notification_preference) + {:ok, %NotificationPreference{}} + + iex> delete_notification_preference(notification_preference) + {:error, %Ecto.Changeset{}} + + """ + def delete_notification_preference(notification_preference = %NotificationPreference{}) do + Repo.delete(notification_preference) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking notification_preference changes. + + ## Examples + + iex> change_notification_preference(notification_preference) + %Ecto.Changeset{data: %NotificationPreference{}} + + """ + def change_notification_preference( + notification_preference = %NotificationPreference{}, + attrs \\ %{} + ) do + NotificationPreference.changeset(notification_preference, attrs) + end + + @doc """ + Creates a sent_notification. + + ## Examples + + iex> create_sent_notification(%{field: value}) + {:ok, %SentNotification{}} + + iex> create_sent_notification(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_sent_notification(course_reg_id, content) do + %SentNotification{} + |> SentNotification.changeset(%{course_reg_id: course_reg_id, content: content}) + |> Repo.insert() + end + + @doc """ + Returns the list of sent_notifications. + + ## Examples + + iex> list_sent_notifications() + [%SentNotification{}, ...] + + """ + + # def list_sent_notifications do + # Repo.all(SentNotification) + # end + + # @doc """ + # Gets a single sent_notification. + + # Raises `Ecto.NoResultsError` if the Sent notification does not exist. + + # ## Examples + + # iex> get_sent_notification!(123) + # %SentNotification{} + + # iex> get_sent_notification!(456) + # ** (Ecto.NoResultsError) + + # """ + # # def get_sent_notification!(id), do: Repo.get!(SentNotification, id) +end diff --git a/lib/cadet/notifications/notification_config.ex b/lib/cadet/notifications/notification_config.ex new file mode 100644 index 000000000..2072b9f45 --- /dev/null +++ b/lib/cadet/notifications/notification_config.ex @@ -0,0 +1,34 @@ +defmodule Cadet.Notifications.NotificationConfig do + @moduledoc """ + NotificationConfig entity to store course/assessment configuration for a specific notification type. + """ + use Ecto.Schema + import Ecto.Changeset + alias Cadet.Courses.{Course, AssessmentConfig} + alias Cadet.Notifications.NotificationType + + schema "notification_configs" do + field(:is_enabled, :boolean, default: false) + + belongs_to(:notification_type, NotificationType) + belongs_to(:course, Course) + belongs_to(:assessment_config, AssessmentConfig) + + timestamps() + end + + @doc false + def changeset(notification_config, attrs) do + notification_config + |> cast(attrs, [:is_enabled, :notification_type_id, :course_id]) + |> validate_required([:notification_type_id, :course_id]) + |> prevent_nil_is_enabled() + end + + defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) + when is_nil(is_enabled), + do: add_error(changeset, :full_name, "empty") + + defp prevent_nil_is_enabled(changeset), + do: changeset +end diff --git a/lib/cadet/notifications/notification_preference.ex b/lib/cadet/notifications/notification_preference.ex new file mode 100644 index 000000000..aec18aa5e --- /dev/null +++ b/lib/cadet/notifications/notification_preference.ex @@ -0,0 +1,34 @@ +defmodule Cadet.Notifications.NotificationPreference do + @moduledoc """ + NotificationPreference entity that stores user preferences for a specific notification for a specific course/assessment. + """ + use Ecto.Schema + import Ecto.Changeset + alias Cadet.Notifications.{NotificationConfig, TimeOption} + alias Cadet.Accounts.CourseRegistration + + schema "notification_preferences" do + field(:is_enabled, :boolean, default: false) + + belongs_to(:notification_config, NotificationConfig) + belongs_to(:time_option, TimeOption) + belongs_to(:course_reg, CourseRegistration) + + timestamps() + end + + @doc false + def changeset(notification_preference, attrs) do + notification_preference + |> cast(attrs, [:is_enabled, :notification_config_id, :course_reg_id]) + |> validate_required([:notification_config_id, :course_reg_id]) + |> prevent_nil_is_enabled() + end + + defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) + when is_nil(is_enabled), + do: add_error(changeset, :full_name, "empty") + + defp prevent_nil_is_enabled(changeset), + do: changeset +end diff --git a/lib/cadet/notifications/notification_type.ex b/lib/cadet/notifications/notification_type.ex new file mode 100644 index 000000000..7f16df022 --- /dev/null +++ b/lib/cadet/notifications/notification_type.ex @@ -0,0 +1,34 @@ +defmodule Cadet.Notifications.NotificationType do + @moduledoc """ + NotificationType entity that represents a unique type of notification that the system supports. + There should only be a single entry of this notification regardless of number of courses/assessments using sending this notification. + Course/assessment specific configuration should exist as NotificationConfig instead. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "notification_types" do + field(:is_autopopulated, :boolean, default: false) + field(:is_enabled, :boolean, default: false) + field(:name, :string) + field(:template_file_name, :string) + + timestamps() + end + + @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]) + |> unique_constraint(:name) + |> prevent_nil_is_enabled() + end + + defp prevent_nil_is_enabled(changeset = %{changes: %{is_enabled: is_enabled}}) + when is_nil(is_enabled), + do: add_error(changeset, :full_name, "empty") + + defp prevent_nil_is_enabled(changeset), + do: changeset +end diff --git a/lib/cadet/notifications/sent_notification.ex b/lib/cadet/notifications/sent_notification.ex new file mode 100644 index 000000000..5ff398624 --- /dev/null +++ b/lib/cadet/notifications/sent_notification.ex @@ -0,0 +1,24 @@ +defmodule Cadet.Notifications.SentNotification do + @moduledoc """ + SentNotification entity to store all sent notifications for logging (and future purposes etc. mailbox) + """ + use Ecto.Schema + import Ecto.Changeset + alias Cadet.Accounts.CourseRegistration + + schema "sent_notifications" do + field(:content, :string) + + belongs_to(:course_reg, CourseRegistration) + + timestamps() + end + + @doc false + def changeset(sent_notification, attrs) do + sent_notification + |> cast(attrs, [:content, :course_reg_id]) + |> validate_required([:content, :course_reg_id]) + |> foreign_key_constraint(:course_reg_id) + end +end diff --git a/lib/cadet/notifications/time_option.ex b/lib/cadet/notifications/time_option.ex new file mode 100644 index 000000000..a8047cfe3 --- /dev/null +++ b/lib/cadet/notifications/time_option.ex @@ -0,0 +1,27 @@ +defmodule Cadet.Notifications.TimeOption do + @moduledoc """ + TimeOption entity for options course admins have created for notifications + """ + use Ecto.Schema + import Ecto.Changeset + alias Cadet.Notifications.NotificationConfig + + schema "time_options" do + field(:is_default, :boolean, default: false) + field(:minutes, :integer) + + belongs_to(:notification_config, NotificationConfig) + + timestamps() + end + + @doc false + def changeset(time_option, attrs) do + time_option + |> cast(attrs, [:minutes, :is_default, :notification_config_id]) + |> validate_required([:minutes, :notification_config_id]) + |> validate_number(:minutes, greater_than_or_equal_to: 0) + |> unique_constraint([:minutes, :notification_config_id], name: :unique_time_options) + |> foreign_key_constraint(:notification_config_id) + end +end diff --git a/lib/cadet/workers/NotificationWorker.ex b/lib/cadet/workers/NotificationWorker.ex new file mode 100644 index 000000000..d96a3df22 --- /dev/null +++ b/lib/cadet/workers/NotificationWorker.ex @@ -0,0 +1,161 @@ +defmodule Cadet.Workers.NotificationWorker do + @moduledoc """ + Contain oban workers for sending notifications + """ + use Oban.Worker, queue: :notifications, max_attempts: 1 + alias Cadet.{Email, Notifications, Mailer} + alias Cadet.Repo + + defp is_system_enabled(notification_type_id) do + Notifications.get_notification_type!(notification_type_id).is_enabled + end + + defp is_course_enabled(notification_type_id, course_id, assessment_config_id) do + notification_config = + Notifications.get_notification_config!( + notification_type_id, + course_id, + assessment_config_id + ) + + if is_nil(notification_config) do + false + else + notification_config.is_enabled + end + end + + defp is_user_enabled(notification_type_id, course_reg_id) do + pref = Notifications.get_notification_preference(notification_type_id, course_reg_id) + + if is_nil(pref) do + true + else + pref.is_enabled + end + end + + # Returns true if user preference matches the job's time option. + # If user has made no preference, the default time option is used instead + def is_user_time_option_matched( + notification_type_id, + assessment_config_id, + course_reg_id, + time_option_minutes + ) do + pref = Notifications.get_notification_preference(notification_type_id, course_reg_id) + + if is_nil(pref) or is_nil(pref.time_option) do + Notifications.get_default_time_option_for_assessment!( + assessment_config_id, + notification_type_id + ).minutes == time_option_minutes + else + pref.time_option.minutes == time_option_minutes + end + end + + @impl Oban.Worker + def perform(%Oban.Job{ + args: %{"notification_type" => notification_type} = _args + }) + when notification_type == "avenger_backlog" do + ungraded_threshold = 5 + + ntype = Cadet.Notifications.get_notification_type_by_name!("AVENGER BACKLOG") + notification_type_id = ntype.id + + if is_system_enabled(notification_type_id) do + for course_id <- Cadet.Courses.get_all_course_ids() do + if is_course_enabled(notification_type_id, course_id, nil) do + avengers_crs = Cadet.Accounts.CourseRegistrations.get_staffs(course_id) + + 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 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 + ) + + {status, email} = Mailer.deliver_now(email) + + if status == :ok do + Notifications.create_sent_notification(avenger_cr.id, email.html_body) + end + end + end + else + IO.puts("[AVENGER_BACKLOG] course-level disabled") + end + end + else + IO.puts("[AVENGER_BACKLOG] system-level disabled!") + end + + :ok + end + + @impl Oban.Worker + def perform(%Oban.Job{ + args: + %{"notification_type" => notification_type, "submission_id" => submission_id} = _args + }) + when notification_type == "assessment_submission" do + notification_type = + Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") + + if is_system_enabled(notification_type.id) do + submission = Cadet.Assessments.get_submission_by_id(submission_id) + course_id = submission.assessment.course_id + student_id = submission.student_id + assessment_config_id = submission.assessment.config_id + course_reg = Repo.get(Cadet.Accounts.CourseRegistration, submission.student_id) + student = Cadet.Accounts.get_user(course_reg.user_id) + avenger_cr = Cadet.Accounts.CourseRegistrations.get_avenger_of(student_id) + avenger = avenger_cr.user + + cond do + !is_course_enabled(notification_type.id, course_id, assessment_config_id) -> + IO.puts("[ASSESSMENT_SUBMISSION] course-level disabled") + + !is_user_enabled(notification_type.id, avenger_cr.id) -> + IO.puts("[ASSESSMENT_SUBMISSION] user-level disabled") + + true -> + IO.puts("[ASSESSMENT_SUBMISSION] SENDING_OUT") + + email = + Email.assessment_submission_email( + notification_type.template_file_name, + avenger, + student, + submission + ) + + {status, email} = Mailer.deliver_now(email) + + if status == :ok do + Notifications.create_sent_notification(course_reg.id, email.html_body) + end + end + else + IO.puts("[ASSESSMENT_SUBMISSION] system-level disabled!") + end + end +end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index 9ba04adee..cbd3fb755 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -199,6 +199,10 @@ defmodule CadetWeb.Router do get("/", DefaultController, :index) end + if Mix.env() == :dev do + forward("/sent_emails", Bamboo.SentEmailViewerPlug) + end + defp assign_course(conn, _opts) do course_id = conn.path_params["course_id"] diff --git a/lib/cadet_web/templates/email/assessment_submission.html.eex b/lib/cadet_web/templates/email/assessment_submission.html.eex new file mode 100644 index 000000000..240c72dcd --- /dev/null +++ b/lib/cadet_web/templates/email/assessment_submission.html.eex @@ -0,0 +1,5 @@ +

Dear <%= @avenger_name %>,

+ +

There is a new submission by <%= @student_name %> for <%= @assessment_title %>. Please Review and grade the submission

+ +Unsubscribe from this email topic. diff --git a/lib/cadet_web/templates/email/avenger_backlog.html.eex b/lib/cadet_web/templates/email/avenger_backlog.html.eex new file mode 100644 index 000000000..1bd59d74b --- /dev/null +++ b/lib/cadet_web/templates/email/avenger_backlog.html.eex @@ -0,0 +1,9 @@ +

Dear <%= @avenger_name %>,

+ +You have ungraded submissions. Please review and grade the following submissions as soon as possible. + +<%= for s <- @submissions do %> +

<%= s["assessment"]["title"] %> by <%= s["student"]["name"]%>

+<% end %> + +Unsubscribe from this email topic. diff --git a/lib/cadet_web/templates/layout/email.html.eex b/lib/cadet_web/templates/layout/email.html.eex new file mode 100644 index 000000000..b9725481e --- /dev/null +++ b/lib/cadet_web/templates/layout/email.html.eex @@ -0,0 +1,7 @@ + + + + + <%= @inner_content %> + + diff --git a/lib/cadet_web/views/email_view.ex b/lib/cadet_web/views/email_view.ex new file mode 100644 index 000000000..989a40f4f --- /dev/null +++ b/lib/cadet_web/views/email_view.ex @@ -0,0 +1,3 @@ +defmodule CadetWeb.EmailView do + use CadetWeb, :view +end diff --git a/lib/cadet_web/views/layout_view.ex b/lib/cadet_web/views/layout_view.ex new file mode 100644 index 000000000..3dfa39d84 --- /dev/null +++ b/lib/cadet_web/views/layout_view.ex @@ -0,0 +1,3 @@ +defmodule CadetWeb.LayoutView do + use CadetWeb, :view +end diff --git a/mix.exs b/mix.exs index a19de61b7..0e7a00f3a 100644 --- a/mix.exs +++ b/mix.exs @@ -81,6 +81,13 @@ defmodule Cadet.Mixfile do {:sweet_xml, "~> 0.6"}, {:timex, "~> 3.7"}, + # notifiations system dependencies + {:phoenix_html, "~> 3.0"}, + {:bamboo, "~> 2.3.0"}, + {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_phoenix, "~> 1.0.0"}, + {:oban, "~> 2.13"}, + # development dependencies {:configparser_ex, "~> 4.0", only: [:dev, :test]}, {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index fd2d2d35d..1e863a766 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,9 @@ "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, + "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, + "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.1", "3c172fc5bf2bbb1f9eec632750496ae1e6468cec4c2f0ac2a6b04351a674e2f2", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.2", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "7e67997479115501da674627b15322a570b41042fc0031be8a5c80e734354c26"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, @@ -50,17 +53,20 @@ "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, + "oban": {:hex, :oban, "2.14.1", "99e28a814ca9faa759cd3f88d9adc56eb5dd0b8d4a5dabb8d2e989cb57c86f52", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6c368b5face9b1e96ba42a1d39710c5193f4b38b62c8aeb651e37897aa3feecd"}, "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, diff --git a/priv/repo/migrations/20230214065925_create_notification_types.exs b/priv/repo/migrations/20230214065925_create_notification_types.exs new file mode 100644 index 000000000..4674aaa78 --- /dev/null +++ b/priv/repo/migrations/20230214065925_create_notification_types.exs @@ -0,0 +1,16 @@ +defmodule Cadet.Repo.Migrations.CreateNotificationTypes do + use Ecto.Migration + + def change do + create table(:notification_types) do + add(:name, :string, null: false) + add(:template_file_name, :string, null: false) + add(:is_enabled, :boolean, default: false, null: false) + add(:is_autopopulated, :boolean, default: false, null: false) + + timestamps() + end + + create(unique_index(:notification_types, [:name])) + end +end diff --git a/priv/repo/migrations/20230214074219_create_notification_configs.exs b/priv/repo/migrations/20230214074219_create_notification_configs.exs new file mode 100644 index 000000000..290a129ce --- /dev/null +++ b/priv/repo/migrations/20230214074219_create_notification_configs.exs @@ -0,0 +1,18 @@ +defmodule Cadet.Repo.Migrations.CreateNotificationConfigs do + use Ecto.Migration + + def change do + create table(:notification_configs) do + add(:is_enabled, :boolean, default: false, null: false) + + add(:notification_type_id, references(:notification_types, on_delete: :delete_all), + null: false + ) + + add(:course_id, references(:courses, on_delete: :delete_all), null: false) + add(:assessment_config_id, references(:assessment_configs, on_delete: :delete_all)) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20230214081421_add_oban_jobs_table.exs b/priv/repo/migrations/20230214081421_add_oban_jobs_table.exs new file mode 100644 index 000000000..15df03e56 --- /dev/null +++ b/priv/repo/migrations/20230214081421_add_oban_jobs_table.exs @@ -0,0 +1,13 @@ +defmodule Cadet.Repo.Migrations.AddObanJobsTable do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 11) + end + + # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if + # necessary, regardless of which version we've migrated `up` to. + def down do + Oban.Migration.down(version: 1) + end +end diff --git a/priv/repo/migrations/20230214132717_create_time_options.exs b/priv/repo/migrations/20230214132717_create_time_options.exs new file mode 100644 index 000000000..911d56cd6 --- /dev/null +++ b/priv/repo/migrations/20230214132717_create_time_options.exs @@ -0,0 +1,20 @@ +defmodule Cadet.Repo.Migrations.CreateTimeOptions do + use Ecto.Migration + + def change do + create table(:time_options) do + add(:minutes, :integer, null: false) + add(:is_default, :boolean, default: false, null: false) + + add(:notification_config_id, references(:notification_configs, on_delete: :delete_all), + null: false + ) + + timestamps() + end + + create( + unique_index(:time_options, [:minutes, :notification_config_id], name: :unique_time_options) + ) + end +end diff --git a/priv/repo/migrations/20230214140555_create_notification_preferences.exs b/priv/repo/migrations/20230214140555_create_notification_preferences.exs new file mode 100644 index 000000000..bce4849e6 --- /dev/null +++ b/priv/repo/migrations/20230214140555_create_notification_preferences.exs @@ -0,0 +1,20 @@ +defmodule Cadet.Repo.Migrations.CreateNotificationPreferences do + use Ecto.Migration + + def change do + create table(:notification_preferences) do + add(:is_enabled, :boolean, default: false, null: false) + + add( + :notification_config_id, + references(:notification_configs, on_delete: :delete_all, null: false), + null: false + ) + + add(:time_option_id, references(:time_options, on_delete: :nothing), default: nil) + add(:course_reg_id, references(:course_registrations, on_delete: :delete_all), null: false) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20230214143617_create_sent_notifications.exs b/priv/repo/migrations/20230214143617_create_sent_notifications.exs new file mode 100644 index 000000000..fd0e1f428 --- /dev/null +++ b/priv/repo/migrations/20230214143617_create_sent_notifications.exs @@ -0,0 +1,14 @@ +defmodule Cadet.Repo.Migrations.CreateSentNotifications do + use Ecto.Migration + + def change do + create table(:sent_notifications) do + add(:content, :text, null: false) + add(:course_reg_id, references(:course_registrations, on_delete: :nothing), null: false) + + timestamps() + end + + create(index(:sent_notifications, [:course_reg_id])) + end +end diff --git a/priv/repo/migrations/20230215051347_users_add_email_column.exs b/priv/repo/migrations/20230215051347_users_add_email_column.exs new file mode 100644 index 000000000..6810f8565 --- /dev/null +++ b/priv/repo/migrations/20230215051347_users_add_email_column.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.UsersAddEmailColumn do + use Ecto.Migration + + def change do + alter table(:users) do + add(:email, :string) + end + end +end diff --git a/priv/repo/migrations/20230215072400_add_assessment_submission_notification_type.exs b/priv/repo/migrations/20230215072400_add_assessment_submission_notification_type.exs new file mode 100644 index 000000000..b7d02efa4 --- /dev/null +++ b/priv/repo/migrations/20230215072400_add_assessment_submission_notification_type.exs @@ -0,0 +1,13 @@ +defmodule Cadet.Repo.Migrations.AddAssessmentSubmissionNotificationType do + use Ecto.Migration + + def up do + execute( + "INSERT INTO notification_types (name, template_file_name, is_autopopulated, inserted_at, updated_at) VALUES ('ASSESSMENT SUBMISSION', 'assessment_submission', FALSE, current_timestamp, current_timestamp)" + ) + end + + def down do + execute("DELETE FROM notification_types WHERE name = 'ASSESSMENT SUBMISSION'") + end +end diff --git a/priv/repo/migrations/20230215091253_add_notification_configs_courses_trigger.exs b/priv/repo/migrations/20230215091253_add_notification_configs_courses_trigger.exs new file mode 100644 index 000000000..30aaf8a11 --- /dev/null +++ b/priv/repo/migrations/20230215091253_add_notification_configs_courses_trigger.exs @@ -0,0 +1,35 @@ +defmodule Cadet.Repo.Migrations.AddNotificationConfigsCoursesTrigger do + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION populate_noti_configs_from_notification_types_for_course() RETURNS trigger AS $$ + DECLARE + ntype Record; + BEGIN + FOR ntype IN (SELECT * FROM notification_types WHERE is_autopopulated = TRUE) LOOP + INSERT INTO notification_configs (notification_type_id, course_id, assessment_config_id, inserted_at, updated_at) + VALUES (ntype.id, NEW.id, NULL, current_timestamp, current_timestamp); + END LOOP; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE TRIGGER populate_notification_configs_on_new_course + AFTER INSERT ON courses + FOR EACH ROW EXECUTE PROCEDURE populate_noti_configs_from_notification_types_for_course(); + """) + end + + def down do + execute(""" + DROP TRIGGER IF EXISTS populate_notification_configs_on_new_course ON courses; + """) + + execute(""" + DROP FUNCTION IF EXISTS populate_noti_configs_from_notification_types_for_course; + """) + end +end diff --git a/priv/repo/migrations/20230215091948_add_notification_configs_assessments_trigger.exs b/priv/repo/migrations/20230215091948_add_notification_configs_assessments_trigger.exs new file mode 100644 index 000000000..7b5b6b6aa --- /dev/null +++ b/priv/repo/migrations/20230215091948_add_notification_configs_assessments_trigger.exs @@ -0,0 +1,35 @@ +defmodule Cadet.Repo.Migrations.AddNotificationConfigsAssessmentsTrigger do + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION populate_noti_configs_from_notification_types_for_assconf() RETURNS trigger AS $$ + DECLARE + ntype Record; + BEGIN + FOR ntype IN (SELECT * FROM notification_types WHERE is_autopopulated = FALSE) LOOP + INSERT INTO notification_configs (notification_type_id, course_id, assessment_config_id, inserted_at, updated_at) + VALUES (ntype.id, NEW.course_id, NEW.id, current_timestamp, current_timestamp); + END LOOP; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE TRIGGER populate_notification_configs_on_new_assessment_config + AFTER INSERT ON assessment_configs + FOR EACH ROW EXECUTE PROCEDURE populate_noti_configs_from_notification_types_for_assconf(); + """) + end + + def down do + execute(""" + DROP TRIGGER IF EXISTS populate_notification_configs_on_new_assessment_config ON assessment_configs; + """) + + execute(""" + DROP FUNCTION IF EXISTS populate_noti_configs_from_notification_types_for_assconf; + """) + end +end diff --git a/priv/repo/migrations/20230215092543_add_avenger_backlog_notification_type.exs b/priv/repo/migrations/20230215092543_add_avenger_backlog_notification_type.exs new file mode 100644 index 000000000..8abf000eb --- /dev/null +++ b/priv/repo/migrations/20230215092543_add_avenger_backlog_notification_type.exs @@ -0,0 +1,13 @@ +defmodule Cadet.Repo.Migrations.AddAvengerBacklogNotificationType do + use Ecto.Migration + + def up do + execute( + "INSERT INTO notification_types (name, template_file_name, is_autopopulated, inserted_at, updated_at) VALUES ('AVENGER BACKLOG', 'avenger_backlog', TRUE, current_timestamp, current_timestamp)" + ) + end + + def down do + execute("DELETE FROM notification_types WHERE name = 'AVENGER BACKLOG'") + end +end diff --git a/priv/repo/migrations/20230311105547_populate_nus_student_emails.exs b/priv/repo/migrations/20230311105547_populate_nus_student_emails.exs new file mode 100644 index 000000000..ffb77b519 --- /dev/null +++ b/priv/repo/migrations/20230311105547_populate_nus_student_emails.exs @@ -0,0 +1,11 @@ +defmodule Cadet.Repo.Migrations.PopulateNusStudentEmails do + use Ecto.Migration + + def change do + execute(" + update users + set email = username || '@u.nus.edu' + where username ~ '^[eE][0-9]{7}$' and email IS NULL and provider = 'luminus'; + ") + end +end diff --git a/test/cadet/email_test.exs b/test/cadet/email_test.exs new file mode 100644 index 000000000..462daad65 --- /dev/null +++ b/test/cadet/email_test.exs @@ -0,0 +1,63 @@ +defmodule Cadet.EmailTest do + use ExUnit.Case + use Bamboo.Test + alias Cadet.{Email, Repo, Accounts} + alias Cadet.Assessments.Submission + + use Cadet.ChangesetCase, entity: Email + + setup do + Cadet.Test.Seeds.assessments() + + submission = + Cadet.Assessments.Submission + |> Repo.all() + |> Repo.preload([:assessment]) + + {:ok, + %{ + submission: submission |> List.first() + }} + end + + test "avenger backlog email" do + avenger_user = insert(:user, %{email: "test@gmail.com"}) + avenger = insert(:course_registration, %{user: avenger_user, role: :staff}) + + ungraded_submissions = + Jason.decode!( + elem(Cadet.Assessments.all_submissions_by_grader_for_index(avenger, true, true), 1) + ) + + email = Email.avenger_backlog_email("avenger_backlog", avenger_user, ungraded_submissions) + + avenger_email = avenger_user.email + assert email.to == avenger_email + assert email.subject == "Backlog for #{avenger_user.name}" + end + + test "assessment submission email", %{ + submission: submission + } do + submission + |> Submission.changeset(%{status: :submitted}) + |> Repo.update() + + student_id = submission.student_id + course_reg = Repo.get(Accounts.CourseRegistration, submission.student_id) + student = Accounts.get_user(course_reg.user_id) + avenger = Accounts.CourseRegistrations.get_avenger_of(student_id).user + + email = + Email.assessment_submission_email( + "assessment_submission", + avenger, + student, + submission + ) + + avenger_email = avenger.email + assert email.to == avenger_email + assert email.subject == "New submission for #{submission.assessment.title}" + end +end diff --git a/test/cadet/jobs/notification_worker/notification_worker_test.exs b/test/cadet/jobs/notification_worker/notification_worker_test.exs new file mode 100644 index 000000000..41606d4ce --- /dev/null +++ b/test/cadet/jobs/notification_worker/notification_worker_test.exs @@ -0,0 +1,58 @@ +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_config_test.exs b/test/cadet/notifications/notification_config_test.exs new file mode 100644 index 000000000..93054b9a4 --- /dev/null +++ b/test/cadet/notifications/notification_config_test.exs @@ -0,0 +1,75 @@ +defmodule Cadet.Notifications.NotificationConfigTest do + alias Cadet.Notifications.NotificationConfig + + use Cadet.ChangesetCase, entity: NotificationConfig + + setup do + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + config1 = insert(:assessment_config, %{course: course1}) + + noti_type1 = insert(:notification_type, %{name: "Notification Type 1"}) + noti_type2 = insert(:notification_type, %{name: "Notification Type 2"}) + + {:ok, + %{ + course1: course1, + course2: course2, + config1: config1, + noti_type1: noti_type1, + noti_type2: noti_type2 + }} + end + + describe "Changesets" do + test "valid changesets", %{ + course1: course1, + course2: course2, + config1: config1, + noti_type1: noti_type1, + noti_type2: noti_type2 + } do + assert_changeset( + %{ + notification_type_id: noti_type1.id, + course_id: course1.id, + config_id: config1.id + }, + :valid + ) + + assert_changeset( + %{ + notification_type_id: noti_type2.id, + course_id: course2.id, + config_id: nil + }, + :valid + ) + end + + test "invalid changesets missing notification type" do + assert_changeset( + %{ + notification_type_id: nil, + course_id: nil, + config_id: nil + }, + :invalid + ) + end + + test "invalid changesets missing course", %{ + noti_type1: noti_type1 + } do + assert_changeset( + %{ + notification_type_id: noti_type1.id, + course_id: nil, + config_id: nil + }, + :invalid + ) + end + end +end diff --git a/test/cadet/notifications/notification_preference_test.exs b/test/cadet/notifications/notification_preference_test.exs new file mode 100644 index 000000000..4a71baefe --- /dev/null +++ b/test/cadet/notifications/notification_preference_test.exs @@ -0,0 +1,99 @@ +defmodule Cadet.Notifications.NotificationPreferenceTest do + alias Cadet.Notifications.NotificationPreference + + use Cadet.ChangesetCase, entity: NotificationPreference + + setup do + course1 = insert(:course, %{course_short_name: "course 1"}) + config1 = insert(:assessment_config, %{course: course1}) + + student_user = insert(:user) + avenger_user = insert(:user) + avenger = insert(:course_registration, %{user: avenger_user, course: course1, role: :staff}) + student = insert(:course_registration, %{user: student_user, course: course1, role: :student}) + + noti_type1 = insert(:notification_type, %{name: "Notification Type 1"}) + + noti_config1 = + insert(:notification_config, %{ + notification_type: noti_type1, + course: course1, + assessment_config: config1 + }) + + time_option1 = + insert(:time_option, %{ + notification_config: noti_config1 + }) + + {:ok, + %{ + course1: course1, + config1: config1, + student: student, + avenger: avenger, + noti_type1: noti_type1, + noti_config1: noti_config1, + time_option1: time_option1 + }} + end + + describe "Changesets" do + test "valid changesets", %{ + student: student, + avenger: avenger, + noti_config1: noti_config1, + time_option1: time_option1 + } do + assert_changeset( + %{ + is_enabled: false, + notification_config_id: noti_config1.id, + time_option_id: time_option1.id, + course_reg_id: student.id + }, + :valid + ) + + assert_changeset( + %{ + is_enabled: false, + notification_config_id: noti_config1.id, + time_option_id: time_option1.id, + course_reg_id: avenger.id + }, + :valid + ) + end + + test "invalid changesets missing notification config", %{ + avenger: avenger, + time_option1: time_option1 + } do + assert_changeset( + %{ + is_enabled: false, + notification_config_id: nil, + time_option_id: time_option1.id, + course_reg_id: avenger.id + }, + :invalid + ) + end + + test "invalid changesets missing course registration", %{ + noti_config1: noti_config1, + time_option1: time_option1 + } do + assert_changeset( + %{ + is_enabled: false, + notification_config_id: noti_config1.id, + time_option_id: time_option1.id, + course_reg_id: nil + }, + :invalid + ) + end + end +end diff --git a/test/cadet/notifications/notification_type_test.exs b/test/cadet/notifications/notification_type_test.exs new file mode 100644 index 000000000..547795521 --- /dev/null +++ b/test/cadet/notifications/notification_type_test.exs @@ -0,0 +1,79 @@ +defmodule Cadet.Notifications.NotificationTypeTest do + alias Cadet.Notifications.NotificationType + alias Cadet.Repo + + use Cadet.ChangesetCase, entity: NotificationType + + setup do + changeset = + NotificationType.changeset(%NotificationType{}, %{ + name: "Notification Type 1", + template_file_name: "template_file_1", + is_enabled: true, + is_autopopulated: true + }) + + {:ok, _noti_type1} = Repo.insert(changeset) + + {:ok, %{changeset: changeset}} + end + + describe "Changesets" do + test "valid changesets" do + assert_changeset( + %{ + name: "Notification Type 2", + template_file_name: "template_file_2", + is_enabled: false, + is_autopopulated: true + }, + :valid + ) + end + + test "invalid changesets missing name" do + assert_changeset( + %{ + template_file_name: "template_file_2", + is_enabled: false, + is_autopopulated: true + }, + :invalid + ) + end + + test "invalid changesets missing template_file_name" do + assert_changeset( + %{ + name: "Notification Type 2", + is_enabled: false, + is_autopopulated: true + }, + :invalid + ) + end + + test "invalid changeset duplicate name", %{changeset: changeset} do + {:error, changeset} = Repo.insert(changeset) + + assert changeset.errors == [ + name: + {"has already been taken", + [constraint: :unique, constraint_name: "notification_types_name_index"]} + ] + + refute changeset.valid? + end + + test "invalid changeset nil is_enabled" do + assert_changeset( + %{ + name: "Notification Type 0", + is_enabled: nil, + is_autopopulated: true + }, + :invalid + ) + end + end +end diff --git a/test/cadet/notifications/notifications_test.exs b/test/cadet/notifications/notifications_test.exs new file mode 100644 index 000000000..5640eeebd --- /dev/null +++ b/test/cadet/notifications/notifications_test.exs @@ -0,0 +1,225 @@ +defmodule Cadet.NotificationsTest do + use Cadet.DataCase + + alias Cadet.Notifications + alias Cadet.Notifications.{NotificationConfig, NotificationPreference, TimeOption} + + describe "notification_types" do + test "get_notification_type!/1 returns the notification_type with given id" do + ntype = insert(:notification_type) + result = Notifications.get_notification_type!(ntype.id) + assert ntype.id == result.id + end + end + + describe "notification_configs" do + @invalid_attrs %{is_enabled: nil} + + test "get_notification_config!/3 returns the notification_config with given id" do + notification_config = insert(:notification_config) + + assert Notifications.get_notification_config!( + notification_config.notification_type.id, + notification_config.course.id, + notification_config.assessment_config.id + ).id == notification_config.id + end + + test "get_notification_config!/3 with no assessment config returns the notification_config with given id" do + notification_config = insert(:notification_config, assessment_config: nil) + + assert Notifications.get_notification_config!( + notification_config.notification_type.id, + notification_config.course.id, + nil + ).id == notification_config.id + end + + test "update_notification_config/2 with valid data updates the notification_config" do + notification_config = insert(:notification_config) + update_attrs = %{is_enabled: true} + + assert {:ok, %NotificationConfig{} = notification_config} = + Notifications.update_notification_config(notification_config, update_attrs) + + assert notification_config.is_enabled == true + end + + test "update_notification_config/2 with invalid data returns error changeset" do + notification_config = insert(:notification_config) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_notification_config(notification_config, @invalid_attrs) + + assert notification_config.id == + Notifications.get_notification_config!( + notification_config.notification_type.id, + notification_config.course.id, + notification_config.assessment_config.id + ).id + end + + test "change_notification_config/1 returns a notification_config changeset" do + notification_config = insert(:notification_config) + assert %Ecto.Changeset{} = Notifications.change_notification_config(notification_config) + end + end + + describe "time_options" do + @invalid_attrs %{is_default: nil, minutes: nil} + + test "get_time_option!/1 returns the time_option with given id" do + time_option = insert(:time_option) + assert Notifications.get_time_option!(time_option.id).id == time_option.id + end + + test "get_time_options_for_assessment/2 returns the time_option with given ids" do + time_option = insert(:time_option) + + assert List.first( + Notifications.get_time_options_for_assessment( + time_option.notification_config.assessment_config.id, + time_option.notification_config.notification_type.id + ) + ).id == time_option.id + end + + test "get_default_time_option_for_assessment!/2 returns the time_option with given ids" do + time_option = insert(:time_option, is_default: true) + + assert Notifications.get_default_time_option_for_assessment!( + time_option.notification_config.assessment_config.id, + time_option.notification_config.notification_type.id + ).id == time_option.id + end + + test "create_time_option/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Notifications.create_time_option(@invalid_attrs) + end + + test "delete_time_option/1 deletes the time_option" do + time_option = insert(:time_option) + assert {:ok, %TimeOption{}} = Notifications.delete_time_option(time_option) + assert_raise Ecto.NoResultsError, fn -> Notifications.get_time_option!(time_option.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_config = insert(:notification_config, notification_type: notification_type) + + notification_preference = + insert(:notification_preference, notification_config: notification_config) + + assert Notifications.get_notification_preference( + notification_preference.notification_config.notification_type.id, + notification_preference.course_reg.id + ).id == notification_preference.id + end + + test "create_notification_preference/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + Notifications.create_notification_preference(@invalid_attrs) + end + + test "update_notification_preference/2 with valid data updates the notification_preference" do + notification_type = + insert(:notification_type, name: "update_notification_preference/2 valid") + + notification_config = insert(:notification_config, notification_type: notification_type) + + notification_preference = + insert(:notification_preference, notification_config: notification_config) + + update_attrs = %{is_enabled: true} + + assert {:ok, %NotificationPreference{} = notification_preference} = + Notifications.update_notification_preference(notification_preference, update_attrs) + + assert notification_preference.is_enabled == true + end + + test "update_notification_preference/2 with invalid data returns error changeset" do + notification_type = + insert(:notification_type, name: "update_notification_preference/2 invalid") + + notification_config = insert(:notification_config, notification_type: notification_type) + + notification_preference = + insert(:notification_preference, notification_config: notification_config) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_notification_preference( + notification_preference, + @invalid_attrs + ) + + assert notification_preference.id == + Notifications.get_notification_preference( + notification_preference.notification_config.notification_type.id, + notification_preference.course_reg.id + ).id + end + + test "delete_notification_preference/1 deletes the notification_preference" do + notification_type = insert(:notification_type, name: "delete_notification_preference/1") + notification_config = insert(:notification_config, notification_type: notification_type) + + notification_preference = + insert(:notification_preference, notification_config: notification_config) + + assert {:ok, %NotificationPreference{}} = + Notifications.delete_notification_preference(notification_preference) + + assert Notifications.get_notification_preference( + notification_preference.notification_config.notification_type.id, + notification_preference.course_reg.id + ) == nil + end + + test "change_notification_preference/1 returns a notification_preference changeset" do + notification_type = insert(:notification_type, name: "change_notification_preference/1") + notification_config = insert(:notification_config, notification_type: notification_type) + + notification_preference = + insert(:notification_preference, notification_config: notification_config) + + assert %Ecto.Changeset{} = + Notifications.change_notification_preference(notification_preference) + end + end + + describe "sent_notifications" do + alias Cadet.Notifications.SentNotification + + setup do + course = insert(:course) + course_reg = insert(:course_registration, course: course) + {:ok, course_reg: course_reg} + end + + test "create_sent_notification/1 with valid data creates a sent_notification", + %{course_reg: course_reg} do + assert {:ok, %SentNotification{}} = + Notifications.create_sent_notification(course_reg.id, "test content") + end + + test "create_sent_notification/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + Notifications.create_sent_notification(nil, "test content") + end + + # test "list_sent_notifications/0 returns all sent_notifications" do + # sent_notification = sent_notification_fixture() + # assert Notifications.list_sent_notifications() == [sent_notification] + # end + + # test "get_sent_notification!/1 returns the sent_notification with given id" do + # sent_notification = sent_notification_fixture() + # assert Notifications.get_sent_notification!(sent_notification.id) == sent_notification + # end + end +end diff --git a/test/cadet/notifications/sent_notification_test.exs b/test/cadet/notifications/sent_notification_test.exs new file mode 100644 index 000000000..4bfb51b7d --- /dev/null +++ b/test/cadet/notifications/sent_notification_test.exs @@ -0,0 +1,81 @@ +defmodule Cadet.Notifications.SentNotificationTest do + alias Cadet.Notifications.SentNotification + alias Cadet.Repo + + use Cadet.ChangesetCase, entity: SentNotification + + setup do + course = insert(:course) + student = insert(:course_registration, %{course: course, role: :student}) + + changeset = + SentNotification.changeset(%SentNotification{}, %{ + content: "Test Content 1", + course_reg_id: student.id + }) + + {:ok, _sent_notification1} = Repo.insert(changeset) + + {:ok, + %{ + changeset: changeset, + course: course, + student: student + }} + end + + describe "Changesets" do + test "valid changesets", %{ + student: student + } do + assert_changeset( + %{ + content: "Test Content 2", + course_reg_id: student.id + }, + :valid + ) + end + + test "invalid changesets missing content", %{ + student: student + } do + assert_changeset( + %{ + course_reg_id: student.id + }, + :invalid + ) + end + + test "invalid changesets missing course_reg_id" do + assert_changeset( + %{ + content: "Test Content 2" + }, + :invalid + ) + end + + test "invalid changeset foreign key constraint", %{ + student: student + } do + changeset = + SentNotification.changeset(%SentNotification{}, %{ + content: "Test Content 2", + course_reg_id: student.id + 1000 + }) + + {:error, changeset} = Repo.insert(changeset) + + assert changeset.errors == [ + course_reg_id: + {"does not exist", + [ + constraint: :foreign, + constraint_name: "sent_notifications_course_reg_id_fkey" + ]} + ] + end + end +end diff --git a/test/cadet/notifications/time_option_test.exs b/test/cadet/notifications/time_option_test.exs new file mode 100644 index 000000000..ef281dcec --- /dev/null +++ b/test/cadet/notifications/time_option_test.exs @@ -0,0 +1,98 @@ +defmodule Cadet.Notifications.TimeOptionTest do + alias Cadet.Notifications.TimeOption + + use Cadet.ChangesetCase, entity: TimeOption + + setup do + course1 = insert(:course, %{course_short_name: "course 1"}) + + config1 = insert(:assessment_config, %{course: course1}) + + noti_type1 = insert(:notification_type, %{name: "Notification Type 1"}) + + noti_config1 = + insert(:notification_config, %{ + notification_type: noti_type1, + course: course1, + assessment_config: config1 + }) + + changeset = + TimeOption.changeset(%TimeOption{}, %{ + minutes: 10, + is_default: true, + notification_config_id: noti_config1.id + }) + + {:ok, _time_option1} = Repo.insert(changeset) + + {:ok, + %{ + noti_config1: noti_config1, + changeset: changeset + }} + end + + describe "Changesets" do + test "valid changesets", %{noti_config1: noti_config1} do + assert_changeset( + %{ + minutes: 20, + is_default: false, + notification_config_id: noti_config1.id + }, + :valid + ) + end + + test "invalid changesets missing minutes" do + assert_changeset( + %{ + is_default: false, + notification_config_id: 2 + }, + :invalid + ) + end + + test "invalid changesets missing notification_config_id" do + assert_changeset( + %{ + minutes: 2, + is_default: false + }, + :invalid + ) + end + + test "invalid changeset duplicate minutes", %{changeset: changeset} do + {:error, changeset} = Repo.insert(changeset) + + assert changeset.errors == [ + minutes: + {"has already been taken", + [constraint: :unique, constraint_name: "unique_time_options"]} + ] + end + + test "invalid notification_config_id", %{noti_config1: noti_config1} do + changeset = + TimeOption.changeset(%TimeOption{}, %{ + minutes: 10, + is_default: true, + notification_config_id: noti_config1.id + 1000 + }) + + {:error, changeset} = Repo.insert(changeset) + + assert changeset.errors == [ + notification_config_id: + {"does not exist", + [ + constraint: :foreign, + constraint_name: "time_options_notification_config_id_fkey" + ]} + ] + end + end +end diff --git a/test/cadet/updater/xml_parser_test.exs b/test/cadet/updater/xml_parser_test.exs index aefbf6bdb..fb0426e35 100644 --- a/test/cadet/updater/xml_parser_test.exs +++ b/test/cadet/updater/xml_parser_test.exs @@ -39,7 +39,8 @@ defmodule Cadet.Updater.XMLParserTest do ) ) - # contest assessment need to be added before assessment containing voting questions can be added. + # contest assessment need to be added before assessment + # containing voting questions can be added. contest_assessment = insert(:assessment, course: course, config: hd(assessment_configs)) assessments_with_config = Enum.into(assessments, %{}, &{&1, &1.config}) diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 6d8fe077c..469346ff5 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -29,6 +29,13 @@ defmodule Cadet.Factory do SourcecastFactory } + use Cadet.Notifications.{ + NotificationTypeFactory, + NotificationConfigFactory, + NotificationPreferenceFactory, + TimeOptionFactory + } + use Cadet.Devices.DeviceFactory def upload_factory do diff --git a/test/factories/notifications/notifcation_config_factory.ex b/test/factories/notifications/notifcation_config_factory.ex new file mode 100644 index 000000000..43588fa29 --- /dev/null +++ b/test/factories/notifications/notifcation_config_factory.ex @@ -0,0 +1,20 @@ +defmodule Cadet.Notifications.NotificationConfigFactory do + @moduledoc """ + Factory for the NotificationConfig entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Notifications.NotificationConfig + + def notification_config_factory do + %NotificationConfig{ + is_enabled: false, + notification_type: build(:notification_type), + course: build(:course), + assessment_config: build(:assessment_config) + } + end + end + end +end diff --git a/test/factories/notifications/notification_preference_factory.ex b/test/factories/notifications/notification_preference_factory.ex new file mode 100644 index 000000000..49ffb90bd --- /dev/null +++ b/test/factories/notifications/notification_preference_factory.ex @@ -0,0 +1,20 @@ +defmodule Cadet.Notifications.NotificationPreferenceFactory do + @moduledoc """ + Factory for the NotificationPreference entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Notifications.NotificationPreference + + def notification_preference_factory do + %NotificationPreference{ + is_enabled: false, + notification_config: build(:notification_config), + time_option: build(:time_option), + course_reg: build(:course_registration) + } + end + end + end +end diff --git a/test/factories/notifications/notification_type_factory.ex b/test/factories/notifications/notification_type_factory.ex new file mode 100644 index 000000000..5c7995564 --- /dev/null +++ b/test/factories/notifications/notification_type_factory.ex @@ -0,0 +1,20 @@ +defmodule Cadet.Notifications.NotificationTypeFactory do + @moduledoc """ + Factory for the NotificationType entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Notifications.NotificationType + + def notification_type_factory do + %NotificationType{ + is_autopopulated: false, + is_enabled: false, + name: "Generic Notificaation Type", + template_file_name: "generic_template_name" + } + end + end + end +end diff --git a/test/factories/notifications/time_option_factory.ex b/test/factories/notifications/time_option_factory.ex new file mode 100644 index 000000000..d5aa1c898 --- /dev/null +++ b/test/factories/notifications/time_option_factory.ex @@ -0,0 +1,19 @@ +defmodule Cadet.Notifications.TimeOptionFactory do + @moduledoc """ + Factory for the TimeOption entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Notifications.TimeOption + + def time_option_factory do + %TimeOption{ + is_default: false, + minutes: 0, + notification_config: build(:notification_config) + } + end + end + end +end diff --git a/test/support/seeds.ex b/test/support/seeds.ex index ef05b1bd5..e88ec7f21 100644 --- a/test/support/seeds.ex +++ b/test/support/seeds.ex @@ -41,7 +41,13 @@ defmodule Cadet.Test.Seeds do course1 = insert(:course) course2 = insert(:course, %{course_name: "Algorithm", course_short_name: "CS2040S"}) # Users - avenger1 = insert(:user, %{name: "avenger", latest_viewed_course: course1}) + avenger1 = + insert(:user, %{ + name: "avenger", + latest_viewed_course: course1, + email: "avenger1@gmail.com" + }) + admin1 = insert(:user, %{name: "admin", latest_viewed_course: course1}) studenta1admin2 = insert(:user, %{name: "student a", latest_viewed_course: course1})