From ed7f772e21347a3f5eb8e57fa497ddf05c6a72df Mon Sep 17 00:00:00 2001 From: Santosh Muthukrishnan <34889879+Santosh3007@users.noreply.github.com> Date: Thu, 25 May 2023 01:08:15 -0700 Subject: [PATCH] Add Avenger Backlog Notifications (#932) * Create NotificationType Model - Create Notifications module and NotificationType model - Start migration for adding notifications - No functionalities added yet only template code * Configure Oban and Bamboo for notifications system * Create NotificationConfig Model * Create TimeOption Model * Create NotificationPreference Model * Create SentNotification Model * Add Email Field to User Model * fix: Fix Oban configuration * chore: Add dependency on bamboo_phoenix for email template rendering * feat: Implement Oban job for avenger backlog emails * chore: Prevent browser from opening automatically when a local email is sent * Add migrations for notification triggers and avenger backlog notification entry * Implement notification configuration checks * Fix formatting errors * fix: Fix notifications schema typos Changes: * Replaced is_enable to is_enabled for Notification Preferences * Update default is_enabled to true for Notifcation Types * fix: Fix test configurations not being applied `import_config` must always appear at the bottom for environment specific configurations to be applied correctly. All configurations after this line will overwrite configurations that exists in the environment specific ones. * chore: remove unused controllers and views - remove auto-generated controllers and views that are not used * chore: Add tests for notifications * chore: Add test for avenger backlog email * chore: Resolve style violations * chore: Resolve style violations * chore: Resolve style violations * style: fix formatting * fix: Fix bad refactoring * fix: Fix testing environment with Oban and Bamboo * test: add tests for notification types * test: add tests for time options * chore: Update constraints and changesets for notification models * chore: Update default behaviour for no time_option in user preference If user preference has no time option, use the time_option from notification_config instead. This is so that the behaviour of these users with no preferences would always follow the default chosen by the course admin * style: fix formatting * test: add tests for sent notifications * Add tests for notifications module * fix: Fix testing with Oban Oban introduced changes to testing in v2.12, this commit changes the old test configurations to the new one recommended by official docs. * Add more tests for notifications module * test: add tests for NotificationWorker * style: fix formatting * style: fix formatting * style: fix formatting * fix: Fix tests under notification types name constraints * feat: Implement job for assessment submission mail * fix: Fix assessment submission worker * chore: Add test for assessment submission * chore: Add migration to populate existing nus users' emails Current nus users will have their email attribute populated based on their username. nus users are identified from the provider attribute. Future nus users will have their email attribute populated on creation ideally. * feat: implement sent_notifications - move mailing logic to notification worker - insert into sent_notifications when email is sent out successfully * fix: fix tests * style: fix formatting * fix: fix guard clauses - move guard clauses to prevent unnecessary querying * fix: Fix db triggers not running for assessment submission notifications --------- Co-authored-by: Goh Jun Yi <54541329+Junyi00@users.noreply.github.com> Co-authored-by: Martin Henz --- config/config.exs | 23 +- config/prod.exs | 2 + config/test.exs | 6 + lib/cadet/accounts/course_registrations.ex | 26 ++ lib/cadet/accounts/user.ex | 1 + lib/cadet/application.ex | 4 +- lib/cadet/assessments/assessments.ex | 24 +- lib/cadet/courses/courses.ex | 10 +- lib/cadet/email.ex | 40 +++ lib/cadet/jobs/scheduler.ex | 3 + lib/cadet/mailer.ex | 6 + lib/cadet/notifications.ex | 308 ++++++++++++++++++ .../notifications/notification_config.ex | 34 ++ .../notifications/notification_preference.ex | 34 ++ lib/cadet/notifications/notification_type.ex | 34 ++ lib/cadet/notifications/sent_notification.ex | 24 ++ lib/cadet/notifications/time_option.ex | 27 ++ lib/cadet/workers/NotificationWorker.ex | 161 +++++++++ lib/cadet_web/router.ex | 4 + .../email/assessment_submission.html.eex | 5 + .../templates/email/avenger_backlog.html.eex | 9 + lib/cadet_web/templates/layout/email.html.eex | 7 + lib/cadet_web/views/email_view.ex | 3 + lib/cadet_web/views/layout_view.ex | 3 + mix.exs | 7 + mix.lock | 6 + ...230214065925_create_notification_types.exs | 16 + ...0214074219_create_notification_configs.exs | 18 + .../20230214081421_add_oban_jobs_table.exs | 13 + .../20230214132717_create_time_options.exs | 20 ++ ...140555_create_notification_preferences.exs | 20 ++ ...230214143617_create_sent_notifications.exs | 14 + .../20230215051347_users_add_email_column.exs | 9 + ...ssessment_submission_notification_type.exs | 13 + ...d_notification_configs_courses_trigger.exs | 35 ++ ...tification_configs_assessments_trigger.exs | 35 ++ ..._add_avenger_backlog_notification_type.exs | 13 + ...0311105547_populate_nus_student_emails.exs | 11 + test/cadet/email_test.exs | 63 ++++ .../notification_worker_test.exs | 58 ++++ .../notification_config_test.exs | 75 +++++ .../notification_preference_test.exs | 99 ++++++ .../notifications/notification_type_test.exs | 79 +++++ .../notifications/notifications_test.exs | 225 +++++++++++++ .../notifications/sent_notification_test.exs | 81 +++++ test/cadet/notifications/time_option_test.exs | 98 ++++++ test/cadet/updater/xml_parser_test.exs | 3 +- test/factories/factory.ex | 7 + .../notifcation_config_factory.ex | 20 ++ .../notification_preference_factory.ex | 20 ++ .../notification_type_factory.ex | 20 ++ .../notifications/time_option_factory.ex | 19 ++ test/support/seeds.ex | 8 +- 53 files changed, 1892 insertions(+), 11 deletions(-) create mode 100644 lib/cadet/email.ex create mode 100644 lib/cadet/mailer.ex create mode 100644 lib/cadet/notifications.ex create mode 100644 lib/cadet/notifications/notification_config.ex create mode 100644 lib/cadet/notifications/notification_preference.ex create mode 100644 lib/cadet/notifications/notification_type.ex create mode 100644 lib/cadet/notifications/sent_notification.ex create mode 100644 lib/cadet/notifications/time_option.ex create mode 100644 lib/cadet/workers/NotificationWorker.ex create mode 100644 lib/cadet_web/templates/email/assessment_submission.html.eex create mode 100644 lib/cadet_web/templates/email/avenger_backlog.html.eex create mode 100644 lib/cadet_web/templates/layout/email.html.eex create mode 100644 lib/cadet_web/views/email_view.ex create mode 100644 lib/cadet_web/views/layout_view.ex create mode 100644 priv/repo/migrations/20230214065925_create_notification_types.exs create mode 100644 priv/repo/migrations/20230214074219_create_notification_configs.exs create mode 100644 priv/repo/migrations/20230214081421_add_oban_jobs_table.exs create mode 100644 priv/repo/migrations/20230214132717_create_time_options.exs create mode 100644 priv/repo/migrations/20230214140555_create_notification_preferences.exs create mode 100644 priv/repo/migrations/20230214143617_create_sent_notifications.exs create mode 100644 priv/repo/migrations/20230215051347_users_add_email_column.exs create mode 100644 priv/repo/migrations/20230215072400_add_assessment_submission_notification_type.exs create mode 100644 priv/repo/migrations/20230215091253_add_notification_configs_courses_trigger.exs create mode 100644 priv/repo/migrations/20230215091948_add_notification_configs_assessments_trigger.exs create mode 100644 priv/repo/migrations/20230215092543_add_avenger_backlog_notification_type.exs create mode 100644 priv/repo/migrations/20230311105547_populate_nus_student_emails.exs create mode 100644 test/cadet/email_test.exs create mode 100644 test/cadet/jobs/notification_worker/notification_worker_test.exs create mode 100644 test/cadet/notifications/notification_config_test.exs create mode 100644 test/cadet/notifications/notification_preference_test.exs create mode 100644 test/cadet/notifications/notification_type_test.exs create mode 100644 test/cadet/notifications/notifications_test.exs create mode 100644 test/cadet/notifications/sent_notification_test.exs create mode 100644 test/cadet/notifications/time_option_test.exs create mode 100644 test/factories/notifications/notifcation_config_factory.ex create mode 100644 test/factories/notifications/notification_preference_factory.ex create mode 100644 test/factories/notifications/notification_type_factory.ex create mode 100644 test/factories/notifications/time_option_factory.ex 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})