diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 543e6e4bb..b72538cb4 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -4,6 +4,7 @@ on: branches: - stable - master + - multitenant-deploy paths: - 'config/**' - 'lib/**' @@ -53,9 +54,7 @@ jobs: run: | mix deps.get - name: mix release - run: | - rm -f _build/prod/cadet-0.0.1.tar.gz - mix release + run: mix release --overwrite - name: Create release uses: marvinpinto/action-automatic-releases@latest with: diff --git a/config/dev.secrets.exs.example b/config/dev.secrets.exs.example index 94801fc3c..2c59d3780 100644 --- a/config/dev.secrets.exs.example +++ b/config/dev.secrets.exs.example @@ -26,6 +26,17 @@ config :cadet, # # You may need to write your own claim extractor for other providers # claim_extractor: Cadet.Auth.Providers.CognitoClaimExtractor # }}, + # # To use authentication with GitHub + # "github" => + # {Cadet.Auth.Providers.GitHub, + # %{ + # # A map of GitHub client_id => client_secret + # clients: %{ + # "client_id" => "client_secret" + # }, + # token_url: "https://github.com/login/oauth/access_token", + # user_api: "https://api.github.com/user" + # }}, "test" => {Cadet.Auth.Providers.Config, [ diff --git a/config/test.exs b/config/test.exs index 9fbcf5f73..802b32cd3 100644 --- a/config/test.exs +++ b/config/test.exs @@ -52,22 +52,22 @@ config :cadet, token: "admin_token", code: "admin_code", name: "Test Admin", - username: "admin", - role: :admin + username: "admin" + # role: :admin }, %{ token: "staff_token", code: "staff_code", name: "Test Staff", - username: "staff", - role: :staff + username: "staff" + # role: :staff }, %{ token: "student_token", code: "student_code", - name: "Test Student", - username: "student", - role: :student + name: "student 1", + username: "E1234564" + # role: :student } ]} }, diff --git a/deployment/init.sh b/deployment/init.sh index 045ee1669..6b1d502bb 100644 --- a/deployment/init.sh +++ b/deployment/init.sh @@ -8,9 +8,9 @@ set -euxo pipefail BASEDIR=/opt/cadet -PKGURL='https://github.com/source-academy/cadet/releases/download/latest-stable/cadet-0.0.1.tar.gz' +PKGURL='https://github.com/source-academy/cadet/releases/download/latest-multitenant-deploy/cadet-0.0.1.tar.gz' PKGPATH='/run/cadet-init/cadet-0.0.1.tar.gz' -SVCURL=${SVCURL:-'https://raw.githubusercontent.com/source-academy/cadet/stable/deployment/cadet.service'} +SVCURL=${SVCURL:-'https://raw.githubusercontent.com/source-academy/cadet/multitenant-deploy/deployment/cadet.service'} SVCPATH='/etc/systemd/system/cadet.service' if [ "$EUID" -ne 0 ]; then diff --git a/lib/cadet/accounts/accounts.ex b/lib/cadet/accounts/accounts.ex index 35b349f14..14987985e 100644 --- a/lib/cadet/accounts/accounts.ex +++ b/lib/cadet/accounts/accounts.ex @@ -6,7 +6,7 @@ defmodule Cadet.Accounts do import Ecto.Query - alias Cadet.Accounts.{Query, User} + alias Cadet.Accounts.{Query, User, CourseRegistration} alias Cadet.Auth.Provider @doc """ @@ -14,17 +14,8 @@ defmodule Cadet.Accounts do Returns {:ok, user} on success, otherwise {:error, changeset} """ - def register(attrs = %{username: username}, role) when is_binary(username) do - attrs |> Map.put(:role, role) |> insert_or_update_user() - end - - @doc """ - Creates User entity with specified attributes. - """ - def create_user(attrs \\ %{}) do - %User{} - |> User.changeset(attrs) - |> Repo.insert() + def register(attrs = %{username: username}) when is_binary(username) do + attrs |> insert_or_update_user() end @doc """ @@ -53,22 +44,28 @@ defmodule Cadet.Accounts do Repo.get(User, id) end + @get_all_role ~w(admin staff)a @doc """ Returns users matching a given set of criteria. """ - def get_users(filter \\ []) do - User - |> join(:left, [u], g in assoc(u, :group)) - |> preload([u, g], group: g) - |> get_users(filter) + def get_users_by(filter \\ [], %CourseRegistration{course_id: course_id, role: role}) + when role in @get_all_role do + CourseRegistration + |> where([cr], cr.course_id == ^course_id) + |> join(:inner, [cr], u in assoc(cr, :user)) + |> preload([cr, u], user: u) + |> join(:left, [cr, u], g in assoc(cr, :group)) + |> preload([cr, u, g], group: g) + |> get_users_helper(filter) end - defp get_users(query, []), do: Repo.all(query) + defp get_users_helper(query, []), do: Repo.all(query) - defp get_users(query, [{:group, group} | filters]), - do: query |> where([u, g], g.name == ^group) |> get_users(filters) + defp get_users_helper(query, [{:group, group} | filters]), + do: query |> where([cr, u, g], g.name == ^group) |> get_users_helper(filters) - defp get_users(query, [filter | filters]), do: query |> where(^[filter]) |> get_users(filters) + defp get_users_helper(query, [filter | filters]), + do: query |> where(^[filter]) |> get_users_helper(filters) @spec sign_in(String.t(), Provider.token(), Provider.provider_instance()) :: {:error, :bad_request | :forbidden | :internal_server_error, String.t()} | {:ok, any} @@ -76,35 +73,50 @@ defmodule Cadet.Accounts do Sign in using given user ID """ def sign_in(username, token, provider) do - case Repo.one(Query.username(username)) do - nil -> - # user is not registered in our database - with {:ok, role} <- Provider.get_role(provider, token), - {:ok, name} <- Provider.get_name(provider, token), - {:ok, _} <- register(%{name: name, username: username}, role) do - sign_in(username, name, token) - else - {:error, :invalid_credentials, err} -> - {:error, :forbidden, err} - - {:error, :upstream, err} -> - {:error, :bad_request, err} - - {:error, _err} -> - {:error, :internal_server_error} - end - - user -> - {:ok, user} + user = username |> Query.username() |> Repo.one() + + if is_nil(user) or is_nil(user.name) do + # user is not registered in our database or does not have a name + # (accounts pre-created by instructors do not have a name, and has to be fetched + # from the auth provider during sign_in) + with {:ok, name} <- Provider.get_name(provider, token), + {:ok, _} <- register(%{name: name, username: username}) do + sign_in(username, name, token) + else + {:error, :invalid_credentials, err} -> + {:error, :forbidden, err} + + {:error, :upstream, err} -> + {:error, :bad_request, err} + + {:error, _err} -> + {:error, :internal_server_error} + end + else + {:ok, user} end end - def update_game_states(user = %User{}, new_game_state = %{}) do - case user - |> User.changeset(%{game_states: new_game_state}) - |> Repo.update() do - result = {:ok, _} -> result - {:error, changeset} -> {:error, {:internal_server_error, full_error_messages(changeset)}} + def update_latest_viewed(user = %User{id: user_id}, latest_viewed_course_id) + when is_ecto_id(latest_viewed_course_id) do + CourseRegistration + |> where(user_id: ^user_id) + |> where(course_id: ^latest_viewed_course_id) + |> Repo.one() + |> case do + nil -> + {:error, {:bad_request, "user is not in the course"}} + + _ -> + case user + |> User.changeset(%{latest_viewed_course_id: latest_viewed_course_id}) + |> Repo.update() do + result = {:ok, _} -> + result + + {:error, changeset} -> + {:error, {:internal_server_error, full_error_messages(changeset)}} + end end end end diff --git a/lib/cadet/accounts/course_registration.ex b/lib/cadet/accounts/course_registration.ex new file mode 100644 index 000000000..d2f7420e2 --- /dev/null +++ b/lib/cadet/accounts/course_registration.ex @@ -0,0 +1,31 @@ +defmodule Cadet.Accounts.CourseRegistration do + @moduledoc """ + The mapping table representing the registration of a user to a course. + """ + use Cadet, :model + + alias Cadet.Accounts.{Role, User} + alias Cadet.Courses.{Course, Group} + + schema "course_registrations" do + field(:role, Role) + field(:game_states, :map) + + belongs_to(:group, Group) + belongs_to(:user, User) + belongs_to(:course, Course) + + timestamps() + end + + @required_fields ~w(user_id course_id role)a + @optional_fields ~w(game_states group_id)a + + def changeset(course_registration, params \\ %{}) do + course_registration + |> cast(params, @optional_fields ++ @required_fields) + |> add_belongs_to_id_from_model([:user, :group, :course], params) + |> validate_required(@required_fields) + |> unique_constraint(:user_id, name: :course_registrations_user_id_course_id_index) + end +end diff --git a/lib/cadet/accounts/course_registrations.ex b/lib/cadet/accounts/course_registrations.ex new file mode 100644 index 000000000..feb7f621b --- /dev/null +++ b/lib/cadet/accounts/course_registrations.ex @@ -0,0 +1,186 @@ +defmodule Cadet.Accounts.CourseRegistrations do + @moduledoc """ + Provides functions fetch, add, update course_registration + """ + use Cadet, [:context, :display] + + import Ecto.Query + + alias Cadet.{Repo, Accounts} + alias Cadet.Accounts.{User, CourseRegistration} + alias Cadet.Assessments.{Answer, Submission} + alias Cadet.Courses.AssessmentConfig + + # guide + # only join with User if need name or user name + # only join with Group if need leader/students in group + # only join with Course if need course info/config + # otherwise just use CourseRegistration + + def get_user_record(user_id, course_id) when is_ecto_id(user_id) and is_ecto_id(course_id) do + CourseRegistration + |> where([cr], cr.user_id == ^user_id) + |> where([cr], cr.course_id == ^course_id) + |> preload(:course) + |> preload(:group) + |> Repo.one() + end + + def get_user_course(user_id, course_id) when is_ecto_id(user_id) and is_ecto_id(course_id) do + CourseRegistration + |> where([cr], cr.user_id == ^user_id) + |> where([cr], cr.course_id == ^course_id) + |> join(:inner, [cr], c in assoc(cr, :course)) + |> join(:left, [cr, c], ac in assoc(c, :assessment_config)) + |> preload([cr, c, ac], + course: {c, assessment_config: ^from(ac in AssessmentConfig, order_by: [asc: ac.order])} + ) + |> preload(:group) + |> Repo.one() + end + + def get_courses(%User{id: id}) do + CourseRegistration + |> where([cr], cr.user_id == ^id) + |> join(:inner, [cr], c in assoc(cr, :course)) + |> preload(:course) + |> Repo.all() + end + + def get_users(course_id) when is_ecto_id(course_id) do + CourseRegistration + |> where([cr], cr.course_id == ^course_id) + |> join(:inner, [cr], u in assoc(cr, :user)) + |> preload(:user) + |> 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) + |> where([cr], cr.group_id == ^group_id) + |> join(:inner, [cr], u in assoc(cr, :user)) + |> join(:inner, [cr, u], g in assoc(cr, :group)) + |> preload(:user) + |> preload(:group) + |> Repo.all() + end + + def upsert_users_in_course(usernames_and_roles, course_id) do + # Note: Usernames have already been namespaced in the controller + usernames_and_roles + |> Enum.reduce_while(nil, fn %{username: username, role: role}, _acc -> + upsert_users_in_course_helper(username, course_id, role) + end) + end + + defp upsert_users_in_course_helper(username, course_id, role) do + case User + |> where(username: ^username) + |> Repo.one() do + nil -> + case Accounts.register(%{username: username}) do + {:ok, _} -> + upsert_users_in_course_helper(username, course_id, role) + + {:error, changeset} -> + {:halt, {:error, {:bad_request, full_error_messages(changeset)}}} + end + + user -> + case enroll_course(%{user_id: user.id, course_id: course_id, role: role}) do + {:ok, _} -> + {:cont, :ok} + + {:error, changeset} -> + {:halt, {:error, {:bad_request, full_error_messages(changeset)}}} + end + end + end + + @doc """ + Enrolls the user into the specified course with the specified role, and updates the user's + latest viewed course id to this enrolled course. + """ + def enroll_course(params = %{user_id: user_id, course_id: course_id, role: _role}) + when is_ecto_id(user_id) and is_ecto_id(course_id) do + case params |> insert_or_update_course_registration() do + {:ok, _course_reg} = ok -> + # Ensures that the user has a latest_viewed_course + User + |> where(id: ^user_id) + |> Repo.one() + |> User.changeset(%{latest_viewed_course_id: course_id}) + |> Repo.update() + + ok + + {:error, _} = error -> + error + end + end + + @spec insert_or_update_course_registration(map()) :: + {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + def insert_or_update_course_registration( + params = %{user_id: user_id, course_id: course_id, role: _role} + ) + when is_ecto_id(user_id) and is_ecto_id(course_id) do + CourseRegistration + |> where(user_id: ^user_id) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> CourseRegistration.changeset(%CourseRegistration{}, params) + cr -> CourseRegistration.changeset(cr, params) + end + |> Repo.insert_or_update() + end + + def update_game_states(cr = %CourseRegistration{}, new_game_state = %{}) do + case cr + |> CourseRegistration.changeset(%{game_states: new_game_state}) + |> Repo.update() do + result = {:ok, _} -> result + {:error, changeset} -> {:error, {:internal_server_error, full_error_messages(changeset)}} + end + end + + def update_role(role, coursereg_id) do + case CourseRegistration |> where(id: ^coursereg_id) |> Repo.one() do + nil -> + {:error, {:bad_request, "User course registration does not exist"}} + + course_reg -> + case course_reg + |> CourseRegistration.changeset(%{role: role}) + |> Repo.update() do + {:ok, _} = result -> result + {:error, changeset} -> {:error, {:bad_request, full_error_messages(changeset)}} + end + end + end + + def delete_course_registration(coursereg_id) do + # TODO: Handle deletions of achievement entries, etc. too + case CourseRegistration |> where(id: ^coursereg_id) |> Repo.one() do + nil -> + {:error, {:bad_request, "User course registration does not exist"}} + + course_reg -> + # Delete submissions and answers before deleting user + Submission + |> where(student_id: ^course_reg.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + + Repo.delete(x) + end) + + Repo.delete(course_reg) + end + end +end diff --git a/lib/cadet/accounts/notification.ex b/lib/cadet/accounts/notification.ex index 9195fcd23..e6e552d1b 100755 --- a/lib/cadet/accounts/notification.ex +++ b/lib/cadet/accounts/notification.ex @@ -1,13 +1,13 @@ defmodule Cadet.Accounts.Notification do @moduledoc """ The Notification entity represents a notification. - It stores information pertaining to the type of notification and who it belongs to. + It stores information pertaining to the type of notification and who in which course it belongs to. Each notification can have an assessment id or submission id, with optional question id. This will be used to pinpoint where the notification will be showed on the frontend. """ use Cadet, :model - alias Cadet.Accounts.{NotificationType, Role, User} + alias Cadet.Accounts.{NotificationType, Role, CourseRegistration} alias Cadet.Assessments.{Assessment, Submission} schema "notifications" do @@ -15,21 +15,21 @@ defmodule Cadet.Accounts.Notification do field(:read, :boolean, default: false) field(:role, Role, virtual: true) - belongs_to(:user, User) + belongs_to(:course_reg, CourseRegistration) belongs_to(:assessment, Assessment) belongs_to(:submission, Submission) timestamps() end - @required_fields ~w(type read role user_id assessment_id)a + @required_fields ~w(type read course_reg_id assessment_id)a @optional_fields ~w(submission_id)a def changeset(answer, params) do answer |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) - |> foreign_key_constraint(:user) + |> foreign_key_constraint(:course_reg_id) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:submission_id) end diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index 68832a899..cec15a064 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -8,20 +8,21 @@ defmodule Cadet.Accounts.Notifications do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Notification, User} + alias Cadet.Accounts.{Notification, CourseRegistration, CourseRegistration} alias Cadet.Assessments.Submission alias Ecto.Multi @doc """ - Fetches all unread notifications belonging to a user as an array + Fetches all unread notifications belonging to a course_reg as an array """ - @spec fetch(%User{}) :: {:ok, {:array, Notification}} - def fetch(user = %User{}) do + @spec fetch(%CourseRegistration{}) :: {:ok, {:array, Notification}} + def fetch(course_reg = %CourseRegistration{}) do notifications = Notification - |> where(user_id: ^user.id) + |> where(course_reg_id: ^course_reg.id) |> where(read: false) - |> preload(:assessment) + |> join(:inner, [n], a in assoc(n, :assessment)) + |> preload([n, a], assessment: {a, :config}) |> Repo.all() {:ok, notifications} @@ -43,13 +44,13 @@ defmodule Cadet.Accounts.Notifications do defp write_student( params = %{ - user_id: user_id, + course_reg_id: course_reg_id, assessment_id: assessment_id, type: type } ) do Notification - |> where(user_id: ^user_id) + |> where(course_reg_id: ^course_reg_id) |> where(assessment_id: ^assessment_id) |> where(type: ^type) |> Repo.one() @@ -71,13 +72,13 @@ defmodule Cadet.Accounts.Notifications do defp write_staff( params = %{ - user_id: user_id, + course_reg_id: course_reg_id, submission_id: submission_id, type: type } ) do Notification - |> where(user_id: ^user_id) + |> where(course_reg_id: ^course_reg_id) |> where(submission_id: ^submission_id) |> where(type: ^type) |> Repo.one() @@ -100,18 +101,19 @@ defmodule Cadet.Accounts.Notifications do @doc """ Changes read status of notification(s) from false to true. """ - @spec acknowledge({:array, :integer}, %User{}) :: + @spec acknowledge({:array, :integer}, %CourseRegistration{}) :: {:ok, Ecto.Schema.t()} | {:error, any} | {:error, Ecto.Multi.name(), any, %{Ecto.Multi.name() => any}} - def acknowledge(notification_ids, user = %User{}) when is_list(notification_ids) do + def acknowledge(notification_ids, course_reg = %CourseRegistration{}) + when is_list(notification_ids) do Multi.new() |> Multi.run(:update_all, fn _repo, _ -> Enum.reduce_while(notification_ids, {:ok, nil}, fn n_id, acc -> # credo:disable-for-next-line case acc do {:ok, _} -> - {:cont, acknowledge(n_id, user)} + {:cont, acknowledge(n_id, course_reg)} _ -> {:halt, acc} @@ -121,9 +123,9 @@ defmodule Cadet.Accounts.Notifications do |> Repo.transaction() end - @spec acknowledge(:integer, %User{}) :: {:ok, Ecto.Schema.t()} | {:error, any()} - def acknowledge(notification_id, user = %User{}) do - notification = Repo.get_by(Notification, id: notification_id, user_id: user.id) + @spec acknowledge(:integer, %CourseRegistration{}) :: {:ok, Ecto.Schema.t()} | {:error, any()} + def acknowledge(notification_id, course_reg = %CourseRegistration{}) do + notification = Repo.get_by(Notification, id: notification_id, course_reg_id: course_reg.id) case notification do nil -> @@ -131,7 +133,7 @@ defmodule Cadet.Accounts.Notifications do notification -> notification - |> Notification.changeset(%{role: user.role, read: true}) + |> Notification.changeset(%{role: course_reg.role, read: true}) |> Repo.update() end end @@ -139,15 +141,15 @@ defmodule Cadet.Accounts.Notifications do @doc """ Function that handles notifications when a submission is unsubmitted. """ - @spec handle_unsubmit_notifications(integer(), %User{}) :: + @spec handle_unsubmit_notifications(integer(), %CourseRegistration{}) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} - def handle_unsubmit_notifications(assessment_id, student = %User{}) + def handle_unsubmit_notifications(assessment_id, student = %CourseRegistration{}) when is_ecto_id(assessment_id) do # Fetch and delete all notifications of :autograded and :graded # Add new notification :unsubmitted Notification - |> where(user_id: ^student.id) + |> where(course_reg_id: ^student.id) |> where(assessment_id: ^assessment_id) |> where([n], n.type in ^[:autograded, :graded]) |> Repo.delete_all() @@ -155,7 +157,7 @@ defmodule Cadet.Accounts.Notifications do write(%{ type: :unsubmitted, role: student.role, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment_id }) end @@ -175,7 +177,7 @@ defmodule Cadet.Accounts.Notifications do type: type, read: false, role: :student, - user_id: submission.student_id, + course_reg_id: submission.student_id, assessment_id: submission.assessment_id }) end @@ -183,12 +185,14 @@ defmodule Cadet.Accounts.Notifications do @doc """ Writes a notification to all students that a new assessment is available. """ - @spec write_notification_for_new_assessment(integer()) :: + @spec write_notification_for_new_assessment(integer(), integer()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} - def write_notification_for_new_assessment(assessment_id) when is_ecto_id(assessment_id) do + def write_notification_for_new_assessment(course_id, assessment_id) + when is_ecto_id(assessment_id) and is_ecto_id(course_id) do Multi.new() |> Multi.run(:insert_all, fn _repo, _ -> - User + CourseRegistration + |> where(course_id: ^course_id) |> where(role: ^:student) |> Repo.all() |> Enum.reduce_while({:ok, nil}, fn student, acc -> @@ -200,7 +204,7 @@ defmodule Cadet.Accounts.Notifications do type: :new, read: false, role: :student, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment_id })} @@ -228,7 +232,7 @@ defmodule Cadet.Accounts.Notifications do type: :submitted, read: false, role: :staff, - user_id: avenger_id, + course_reg_id: avenger_id, assessment_id: submission.assessment_id, submission_id: submission.id }) @@ -236,7 +240,7 @@ defmodule Cadet.Accounts.Notifications do end defp get_avenger_id_of(student_id) when is_ecto_id(student_id) do - User + CourseRegistration |> Repo.get_by(id: student_id) |> Repo.preload(:group) |> Map.get(:group) diff --git a/lib/cadet/accounts/query.ex b/lib/cadet/accounts/query.ex index 3921a65e7..3c368d1f1 100644 --- a/lib/cadet/accounts/query.ex +++ b/lib/cadet/accounts/query.ex @@ -4,35 +4,37 @@ defmodule Cadet.Accounts.Query do """ import Ecto.Query - alias Cadet.Accounts.User - alias Cadet.Courses.Group + alias Cadet.Accounts.{User, CourseRegistration} alias Cadet.Repo - # This gets all users where each and every user is a student. - def all_students do + def all_students(course_id) do User - |> where([u], u.role == "student") - |> preload(:group) + |> in_course(course_id) + |> where([u, cr], cr.role == "student") |> Repo.all() end def username(username) do User |> of_username(username) + |> preload(:latest_viewed_course) end - @spec students_of(%User{}) :: Ecto.Query.t() - def students_of(%User{id: id, role: :staff}) do - User - |> join(:inner, [u], g in Group, on: u.group_id == g.id) - |> where([_, g], g.leader_id == ^id) + @spec students_of(%CourseRegistration{}) :: Ecto.Query.t() + def students_of(course_reg = %CourseRegistration{course_id: course_id}) do + # Note that staff role is not check here as we assume that + # group leader is assign to a staff validated by group changeset + CourseRegistration + |> where([cr], cr.course_id == ^course_id) + |> join(:inner, [cr], g in assoc(cr, :group)) + |> where([cr, g], g.leader_id == ^course_reg.id) end def avenger_of?(avenger, student_id) do students = students_of(avenger) students - |> Repo.get(student_id) + |> Repo.get_by(id: student_id) |> case do nil -> false _ -> true @@ -42,4 +44,10 @@ defmodule Cadet.Accounts.Query do defp of_username(query, username) do query |> where([a], a.username == ^username) end + + defp in_course(user, course_id) do + user + |> join(:inner, [u], cr in CourseRegistration, on: u.id == cr.user_id) + |> where([_, cr], cr.course_id == ^course_id) + end end diff --git a/lib/cadet/accounts/user.ex b/lib/cadet/accounts/user.ex index 3bda352e9..99f19f9bb 100644 --- a/lib/cadet/accounts/user.ex +++ b/lib/cadet/accounts/user.ex @@ -1,31 +1,30 @@ defmodule Cadet.Accounts.User do @moduledoc """ The User entity represents a user. - It stores basic information such as name and role - Each user is associated to one `role` which determines the access level - of the user. + It stores basic information such as name """ use Cadet, :model - alias Cadet.Accounts.Role - alias Cadet.Courses.Group + alias Cadet.Accounts.CourseRegistration + alias Cadet.Courses.Course schema "users" do field(:name, :string) - field(:role, Role) field(:username, :string) - field(:game_states, :map) - belongs_to(:group, Group) + + belongs_to(:latest_viewed_course, Course) + has_many(:courses, CourseRegistration) + timestamps() end - @required_fields ~w(name role)a - @optional_fields ~w(username group_id game_states)a + @required_fields ~w(username)a + @optional_fields ~w(name latest_viewed_course_id)a def changeset(user, params \\ %{}) do user |> cast(params, @required_fields ++ @optional_fields) - |> add_belongs_to_id_from_model(:group, params) |> validate_required(@required_fields) + |> foreign_key_constraint(:latest_viewed_course_id) end end diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index 55277e881..1f8a04f5c 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -6,16 +6,14 @@ defmodule Cadet.Assessments.Answer do use Cadet, :model alias Cadet.Repo - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.Answer.AutogradingStatus alias Cadet.Assessments.AnswerTypes.{MCQAnswer, ProgrammingAnswer, VotingAnswer} alias Cadet.Assessments.{Question, QuestionType, Submission} schema "answers" do - field(:grade, :integer, default: 0) # used to compare answers with others field(:relative_score, :float, default: 0.0) - field(:adjustment, :integer, default: 0) field(:xp, :integer, default: 0) field(:xp_adjustment, :integer, default: 0) field(:comments, :string) @@ -24,7 +22,7 @@ defmodule Cadet.Assessments.Answer do field(:answer, :map) field(:type, QuestionType, virtual: true) - belongs_to(:grader, User) + belongs_to(:grader, CourseRegistration) belongs_to(:submission, Submission) belongs_to(:question, Question) @@ -32,7 +30,7 @@ defmodule Cadet.Assessments.Answer do end @required_fields ~w(answer submission_id question_id type)a - @optional_fields ~w(xp xp_adjustment grade adjustment grader_id comments)a + @optional_fields ~w(xp xp_adjustment grader_id comments)a def changeset(answer, params) do answer @@ -43,7 +41,7 @@ defmodule Cadet.Assessments.Answer do |> foreign_key_constraint(:submission_id) |> foreign_key_constraint(:question_id) |> validate_answer_content() - |> validate_xp_grade_adjustment_total() + |> validate_xp_adjustment_total() end @spec grading_changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() @@ -55,8 +53,6 @@ defmodule Cadet.Assessments.Answer do grader_id xp xp_adjustment - grade - adjustment autograding_results autograding_status comments @@ -64,29 +60,26 @@ defmodule Cadet.Assessments.Answer do ) |> add_belongs_to_id_from_model(:grader, params) |> foreign_key_constraint(:grader_id) - |> validate_xp_grade_adjustment_total() + |> validate_xp_adjustment_total() end @spec autograding_changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def autograding_changeset(answer, params) do answer - |> cast(params, ~w(grade adjustment xp xp_adjustment autograding_status autograding_results)a) - |> validate_xp_grade_adjustment_total() + |> cast(params, ~w(xp xp_adjustment autograding_status autograding_results)a) + |> validate_xp_adjustment_total() end - @spec validate_xp_grade_adjustment_total(Ecto.Changeset.t()) :: Ecto.Changeset.t() - defp validate_xp_grade_adjustment_total(changeset) do + @spec validate_xp_adjustment_total(Ecto.Changeset.t()) :: Ecto.Changeset.t() + defp validate_xp_adjustment_total(changeset) do answer = apply_changes(changeset) - total_grade = answer.grade + answer.adjustment - total_xp = answer.xp + answer.xp_adjustment with {:question_id, question_id} when is_ecto_id(question_id) <- {:question_id, answer.question_id}, - {:question, %{max_grade: max_grade, max_xp: max_xp}} <- + {:question, %{max_xp: max_xp}} <- {:question, Repo.get(Question, question_id)}, - {:total_grade, true} <- {:total_grade, total_grade >= 0 and total_grade <= max_grade}, {:total_xp, true} <- {:total_xp, total_xp >= 0 and total_xp <= max_xp} do changeset else @@ -96,13 +89,9 @@ defmodule Cadet.Assessments.Answer do {:question, _} -> add_error(changeset, :question_id, "refers to non-existent question") - {:total_grade, false} -> - add_error(changeset, :adjustment, "must make total be between 0 and question.max_grade") - {:total_xp, false} -> add_error(changeset, :xp_adjustment, "must make total be between 0 and question.max_xp") end - |> validate_number(:grade, greater_than_or_equal_to: 0) |> validate_number(:xp, greater_than_or_equal_to: 0) end diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 3165c0676..547e3e6e3 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -6,15 +6,12 @@ defmodule Cadet.Assessments.Assessment do use Cadet, :model use Arc.Ecto.Schema + alias Cadet.Repo alias Cadet.Assessments.{AssessmentAccess, Question, SubmissionStatus, Upload} - - @assessment_types ~w(contest mission path practical sidequest) - def assessment_types, do: @assessment_types + alias Cadet.Courses.{Course, AssessmentConfig} schema "assessments" do field(:access, AssessmentAccess, virtual: true, default: :public) - field(:max_grade, :integer, virtual: true) - field(:grade, :integer, virtual: true, default: 0) field(:max_xp, :integer, virtual: true) field(:xp, :integer, virtual: true, default: 0) field(:user_status, SubmissionStatus, virtual: true) @@ -23,7 +20,6 @@ defmodule Cadet.Assessments.Assessment do field(:graded_count, :integer, virtual: true) field(:title, :string) field(:is_published, :boolean, default: false) - field(:type, :string) field(:summary_short, :string) field(:summary_long, :string) field(:open_at, :utc_datetime_usec) @@ -35,11 +31,14 @@ defmodule Cadet.Assessments.Assessment do field(:reading, :string) field(:password, :string, default: nil) + belongs_to(:config, AssessmentConfig) + belongs_to(:course, Course) + has_many(:questions, Question, on_delete: :delete_all) timestamps() end - @required_fields ~w(type title open_at close_at number)a + @required_fields ~w(title open_at close_at number course_id config_id)a @optional_fields ~w(reading summary_short summary_long is_published story cover_picture access password)a @optional_file_fields ~w(mission_pdf)a @@ -54,10 +53,31 @@ defmodule Cadet.Assessments.Assessment do |> cast_attachments(params, @optional_file_fields) |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) - |> validate_inclusion(:type, @assessment_types) + |> add_belongs_to_id_from_model([:config, :course], params) + |> foreign_key_constraint(:config_id) + |> foreign_key_constraint(:course_id) + |> unique_constraint([:number, :course_id]) + |> validate_config_course |> validate_open_close_date end + defp validate_config_course(changeset) do + config_id = get_field(changeset, :config_id) + course_id = get_field(changeset, :course_id) + + case Repo.get(AssessmentConfig, config_id) do + nil -> + add_error(changeset, :config, "does not exist") + + config -> + if config.course_id == course_id do + changeset + else + add_error(changeset, :config, "does not belong to the same course as this assessment") + end + end + end + defp validate_open_close_date(changeset) do validate_change(changeset, :open_at, fn :open_at, open_at -> if Timex.before?(open_at, get_field(changeset, :close_at)) do diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b856a3ef3..bbba89f52 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -6,18 +6,16 @@ defmodule Cadet.Assessments do use Cadet, [:context, :display] import Ecto.Query - alias Cadet.Accounts.{Notification, Notifications, User} + alias Cadet.Accounts.{Notification, Notifications, User, CourseRegistration} alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} alias Cadet.Autograder.GradingJob - alias Cadet.Courses.Group + alias Cadet.Courses.{Group, AssessmentConfig} alias Cadet.Jobs.Log alias Cadet.ProgramAnalysis.Lexer alias Ecto.Multi require Decimal - @xp_early_submission_max_bonus 100 - @xp_bonus_assessment_type ~w(mission sidequest) @open_all_assessment_roles ~w(staff admin)a # These roles can save and finalise answers for closed assessments and @@ -63,45 +61,44 @@ defmodule Cadet.Assessments do Repo.delete_all(submissions) end - @spec user_max_grade(%User{}) :: integer() - def user_max_grade(%User{id: user_id}) when is_ecto_id(user_id) do + @spec user_max_xp(%CourseRegistration{}) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do Submission |> where(status: ^:submitted) - |> where(student_id: ^user_id) + |> where(student_id: ^cr_id) |> join( :inner, [s], - a in subquery(Query.all_assessments_with_max_grade()), + a in subquery(Query.all_assessments_with_max_xp()), on: s.assessment_id == a.id ) - |> select([_, a], sum(a.max_grade)) + |> select([_, a], sum(a.max_xp)) |> Repo.one() |> decimal_to_integer() end - def user_total_grade_xp(%User{id: user_id}) do - submission_grade_xp = + def user_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = Submission - |> where(student_id: ^user_id) + |> where(student_id: ^cr_id) |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) |> group_by([s], s.id) |> select([s, a], %{ - total_grade: sum(a.grade) + sum(a.adjustment), # grouping by submission, so s.xp_bonus will be the same, but we need an # aggregate function total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) }) total = - submission_grade_xp + submission_xp |> subquery |> select([s], %{ - total_grade: sum(s.total_grade), total_xp: sum(s.total_xp) }) |> Repo.one() - for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) end defp decimal_to_integer(decimal) do @@ -112,23 +109,17 @@ defmodule Cadet.Assessments do end end - def user_with_group(%User{id: id}) do - User - |> preload(:group) - |> Repo.get(id) - end - - def user_current_story(user = %User{}) do + def user_current_story(cr = %CourseRegistration{}) do {:ok, %{result: story}} = Multi.new() |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(user, :unattempted)} + {:ok, get_user_story_by_type(cr, :unattempted)} end) |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> if unattempted_story do {:ok, %{play_story?: true, story: unattempted_story}} else - {:ok, %{play_story?: false, story: get_user_story_by_type(user, :attempted)}} + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} end end) |> Repo.transaction() @@ -136,8 +127,9 @@ defmodule Cadet.Assessments do story end - @spec get_user_story_by_type(%User{}, :unattempted | :attempted) :: String.t() | nil - def get_user_story_by_type(%User{id: user_id}, type) + @spec get_user_story_by_type(%CourseRegistration{}, :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) when is_atom(type) do filter_and_sort = fn query -> case type do @@ -155,9 +147,9 @@ defmodule Cadet.Assessments do |> where(is_published: true) |> where([a], not is_nil(a.story)) |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^user_id) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) |> filter_and_sort.() - |> order_by([a], a.type) + |> order_by([a], a.config_id) |> select([a], a.story) |> first() |> Repo.one() @@ -165,62 +157,64 @@ defmodule Cadet.Assessments do def assessment_with_questions_and_answers( assessment = %Assessment{password: nil}, - user = %User{}, + cr = %CourseRegistration{}, nil ) do - assessment_with_questions_and_answers(assessment, user) + assessment_with_questions_and_answers(assessment, cr) end def assessment_with_questions_and_answers( assessment = %Assessment{password: nil}, - user = %User{}, + cr = %CourseRegistration{}, _ ) do - assessment_with_questions_and_answers(assessment, user) + assessment_with_questions_and_answers(assessment, cr) end def assessment_with_questions_and_answers( assessment = %Assessment{password: password}, - user = %User{}, + cr = %CourseRegistration{}, given_password ) do cond do Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, user) + assessment_with_questions_and_answers(assessment, cr) - match?({:ok, _}, find_submission(user, assessment)) -> - assessment_with_questions_and_answers(assessment, user) + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) given_password == nil -> {:error, {:forbidden, "Missing Password."}} password == given_password -> - find_or_create_submission(user, assessment) - assessment_with_questions_and_answers(assessment, user) + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) true -> {:error, {:forbidden, "Invalid Password."}} end end - def assessment_with_questions_and_answers(id, user = %User{}, password) + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) when is_ecto_id(id) do - role = user.role + role = cr.role assessment = if role in @open_all_assessment_roles do Assessment |> where(id: ^id) + |> preload(:config) |> Repo.one() else Assessment |> where(id: ^id) |> where(is_published: true) + |> preload(:config) |> Repo.one() end if assessment do - assessment_with_questions_and_answers(assessment, user, password) + assessment_with_questions_and_answers(assessment, cr, password) else {:error, {:bad_request, "Assessment not found"}} end @@ -228,52 +222,53 @@ defmodule Cadet.Assessments do def assessment_with_questions_and_answers( assessment = %Assessment{id: id}, - user = %User{role: role} + course_reg = %CourseRegistration{role: role} ) do if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do answer_query = Answer |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^user.id) + |> where([_, s], s.student_id == ^course_reg.id) questions = Question |> where(assessment_id: ^id) |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) |> join(:left, [_, a], g in assoc(a, :grader)) - |> select([q, a, g], {q, a, g}) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) |> order_by(:display_order) |> Repo.all() |> Enum.map(fn - {q, nil, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, g} -> %{q | answer: %Answer{a | grader: g}} + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} end) - |> load_contest_voting_entries(user.id) + |> load_contest_voting_entries(course_reg.id) - assessment = Map.put(assessment, :questions, questions) + assessment = assessment |> Map.put(:questions, questions) {:ok, assessment} else {:error, {:unauthorized, "Assessment not open"}} end end - def assessment_with_questions_and_answers(id, user = %User{}) do - assessment_with_questions_and_answers(id, user, nil) + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) end @doc """ Returns a list of assessments with all fields and an indicator showing whether it has been attempted by the supplied user """ - def all_assessments(user = %User{}) do + def all_assessments(cr = %CourseRegistration{}) do submission_aggregates = Submission |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^user.id) + |> where([s], s.student_id == ^cr.id) |> group_by([s], s.assessment_id) |> select([s, ans], %{ assessment_id: s.assessment_id, - grade: fragment("? + ?", sum(ans.grade), sum(ans.adjustment)), # s.xp_bonus should be the same across the group, but we need an aggregate function here xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) @@ -281,11 +276,12 @@ defmodule Cadet.Assessments do submission_status = Submission - |> where([s], s.student_id == ^user.id) + |> where([s], s.student_id == ^cr.id) |> select([s], [:assessment_id, :status]) assessments = - Query.all_assessments_with_aggregates() + cr.course_id + |> Query.all_assessments_with_aggregates() |> subquery() |> join( :left, @@ -297,19 +293,19 @@ defmodule Cadet.Assessments do |> select([a, sa, s], %{ a | xp: sa.xp, - grade: sa.grade, graded_count: sa.graded_count, user_status: s.status }) - |> filter_published_assessments(user) + |> filter_published_assessments(cr) |> order_by(:open_at) + |> preload(:config) |> Repo.all() {:ok, assessments} end - def filter_published_assessments(assessments, user) do - role = user.role + def filter_published_assessments(assessments, cr) do + role = cr.role case role do :student -> where(assessments, is_published: true) @@ -423,9 +419,13 @@ defmodule Cadet.Assessments do end @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset(params = %{number: number}, force_update) do + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do Assessment |> where(number: ^number) + |> where(course_id: ^course_id) |> Repo.one() |> case do nil -> @@ -515,9 +515,9 @@ defmodule Cadet.Assessments do contest_submission_ids_length = Enum.count(contest_submission_ids) user_ids = - User + CourseRegistration |> where(role: "student") - |> select([u], u.id) + |> select([cr], cr.id) |> Repo.all() votes_per_user = min(contest_submission_ids_length, 10) @@ -575,7 +575,7 @@ defmodule Cadet.Assessments do new_submission_votes = votes |> Enum.map(fn s_id -> - %SubmissionVotes{user_id: user_id, submission_id: s_id, question_id: question_id} + %SubmissionVotes{voter_id: user_id, submission_id: s_id, question_id: question_id} end) |> Enum.concat(submission_votes) @@ -655,10 +655,15 @@ defmodule Cadet.Assessments do `{:bad_request, "Missing or invalid parameter(s)"}` """ - def answer_question(question = %Question{}, user = %User{id: user_id}, raw_answer, force_submit) do - with {:ok, submission} <- find_or_create_submission(user, question.assessment), + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, user_id) do + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do update_submission_status_router(submission, question) {:ok, nil} @@ -677,11 +682,11 @@ defmodule Cadet.Assessments do end end - def get_submission(assessment_id, %User{id: user_id}) + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) when is_ecto_id(assessment_id) do Submission |> where(assessment_id: ^assessment_id) - |> where(student_id: ^user_id) + |> where(student_id: ^cr_id) |> join(:inner, [s], a in assoc(s, :assessment)) |> preload([_, a], assessment: a) |> Repo.one() @@ -690,7 +695,7 @@ defmodule Cadet.Assessments do def finalise_submission(submission = %Submission{}) do with {:status, :attempted} <- {:status, submission.status}, {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # TODO: Couple with update_submission_status_and_xp_bonus to ensure notification is sent + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent Notifications.write_notification_when_student_submits(submission) # Begin autograding job GradingJob.force_grade_individual_submission(updated_submission) @@ -708,7 +713,10 @@ defmodule Cadet.Assessments do end end - def unsubmit_submission(submission_id, user = %User{id: user_id, role: role}) + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) when is_ecto_id(submission_id) do submission = Submission @@ -716,7 +724,7 @@ defmodule Cadet.Assessments do |> preload([_, a], assessment: a) |> Repo.get(submission_id) - bypass = role in @bypass_closed_roles and submission.student_id == user_id + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, @@ -724,7 +732,7 @@ defmodule Cadet.Assessments do {:allowed_to_unsubmit?, true} <- {:allowed_to_unsubmit?, role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(user, submission.student_id)} do + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do Multi.new() |> Multi.run( :rollback_submission, @@ -733,7 +741,7 @@ defmodule Cadet.Assessments do |> Submission.changeset(%{ status: :attempted, xp_bonus: 0, - unsubmitted_by_id: user_id, + unsubmitted_by_id: course_reg_id, unsubmitted_at: Timex.now() }) |> Repo.update() @@ -755,8 +763,6 @@ defmodule Cadet.Assessments do {:cont, answer |> Answer.grading_changeset(%{ - grade: 0, - adjustment: 0, xp: 0, xp_adjustment: 0, autograding_status: :none, @@ -770,7 +776,7 @@ defmodule Cadet.Assessments do Cadet.Accounts.Notifications.handle_unsubmit_notifications( submission.assessment.id, - Cadet.Accounts.get_user(submission.student_id) + Repo.get(CourseRegistration, submission.student_id) ) {:ok, nil} @@ -799,18 +805,21 @@ defmodule Cadet.Assessments do {:ok, %Submission{}} | {:error, Ecto.Changeset.t()} defp update_submission_status_and_xp_bonus(submission = %Submission{}) do assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - xp_bonus = - cond do - assessment.type not in @xp_bonus_assessment_type -> - 0 - - Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: 48)) -> - @xp_early_submission_max_bonus + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay - true -> - deduction = Timex.diff(Timex.now(), assessment.open_at, :hours) - 48 - Enum.max([0, @xp_early_submission_max_bonus - deduction]) + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) end submission @@ -853,24 +862,24 @@ defmodule Cadet.Assessments do end defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - not_nil_entries = + has_nil_entries = SubmissionVotes |> where(question_id: ^question.id) - |> where(user_id: ^submission.student_id) + |> where(voter_id: ^submission.student_id) |> where([sv], is_nil(sv.rank)) |> Repo.exists?() - unless not_nil_entries do + unless has_nil_entries do submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() end end - defp load_contest_voting_entries(questions, user_id) do + defp load_contest_voting_entries(questions, voter_id) do Enum.map( questions, fn q -> if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_user_id(q.id, user_id) + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) # fetch top 10 contest voting entries with the contest question id question_id = fetch_associated_contest_question_id(q) @@ -896,9 +905,9 @@ defmodule Cadet.Assessments do ) end - defp all_submission_votes_by_question_id_and_user_id(question_id, user_id) do + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do SubmissionVotes - |> where([v], v.user_id == ^user_id and v.question_id == ^question_id) + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) |> join(:inner, [v], s in assoc(v, :submission)) |> join(:inner, [v, s], a in assoc(s, :answers)) |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, rank: v.rank}) @@ -929,14 +938,23 @@ defmodule Cadet.Assessments do def fetch_top_relative_score_answers(question_id, number_of_answers) do Answer |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) |> order_by(desc: :relative_score) |> join(:left, [a], s in assoc(a, :submission)) |> join(:left, [a, s], student in assoc(s, :student)) - |> select([a, s, student], %{ + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> select([a, s, student, student_user], %{ submission_id: a.submission_id, answer: a.answer, relative_score: a.relative_score, - student_name: student.name + student_name: student_user.name }) |> limit(^number_of_answers) |> Repo.all() @@ -1077,18 +1095,21 @@ defmodule Cadet.Assessments do The return value is {:ok, submissions} if no errors, else it is {:error, {:unauthorized, "Forbidden."}} """ - @spec all_submissions_by_grader_for_index(%User{}) :: + @spec all_submissions_by_grader_for_index(%CourseRegistration{}) :: {:ok, String.t()} - def all_submissions_by_grader_for_index(grader = %User{}, group_only \\ false) do + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false + ) do show_all = not group_only group_where = if show_all, do: "", else: - "where s.student_id in (select u.id from users u inner join groups g on u.group_id = g.id where g.leader_id = $1) or s.student_id = $1" + "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" - params = if show_all, do: [], else: [grader.id] + 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 # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. @@ -1101,13 +1122,11 @@ defmodule Cadet.Assessments do s.id, s.status, s."unsubmittedAt", - s.grade, - s.adjustment, s.xp, s."xpAdjustment", s."xpBonus", s."gradedCount", - assts.jsn AS assessment, + assts.jsn as assessment, students.jsn as student, unsubmitters.jsn as "unsubmittedBy" from @@ -1118,8 +1137,6 @@ defmodule Cadet.Assessments do s.status, s.unsubmitted_at as "unsubmittedAt", s.unsubmitted_by_id, - sum(ans.grade) as grade, - sum(ans.adjustment) as adjustment, sum(ans.xp) as xp, sum(ans.xp_adjustment) as "xpAdjustment", s.xp_bonus as "xpBonus", @@ -1132,11 +1149,45 @@ defmodule Cadet.Assessments do inner join (select a.id, to_json(a) as jsn - from (select a.id, a.title, a.type, sum(q.max_grade) as "maxGrade", sum(q.max_xp) as "maxXp", count(q.id) as "questionCount" from assessments a left join questions q on a.id = q.assessment_id group by a.id) a) assts on assts.id = s.assessment_id + from + (select + a.id, + a.title, + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id inner join - (select u.id, to_json(u) as jsn from (select u.id, u.name, g.name as "groupName", g.leader_id as "groupLeaderId" from users u left join groups g on g.id = u.group_id) u) students on students.id = s.student_id + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id left join - (select u.id, to_json(u) as jsn from (select u.id, u.name from users u) u) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id ) q """, params @@ -1154,13 +1205,16 @@ defmodule Cadet.Assessments do |> where(submission_id: ^id) |> join(:inner, [a], q in assoc(a, :question)) |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) |> join(:inner, [a, ...], s in assoc(a, :submission)) |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> preload([_, q, ast, g, s, st], - question: {q, assessment: ast}, - grader: g, - submission: {s, student: st} + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} ) answers = @@ -1209,14 +1263,14 @@ defmodule Cadet.Assessments do @spec update_grading_info( %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, %{}, - %User{} + %CourseRegistration{} ) :: {:ok, nil} | {:error, {:unauthorized | :bad_request | :internal_server_error, String.t()}} def update_grading_info( %{submission_id: submission_id, question_id: question_id}, attrs, - %User{id: grader_id} + %CourseRegistration{id: grader_id} ) when is_ecto_id(submission_id) and is_ecto_id(question_id) do attrs = Map.put(attrs, "grader_id", grader_id) @@ -1270,9 +1324,12 @@ defmodule Cadet.Assessments do {:error, {:unauthorized, "User is not permitted to grade."}} end - @spec force_regrade_submission(integer() | String.t(), %User{}) :: + @spec force_regrade_submission(integer() | String.t(), %CourseRegistration{}) :: {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission(submission_id, _requesting_user = %User{id: grader_id}) + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) when is_ecto_id(submission_id) do with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do @@ -1291,12 +1348,16 @@ defmodule Cadet.Assessments do {:error, {:forbidden, "User is not permitted to grade."}} end - @spec force_regrade_answer(integer() | String.t(), integer() | String.t(), %User{}) :: + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + %CourseRegistration{} + ) :: {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} def force_regrade_answer( submission_id, question_id, - _requesting_user = %User{id: grader_id} + _requesting_user = %CourseRegistration{id: grader_id} ) when is_ecto_id(submission_id) and is_ecto_id(question_id) do answer = @@ -1324,10 +1385,10 @@ defmodule Cadet.Assessments do {:error, {:forbidden, "User is not permitted to grade."}} end - defp find_submission(user = %User{}, assessment = %Assessment{}) do + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do submission = Submission - |> where(student_id: ^user.id) + |> where(student_id: ^cr.id) |> where(assessment_id: ^assessment.id) |> Repo.one() @@ -1344,58 +1405,95 @@ defmodule Cadet.Assessments do Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published end - @type group_summary_entry :: %{ - group_name: String.t(), - leader_name: String.t(), - ungraded_missions: integer(), - submitted_missions: integer(), - ungraded_sidequests: number(), - submitted_sidequests: number() - } - - @spec get_group_grading_summary :: - {:ok, [group_summary_entry()]} - def get_group_grading_summary do + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do subs = Answer |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in User, on: s.student_id == st.id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) |> where( - [ans, s, st, a], + [ans, s, st, a, ac], not is_nil(st.group_id) and s.status == ^:submitted and - a.type in ^["mission", "sidequest"] + ac.show_grading_summary and a.course_id == ^course_id ) - |> group_by([ans, s, st, a], s.id) - |> select([ans, s, st, a], %{ + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ group_id: max(st.group_id), - type: max(a.type), + config_id: max(ac.id), + config_type: max(ac.type), num_submitted: count(), num_ungraded: filter(count(), is_nil(ans.grader_id)) }) - rows = + raw_data = subs |> subquery() |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in User, on: l.id == g.leader_id) - |> group_by([t, g, l], [t.group_id, g.name, l.name]) - |> select([t, g, l], %{ + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ group_name: g.name, - leader_name: l.name, - ungraded_missions: filter(count(), t.type == "mission" and t.num_ungraded > 0), - submitted_missions: filter(count(), t.type == "mission"), - ungraded_sidequests: filter(count(), t.type == "sidequest" and t.num_ungraded > 0), - submitted_sidequests: filter(count(), t.type == "sidequest") + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() }) |> Repo.all() - {:ok, rows} + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} end - defp create_empty_submission(user = %User{}, assessment = %Assessment{}) do + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do %Submission{} - |> Submission.changeset(%{student: user, assessment: assessment}) + |> Submission.changeset(%{student: cr, assessment: assessment}) |> Repo.insert() |> case do {:ok, submission} -> {:ok, submission} @@ -1403,10 +1501,10 @@ defmodule Cadet.Assessments do end end - defp find_or_create_submission(user = %User{}, assessment = %Assessment{}) do - case find_submission(user, assessment) do + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(user, assessment) + {:error, _} -> create_empty_submission(cr, assessment) end end @@ -1414,12 +1512,12 @@ defmodule Cadet.Assessments do submission = %Submission{}, question = %Question{}, raw_answer, - user_id + course_reg_id ) do answer_content = build_answer_content(raw_answer, question.type) if question.type == :voting do - insert_or_update_voting_answer(submission.id, user_id, question.id, answer_content) + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) else answer_changeset = %Answer{} @@ -1438,10 +1536,10 @@ defmodule Cadet.Assessments do end end - def insert_or_update_voting_answer(submission_id, user_id, question_id, answer_content) do + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do set_rank_to_nil = SubmissionVotes - |> where(user_id: ^user_id, question_id: ^question_id) + |> where(voter_id: ^course_reg_id, question_id: ^question_id) voting_multi = Multi.new() @@ -1454,7 +1552,7 @@ defmodule Cadet.Assessments do |> Multi.run("update#{index}", fn _repo, _ -> SubmissionVotes |> Repo.get_by( - user_id: user_id, + voter_id: course_reg_id, submission_id: entry.submission_id ) |> SubmissionVotes.changeset(%{rank: entry.rank}) diff --git a/lib/cadet/assessments/query.ex b/lib/cadet/assessments/query.ex index 34ced60e1..85cca7468 100644 --- a/lib/cadet/assessments/query.ex +++ b/lib/cadet/assessments/query.ex @@ -2,42 +2,44 @@ defmodule Cadet.Assessments.Query do @moduledoc """ Generate queries related to the Assessments context """ + use Cadet, :context + import Ecto.Query alias Cadet.Assessments.{Assessment, Question} @doc """ Returns a query with the following bindings: - [assessments_with_xp_and_grade, questions] + [assessments_with_xp, questions] """ - @spec all_assessments_with_aggregates :: Ecto.Query.t() - def all_assessments_with_aggregates do + @spec all_assessments_with_aggregates(integer()) :: Ecto.Query.t() + def all_assessments_with_aggregates(course_id) when is_ecto_id(course_id) do Assessment + |> where(course_id: ^course_id) |> join(:inner, [a], q in subquery(assessments_aggregates()), on: a.id == q.assessment_id) |> select([a, q], %Assessment{ a - | max_grade: q.max_grade, - max_xp: q.max_xp, + | max_xp: q.max_xp, question_count: q.question_count }) end @doc """ Returns a query with the following bindings: - [assessments_with_grade, questions] + [assessments_with_xp, questions] """ - @spec all_assessments_with_max_grade :: Ecto.Query.t() - def all_assessments_with_max_grade do + @spec all_assessments_with_max_xp :: Ecto.Query.t() + def all_assessments_with_max_xp do Assessment - |> join(:inner, [a], q in subquery(assessments_max_grade()), on: a.id == q.assessment_id) - |> select([a, q], %Assessment{a | max_grade: q.max_grade}) + |> join(:inner, [a], q in subquery(assessments_max_xp()), on: a.id == q.assessment_id) + |> select([a, q], %Assessment{a | max_xp: q.max_xp}) end - @spec assessments_max_grade :: Ecto.Query.t() - def assessments_max_grade do + @spec assessments_max_xp :: Ecto.Query.t() + def assessments_max_xp do Question |> group_by(:assessment_id) - |> select([q], %{assessment_id: q.assessment_id, max_grade: sum(q.max_grade)}) + |> select([q], %{assessment_id: q.assessment_id, max_xp: sum(q.max_xp)}) end @spec assessments_aggregates :: Ecto.Query.t() @@ -46,7 +48,6 @@ defmodule Cadet.Assessments.Query do |> group_by(:assessment_id) |> select([q], %{ assessment_id: q.assessment_id, - max_grade: sum(q.max_grade), max_xp: sum(q.max_xp), question_count: count(q.id) }) diff --git a/lib/cadet/assessments/question.ex b/lib/cadet/assessments/question.ex index e1aadf3e5..7ab6e45c5 100644 --- a/lib/cadet/assessments/question.ex +++ b/lib/cadet/assessments/question.ex @@ -12,8 +12,9 @@ defmodule Cadet.Assessments.Question do field(:display_order, :integer) field(:question, :map) field(:type, QuestionType) - field(:max_grade, :integer) field(:max_xp, :integer) + field(:show_solution, :boolean, default: false) + field(:blocking, :boolean, default: false) field(:answer, :map, virtual: true) embeds_one(:library, Library, on_replace: :update) embeds_one(:grading_library, Library, on_replace: :update) @@ -22,7 +23,7 @@ defmodule Cadet.Assessments.Question do end @required_fields ~w(question type assessment_id)a - @optional_fields ~w(display_order max_grade max_xp)a + @optional_fields ~w(display_order max_xp show_solution blocking)a @required_embeds ~w(library)a def changeset(question, params) do diff --git a/lib/cadet/assessments/question_types/programming_question.ex b/lib/cadet/assessments/question_types/programming_question.ex index 168950d33..a39625de3 100644 --- a/lib/cadet/assessments/question_types/programming_question.ex +++ b/lib/cadet/assessments/question_types/programming_question.ex @@ -14,7 +14,8 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do field(:postpend, :string, default: "") field(:solution, :string) embeds_many(:public, Testcase) - embeds_many(:private, Testcase) + embeds_many(:opaque, Testcase) + embeds_many(:secret, Testcase) end @required_fields ~w(content template)a @@ -24,7 +25,8 @@ defmodule Cadet.Assessments.QuestionTypes.ProgrammingQuestion do question |> cast(params, @required_fields ++ @optional_fields) |> cast_embed(:public, with: &Testcase.changeset/2) - |> cast_embed(:private, with: &Testcase.changeset/2) + |> cast_embed(:opaque, with: &Testcase.changeset/2) + |> cast_embed(:secret, with: &Testcase.changeset/2) |> validate_required(@required_fields) end end diff --git a/lib/cadet/assessments/question_types/programming_question_testcases.ex b/lib/cadet/assessments/question_types/programming_question_testcases.ex index 6aebafb23..338abe854 100644 --- a/lib/cadet/assessments/question_types/programming_question_testcases.ex +++ b/lib/cadet/assessments/question_types/programming_question_testcases.ex @@ -1,6 +1,6 @@ defmodule Cadet.Assessments.QuestionTypes.Testcase do @moduledoc """ - The Assessments.QuestionTypes.Testcase entity represents a public/private testcase. + The Assessments.QuestionTypes.Testcase entity represents a public/opaque/secret testcase. """ use Cadet, :model diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index 299ffb58e..5d789f334 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -2,12 +2,10 @@ defmodule Cadet.Assessments.Submission do @moduledoc false use Cadet, :model - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.{Answer, Assessment, SubmissionStatus} schema "submissions" do - field(:grade, :integer, virtual: true) - field(:adjustment, :integer, virtual: true) field(:xp, :integer, virtual: true) field(:xp_adjustment, :integer, virtual: true) field(:xp_bonus, :integer, default: 0) @@ -19,8 +17,8 @@ defmodule Cadet.Assessments.Submission do field(:unsubmitted_at, :utc_datetime_usec) belongs_to(:assessment, Assessment) - belongs_to(:student, User) - belongs_to(:unsubmitted_by, User) + belongs_to(:student, CourseRegistration) + belongs_to(:unsubmitted_by, CourseRegistration) has_many(:answers, Answer) timestamps() @@ -28,15 +26,13 @@ defmodule Cadet.Assessments.Submission do @required_fields ~w(student_id assessment_id status)a @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at)a - @xp_early_submission_max_bonus 100 def changeset(submission, params) do submission |> cast(params, @required_fields ++ @optional_fields) |> validate_number( :xp_bonus, - greater_than_or_equal_to: 0, - less_than_or_equal_to: @xp_early_submission_max_bonus + greater_than_or_equal_to: 0 ) |> add_belongs_to_id_from_model([:student, :assessment, :unsubmitted_by], params) |> validate_required(@required_fields) diff --git a/lib/cadet/assessments/submission_votes.ex b/lib/cadet/assessments/submission_votes.ex index e476e9dc9..1a010264c 100644 --- a/lib/cadet/assessments/submission_votes.ex +++ b/lib/cadet/assessments/submission_votes.ex @@ -2,27 +2,27 @@ defmodule Cadet.Assessments.SubmissionVotes do @moduledoc false use Cadet, :model - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.{Question, Submission} schema "submission_votes" do field(:rank, :integer) - belongs_to(:user, User) + belongs_to(:voter, CourseRegistration) belongs_to(:submission, Submission) belongs_to(:question, Question) timestamps() end - @required_fields ~w(user_id submission_id question_id)a + @required_fields ~w(voter_id submission_id question_id)a @optional_fields ~w(rank)a def changeset(submission_vote, params) do submission_vote |> cast(params, @required_fields ++ @optional_fields) - |> add_belongs_to_id_from_model([:user, :submission, :question], params) + |> add_belongs_to_id_from_model([:voter, :submission, :question], params) |> validate_required(@required_fields) - |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:voter_id) |> foreign_key_constraint(:submission_id) |> foreign_key_constraint(:question_id) |> unique_constraint(:vote_not_unique, name: :unique_score) diff --git a/lib/cadet/auth/provider.ex b/lib/cadet/auth/provider.ex index 3901c3c4c..3f916e5b5 100644 --- a/lib/cadet/auth/provider.ex +++ b/lib/cadet/auth/provider.ex @@ -13,6 +13,8 @@ defmodule Cadet.Auth.Provider do @type redirect_uri :: String.t() @type error :: :upstream | :invalid_credentials | :other @type provider_instance :: String.t() + @type username :: String.t() + @type prefix :: String.t() @doc "Exchanges the OAuth2 authorisation code for a token and the user ID." @callback authorise(any(), code, client_id, redirect_uri) :: @@ -53,4 +55,9 @@ defmodule Cadet.Auth.Provider do _ -> {:error, :other, "Invalid or nonexistent provider config"} end end + + @spec namespace(username, prefix) :: String.t() + def namespace(username, prefix) do + prefix <> "/" <> username + end end diff --git a/lib/cadet/auth/providers/config.ex b/lib/cadet/auth/providers/config.ex index 1d64d1140..d322f8dc0 100644 --- a/lib/cadet/auth/providers/config.ex +++ b/lib/cadet/auth/providers/config.ex @@ -20,8 +20,11 @@ defmodule Cadet.Auth.Providers.Config do | {:error, Provider.error(), String.t()} def authorise(config, code, _client_id, _redirect_uri) do case Enum.find(config, nil, fn %{code: this_code} -> code == this_code end) do - %{token: token, username: username} -> {:ok, %{token: token, username: username}} - _ -> {:error, :invalid_credentials, "Invalid code"} + %{token: token, username: username} -> + {:ok, %{token: token, username: Provider.namespace(username, "test")}} + + _ -> + {:error, :invalid_credentials, "Invalid code"} end end diff --git a/lib/cadet/auth/providers/github.ex b/lib/cadet/auth/providers/github.ex new file mode 100644 index 000000000..1a1fbb341 --- /dev/null +++ b/lib/cadet/auth/providers/github.ex @@ -0,0 +1,87 @@ +defmodule Cadet.Auth.Providers.GitHub do + @moduledoc """ + Provides identity using GitHub OAuth. + """ + alias Cadet.Auth.Provider + + @behaviour Provider + + @type config :: %{ + clients: %{}, + token_url: String.t(), + user_api: String.t() + } + + @spec authorise(config(), Provider.code(), Provider.client_id(), Provider.redirect_uri()) :: + {:ok, %{token: Provider.token(), username: String.t()}} + | {:error, Provider.error(), String.t()} + def authorise(config, code, client_id, redirect_uri) do + token_headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Accept", "application/json"} + ] + + token_url = config.token_url + user_api = config.user_api + + with {:validate_client, {:ok, client_secret}} <- + {:validate_client, Map.fetch(config.clients, client_id)}, + {:token_query, token_query} <- + {:token_query, + URI.encode_query(%{ + client_id: client_id, + client_secret: client_secret, + code: code, + redirect_uri: redirect_uri + })}, + {:token, {:ok, %{body: body, status_code: 200}}} <- + {:token, HTTPoison.post(token_url, token_query, token_headers)}, + {:token_response, %{"access_token" => token}} <- {:token_response, Jason.decode!(body)}, + {:user, {:ok, %{"login" => username}}} <- {:user, api_call(user_api, token)} do + {:ok, %{token: token, username: Provider.namespace(username, "github")}} + else + {:validate_client, :error} -> + {:error, :invalid_credentials, "Invalid client id"} + + {:token, {:ok, %{status_code: status}}} -> + {:error, :upstream, "Status code #{status} from GitHub"} + + {:token_response, %{"error" => error}} -> + {:error, :invalid_credentials, "Error from GitHub: #{error}"} + + {:user, {:error, _, _} = error} -> + error + end + end + + @spec get_name(config(), Provider.token()) :: + {:ok, String.t()} | {:error, Provider.error(), String.t()} + def get_name(config, token) do + user_api = config.user_api + + case api_call(user_api, token) do + {:ok, %{"name" => name}} -> + {:ok, name} + + {:error, _, _} = error -> + error + end + end + + def get_role(_config, _claims) do + # There is no role specified for the GitHub provider + {:error, :invalid_credentials, "No role specified in token"} + end + + defp api_call(url, token) do + headers = [{"Authorization", "token " <> token}] + + case HTTPoison.get(url, headers) do + {:ok, %{body: body, status_code: 200}} -> + {:ok, Jason.decode!(body)} + + {:ok, %{status_code: status}} -> + {:error, :upstream, "Status code #{status} from GitHub"} + end + end +end diff --git a/lib/cadet/auth/providers/google_claim_extractor.ex b/lib/cadet/auth/providers/google_claim_extractor.ex index 25358b4e6..ab0d41a7a 100644 --- a/lib/cadet/auth/providers/google_claim_extractor.ex +++ b/lib/cadet/auth/providers/google_claim_extractor.ex @@ -13,7 +13,9 @@ defmodule Cadet.Auth.Providers.GoogleClaimExtractor do end end - def get_name(_claims), do: nil + def get_name(claims) do + claims["name"] + end def get_role(_claims), do: nil diff --git a/lib/cadet/auth/providers/luminus.ex b/lib/cadet/auth/providers/luminus.ex index 627f4a1a4..7caa73d89 100644 --- a/lib/cadet/auth/providers/luminus.ex +++ b/lib/cadet/auth/providers/luminus.ex @@ -36,7 +36,7 @@ defmodule Cadet.Auth.Providers.LumiNUS do {:verify_jwt, {:ok, _}} <- {:verify_jwt, Guardian.Token.Jwt.Verify.verify_claims(Cadet.Auth.EmptyGuardian, claims, nil)} do - {:ok, %{token: token, username: username}} + {:ok, %{token: token, username: Provider.namespace(username, "luminus")}} else {:token, {:ok, %{body: body, status_code: status}}} -> {:error, :upstream, "Status code #{status} from LumiNUS: #{body}"} diff --git a/lib/cadet/auth/providers/openid.ex b/lib/cadet/auth/providers/openid.ex index 77a4f9410..1d16c6088 100644 --- a/lib/cadet/auth/providers/openid.ex +++ b/lib/cadet/auth/providers/openid.ex @@ -30,8 +30,15 @@ defmodule Cadet.Auth.Providers.OpenID do nil )} do case claim_extractor.get_username(claims) do - nil -> {:error, :invalid_credentials, "No username specified in token"} - username -> {:ok, %{token: token, username: username}} + nil -> + {:error, :invalid_credentials, "No username specified in token"} + + username -> + {:ok, + %{ + token: token, + username: Provider.namespace(username, Atom.to_string(openid_provider)) + }} end else {:token, {:error, _, _}} -> diff --git a/lib/cadet/courses/assessment_config.ex b/lib/cadet/courses/assessment_config.ex index 42527b176..69326c51b 100644 --- a/lib/cadet/courses/assessment_config.ex +++ b/lib/cadet/courses/assessment_config.ex @@ -1,28 +1,38 @@ defmodule Cadet.Courses.AssessmentConfig do @moduledoc """ - The AssessmentConfig entity stores the assessment configuration - of a particular course. + The AssessmentConfig entity stores the assessment types in a + particular course. """ use Cadet, :model alias Cadet.Courses.Course schema "assessment_configs" do - field(:early_submission_xp, :integer, default: 200) - field(:days_before_early_xp_decay, :integer, default: 2) - field(:decay_rate_points_per_hour, :integer, default: 1) + field(:order, :integer) + field(:type, :string) + field(:show_grading_summary, :boolean, default: true) + field(:is_manually_graded, :boolean, default: true) + # used by fronend to determine display styles + field(:early_submission_xp, :integer, default: 0) + field(:hours_before_early_xp_decay, :integer, default: 0) + belongs_to(:course, Course) timestamps() end - @required_fields ~w(course)a - @optional_fields ~w(early_submission_xp days_before_early_xp_decay - decay_rate_points_per_hour)a + @required_fields ~w(course_id)a + @optional_fields ~w(order type early_submission_xp + hours_before_early_xp_decay show_grading_summary is_manually_graded)a def changeset(assessment_config, params) do assessment_config |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> validate_number(:order, greater_than: 0) + |> validate_number(:order, less_than_or_equal_to: 8) + |> validate_number(:early_submission_xp, greater_than_or_equal_to: 0) + |> validate_number(:hours_before_early_xp_decay, greater_than_or_equal_to: 0) + |> unique_constraint([:order, :course_id]) end end diff --git a/lib/cadet/courses/assessment_types.ex b/lib/cadet/courses/assessment_types.ex deleted file mode 100644 index 42dd43d09..000000000 --- a/lib/cadet/courses/assessment_types.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Cadet.Courses.AssessmentTypes do - @moduledoc """ - The AssessmentType entity stores the assessment tyoes in a - particular course. - """ - use Cadet, :model - - alias Cadet.Courses.Course - - schema "assessment_types" do - field(:order, :integer) - field(:type, :string) - belongs_to(:course, Course) - - timestamps() - end - - @required_fields ~w(order type course)a - - def changeset(assessment_type, params) do - assessment_type - |> cast(params, @required_fields) - |> validate_required(@required_fields) - end -end diff --git a/lib/cadet/courses/course.ex b/lib/cadet/courses/course.ex index ec7f45e55..e87a62d78 100644 --- a/lib/cadet/courses/course.ex +++ b/lib/cadet/courses/course.ex @@ -4,9 +4,11 @@ defmodule Cadet.Courses.Course do """ use Cadet, :model + alias Cadet.Courses.AssessmentConfig + schema "courses" do - field(:name, :string) - field(:module_code, :string) + field(:course_name, :string) + field(:course_short_name, :string) field(:viewable, :boolean, default: true) field(:enable_game, :boolean, default: true) field(:enable_achievements, :boolean, default: true) @@ -15,27 +17,56 @@ defmodule Cadet.Courses.Course do field(:source_variant, :string) field(:module_help_text, :string) + has_many(:assessment_config, AssessmentConfig) + timestamps() end - @optional_fields ~w(name source_chapter source_variant module_code viewable enable_game - enable_achievements enable_sourcecast module_help_text)a + @required_fields ~w(course_name viewable enable_game + enable_achievements enable_sourcecast source_chapter source_variant)a + @optional_fields ~w(course_short_name module_help_text)a def changeset(course, params) do course - |> cast(params, @optional_fields) + |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) - |> validate_allowed_combination() + |> validate_sublanguage_combination(params) end # Validates combination of Source chapter and variant - defp validate_allowed_combination(changeset) do - case get_field(changeset, :source_chapter) do - 1 -> validate_inclusion(changeset, :variant, ["default", "lazy", "wasm"]) - 2 -> validate_inclusion(changeset, :variant, ["default", "lazy"]) - 3 -> validate_inclusion(changeset, :variant, ["default", "concurrent", "non-det"]) - 4 -> validate_inclusion(changeset, :variant, ["default", "gpu"]) - _ -> add_error(changeset, :chapter, "is invalid") + defp validate_sublanguage_combination(changeset, params) do + chap = Map.has_key?(params, :source_chapter) + var = Map.has_key?(params, :source_variant) + + # not (chap xor var) + case {chap, var} do + {true, true} -> + case get_field(changeset, :source_chapter) do + 1 -> + validate_inclusion(changeset, :source_variant, ["default", "lazy", "wasm"]) + + 2 -> + validate_inclusion(changeset, :source_variant, ["default", "lazy"]) + + 3 -> + validate_inclusion(changeset, :source_variant, ["default", "concurrent", "non-det"]) + + 4 -> + validate_inclusion(changeset, :source_variant, ["default", "gpu"]) + + _ -> + add_error(changeset, :source_chapter, "is invalid") + end + + {false, false} -> + changeset + + {_, _} -> + add_error( + changeset, + :source_chapter, + "source chapter and source variant must be present together" + ) end end end diff --git a/lib/cadet/courses/courses.ex b/lib/cadet/courses/courses.ex index 6a3d518f6..431cb4212 100644 --- a/lib/cadet/courses/courses.ex +++ b/lib/cadet/courses/courses.ex @@ -6,109 +6,362 @@ defmodule Cadet.Courses do use Cadet, [:context, :display] import Ecto.Query + alias Ecto.Multi - alias Cadet.Accounts.User - alias Cadet.Courses.{Group, Sourcecast, SourcecastUpload} + alias Cadet.Accounts.{CourseRegistration, User, CourseRegistrations} + + alias Cadet.Courses.{ + AssessmentConfig, + Course, + Group, + Sourcecast, + SourcecastUpload + } + + alias Cadet.Assessments + alias Cadet.Assessments.Assessment + + @doc """ + Creates a new course configuration, course registration, and sets + the user's latest course id to the newly created course. + """ + def create_course_config(params, user) do + Multi.new() + |> Multi.insert(:course, Course.changeset(%Course{}, params)) + |> Multi.run(:course_reg, fn _repo, %{course: course} -> + CourseRegistrations.enroll_course(%{ + course_id: course.id, + user_id: user.id, + role: :admin + }) + end) + |> Repo.transaction() + end + + @doc """ + Returns the course configuration for the specified course. + """ + @spec get_course_config(integer) :: + {:ok, %Course{}} | {:error, {:bad_request, String.t()}} + def get_course_config(course_id) when is_ecto_id(course_id) do + case retrieve_course(course_id) do + nil -> + {:error, {:bad_request, "Invalid course id"}} + + course -> + assessment_configs = + AssessmentConfig + |> where(course_id: ^course_id) + |> Repo.all() + |> Enum.sort(&(&1.order < &2.order)) + |> Enum.map(& &1.type) + + {:ok, Map.put_new(course, :assessment_configs, assessment_configs)} + end + end @doc """ - Get a group based on the group name or create one if it doesn't exist + Updates the general course configuration for the specified course """ - @spec get_or_create_group(String.t()) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t()} - def get_or_create_group(name) when is_binary(name) do + @spec update_course_config(integer, %{}) :: + {:ok, %Course{}} | {:error, Ecto.Changeset.t()} | {:error, {:bad_request, String.t()}} + def update_course_config(course_id, params) when is_ecto_id(course_id) do + case retrieve_course(course_id) do + nil -> + {:error, {:bad_request, "Invalid course id"}} + + course -> + if Map.has_key?(params, :viewable) and not params.viewable do + remove_latest_viewed_course_id(course_id) + end + + course + |> Course.changeset(params) + |> Repo.update() + end + end + + defp retrieve_course(course_id) when is_ecto_id(course_id) do + Course + |> where(id: ^course_id) + |> Repo.one() + end + + defp remove_latest_viewed_course_id(course_id) do + User + |> where(latest_viewed_course_id: ^course_id) + |> Repo.all() + |> Enum.each(fn user -> + user + |> User.changeset(%{latest_viewed_course_id: nil}) + |> Repo.update() + end) + end + + def get_assessment_configs(course_id) when is_ecto_id(course_id) do + AssessmentConfig + |> where([at], at.course_id == ^course_id) + |> order_by(:order) + |> Repo.all() + end + + def mass_upsert_and_reorder_assessment_configs(course_id, configs) do + if is_list(configs) do + configs_length = configs |> length() + + with true <- configs_length <= 8, + true <- configs_length >= 1 do + new_configs = + configs + |> Enum.map(fn elem -> + {:ok, config} = insert_or_update_assessment_config(course_id, elem) + Map.put(elem, :assessment_config_id, config.id) + end) + + reorder_assessment_configs(course_id, new_configs) + else + false -> {:error, {:bad_request, "Invalid parameter(s)"}} + end + else + {:error, {:bad_request, "Invalid parameter(s)"}} + end + end + + def insert_or_update_assessment_config( + course_id, + params = %{assessment_config_id: assessment_config_id} + ) do + AssessmentConfig + |> where(course_id: ^course_id) + |> where(id: ^assessment_config_id) + |> Repo.one() + |> case do + nil -> + AssessmentConfig.changeset(%AssessmentConfig{}, Map.put(params, :course_id, course_id)) + + at -> + AssessmentConfig.changeset(at, params) + end + |> Repo.insert_or_update() + end + + defp update_assessment_config( + course_id, + params = %{assessment_config_id: assessment_config_id} + ) do + AssessmentConfig + |> where(course_id: ^course_id) + |> where(id: ^assessment_config_id) + |> Repo.one() + |> case do + nil -> {:error, :no_such_entry} + at -> at |> AssessmentConfig.changeset(params) |> Repo.update() + end + end + + def reorder_assessment_configs(course_id, configs) do + Repo.transaction(fn -> + configs + |> Enum.each(fn elem -> + update_assessment_config(course_id, Map.put(elem, :order, nil)) + end) + + configs + |> Enum.with_index(1) + |> Enum.each(fn {elem, idx} -> + update_assessment_config(course_id, Map.put(elem, :order, idx)) + end) + end) + end + + @spec delete_assessment_config(integer(), integer()) :: + {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} | {:error, :no_such_enrty} + def delete_assessment_config(course_id, assessment_config_id) do + config = + AssessmentConfig + |> where(course_id: ^course_id) + |> where(id: ^assessment_config_id) + |> Repo.one() + + case config do + nil -> + {:error, :no_such_enrty} + + config -> + Assessment + |> where(config_id: ^config.id) + |> Repo.all() + |> Enum.each(fn assessment -> Assessments.delete_assessment(assessment.id) end) + + Repo.delete(config) + end + end + + def upsert_groups_in_course(usernames_and_groups, course_id) do + usernames_and_groups + |> Enum.reduce_while(nil, fn %{username: username} = entry, _acc -> + entry + |> Map.fetch(:group) + |> case do + {:ok, groupname} -> + # Add users to group + upsert_groups_in_course_helper(username, course_id, groupname) + + :error -> + # Delete users from group + upsert_groups_in_course_helper(username, course_id) + end + |> case do + {:ok, _} -> {:cont, :ok} + {:error, changeset} -> {:halt, {:error, {:bad_request, full_error_messages(changeset)}}} + end + end) + end + + defp upsert_groups_in_course_helper(username, course_id, groupname) do + with {:get_group, {:ok, group}} <- {:get_group, get_or_create_group(groupname, course_id)}, + {:get_course_reg, %{role: role} = course_reg} <- + {:get_course_reg, + CourseRegistration + |> where( + user_id: ^(User |> where(username: ^username) |> Repo.one() |> Map.fetch!(:id)) + ) + |> 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 + case role do + # If student, update his course registration + :student -> + course_reg + |> CourseRegistration.changeset(%{group_id: group.id}) + |> Repo.update() + + # If admin or staff, remove their previous group assignment and set them as group leader + _ -> + remove_staff_from_group(course_id, course_reg.id) + + group + |> Group.changeset(%{leader_id: course_reg.id}) + |> Repo.update() + end + end + end + + defp upsert_groups_in_course_helper(username, course_id) do + with {:get_course_reg, %{role: role} = course_reg} <- + {:get_course_reg, + CourseRegistration + |> where( + user_id: ^(User |> where(username: ^username) |> Repo.one() |> Map.fetch!(:id)) + ) + |> where(course_id: ^course_id) + |> Repo.one()} do + case role do + :student -> + course_reg + |> CourseRegistration.changeset(%{group_id: nil}) + |> Repo.update() + + _ -> + remove_staff_from_group(course_id, course_reg.id) + {:ok, nil} + end + end + end + + defp remove_staff_from_group(course_id, leader_id) do Group - |> where(name: ^name) + |> where(course_id: ^course_id) + |> where(leader_id: ^leader_id) |> Repo.one() |> case do nil -> - %Group{} - |> Group.changeset(%{name: name}) - |> Repo.insert() + nil group -> - {:ok, group} + group + |> Group.changeset(%{leader_id: nil}) + |> Repo.update() end end @doc """ - Updates a group based on the group name or create one if it doesn't exist + Get a group based on the group name and course id or create one if it doesn't exist """ - @spec insert_or_update_group(map()) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t()} - def insert_or_update_group(params = %{name: name}) when is_binary(name) do + @spec get_or_create_group(String.t(), integer()) :: + {:ok, %Group{}} | {:error, Ecto.Changeset.t()} + def get_or_create_group(name, course_id) when is_binary(name) and is_ecto_id(course_id) do Group |> where(name: ^name) + |> where(course_id: ^course_id) |> Repo.one() |> case do nil -> - Group.changeset(%Group{}, params) + %Group{} + |> Group.changeset(%{name: name, course_id: course_id}) + |> Repo.insert() group -> - Group.changeset(group, params) + {:ok, group} end - |> Repo.insert_or_update() end - # @doc """ - # Reassign a student to a discussion group - # This will un-assign student from the current discussion group - # """ - # def assign_group(leader = %User{}, student = %User{}) do - # cond do - # leader.role == :student -> - # {:error, :invalid} - - # student.role != :student -> - # {:error, :invalid} - - # true -> - # Repo.transaction(fn -> - # {:ok, _} = unassign_group(student) - - # %Group{} - # |> Group.changeset(%{}) - # |> put_assoc(:leader, leader) - # |> put_assoc(:student, student) - # |> Repo.insert!() - # end) - # end - # end - - # @doc """ - # Remove existing student from discussion group, no-op if a student - # is unassigned - # """ - # def unassign_group(student = %User{}) do - # existing_group = Repo.get_by(Group, student_id: student.id) - - # if existing_group == nil do - # {:ok, nil} - # else - # Repo.delete(existing_group) - # end - # end - - # @doc """ - # Get list of students under staff discussion group - # """ - # def list_students_by_leader(staff = %User{}) do - # import Cadet.Course.Query, only: [group_members: 1] - - # staff - # |> group_members() - # |> Repo.all() - # |> Repo.preload([:student]) - # end - @upload_file_roles ~w(admin staff)a @doc """ - Upload a sourcecast file + Upload a sourcecast file. + + Note that there are no checks for whether the user belongs to the course, as this has been checked + inside a plug in the router. """ - def upload_sourcecast_file(uploader = %User{role: role}, attrs = %{}) do + def upload_sourcecast_file( + _inserter = %CourseRegistration{user_id: user_id, course_id: course_id, role: role}, + attrs = %{} + ) do if role in @upload_file_roles do + course_reg = + CourseRegistration + |> where(user_id: ^user_id) + |> where(course_id: ^course_id) + |> preload(:course) + |> preload(:user) + |> Repo.one() + changeset = %Sourcecast{} |> Sourcecast.changeset(attrs) - |> put_assoc(:uploader, uploader) + |> put_assoc(:uploader, course_reg.user) + |> put_assoc(:course, course_reg.course) + + case Repo.insert(changeset) do + {:ok, sourcecast} -> + {:ok, sourcecast} + + {:error, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + end + else + {:error, {:forbidden, "User is not permitted to upload"}} + end + end + + @doc """ + Upload a public sourcecast file. + + Note that there are no checks for whether the user belongs to the course, as this has been checked + inside a plug in the router. + """ + def upload_sourcecast_file_public( + inserter, + _inserter_course_reg = %CourseRegistration{role: role}, + attrs = %{} + ) do + if role in @upload_file_roles do + changeset = + %Sourcecast{} + |> Sourcecast.changeset(attrs) + |> put_assoc(:uploader, inserter) case Repo.insert(changeset) do {:ok, sourcecast} -> @@ -124,14 +377,35 @@ defmodule Cadet.Courses do @doc """ Delete a sourcecast file + + Note that there are no checks for whether the user belongs to the course, as this has been checked + inside a plug in the router. """ - def delete_sourcecast_file(_deleter = %User{role: role}, id) do + def delete_sourcecast_file(_deleter = %CourseRegistration{role: role}, sourcecast_id) do if role in @upload_file_roles do - sourcecast = Repo.get(Sourcecast, id) + sourcecast = Repo.get(Sourcecast, sourcecast_id) SourcecastUpload.delete({sourcecast.audio, sourcecast}) Repo.delete(sourcecast) else {:error, {:forbidden, "User is not permitted to delete"}} end end + + @doc """ + Get sourcecast files + """ + def get_sourcecast_files(course_id) when is_ecto_id(course_id) do + Sourcecast + |> where(course_id: ^course_id) + |> Repo.all() + |> Repo.preload(:uploader) + end + + def get_sourcecast_files do + Sourcecast + # Public sourcecasts are those without course_id + |> where([s], is_nil(s.course_id)) + |> Repo.all() + |> Repo.preload(:uploader) + end end diff --git a/lib/cadet/courses/group.ex b/lib/cadet/courses/group.ex index 5f9a75a8e..838de20cf 100644 --- a/lib/cadet/courses/group.ex +++ b/lib/cadet/courses/group.ex @@ -5,20 +5,49 @@ defmodule Cadet.Courses.Group do """ use Cadet, :model - alias Cadet.Accounts.User + alias Cadet.Repo + alias Cadet.Accounts.CourseRegistration + alias Cadet.Courses.Course schema "groups" do field(:name, :string) - belongs_to(:leader, User) - belongs_to(:mentor, User) - has_many(:students, User) + belongs_to(:leader, CourseRegistration) + belongs_to(:course, Course) + + has_many(:students, CourseRegistration) end - @optional_fields ~w(name leader_id mentor_id)a + @required_fields ~w(name course_id)a + @optional_fields ~w(leader_id)a def changeset(group, attrs \\ %{}) do group - |> cast(attrs, @optional_fields) - |> add_belongs_to_id_from_model([:leader, :mentor], attrs) + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> add_belongs_to_id_from_model([:leader, :course], attrs) + |> validate_role + |> validate_course + end + + defp validate_role(changeset) do + leader_id = get_field(changeset, :leader_id) + + if leader_id != nil && + !Enum.member?([:staff, :admin], Repo.get(CourseRegistration, leader_id).role) do + add_error(changeset, :leader, "is not a staff") + else + changeset + end + end + + defp validate_course(changeset) do + course_id = get_field(changeset, :course_id) + leader_id = get_field(changeset, :leader_id) + + if leader_id != nil && Repo.get(CourseRegistration, leader_id).course_id != course_id do + add_error(changeset, :leader, "does not belong to the same course ") + else + changeset + end end end diff --git a/lib/cadet/courses/sourcecast.ex b/lib/cadet/courses/sourcecast.ex index b8b01a529..330663ab8 100644 --- a/lib/cadet/courses/sourcecast.ex +++ b/lib/cadet/courses/sourcecast.ex @@ -6,7 +6,7 @@ defmodule Cadet.Courses.Sourcecast do use Arc.Ecto.Schema alias Cadet.Accounts.User - alias Cadet.Courses.SourcecastUpload + alias Cadet.Courses.{Course, SourcecastUpload} schema "sourcecasts" do field(:title, :string) @@ -16,12 +16,13 @@ defmodule Cadet.Courses.Sourcecast do field(:audio, SourcecastUpload.Type) belongs_to(:uploader, User) + belongs_to(:course, Course) timestamps() end @required_fields ~w(title playbackData uid)a - @optional_fields ~w(description)a + @optional_fields ~w(description course_id)a @required_file_fields ~w(audio)a @regex Regex.compile!("^[a-zA-Z0-9_-]*$") @@ -46,5 +47,6 @@ defmodule Cadet.Courses.Sourcecast do |> validate_required(@required_fields ++ @required_file_fields) |> validate_format(:uid, @regex) |> foreign_key_constraint(:uploader_id) + |> foreign_key_constraint(:course_id) end end diff --git a/lib/cadet/helpers/model_helper.ex b/lib/cadet/helpers/model_helper.ex index 9d982ef19..b41fc8a35 100644 --- a/lib/cadet/helpers/model_helper.ex +++ b/lib/cadet/helpers/model_helper.ex @@ -54,26 +54,6 @@ defmodule Cadet.ModelHelper do end) end - @doc """ - Given a changeset for a model that has some `belongs_to` associations, this function will attach only one id to the changeset if the models are provided in the parameters. - - example: - ``` - defmodule MyTest do - schema "my_test" do - belongs_to(:bossman, User) - end - - def changeset(my_test, params) do - # params = %{bossman: %User{}} - - my_test - |> cast(params, []) - |> add_belongs_to_id_from_model(:bossman, params) - end - end - ``` - """ def add_belongs_to_id_from_model(changeset, assoc, params) when is_atom(assoc) do assoc_id_field = String.to_atom("#{assoc}_id") @@ -152,4 +132,15 @@ defmodule Cadet.ModelHelper do |> cast_assoc(assoc_field) end end + + def remove_preload(struct, field, cardinality \\ :one) do + %{ + struct + | field => %Ecto.Association.NotLoaded{ + __field__: field, + __owner__: struct.__struct__, + __cardinality__: cardinality + } + } + end end diff --git a/lib/cadet/helpers/shared_helper.ex b/lib/cadet/helpers/shared_helper.ex index b1c7e622a..0c20d7fcf 100644 --- a/lib/cadet/helpers/shared_helper.ex +++ b/lib/cadet/helpers/shared_helper.ex @@ -32,6 +32,12 @@ defmodule Cadet.SharedHelper do do: {if(is_binary(key), do: Recase.to_snake(key), else: key), val} end + def to_snake_case_atom_keys(map = %{}) do + map + |> snake_casify_string_keys() + |> (&for({key, val} <- &1, into: %{}, do: {String.to_atom(key), val})).() + end + @doc """ Snake-casifies string keys, recursively. diff --git a/lib/cadet/incentives/achievement.ex b/lib/cadet/incentives/achievement.ex index 886fb1480..d6ddaa057 100644 --- a/lib/cadet/incentives/achievement.ex +++ b/lib/cadet/incentives/achievement.ex @@ -4,6 +4,7 @@ defmodule Cadet.Incentives.Achievement do """ use Cadet, :model + alias Cadet.Courses.Course alias Cadet.Incentives.{AchievementPrerequisite, AchievementToGoal} @valid_abilities ~w(Core Community Effort Exploration Flex) @@ -25,6 +26,7 @@ defmodule Cadet.Incentives.Achievement do field(:description, :string) field(:completion_text, :string) + belongs_to(:course, Course) has_many(:prerequisites, AchievementPrerequisite, on_replace: :delete) has_many(:goals, AchievementToGoal, on_replace: :delete_if_exists) @@ -32,7 +34,7 @@ defmodule Cadet.Incentives.Achievement do field(:goal_uuids, {:array, :binary_id}, virtual: true) end - @required_fields ~w(uuid title ability is_task position xp is_variable_xp)a + @required_fields ~w(uuid title ability is_task position xp is_variable_xp course_id)a @optional_fields ~w(card_tile_url open_at close_at canvas_url description completion_text prerequisite_uuids goal_uuids)a @@ -46,6 +48,7 @@ defmodule Cadet.Incentives.Achievement do |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) |> validate_inclusion(:ability, @valid_abilities) + |> foreign_key_constraint(:course_id) |> cast_join_ids( :prerequisite_uuids, :prerequisites, diff --git a/lib/cadet/incentives/achievements.ex b/lib/cadet/incentives/achievements.ex index 80c5729d7..5fa93c0a4 100644 --- a/lib/cadet/incentives/achievements.ex +++ b/lib/cadet/incentives/achievements.ex @@ -13,9 +13,10 @@ defmodule Cadet.Incentives.Achievements do This returns Achievement structs with prerequisites and goal association maps pre-loaded. """ - @spec get() :: [%Achievement{}] - def get do + @spec get(integer()) :: [%Achievement{}] + def get(course_id) when is_ecto_id(course_id) do Achievement + |> where(course_id: ^course_id) |> preload([:prerequisites, :goals]) |> Repo.all() end @@ -25,6 +26,7 @@ defmodule Cadet.Incentives.Achievements do Inserts a new achievement, or updates it if it already exists. """ def upsert(attrs) when is_map(attrs) do + # course_id not nil check is left to the changeset case attrs[:uuid] || attrs["uuid"] do nil -> {:error, {:bad_request, "No UUID specified in Achievement"}} diff --git a/lib/cadet/incentives/goal.ex b/lib/cadet/incentives/goal.ex index 26b21fe75..7a30664e9 100644 --- a/lib/cadet/incentives/goal.ex +++ b/lib/cadet/incentives/goal.ex @@ -4,6 +4,7 @@ defmodule Cadet.Incentives.Goal do """ use Cadet, :model + alias Cadet.Courses.Course alias Cadet.Incentives.{AchievementToGoal, GoalProgress} @primary_key {:uuid, :binary_id, autogenerate: false} @@ -14,15 +15,17 @@ defmodule Cadet.Incentives.Goal do field(:type, :string) field(:meta, :map) + belongs_to(:course, Course) has_many(:progress, GoalProgress, foreign_key: :goal_uuid) has_many(:achievements, AchievementToGoal, on_replace: :delete_if_exists) end - @required_fields ~w(uuid text target_count type meta)a + @required_fields ~w(uuid text target_count type meta course_id)a def changeset(goal, params) do goal |> cast(params, @required_fields) |> validate_required(@required_fields) + |> foreign_key_constraint(:course_id) end end diff --git a/lib/cadet/incentives/goal_progress.ex b/lib/cadet/incentives/goal_progress.ex index f7c552486..3824a4f41 100644 --- a/lib/cadet/incentives/goal_progress.ex +++ b/lib/cadet/incentives/goal_progress.ex @@ -5,14 +5,14 @@ defmodule Cadet.Incentives.GoalProgress do use Cadet, :model alias Cadet.Incentives.Goal - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration @primary_key false schema "goal_progress" do field(:count, :integer) field(:completed, :boolean) - belongs_to(:user, User, primary_key: true) + belongs_to(:course_reg, CourseRegistration, primary_key: true) belongs_to(:goal, Goal, primary_key: true, @@ -24,13 +24,13 @@ defmodule Cadet.Incentives.GoalProgress do timestamps() end - @required_fields ~w(count completed user_id goal_uuid)a + @required_fields ~w(count completed course_reg_id goal_uuid)a def changeset(progress, params) do progress |> cast(params, @required_fields) |> validate_required(@required_fields) - |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:course_reg_id) |> foreign_key_constraint(:goal_uuid) end end diff --git a/lib/cadet/incentives/goals.ex b/lib/cadet/incentives/goals.ex index 96566bb17..4055b3fb8 100644 --- a/lib/cadet/incentives/goals.ex +++ b/lib/cadet/incentives/goals.ex @@ -6,24 +6,26 @@ defmodule Cadet.Incentives.Goals do alias Cadet.Incentives.{Goal, GoalProgress} - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration import Ecto.Query @doc """ Returns all goals. """ - @spec get() :: [%Goal{}] - def get do - Repo.all(Goal) + @spec get(integer()) :: [%Goal{}] + def get(course_id) when is_ecto_id(course_id) do + Goal + |> where(course_id: ^course_id) + |> Repo.all() end @doc """ - Returns goals with user progress. + Returns goals with progress for each course_registration. """ - def get_with_progress(%User{id: user_id}) do + def get_with_progress(%CourseRegistration{id: course_reg_id}) do Goal - |> join(:left, [g], p in assoc(g, :progress), on: p.user_id == ^user_id) + |> join(:left, [g], p in assoc(g, :progress), on: p.course_reg_id == ^course_reg_id) |> preload([g, p], [:achievements, progress: p]) |> Repo.all() end @@ -79,21 +81,30 @@ defmodule Cadet.Incentives.Goals do end end - def upsert_progress(attrs, goal_uuid, user_id) do - if goal_uuid == nil or user_id == nil do + def upsert_progress(attrs, goal_uuid, course_reg_id) do + if goal_uuid == nil or course_reg_id == nil do {:error, {:bad_request, "No UUID specified in Goal"}} else - GoalProgress - |> Repo.get_by(goal_uuid: goal_uuid, user_id: user_id) - |> (&(&1 || %GoalProgress{})).() - |> GoalProgress.changeset(attrs) - |> Repo.insert_or_update() - |> case do - result = {:ok, _} -> - result + course_reg = Repo.get(CourseRegistration, course_reg_id) + goal = Repo.get_by(Goal, uuid: goal_uuid, course_id: course_reg.course_id) + + case goal do + nil -> + {:error, {:bad_request, "User and goal are not in the same course"}} + + _ -> + GoalProgress + |> Repo.get_by(goal_uuid: goal_uuid, course_reg_id: course_reg_id) + |> (&(&1 || %GoalProgress{})).() + |> GoalProgress.changeset(attrs) + |> Repo.insert_or_update() + |> case do + result = {:ok, _} -> + result - {:error, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} + {:error, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + end end end end diff --git a/lib/cadet/jobs/autograder/grading_job.ex b/lib/cadet/jobs/autograder/grading_job.ex index 62936b74a..2349ad7d1 100644 --- a/lib/cadet/jobs/autograder/grading_job.ex +++ b/lib/cadet/jobs/autograder/grading_job.ex @@ -139,19 +139,16 @@ defmodule Cadet.Autograder.GradingJob do Submission |> where(id: ^submission_id) |> join(:inner, [s], sv in SubmissionVotes, - on: sv.user_id == s.student_id and sv.question_id == ^question.id + on: sv.voter_id == s.student_id and sv.question_id == ^question.id ) |> where([_, sv], is_nil(sv.rank)) |> Repo.exists?() - grade = if is_nil_entries, do: 0, else: question.max_grade xp = if is_nil_entries, do: 0, else: question.max_xp answer |> Answer.autograding_changeset(%{ - adjustment: 0, xp_adjustment: 0, - grade: grade, xp: xp, autograding_status: :success }) @@ -165,14 +162,11 @@ defmodule Cadet.Autograder.GradingJob do |> Map.get("choice_id") correct? = answer.answer["choice_id"] == correct_choice - grade = if correct?, do: question.max_grade, else: 0 xp = if correct?, do: question.max_xp, else: 0 answer |> Answer.autograding_changeset(%{ - adjustment: 0, xp_adjustment: 0, - grade: grade, xp: xp, autograding_status: :success }) diff --git a/lib/cadet/jobs/autograder/lambda_worker.ex b/lib/cadet/jobs/autograder/lambda_worker.ex index a6c8563f2..3ef4a75fd 100644 --- a/lib/cadet/jobs/autograder/lambda_worker.ex +++ b/lib/cadet/jobs/autograder/lambda_worker.ex @@ -56,7 +56,8 @@ defmodule Cadet.Autograder.LambdaWorker do %{ answer_id: answer.id, result: %{ - grade: 0, + score: 0, + max_score: 1, status: :failed, result: [ %{ @@ -91,7 +92,8 @@ defmodule Cadet.Autograder.LambdaWorker do studentProgram: Map.get(answer.answer, "code"), postpendProgram: Map.get(question_content, "postpend", ""), testcases: - Map.get(question_content, "public", []) ++ Map.get(question_content, "private", []), + Map.get(question_content, "public", []) ++ + Map.get(question_content, "opaque", []) ++ Map.get(question_content, "secret", []), library: %{ chapter: question.grading_library.chapter, external: upcased_name_external, @@ -105,7 +107,8 @@ defmodule Cadet.Autograder.LambdaWorker do # %{"errorMessage" => "${message}"} if Map.has_key?(response, "errorMessage") do %{ - grade: 0, + score: 0, + max_score: 1, status: :failed, result: [ %{ @@ -117,7 +120,12 @@ defmodule Cadet.Autograder.LambdaWorker do ] } else - %{grade: response["totalScore"], result: response["results"], status: :success} + %{ + score: response["totalScore"], + max_score: response["maxScore"], + result: response["results"], + status: :success + } end end end diff --git a/lib/cadet/jobs/autograder/result_store_worker.ex b/lib/cadet/jobs/autograder/result_store_worker.ex index 6fa601234..0851de8de 100644 --- a/lib/cadet/jobs/autograder/result_store_worker.ex +++ b/lib/cadet/jobs/autograder/result_store_worker.ex @@ -56,7 +56,7 @@ defmodule Cadet.Autograder.ResultStoreWorker do defp update_answer(answer = %Answer{}, result = %{status: status}, overwrite) do xp = cond do - answer.question.max_grade == 0 and length(result.result) > 0 -> + result.max_score == 0 and length(result.result) > 0 -> testcase_results = result.result num_passed = @@ -64,23 +64,14 @@ defmodule Cadet.Autograder.ResultStoreWorker do Integer.floor_div(answer.question.max_xp * num_passed, length(testcase_results)) - answer.question.max_grade == 0 -> + result.max_score == 0 -> 0 true -> - Integer.floor_div(answer.question.max_xp * result.grade, answer.question.max_grade) - end - - new_adjustment = - if not overwrite and answer.grader_id do - answer.adjustment - result.grade - else - 0 + Integer.floor_div(answer.question.max_xp * result.score, result.max_score) end changes = %{ - adjustment: new_adjustment, - grade: result.grade, xp: xp, autograding_status: status, autograding_results: result.result diff --git a/lib/cadet/jobs/autograder/utilities.ex b/lib/cadet/jobs/autograder/utilities.ex index 4f4b40401..23f3ed80f 100644 --- a/lib/cadet/jobs/autograder/utilities.ex +++ b/lib/cadet/jobs/autograder/utilities.ex @@ -8,7 +8,7 @@ defmodule Cadet.Autograder.Utilities do import Ecto.Query - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.{Answer, Assessment, Question, Submission} def dispatch_programming_answer(answer = %Answer{}, question = %Question{}, overwrite \\ false) do @@ -26,15 +26,15 @@ defmodule Cadet.Autograder.Utilities do end def fetch_submissions(assessment_id) when is_ecto_id(assessment_id) do - User + CourseRegistration |> where(role: "student") |> join( :left, - [u], + [cr], s in Submission, - on: u.id == s.student_id and s.assessment_id == ^assessment_id + on: cr.id == s.student_id and s.assessment_id == ^assessment_id ) - |> select([u, s], %{student_id: u.id, submission: s}) + |> select([cr, s], %{student_id: cr.id, submission: s}) |> Repo.all() end @@ -42,8 +42,7 @@ defmodule Cadet.Autograder.Utilities do Assessment |> where(is_published: true) |> where([a], a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)) - |> where([a], a.type != "contest") - |> join(:inner, [a], q in assoc(a, :questions)) + |> join(:inner, [a, c], q in assoc(a, :questions)) |> preload([_, q], questions: q) |> Repo.all() |> Enum.map(&sort_assessment_questions(&1)) diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index 0152f56a5..a8458bb57 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -15,10 +15,10 @@ defmodule Cadet.Updater.XMLParser do quote do: is_list(unquote(term)) and unquote(term) != [] end - @spec parse_xml(String.t(), boolean()) :: + @spec parse_xml(String.t(), integer(), integer(), boolean()) :: :ok | {:ok, String.t()} | {:error, {atom(), String.t()}} - def parse_xml(xml, force_update \\ false) do - with {:ok, assessment_params} <- process_assessment(xml), + def parse_xml(xml, course_id, assessment_config_id, force_update \\ false) do + with {:ok, assessment_params} <- process_assessment(xml, course_id, assessment_config_id), {:ok, questions_params} <- process_questions(xml), {:ok, %{assessment: assessment}} <- Assessments.insert_or_update_assessments_and_questions( @@ -69,8 +69,9 @@ defmodule Cadet.Updater.XMLParser do |> List.foldr("", fn x, acc -> "#{acc <> x} " end) end - @spec process_assessment(String.t()) :: {:ok, map()} | {:error, String.t()} - defp process_assessment(xml) do + @spec process_assessment(String.t(), integer(), integer()) :: + {:ok, map()} | {:error, String.t()} + defp process_assessment(xml, course_id, assessment_config_id) do open_at = Timex.now() |> Timex.beginning_of_day() @@ -84,7 +85,7 @@ defmodule Cadet.Updater.XMLParser do |> xpath( ~x"//TASK"e, access: ~x"./@access"s |> transform_by(&process_access/1), - type: ~x"./@kind"s |> transform_by(&change_quest_to_sidequest/1), + # type: ~x"./@kind"s |> transform_by(&change_quest_to_sidequest/1), title: ~x"./@title"s, number: ~x"./@number"s, story: ~x"./@story"s, @@ -97,6 +98,8 @@ defmodule Cadet.Updater.XMLParser do |> Map.put(:is_published, false) |> Map.put(:open_at, open_at) |> Map.put(:close_at, close_at) + |> Map.put(:course_id, course_id) + |> Map.put(:config_id, assessment_config_id) if assessment_params.access === "public" do _ = Map.put(assessment_params, :password, nil) @@ -121,15 +124,6 @@ defmodule Cadet.Updater.XMLParser do "public" end - @spec change_quest_to_sidequest(String.t()) :: String.t() - defp change_quest_to_sidequest("quest") do - "sidequest" - end - - defp change_quest_to_sidequest(type) when is_binary(type) do - type - end - @spec process_questions(String.t()) :: {:ok, [map()]} | {:error, String.t()} defp process_questions(xml) do default_library = xpath(xml, ~x"//TASK/DEPLOYMENT"e) @@ -140,13 +134,15 @@ defmodule Cadet.Updater.XMLParser do |> xpath( ~x"//PROBLEMS/PROBLEM"el, type: ~x"./@type"o |> transform_by(&process_charlist/1), - max_grade: ~x"./@maxgrade"oi, max_xp: ~x"./@maxxp"oi, + show_solution: ~x"./@showsolution"os, + blocking: ~x"./@blocking"os, entity: ~x"." ) |> Enum.map(fn param -> with {:no_missing_attr?, true} <- - {:no_missing_attr?, not is_nil(param[:type]) and not is_nil(param[:max_grade])}, + {:no_missing_attr?, not is_nil(param[:type]) and not is_nil(param[:max_xp])}, + question when is_map(question) <- process_question_booleans(param), question when is_map(question) <- process_question_by_question_type(param), question when is_map(question) <- process_question_library(question, default_library, default_grading_library), @@ -176,6 +172,16 @@ defmodule Cadet.Updater.XMLParser do Logger.error("Changeset: #{inspect(changeset, pretty: true)}") end + @spec process_question_booleans(map()) :: map() + defp process_question_booleans(question) do + flags = [:show_solution, :blocking] + + flags + |> Enum.reduce(question, fn flag, acc -> + put_in(acc[flag], acc[flag] == "true") + end) + end + @spec process_question_by_question_type(map()) :: map() | {:error, String.t()} defp process_question_by_question_type(question) do question[:entity] @@ -208,8 +214,14 @@ defmodule Cadet.Updater.XMLParser do answer: ~x"./@answer" |> transform_by(&process_charlist/1), program: ~x"./text()" |> transform_by(&process_charlist/1) ], - private: [ - ~x"./SNIPPET/TESTCASES/PRIVATE"l, + opaque: [ + ~x"./SNIPPET/TESTCASES/OPAQUE"l, + score: ~x"./@score"oi, + answer: ~x"./@answer" |> transform_by(&process_charlist/1), + program: ~x"./text()" |> transform_by(&process_charlist/1) + ], + secret: [ + ~x"./SNIPPET/TESTCASES/SECRET"l, score: ~x"./@score"oi, answer: ~x"./@answer" |> transform_by(&process_charlist/1), program: ~x"./text()" |> transform_by(&process_charlist/1) diff --git a/lib/cadet/settings/settings.ex b/lib/cadet/settings/settings.ex deleted file mode 100644 index d46524867..000000000 --- a/lib/cadet/settings/settings.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Cadet.Settings do - @moduledoc """ - The Settings context contains functions to retrieve and configure Academy-wide - settings. - """ - use Cadet, [:context, :display] - - import Ecto.Query - - alias Cadet.Settings.Sublanguage - - @doc """ - Returns the default Source sublanguage of the Playground, from the most recent - entry in the Sublanguage table (there should only be 1, as seeded). - - If no entries exist, returns Source 1 as the default sublanguage. - """ - @spec get_sublanguage :: {:ok, %Sublanguage{}} - def get_sublanguage do - {:ok, retrieve_sublanguage() || %Sublanguage{chapter: 1, variant: "default"}} - end - - @doc """ - Updates the most recent entry in the Sublanguage table to the new chapter and - variant. - - If no entries exist, inserts a new entry in the Sublanguage table with the - given chapter and variant. - """ - @spec update_sublanguage(integer(), String.t()) :: - {:ok, %Sublanguage{}} | {:error, Ecto.Changeset.t()} - def update_sublanguage(chapter, variant) do - case retrieve_sublanguage() do - nil -> - %Sublanguage{} - |> Sublanguage.changeset(%{chapter: chapter, variant: variant}) - |> Repo.insert() - - sublanguage -> - sublanguage - |> Sublanguage.changeset(%{chapter: chapter, variant: variant}) - |> Repo.update() - end - end - - defp retrieve_sublanguage do - Sublanguage |> order_by(desc: :id) |> limit(1) |> Repo.one() - end -end diff --git a/lib/cadet/settings/sublanguage.ex b/lib/cadet/settings/sublanguage.ex deleted file mode 100644 index ed1d22162..000000000 --- a/lib/cadet/settings/sublanguage.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Cadet.Settings.Sublanguage do - @moduledoc """ - The Sublanguage entity stores the chapter and variant of the default - sublanguage in use by the Playground. - """ - use Cadet, :model - use Arc.Ecto.Schema - - schema "sublanguages" do - field(:chapter, :integer) - field(:variant, :string) - end - - @required_fields ~w(chapter variant)a - - def changeset(sublanguage, params) do - sublanguage - |> cast(params, @required_fields) - |> validate_required(@required_fields) - |> validate_allowed_combination() - end - - defp validate_allowed_combination(changeset) do - case get_field(changeset, :chapter) do - 1 -> validate_inclusion(changeset, :variant, ["default", "lazy", "wasm"]) - 2 -> validate_inclusion(changeset, :variant, ["default", "lazy"]) - 3 -> validate_inclusion(changeset, :variant, ["default", "concurrent", "non-det"]) - 4 -> validate_inclusion(changeset, :variant, ["default", "gpu"]) - _ -> add_error(changeset, :chapter, "is invalid") - end - end -end diff --git a/lib/cadet/stories/stories.ex b/lib/cadet/stories/stories.ex index b619c98cf..d996c5961 100644 --- a/lib/cadet/stories/stories.ex +++ b/lib/cadet/stories/stories.ex @@ -2,57 +2,90 @@ defmodule Cadet.Stories.Stories do @moduledoc """ Manages stories for the Source Academy game """ + use Cadet, [:context] import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.User + alias Cadet.Accounts.CourseRegistration alias Cadet.Stories.Story + alias Cadet.Courses.Course @manage_stories_role ~w(staff admin)a - def list_stories(_user = %User{role: role}) do + def list_stories( + _user_course_registration = %CourseRegistration{course_id: course_id, role: role} + ) do if role in @manage_stories_role do - Repo.all(Story) + Story + |> where(course_id: ^course_id) + |> Repo.all() else Story + |> where(course_id: ^course_id) |> where(is_published: ^true) |> where([s], s.open_at <= ^Timex.now()) |> Repo.all() end end - def create_story(attrs = %{}, _user = %User{role: role}) do + def create_story( + attrs = %{}, + _user_course_registration = %CourseRegistration{course_id: course_id, role: role} + ) do if role in @manage_stories_role do + course = + Course + |> where(id: ^course_id) + |> Repo.one() + %Story{} - |> Story.changeset(attrs) + |> Story.changeset(Map.put(attrs, :course_id, course.id)) |> Repo.insert() else {:error, {:forbidden, "User not allowed to manage stories"}} end end - def update_story(attrs = %{}, id, _user = %User{role: role}) do + def update_story( + attrs = %{}, + id, + _user_course_registration = %CourseRegistration{course_id: course_id, role: role} + ) do if role in @manage_stories_role do case Repo.get(Story, id) do nil -> {:error, {:not_found, "Story not found"}} story -> - story - |> Story.changeset(attrs) - |> Repo.update() + if story.course_id == course_id do + story + |> Story.changeset(attrs) + |> Repo.update() + else + {:error, {:forbidden, "User not allowed to manage stories from another course"}} + end end else {:error, {:forbidden, "User not allowed to manage stories"}} end end - def delete_story(id, _user = %User{role: role}) do + def delete_story( + id, + _user_course_registration = %CourseRegistration{course_id: course_id, role: role} + ) do if role in @manage_stories_role do case Repo.get(Story, id) do - nil -> {:error, {:not_found, "Story not found"}} - story -> Repo.delete(story) + nil -> + {:error, {:not_found, "Story not found"}} + + story -> + if story.course_id == course_id do + Repo.delete(story) + else + {:error, {:forbidden, "User not allowed to manage stories from another course"}} + end end else {:error, {:forbidden, "User not allowed to manage stories"}} diff --git a/lib/cadet/stories/story.ex b/lib/cadet/stories/story.ex index a789f9788..0d7ca679b 100644 --- a/lib/cadet/stories/story.ex +++ b/lib/cadet/stories/story.ex @@ -4,6 +4,8 @@ defmodule Cadet.Stories.Story do """ use Cadet, :model + alias Cadet.Courses.Course + schema "stories" do field(:open_at, :utc_datetime_usec) field(:close_at, :utc_datetime_usec) @@ -12,10 +14,12 @@ defmodule Cadet.Stories.Story do field(:image_url, :string) field(:filenames, {:array, :string}) + belongs_to(:course, Course) + timestamps() end - @required_fields ~w(open_at close_at title filenames)a + @required_fields ~w(open_at close_at title filenames course_id)a @optional_fields ~w(is_published image_url)a def changeset(story, attrs \\ %{}) do diff --git a/lib/cadet_web/admin_controllers/admin_achievements_controller.ex b/lib/cadet_web/admin_controllers/admin_achievements_controller.ex index f73de07f4..36e85c627 100644 --- a/lib/cadet_web/admin_controllers/admin_achievements_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_achievements_controller.ex @@ -6,15 +6,19 @@ defmodule CadetWeb.AdminAchievementsController do alias Cadet.Incentives.Achievements def bulk_update(conn, %{"achievements" => achievements}) do + course_reg = conn.assigns.course_reg + achievements - |> Enum.map(&json_to_achievement(&1)) + |> Enum.map(&json_to_achievement(&1, course_reg.course_id)) |> Achievements.upsert_many() |> handle_standard_result(conn) end def update(conn, %{"uuid" => uuid, "achievement" => achievement}) do + course_reg = conn.assigns.course_reg + achievement - |> json_to_achievement(uuid) + |> json_to_achievement(course_reg.course_id, uuid) |> Achievements.upsert() |> handle_standard_result(conn) end @@ -25,7 +29,7 @@ defmodule CadetWeb.AdminAchievementsController do |> handle_standard_result(conn) end - defp json_to_achievement(json, uuid \\ nil) do + defp json_to_achievement(json, course_id, uuid \\ nil) do json = json |> snake_casify_string_keys_recursive() @@ -34,6 +38,7 @@ defmodule CadetWeb.AdminAchievementsController do {"release", "open_at"}, {"card_background", "card_tile_url"} ]) + |> Map.put("course_id", course_id) |> case do map = %{"view" => view} -> map diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 256ec5832..c39f23819 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -4,17 +4,22 @@ defmodule CadetWeb.AdminAssessmentsController do use PhoenixSwagger alias Cadet.Assessments - import Cadet.Updater.XMLParser, only: [parse_xml: 2] - - def create(conn, %{"assessment" => assessment, "forceUpdate" => force_update}) do + import Cadet.Updater.XMLParser, only: [parse_xml: 4] + + def create(conn, %{ + "course_id" => course_id, + "assessment" => assessment, + "forceUpdate" => force_update, + "assessmentConfigId" => assessment_config_id + }) do file = assessment["file"].path |> File.read!() result = case force_update do - "true" -> parse_xml(file, true) - "false" -> parse_xml(file, false) + "true" -> parse_xml(file, course_id, assessment_config_id, true) + "false" -> parse_xml(file, course_id, assessment_config_id, false) end case result do diff --git a/lib/cadet_web/admin_controllers/admin_courses_controller.ex b/lib/cadet_web/admin_controllers/admin_courses_controller.ex index 8b1378917..2f0a2f7e7 100644 --- a/lib/cadet_web/admin_controllers/admin_courses_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_courses_controller.ex @@ -1 +1,150 @@ +defmodule CadetWeb.AdminCoursesController do + use CadetWeb, :controller + use PhoenixSwagger + + alias Cadet.Courses + + def update_course_config(conn, params = %{"course_id" => course_id}) + when is_ecto_id(course_id) do + params = params |> to_snake_case_atom_keys() + + case Courses.update_course_config(course_id, params) do + {:ok, _} -> + text(conn, "OK") + + {:error, {status, message}} -> + send_resp(conn, status, message) + + {:error, _} -> + conn + |> put_status(:bad_request) + |> text("Invalid parameter(s)") + end + end + + def get_assessment_configs(conn, %{"course_id" => course_id}) when is_ecto_id(course_id) do + assessment_configs = Courses.get_assessment_configs(course_id) + render(conn, "assessment_configs.json", %{configs: assessment_configs}) + end + + def update_assessment_configs(conn, %{ + "course_id" => course_id, + "assessmentConfigs" => assessment_configs + }) + when is_ecto_id(course_id) and is_list(assessment_configs) do + if Enum.all?(assessment_configs, &is_map/1) do + configs = + assessment_configs + |> Enum.map(&to_snake_case_atom_keys/1) + |> update_in( + [Access.all()], + &with( + {v, m} <- Map.pop(&1, :display_in_dashboard), + do: Map.put(m, :show_grading_summary, v) + ) + ) + + case Courses.mass_upsert_and_reorder_assessment_configs(course_id, configs) do + {:ok, _} -> + text(conn, "OK") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + else + send_resp( + conn, + :bad_request, + "assessmentConfigs should be a list of assessment configuration objects" + ) + end + end + + def update_assessment_configs(conn, _) do + send_resp(conn, :bad_request, "missing assessmentConfig") + end + + def delete_assessment_config(conn, %{ + "course_id" => course_id, + "assessment_config_id" => assessment_config_id + }) + when is_ecto_id(course_id) and is_ecto_id(assessment_config_id) do + case Courses.delete_assessment_config(course_id, assessment_config_id) do + {:ok, _} -> + text(conn, "OK") + + {:error, message} -> + conn + |> put_status(:bad_request) + |> text(message) + end + end + + swagger_path :update_course_config do + put("/v2/courses/{course_id}/admin/onfig") + + summary("Updates the course configuration for the specified course") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + course_name(:body, :string, "Course name") + course_short_name(:body, :string, "Course module code") + viewable(:body, :boolean, "Course viewability") + enable_game(:body, :boolean, "Enable game") + enable_achievements(:body, :boolean, "Enable achievements") + enable_sourcecast(:body, :boolean, "Enable sourcecast") + sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object") + module_help_text(:body, :string, "Module help text") + end + + response(200, "OK") + response(400, "Missing or invalid parameter(s)") + response(403, "Forbidden") + end + + swagger_path :update_assessment_configs do + put("/v2/courses/{course_id}/admin/config/assessment_configs") + + summary("Updates the assessment configuration for the specified course") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + assessment_configs(:body, :list, "Assessment Configs") + end + + response(200, "OK") + response(400, "Missing or invalid parameter(s)") + response(403, "Forbidden") + end + + def swagger_definitions do + %{ + AdminSublanguage: + swagger_schema do + title("AdminSublanguage") + + properties do + chapter(:integer, "Chapter number from 1 to 4", required: true, minimum: 1, maximum: 4) + + variant(Schema.ref(:SourceVariant), "Variant name", required: true) + end + + example(%{ + chapter: 2, + variant: "lazy" + }) + end + } + end +end diff --git a/lib/cadet_web/admin_controllers/admin_goals_controller.ex b/lib/cadet_web/admin_controllers/admin_goals_controller.ex index 0ed49c929..e609937fe 100644 --- a/lib/cadet_web/admin_controllers/admin_goals_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_goals_controller.ex @@ -6,29 +6,38 @@ defmodule CadetWeb.AdminGoalsController do alias Cadet.Incentives.Goals def index(conn, _) do - render(conn, "index.json", goals: Goals.get()) + course_id = conn.assigns.course_reg.course_id + render(conn, "index.json", goals: Goals.get(course_id)) end def bulk_update(conn, %{"goals" => goals}) do + course_reg = conn.assigns.course_reg + goals - |> Enum.map(&json_to_goal(&1)) + |> Enum.map(&json_to_goal(&1, course_reg.course_id)) |> Goals.upsert_many() |> handle_standard_result(conn) end def update(conn, %{"uuid" => uuid, "goal" => goal}) do + course_reg = conn.assigns.course_reg + goal - |> json_to_goal(uuid) + |> json_to_goal(course_reg.course_id, uuid) |> Goals.upsert() |> handle_standard_result(conn) end - def update_progress(conn, %{"uuid" => uuid, "userid" => user_id, "progress" => progress}) do - user_id = String.to_integer(user_id) + def update_progress(conn, %{ + "uuid" => uuid, + "course_reg_id" => course_reg_id, + "progress" => progress + }) do + course_reg_id = String.to_integer(course_reg_id) progress - |> json_to_progress(uuid, user_id) - |> Goals.upsert_progress(uuid, user_id) + |> json_to_progress(uuid, course_reg_id) + |> Goals.upsert_progress(uuid, course_reg_id) |> handle_standard_result(conn) end @@ -38,13 +47,14 @@ defmodule CadetWeb.AdminGoalsController do |> handle_standard_result(conn) end - defp json_to_goal(json, uuid \\ nil) do + defp json_to_goal(json, course_id, uuid \\ nil) do original_meta = json["meta"] json = json |> snake_casify_string_keys_recursive() |> Map.put("meta", original_meta) + |> Map.put("course_id", course_id) if is_nil(uuid) do json @@ -53,7 +63,7 @@ defmodule CadetWeb.AdminGoalsController do end end - defp json_to_progress(json, uuid, user_id) do + defp json_to_progress(json, uuid, course_reg_id) do json = json |> snake_casify_string_keys_recursive() @@ -62,7 +72,7 @@ defmodule CadetWeb.AdminGoalsController do count: Map.get(json, "count"), completed: Map.get(json, "completed"), goal_uuid: uuid, - user_id: user_id + course_reg_id: course_reg_id } end diff --git a/lib/cadet_web/admin_controllers/admin_grading_controller.ex b/lib/cadet_web/admin_controllers/admin_grading_controller.ex index 2074e47b5..eb628107b 100644 --- a/lib/cadet_web/admin_controllers/admin_grading_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_grading_controller.ex @@ -5,11 +5,11 @@ defmodule CadetWeb.AdminGradingController do alias Cadet.Assessments def index(conn, %{"group" => group}) when group in ["true", "false"] do - user = conn.assigns[:current_user] + course_reg = conn.assigns[:course_reg] group = String.to_atom(group) - case Assessments.all_submissions_by_grader_for_index(user, group) do + case Assessments.all_submissions_by_grader_for_index(course_reg, group) do {:ok, submissions} -> conn |> put_status(:ok) @@ -43,19 +43,14 @@ defmodule CadetWeb.AdminGradingController do } ) when is_ecto_id(submission_id) and is_ecto_id(question_id) do - user = conn.assigns[:current_user] + course_reg = conn.assigns[:course_reg] - grading = - if raw_grading["xpAdjustment"] do - Map.put(raw_grading, "xp_adjustment", raw_grading["xpAdjustment"]) - else - raw_grading - end + grading = raw_grading |> snake_casify_string_keys() case Assessments.update_grading_info( %{submission_id: submission_id, question_id: question_id}, grading, - user + course_reg ) do {:ok, _} -> text(conn, "OK") @@ -74,9 +69,9 @@ defmodule CadetWeb.AdminGradingController do end def unsubmit(conn, %{"submissionid" => submission_id}) when is_ecto_id(submission_id) do - user = conn.assigns[:current_user] + course_reg = conn.assigns[:course_reg] - case Assessments.unsubmit_submission(submission_id, user) do + case Assessments.unsubmit_submission(submission_id, course_reg) do {:ok, nil} -> text(conn, "OK") @@ -94,9 +89,9 @@ defmodule CadetWeb.AdminGradingController do end def autograde_submission(conn, %{"submissionid" => submission_id}) do - user = conn.assigns[:current_user] + course_reg = conn.assigns[:course_reg] - case Assessments.force_regrade_submission(submission_id, user) do + case Assessments.force_regrade_submission(submission_id, course_reg) do {:ok, nil} -> send_resp(conn, :no_content, "") @@ -108,9 +103,9 @@ defmodule CadetWeb.AdminGradingController do end def autograde_answer(conn, %{"submissionid" => submission_id, "questionid" => question_id}) do - user = conn.assigns[:current_user] + course_reg = conn.assigns[:course_reg] - case Assessments.force_regrade_answer(submission_id, question_id, user) do + case Assessments.force_regrade_answer(submission_id, question_id, course_reg) do {:ok, nil} -> send_resp(conn, :no_content, "") @@ -121,10 +116,10 @@ defmodule CadetWeb.AdminGradingController do end end - def grading_summary(conn, _params) do - case Assessments.get_group_grading_summary() do - {:ok, summary} -> - render(conn, "grading_summary.json", summary: summary) + def grading_summary(conn, %{"course_id" => course_id}) do + case Assessments.get_group_grading_summary(course_id) do + {:ok, cols, summary} -> + render(conn, "grading_summary.json", cols: cols, summary: summary) end end @@ -295,7 +290,7 @@ defmodule CadetWeb.AdminGradingController do properties do id(:integer, "assessment id", required: true) - type(Schema.ref(:AssessmentType), "Either mission/sidequest/path/contest", + config(Schema.ref(:AssessmentConfig), "Either mission/sidequest/path/contest", required: true ) diff --git a/lib/cadet_web/admin_controllers/admin_settings_controller.ex b/lib/cadet_web/admin_controllers/admin_settings_controller.ex deleted file mode 100644 index d1e37bb5c..000000000 --- a/lib/cadet_web/admin_controllers/admin_settings_controller.ex +++ /dev/null @@ -1,69 +0,0 @@ -defmodule CadetWeb.AdminSettingsController do - @moduledoc """ - Receives authorized requests involving Academy-wide configuration settings. - """ - use CadetWeb, :controller - - use PhoenixSwagger - - alias Cadet.Settings - - @doc """ - Receives a /settings/sublanguage PUT request with valid attributes. - - Overrides the stored default Source sublanguage of the Playground. - """ - def update(conn, %{"chapter" => chapter, "variant" => variant}) do - case Settings.update_sublanguage(chapter, variant) do - {:ok, _} -> - text(conn, "OK") - - {:error, _} -> - conn - |> put_status(:bad_request) - |> text("Invalid parameter(s)") - end - end - - def update(conn, _) do - send_resp(conn, :bad_request, "Missing parameter(s)") - end - - swagger_path :update do - put("/admin/settings/sublanguage") - - summary("Updates the default Source sublanguage of the Playground") - - security([%{JWT: []}]) - - consumes("application/json") - - parameters do - sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object", required: true) - end - - response(200, "OK") - response(400, "Missing or invalid parameter(s)") - response(403, "Forbidden") - end - - def swagger_definitions do - %{ - AdminSublanguage: - swagger_schema do - title("AdminSublanguage") - - properties do - chapter(:integer, "Chapter number from 1 to 4", required: true, minimum: 1, maximum: 4) - - variant(Schema.ref(:SourceVariant), "Variant name", required: true) - end - - example(%{ - chapter: 2, - variant: "lazy" - }) - end - } - end -end diff --git a/lib/cadet_web/admin_controllers/admin_user_controller.ex b/lib/cadet_web/admin_controllers/admin_user_controller.ex index fa20d0d58..2a2d6ea7d 100644 --- a/lib/cadet_web/admin_controllers/admin_user_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_user_controller.ex @@ -2,18 +2,180 @@ defmodule CadetWeb.AdminUserController do use CadetWeb, :controller use PhoenixSwagger - alias Cadet.Accounts + import Ecto.Query + + alias Cadet.Repo + alias Cadet.{Accounts, Courses} + alias Cadet.Accounts.{CourseRegistrations, CourseRegistration} + alias Cadet.Auth.Provider + + # This controller is used to find all users of a course def index(conn, filter) do - users = filter |> try_keywordise_string_keys() |> Accounts.get_users() + users = + filter |> try_keywordise_string_keys() |> Accounts.get_users_by(conn.assigns.course_reg) render(conn, "users.json", users: users) end + @add_users_role ~w(admin)a + def upsert_users_and_groups(conn, %{ + "course_id" => course_id, + "users" => usernames_roles_groups, + "provider" => provider + }) do + %{role: admin_role} = conn.assigns.course_reg + + {:ok, conn} = + Repo.transaction( + fn -> + # Note: Usernames from frontend have not been namespaced yet + with {:validate_role, true} <- {:validate_role, admin_role in @add_users_role}, + {:validate_provider, true} <- + {:validate_provider, + Map.has_key?(Application.get_env(:cadet, :identity_providers, %{}), provider)}, + {:atomify_keys, usernames_roles_groups} <- + {:atomify_keys, + Enum.map(usernames_roles_groups, fn x -> + for({key, val} <- x, into: %{}, do: {String.to_atom(key), val}) + end)}, + {:validate_usernames, true} <- + {:validate_usernames, + Enum.reduce(usernames_roles_groups, true, fn x, acc -> + acc and Map.has_key?(x, :username) and is_binary(x.username) and + x.username != "" + end)}, + {:validate_roles, true} <- + {:validate_roles, + Enum.reduce(usernames_roles_groups, true, fn x, acc -> + acc and Map.has_key?(x, :role) and + String.to_atom(x.role) in Cadet.Accounts.Role.__enums__() + end)}, + {:namespace, usernames_roles_groups} <- + {:namespace, + Enum.map(usernames_roles_groups, fn x -> + %{x | username: Provider.namespace(x.username, provider)} + end)}, + {:upsert_users, :ok} <- + {:upsert_users, + Accounts.CourseRegistrations.upsert_users_in_course( + usernames_roles_groups, + course_id + )}, + {:upsert_groups, :ok} <- + {:upsert_groups, + Courses.upsert_groups_in_course(usernames_roles_groups, course_id)} do + text(conn, "OK") + else + {:validate_role, false} -> + conn |> put_status(:forbidden) |> text("User is not permitted to add users") + + {:validate_provider, false} -> + conn |> put_status(:bad_request) |> text("Invalid authentication provider") + + {:validate_usernames, false} -> + conn |> put_status(:bad_request) |> text("Invalid username(s) provided") + + {:validate_roles, false} -> + conn |> put_status(:bad_request) |> text("Invalid role(s) provided") + + {:upsert_users, {:error, {status, message}}} -> + conn |> put_status(status) |> text(message) + + {:upsert_groups, {:error, {status, message}}} -> + conn |> put_status(status) |> text(message) + end + end, + timeout: 20_000 + ) + + conn + end + + @update_role_roles ~w(admin)a + def update_role(conn, %{"role" => role, "course_reg_id" => course_reg_id}) do + course_reg_id = course_reg_id |> String.to_integer() + + %{id: admin_course_reg_id, role: admin_role, course_id: admin_course_id} = + conn.assigns.course_reg + + with {:validate_role, true} <- {:validate_role, admin_role in @update_role_roles}, + {:validate_not_self, true} <- {:validate_not_self, admin_course_reg_id != course_reg_id}, + {:get_cr, user_course_reg} when not is_nil(user_course_reg) <- + {:get_cr, CourseRegistration |> where(id: ^course_reg_id) |> Repo.one()}, + {:validate_same_course, true} <- + {:validate_same_course, user_course_reg.course_id == admin_course_id} do + case CourseRegistrations.update_role(role, course_reg_id) do + {:ok, %{}} -> + text(conn, "OK") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + else + {:validate_role, false} -> + conn |> put_status(:forbidden) |> text("User is not permitted to change others' roles") + + {:validate_not_self, false} -> + conn |> put_status(:bad_request) |> text("Admin not allowed to downgrade own role") + + {:get_cr, _} -> + conn |> put_status(:bad_request) |> text("User course registration does not exist") + + {:validate_same_course, false} -> + conn |> put_status(:forbidden) |> text("User is in a different course") + end + end + + @delete_user_roles ~w(admin)a + def delete_user(conn, %{"course_reg_id" => course_reg_id}) do + course_reg_id = course_reg_id |> String.to_integer() + + %{id: admin_course_reg_id, role: admin_role, course_id: admin_course_id} = + conn.assigns.course_reg + + with {:validate_role, true} <- {:validate_role, admin_role in @delete_user_roles}, + {:validate_not_self, true} <- {:validate_not_self, admin_course_reg_id != course_reg_id}, + {:get_cr, user_course_reg} when not is_nil(user_course_reg) <- + {:get_cr, CourseRegistration |> where(id: ^course_reg_id) |> Repo.one()}, + {:prevent_delete_admin, true} <- {:prevent_delete_admin, user_course_reg.role != :admin}, + {:validate_same_course, true} <- + {:validate_same_course, user_course_reg.course_id == admin_course_id} do + case CourseRegistrations.delete_course_registration(course_reg_id) do + {:ok, %{}} -> + text(conn, "OK") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + else + {:validate_role, false} -> + conn |> put_status(:forbidden) |> text("User is not permitted to delete other users") + + {:validate_not_self, false} -> + conn + |> put_status(:bad_request) + |> text("Admin not allowed to delete ownself from course") + + {:get_cr, _} -> + conn |> put_status(:bad_request) |> text("User course registration does not exist") + + {:prevent_delete_admin, false} -> + conn |> put_status(:bad_request) |> text("Admins cannot be deleted") + + {:validate_same_course, false} -> + conn |> put_status(:forbidden) |> text("User is in a different course") + end + end + swagger_path :index do - get("/admin/users") + get("/v2/courses/{course_id}/admin/users") - summary("Returns a list of users") + summary("Returns a list of users in the course owned by the admin") security([%{JWT: []}]) produces("application/json") @@ -21,23 +183,114 @@ defmodule CadetWeb.AdminUserController do response(401, "Unauthorised") end + swagger_path :add_users do + put("/v2/courses/{course_id}/admin/users") + + summary("Adds the list of usernames and roles to the course") + security([%{JWT: []}]) + consumes("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + users(:body, Schema.array(:UsernameAndRole), "Array of usernames and roles", required: true) + + provider(:body, :string, "The authentication provider linked to these usernames", + required: true + ) + end + + response(200, "OK") + response(400, "Bad Request. Invalid provider, username or role") + response(403, "Forbidden. You are not an admin") + end + + swagger_path :update_role do + put("/v2/courses/{course_id}/admin/users/role") + + summary("Updates the role of the given user in the the course") + security([%{JWT: []}]) + consumes("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + role(:body, :role, "The new role", required: true) + + courseRegId( + :body, + :integer, + "The course registration of the user whose role is to be updated", + required: true + ) + end + + response(200, "OK") + + response( + 400, + "Bad Request. User course registration does not exist or admin not allowed to downgrade own role" + ) + + response(403, "Forbidden. User is in different course, or you are not an admin") + end + + swagger_path :delete_user do + delete("/v2/courses/{course_id}/admin/users") + + summary("Deletes a user from a course") + consumes("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + + courseRegId( + :body, + :integer, + "The course registration of the user whose role is to be updated", + required: true + ) + end + + response(200, "OK") + + response( + 400, + "Bad Request. User course registration does not exist or admin not allowed to delete ownself from course or admins cannot be deleted" + ) + + response(403, "Forbidden. User is in different course, or you are not an admin") + end + def swagger_definitions do %{ AdminUserInfo: swagger_schema do title("User") - description("Basic information about the user") + description("Basic information about the user in this course") properties do userId(:integer, "User's ID") name(:string, "Full name of the user") - role(:string, "Role of the user. Can be 'student', 'staff', or 'admin'") + + role( + :string, + "Role of the user in this course. Can be 'student', 'staff', or 'admin'" + ) group( :string, - "Group the user belongs to. May be null if the user does not belong to any group" + "Group the user belongs to in this course. May be null if the user does not belong to any group" ) end + end, + UsernameAndRole: + swagger_schema do + title("Username and role") + description("Username and role of the user to add to this course") + + properties do + username(:string, "The user's username") + role(:role, "The user's role. Can be 'student', 'staff', or 'admin'") + end end } end diff --git a/lib/cadet_web/admin_views/admin_courses_view.ex b/lib/cadet_web/admin_views/admin_courses_view.ex new file mode 100644 index 000000000..2de1c970c --- /dev/null +++ b/lib/cadet_web/admin_views/admin_courses_view.ex @@ -0,0 +1,18 @@ +defmodule CadetWeb.AdminCoursesView do + use CadetWeb, :view + + def render("assessment_configs.json", %{configs: configs}) do + render_many(configs, CadetWeb.AdminCoursesView, "config.json", as: :config) + end + + def render("config.json", %{config: config}) do + transform_map_for_view(config, %{ + assessmentConfigId: :id, + type: :type, + displayInDashboard: :show_grading_summary, + isManuallyGraded: :is_manually_graded, + earlySubmissionXp: :early_submission_xp, + hoursBeforeEarlyXpDecay: :hours_before_early_xp_decay + }) + end +end diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index 12d3ab74a..ccb9f25f3 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -9,33 +9,23 @@ defmodule CadetWeb.AdminGradingView do def render("grading_info.json", %{answer: answer}) do transform_map_for_view(answer, %{ - student: &transform_map_for_view(&1.submission.student, [:name, :id]), + student: + &transform_map_for_view(&1.submission.student, %{name: fn st -> st.user.name end, id: :id}), question: &build_grading_question/1, solution: &(&1.question.question["solution"] || ""), grade: &build_grade/1 }) end - def render("grading_summary.json", %{summary: summary}) do - render_many(summary, CadetWeb.AdminGradingView, "grading_summary_entry.json", as: :entry) - end - - def render("grading_summary_entry.json", %{entry: entry}) do - transform_map_for_view(entry, %{ - groupName: :group_name, - leaderName: :leader_name, - ungradedMissions: :ungraded_missions, - submittedMissions: :submitted_missions, - ungradedSidequests: :ungraded_sidequests, - submittedSidequests: :submitted_sidequests - }) + def render("grading_summary.json", %{cols: cols, summary: summary}) do + %{cols: cols, rows: summary} end defp build_grading_question(answer) do results = build_autograding_results(answer.autograding_results) - %{question: answer.question, assessment_type: answer.question.assessment.type} - |> build_question_by_assessment_type(true) + %{question: answer.question} + |> build_question_by_question_config(true) |> Map.put(:answer, answer.answer["code"] || answer.answer["choice_id"]) |> Map.put(:autogradingStatus, answer.autograding_status) |> Map.put(:autogradingResults, results) @@ -51,8 +41,6 @@ defmodule CadetWeb.AdminGradingView do transform_map_for_view(answer, %{ grader: grader_builder(grader), gradedAt: graded_at_builder(grader), - grade: :grade, - adjustment: :adjustment, xp: :xp, xpAdjustment: :xp_adjustment, comments: :comments diff --git a/lib/cadet_web/admin_views/admin_user_view.ex b/lib/cadet_web/admin_views/admin_user_view.ex index 3bf82ac3c..61c48ca30 100644 --- a/lib/cadet_web/admin_views/admin_user_view.ex +++ b/lib/cadet_web/admin_views/admin_user_view.ex @@ -2,18 +2,20 @@ defmodule CadetWeb.AdminUserView do use CadetWeb, :view def render("users.json", %{users: users}) do - render_many(users, CadetWeb.AdminUserView, "user.json", as: :user) + render_many(users, CadetWeb.AdminUserView, "cr.json", as: :cr) end - def render("user.json", %{user: user}) do + def render("cr.json", %{cr: cr}) do %{ - userId: user.id, - name: user.name, - role: user.role, + courseRegId: cr.id, + course_id: cr.course_id, + name: cr.user.name, + username: cr.user.username, + role: cr.role, group: - case user.group do + case cr.group do nil -> nil - _ -> user.group.name + _ -> cr.group.name end } end diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 3c744cd32..5a5b22cb0 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -5,20 +5,20 @@ defmodule CadetWeb.AnswerController do alias Cadet.Assessments - # These roles can save and finalise answers for closed assessments and - # submitted answers + # These roles can save and finalise answers for + # closed assessments and submitted answers @bypass_closed_roles ~w(staff admin)a def submit(conn, %{"questionid" => question_id, "answer" => answer}) when is_ecto_id(question_id) do - user = conn.assigns[:current_user] - can_bypass? = user.role in @bypass_closed_roles + course_reg = conn.assigns[:course_reg] + can_bypass? = course_reg.role in @bypass_closed_roles with {:question, question} when not is_nil(question) <- {:question, Assessments.get_question(question_id)}, {:is_open?, true} <- {:is_open?, can_bypass? or Assessments.is_open?(question.assessment)}, - {:ok, _nil} <- Assessments.answer_question(question, user, answer, can_bypass?) do + {:ok, _nil} <- Assessments.answer_question(question, course_reg, answer, can_bypass?) do text(conn, "OK") else {:question, nil} -> diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index 5954bcdf9..dc790e176 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -10,13 +10,13 @@ defmodule CadetWeb.AssessmentsController do @bypass_closed_roles ~w(staff admin)a def submit(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - user = conn.assigns[:current_user] + cr = conn.assigns.course_reg with {:submission, submission} when not is_nil(submission) <- - {:submission, Assessments.get_submission(assessment_id, user)}, + {:submission, Assessments.get_submission(assessment_id, cr)}, {:is_open?, true} <- {:is_open?, - user.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, + cr.role in @bypass_closed_roles or Assessments.is_open?(submission.assessment)}, {:ok, _nil} <- Assessments.finalise_submission(submission) do text(conn, "OK") else @@ -38,16 +38,16 @@ defmodule CadetWeb.AssessmentsController do end def index(conn, _) do - user = conn.assigns[:current_user] - {:ok, assessments} = Assessments.all_assessments(user) + cr = conn.assigns.course_reg + {:ok, assessments} = Assessments.all_assessments(cr) render(conn, "index.json", assessments: assessments) end def show(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do - user = conn.assigns[:current_user] + cr = conn.assigns.course_reg - case Assessments.assessment_with_questions_and_answers(assessment_id, user) do + case Assessments.assessment_with_questions_and_answers(assessment_id, cr) do {:ok, assessment} -> render(conn, "show.json", assessment: assessment) {:error, {status, message}} -> send_resp(conn, status, message) end @@ -55,9 +55,9 @@ defmodule CadetWeb.AssessmentsController do def unlock(conn, %{"assessmentid" => assessment_id, "password" => password}) when is_ecto_id(assessment_id) do - user = conn.assigns[:current_user] + cr = conn.assigns.course_reg - case Assessments.assessment_with_questions_and_answers(assessment_id, user, password) do + case Assessments.assessment_with_questions_and_answers(assessment_id, cr, password) do {:ok, assessment} -> render(conn, "show.json", assessment: assessment) {:error, {status, message}} -> send_resp(conn, status, message) end @@ -153,7 +153,7 @@ defmodule CadetWeb.AssessmentsController do id(:integer, "The assessment ID", required: true) title(:string, "The title of the assessment", required: true) - type(Schema.ref(:AssessmentType), "The assessment type", required: true) + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) shortSummary(:string, "Short summary", required: true) @@ -174,12 +174,6 @@ defmodule CadetWeb.AssessmentsController do required: true ) - maxGrade( - :integer, - "The maximum grade for this assessment", - required: true - ) - maxXp( :integer, "The maximum XP for this assessment", @@ -188,8 +182,6 @@ defmodule CadetWeb.AssessmentsController do xp(:integer, "The XP earned for this assessment", required: true) - grade(:integer, "The grade earned for this assessment", required: true) - coverImage(:string, "The URL to the cover picture", required: true) private(:boolean, "Is this an private assessment?", required: true) @@ -211,7 +203,7 @@ defmodule CadetWeb.AssessmentsController do id(:integer, "The assessment ID", required: true) title(:string, "The title of the assessment", required: true) - type(Schema.ref(:AssessmentType), "The assessment type", required: true) + config(Schema.ref(:AssessmentConfig), "The assessment config", required: true) number( :string, @@ -227,9 +219,9 @@ defmodule CadetWeb.AssessmentsController do questions(Schema.ref(:Questions), "The list of questions for this assessment") end end, - AssessmentType: + AssessmentConfig: swagger_schema do - description("Assessment type") + description("Assessment config") type(:string) enum([:mission, :sidequest, :path, :contest, :practical]) end, @@ -370,13 +362,13 @@ defmodule CadetWeb.AssessmentsController do answer(:string) score(:integer) program(:string) - type(Schema.ref(:TestcaseType), "One of public/hidden/private") + type(Schema.ref(:TestcaseType), "One of public/opaque/secret") end end, TestcaseType: swagger_schema do type(:string) - enum([:public, :hidden, :private]) + enum([:public, :opaque, :secret]) end, AutogradingResult: swagger_schema do diff --git a/lib/cadet_web/controllers/auth_controller.ex b/lib/cadet_web/controllers/auth_controller.ex index 8571db6cf..3b589274c 100644 --- a/lib/cadet_web/controllers/auth_controller.ex +++ b/lib/cadet_web/controllers/auth_controller.ex @@ -34,7 +34,7 @@ defmodule CadetWeb.AuthController do {:authorise, {:error, :upstream, reason}} -> conn |> put_status(:bad_request) - |> text("Unable to retrieve token from ADFS: #{reason}") + |> text("Unable to retrieve token from authentication provider: #{reason}") {:authorise, {:error, :invalid_credentials, reason}} -> conn diff --git a/lib/cadet_web/controllers/courses_controller.ex b/lib/cadet_web/controllers/courses_controller.ex index 8b1378917..0c09c291f 100644 --- a/lib/cadet_web/controllers/courses_controller.ex +++ b/lib/cadet_web/controllers/courses_controller.ex @@ -1 +1,111 @@ +defmodule CadetWeb.CoursesController do + use CadetWeb, :controller + use PhoenixSwagger + + alias Cadet.Courses + + def index(conn, %{"course_id" => course_id}) when is_ecto_id(course_id) do + case Courses.get_course_config(course_id) do + {:ok, config} -> render(conn, "config.json", config: config) + {:error, {status, message}} -> send_resp(conn, status, message) + end + end + + def create(conn, params) do + user = conn.assigns.current_user + params = params |> to_snake_case_atom_keys() + + case Courses.create_course_config(params, user) do + {:ok, _} -> + text(conn, "OK") + + {:error, _, _, _} -> + conn + |> put_status(:bad_request) + |> text("Invalid parameter(s)") + end + end + + swagger_path :create do + post("/v2/config/create") + + summary("Creates a new course") + + security([%{JWT: []}]) + consumes("application/json") + + parameters do + course_name(:body, :string, "Course name", required: true) + course_short_name(:body, :string, "Course module code", required: true) + viewable(:body, :boolean, "Course viewability", required: true) + enable_game(:body, :boolean, "Enable game", required: true) + enable_achievements(:body, :boolean, "Enable achievements", required: true) + enable_sourcecast(:body, :boolean, "Enable sourcecast", required: true) + source_chapter(:body, :number, "Default source chapter", required: true) + + source_variant(:body, Schema.ref(:SourceVariant), "Default source variant name", + required: true + ) + + module_help_text(:body, :string, "Module help text", required: true) + end + end + + swagger_path :get_course_config do + get("/v2/courses/{course_id}/config") + + summary("Retrieves the course configuration of the specified course") + + security([%{JWT: []}]) + + produces("application/json") + + parameters do + course_id(:path, :integer, "Course ID", required: true) + end + + response(200, "OK", Schema.ref(:Config)) + response(400, "Invalid course_id") + end + + def swagger_definitions do + %{ + Config: + swagger_schema do + title("Course Configuration") + + properties do + course_name(:string, "Course name", required: true) + course_short_name(:string, "Course module code", required: true) + viewable(:boolean, "Course viewability", required: true) + enable_game(:boolean, "Enable game", required: true) + enable_achievements(:boolean, "Enable achievements", required: true) + enable_sourcecast(:boolean, "Enable sourcecast", required: true) + source_chapter(:integer, "Source Chapter number from 1 to 4", required: true) + source_variant(Schema.ref(:SourceVariant), "Source Variant name", required: true) + module_help_text(:string, "Module help text", required: true) + assessment_types(:list, "Assessment Types", required: true) + end + + example(%{ + course_name: "Programming Methodology", + course_short_name: "CS1101S", + viewable: true, + enable_game: true, + enable_achievements: true, + enable_sourcecast: true, + source_chapter: 1, + source_variant: "default", + module_help_text: "Help text", + assessment_types: ["Missions", "Quests", "Paths", "Contests", "Others"] + }) + end, + SourceVariant: + swagger_schema do + type(:string) + enum([:default, :concurrent, :gpu, :lazy, "non-det", :wasm]) + end + } + end +end diff --git a/lib/cadet_web/controllers/incentives_controller.ex b/lib/cadet_web/controllers/incentives_controller.ex index a375b8d60..4e0765dd3 100644 --- a/lib/cadet_web/controllers/incentives_controller.ex +++ b/lib/cadet_web/controllers/incentives_controller.ex @@ -6,25 +6,26 @@ defmodule CadetWeb.IncentivesController do alias Cadet.Incentives.{Achievements, Goals} def index_achievements(conn, _) do - render(conn, "index_achievements.json", achievements: Achievements.get()) + course_id = conn.assigns.course_reg.course_id + render(conn, "index_achievements.json", achievements: Achievements.get(course_id)) end def index_goals(conn, _) do render(conn, "index_goals_with_progress.json", - goals: Goals.get_with_progress(conn.assigns.current_user) + goals: Goals.get_with_progress(conn.assigns.course_reg) ) end def update_progress(conn, %{"uuid" => uuid, "progress" => progress}) do - user_id = conn.assigns.current_user.id + course_reg_id = conn.assigns.course_reg.id progress - |> json_to_progress(uuid, user_id) - |> Goals.upsert_progress(uuid, user_id) + |> json_to_progress(uuid, course_reg_id) + |> Goals.upsert_progress(uuid, course_reg_id) |> handle_standard_result(conn) end - defp json_to_progress(json, uuid, user_id) do + defp json_to_progress(json, uuid, course_reg_id) do json = json |> snake_casify_string_keys_recursive() @@ -33,7 +34,7 @@ defmodule CadetWeb.IncentivesController do count: Map.get(json, "count"), completed: Map.get(json, "completed"), goal_uuid: uuid, - user_id: user_id + course_reg_id: course_reg_id } end diff --git a/lib/cadet_web/controllers/notifications_controller.ex b/lib/cadet_web/controllers/notifications_controller.ex index 5e82d3c63..5989f3336 100644 --- a/lib/cadet_web/controllers/notifications_controller.ex +++ b/lib/cadet_web/controllers/notifications_controller.ex @@ -9,7 +9,7 @@ defmodule CadetWeb.NotificationsController do alias Cadet.Accounts.Notifications def index(conn, _) do - {:ok, notifications} = Notifications.fetch(conn.assigns.current_user) + {:ok, notifications} = Notifications.fetch(conn.assigns.course_reg) render( conn, @@ -21,7 +21,7 @@ defmodule CadetWeb.NotificationsController do def acknowledge(conn, %{"notificationIds" => notification_ids}) do case Notifications.acknowledge( notification_ids, - conn.assigns.current_user + conn.assigns.course_reg ) do {:ok, _nil} -> text(conn, "OK") @@ -39,7 +39,7 @@ defmodule CadetWeb.NotificationsController do end swagger_path :index do - get("/notifications") + get("/v2/courses/{course_id}/notifications") summary("Get the unread notifications belonging to a user") @@ -52,7 +52,7 @@ defmodule CadetWeb.NotificationsController do end swagger_path :acknowledge do - post("/notifications/acknowledge") + post("/v2/courses/{course_id}/notifications/acknowledge") summary("Acknowledge notification(s)") security([%{JWT: []}]) diff --git a/lib/cadet_web/controllers/settings_controller.ex b/lib/cadet_web/controllers/settings_controller.ex deleted file mode 100644 index 901cf37c9..000000000 --- a/lib/cadet_web/controllers/settings_controller.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule CadetWeb.SettingsController do - @moduledoc """ - Receives public requests involving Academy-wide configuration settings. - """ - use CadetWeb, :controller - - use PhoenixSwagger - - alias Cadet.Settings - - @doc """ - Receives a /settings/sublanguage GET request. - - Returns the default Source sublanguage of the Playground. - """ - def index(conn, _) do - {:ok, sublanguage} = Settings.get_sublanguage() - - render(conn, "show.json", sublanguage: sublanguage) - end - - swagger_path :index do - get("/settings/sublanguage") - - summary("Retrieves the default Source sublanguage of the Playground") - - produces("application/json") - - response(200, "OK", Schema.ref(:Sublanguage)) - end - - def swagger_definitions do - %{ - Sublanguage: - swagger_schema do - title("Sublanguage") - - properties do - chapter(:integer, "Chapter number from 1 to 4", required: true, minimum: 1, maximum: 4) - - variant(Schema.ref(:SourceVariant), "Variant name", required: true) - end - - example(%{ - chapter: 1, - variant: "default" - }) - end, - SourceVariant: - swagger_schema do - type(:string) - enum([:default, :concurrent, :gpu, :lazy, "non-det", :wasm]) - end - } - end -end diff --git a/lib/cadet_web/controllers/sourcecast_controller.ex b/lib/cadet_web/controllers/sourcecast_controller.ex index dd08acd12..c16704735 100644 --- a/lib/cadet_web/controllers/sourcecast_controller.ex +++ b/lib/cadet_web/controllers/sourcecast_controller.ex @@ -2,16 +2,39 @@ defmodule CadetWeb.SourcecastController do use CadetWeb, :controller use PhoenixSwagger - alias Cadet.{Repo, Course} - alias Cadet.Courses.Sourcecast + alias Cadet.Courses + + def index(conn, %{"course_id" => course_id}) do + sourcecasts = Courses.get_sourcecast_files(course_id) + render(conn, "index.json", sourcecasts: sourcecasts) + end def index(conn, _params) do - sourcecasts = Sourcecast |> Repo.all() |> Repo.preload(:uploader) + sourcecasts = Courses.get_sourcecast_files() render(conn, "index.json", sourcecasts: sourcecasts) end + def create(conn, %{"sourcecast" => sourcecast, "public" => _public}) do + result = + Courses.upload_sourcecast_file_public( + conn.assigns.current_user, + conn.assigns.course_reg, + sourcecast + ) + + case result do + {:ok, _nil} -> + send_resp(conn, 200, "OK") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + def create(conn, %{"sourcecast" => sourcecast}) do - result = Course.upload_sourcecast_file(conn.assigns.current_user, sourcecast) + result = Courses.upload_sourcecast_file(conn.assigns.course_reg, sourcecast) case result do {:ok, _nil} -> @@ -29,7 +52,7 @@ defmodule CadetWeb.SourcecastController do end def delete(conn, %{"id" => id}) do - result = Course.delete_sourcecast_file(conn.assigns.current_user, id) + result = Courses.delete_sourcecast_file(conn.assigns.course_reg, id) case result do {:ok, _nil} -> @@ -62,6 +85,12 @@ defmodule CadetWeb.SourcecastController do security([%{JWT: []}]) parameters do + public( + :body, + :boolean, + "Uploads as public sourcecast when 'public' is specified regardless of truthy or falsy" + ) + sourcecast(:body, Schema.ref(:Sourcecast), "sourcecast object", required: true) end diff --git a/lib/cadet_web/controllers/stories_controller.ex b/lib/cadet_web/controllers/stories_controller.ex index 71cb846f5..5d509eac3 100644 --- a/lib/cadet_web/controllers/stories_controller.ex +++ b/lib/cadet_web/controllers/stories_controller.ex @@ -5,7 +5,7 @@ defmodule CadetWeb.StoriesController do alias Cadet.Stories.Stories def index(conn, _) do - stories = Stories.list_stories(conn.assigns.current_user) + stories = Stories.list_stories(conn.assigns.course_reg) render(conn, "index.json", stories: stories) end @@ -13,7 +13,8 @@ defmodule CadetWeb.StoriesController do result = story |> snake_casify_string_keys() - |> Stories.create_story(conn.assigns.current_user) + |> string_to_atom_map_keys() + |> Stories.create_story(conn.assigns.course_reg) case result do {:ok, _story} -> @@ -30,7 +31,7 @@ defmodule CadetWeb.StoriesController do result = story |> snake_casify_string_keys() - |> Stories.update_story(id, conn.assigns.current_user) + |> Stories.update_story(id, conn.assigns.course_reg) case result do {:ok, _story} -> @@ -44,7 +45,7 @@ defmodule CadetWeb.StoriesController do end def delete(conn, _params = %{"storyid" => id}) do - result = Stories.delete_story(id, conn.assigns.current_user) + result = Stories.delete_story(id, conn.assigns.course_reg) case result do {:ok, _nil} -> @@ -57,19 +58,22 @@ defmodule CadetWeb.StoriesController do end end + defp string_to_atom_map_keys(map) do + for {key, value} <- map, into: %{}, do: {key |> String.to_atom(), value} + end + swagger_path :index do - get("/stories") + get("/v2/courses/{course_id}/stories") summary("Get a list of all stories") security([%{JWT: []}]) response(200, "OK", Schema.array(:Story)) - response(403, "User not allowed to manage stories") end swagger_path :create do - post("/stories") + post("/v2{course_id}/stories") summary("Creates a new story") @@ -81,7 +85,7 @@ defmodule CadetWeb.StoriesController do end swagger_path :delete do - PhoenixSwagger.Path.delete("/stories/{storyId}") + PhoenixSwagger.Path.delete("/v2/courses/{course_id}/stories/{storyId}") summary("Delete a story from database by id") @@ -92,12 +96,12 @@ defmodule CadetWeb.StoriesController do security([%{JWT: []}]) response(204, "OK") - response(403, "User not allowed to manage stories") + response(403, "User not allowed to manage stories or stories from another course") response(404, "Story not found") end swagger_path :update do - post("/stories/{storyId}") + post("/v2/courses/{course_id}/stories/{storyId}") summary("Update details regarding a story") @@ -110,7 +114,7 @@ defmodule CadetWeb.StoriesController do produces("application/json") response(200, "OK", :Story) - response(403, "User not allowed to manage stories") + response(403, "User not allowed to manage stories or stories from another course") response(404, "Story not found") end @@ -126,6 +130,7 @@ defmodule CadetWeb.StoriesController do openAt(:string, "The opening date", format: "date-time", required: true) closeAt(:string, "The closing date", format: "date-time", required: true) isPublished(:boolean, "Whether or not is published", required: false) + course_id(:integer, "The id of the course that this story belongs to", required: true) end end } diff --git a/lib/cadet_web/controllers/user_controller.ex b/lib/cadet_web/controllers/user_controller.ex index f8ac61f26..e5031b863 100644 --- a/lib/cadet_web/controllers/user_controller.ex +++ b/lib/cadet_web/controllers/user_controller.ex @@ -7,29 +7,92 @@ defmodule CadetWeb.UserController do use PhoenixSwagger import Cadet.Assessments alias Cadet.Accounts + alias Cadet.Accounts.CourseRegistrations def index(conn, _) do - user = user_with_group(conn.assigns.current_user) - %{total_grade: grade, total_xp: xp} = user_total_grade_xp(user) - max_grade = user_max_grade(user) - story = user_current_story(user) + user = conn.assigns.current_user + courses = CourseRegistrations.get_courses(conn.assigns.current_user) + + if user.latest_viewed_course_id do + latest = CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id) + xp = user_total_xp(latest) + max_xp = user_max_xp(latest) + story = user_current_story(latest) + + render( + conn, + "index.json", + user: user, + courses: courses, + latest: latest, + max_xp: max_xp, + story: story, + xp: xp + ) + else + render(conn, "index.json", + user: user, + courses: courses, + latest: nil, + max_xp: nil, + story: nil, + xp: nil + ) + end + end + + def get_latest_viewed(conn, _) do + user = conn.assigns.current_user + + latest = + case user.latest_viewed_course_id do + nil -> nil + _ -> CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id) + end + + get_course_reg_config(conn, latest) + end + + def get_course_reg(conn, _) do + course_reg = conn.assigns.course_reg + get_course_reg_config(conn, course_reg) + end + + defp get_course_reg_config(conn, course_reg) when is_nil(course_reg) do + render(conn, "course.json", latest: nil, story: nil, xp: nil, max_xp: nil) + end + + defp get_course_reg_config(conn, course_reg) do + xp = user_total_xp(course_reg) + max_xp = user_max_xp(course_reg) + story = user_current_story(course_reg) render( conn, - "index.json", - user: user, - grade: grade, - max_grade: max_grade, + "course.json", + latest: course_reg, + max_xp: max_xp, story: story, - xp: xp, - game_states: user.game_states + xp: xp ) end + def update_latest_viewed(conn, %{"courseId" => course_id}) do + case Accounts.update_latest_viewed(conn.assigns.current_user, course_id) do + {:ok, %{}} -> + text(conn, "OK") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + def update_game_states(conn, %{"gameStates" => new_game_states}) do - user = conn.assigns[:current_user] + cr = conn.assigns[:course_reg] - case Accounts.update_game_states(user, new_game_states) do + case CourseRegistrations.update_game_states(cr, new_game_states) do {:ok, %{}} -> text(conn, "OK") @@ -41,18 +104,42 @@ defmodule CadetWeb.UserController do end swagger_path :index do - get("/user") + get("/v2/user") + + summary("Get the name, and latest_viewed_course of a user") + + security([%{JWT: []}]) + produces("application/json") + response(200, "OK", Schema.ref(:IndexInfo)) + response(401, "Unauthorised") + end + + swagger_path :get_latest_viewed do + get("/v2/user/latest_viewed_course") - summary("Get the name, role and group of a user") + summary("Get the latest_viewed_course of a user") security([%{JWT: []}]) produces("application/json") - response(200, "OK", Schema.ref(:UserInfo)) + response(200, "OK", Schema.ref(:LatestViewedInfo)) response(401, "Unauthorised") end + swagger_path :update_latest_viewed do + put("/v2/user/latest_viewed_course") + summary("Update user's latest viewed course") + security([%{JWT: []}]) + consumes("application/json") + + parameters do + course_id(:body, :integer, "new latest viewed course", required: true) + end + + response(200, "OK") + end + swagger_path :update_game_states do - put("/user/game_states") + put("/v2/courses/:course_id/user/game_states") summary("Update user's game states") security([%{JWT: []}]) consumes("application/json") @@ -66,6 +153,42 @@ defmodule CadetWeb.UserController do def swagger_definitions do %{ + IndexInfo: + swagger_schema do + title("User Index") + description("user, course_registration and course configuration of the latest course") + + properties do + user(Schema.ref(:UserInfo), "user info") + + courseRegistration( + Schema.ref(:CourseRegistration), + "course registration of the latest viewed course" + ) + + courseConfiguration( + Schema.ref(:CourseConfiguration), + "course configuration of the latest viewed course" + ) + end + end, + LatestViewedInfo: + swagger_schema do + title("Latest viewed course") + description("course_registration and course configuration of the latest course") + + properties do + courseRegistration( + Schema.ref(:CourseRegistration), + "course registration of the latest viewed course" + ) + + courseConfiguration( + Schema.ref(:CourseConfiguration), + "course configuration of the latest viewed course" + ) + end + end, UserInfo: swagger_schema do title("User") @@ -73,9 +196,15 @@ defmodule CadetWeb.UserController do properties do userId(:integer, "User's ID", required: true) - name(:string, "Full name of the user", required: true) + end + end, + CourseRegistration: + swagger_schema do + title("CourseRegistration") + description("information about the CourseRegistration") + properties do role( :string, "Role of the user. Can be 'Student', 'Staff', or 'Admin'", @@ -90,15 +219,9 @@ defmodule CadetWeb.UserController do story(Schema.ref(:UserStory), "Story to displayed to current user. ") - grade( - :integer, - "Amount of grade. Only provided for 'Student'. " <> - "Value will be 0 for non-students." - ) - - maxGrade( + maxXp( :integer, - "Total maximum grade achievable based on submitted assessments. " <> + "Total maximum xp achievable based on submitted assessments. " <> "Only provided for 'Student'" ) @@ -113,6 +236,41 @@ defmodule CadetWeb.UserController do ) end end, + CourseConfiguration: + swagger_schema do + title("Course Configuration") + + properties do + course_name(:string, "Course name", required: true) + course_short_name(:string, "Course module code", required: true) + viewable(:boolean, "Course viewability", required: true) + enable_game(:boolean, "Enable game", required: true) + enable_achievements(:boolean, "Enable achievements", required: true) + enable_sourcecast(:boolean, "Enable sourcecast", required: true) + source_chapter(:integer, "Source Chapter number from 1 to 4", required: true) + source_variant(Schema.ref(:SourceVariant), "Source Variant name", required: true) + module_help_text(:string, "Module help text", required: true) + assessment_types(:list, "Assessment Types", required: true) + end + + example(%{ + course_name: "Programming Methodology", + course_short_name: "CS1101S", + viewable: true, + enable_game: true, + enable_achievements: true, + enable_sourcecast: true, + source_chapter: 1, + source_variant: "default", + module_help_text: "Help text", + assessment_types: ["Missions", "Quests", "Paths", "Contests", "Others"] + }) + end, + SourceVariant: + swagger_schema do + type(:string) + enum([:default, :concurrent, :gpu, :lazy, "non-det", :wasm]) + end, UserStory: swagger_schema do properties do diff --git a/lib/cadet_web/helpers/view_helper.ex b/lib/cadet_web/helpers/view_helper.ex index 033ca8fae..39310ea73 100644 --- a/lib/cadet_web/helpers/view_helper.ex +++ b/lib/cadet_web/helpers/view_helper.ex @@ -3,8 +3,8 @@ defmodule CadetWeb.ViewHelper do Helper functions shared throughout views """ - defp build_staff(user) do - transform_map_for_view(user, [:name, :id]) + defp build_staff(course_reg) do + transform_map_for_view(course_reg, %{name: fn st -> st.user.name end, id: :id}) end def unsubmitted_by_builder(nil), do: nil diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index a66cd6abf..8383f371b 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -16,6 +16,10 @@ defmodule CadetWeb.Router do plug(Guardian.Plug.EnsureAuthenticated) end + pipeline :course do + plug(:assign_course) + end + pipeline :ensure_staff do plug(:ensure_role, [:staff, :admin]) end @@ -34,7 +38,6 @@ defmodule CadetWeb.Router do post("/auth/refresh", AuthController, :refresh) post("/auth/login", AuthController, :create) post("/auth/logout", AuthController, :logout) - get("/settings/sublanguage", SettingsController, :index) end scope "/v2", CadetWeb do @@ -46,10 +49,28 @@ defmodule CadetWeb.Router do get("/devices/:secret/mqtt_endpoint", DevicesController, :get_mqtt_endpoint) end - # Authenticated Pages + # Authenticated Pages without course scope "/v2", CadetWeb do pipe_through([:api, :auth, :ensure_auth]) + get("/user", UserController, :index) + get("/user/latest_viewed_course", UserController, :get_latest_viewed) + put("/user/latest_viewed_course", UserController, :update_latest_viewed) + + post("/config/create", CoursesController, :create) + + get("/devices", DevicesController, :index) + post("/devices", DevicesController, :register) + post("/devices/:id", DevicesController, :edit) + delete("/devices/:id", DevicesController, :deregister) + get("/devices/:id/ws_endpoint", DevicesController, :get_ws_endpoint) + end + + # Authenticated Pages with course + scope "/v2/courses/:course_id", CadetWeb do + pipe_through([:api, :auth, :ensure_auth, :course]) + + get("/sourcecast", SourcecastController, :index) resources("/sourcecast", SourcecastController, only: [:create, :delete]) get("/assessments", AssessmentsController, :index) @@ -68,27 +89,23 @@ defmodule CadetWeb.Router do get("/notifications", NotificationsController, :index) post("/notifications/acknowledge", NotificationsController, :acknowledge) - get("/user", UserController, :index) + get("/user", UserController, :get_course_reg) put("/user/game_states", UserController, :update_game_states) - get("/devices", DevicesController, :index) - post("/devices", DevicesController, :register) - post("/devices/:id", DevicesController, :edit) - delete("/devices/:id", DevicesController, :deregister) - get("/devices/:id/ws_endpoint", DevicesController, :get_ws_endpoint) + get("/config", CoursesController, :index) end # Authenticated Pages - scope "/v2/self", CadetWeb do - pipe_through([:api, :auth, :ensure_auth]) + scope "/v2/courses/:course_id/self", CadetWeb do + pipe_through([:api, :auth, :ensure_auth, :course]) get("/goals", IncentivesController, :index_goals) post("/goals/:uuid/progress", IncentivesController, :update_progress) end # Admin pages - scope "/v2/admin", CadetWeb do - pipe_through([:api, :auth, :ensure_auth, :ensure_staff]) + scope "/v2/courses/:course_id/admin", CadetWeb do + pipe_through([:api, :auth, :ensure_auth, :course, :ensure_staff]) get("/assets/:foldername", AdminAssetsController, :index) post("/assets/:foldername/*filename", AdminAssetsController, :upload) @@ -112,7 +129,10 @@ defmodule CadetWeb.Router do ) get("/users", AdminUserController, :index) - post("/users/:userid/goals/:uuid/progress", AdminGoalsController, :update_progress) + put("/users", AdminUserController, :upsert_users_and_groups) + put("/users/:course_reg_id/role", AdminUserController, :update_role) + delete("/users/:course_reg_id", AdminUserController, :delete_user) + post("/users/:course_reg_id/goals/:uuid/progress", AdminGoalsController, :update_progress) put("/achievements", AdminAchievementsController, :bulk_update) put("/achievements/:uuid", AdminAchievementsController, :update) @@ -123,7 +143,15 @@ defmodule CadetWeb.Router do put("/goals/:uuid", AdminGoalsController, :update) delete("/goals/:uuid", AdminGoalsController, :delete) - put("/settings/sublanguage", AdminSettingsController, :update) + put("/config", AdminCoursesController, :update_course_config) + get("/config/assessment_configs", AdminCoursesController, :get_assessment_configs) + put("/config/assessment_configs", AdminCoursesController, :update_assessment_configs) + + delete( + "/config/assessment_config/:assessment_config_id", + AdminCoursesController, + :delete_assessment_config + ) end # Other scopes may use custom stacks. @@ -156,8 +184,20 @@ defmodule CadetWeb.Router do get("/", DefaultController, :index) end + defp assign_course(conn, _opts) do + course_id = conn.path_params["course_id"] + + course_reg = + Cadet.Accounts.CourseRegistrations.get_user_record(conn.assigns.current_user.id, course_id) + + case course_reg do + nil -> conn |> send_resp(403, "Forbidden") |> halt() + cr -> assign(conn, :course_reg, cr) + end + end + defp ensure_role(conn, opts) do - if not is_nil(conn.assigns.current_user) and conn.assigns.current_user.role in opts do + if not is_nil(conn.assigns.current_user) and conn.assigns.course_reg.role in opts do conn else conn diff --git a/lib/cadet_web/views/assessments_helpers.ex b/lib/cadet_web/views/assessments_helpers.ex index 5fdb23282..f534a9f3b 100644 --- a/lib/cadet_web/views/assessments_helpers.ex +++ b/lib/cadet_web/views/assessments_helpers.ex @@ -4,8 +4,6 @@ defmodule CadetWeb.AssessmentsHelpers do """ import CadetWeb.ViewHelper - @graded_assessment_types ~w(mission sidequest contest) - defp build_library(%{library: library}) do transform_map_for_view(library, %{ chapter: :chapter, @@ -18,36 +16,26 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(external_library, [:name, :symbols]) end - def build_question_by_assessment_type( - %{ - question: question, - assessment_type: assessment_type - }, + def build_question_by_question_config( + %{question: question}, all_testcases? \\ false ) do Map.merge( build_generic_question_fields(%{question: question}), - build_question_content_by_type( - %{ - question: question, - assessment_type: assessment_type - }, + build_question_content_by_config( + %{question: question}, all_testcases? ) ) end - def build_question_with_answer_and_solution_if_ungraded(%{ - question: question, - assessment: assessment - }) do + def build_question_with_answer_and_solution_if_ungraded(%{question: question}) do components = [ - build_question_by_assessment_type(%{ - question: question, - assessment_type: assessment.type + build_question_by_question_config(%{ + question: question }), build_answer_fields_by_question_type(%{question: question}), - build_solution_if_ungraded_by_type(%{question: question, assessment: assessment}) + build_solution_if_ungraded_by_config(%{question: question}) ] components @@ -61,15 +49,14 @@ defmodule CadetWeb.AssessmentsHelpers do type: :type, library: &build_library(%{library: &1.library}), maxXp: :max_xp, - maxGrade: :max_grade + blocking: :blocking }) end - defp build_solution_if_ungraded_by_type(%{ - question: %{question: question, type: question_type}, - assessment: %{type: assessment_type} + defp build_solution_if_ungraded_by_config(%{ + question: %{question: question, type: question_type, show_solution: show_solution} }) do - if assessment_type not in @graded_assessment_types do + if show_solution do solution_getter = case question_type do :programming -> &Map.get(&1, "solution") @@ -98,7 +85,6 @@ defmodule CadetWeb.AssessmentsHelpers do grader: grader_builder(grader), gradedAt: graded_at_builder(grader), xp: &((&1.xp || 0) + (&1.xp_adjustment || 0)), - grade: &((&1.grade || 0) + (&1.adjustment || 0)), autogradingStatus: :autograding_status, autogradingResults: build_results(%{results: answer.autograding_results}), comments: :comments @@ -177,38 +163,29 @@ defmodule CadetWeb.AssessmentsHelpers do }) end - defp build_testcases(%{assessment_type: assessment_type}, all_testcases?) do - cond do - all_testcases? -> - &Enum.concat( - Enum.map(&1["public"], fn testcase -> build_testcase(testcase, "public") end), - Enum.map(&1["private"], fn testcase -> build_testcase(testcase, "private") end) - ) - - assessment_type == "path" -> - &Enum.concat( + defp build_testcases(all_testcases?) do + if all_testcases? do + &Enum.concat( + Enum.concat( Enum.map(&1["public"], fn testcase -> build_testcase(testcase, "public") end), - Enum.map(&1["private"], fn testcase -> build_testcase(testcase, "hidden") end) - ) - - true -> - &Enum.map(&1["public"], fn testcase -> build_testcase(testcase, "public") end) - end - end - - defp build_postpend(%{assessment_type: assessment_type}, all_testcases?) do - case {all_testcases?, assessment_type} do - {true, _} -> & &1["postpend"] - {_, "path"} -> & &1["postpend"] - # Create a 1-arity function to return an empty postpend for non-paths - _ -> fn _question -> "" end + Enum.map(&1["opaque"], fn testcase -> build_testcase(testcase, "opaque") end) + ), + Enum.map(&1["secret"], fn testcase -> build_testcase(testcase, "secret") end) + ) + else + &Enum.concat( + Enum.map(&1["public"], fn testcase -> build_testcase(testcase, "public") end), + Enum.map(&1["opaque"], fn testcase -> build_testcase(testcase, "opaque") end) + ) end end - defp build_question_content_by_type( + defp build_question_content_by_config( %{ - question: %{question: question, type: question_type}, - assessment_type: assessment_type + question: %{ + question: question, + type: question_type + } }, all_testcases? ) do @@ -218,8 +195,8 @@ defmodule CadetWeb.AssessmentsHelpers do content: "content", prepend: "prepend", solutionTemplate: "template", - postpend: build_postpend(%{assessment_type: assessment_type}, all_testcases?), - testcases: build_testcases(%{assessment_type: assessment_type}, all_testcases?) + postpend: "postpend", + testcases: build_testcases(all_testcases?) }) :mcq -> diff --git a/lib/cadet_web/views/assessments_view.ex b/lib/cadet_web/views/assessments_view.ex index 32f8fd897..20aad951f 100644 --- a/lib/cadet_web/views/assessments_view.ex +++ b/lib/cadet_web/views/assessments_view.ex @@ -11,19 +11,19 @@ defmodule CadetWeb.AssessmentsView do def render("overview.json", %{assessment: assessment}) do transform_map_for_view(assessment, %{ id: :id, + courseId: :course_id, title: :title, shortSummary: :summary_short, openAt: &format_datetime(&1.open_at), closeAt: &format_datetime(&1.close_at), - type: :type, + type: & &1.config.type, + isManuallyGraded: & &1.config.is_manually_graded, story: :story, number: :number, reading: :reading, status: &(&1.user_status || "not_attempted"), - maxGrade: :max_grade, maxXp: :max_xp, xp: &(&1.xp || 0), - grade: &(&1.grade || 0), coverImage: :cover_picture, private: &password_protected?(&1.password), isPublished: :is_published, @@ -37,8 +37,9 @@ defmodule CadetWeb.AssessmentsView do assessment, %{ id: :id, + courseId: :course_id, title: :title, - type: :type, + type: & &1.config.type, story: :story, number: :number, reading: :reading, @@ -46,10 +47,12 @@ defmodule CadetWeb.AssessmentsView do missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}), questions: &Enum.map(&1.questions, fn question -> - build_question_with_answer_and_solution_if_ungraded(%{ - question: question, - assessment: assessment - }) + map = + build_question_with_answer_and_solution_if_ungraded(%{ + question: question + }) + + map end) } ) diff --git a/lib/cadet_web/views/courses_view.ex b/lib/cadet_web/views/courses_view.ex new file mode 100644 index 000000000..285e78584 --- /dev/null +++ b/lib/cadet_web/views/courses_view.ex @@ -0,0 +1,21 @@ +defmodule CadetWeb.CoursesView do + use CadetWeb, :view + + def render("config.json", %{config: config}) do + %{ + config: + transform_map_for_view(config, %{ + courseName: :course_name, + courseShortName: :course_short_name, + viewable: :viewable, + enableGame: :enable_game, + enableAchievements: :enable_achievements, + enableSourcecast: :enable_sourcecast, + sourceChapter: :source_chapter, + sourceVariant: :source_variant, + moduleHelpText: :module_help_text, + assessmentTypes: :assessment_configs + }) + } + end +end diff --git a/lib/cadet_web/views/notifications_view.ex b/lib/cadet_web/views/notifications_view.ex index 09171d542..64c4a8cf0 100644 --- a/lib/cadet_web/views/notifications_view.ex +++ b/lib/cadet_web/views/notifications_view.ex @@ -11,11 +11,14 @@ defmodule CadetWeb.NotificationsView do type: :type, assessment_id: :assessment_id, submission_id: :submission_id, - assessment: - &transform_map_for_view(&1.assessment, %{ - type: :type, - title: :title - }) + assessment: &render_notification_assessment/1 + }) + end + + defp render_notification_assessment(notification) do + transform_map_for_view(notification.assessment, %{ + type: & &1.config.type, + title: :title }) end end diff --git a/lib/cadet_web/views/settings_view.ex b/lib/cadet_web/views/settings_view.ex deleted file mode 100644 index c812aebdc..000000000 --- a/lib/cadet_web/views/settings_view.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule CadetWeb.SettingsView do - use CadetWeb, :view - - def render("show.json", %{sublanguage: sublanguage}) do - %{ - sublanguage: transform_map_for_view(sublanguage, [:chapter, :variant]) - } - end -end diff --git a/lib/cadet_web/views/sourcecast_view.ex b/lib/cadet_web/views/sourcecast_view.ex index 767d00633..8c6af3e07 100644 --- a/lib/cadet_web/views/sourcecast_view.ex +++ b/lib/cadet_web/views/sourcecast_view.ex @@ -16,7 +16,8 @@ defmodule CadetWeb.SourcecastView do audio: :audio, playbackData: :playbackData, uploader: &transform_map_for_view(&1.uploader, [:name, :id]), - url: &Cadet.Courses.SourcecastUpload.url({&1.audio, &1}) + url: &Cadet.Courses.SourcecastUpload.url({&1.audio, &1}), + courseId: :course_id }) end end diff --git a/lib/cadet_web/views/stories_view.ex b/lib/cadet_web/views/stories_view.ex index cd3a02da4..00508e6c7 100644 --- a/lib/cadet_web/views/stories_view.ex +++ b/lib/cadet_web/views/stories_view.ex @@ -13,7 +13,8 @@ defmodule CadetWeb.StoriesView do imageUrl: :image_url, isPublished: :is_published, openAt: &format_datetime(&1.open_at), - closeAt: &format_datetime(&1.close_at) + closeAt: &format_datetime(&1.close_at), + courseId: :course_id }) end end diff --git a/lib/cadet_web/views/user_view.ex b/lib/cadet_web/views/user_view.ex index 553f00603..5308c2603 100644 --- a/lib/cadet_web/views/user_view.ex +++ b/lib/cadet_web/views/user_view.ex @@ -3,30 +3,127 @@ defmodule CadetWeb.UserView do def render("index.json", %{ user: user, - grade: grade, - max_grade: max_grade, + courses: courses, + latest: latest, + max_xp: max_xp, xp: xp, - story: story, - game_states: game_states + story: story }) do %{ - userId: user.id, - name: user.name, - role: user.role, - group: - case user.group do - nil -> nil - _ -> user.group.name - end, - grade: grade, - xp: xp, - maxGrade: max_grade, - story: - transform_map_for_view(story, %{ - story: :story, - playStory: :play_story? + user: %{ + userId: user.id, + name: user.name, + courses: render_many(courses, CadetWeb.UserView, "courses.json", as: :cr) + }, + courseRegistration: + render_latest(%{ + latest: latest, + max_xp: max_xp, + xp: xp, + story: story }), - gameStates: game_states + courseConfiguration: render_config(latest), + assessmentConfigurations: render_assessment_configs(latest) } end + + def render("course.json", %{ + latest: latest, + max_xp: max_xp, + xp: xp, + story: story + }) do + %{ + courseRegistration: + render_latest(%{ + latest: latest, + max_xp: max_xp, + xp: xp, + story: story + }), + courseConfiguration: render_config(latest), + assessmentConfigurations: render_assessment_configs(latest) + } + end + + def render("courses.json", %{cr: cr}) do + %{ + courseId: cr.course_id, + courseName: cr.course.course_name, + courseShortName: cr.course.course_short_name, + role: cr.role, + viewable: cr.course.viewable + } + end + + defp render_latest(%{ + latest: latest, + max_xp: max_xp, + xp: xp, + story: story + }) do + case latest do + nil -> + nil + + _ -> + %{ + courseRegId: latest.id, + courseId: latest.course_id, + role: latest.role, + group: + case latest.group do + nil -> nil + _ -> latest.group.name + end, + xp: xp, + maxXp: max_xp, + story: + transform_map_for_view(story, %{ + story: :story, + playStory: :play_story? + }), + gameStates: latest.game_states + } + end + end + + defp render_config(latest) do + case latest do + nil -> + nil + + _ -> + transform_map_for_view(latest.course, %{ + courseName: :course_name, + courseShortName: :course_short_name, + viewable: :viewable, + enableGame: :enable_game, + enableAchievements: :enable_achievements, + enableSourcecast: :enable_sourcecast, + sourceChapter: :source_chapter, + sourceVariant: :source_variant, + moduleHelpText: :module_help_text + }) + end + end + + defp render_assessment_configs(latest) do + case latest do + nil -> + nil + + latest -> + Enum.map(latest.course.assessment_config, fn config -> + transform_map_for_view(config, %{ + assessmentConfigId: :id, + type: :type, + displayInDashboard: :show_grading_summary, + isManuallyGraded: :is_manually_graded, + earlySubmissionXp: :early_submission_xp, + hoursBeforeEarlyXpDecay: :hours_before_early_xp_decay + }) + end) + end + end end diff --git a/lib/mix/tasks/users/import.ex b/lib/mix/tasks/users/import.ex deleted file mode 100644 index e49ed4c58..000000000 --- a/lib/mix/tasks/users/import.ex +++ /dev/null @@ -1,143 +0,0 @@ -defmodule Mix.Tasks.Cadet.Users.Import do - @moduledoc """ - Import user and grouping information from several csv files. - - To use this, you need to prepare 3 csv files: - 1. List of all the students together with their group names - 2. List of all the leaders together with their group names - 3. List of all the mentors together with their group names - - For all the files, they must be comma-separated csv and in this format: - - ``` - name,username,group_name - ``` - - (Username could be e.g. NUSNET ID) - - Note that group names must be unique. - """ - - @shortdoc "Import user and grouping information from csv files." - - use Mix.Task - - require Logger - - alias Cadet.{Accounts, Course, Repo} - alias Cadet.Courses.Group - alias Cadet.Accounts.User - - def run(_args) do - # Required for Ecto to work properly, from Mix.Ecto - if function_exported?(Mix.Task, :run, 2), do: Mix.Task.run("app.start") - - students_csv_path = trimmed_gets("Path to students csv (leave blank to skip): ") - leaders_csv_path = trimmed_gets("Path to leaders csv (leave blank to skip): ") - mentors_csv_path = trimmed_gets("Path to mentors csv (leave blank to skip): ") - - Repo.transaction(fn -> - students_csv_path != "" && process_students_csv(students_csv_path) - leaders_csv_path != "" && process_leaders_csv(leaders_csv_path) - mentors_csv_path != "" && process_mentors_csv(mentors_csv_path) - end) - end - - defp process_students_csv(path) when is_binary(path) do - if File.exists?(path) do - csv_stream = path |> File.stream!() |> CSV.decode(strip_fields: true) - - for {:ok, [name, username, group_name]} <- csv_stream do - with {:ok, group = %Group{}} <- Course.get_or_create_group(group_name), - {:ok, %User{}} <- - Accounts.insert_or_update_user(%{ - username: username, - name: name, - role: :student, - group: group - }) do - :ok - else - error -> - Logger.error( - "Unable to insert student (name: #{name}, username: #{username}, " <> - "group_name: #{group_name})" - ) - - Logger.error("error: #{inspect(error, pretty: true)}") - - Repo.rollback(error) - end - end - - Logger.info("Imported students csv at #{path}") - else - Logger.error("Cannot find students csv at #{path}") - end - end - - defp process_leaders_csv(path) when is_binary(path) do - if File.exists?(path) do - csv_stream = path |> File.stream!() |> CSV.decode(strip_fields: true) - - for {:ok, [name, username, group_name]} <- csv_stream do - with {:ok, leader = %User{}} <- - Accounts.insert_or_update_user(%{username: username, name: name, role: :staff}), - {:ok, %Group{}} <- - Course.insert_or_update_group(%{name: group_name, leader: leader}) do - :ok - else - error -> - Logger.error( - "Unable to insert leader (name: #{name}, username: #{username}, " <> - "group_name: #{group_name})" - ) - - Logger.error("error: #{inspect(error, pretty: true)}") - - Repo.rollback(error) - end - end - - Logger.info("Imported leaders csv at #{path}") - else - Logger.error("Cannot find leaders csv at #{path}") - end - end - - defp process_mentors_csv(path) when is_binary(path) do - if File.exists?(path) do - csv_stream = path |> File.stream!() |> CSV.decode(strip_fields: true) - - for {:ok, [name, username, group_name]} <- csv_stream do - with {:ok, mentor = %User{}} <- - Accounts.insert_or_update_user(%{username: username, name: name, role: :staff}), - {:ok, %Group{}} <- - Course.insert_or_update_group(%{name: group_name, mentor: mentor}) do - :ok - else - error -> - Logger.error( - "Unable to insert mentor (name: #{name}, username: #{username}, " <> - "group_name: #{group_name})" - ) - - Logger.error("error: #{inspect(error, pretty: true)}") - - Repo.rollback(error) - end - end - - Logger.info("Imported mentors csv at #{path}") - else - Logger.error("Cannot find mentors csv at #{path}") - end - end - - @spec trimmed_gets(String.t()) :: String.t() - defp trimmed_gets(prompt) when is_binary(prompt) do - prompt - |> IO.gets() - |> String.trim() - end -end diff --git a/priv/repo/migrations/20210531155751_add_course_configuration.exs b/priv/repo/migrations/20210531155751_add_course_configuration.exs deleted file mode 100644 index 8d0449a6b..000000000 --- a/priv/repo/migrations/20210531155751_add_course_configuration.exs +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Cadet.Repo.Migrations.AddCourseConfiguration do - use Ecto.Migration - - def change do - create table(:courses) do - add(:name, :string, null: false) - add(:module_code, :string) - add(:viewable, :boolean, null: false, default: true) - add(:enable_game, :boolean, null: false, default: true) - add(:enable_achievements, :boolean, null: false, default: true) - add(:enable_sourcecast, :boolean, null: false, default: true) - add(:source_chapter, :integer, null: false) - add(:source_variant, :string, null: false) - add(:module_help_text, :string) - timestamps() - end - - create table(:assessment_configs) do - add(:early_submission_xp, :integer, null: false, default: 200) - add(:days_before_early_xp_decay, :integer, null: false, default: 2) - add(:decay_rate_points_per_hour, :integer, null: false, default: 1) - add(:course_id, references(:courses), null: false) - timestamps() - end - - create table(:assessment_types) do - add(:order, :integer, null: false) - add(:type, :string, null: false) - add(:course_id, references(:courses), null: false) - timestamps() - end - end -end diff --git a/priv/repo/migrations/20210531155751_multitenant_upgrade.exs b/priv/repo/migrations/20210531155751_multitenant_upgrade.exs new file mode 100644 index 000000000..62c6385a9 --- /dev/null +++ b/priv/repo/migrations/20210531155751_multitenant_upgrade.exs @@ -0,0 +1,437 @@ +defmodule Cadet.Repo.Migrations.MultitenantUpgrade do + use Ecto.Migration + import Ecto.Query + + alias Cadet.Accounts.{CourseRegistration, Notification, Role, User} + alias Cadet.Assessments.{Answer, Assessment, Question, Submission, SubmissionVotes} + alias Cadet.Courses.{AssessmentConfig, Course, Group, Sourcecast} + alias Cadet.Repo + alias Cadet.Stories.Story + + def change do + # Tracks course configurations + create table(:courses) do + add(:course_name, :string, null: false) + add(:course_short_name, :string) + add(:viewable, :boolean, null: false, default: true) + add(:enable_game, :boolean, null: false, default: true) + add(:enable_achievements, :boolean, null: false, default: true) + add(:enable_sourcecast, :boolean, null: false, default: true) + add(:source_chapter, :integer, null: false) + add(:source_variant, :string, null: false) + add(:module_help_text, :string) + timestamps() + end + + # Tracks assessment configurations per assessment type in a course + create table(:assessment_configs) do + add(:order, :integer, null: true) + add(:type, :string, null: false) + add(:course_id, references(:courses), null: false) + add(:show_grading_summary, :boolean, null: false, default: true) + add(:is_manually_graded, :boolean, null: false, default: true) + add(:early_submission_xp, :integer, null: false) + add(:hours_before_early_xp_decay, :integer, null: false) + timestamps() + end + + # Tracks course registrations (many-to-many r/s between users and courses) + create table(:course_registrations) do + add(:role, :role, null: false) + add(:game_states, :map, default: %{}) + add(:group_id, references(:groups)) + add(:user_id, references(:users), null: false) + add(:course_id, references(:courses), null: false) + timestamps() + end + + # Enforce that users cannot be enrolled twice in a course + create( + unique_index(:course_registrations, [:user_id, :course_id], + name: :course_registrations_user_id_course_id_index + ) + ) + + # latest_viewed_id to track which course to load after the user logs in. + # name and username modifications to allow for names to be nullable as accounts can + # now be precreated by any course instructor by specifying the username used in the + # respective auth provider. + alter table(:users) do + add(:latest_viewed_id, references(:courses), null: true) + modify(:name, :string, null: true) + modify(:username, :string, null: false) + end + + # Prep for migration of leader_id from User entity to CourseRegistration entity. + # Also make groups associated with a course. + rename(table(:groups), :leader_id, to: :temp_leader_id) + drop(constraint(:groups, "groups_leader_id_fkey")) + drop(constraint(:groups, "groups_mentor_id_fkey")) + + alter table(:groups) do + remove(:mentor_id) + add(:leader_id, references(:course_registrations), null: true) + add(:course_id, references(:courses)) + end + + # Make assessments related to an assessment config and a course + alter table(:assessments) do + add(:config_id, references(:assessment_configs)) + add(:course_id, references(:courses)) + end + + drop(unique_index(:assessments, [:number])) + create(unique_index(:assessments, [:number, :course_id])) + + # Prep for migration of student_id and unsubmitted_by_id from User entity to CourseRegistration entity. + rename(table(:submissions), :student_id, to: :temp_student_id) + rename(table(:submissions), :unsubmitted_by_id, to: :temp_unsubmitted_by_id) + drop(constraint(:submissions, "submissions_student_id_fkey")) + drop(constraint(:submissions, "submissions_unsubmitted_by_id_fkey")) + + alter table(:submissions) do + add(:student_id, references(:course_registrations)) + add(:unsubmitted_by_id, references(:course_registrations)) + end + + alter table(:submission_votes) do + add(:voter_id, references(:course_registrations)) + end + + rename(table(:answers), :grader_id, to: :temp_grader_id) + drop(constraint(:answers, "answers_grader_id_fkey")) + + # Remove grade metric from backend + alter table(:answers) do + remove(:grade) + remove(:adjustment) + add(:grader_id, references(:course_registrations), null: true) + end + + alter table(:questions) do + remove(:max_grade) + add(:show_solution, :boolean, null: false, default: false) + add(:build_hidden_testcases, :boolean, null: false, default: false) + add(:blocking, :boolean, null: false, default: false) + end + + # Update notifications + alter table(:notifications) do + add(:course_reg_id, references(:course_registrations)) + end + + # Sourcecasts to be associated with a course + alter table(:sourcecasts) do + add(:course_id, references(:courses)) + end + + # Stories to be associated with a course + alter table(:stories) do + add(:course_id, references(:courses)) + end + + # Sublanguage is now being tracked under course configuration, and can be different depending on course + drop_if_exists(table(:sublanguages)) + + # Manual data entry and manipulation to migrate data from Source Academy Knight --> Rook. + # Note that in Knight, there was only 1 course running at a time, so it is okay to assume + # that all existing data belongs to that course. + execute( + fn -> + # Create the new course for migration + {:ok, course} = + %Course{} + |> Course.changeset(%{ + course_name: "CS1101S Programming Methodology (AY21/22 Sem 1)", + course_short_name: "CS1101S", + viewable: true, + enable_game: true, + enable_achievments: true, + enable_sourcecast: true, + source_chapter: 1, + source_variant: "default" + }) + |> Repo.insert() + + # Namespace existing usernames + from(u in "users", update: [set: [username: fragment("? || ? ", "luminus/", u.username)]]) + |> repo().update_all([]) + + # Create course registrations for existing users + from(u in "users", select: {u.id, u.role, u.group_id, u.game_states}) + |> Repo.all() + |> Enum.each(fn user -> + %CourseRegistration{} + |> CourseRegistration.changeset(%{ + user_id: elem(user, 0), + role: elem(user, 1), + group_id: elem(user, 2), + game_states: elem(user, 3), + course_id: course.id + }) + |> Repo.insert() + end) + + # Add latest_viewed_id to existing users + repo().update_all("users", set: [latest_viewed_id: course.id]) + + # Handle groups (adding course_id, and updating leader_id to course registrations) + from(g in "groups", select: {g.id, g.temp_leader_id}) + |> Repo.all() + |> Enum.each(fn group -> + leader_id = + case elem(group, 1) do + # leader_id is now going to be non-nullable. if it was previously nil, we will just + # assign a staff to be the leader_id during migration + nil -> + CourseRegistration + |> where([cr], cr.role in [:admin, :staff]) + |> Repo.one() + |> Map.fetch!(:id) + + id -> + CourseRegistration + |> where(user_id: ^id) + |> Repo.one() + |> Map.fetch!(:id) + end + + Group + |> where(id: ^elem(group, 0)) + |> Repo.one() + |> Group.changeset(%{leader_id: leader_id, course_id: course.id}) + |> Repo.update() + end) + + # Update existing Path questions with new question config + # The questions from other assessment types are not updated as these fields default to false + from(q in "questions", + join: a in "assessments", + on: a.id == q.assessment_id, + where: a.type == "path", + select: q.id + ) + |> Repo.all() + |> Enum.each(fn question_id -> + Question + |> Repo.get(question_id) + |> Question.changeset(%{ + show_solution: true, + build_hidden_testcases: true, + blocking: true + }) + |> Repo.update() + end) + + # Create Assessment Configurations based on Source Academy Knight + ["Missions", "Quests", "Paths", "Contests", "Others"] + |> Enum.with_index(1) + |> Enum.each(fn {assessment_type, idx} -> + %AssessmentConfig{} + |> AssessmentConfig.changeset(%{ + order: idx, + type: assessment_type, + course_id: course.id, + show_grading_summary: assessment_type in ["Missions", "Quests"], + is_manually_graded: assessment_type != "Paths", + early_submission_xp: 200, + hours_before_early_xp_decay: 48 + }) + |> Repo.insert() + end) + + # Link existing assessments to an assessment config and course + from(a in "assessments", select: {a.id, a.type}) + |> Repo.all() + |> Enum.each(fn assessment -> + assessment_type = + case elem(assessment, 1) do + "mission" -> "Missions" + "sidequest" -> "Quests" + "path" -> "Paths" + "contest" -> "Contests" + "practical" -> "Others" + end + + assessment_config = + AssessmentConfig + |> where(type: ^assessment_type) + |> Repo.one() + + Assessment + |> where(id: ^elem(assessment, 0)) + |> Repo.one() + |> Assessment.changeset(%{config_id: assessment_config.id, course_id: course.id}) + |> Repo.update() + end) + + # Updating student_id and unsubmitted_by_id from User to CourseRegistration + from(s in "submissions", select: {s.id, s.temp_student_id, s.temp_unsubmitted_by_id}) + |> Repo.all() + |> Enum.each(fn submission -> + student_id = + CourseRegistration + |> where(user_id: ^elem(submission, 1)) + |> Repo.one() + |> Map.fetch!(:id) + + unsubmitted_by_id = + case elem(submission, 2) do + nil -> + nil + + id -> + CourseRegistration + |> where(user_id: ^id) + |> Repo.one() + |> Map.fetch!(:id) + end + + Submission + |> where(id: ^elem(submission, 0)) + |> Repo.one() + |> Submission.changeset(%{student_id: student_id, unsubmitted_by_id: unsubmitted_by_id}) + |> Repo.update() + end) + + from(a in "answers", select: {a.id, a.temp_grader_id}) + |> Repo.all() + |> Enum.each(fn answer -> + case elem(answer, 1) do + nil -> + nil + + user_id -> + grader_id = + CourseRegistration + |> where(user_id: ^user_id) + |> Repo.one() + |> Map.fetch!(:id) + + Answer + |> where(id: ^elem(answer, 0)) + |> Repo.one() + |> Answer.grading_changeset(%{grader_id: grader_id}) + |> Repo.update() + end + end) + + from(s in "submission_votes", select: {s.id, s.user_id}) + |> Repo.all() + |> Enum.each(fn vote -> + voter_id = + CourseRegistration + |> where(user_id: ^elem(vote, 1)) + |> Repo.one() + |> Map.fetch!(:id) + + SubmissionVotes + |> where(id: ^elem(vote, 0)) + |> Repo.one() + |> SubmissionVotes.changeset(%{voter_id: voter_id}) + |> Repo.update() + end) + + from(n in "notifications", select: {n.id, n.user_id}) + |> Repo.all() + |> Enum.each(fn notification -> + course_reg_id = + CourseRegistration + |> where(user_id: ^elem(notification, 1)) + |> Repo.one() + |> Map.fetch!(:id) + + Notification + |> where(id: ^elem(notification, 0)) + |> Repo.one() + |> Notification.changeset(%{read: true, course_reg_id: course_reg_id}) + |> Repo.update() + end) + + # Add course id to all Sourcecasts + Sourcecast + |> Repo.all() + |> Enum.each(fn x -> + x + |> Sourcecast.changeset(%{course_id: course.id}) + |> Repo.update() + end) + + # Add course id to all Stories + Story + |> Repo.all() + |> Enum.each(fn x -> + x + |> Story.changeset(%{course_id: course.id}) + |> Repo.update() + end) + end, + fn -> nil end + ) + + # Cleanup users table after data migration + alter table(:users) do + remove(:role) + remove(:group_id) + remove(:game_states) + end + + # Cleanup groups table, and make course_id and leader_id non-nullable + alter table(:groups) do + remove(:temp_leader_id) + + modify(:course_id, references(:courses), null: false, from: references(:courses)) + end + + create(unique_index(:groups, [:name, :course_id])) + + # Cleanup assessments table, and make config_id and course_id non-nullable + alter table(:assessments) do + remove(:type) + modify(:config_id, references(:assessment_configs), null: false, from: references(:courses)) + modify(:course_id, references(:courses), null: false, from: references(:courses)) + end + + alter table(:submissions) do + remove(:temp_student_id) + remove(:temp_unsubmitted_by_id) + + modify(:student_id, references(:course_registrations), + null: false, + from: references(:course_registrations) + ) + end + + alter table(:answers) do + remove(:temp_grader_id) + end + + create(index(:submissions, :student_id)) + create(unique_index(:submissions, [:assessment_id, :student_id])) + + alter table(:submission_votes) do + remove(:user_id) + + modify(:voter_id, references(:course_registrations), + null: false, + from: references(:course_registrations) + ) + end + + create(unique_index(:submission_votes, [:voter_id, :question_id, :rank], name: :unique_score)) + + alter table(:notifications) do + remove(:user_id) + + modify(:course_reg_id, references(:course_registrations), + null: false, + from: references(:course_registrations) + ) + end + + # Set course_id to be non-nullable + alter table(:stories) do + modify(:course_id, references(:courses), null: false, from: references(:courses)) + end + end +end diff --git a/priv/repo/migrations/20210713113032_update_testcase_format.exs b/priv/repo/migrations/20210713113032_update_testcase_format.exs new file mode 100644 index 000000000..0fb57f0e2 --- /dev/null +++ b/priv/repo/migrations/20210713113032_update_testcase_format.exs @@ -0,0 +1,17 @@ +defmodule Cadet.Repo.Migrations.UpdateTestcaseFormat do + use Ecto.Migration + + def change do + execute( + "update questions set question = (question - 'private' || jsonb_build_object('opaque', question->'private', 'secret', '[]'::jsonb)) where type = 'programming' and build_hidden_testcases;" + ) + + execute( + "update questions set question = (question - 'private' || jsonb_build_object('secret', question->'private', 'opaque', '[]'::jsonb)) where type = 'programming' and not build_hidden_testcases;" + ) + + alter table(:questions) do + remove(:build_hidden_testcases) + end + end +end diff --git a/priv/repo/migrations/20210716073359_update_achievement.exs b/priv/repo/migrations/20210716073359_update_achievement.exs new file mode 100644 index 000000000..14e851b55 --- /dev/null +++ b/priv/repo/migrations/20210716073359_update_achievement.exs @@ -0,0 +1,46 @@ +defmodule Cadet.Repo.Migrations.UpdateAchievement do + use Ecto.Migration + import Ecto.Query, only: [from: 2, where: 2] + + def change do + alter table(:achievements) do + add(:course_id, references(:courses), null: true) + end + + alter table(:goals) do + add(:course_id, references(:courses), null: true) + end + + alter table(:goal_progress) do + add(:course_reg_id, references(:course_registrations), null: true) + end + + execute(fn -> + courses = from(c in "courses", select: {c.id}) |> repo().all() + course_id = courses |> Enum.at(0) |> elem(0) + repo().update_all("achievements", set: [course_id: course_id]) + repo().update_all("goals", set: [course_id: course_id]) + end) + + execute( + "update goal_progress gp set course_reg_id = (select cr.id from course_registrations cr where cr.user_id = gp.user_id)" + ) + + alter table(:achievements) do + modify(:course_id, references(:courses), null: false, from: references(:courses)) + end + + alter table(:goals) do + modify(:course_id, references(:courses), null: false, from: references(:courses)) + end + + alter table(:goal_progress) do + remove(:user_id) + + modify(:course_reg_id, references(:course_registrations), + null: false, + from: references(:course_registrations) + ) + end + end +end diff --git a/priv/repo/migrations/20210719091011_rename_latest_viewed_id.exs b/priv/repo/migrations/20210719091011_rename_latest_viewed_id.exs new file mode 100644 index 000000000..33d6ac29a --- /dev/null +++ b/priv/repo/migrations/20210719091011_rename_latest_viewed_id.exs @@ -0,0 +1,7 @@ +defmodule Cadet.Repo.Migrations.RenameLatestViewedId do + use Ecto.Migration + + def change do + rename(table(:users), :latest_viewed_id, to: :latest_viewed_course_id) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 71375a4ff..7c65ed19d 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -14,31 +14,60 @@ import Cadet.Factory alias Cadet.Assessments.SubmissionStatus # insert default source version -Cadet.Repo.insert!(%Cadet.Settings.Sublanguage{chapter: 1, variant: "default"}) +# Cadet.Repo.insert!(%Cadet.Settings.Sublanguage{chapter: 1, variant: "default"}) if Cadet.Env.env() == :dev do - # User and Group - avenger = insert(:user, %{name: "avenger", role: :staff}) - mentor = insert(:user, %{name: "mentor", role: :staff}) - group = insert(:group, %{leader: avenger, mentor: mentor}) - students = insert_list(5, :student, %{group: group}) - admin = insert(:user, %{name: "admin", role: :admin}) + # Course + course1 = insert(:course) + course2 = insert(:course, %{course_name: "Algorithm", course_short_name: "CS2040S"}) + # Users + avenger1 = insert(:user, %{name: "avenger", latest_viewed_course: course1}) + admin1 = insert(:user, %{name: "admin", latest_viewed_course: course1}) + + studenta1admin2 = insert(:user, %{name: "student a", latest_viewed_course: course1}) + + studentb1 = insert(:user, %{latest_viewed_course: course1}) + studentc1 = insert(:user, %{latest_viewed_course: course1}) + # CourseRegistration and Group + avenger1_cr = insert(:course_registration, %{user: avenger1, course: course1, role: :staff}) + admin1_cr = insert(:course_registration, %{user: admin1, course: course1, role: :admin}) + group = insert(:group, %{leader: avenger1_cr}) + + student1a_cr = + insert(:course_registration, %{ + user: studenta1admin2, + course: course1, + role: :student, + group: group + }) + + student1b_cr = + insert(:course_registration, %{user: studentb1, course: course1, role: :student, group: group}) + + student1c_cr = + insert(:course_registration, %{user: studentc1, course: course1, role: :student, group: group}) + + students = [student1a_cr, student1b_cr, student1c_cr] + + admin2cr = insert(:course_registration, %{user: studenta1admin2, course: course2, role: :admin}) # Assessments - for _ <- 1..5 do - assessment = insert(:assessment, %{is_published: true}) + for i <- 1..5 do + config = insert(:assessment_config, %{type: "Mission#{i}", order: i, course: course1}) + assessment = insert(:assessment, %{is_published: true, config: config, course: course1}) + + config2 = insert(:assessment_config, %{type: "Homework#{i}", order: i, course: course2}) + assessment2 = insert(:assessment, %{is_published: true, config: config2, course: course2}) programming_questions = insert_list(3, :programming_question, %{ assessment: assessment, - max_grade: 200, max_xp: 1_000 }) mcq_questions = insert_list(3, :mcq_question, %{ assessment: assessment, - max_grade: 40, max_xp: 500 }) @@ -57,7 +86,6 @@ if Cadet.Env.env() == :dev do for submission <- submissions, question <- programming_questions do insert(:answer, %{ - grade: Enum.random(0..200), xp: Enum.random(0..1_000), question: question, submission: submission, @@ -69,7 +97,6 @@ if Cadet.Env.env() == :dev do for submission <- submissions, question <- mcq_questions do insert(:answer, %{ - grade: Enum.random(0..40), xp: Enum.random(0..500), question: question, submission: submission, @@ -77,332 +104,332 @@ if Cadet.Env.env() == :dev do }) end - # Notifications - for submission <- submissions do - case submission.status do - :submitted -> - insert(:notification, %{ - type: :submitted, - read: false, - user_id: avenger.id, - submission_id: submission.id, - assessment_id: assessment.id - }) - - _ -> - nil - end - end - - for student <- students do - insert(:notification, %{ - type: :new, - user_id: student.id, - assessment_id: assessment.id - }) - end + # # Notifications + # for submission <- submissions do + # case submission.status do + # :submitted -> + # insert(:notification, %{ + # type: :submitted, + # read: false, + # user_id: avenger.id, + # submission_id: submission.id, + # assessment_id: assessment.id + # }) + + # _ -> + # nil + # end + # end + + # for student <- students do + # insert(:notification, %{ + # type: :new, + # user_id: student.id, + # assessment_id: assessment.id + # }) + # end end - goal_0 = - insert(:goal, %{ - text: "Complete Beyond the Second Dimension achievement", - max_xp: 250 - }) - - goal_1 = - insert(:goal, %{ - text: "Complete Colorful Carpet achievement", - max_xp: 250 - }) - - goal_2 = - insert(:goal, %{ - text: "Bonus for completing Rune Master achievement", - max_xp: 250 - }) - - goal_3 = - insert(:goal, %{ - text: "Complete Beyond the Second Dimension mission", - max_xp: 100 - }) - - goal_4 = - insert(:goal, %{ - text: "Score earned from Beyond the Second Dimension mission", - max_xp: 150 - }) - - goal_5 = - insert(:goal, %{ - text: "Complete Colorful Carpet mission", - max_xp: 100 - }) - - goal_6 = - insert(:goal, %{ - text: "Score earned from Colorful Carpet mission", - max_xp: 150 - }) - - goal_7 = - insert(:goal, %{ - text: "Complete Curve Introduction mission", - max_xp: 250 - }) - - goal_8 = - insert(:goal, %{ - text: "Complete Curve Manipulation mission", - max_xp: 250 - }) - - goal_9 = - insert(:goal, %{ - text: "Bonus for completing Curve Wizard achievement", - max_xp: 100 - }) - - goal_10 = - insert(:goal, %{ - text: "Complete Curve Introduction mission", - max_xp: 50 - }) - - goal_11 = - insert(:goal, %{ - text: "Score earned from Curve Introduction mission", - max_xp: 200 - }) - - goal_12 = - insert(:goal, %{ - text: "Complete Curve Manipulation mission", - max_xp: 50 - }) - - goal_13 = - insert(:goal, %{ - text: "Score earned from Curve Manipulation mission", - max_xp: 200 - }) - - goal_14 = - insert(:goal, %{ - text: "Complete Source 3 path", - max_xp: 100 - }) - - goal_15 = - insert(:goal, %{ - text: "Score earned from Source 3 path", - max_xp: 300 - }) - - goal_16 = - insert(:goal, %{ - text: "Complete Piazza Guru achievement", - max_xp: 100 - }) - - goal_17 = - insert(:goal, %{ - text: "Each Top Voted answer in Piazza gives 10 XP", - max_xp: 100 - }) - - goal_18 = - insert(:goal, %{ - text: "Submit 1 PR to Source Academy Github", - max_xp: 100 - }) - - # Achievements - achievement_0 = - insert(:achievement, %{ - title: "Rune Master", - ability: "Core", - is_task: true, - position: 1, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/rune-master-tile.png", - goals: [ - %{goal_uuid: goal_0.uuid}, - %{goal_uuid: goal_1.uuid}, - %{goal_uuid: goal_2.uuid} - ] - }) - - achievement_1 = - insert(:achievement, %{ - title: "Beyond the Second Dimension", - ability: "Core", - is_task: false, - position: 0, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/btsd-tile.png", - open_at: ~U[2020-07-16 16:00:00Z], - close_at: ~U[2020-07-20 16:00:00Z], - goals: [ - %{goal_uuid: goal_3.uuid}, - %{goal_uuid: goal_4.uuid} - ] - }) - - achievement_2 = - insert(:achievement, %{ - title: "Colorful Carpet", - ability: "Core", - is_task: false, - position: 0, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/colorful-carpet-tile.png", - open_at: ~U[2020-07-11 16:00:00Z], - close_at: ~U[2020-07-15 16:00:00Z], - goals: [ - %{goal_uuid: goal_5.uuid}, - %{goal_uuid: goal_6.uuid} - ] - }) - - achievement_3 = - insert(:achievement, %{ - title: "Unpublished", - ability: "Core", - is_task: false, - position: 0, - card_tile_url: - "https://www.publicdomainpictures.net/pictures/30000/velka/plain-white-background.jpg" - }) - - achievement_4 = - insert(:achievement, %{ - title: "Curve Wizard", - ability: "Core", - is_task: true, - position: 4, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-wizard-tile.png", - open_at: ~U[2020-07-31 16:00:00Z], - close_at: ~U[2020-08-04 16:00:00Z], - goals: [ - %{goal_uuid: goal_7.uuid}, - %{goal_uuid: goal_8.uuid}, - %{goal_uuid: goal_9.uuid} - ] - }) - - achievement_5 = - insert(:achievement, %{ - title: "Curve Introduction", - ability: "Core", - is_task: false, - position: 0, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-introduction-tile.png", - open_at: ~U[2020-07-23 16:00:00Z], - close_at: ~U[2020-07-27 16:00:00Z], - goals: [ - %{goal_uuid: goal_10.uuid}, - %{goal_uuid: goal_11.uuid} - ] - }) - - achievement_6 = - insert(:achievement, %{ - title: "Curve Manipulation", - ability: "Core", - is_task: false, - position: 0, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-manipulation-tile.png", - open_at: ~U[2020-07-31 16:00:00Z], - close_at: ~U[2020-08-04 16:00:00Z], - goals: [ - %{goal_uuid: goal_12.uuid}, - %{goal_uuid: goal_13.uuid} - ] - }) - - achievement_7 = - insert(:achievement, %{ - title: "The Source-rer", - ability: "Effort", - is_task: true, - position: 3, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/the-source-rer-tile.png", - open_at: ~U[2020-07-16 16:00:00Z], - close_at: ~U[2020-07-20 16:00:00Z], - goals: [ - %{goal_uuid: goal_14.uuid}, - %{goal_uuid: goal_15.uuid} - ] - }) - - achievement_8 = - insert(:achievement, %{ - title: "Power of Friendship", - ability: "Community", - is_task: true, - position: 2, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/power-of-friendship-tile.png", - open_at: ~U[2020-07-16 16:00:00Z], - close_at: ~U[2020-07-20 16:00:00Z], - goals: [ - %{goal_uuid: goal_16.uuid} - ] - }) - - achievement_9 = - insert(:achievement, %{ - title: "Piazza Guru", - ability: "Community", - is_task: false, - position: 0, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/piazza-guru-tile.png", - goals: [ - %{goal_uuid: goal_17.uuid} - ] - }) - - achievement_10 = - insert(:achievement, %{ - title: "Thats the Spirit", - ability: "Exploration", - is_task: true, - position: 5, - card_tile_url: - "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/annotated-tile.png", - goals: [ - %{goal_uuid: goal_18.uuid} - ] - }) - - insert(:achievement_prerequisite, %{ - prerequisite_uuid: achievement_9.uuid, - achievement_uuid: achievement_8.uuid - }) - - insert(:achievement_prerequisite, %{ - prerequisite_uuid: achievement_5.uuid, - achievement_uuid: achievement_4.uuid - }) - - insert(:achievement_prerequisite, %{ - prerequisite_uuid: achievement_6.uuid, - achievement_uuid: achievement_4.uuid - }) - - insert(:achievement_prerequisite, %{ - prerequisite_uuid: achievement_1.uuid, - achievement_uuid: achievement_0.uuid - }) - - insert(:achievement_prerequisite, %{ - prerequisite_uuid: achievement_2.uuid, - achievement_uuid: achievement_0.uuid - }) + # goal_0 = + # insert(:goal, %{ + # text: "Complete Beyond the Second Dimension achievement", + # max_xp: 250 + # }) + + # goal_1 = + # insert(:goal, %{ + # text: "Complete Colorful Carpet achievement", + # max_xp: 250 + # }) + + # goal_2 = + # insert(:goal, %{ + # text: "Bonus for completing Rune Master achievement", + # max_xp: 250 + # }) + + # goal_3 = + # insert(:goal, %{ + # text: "Complete Beyond the Second Dimension mission", + # max_xp: 100 + # }) + + # goal_4 = + # insert(:goal, %{ + # text: "Score earned from Beyond the Second Dimension mission", + # max_xp: 150 + # }) + + # goal_5 = + # insert(:goal, %{ + # text: "Complete Colorful Carpet mission", + # max_xp: 100 + # }) + + # goal_6 = + # insert(:goal, %{ + # text: "Score earned from Colorful Carpet mission", + # max_xp: 150 + # }) + + # goal_7 = + # insert(:goal, %{ + # text: "Complete Curve Introduction mission", + # max_xp: 250 + # }) + + # goal_8 = + # insert(:goal, %{ + # text: "Complete Curve Manipulation mission", + # max_xp: 250 + # }) + + # goal_9 = + # insert(:goal, %{ + # text: "Bonus for completing Curve Wizard achievement", + # max_xp: 100 + # }) + + # goal_10 = + # insert(:goal, %{ + # text: "Complete Curve Introduction mission", + # max_xp: 50 + # }) + + # goal_11 = + # insert(:goal, %{ + # text: "Score earned from Curve Introduction mission", + # max_xp: 200 + # }) + + # goal_12 = + # insert(:goal, %{ + # text: "Complete Curve Manipulation mission", + # max_xp: 50 + # }) + + # goal_13 = + # insert(:goal, %{ + # text: "Score earned from Curve Manipulation mission", + # max_xp: 200 + # }) + + # goal_14 = + # insert(:goal, %{ + # text: "Complete Source 3 path", + # max_xp: 100 + # }) + + # goal_15 = + # insert(:goal, %{ + # text: "Score earned from Source 3 path", + # max_xp: 300 + # }) + + # goal_16 = + # insert(:goal, %{ + # text: "Complete Piazza Guru achievement", + # max_xp: 100 + # }) + + # goal_17 = + # insert(:goal, %{ + # text: "Each Top Voted answer in Piazza gives 10 XP", + # max_xp: 100 + # }) + + # goal_18 = + # insert(:goal, %{ + # text: "Submit 1 PR to Source Academy Github", + # max_xp: 100 + # }) + + # # Achievements + # achievement_0 = + # insert(:achievement, %{ + # title: "Rune Master", + # ability: "Core", + # is_task: true, + # position: 1, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/rune-master-tile.png", + # goals: [ + # %{goal_uuid: goal_0.uuid}, + # %{goal_uuid: goal_1.uuid}, + # %{goal_uuid: goal_2.uuid} + # ] + # }) + + # achievement_1 = + # insert(:achievement, %{ + # title: "Beyond the Second Dimension", + # ability: "Core", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/btsd-tile.png", + # open_at: ~U[2020-07-16 16:00:00Z], + # close_at: ~U[2020-07-20 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_3.uuid}, + # %{goal_uuid: goal_4.uuid} + # ] + # }) + + # achievement_2 = + # insert(:achievement, %{ + # title: "Colorful Carpet", + # ability: "Core", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/colorful-carpet-tile.png", + # open_at: ~U[2020-07-11 16:00:00Z], + # close_at: ~U[2020-07-15 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_5.uuid}, + # %{goal_uuid: goal_6.uuid} + # ] + # }) + + # achievement_3 = + # insert(:achievement, %{ + # title: "Unpublished", + # ability: "Core", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://www.publicdomainpictures.net/pictures/30000/velka/plain-white-background.jpg" + # }) + + # achievement_4 = + # insert(:achievement, %{ + # title: "Curve Wizard", + # ability: "Core", + # is_task: true, + # position: 4, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-wizard-tile.png", + # open_at: ~U[2020-07-31 16:00:00Z], + # close_at: ~U[2020-08-04 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_7.uuid}, + # %{goal_uuid: goal_8.uuid}, + # %{goal_uuid: goal_9.uuid} + # ] + # }) + + # achievement_5 = + # insert(:achievement, %{ + # title: "Curve Introduction", + # ability: "Core", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-introduction-tile.png", + # open_at: ~U[2020-07-23 16:00:00Z], + # close_at: ~U[2020-07-27 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_10.uuid}, + # %{goal_uuid: goal_11.uuid} + # ] + # }) + + # achievement_6 = + # insert(:achievement, %{ + # title: "Curve Manipulation", + # ability: "Core", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/curve-manipulation-tile.png", + # open_at: ~U[2020-07-31 16:00:00Z], + # close_at: ~U[2020-08-04 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_12.uuid}, + # %{goal_uuid: goal_13.uuid} + # ] + # }) + + # achievement_7 = + # insert(:achievement, %{ + # title: "The Source-rer", + # ability: "Effort", + # is_task: true, + # position: 3, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/the-source-rer-tile.png", + # open_at: ~U[2020-07-16 16:00:00Z], + # close_at: ~U[2020-07-20 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_14.uuid}, + # %{goal_uuid: goal_15.uuid} + # ] + # }) + + # achievement_8 = + # insert(:achievement, %{ + # title: "Power of Friendship", + # ability: "Community", + # is_task: true, + # position: 2, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/power-of-friendship-tile.png", + # open_at: ~U[2020-07-16 16:00:00Z], + # close_at: ~U[2020-07-20 16:00:00Z], + # goals: [ + # %{goal_uuid: goal_16.uuid} + # ] + # }) + + # achievement_9 = + # insert(:achievement, %{ + # title: "Piazza Guru", + # ability: "Community", + # is_task: false, + # position: 0, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/piazza-guru-tile.png", + # goals: [ + # %{goal_uuid: goal_17.uuid} + # ] + # }) + + # achievement_10 = + # insert(:achievement, %{ + # title: "Thats the Spirit", + # ability: "Exploration", + # is_task: true, + # position: 5, + # card_tile_url: + # "https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement/card-tile/annotated-tile.png", + # goals: [ + # %{goal_uuid: goal_18.uuid} + # ] + # }) + + # insert(:achievement_prerequisite, %{ + # prerequisite_uuid: achievement_9.uuid, + # achievement_uuid: achievement_8.uuid + # }) + + # insert(:achievement_prerequisite, %{ + # prerequisite_uuid: achievement_5.uuid, + # achievement_uuid: achievement_4.uuid + # }) + + # insert(:achievement_prerequisite, %{ + # prerequisite_uuid: achievement_6.uuid, + # achievement_uuid: achievement_4.uuid + # }) + + # insert(:achievement_prerequisite, %{ + # prerequisite_uuid: achievement_1.uuid, + # achievement_uuid: achievement_0.uuid + # }) + + # insert(:achievement_prerequisite, %{ + # prerequisite_uuid: achievement_2.uuid, + # achievement_uuid: achievement_0.uuid + # }) end diff --git a/test/cadet/accounts/accounts_test.exs b/test/cadet/accounts/accounts_test.exs index 956ea40c0..65434a69e 100644 --- a/test/cadet/accounts/accounts_test.exs +++ b/test/cadet/accounts/accounts_test.exs @@ -9,36 +9,16 @@ defmodule Cadet.AccountsTest do alias Cadet.{Accounts, Repo} alias Cadet.Accounts.{Query, User} - import Mock + # import Mock setup_all do HTTPoison.start() end - test "create user" do - {:ok, user} = - Accounts.create_user(%{ - name: "happy user", - role: :student - }) - - assert %{name: "happy user", role: :student} = user - end - - test "invalid create user" do - {:error, changeset} = - Accounts.create_user(%{ - name: "happy user", - role: :unknown - }) - - assert %{role: ["is invalid"]} = errors_on(changeset) - end - test "get existing user" do - user = insert(:user, name: "Teddy", role: :student) + user = insert(:user, name: "Teddy") result = Accounts.get_user(user.id) - assert %{name: "Teddy", role: :student} = result + assert %{name: "Teddy"} = result end test "get unknown user" do @@ -51,14 +31,28 @@ defmodule Cadet.AccountsTest do username: "e0123456" } - assert {:ok, user} = Accounts.register(attrs, :student) - assert %{name: "Test Name", role: :student} = user + assert {:ok, user} = Accounts.register(attrs) + assert %{name: "Test Name"} = user end describe "sign in using auth provider" do test "unregistered user" do {:ok, _user} = Accounts.sign_in("student", "student_token", "test") - assert Repo.one(Query.username("student")).username == "student" + user = Repo.one(Query.username("student")) + assert user.username == "student" + + # as set in config/test.exs + assert user.name == "student 1" + end + + test "pre-created user during first login" do + insert(:user, %{username: "student", name: nil}) + {:ok, _user} = Accounts.sign_in("student", "student_token", "test") + user = Repo.one(Query.username("student")) + assert user.username == "student" + + # as set in config/test.exs + assert user.name == "student 1" end test "registered user" do @@ -75,29 +69,29 @@ defmodule Cadet.AccountsTest do Accounts.sign_in("student", "invalid_token", "test") end - test_with_mock "upstream error", Cadet.Auth.Provider, - get_role: fn _, _ -> {:error, :upstream, "Upstream error"} end do - assert {:error, :bad_request, "Upstream error"} == - Accounts.sign_in("student", "student_token", "test") - end + # test_with_mock "upstream error", Cadet.Auth.Provider, + # get_role: fn _, _ -> {:error, :upstream, "Upstream error"} end do + # assert {:error, :bad_request, "Upstream error"} == + # Accounts.sign_in("student", "student_token", "test") + # end end - describe "sign in with unregistered user gets the right roles" do - test ~s(user has admin access) do - assert {:ok, user} = Accounts.sign_in("admin", "admin_token", "test") - assert %{role: :admin} = user - end + # describe "sign in with unregistered user gets the right roles" do + # test ~s(user has admin access) do + # assert {:ok, user} = Accounts.sign_in("admin", "admin_token", "test") + # assert %{role: :admin} = user + # end - test ~s(user has staff access) do - assert {:ok, user} = Accounts.sign_in("staff", "staff_token", "test") - assert %{role: :staff} = user - end + # test ~s(user has staff access) do + # assert {:ok, user} = Accounts.sign_in("staff", "staff_token", "test") + # assert %{role: :staff} = user + # end - test ~s(user has student access) do - assert {:ok, user} = Accounts.sign_in("student", "student_token", "test") - assert %{role: :student} = user - end - end + # test ~s(user has student access) do + # assert {:ok, user} = Accounts.sign_in("student", "student_token", "test") + # assert %{role: :student} = user + # end + # end describe "insert_or_update_user" do test "existing user" do @@ -112,7 +106,6 @@ defmodule Cadet.AccountsTest do assert updated_user.id == user.id assert updated_user.name == user_params.name - assert updated_user.role == user_params.role end test "non-existing user" do @@ -125,7 +118,55 @@ defmodule Cadet.AccountsTest do |> Repo.one() assert updated_user.name == user_params.name - assert updated_user.role == user_params.role + end + end + + describe "get_users_by" do + setup do + c1 = insert(:course, %{course_name: "c1"}) + c2 = insert(:course, %{course_name: "c2"}) + admin1 = insert(:course_registration, %{course: c1, role: :admin}) + admin2 = insert(:course_registration, %{course: c2, role: :admin}) + g1 = insert(:group, %{course: c1}) + g2 = insert(:group, %{course: c1}) + insert(:course_registration, %{course: c1, group: g1, role: :student}) + insert(:course_registration, %{course: c1, group: g1, role: :student}) + + {:ok, %{c1: c1, c2: c2, a1: admin1, a2: admin2, g1: g1, g2: g2}} + end + + test "get all users in a course", %{a1: admin1, a2: admin2} do + all_in_c1 = Accounts.get_users_by([], admin1) + assert length(all_in_c1) == 3 + all_in_c2 = Accounts.get_users_by([], admin2) + assert length(all_in_c2) == 1 + end + + test "get all students in a course", %{a1: admin1, a2: admin2} do + all_stu_in_c1 = Accounts.get_users_by([role: :student], admin1) + assert length(all_stu_in_c1) == 2 + all_stu_in_c2 = Accounts.get_users_by([role: :student], admin2) + assert all_stu_in_c2 == [] + end + + test "get all users in a group in a course", %{a1: admin1, g1: g1, g2: g2} do + all_in_c1g1 = Accounts.get_users_by([group: g1.name], admin1) + assert length(all_in_c1g1) == 2 + all_in_c1g2 = Accounts.get_users_by([group: g2.name], admin1) + assert all_in_c1g2 == [] + end + + test "get all students in a group in a course", %{c1: c1, a1: admin1, g1: g1, g2: g2} do + insert(:course_registration, %{course: c1, group: g1, role: :staff}) + insert(:course_registration, %{course: c1, group: g2, role: :staff}) + all_in_c1g1 = Accounts.get_users_by([group: g1.name], admin1) + assert length(all_in_c1g1) == 3 + all_in_c1g2 = Accounts.get_users_by([group: g2.name], admin1) + assert length(all_in_c1g2) == 1 + all_stu_in_c1g1 = Accounts.get_users_by([group: g1.name, role: :student], admin1) + assert length(all_stu_in_c1g1) == 2 + all_stu_in_c1g2 = Accounts.get_users_by([group: g2.name, role: :student], admin1) + assert all_stu_in_c1g2 == [] end end end diff --git a/test/cadet/accounts/course_registration_test.exs b/test/cadet/accounts/course_registration_test.exs new file mode 100644 index 000000000..49e07b499 --- /dev/null +++ b/test/cadet/accounts/course_registration_test.exs @@ -0,0 +1,331 @@ +defmodule Cadet.Accounts.CourseRegistrationTest do + alias Cadet.Accounts.{CourseRegistration, CourseRegistrations, User} + + use Cadet.ChangesetCase, entity: CourseRegistration + + alias Cadet.Repo + + setup do + user1 = insert(:user, %{name: "user 1"}) + user2 = insert(:user, %{name: "user 2"}) + group1 = insert(:group, %{name: "group 1"}) + group2 = insert(:group, %{name: "group 2"}) + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + + changeset = + CourseRegistration.changeset(%CourseRegistration{}, %{ + course_id: course1.id, + user_id: user1.id, + group_id: group1.id, + role: :student + }) + + {:ok, _course_reg} = Repo.insert(changeset) + + {:ok, + %{ + user1: user1, + user2: user2, + group1: group1, + group2: group2, + course1: course1, + course2: course2, + changeset: changeset + }} + end + + describe "Changesets:" do + test "valid changeset", %{ + user1: user1, + user2: user2, + course1: course1, + course2: course2 + } do + assert_changeset(%{user_id: user1.id, course_id: course2.id, role: :admin}, :valid) + assert_changeset(%{user_id: user2.id, course_id: course1.id, role: :student}, :valid) + + # assert_changeset(%{user_id: user2.id, course_id: course2.id, role: :staff, group_id: group.id}, :valid) + end + + test "invalid changeset missing required params", %{user1: user1, course2: course2} do + assert_changeset(%{user_id: user1.id, course_id: course2.id}, :invalid) + assert_changeset(%{user_id: user1.id, role: :avenger}, :invalid) + assert_changeset(%{course_id: course2.id, role: :avenger}, :invalid) + end + + test "invalid changeset bad params", %{ + user1: user1, + course2: course2 + } do + assert_changeset(%{user_id: user1.id, course_id: course2.id, role: :avenger}, :invalid) + end + + test "invalid changeset repeated records", %{changeset: changeset} do + {:error, changeset} = Repo.insert(changeset) + + assert changeset.errors == [ + user_id: + {"has already been taken", + [ + {:constraint, :unique}, + {:constraint_name, "course_registrations_user_id_course_id_index"} + ]} + ] + + refute changeset.valid? + end + end + + describe "get course_registrations" do + test "of a user succeeds", %{user1: user1, course1: course1, course2: course2} do + changeset2 = + CourseRegistration.changeset(%CourseRegistration{}, %{ + course_id: course2.id, + user_id: user1.id, + role: :student + }) + + {:ok, _course_reg} = Repo.insert(changeset2) + + course_reg_user1 = CourseRegistrations.get_courses(user1) + course_reg_user1_course1 = hd(course_reg_user1) + course_reg_user1_course2 = hd(tl(course_reg_user1)) + assert user1.id == course_reg_user1_course1.user_id + assert course1.id == course_reg_user1_course1.course_id + assert user1.id == course_reg_user1_course2.user_id + assert course2.id == course_reg_user1_course2.course_id + end + + test "of a user failed due to invalid id", %{user2: user2} do + assert CourseRegistrations.get_courses(user2) == [] + end + + test "of a course succeeds", %{user1: user1, user2: user2, course1: course1} do + changeset2 = + CourseRegistration.changeset(%CourseRegistration{}, %{ + course_id: course1.id, + user_id: user2.id, + role: :student + }) + + {:ok, _course_reg} = Repo.insert(changeset2) + + course_reg_course1 = CourseRegistrations.get_users(course1.id) + course_reg_course1_user1 = hd(course_reg_course1) + course_reg_course1_user2 = hd(tl(course_reg_course1)) + assert user1.id == course_reg_course1_user1.user_id + assert course1.id == course_reg_course1_user1.course_id + assert user2.id == course_reg_course1_user2.user_id + assert course1.id == course_reg_course1_user2.course_id + end + + test "of a course failed due to invalid id", %{course2: course2} do + assert CourseRegistrations.get_users(course2.id) == [] + end + + test "of a group in a course succeeds", %{ + user1: user1, + user2: user2, + group1: group1, + group2: group2, + course1: course1 + } do + changeset2 = + CourseRegistration.changeset(%CourseRegistration{}, %{ + course_id: course1.id, + user_id: user2.id, + group_id: group2.id, + role: :student + }) + + {:ok, _course_reg} = Repo.insert(changeset2) + course_reg_course1_group1 = CourseRegistrations.get_users(course1.id, group1.id) + assert length(course_reg_course1_group1) == 1 + [hd | _] = course_reg_course1_group1 + assert user1.id == hd.user_id + assert group1.id == hd.group_id + assert course1.id == hd.course_id + end + + test "of a group in a course failed due to invalid id", %{course1: course1} do + group2 = insert(:group, %{name: "group2"}) + assert CourseRegistrations.get_users(course1.id, group2.id) == [] + end + end + + describe "upsert_users_in_course" do + # Note: roles are already validated in the controller + test "successful", %{course2: course2} do + user = insert(:user, %{username: "existing-user"}) + insert(:course_registration, %{course: course2, user: user}) + assert length(CourseRegistrations.get_users(course2.id)) == 1 + + usernames_and_roles = [ + %{username: "existing-user", role: "admin"}, + %{username: "student1", role: "student"}, + %{username: "student2", role: "student"}, + %{username: "staff1", role: "staff"}, + %{username: "admin1", role: "admin"} + ] + + assert :ok == CourseRegistrations.upsert_users_in_course(usernames_and_roles, course2.id) + assert length(CourseRegistrations.get_users(course2.id)) == 5 + end + + test "successful when there are duplicate inputs in list", %{course2: course2} do + user = insert(:user, %{username: "existing-user"}) + insert(:course_registration, %{course: course2, user: user}) + assert length(CourseRegistrations.get_users(course2.id)) == 1 + + usernames_and_roles = [ + %{username: "existing-user", role: "admin"}, + %{username: "student1", role: "student"}, + %{username: "student1", role: "student"}, + %{username: "staff1", role: "staff"}, + %{username: "admin1", role: "admin"} + ] + + assert :ok == CourseRegistrations.upsert_users_in_course(usernames_and_roles, course2.id) + assert length(CourseRegistrations.get_users(course2.id)) == 4 + end + end + + describe "enroll course" do + test "successful enrollment", %{course1: course1, user2: user2} do + assert length(CourseRegistrations.get_users(course1.id)) == 1 + + {:ok, course_reg} = + CourseRegistrations.enroll_course(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + assert length(CourseRegistrations.get_users(course1.id)) == 2 + assert course_reg.user_id == user2.id + assert course_reg.course_id == course1.id + + assert User |> where(id: ^user2.id) |> Repo.one() |> Map.fetch!(:latest_viewed_course_id) == + course1.id + end + end + + describe "update course_registration" do + test "successful insert", %{course1: course1, user2: user2} do + assert length(CourseRegistrations.get_users(course1.id)) == 1 + + {:ok, course_reg} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + assert length(CourseRegistrations.get_users(course1.id)) == 2 + assert course_reg.user_id == user2.id + assert course_reg.course_id == course1.id + end + + test "successfully update role", %{course1: course1, user1: user1, group1: group1} do + assert length(CourseRegistrations.get_users(course1.id)) == 1 + + {:ok, course_reg} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :staff + }) + + assert length(CourseRegistrations.get_users(course1.id)) == 1 + assert course_reg.user_id == user1.id + assert course_reg.course_id == course1.id + assert course_reg.role == :staff + assert course_reg.group_id == group1.id + end + + test "successfully update group", %{course1: course1, user1: user1, group2: group2} do + assert length(CourseRegistrations.get_users(course1.id)) == 1 + + {:ok, course_reg} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student, + group_id: group2.id + }) + + assert length(CourseRegistrations.get_users(course1.id)) == 1 + assert course_reg.user_id == user1.id + assert course_reg.course_id == course1.id + assert course_reg.role == :student + assert course_reg.group_id == group2.id + end + + test "failed due to incomplete changeset", %{course1: course1, user2: user2} do + assert length(CourseRegistrations.get_users(course1.id)) == 1 + + assert_raise FunctionClauseError, fn -> + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id + }) + end + + assert length(CourseRegistrations.get_users(course1.id)) == 1 + end + end + + describe "update_role" do + setup do + student = insert(:course_registration, %{role: :student}) + staff = insert(:course_registration, %{role: :staff}) + admin = insert(:course_registration, %{role: :admin}) + + {:ok, %{student: student, staff: staff, admin: admin}} + end + + test "succeeds for student to staff", %{student: %{id: coursereg_id}} do + {:ok, updated_coursereg} = CourseRegistrations.update_role("staff", coursereg_id) + assert updated_coursereg.role == :staff + end + + test "succeeds for student to admin", %{student: %{id: coursereg_id}} do + {:ok, updated_coursereg} = CourseRegistrations.update_role("admin", coursereg_id) + assert updated_coursereg.role == :admin + end + + test "succeeds for admin to staff", %{admin: %{id: coursereg_id}} do + {:ok, updated_coursereg} = CourseRegistrations.update_role("staff", coursereg_id) + assert updated_coursereg.role == :staff + end + + test "fails when invalid role is provided", %{student: %{id: coursereg_id}} do + assert {:error, {:bad_request, "role is invalid"}} == + CourseRegistrations.update_role("invalidrole", coursereg_id) + end + + test "fails when course registration does not exist", %{} do + assert {:error, {:bad_request, "User course registration does not exist"}} == + CourseRegistrations.update_role("staff", 10_000) + end + end + + describe "delete_course_registration" do + setup do + student = insert(:course_registration, %{role: :student}) + + {:ok, %{student: student}} + end + + test "succeeds", %{student: %{id: coursereg_id}} do + {:ok, _deleted_coursereg} = CourseRegistrations.delete_course_registration(coursereg_id) + assert is_nil(CourseRegistration |> where(id: ^coursereg_id) |> Repo.one()) + end + + test "fails when course registration does not exist", %{} do + assert {:error, {:bad_request, "User course registration does not exist"}} == + CourseRegistrations.delete_course_registration(10_000) + end + end +end diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index 13837aafd..ba65bc7f2 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -3,19 +3,21 @@ defmodule Cadet.Accounts.NotificationTest do use Cadet.ChangesetCase, entity: Notification - @required_fields ~w(type role user_id)a + @required_fields ~w(type course_reg_id)a setup do assessment = insert(:assessment, %{is_published: true}) - avenger = insert(:user, %{role: :staff}) - student = insert(:user, %{role: :student}) + avenger_user = insert(:user) + student_user = insert(:user) + avenger = insert(:course_registration, %{user: avenger_user, role: :staff}) + student = insert(:course_registration, %{user: student_user, role: :student}) submission = insert(:submission, %{student: student, assessment: assessment}) valid_params_for_student = %{ type: :new, read: false, role: student.role, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment.id } @@ -23,7 +25,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :submitted, read: false, role: avenger.role, - user_id: avenger.id, + course_reg_id: avenger.id, assessment_id: assessment.id, submission_id: submission.id } @@ -74,14 +76,16 @@ defmodule Cadet.Accounts.NotificationTest do read: false, assessment_id: assessment.id, assessment: assessment, - user_id: student.id + course_reg_id: student.id }) - expected = Enum.sort(notifications, &(&1.id < &2.id)) + expected = + notifications |> Enum.sort(&(&1.id < &2.id)) |> Enum.map(&Map.delete(&1, :assessment)) {:ok, notifications_db} = Notifications.fetch(student) - results = Enum.sort(notifications_db, &(&1.id < &2.id)) + results = + notifications_db |> Enum.sort(&(&1.id < &2.id)) |> Enum.map(&Map.delete(&1, :assessment)) assert results == expected end @@ -90,7 +94,7 @@ defmodule Cadet.Accounts.NotificationTest do insert_list(3, :notification, %{ read: true, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) {:ok, notifications_db} = Notifications.fetch(student) @@ -108,7 +112,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :new, read: false, role: student.role, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment.id } @@ -116,7 +120,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :submitted, read: false, role: avenger.role, - user_id: avenger.id, + course_reg_id: avenger.id, assessment_id: assessment.id, submission_id: submission.id } @@ -135,7 +139,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :new, read: false, role: student.role, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment.id } @@ -143,7 +147,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :submitted, read: false, role: avenger.role, - user_id: avenger.id, + course_reg_id: avenger.id, assessment_id: assessment.id, submission_id: submission.id } @@ -154,7 +158,7 @@ defmodule Cadet.Accounts.NotificationTest do assert Repo.one( from(n in Notification, where: - n.type == ^:new and n.user_id == ^student.id and + n.type == ^:new and n.course_reg_id == ^student.id and n.assessment_id == ^assessment.id ) ) @@ -165,7 +169,7 @@ defmodule Cadet.Accounts.NotificationTest do assert Repo.one( from(n in Notification, where: - n.type == ^:submitted and n.user_id == ^avenger.id and + n.type == ^:submitted and n.course_reg_id == ^avenger.id and n.submission_id == ^submission.id ) ) @@ -179,7 +183,7 @@ defmodule Cadet.Accounts.NotificationTest do type: :new, read: false, role: student.role, - user_id: student.id, + course_reg_id: student.id, assessment_id: assessment.id } @@ -200,7 +204,7 @@ defmodule Cadet.Accounts.NotificationTest do insert(:notification, %{ read: false, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) Notifications.acknowledge([notification.id], student) @@ -218,7 +222,7 @@ defmodule Cadet.Accounts.NotificationTest do insert_list(3, :notification, %{ read: false, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) notifications @@ -239,7 +243,7 @@ defmodule Cadet.Accounts.NotificationTest do insert(:notification, %{ read: false, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) assert {:error, _} = Notifications.acknowledge(notification.id, avenger) @@ -256,16 +260,16 @@ defmodule Cadet.Accounts.NotificationTest do test "receives notification when submitted" do assessment = insert(:assessment, %{is_published: true}) - avenger = insert(:user, %{role: :staff}) + avenger = insert(:course_registration, %{role: :staff}) group = insert(:group, %{leader: avenger}) - student = insert(:user, %{role: :student, group: group}) + student = insert(:course_registration, %{role: :student, group: group}) submission = insert(:submission, %{student: student, assessment: assessment}) Notifications.write_notification_when_student_submits(submission) notification = Repo.get_by(Notification, - user_id: avenger.id, + course_reg_id: avenger.id, type: :submitted, submission_id: submission.id ) @@ -282,7 +286,7 @@ defmodule Cadet.Accounts.NotificationTest do notification = Repo.get_by(Notification, - user_id: student.id, + course_reg_id: student.id, type: :autograded, assessment_id: assessment.id ) @@ -298,7 +302,11 @@ defmodule Cadet.Accounts.NotificationTest do Notifications.write_notification_when_graded(submission.id, :graded) notification = - Repo.get_by(Notification, user_id: student.id, type: :graded, assessment_id: assessment.id) + Repo.get_by(Notification, + course_reg_id: student.id, + type: :graded, + assessment_id: assessment.id + ) assert %{type: :graded} = notification end @@ -307,13 +315,19 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, student: student } do - students = [student | insert_list(3, :user, %{role: :student})] + students = [ + student | insert_list(3, :course_registration, %{course: student.course, role: :student}) + ] - Notifications.write_notification_for_new_assessment(assessment.id) + Notifications.write_notification_for_new_assessment(student.course_id, assessment.id) for student <- students do notification = - Repo.get_by(Notification, user_id: student.id, type: :new, assessment_id: assessment.id) + Repo.get_by(Notification, + course_reg_id: student.id, + type: :new, + assessment_id: assessment.id + ) assert %{type: :new} = notification end diff --git a/test/cadet/accounts/query_test.exs b/test/cadet/accounts/query_test.exs index 405348cf4..57c826809 100644 --- a/test/cadet/accounts/query_test.exs +++ b/test/cadet/accounts/query_test.exs @@ -4,10 +4,60 @@ defmodule Cadet.Accounts.QueryTest do alias Cadet.Accounts.Query test "all_students" do - insert(:student) + course = insert(:course) + insert(:course_registration, %{course: course, role: :student}) - result = Query.all_students() + result = Query.all_students(course.id) - assert 1 = Enum.count(result) + assert 1 == Enum.count(result) + end + + describe "avenger of function:" do + setup do + user_a = insert(:user) + user_b = insert(:user) + user_c = insert(:user) + course1 = insert(:course, course_name: "course 1") + course2 = insert(:course, course_name: "course 2") + staff_a1 = insert(:course_registration, %{user: user_a, course: course1, role: :staff}) + group1 = insert(:group, %{leader: staff_a1, course: course1}) + + student_b1 = + insert(:course_registration, %{ + user: user_b, + course: course1, + role: :student, + group: group1 + }) + + student_c1 = insert(:course_registration, %{user: user_c, course: course1, role: :student}) + staff_a2 = insert(:course_registration, %{user: user_a, course: course2, role: :staff}) + + {:ok, + %{ + c1: course1, + c2: course2, + sta_a1: staff_a1, + stu_b1: student_b1, + stu_c1: student_c1, + sta_a2: staff_a2 + }} + end + + test "true, when in same course same group", %{sta_a1: sta_a1, stu_b1: stu_b1} do + assert Query.avenger_of?(sta_a1, stu_b1.id) + end + + test "false, when in same course different group", %{sta_a1: sta_a1, stu_c1: stu_c1} do + refute Query.avenger_of?(sta_a1, stu_c1.id) + end + + test "false, when asked by a student", %{sta_a1: sta_a1, stu_b1: stu_b1} do + refute Query.avenger_of?(stu_b1, sta_a1.id) + end + + test "false, when asked in different course", %{sta_a2: sta_a2, stu_b1: stu_b1} do + refute Query.avenger_of?(sta_a2, stu_b1.id) + end end end diff --git a/test/cadet/accounts/user_test.exs b/test/cadet/accounts/user_test.exs index 932db6588..97318b44a 100644 --- a/test/cadet/accounts/user_test.exs +++ b/test/cadet/accounts/user_test.exs @@ -5,13 +5,14 @@ defmodule Cadet.Accounts.UserTest do describe "Changesets" do test "valid changeset" do - assert_changeset(%{name: "happy people", role: :admin}, :valid) - assert_changeset(%{name: "happy", role: :student}, :valid) + assert_changeset(%{username: "luminus/E0000000"}, :valid) + assert_changeset(%{username: "luminus/E0000001", name: "Avenger"}, :valid) + assert_changeset(%{username: "happy", latest_viewed_course_id: 1}, :valid) end test "invalid changeset" do assert_changeset(%{name: "people"}, :invalid) - assert_changeset(%{role: :avenger}, :invalid) + assert_changeset(%{latest_viewed_course_id: 1}, :invalid) end end end diff --git a/test/cadet/assessments/answer_test.exs b/test/cadet/assessments/answer_test.exs index 669d4146d..746bb0f3a 100644 --- a/test/cadet/assessments/answer_test.exs +++ b/test/cadet/assessments/answer_test.exs @@ -7,7 +7,7 @@ defmodule Cadet.Assessments.AnswerTest do setup do assessment = insert(:assessment, %{is_published: true}) - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{role: :student}) submission = insert(:submission, %{student: student, assessment: assessment}) mcq_question = insert(:mcq_question, %{assessment: assessment}) programming_question = insert(:programming_question, %{assessment: assessment}) @@ -121,40 +121,6 @@ defmodule Cadet.Assessments.AnswerTest do |> Map.put(:question_id, new_mcq_question.id) |> assert_changeset_db(:invalid) end - end - - describe "grading_changeset" do - test "invalid changeset total grade < 0", %{ - valid_mcq_params: valid_mcq_params, - mcq_question: mcq_question, - valid_programming_params: valid_programming_params, - programming_question: programming_question - } do - for {question, params} <- [ - {mcq_question, valid_mcq_params}, - {programming_question, valid_programming_params} - ] do - answer = insert(:answer, %{params | question_id: question.id, grade: 1}) - - refute Answer.grading_changeset(answer, %{adjustment: -2}).valid? - end - end - - test "invalid changeset total grade > max_grade", %{ - valid_mcq_params: valid_mcq_params, - mcq_question: mcq_question, - valid_programming_params: valid_programming_params, - programming_question: programming_question - } do - for {question, params} <- [ - {mcq_question, valid_mcq_params}, - {programming_question, valid_programming_params} - ] do - answer = insert(:answer, %{params | question_id: question.id, grade: question.max_grade}) - - refute Answer.grading_changeset(answer, %{adjustment: 1}).valid? - end - end test "invalid changeset without question_id" do assert_changeset(%{}, :invalid, :grading_changeset) diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index fdf62e3e9..5c52e2ed7 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -3,11 +3,26 @@ defmodule Cadet.Assessments.AssessmentTest do use Cadet.ChangesetCase, entity: Assessment + setup do + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + config1 = insert(:assessment_config, %{course: course1}) + config2 = insert(:assessment_config, %{course: course2}) + + {:ok, %{course1: course1, course2: course2, config1: config1, config2: config2}} + end + describe "Changesets" do - test "valid changesets" do + test "valid changesets", %{ + course1: course1, + course2: course2, + config1: config1, + config2: config2 + } do assert_changeset( %{ - type: "mission", + config_id: config1.id, + course_id: course1.id, title: "mission", number: "M#{Enum.random(0..10)}", open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), @@ -18,7 +33,8 @@ defmodule Cadet.Assessments.AssessmentTest do assert_changeset( %{ - type: Enum.random(Assessment.assessment_types()), + config_id: config2.id, + course_id: course2.id, title: "mission", number: "M#{Enum.random(0..10)}", open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), @@ -30,23 +46,57 @@ defmodule Cadet.Assessments.AssessmentTest do ) end - test "invalid changesets" do - assert_changeset(%{type: "mission", title: "mission", max_grade: 100}, :invalid) - + test "invalid changesets missing required params", %{course1: course1, config1: config1} do assert_changeset( %{ + config_id: config1.id, + course_id: course1.id, title: "mission", - open_at: Timex.now(), - close_at: Timex.shift(Timex.now(), days: 7), - max_grade: 100 + number: "M#{Enum.random(0..10)}" }, :invalid ) assert_changeset( %{ - type: "misc", - title: "invalid type", + config_id: config1.id, + course_id: course1.id, + title: "mission", + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string() + }, + :invalid + ) + end + + test "invalid changesets due to config_course", %{ + course1: course1, + config1: config1, + config2: config2 + } do + config_not_in_course = + Assessment.changeset(%Assessment{}, %{ + config_id: config2.id, + course_id: course1.id, + title: "mission", + number: "4", + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + {:error, changeset} = Repo.insert(config_not_in_course) + + assert changeset.errors == [ + {:config, {"does not belong to the same course as this assessment", []}} + ] + + refute changeset.valid? + + config_not_exist = + Assessment.changeset(%Assessment{}, %{ + config_id: config1.id + config2.id, + course_id: course1.id, + title: "invalid config", number: "M#{Enum.random(1..10)}", open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), close_at: @@ -54,9 +104,27 @@ defmodule Cadet.Assessments.AssessmentTest do |> Timex.shift(days: Enum.random(1..7)) |> Timex.to_unix() |> Integer.to_string() - }, - :invalid - ) + }) + + {:error, changeset2} = Repo.insert(config_not_exist) + assert changeset2.errors == [{:config, {"does not exist", []}}] + refute changeset2.valid? + end + + test "invalid changesets due to invalid dates", %{course1: course1, config1: config1} do + invalid_date = + Assessment.changeset(%Assessment{}, %{ + config_id: config1.id, + course_id: course1.id, + title: "mission", + number: "4", + open_at: Timex.shift(Timex.now(), days: 7), + close_at: Timex.now() + }) + + {:error, changeset} = Repo.insert(invalid_date) + assert changeset.errors == [{:open_at, {"Open date must be before close date", []}}] + refute changeset.valid? end end end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 456f3e6c1..a34133597 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -5,20 +5,22 @@ defmodule Cadet.AssessmentsTest do alias Cadet.Assessments.{Assessment, Question, SubmissionVotes} test "create assessments of all types" do - for type <- Assessment.assessment_types() do - title_string = type - - {_res, assessment} = - Assessments.create_assessment(%{ - title: title_string, - type: type, - number: "#{type |> String.upcase()}#{Enum.random(0..10)}", - open_at: Timex.now(), - close_at: Timex.shift(Timex.now(), days: 7) - }) - - assert %{title: ^title_string, type: ^type} = assessment - end + course = insert(:course) + config = insert(:assessment_config, %{type: "Test", course: course}) + course_id = course.id + config_id = config.id + + {_res, assessment} = + Assessments.create_assessment(%{ + course_id: course_id, + title: "test", + config_id: config_id, + number: "#{config.type |> String.upcase()}#{Enum.random(0..10)}", + open_at: Timex.now(), + close_at: Timex.shift(Timex.now(), days: 7) + }) + + assert %{title: "test", config_id: ^config_id, course_id: ^course_id} = assessment end test "create programming question" do @@ -108,14 +110,18 @@ defmodule Cadet.AssessmentsTest do end test "publish assessment" do - assessment = insert(:assessment, is_published: false) + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{is_published: false, course: course, config: config}) {:ok, assessment} = Assessments.publish_assessment(assessment.id) assert assessment.is_published == true end test "update assessment" do - assessment = insert(:assessment, title: "assessment") + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{title: "assessment", course: course, config: config}) Assessments.update_assessment(assessment.id, %{title: "changed_assessment"}) @@ -141,12 +147,17 @@ defmodule Cadet.AssessmentsTest do test "inserts votes into submission_votes table" do contest_question = insert(:programming_question) question = insert(:voting_question) - users = Enum.map(0..5, fn _x -> insert(:user, role: "student") end) + # users = Enum.map(0..5, fn _x -> insert(:user, role: "student") end) + students = + insert_list(6, :course_registration, %{ + role: :student, + course: contest_question.assessment.course + }) - Enum.map(users, fn user -> + Enum.map(students, fn student -> submission = insert(:submission, - student: user, + student: student, assessment: contest_question.assessment, status: "submitted" ) @@ -158,7 +169,8 @@ defmodule Cadet.AssessmentsTest do ) end) - unattempted_student = insert(:user, role: "student") + unattempted_student = + insert(:course_registration, %{role: :student, course: contest_question.assessment.course}) # unattempted submission will automatically be submitted after the assessment closes. unattempted_submission = @@ -193,14 +205,16 @@ defmodule Cadet.AssessmentsTest do test "deletes submission_votes when assessment is deleted" do contest_question = insert(:programming_question) - voting_assessment = insert(:assessment, type: "practical") + course = contest_question.assessment.course + config = contest_question.assessment.config + voting_assessment = insert(:assessment, %{course: course, config: config}) question = insert(:voting_question, assessment: voting_assessment) - users = Enum.map(0..5, fn _x -> insert(:user, role: "student") end) + students = insert_list(5, :course_registration, %{role: :student, course: course}) - Enum.map(users, fn user -> + Enum.map(students, fn student -> submission = insert(:submission, - student: user, + student: student, assessment: contest_question.assessment, status: "submitted" ) @@ -222,16 +236,14 @@ defmodule Cadet.AssessmentsTest do describe "contest voting leaderboard utility functions" do setup do - contest_assessment = insert(:assessment, type: "contest") - voting_assessment = insert(:assessment, type: "practical") + course = insert(:course) + config = insert(:assessment_config) + contest_assessment = insert(:assessment, %{course: course, config: config}) + voting_assessment = insert(:assessment, %{course: course, config: config}) voting_question = insert(:voting_question, assessment: voting_assessment) # generate 5 students - student_list = - Enum.map( - 1..5, - fn _index -> insert(:user) end - ) + student_list = insert_list(5, :course_registration, %{course: course, role: :student}) # generate contest submission for each student submission_list = @@ -254,6 +266,7 @@ defmodule Cadet.AssessmentsTest do fn submission -> insert( :answer, + answer: build(:programming_answer), submission: submission, question: voting_question ) @@ -271,7 +284,7 @@ defmodule Cadet.AssessmentsTest do insert( :submission_vote, rank: index + 1, - user: student, + voter: student, submission: submission, question: voting_question ) @@ -289,56 +302,55 @@ defmodule Cadet.AssessmentsTest do top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, 5) - assert get_answer_relative_scores(top_x_ans) == [ - 99.0, - 89.0, - 79.0, - 69.0, - 59.0 - ] + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5) x = 3 top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, x) # verify that top x ans are queried correctly - assert get_answer_relative_scores(top_x_ans) == [99.0, 89.0, 79.0] + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3) end end describe "contest leaderboard updating functions" do setup do - current_contest_assessment = insert(:assessment, type: "contest") + course = insert(:course) + config = insert(:assessment_config) + current_contest_assessment = insert(:assessment, %{course: course, config: config}) # contest_voting assessment that is still ongoing current_assessment = insert(:assessment, is_published: true, open_at: Timex.shift(Timex.now(), days: -1), close_at: Timex.shift(Timex.now(), days: +1), - type: "practical" + course: course, + config: config ) current_question = insert(:voting_question, assessment: current_assessment) - yesterday_contest_assessment = insert(:assessment, type: "contest") + yesterday_contest_assessment = insert(:assessment, %{course: course, config: config}) # contest_voting assessment closed yesterday yesterday_assessment = insert(:assessment, is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "practical" + course: course, + config: config ) yesterday_question = insert(:voting_question, assessment: yesterday_assessment) - past_contest_assessment = insert(:assessment, type: "contest") + past_contest_assessment = insert(:assessment, %{course: course, config: config}) # contest voting assessment closed >1 day ago past_assessment = insert(:assessment, is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), days: -4), - type: "practical" + course: course, + config: config ) past_question = @@ -347,11 +359,7 @@ defmodule Cadet.AssessmentsTest do ) # generate 5 students - student_list = - Enum.map( - 1..5, - fn _index -> insert(:user) end - ) + student_list = insert_list(5, :course_registration, %{course: course, role: :student}) # generate contest submission for each user current_submission_list = @@ -399,6 +407,7 @@ defmodule Cadet.AssessmentsTest do fn submission -> insert( :answer, + answer: build(:programming_answer), submission: submission, question: current_question ) @@ -410,6 +419,7 @@ defmodule Cadet.AssessmentsTest do fn submission -> insert( :answer, + answer: build(:programming_answer), submission: submission, question: yesterday_question ) @@ -421,6 +431,7 @@ defmodule Cadet.AssessmentsTest do fn submission -> insert( :answer, + answer: build(:programming_answer), submission: submission, question: past_question ) @@ -438,7 +449,7 @@ defmodule Cadet.AssessmentsTest do insert( :submission_vote, rank: index + 1, - user: student, + voter: student, submission: submission, question: current_question ) @@ -457,7 +468,7 @@ defmodule Cadet.AssessmentsTest do insert( :submission_vote, rank: index + 1, - user: student, + voter: student, submission: submission, question: yesterday_question ) @@ -476,7 +487,7 @@ defmodule Cadet.AssessmentsTest do insert( :submission_vote, rank: index + 1, - user: student, + voter: student, submission: submission, question: past_question ) @@ -512,8 +523,7 @@ defmodule Cadet.AssessmentsTest do get_question_ids(Assessments.fetch_active_voting_questions()) end - test "update_final_contest_leaderboards correctly updates leaderboards - that voting closed yesterday", + test "update_final_contest_leaderboards correctly updates leaderboards that voting closed yesterday", %{ yesterday_question: yesterday_question, current_question: current_question, @@ -532,11 +542,10 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 5) - ) == [99.0, 89.0, 79.0, 69.0, 59.0] + ) == expected_top_relative_scores(5) end - test "update_rolling_contest_leaderboards correcly updates leaderboards - which voting is active", + test "update_rolling_contest_leaderboards correcly updates leaderboards which voting is active", %{ yesterday_question: yesterday_question, current_question: current_question, @@ -555,7 +564,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 5) - ) == [99.0, 89.0, 79.0, 69.0, 59.0] + ) == expected_top_relative_scores(5) end end @@ -566,4 +575,12 @@ defmodule Cadet.AssessmentsTest do defp get_question_ids(questions) do questions |> Enum.map(fn q -> q.id end) |> Enum.sort() end + + defp expected_top_relative_scores(top_x) do + # "return 0;" in the factory has 3 token + 10..0 + |> Enum.to_list() + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 50) end) + |> Enum.take(top_x) + end end diff --git a/test/cadet/assessments/query_test.exs b/test/cadet/assessments/query_test.exs index ab314dc06..5f01b6e0e 100644 --- a/test/cadet/assessments/query_test.exs +++ b/test/cadet/assessments/query_test.exs @@ -5,27 +5,27 @@ defmodule Cadet.Assessments.QueryTest do test "all_assessments_with_max_grade" do assessment = insert(:assessment) - insert_list(5, :question, assessment: assessment, max_grade: 200) + insert_list(5, :question, assessment: assessment, max_xp: 200) result = - Query.all_assessments_with_max_grade() + Query.all_assessments_with_max_xp() |> where(id: ^assessment.id) |> Repo.one() assessment_id = assessment.id - assert %{max_grade: 1000, id: ^assessment_id} = result + assert %{max_xp: 1000, id: ^assessment_id} = result end test "assessments_max_grade" do assessment = insert(:assessment) - insert_list(5, :question, assessment: assessment, max_grade: 200) + insert_list(5, :question, assessment: assessment, max_xp: 200) result = - Query.assessments_max_grade() + Query.assessments_max_xp() |> Repo.all() |> Enum.find(&(&1.assessment_id == assessment.id)) - assert result.max_grade == 1000 + assert result.max_xp == 1000 end end diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index f06d834b1..4d7438d8d 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -6,8 +6,10 @@ defmodule Cadet.Assessments.SubmissionTest do @required_fields ~w(student_id assessment_id)a setup do - assessment = insert(:assessment) - student = insert(:user, %{role: :student}) + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course}) + student = insert(:course_registration, %{course: course, role: :student}) valid_params = %{student_id: student.id, assessment_id: assessment.id} @@ -40,7 +42,7 @@ defmodule Cadet.Assessments.SubmissionTest do assert_changeset_db(params, :invalid) - new_student = insert(:user, %{role: :student}) + new_student = insert(:course_registration, %{role: :student}) {:ok, _} = Repo.delete(assessment) params diff --git a/test/cadet/assessments/submission_votes_test.exs b/test/cadet/assessments/submission_votes_test.exs index bfb7a4076..3840436c2 100644 --- a/test/cadet/assessments/submission_votes_test.exs +++ b/test/cadet/assessments/submission_votes_test.exs @@ -3,16 +3,16 @@ defmodule Cadet.Assessments.SubmissionVotesTest do use Cadet.ChangesetCase, entity: SubmissionVotes - @required_fields ~w(user_id submission_id question_id)a + @required_fields ~w(voter_id submission_id question_id)a setup do question = insert(:question) - user = insert(:user) + voter = insert(:course_registration) submission = insert(:submission) - valid_params = %{user_id: user.id, submission_id: submission.id, question_id: question.id} + valid_params = %{voter_id: voter.id, submission_id: submission.id, question_id: question.id} - {:ok, %{question: question, user: user, submission: submission, valid_params: valid_params}} + {:ok, %{question: question, voter: voter, submission: submission, valid_params: valid_params}} end describe "Changesets" do @@ -22,10 +22,10 @@ defmodule Cadet.Assessments.SubmissionVotesTest do test "converts valid params with models into ids", %{ question: question, - user: user, + voter: voter, submission: submission } do - assert_changeset_db(%{question: question, user: user, submission: submission}, :valid) + assert_changeset_db(%{question: question, voter: voter, submission: submission}, :valid) end test "invalid changeset missing params", %{valid_params: params} do @@ -38,15 +38,15 @@ defmodule Cadet.Assessments.SubmissionVotesTest do test "invalid changeset foreign key constraint", %{ question: question, - user: user, + voter: voter, submission: submission, valid_params: params } do - {:ok, _} = Repo.delete(user) + {:ok, _} = Repo.delete(voter) assert_changeset_db(params, :invalid) - new_user = insert(:user) + new_user = insert(:course_registration) {:ok, _} = Repo.delete(question) params diff --git a/test/cadet/auth/guardian_test.exs b/test/cadet/auth/guardian_test.exs index 90211f252..483adf091 100644 --- a/test/cadet/auth/guardian_test.exs +++ b/test/cadet/auth/guardian_test.exs @@ -1,6 +1,7 @@ defmodule Cadet.Auth.GuardianTest do use Cadet.DataCase + import Cadet.ModelHelper alias Cadet.Auth.Guardian test "token subject is user id" do @@ -19,7 +20,9 @@ defmodule Cadet.Auth.GuardianTest do "sub" => "2000" } - assert Guardian.resource_from_claims(good_claims) == {:ok, user} + assert Guardian.resource_from_claims(good_claims) == + {:ok, remove_preload(user, :latest_viewed_course)} + assert Guardian.resource_from_claims(bad_claims) == {:error, :not_found} end end diff --git a/test/cadet/auth/provider_test.exs b/test/cadet/auth/provider_test.exs index 7836323b7..3d59ae01e 100644 --- a/test/cadet/auth/provider_test.exs +++ b/test/cadet/auth/provider_test.exs @@ -10,7 +10,6 @@ defmodule Cadet.Auth.ProviderTest do test "with valid provider" do assert {:ok, _} = Provider.authorise("test", "student_code", nil, nil) assert {:ok, _} = Provider.get_name("test", "student_token") - assert {:ok, _} = Provider.get_role("test", "student_token") end test "with invalid provider" do diff --git a/test/cadet/auth/providers/config_test.exs b/test/cadet/auth/providers/config_test.exs index b2ad33aef..c75ffa3a4 100644 --- a/test/cadet/auth/providers/config_test.exs +++ b/test/cadet/auth/providers/config_test.exs @@ -7,6 +7,7 @@ defmodule Cadet.Auth.Providers.ConfigTest do @token "token" @name "Test Name" @username "testusername" + @namespaced_username "test/testusername" @role :student @config [ @@ -21,7 +22,7 @@ defmodule Cadet.Auth.Providers.ConfigTest do describe "authorise" do test "successfully" do - assert {:ok, %{token: @token, username: @username}} = + assert {:ok, %{token: @token, username: @namespaced_username}} = Config.authorise(@config, @code, nil, nil) end diff --git a/test/cadet/auth/providers/github_test.exs b/test/cadet/auth/providers/github_test.exs new file mode 100644 index 000000000..582a183dd --- /dev/null +++ b/test/cadet/auth/providers/github_test.exs @@ -0,0 +1,105 @@ +defmodule Cadet.Auth.Providers.GitHubTest do + use ExUnit.Case, async: false + + alias Cadet.Auth.Providers.GitHub + alias Plug.Conn, as: PlugConn + + @username "username" + @namespaced_username "github/username" + @name "name" + + @dummy_access_token "dummy_access_token" + + setup_all do + Application.ensure_all_started(:bypass) + bypass = Bypass.open() + + {:ok, bypass: bypass} + end + + defp config(bypass) do + %{ + clients: %{"dummy_client_id" => "dummy_client_secret"}, + token_url: "http://localhost:#{bypass.port}/login/oauth/access_token", + user_api: "http://localhost:#{bypass.port}/user" + } + end + + defp bypass_return_token(bypass) do + Bypass.stub(bypass, "POST", "login/oauth/access_token", fn conn -> + conn + |> PlugConn.put_resp_header("content-type", "application/json") + |> PlugConn.resp(200, ~s({"access_token":"#{@dummy_access_token}"})) + end) + end + + defp bypass_api_call(bypass) do + Bypass.stub(bypass, "GET", "user", fn conn -> + conn + |> PlugConn.put_resp_header("content-type", "application/json") + |> PlugConn.resp(200, ~s({"login":"#{@username}","name":"#{@name}"})) + end) + end + + test "successful", %{bypass: bypass} do + bypass_return_token(bypass) + bypass_api_call(bypass) + + assert {:ok, %{token: @dummy_access_token, username: @namespaced_username}} == + GitHub.authorise(config(bypass), "", "dummy_client_id", "") + end + + test "invalid github client id", %{bypass: bypass} do + bypass_return_token(bypass) + bypass_api_call(bypass) + + assert {:error, :invalid_credentials, "Invalid client id"} == + GitHub.authorise(config(bypass), "", "invalid_client_id", "") + end + + test "non-successful HTTP status (access token)", %{bypass: bypass} do + Bypass.stub(bypass, "POST", "login/oauth/access_token", fn conn -> + PlugConn.resp(conn, 403, "") + end) + + assert {:error, :upstream, "Status code 403 from GitHub"} == + GitHub.authorise(config(bypass), "", "dummy_client_id", "") + end + + test "error token response", %{bypass: bypass} do + Bypass.stub(bypass, "POST", "login/oauth/access_token", fn conn -> + conn + |> PlugConn.put_resp_header("content-type", "application/json") + |> PlugConn.resp(200, ~s({"error":"bad_verification_code"})) + + assert {:error, :invalid_credentials, "Error from GitHub: bad_verification_code"} == + GitHub.authorise(config(bypass), "", "dummy_client_id", "") + end) + end + + test "non-successful HTTP status (user api call)", %{bypass: bypass} do + bypass_return_token(bypass) + + Bypass.stub(bypass, "GET", "user", fn conn -> + PlugConn.resp(conn, 401, "") + end) + + assert {:error, :upstream, "Status code 401 from GitHub"} + GitHub.authorise(config(bypass), "", "dummy_client_id", "") + end + + test "get_name successful", %{bypass: bypass} do + bypass_api_call(bypass) + + assert {:ok, @name} == GitHub.get_name(config(bypass), @dummy_access_token) + end + + test "get_name non-successful HTTP status", %{bypass: bypass} do + Bypass.stub(bypass, "GET", "user", fn conn -> + PlugConn.resp(conn, 401, "") + end) + + assert {:error, :upstream, "Status code 401 from GitHub"} == + GitHub.get_name(config(bypass), "invalid_access_token") + end +end diff --git a/test/cadet/auth/providers/openid_test.exs b/test/cadet/auth/providers/openid_test.exs index 27d0aed70..6526a395b 100644 --- a/test/cadet/auth/providers/openid_test.exs +++ b/test/cadet/auth/providers/openid_test.exs @@ -33,6 +33,7 @@ defmodule Cadet.Auth.Providers.OpenIDTest do """ @username "username" + @namespaced_username "test/username" @role :admin @openid_provider_name :test @@ -103,7 +104,7 @@ defmodule Cadet.Auth.Providers.OpenIDTest do bypass_return_token(bypass, @okay_token) - assert {:ok, %{token: @okay_token, username: @username}} = + assert {:ok, %{token: @okay_token, username: @namespaced_username}} = OpenID.authorise(@config, "dummy_code", "", "") assert {:ok, @username} == OpenID.get_name(@config, @okay_token) diff --git a/test/cadet/course/course_test.exs b/test/cadet/course/course_test.exs deleted file mode 100644 index c3c66d3be..000000000 --- a/test/cadet/course/course_test.exs +++ /dev/null @@ -1,91 +0,0 @@ -defmodule Cadet.CourseTest do - use Cadet.DataCase - - alias Cadet.{Course, Repo} - alias Cadet.Courses.{Group, Sourcecast, SourcecastUpload} - - describe "Sourcecast" do - setup do - on_exit(fn -> File.rm_rf!("uploads/test/sourcecasts") end) - end - - test "upload file to folder then delete it" do - uploader = insert(:user, %{role: :staff}) - - upload = %Plug.Upload{ - content_type: "audio/wav", - filename: "upload.wav", - path: "test/fixtures/upload.wav" - } - - result = - Course.upload_sourcecast_file(uploader, %{ - title: "Test Upload", - audio: upload, - playbackData: - "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}" - }) - - assert {:ok, sourcecast} = result - path = SourcecastUpload.url({sourcecast.audio, sourcecast}) - assert path =~ "/uploads/test/sourcecasts/upload.wav" - - deleter = insert(:user, %{role: :staff}) - assert {:ok, _} = Course.delete_sourcecast_file(deleter, sourcecast.id) - assert Repo.get(Sourcecast, sourcecast.id) == nil - refute File.exists?("uploads/test/sourcecasts/upload.wav") - end - end - - describe "get_or_create_group" do - test "existing group" do - group = insert(:group) - - {:ok, group_db} = Course.get_or_create_group(group.name) - - assert group_db.id == group.id - assert group_db.leader_id == group.leader_id - end - - test "non-existent group" do - group_name = params_for(:group).name - - {:ok, _} = Course.get_or_create_group(group_name) - - group_db = - Group - |> where(name: ^group_name) - |> Repo.one() - - refute is_nil(group_db) - end - end - - describe "insert_or_update_group" do - test "existing group" do - group = insert(:group) - group_params = params_with_assocs(:group, name: group.name) - Course.insert_or_update_group(group_params) - - updated_group = - Group - |> where(name: ^group.name) - |> Repo.one() - - assert updated_group.id == group.id - assert updated_group.leader_id == group_params.leader_id - end - - test "non-existent group" do - group_params = params_with_assocs(:group) - Course.insert_or_update_group(group_params) - - updated_group = - Group - |> where(name: ^group_params.name) - |> Repo.one() - - assert updated_group.leader_id == group_params.leader_id - end - end -end diff --git a/test/cadet/course/group_test.exs b/test/cadet/course/group_test.exs deleted file mode 100644 index d42d7f324..000000000 --- a/test/cadet/course/group_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Cadet.Courses.GroupTest do - alias Cadet.Courses.Group - - use Cadet.ChangesetCase, entity: Group - - describe "Changesets" do - test "valid changeset" do - assert_changeset(%{}, :valid) - assert_changeset(%{name: "tst"}, :valid) - end - end -end diff --git a/test/cadet/courses/assessment_config_test.exs b/test/cadet/courses/assessment_config_test.exs new file mode 100644 index 000000000..52533f8c2 --- /dev/null +++ b/test/cadet/courses/assessment_config_test.exs @@ -0,0 +1,88 @@ +defmodule Cadet.Courses.AssessmentConfigTest do + alias Cadet.Courses.AssessmentConfig + + use Cadet.ChangesetCase, entity: AssessmentConfig + + describe "Assessment Configs Changesets" do + test "valid changesets" do + assert_changeset(%{order: 1, type: "Missions", course_id: 1}, :valid) + assert_changeset(%{order: 2, type: "quests", course_id: 1}, :valid) + assert_changeset(%{order: 3, type: "Paths", course_id: 1}, :valid) + assert_changeset(%{order: 4, type: "contests", course_id: 1}, :valid) + assert_changeset(%{order: 5, type: "Others", course_id: 1}, :valid) + end + + test "invalid changeset missing required params" do + assert_changeset(%{order: 1}, :invalid) + assert_changeset(%{order: 1, type: "Missions"}, :invalid) + end + + test "invalid changeset with invalid order" do + assert_changeset(%{order: 0, type: "Missions", course_id: 1}, :invalid) + assert_changeset(%{order: 9, type: "Missions", course_id: 1}, :invalid) + end + end + + describe "Configuration-related Changesets" do + test "valid changesets" do + assert_changeset( + %{ + order: 1, + type: "Missions", + course_id: 1, + early_submission_xp: 200, + hours_before_early_xp_decay: 48 + }, + :valid + ) + + assert_changeset( + %{ + order: 1, + type: "Missions", + course_id: 1, + early_submission_xp: 0, + hours_before_early_xp_decay: 0 + }, + :valid + ) + + assert_changeset( + %{ + order: 1, + type: "Missions", + course_id: 1, + early_submission_xp: 200, + hours_before_early_xp_decay: 0 + }, + :valid + ) + end + + test "invalid changeset with invalid early xp" do + assert_changeset( + %{ + order: 1, + type: "Missions", + course_id: 1, + early_submission_xp: -1, + hours_before_early_xp_decay: 0 + }, + :invalid + ) + end + + test "invalid changeset with invalid hours before decay" do + assert_changeset( + %{ + order: 1, + type: "Missions", + course_id: 1, + early_submission_xp: 200, + hours_before_early_xp_decay: -1 + }, + :invalid + ) + end + end +end diff --git a/test/cadet/courses/course_test.exs b/test/cadet/courses/course_test.exs new file mode 100644 index 000000000..da782b36c --- /dev/null +++ b/test/cadet/courses/course_test.exs @@ -0,0 +1,154 @@ +defmodule Cadet.Courses.CourseTest do + alias Cadet.Courses.Course + + use Cadet.ChangesetCase, entity: Course + + describe "Course Configuration Changesets" do + test "valid changesets" do + assert_changeset( + %{ + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + course_short_name: "CS2040S", + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + viewable: false, + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + enable_game: false, + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + enable_achievements: false, + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + enable_sourcecast: false, + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + module_help_text: "", + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + module_help_text: "Module help text", + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + enable_game: true, + enable_achievements: true, + enable_sourcecast: true, + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default" + }, + :valid + ) + + assert_changeset( + %{ + source_chapter: 1, + source_variant: "wasm", + course_name: "Data Structures and Algorithms" + }, + :valid + ) + + assert_changeset( + %{ + source_chapter: 2, + source_variant: "lazy", + course_name: "Data Structures and Algorithms" + }, + :valid + ) + + assert_changeset( + %{ + source_chapter: 3, + source_variant: "non-det", + course_name: "Data Structures and Algorithms" + }, + :valid + ) + + assert_changeset( + %{ + source_chapter: 4, + source_variant: "default", + enable_achievements: true, + course_name: "Data Structures and Algorithms" + }, + :valid + ) + end + + test "invalid changeset missing required params" do + assert_changeset(%{source_chapter: 2}, :invalid) + assert_changeset(%{source_variant: "default"}, :invalid) + end + + test "invalid changeset with invalid chapter" do + assert_changeset(%{source_chapter: 5, source_variant: "default"}, :invalid) + end + + test "invalid changeset with invalid variant" do + assert_changeset(%{source_chapter: Enum.random(1..4), source_variant: "error"}, :invalid) + end + + test "invalid changeset with invalid chapter-variant combination" do + assert_changeset(%{source_chapter: 4, source_variant: "lazy"}, :invalid) + end + end +end diff --git a/test/cadet/courses/courses_test.exs b/test/cadet/courses/courses_test.exs new file mode 100644 index 000000000..07fd08537 --- /dev/null +++ b/test/cadet/courses/courses_test.exs @@ -0,0 +1,549 @@ +defmodule Cadet.CoursesTest do + use Cadet.DataCase + + alias Cadet.{Courses, Repo} + alias Cadet.Accounts.{CourseRegistration, User} + alias Cadet.Courses.{Course, Group, Sourcecast, SourcecastUpload} + alias Cadet.Assessments.Assessment + + describe "create course config" do + test "succeeds" do + user = insert(:user) + + # Course precreated in User factory + old_courses = Course |> Repo.all() |> length() + + params = %{ + course_name: "CS1101S Programming Methodology (AY20/21 Sem 1)", + course_short_name: "CS1101S", + viewable: true, + enable_game: true, + enable_achievements: true, + enable_sourcecast: true, + source_chapter: 1, + source_variant: "default", + module_help_text: "Help Text" + } + + Courses.create_course_config(params, user) + + # New course created + new_courses = Course |> Repo.all() |> length() + assert new_courses - old_courses == 1 + + # New admin course registration for user + course_regs = CourseRegistration |> where(user_id: ^user.id) |> Repo.all() + assert length(course_regs) == 1 + assert Enum.at(course_regs, 0).role == :admin + + # User's latest_viewed_course is updated + assert User |> where(id: ^user.id) |> Repo.one() |> Map.fetch!(:latest_viewed_course_id) == + Enum.at(course_regs, 0).course_id + end + end + + describe "get course config" do + test "succeeds" do + course = insert(:course) + insert(:assessment_config, %{order: 1, type: "Missions", course: course}) + insert(:assessment_config, %{order: 2, type: "Quests", course: course}) + + {:ok, course} = Courses.get_course_config(course.id) + assert course.course_name == "Programming Methodology" + assert course.course_short_name == "CS1101S" + assert course.viewable == true + assert course.enable_game == true + assert course.enable_achievements == true + assert course.enable_sourcecast == true + assert course.source_chapter == 1 + assert course.source_variant == "default" + assert course.module_help_text == "Help Text" + assert course.assessment_configs == ["Missions", "Quests"] + end + + test "returns with error for invalid course id" do + course = insert(:course) + + assert {:error, {:bad_request, "Invalid course id"}} = + Courses.get_course_config(course.id + 1) + end + end + + describe "update course config" do + test "succeeds (without sublanguage update)" do + course = insert(:course) + + {:ok, updated_course} = + Courses.update_course_config(course.id, %{ + course_name: "Data Structures and Algorithms", + course_short_name: "CS2040S", + viewable: false, + enable_game: false, + enable_achievements: false, + enable_sourcecast: false, + module_help_text: "" + }) + + assert updated_course.course_name == "Data Structures and Algorithms" + assert updated_course.course_short_name == "CS2040S" + assert updated_course.viewable == false + assert updated_course.enable_game == false + assert updated_course.enable_achievements == false + assert updated_course.enable_sourcecast == false + assert updated_course.source_chapter == 1 + assert updated_course.source_variant == "default" + assert updated_course.module_help_text == nil + end + + test "succeeds (with sublanguage update)" do + course = insert(:course) + new_chapter = Enum.random(1..4) + + {:ok, updated_course} = + Courses.update_course_config(course.id, %{ + course_name: "Data Structures and Algorithms", + course_short_name: "CS2040S", + viewable: false, + enable_game: false, + enable_achievements: false, + enable_sourcecast: false, + source_chapter: new_chapter, + source_variant: "default", + module_help_text: "help" + }) + + assert updated_course.course_name == "Data Structures and Algorithms" + assert updated_course.course_short_name == "CS2040S" + assert updated_course.viewable == false + assert updated_course.enable_game == false + assert updated_course.enable_achievements == false + assert updated_course.enable_sourcecast == false + assert updated_course.source_chapter == new_chapter + assert updated_course.source_variant == "default" + assert updated_course.module_help_text == "help" + end + + test "succeeds (removes latest_viewed_course_id)" do + course = insert(:course) + user = insert(:user, %{latest_viewed_course: course}) + + {:ok, updated_course} = + Courses.update_course_config(course.id, %{ + course_name: "Data Structures and Algorithms", + course_short_name: "CS2040S", + viewable: false, + enable_game: false, + enable_achievements: false, + enable_sourcecast: false, + module_help_text: "help" + }) + + assert updated_course.viewable == false + assert is_nil(Repo.get(User, user.id).latest_viewed_course_id) + end + + test "returns with error for invalid course id" do + course = insert(:course) + new_chapter = Enum.random(1..4) + + assert {:error, {:bad_request, "Invalid course id"}} = + Courses.update_course_config(course.id + 1, %{ + source_chapter: new_chapter, + source_variant: "default" + }) + end + + test "returns with error for failed updates" do + course = insert(:course) + + assert {:error, changeset} = + Courses.update_course_config(course.id, %{ + source_chapter: 0, + source_variant: "default" + }) + + assert %{source_chapter: ["is invalid"]} = errors_on(changeset) + + assert {:error, changeset} = + Courses.update_course_config(course.id, %{source_chapter: 2, source_variant: "gpu"}) + + assert %{source_variant: ["is invalid"]} = errors_on(changeset) + end + end + + describe "get assessment configs" do + test "succeeds" do + course = insert(:course) + + for i <- 1..5 do + insert(:assessment_config, %{order: 6 - i, type: "Mission#{i}", course: course}) + end + + assessment_configs = Courses.get_assessment_configs(course.id) + + assert length(assessment_configs) <= 5 + + assessment_configs + |> Enum.with_index(1) + |> Enum.each(fn {at, idx} -> + assert at.order == idx + assert at.type == "Mission#{6 - idx}" + end) + end + end + + describe "mass_upsert_and_reorder_assessment_configs" do + setup do + course = insert(:course) + config1 = insert(:assessment_config, %{order: 1, type: "Missions", course: course}) + config2 = insert(:assessment_config, %{order: 2, type: "Quests", course: course}) + config3 = insert(:assessment_config, %{order: 3, type: "Paths", course: course}) + config4 = insert(:assessment_config, %{order: 4, type: "Contests", course: course}) + expected = ["Paths", "Quests", "Missions", "Others", "Contests"] + + {:ok, + %{ + course: course, + expected: expected, + config1: config1, + config2: config2, + config3: config3, + config4: config4 + }} + end + + test "succeeds", %{ + course: course, + expected: expected, + config1: config1, + config2: config2, + config3: config3, + config4: config4 + } do + {:ok, _} = + Courses.mass_upsert_and_reorder_assessment_configs(course.id, [ + %{assessment_config_id: config1.id, type: "Paths"}, + %{assessment_config_id: config2.id, type: "Quests"}, + %{assessment_config_id: config3.id, type: "Missions"}, + %{assessment_config_id: config4.id, type: "Others"}, + %{assessment_config_id: -1, type: "Contests"} + ]) + + assessment_configs = Courses.get_assessment_configs(course.id) + + assert Enum.map(assessment_configs, & &1.type) == expected + end + + test "succeeds to capitalise", %{ + course: course, + expected: expected, + config1: config1, + config2: config2, + config3: config3, + config4: config4 + } do + {:ok, _} = + Courses.mass_upsert_and_reorder_assessment_configs(course.id, [ + %{assessment_config_id: config1.id, type: "Paths"}, + %{assessment_config_id: config2.id, type: "Quests"}, + %{assessment_config_id: config3.id, type: "Missions"}, + %{assessment_config_id: config4.id, type: "Others"}, + %{assessment_config_id: -1, type: "Contests"} + ]) + + assessment_configs = Courses.get_assessment_configs(course.id) + + assert Enum.map(assessment_configs, & &1.type) == expected + end + + # test "succeed to delete", %{course: course} do + # :ok = + # Courses.mass_upsert_and_reorder_assessment_configs(course.id, [ + # %{order: 1, type: "Paths"}, + # %{order: 2, type: "quests"}, + # %{order: 3, type: "missions"} + # ]) + + # assessment_configs = Courses.get_assessment_configs(course.id) + + # assert Enum.map(assessment_configs, & &1.type) == ["Paths", "Quests", "Missions"] + # end + + test "returns with error for empty list parameter", %{course: course} do + assert {:error, {:bad_request, "Invalid parameter(s)"}} = + Courses.mass_upsert_and_reorder_assessment_configs(course.id, []) + end + + test "returns with error for list parameter of greater than length 8", %{ + course: course, + config1: config1, + config2: config2, + config3: config3, + config4: config4 + } do + params = [ + %{assessment_config_id: config1.id, type: "Paths"}, + %{assessment_config_id: config2.id, type: "Quests"}, + %{assessment_config_id: config3.id, type: "Missions"}, + %{assessment_config_id: config4.id, type: "Others"}, + %{assessment_config_id: -1, type: "Contests"}, + %{assessment_config_id: -1, type: "Contests"}, + %{assessment_config_id: -1, type: "Contests"}, + %{assessment_config_id: -1, type: "Contests"}, + %{assessment_config_id: -1, type: "Contests"} + ] + + assert {:error, {:bad_request, "Invalid parameter(s)"}} = + Courses.mass_upsert_and_reorder_assessment_configs(course.id, params) + end + + test "returns with error for non-list parameter", %{course: course} do + params = %{course_id: course.id, order: 1, type: "Paths"} + + assert {:error, {:bad_request, "Invalid parameter(s)"}} = + Courses.mass_upsert_and_reorder_assessment_configs(course.id, params) + end + end + + describe "insert_or_update_assessment_config" do + test "succeeds with insert configs" do + course = insert(:course) + old_configs = Courses.get_assessment_configs(course.id) + + params = %{ + assessment_config_id: -1, + order: 1, + type: "Mission", + early_submission_xp: 100, + hours_before_early_xp_decay: 24 + } + + {:ok, updated_config} = Courses.insert_or_update_assessment_config(course.id, params) + + new_configs = Courses.get_assessment_configs(course.id) + assert old_configs == [] + assert length(new_configs) == 1 + assert updated_config.early_submission_xp == 100 + assert updated_config.hours_before_early_xp_decay == 24 + end + + test "succeeds with update" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + params = %{ + assessment_config_id: config.id, + type: "Mission", + early_submission_xp: 100, + hours_before_early_xp_decay: 24 + } + + {:ok, updated_config} = Courses.insert_or_update_assessment_config(course.id, params) + + assert updated_config.type == "Mission" + assert updated_config.early_submission_xp == 100 + assert updated_config.hours_before_early_xp_decay == 24 + end + end + + describe "reorder_assessment_config" do + test "succeeds" do + course = insert(:course) + config1 = insert(:assessment_config, %{order: 1, type: "Missions", course: course}) + config3 = insert(:assessment_config, %{order: 2, type: "Paths", course: course}) + config2 = insert(:assessment_config, %{order: 3, type: "Quests", course: course}) + config4 = insert(:assessment_config, %{order: 4, type: "Others", course: course}) + old_configs = Courses.get_assessment_configs(course.id) + + params = [ + %{assessment_config_id: config1.id, type: "Paths"}, + %{assessment_config_id: config2.id, type: "Quests"}, + %{assessment_config_id: config3.id, type: "Missions"}, + %{assessment_config_id: config4.id, type: "Others"} + ] + + expected = ["Paths", "Quests", "Missions", "Others"] + + {:ok, _} = Courses.reorder_assessment_configs(course.id, params) + + new_configs = Courses.get_assessment_configs(course.id) + assert length(old_configs) == length(new_configs) + assert Enum.map(new_configs, & &1.type) == expected + end + end + + describe "delete_assessment_config" do + test "succeeds" do + course = insert(:course) + config = insert(:assessment_config, %{order: 1, course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + old_configs = Courses.get_assessment_configs(course.id) + refute Assessment |> Repo.get(assessment.id) |> is_nil() + + {:ok, _} = Courses.delete_assessment_config(course.id, config.id) + + new_configs = Courses.get_assessment_configs(course.id) + assert length(old_configs) == 1 + assert new_configs == [] + assert Assessment |> Repo.get(assessment.id) |> is_nil() + end + + test "error" do + course = insert(:course) + insert(:assessment_config, %{order: 1, course: course}) + + assert {:error, :no_such_enrty} == Courses.delete_assessment_config(course.id, -1) + end + end + + describe "upsert_groups_in_course" do + setup do + course = insert(:course) + existing_group_leader = insert(:course_registration, %{course: course, role: :staff}) + + existing_group = + insert(:group, %{name: "Existing Group", course: course, leader: existing_group_leader}) + + existing_student = + insert(:course_registration, %{course: course, group: existing_group, role: :student}) + + {:ok, + course: course, + existing_group: existing_group, + existing_group_leader: existing_group_leader, + existing_student: existing_student} + end + + test "succeeds in upserting existing groups", %{ + course: course, + existing_group: existing_group, + existing_group_leader: existing_group_leader, + existing_student: existing_student + } do + student = insert(:course_registration, %{course: course, group: nil, role: :student}) + admin = insert(:course_registration, %{course: course, group: nil, role: :admin}) + + usernames_and_groups = [ + %{username: existing_student.user.username, group: "Group1"}, + %{username: admin.user.username, group: "Group2"}, + %{username: student.user.username, group: "Group2"}, + %{username: existing_group_leader.user.username, group: "Group1"} + ] + + assert :ok == Courses.upsert_groups_in_course(usernames_and_groups, course.id) + + # Check that Group1 and Group2 were created + assert length(Group |> where(course_id: ^course.id) |> Repo.all()) == 3 + + # Check that leaders were assigned/ updated correctly + assert is_nil( + Group + |> where(id: ^existing_group.id) + |> Repo.one() + |> Map.fetch!(:leader_id) + ) + + group1 = Group |> where(course_id: ^course.id) |> where(name: "Group1") |> Repo.one() + group2 = Group |> where(course_id: ^course.id) |> where(name: "Group2") |> Repo.one() + assert group1 |> Map.fetch!(:leader_id) == existing_group_leader.id + assert group2 |> Map.fetch!(:leader_id) == admin.id + + # Check that students were assigned to the correct groups + assert CourseRegistration + |> where(id: ^existing_student.id) + |> Repo.one() + |> Map.fetch!(:group_id) == + group1.id + + assert CourseRegistration |> where(id: ^student.id) |> Repo.one() |> Map.fetch!(:group_id) == + group2.id + end + + test "succeeds (removes user from existing groups when group is not specified)", %{ + course: course, + existing_group: existing_group, + existing_group_leader: existing_group_leader, + existing_student: existing_student + } do + usernames_and_groups = [ + %{username: existing_student.user.username}, + %{username: existing_group_leader.user.username} + ] + + assert :ok == Courses.upsert_groups_in_course(usernames_and_groups, course.id) + + assert is_nil( + Group + |> where(id: ^existing_group.id) + |> Repo.one() + |> Map.fetch!(:leader_id) + ) + + assert is_nil( + CourseRegistration + |> where(id: ^existing_student.id) + |> Repo.one() + |> Map.fetch!(:group_id) + ) + end + end + + describe "Sourcecast" do + setup do + on_exit(fn -> File.rm_rf!("uploads/test/sourcecasts") end) + end + + test "upload file to folder then delete it" do + inserter_course_registration = insert(:course_registration, %{role: :staff}) + + upload = %Plug.Upload{ + content_type: "audio/wav", + filename: "upload.wav", + path: "test/fixtures/upload.wav" + } + + result = + Courses.upload_sourcecast_file(inserter_course_registration, %{ + title: "Test Upload", + audio: upload, + playbackData: + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}" + }) + + assert {:ok, sourcecast} = result + path = SourcecastUpload.url({sourcecast.audio, sourcecast}) + assert path =~ "/uploads/test/sourcecasts/upload.wav" + + deleter_course_registration = insert(:course_registration, %{role: :staff}) + assert {:ok, _} = Courses.delete_sourcecast_file(deleter_course_registration, sourcecast.id) + assert Repo.get(Sourcecast, sourcecast.id) == nil + refute File.exists?("uploads/test/sourcecasts/upload.wav") + end + end + + describe "get_or_create_group" do + test "existing group" do + course = insert(:course) + group = insert(:group, %{course: course}) + + {:ok, group_db} = Courses.get_or_create_group(group.name, course.id) + + assert group_db.id == group.id + assert group_db.leader_id == group.leader_id + end + + test "non-existent group" do + course = insert(:course) + group_name = params_for(:group).name + + {:ok, _} = Courses.get_or_create_group(group_name, course.id) + + group_db = + Group + |> where(name: ^group_name) + |> Repo.one() + + refute is_nil(group_db) + end + end +end diff --git a/test/cadet/courses/group_test.exs b/test/cadet/courses/group_test.exs new file mode 100644 index 000000000..a8e4495c6 --- /dev/null +++ b/test/cadet/courses/group_test.exs @@ -0,0 +1,34 @@ +defmodule Cadet.Courses.GroupTest do + alias Cadet.Courses.Group + + use Cadet.ChangesetCase, entity: Group + + describe "Changesets" do + test "valid changeset" do + assert_changeset(%{name: "test", course_id: 1}, :valid) + assert_changeset(%{name: "tst"}, :invalid) + end + + test "validate role" do + course = insert(:course) + student = insert(:course_registration, %{course: course, role: :student}) + staff = insert(:course_registration, %{course: course, role: :staff}) + admin = insert(:course_registration, %{course: course, role: :admin}) + + assert_changeset(%{name: "test", course_id: course.id, leader_id: staff.id}, :valid) + assert_changeset(%{name: "test", course_id: course.id, leader_id: admin.id}, :valid) + assert_changeset(%{name: "test", course_id: course.id, leader_id: student.id}, :invalid) + end + + test "validate course" do + course = insert(:course) + student = insert(:course_registration, %{course: course, role: :student}) + staff = insert(:course_registration, %{course: course, role: :staff}) + admin = insert(:course_registration, %{course: course, role: :admin}) + + assert_changeset(%{name: "test", course_id: course.id + 1, leader_id: staff.id}, :invalid) + assert_changeset(%{name: "test", course_id: course.id + 1, leader_id: admin.id}, :invalid) + assert_changeset(%{name: "test", course_id: course.id + 1, leader_id: student.id}, :invalid) + end + end +end diff --git a/test/cadet/course/sourcecast_test.exs b/test/cadet/courses/sourcecast_test.exs similarity index 100% rename from test/cadet/course/sourcecast_test.exs rename to test/cadet/courses/sourcecast_test.exs diff --git a/test/cadet/devices/devices_test.exs b/test/cadet/devices/devices_test.exs index 5764d7042..577f2a961 100644 --- a/test/cadet/devices/devices_test.exs +++ b/test/cadet/devices/devices_test.exs @@ -9,7 +9,7 @@ defmodule Cadet.DevicesTest do @registration_compare_fields ~w(id title device_id user_id)a setup do - user = insert(:user, %{role: :student}) + user = insert(:user) device = insert(:device, client_key: nil, client_cert: nil) {:ok, registration} = @@ -65,7 +65,7 @@ defmodule Cadet.DevicesTest do end test "add existing device to new user", %{device: device} do - user = insert(:user, %{role: :student}) + user = insert(:user) title = Faker.Person.En.first_name() assert {:ok, %DeviceRegistration{} = registration} = diff --git a/test/cadet/incentives/achievement_test.exs b/test/cadet/incentives/achievement_test.exs index b6d8e64c0..3b7a664bc 100644 --- a/test/cadet/incentives/achievement_test.exs +++ b/test/cadet/incentives/achievement_test.exs @@ -5,10 +5,13 @@ defmodule Cadet.Incentives.AchievementTest do describe "Changesets" do test "valid changesets" do + course = insert(:course) + assert_changeset( %{ uuid: "d1fdae3f-2775-4503-ab6b-e043149d4a15", title: "Hello World", + course_id: course.id, ability: "Core", open_at: DateTime.from_naive!(~N[2016-05-24 13:26:08.003], "Etc/UTC"), close_at: DateTime.from_naive!(~N[2016-05-27 13:26:08.003], "Etc/UTC"), diff --git a/test/cadet/incentives/achievements_test.exs b/test/cadet/incentives/achievements_test.exs index f9f738c12..e061c8d13 100644 --- a/test/cadet/incentives/achievements_test.exs +++ b/test/cadet/incentives/achievements_test.exs @@ -13,10 +13,13 @@ defmodule Cadet.Incentives.AchievementsTest do import Cadet.TestEntityHelper test "create achievements" do + course = insert(:course) + for ability <- Achievement.valid_abilities() do {:ok, %{uuid: uuid}} = Achievements.upsert(%{ uuid: Ecto.UUID.generate(), + course_id: course.id, title: ability, ability: ability, is_task: false, @@ -30,8 +33,9 @@ defmodule Cadet.Incentives.AchievementsTest do end test "create achievement with prerequisites as id" do - a1 = insert(:achievement) - a2 = insert(:achievement) + course = insert(:course) + a1 = insert(:achievement, %{course: course}) + a2 = insert(:achievement, %{course: course}) prerequisite_uuids = [a1.uuid, a2.uuid] a_uuid = UUID.generate() attrs = achievement_literal(0) @@ -39,6 +43,7 @@ defmodule Cadet.Incentives.AchievementsTest do {:ok, _} = attrs |> Map.merge(%{ + course_id: course.id, uuid: a_uuid, prerequisite_uuids: prerequisite_uuids }) @@ -55,6 +60,7 @@ defmodule Cadet.Incentives.AchievementsTest do {:ok, _} = attrs |> Map.merge(%{ + course_id: g.course_id, uuid: a_uuid, goal_uuids: [g.uuid] }) @@ -64,9 +70,10 @@ defmodule Cadet.Incentives.AchievementsTest do end test "get achievements" do - goal = insert(:goal) - prereq = insert(:achievement) - achievement = insert(:achievement, achievement_literal(0)) + course = insert(:course) + goal = insert(:goal, %{course: course}) + prereq = insert(:achievement, %{course: course}) + achievement = insert(:achievement, Map.merge(achievement_literal(0), %{course: course})) Repo.insert(%AchievementPrerequisite{ prerequisite_uuid: prereq.uuid, @@ -80,7 +87,7 @@ defmodule Cadet.Incentives.AchievementsTest do goal_uuid = goal.uuid prereq_uuid = prereq.uuid - achievement = Enum.find(Achievements.get(), &(&1.uuid == achievement.uuid)) + achievement = Enum.find(Achievements.get(course.id), &(&1.uuid == achievement.uuid)) assert achievement_literal(0) = achievement assert [%{goal_uuid: ^goal_uuid}] = achievement.goals @@ -125,9 +132,11 @@ defmodule Cadet.Incentives.AchievementsTest do end test "bulk insert succeeds" do + course = insert(:course) + attrs = [achievement_literal(0), achievement_literal(1)] - |> Enum.map(&Map.merge(&1, %{uuid: UUID.generate()})) + |> Enum.map(&Map.merge(&1, %{course_id: course.id, uuid: UUID.generate()})) assert {:ok, result} = Achievements.upsert_many(attrs) assert [achievement_literal(0), achievement_literal(1)] = result diff --git a/test/cadet/incentives/goal_progress_test.exs b/test/cadet/incentives/goal_progress_test.exs index 1e12c6663..dbd4a4e2e 100644 --- a/test/cadet/incentives/goal_progress_test.exs +++ b/test/cadet/incentives/goal_progress_test.exs @@ -5,13 +5,13 @@ defmodule Cadet.Incentives.GoalProgressTest do describe "Changesets" do test "valid params" do - user = insert(:user) + course_reg = insert(:course_registration) goal = insert(:goal) assert_changeset_db( %{ goal_uuid: goal.uuid, - user_id: user.id, + course_reg_id: course_reg.id, count: 500, completed: false }, diff --git a/test/cadet/incentives/goal_test.exs b/test/cadet/incentives/goal_test.exs index d97786068..48b014752 100644 --- a/test/cadet/incentives/goal_test.exs +++ b/test/cadet/incentives/goal_test.exs @@ -6,9 +6,12 @@ defmodule Cadet.Incentives.GoalTest do describe "Changesets" do test "valid params" do + course = insert(:course) + assert_changeset_db( %{ uuid: UUID.generate(), + course_id: course.id, target_count: 1000, text: "Sample Text", type: "test_type", diff --git a/test/cadet/incentives/goals_test.exs b/test/cadet/incentives/goals_test.exs index 427823961..46ce0fca3 100644 --- a/test/cadet/incentives/goals_test.exs +++ b/test/cadet/incentives/goals_test.exs @@ -7,28 +7,29 @@ defmodule Cadet.Incentives.GoalssTest do import Cadet.TestEntityHelper test "create goal" do + course = insert(:course) uuid = UUID.generate() - Goals.upsert(Map.merge(goal_literal(0), %{uuid: uuid})) + Goals.upsert(Map.merge(goal_literal(0), %{course_id: course.id, uuid: uuid})) assert goal_literal(0) = Repo.get(Goal, uuid) end test "get goals" do - insert(:goal, goal_literal(0)) - assert [goal_literal(0)] = Goals.get() + goal = insert(:goal, goal_literal(0)) + assert [goal_literal(0)] = Goals.get(goal.course_id) end test "get goals with progress" do goal = insert(:goal, goal_literal(0)) - user = insert(:user) + course_reg = insert(:course_registration) Repo.insert(%GoalProgress{ count: 500, completed: false, - user_id: user.id, + course_reg_id: course_reg.id, goal_uuid: goal.uuid }) - retrieved_goal = Goals.get_with_progress(user) + retrieved_goal = Goals.get_with_progress(course_reg) assert [goal_literal(0)] = retrieved_goal assert [%{progress: [%{count: 500, completed: false}]}] = retrieved_goal @@ -48,8 +49,11 @@ defmodule Cadet.Incentives.GoalssTest do end test "bulk insert succeeds" do + course = insert(:course) + attrs = - [goal_literal(0), goal_literal(1)] |> Enum.map(&Map.merge(&1, %{uuid: UUID.generate()})) + [goal_literal(0), goal_literal(1)] + |> Enum.map(&Map.merge(&1, %{course_id: course.id, uuid: UUID.generate()})) assert {:ok, result} = Goals.upsert_many(attrs) assert [goal_literal(0), goal_literal(1)] = result @@ -76,7 +80,7 @@ defmodule Cadet.Incentives.GoalssTest do test "upsert progress" do goal = insert(:goal, goal_literal(0)) - user = insert(:user) + course_reg = insert(:course_registration, %{course: goal.course}) assert {:ok, _} = Goals.upsert_progress( @@ -84,13 +88,13 @@ defmodule Cadet.Incentives.GoalssTest do count: 100, completed: false, goal_uuid: goal.uuid, - user_id: user.id + course_reg_id: course_reg.id }, goal.uuid, - user.id + course_reg.id ) - retrieved_goal = Goals.get_with_progress(user) + retrieved_goal = Goals.get_with_progress(course_reg) assert [%{progress: [%{count: 100, completed: false}]}] = retrieved_goal assert {:ok, _} = @@ -99,13 +103,13 @@ defmodule Cadet.Incentives.GoalssTest do count: 200, completed: true, goal_uuid: goal.uuid, - user_id: user.id + course_reg_id: course_reg.id }, goal.uuid, - user.id + course_reg.id ) - retrieved_goal = Goals.get_with_progress(user) + retrieved_goal = Goals.get_with_progress(course_reg) assert [%{progress: [%{count: 200, completed: true}]}] = retrieved_goal end end diff --git a/test/cadet/jobs/autograder/grading_job_test.exs b/test/cadet/jobs/autograder/grading_job_test.exs index 1e5ce488f..27ca0b66d 100644 --- a/test/cadet/jobs/autograder/grading_job_test.exs +++ b/test/cadet/jobs/autograder/grading_job_test.exs @@ -23,12 +23,16 @@ defmodule Cadet.Autograder.GradingJobTest do describe "#force_grade_individual_submission, all programming questions" do setup do + course = insert(:course) + assessment_config = insert(:assessment_config, %{course: course}) + assessments = insert_list(3, :assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + config: assessment_config, + course: course }) questions = @@ -36,13 +40,13 @@ defmodule Cadet.Autograder.GradingJobTest do insert_list(3, :programming_question, %{assessment: assessment}) end - %{assessments: Enum.zip(assessments, questions)} + %{course: course, assessments: Enum.zip(assessments, questions)} end test "all assessments attempted, all questions graded, assocs preloaded, should enqueue all jobs", - %{assessments: assessments} do + %{course: course, assessments: assessments} do with_mock Que, add: fn _, _ -> nil end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) [{assessment, questions} | _] = assessments @@ -73,9 +77,9 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempted, all questions graded, no assocs preloaded, " <> "should enqueue all jobs", - %{assessments: assessments} do + %{course: course, assessments: assessments} do with_mock Que, add: fn _, _ -> nil end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) [{assessment, questions} | _] = assessments @@ -107,12 +111,16 @@ defmodule Cadet.Autograder.GradingJobTest do describe "#grade_all_due_yesterday, all programming questions" do setup do + course = insert(:course) + assessment_config = insert(:assessment_config, %{course: course}) + assessments = insert_list(3, :assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + config: assessment_config, + course: course }) questions = @@ -120,14 +128,15 @@ defmodule Cadet.Autograder.GradingJobTest do insert_list(3, :programming_question, %{assessment: assessment}) end - %{assessments: Enum.zip(assessments, questions)} + %{course: course, assessments: Enum.zip(assessments, questions)} end test "all assessments attempted, all questions answered, should enqueue all jobs", %{ + course: course, assessments: assessments } do with_mock Que, add: fn _, _ -> nil end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) submissions_answers = Enum.map(assessments, fn {assessment, questions} -> @@ -165,9 +174,10 @@ defmodule Cadet.Autograder.GradingJobTest do end test "all assessments attempted, all questions graded, should not do anything", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) Enum.map(assessments, fn {assessment, questions} -> submission = @@ -190,11 +200,14 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempted, should update all submission statuses and create notifications", %{ + course: course, assessments: assessments } do with_mock Que, add: fn _, _ -> nil end do - group = insert(:group) - student = insert(:student, %{group_id: group.id}) + group = insert(:group, %{course: course}) + + student = + insert(:course_registration, %{course: course, role: :student, group_id: group.id}) submissions = Enum.map(assessments, fn {assessment, _} -> @@ -216,11 +229,14 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments submitted, should not create notifications", %{ + course: course, assessments: assessments } do with_mock Que, add: fn _, _ -> nil end do - group = insert(:group) - student = insert(:student, %{group_id: group.id}) + group = insert(:group, %{course: course}) + + student = + insert(:course_registration, %{course: course, role: :student, group_id: group.id}) submissions = Enum.map(assessments, fn {assessment, _} -> @@ -239,10 +255,11 @@ defmodule Cadet.Autograder.GradingJobTest do end test "all assessments unattempted, should create submissions", %{ + course: course, assessments: assessments } do with_mock Que, add: fn _, _ -> nil end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) GradingJob.grade_all_due_yesterday() @@ -261,9 +278,10 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempting, no questions answered, " <> "should insert empty answers, should not enqueue any", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) for {assessment, _} <- assessments do insert(:submission, %{student: student, assessment: assessment, status: :attempting}) @@ -281,7 +299,6 @@ defmodule Cadet.Autograder.GradingJobTest do assert Enum.count(answers) == 9 for answer <- answers do - assert answer.grade == 0 assert answer.autograding_status == :success assert answer.answer == %{"code" => "// Question was left blank by the student."} end @@ -293,10 +310,11 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempting, first question unanswered, " <> "should insert empty answer, should dispatch submitted answers", %{ + course: course, assessments: assessments } do with_mock Que, add: fn _, _ -> nil end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) # Do not answer first question in each assessment submissions_answers = @@ -343,7 +361,6 @@ defmodule Cadet.Autograder.GradingJobTest do |> Enum.filter(&(&1.question_id in unanswered_question_ids)) for answer <- inserted_empty_answers do - assert answer.grade == 0 assert answer.xp == 0 assert answer.autograding_status == :success assert answer.answer == %{"code" => "// Question was left blank by the student."} @@ -353,10 +370,11 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempted, all questions answered, instance raced, should not do anything", %{ + course: course, assessments: assessments } do with_mock Cadet.Jobs.Log, log_execution: fn _name, _period -> false end do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) submissions_answers = Enum.map(assessments, fn {assessment, questions} -> @@ -395,28 +413,33 @@ defmodule Cadet.Autograder.GradingJobTest do describe "#grade_all_due_yesterday, all mcq questions" do setup do + course = insert(:course) + assessment_config = insert(:assessment_config, %{course: course}) + assessments = insert_list(3, :assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + config: assessment_config, + course: course }) questions = for assessment <- assessments do - insert_list(3, :mcq_question, %{max_grade: 20, assessment: assessment}) + insert_list(3, :mcq_question, %{max_xp: 200, assessment: assessment}) end - %{assessments: Enum.zip(assessments, questions)} + %{course: course, assessments: Enum.zip(assessments, questions)} end test "all assessments attempted, all questions unanswered, " <> "should insert empty answers, should not enqueue any", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) for {assessment, _} <- assessments do insert(:submission, %{student: student, assessment: assessment, status: :attempting}) @@ -434,7 +457,6 @@ defmodule Cadet.Autograder.GradingJobTest do assert Enum.count(answers) == 9 for answer <- answers do - assert answer.grade == 0 assert answer.xp == 0 assert answer.autograding_status == :success assert answer.answer == %{"choice_id" => 0} @@ -446,9 +468,10 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempted, all questions answered, " <> "should grade all questions, should not enqueue any", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) submissions_answers = Enum.map(assessments, fn {assessment, questions} -> @@ -481,10 +504,8 @@ defmodule Cadet.Autograder.GradingJobTest do # seeded questions have correct choice as 0 if answer_db.answer["choice_id"] == 0 do - assert answer_db.grade == question.max_grade assert answer_db.xp == question.max_xp else - assert answer_db.grade == 0 assert answer_db.xp == 0 end @@ -497,28 +518,33 @@ defmodule Cadet.Autograder.GradingJobTest do describe "#grade_all_due_yesterday, all voting questions" do setup do + course = insert(:course) + assessment_config = insert(:assessment_config, %{course: course}) + assessments = insert_list(3, :assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + config: assessment_config, + course: course }) questions = for assessment <- assessments do - insert_list(3, :voting_question, %{max_grade: 20, assessment: assessment}) + insert_list(3, :voting_question, %{max_xp: 20, assessment: assessment}) end - %{assessments: Enum.zip(assessments, questions)} + %{course: course, assessments: Enum.zip(assessments, questions)} end test "all assessments attempted, all questions unanswered, " <> "should insert empty answers, should not enqueue any", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) for {assessment, _} <- assessments do insert(:submission, %{student: student, assessment: assessment, status: :attempting}) @@ -536,7 +562,6 @@ defmodule Cadet.Autograder.GradingJobTest do assert Enum.count(answers) == 9 for answer <- answers do - assert answer.grade == 0 assert answer.xp == 0 assert answer.autograding_status == :success assert answer.answer == %{"completed" => false} @@ -548,9 +573,10 @@ defmodule Cadet.Autograder.GradingJobTest do test "all assessments attempted, all questions aswered, " <> "should grade all questions, should not enqueue any", %{ + course: course, assessments: assessments } do - student = insert(:user, %{role: :student}) + student = insert(:course_registration, %{course: course, role: :student}) submissions_answers = for {assessment, questions} <- assessments do @@ -560,8 +586,8 @@ defmodule Cadet.Autograder.GradingJobTest do answers = for question <- questions do case Enum.random(0..1) do - 0 -> insert(:submission_vote, %{user: student, question: question, rank: 1}) - 1 -> insert(:submission_vote, %{user: student, question: question}) + 0 -> insert(:submission_vote, %{voter: student, question: question, rank: 1}) + 1 -> insert(:submission_vote, %{voter: student, question: question}) end insert(:answer, %{ @@ -586,7 +612,7 @@ defmodule Cadet.Autograder.GradingJobTest do for {question, answer} <- Enum.zip(questions, answers) do is_nil_entries = SubmissionVotes - |> where(user_id: ^student.id) + |> where(voter_id: ^student.id) |> where(question_id: ^question.id) |> where([sv], is_nil(sv.rank)) |> Repo.exists?() @@ -594,10 +620,8 @@ defmodule Cadet.Autograder.GradingJobTest do answer_db = Repo.get(Answer, answer.id) if is_nil_entries do - assert answer_db.grade == 0 assert answer_db.xp == 0 else - assert answer_db.grade == question.max_grade assert answer_db.xp == question.max_xp end diff --git a/test/cadet/jobs/autograder/lambda_worker_test.exs b/test/cadet/jobs/autograder/lambda_worker_test.exs index 03535b164..6ecb9cd49 100644 --- a/test/cadet/jobs/autograder/lambda_worker_test.exs +++ b/test/cadet/jobs/autograder/lambda_worker_test.exs @@ -23,7 +23,10 @@ defmodule Cadet.Autograder.LambdaWorkerTest do public: [ %{"score" => 1, "answer" => "1", "program" => "f(1);"} ], - private: [ + opaque: [ + %{"score" => 1, "answer" => "45", "program" => "f(10);"} + ], + secret: [ %{"score" => 1, "answer" => "45", "program" => "f(10);"} ] }) @@ -32,7 +35,7 @@ defmodule Cadet.Autograder.LambdaWorkerTest do submission = insert(:submission, %{ - student: insert(:user, %{role: :student}), + student: insert(:course_registration, %{role: :student}), assessment: question.assessment }) @@ -63,7 +66,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do %{"resultType" => "pass", "score" => 1}, %{"resultType" => "pass", "score" => 1} ], - grade: 2, + score: 2, + max_score: 2, status: :success } }) @@ -112,7 +116,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do ] } ], - grade: 0, + score: 0, + max_score: 2, status: :success } }) @@ -133,7 +138,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do Que.add(ResultStoreWorker, %{ answer_id: answer.id, result: %{ - grade: 0, + score: 0, + max_score: 1, status: :failed, result: [ %{ @@ -162,7 +168,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do question: build(:programming_question_content, %{ public: [], - private: [] + opaque: [], + secret: [] }) } ) @@ -202,7 +209,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do %{ answer_id: answer.id, result: %{ - grade: 0, + score: 0, + max_score: 1, status: :failed, result: [ %{ @@ -229,7 +237,8 @@ defmodule Cadet.Autograder.LambdaWorkerTest do expected = %{ prependProgram: question.question.prepend, postpendProgram: question.question.postpend, - testcases: question.question.public ++ question.question.private, + testcases: + question.question.public ++ question.question.opaque ++ question.question.secret, studentProgram: answer.answer.code, library: %{ chapter: question.grading_library.chapter, diff --git a/test/cadet/jobs/autograder/result_store_worker_test.exs b/test/cadet/jobs/autograder/result_store_worker_test.exs index 7a20d6493..a907b809d 100644 --- a/test/cadet/jobs/autograder/result_store_worker_test.exs +++ b/test/cadet/jobs/autograder/result_store_worker_test.exs @@ -8,7 +8,7 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do setup do answer = insert(:answer, %{question: insert(:question), submission: insert(:submission)}) - success_no_errors = %{status: :success, grade: 10, result: []} + success_no_errors = %{status: :success, score: 10, max_score: 10, result: []} success_with_errors = %{ result: [ @@ -37,7 +37,8 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do ] } ], - grade: 0, + score: 0, + max_score: 10, status: :success } @@ -47,7 +48,8 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do "systemError" => "Autograder runtime error. Please contact a system administrator" } ], - grade: 0, + score: 0, + max_score: 10, status: :failed } @@ -83,16 +85,13 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do end) end) - assert answer.grade == result.grade - assert answer.adjustment == 0 - - if answer.question.max_grade == 0 do + if result.max_score == 0 do assert answer.xp == 0 else assert answer.xp == Integer.floor_div( - answer.question.max_xp * answer.grade, - answer.question.max_grade + answer.question.max_xp * result.score, + result.max_score ) end @@ -102,13 +101,12 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do end test "after manual grading", %{results: results} do - grader = insert(:user, %{role: :staff}) + grader = insert(:course_registration, %{role: :staff}) answer = insert(:answer, %{ question: insert(:question), submission: insert(:submission), - adjustment: 5, grader_id: grader.id }) @@ -128,16 +126,13 @@ defmodule Cadet.Autograder.ResultStoreWorkerTest do end) end) - assert answer.grade == result.grade - assert answer.adjustment == 5 - result.grade - - if answer.question.max_grade == 0 do + if result.max_score == 0 do assert answer.xp == 0 else assert answer.xp == Integer.floor_div( - answer.question.max_xp * answer.grade, - answer.question.max_grade + answer.question.max_xp * result.score, + result.max_score ) end diff --git a/test/cadet/jobs/autograder/utilities_test.exs b/test/cadet/jobs/autograder/utilities_test.exs index ab0f57fff..dfc234a36 100644 --- a/test/cadet/jobs/autograder/utilities_test.exs +++ b/test/cadet/jobs/autograder/utilities_test.exs @@ -1,17 +1,20 @@ defmodule Cadet.Autograder.UtilitiesTest do use Cadet.DataCase - alias Cadet.Assessments.Assessment alias Cadet.Autograder.Utilities describe "fetch_assessments_due_yesterday" do test "it only returns yesterday's assessments" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + yesterday = insert_list(2, :assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + course: course, + config: config }) past = @@ -19,7 +22,8 @@ defmodule Cadet.Autograder.UtilitiesTest do is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), days: -4), - type: "mission" + course: course, + config: config }) future = @@ -27,7 +31,8 @@ defmodule Cadet.Autograder.UtilitiesTest do is_published: true, open_at: Timex.shift(Timex.now(), days: -3), close_at: Timex.shift(Timex.now(), days: 4), - type: "mission" + course: course, + config: config }) for assessment <- yesterday ++ past ++ future do @@ -38,32 +43,17 @@ defmodule Cadet.Autograder.UtilitiesTest do get_assessments_ids(Utilities.fetch_assessments_due_yesterday()) end - test "it return only paths, missions, sidequests" do - assessments = - for type <- Assessment.assessment_types() do - insert(:assessment, %{ - is_published: true, - open_at: Timex.shift(Timex.now(), days: -5), - close_at: Timex.shift(Timex.now(), hours: -4), - type: type - }) - end - - for assessment <- assessments do - insert_list(2, :programming_question, %{assessment: assessment}) - end - - assert get_assessments_ids(Enum.filter(assessments, &(&1.type != "contest"))) == - get_assessments_ids(Utilities.fetch_assessments_due_yesterday()) - end - test "it returns assessment questions in sorted order" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{ is_published: true, open_at: Timex.shift(Timex.now(), days: -5), close_at: Timex.shift(Timex.now(), hours: -4), - type: "mission" + course: course, + config: config }) insert_list(5, :programming_question, %{assessment: assessment}) @@ -80,8 +70,10 @@ defmodule Cadet.Autograder.UtilitiesTest do describe "fetch_submissions" do setup do - assessment = insert(:assessment, %{is_published: true}) - students = insert_list(5, :user, %{role: :student}) + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{is_published: true, course: course, config: config}) + students = insert_list(5, :course_registration, %{role: :student, course: course}) %{students: students, assessment: assessment} end diff --git a/test/cadet/settings/settings_test.exs b/test/cadet/settings/settings_test.exs deleted file mode 100644 index ec3a4c575..000000000 --- a/test/cadet/settings/settings_test.exs +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Cadet.SettingsTest do - use Cadet.DataCase - - alias Cadet.Settings - - describe "get sublanguage" do - test "succeeds" do - insert(:sublanguage, %{chapter: 3, variant: "concurrent"}) - {:ok, sublanguage} = Settings.get_sublanguage() - assert sublanguage.chapter == 3 - assert sublanguage.variant == "concurrent" - end - - test "returns default if no existing entry exists" do - {:ok, sublanguage} = Settings.get_sublanguage() - assert sublanguage.chapter == 1 - assert sublanguage.variant == "default" - end - end - - describe "update sublanguage" do - test "succeeds" do - insert(:sublanguage) - new_chapter = Enum.random(1..4) - {:ok, sublanguage} = Settings.update_sublanguage(new_chapter, "default") - assert sublanguage.chapter == new_chapter - assert sublanguage.variant == "default" - end - - test "succeeds if no existing entry exists" do - new_chapter = Enum.random(1..4) - {:ok, sublanguage} = Settings.update_sublanguage(new_chapter, "default") - assert sublanguage.chapter == new_chapter - assert sublanguage.variant == "default" - end - - test "returns with error for failed updates" do - assert {:error, changeset} = Settings.update_sublanguage(0, "default") - assert %{chapter: ["is invalid"]} = errors_on(changeset) - - assert {:error, changeset} = Settings.update_sublanguage(2, "gpu") - assert %{variant: ["is invalid"]} = errors_on(changeset) - end - end -end diff --git a/test/cadet/settings/sublanguage_test.exs b/test/cadet/settings/sublanguage_test.exs deleted file mode 100644 index f5c845105..000000000 --- a/test/cadet/settings/sublanguage_test.exs +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Cadet.Settings.SublanguageTest do - alias Cadet.Settings.Sublanguage - - use Cadet.ChangesetCase, entity: Sublanguage - - describe "Changesets" do - test "valid changesets" do - assert_changeset(%{chapter: 1, variant: "wasm"}, :valid) - assert_changeset(%{chapter: 2, variant: "lazy"}, :valid) - assert_changeset(%{chapter: 3, variant: "non-det"}, :valid) - assert_changeset(%{chapter: 4, variant: "default"}, :valid) - end - - test "invalid changeset missing required params" do - assert_changeset(%{chapter: 2}, :invalid) - end - - test "invalid changeset with invalid chapter" do - assert_changeset(%{chapter: 5, variant: "default"}, :invalid) - end - - test "invalid changeset with invalid variant" do - assert_changeset(%{chapter: Enum.random(1..4), variant: "error"}, :invalid) - end - - test "invalid changeset with invalid chapter-variant combination" do - assert_changeset(%{chapter: 4, variant: "lazy"}, :invalid) - end - end -end diff --git a/test/cadet/stories/stories_test.exs b/test/cadet/stories/stories_test.exs index 2d763b76c..51f225859 100644 --- a/test/cadet/stories/stories_test.exs +++ b/test/cadet/stories/stories_test.exs @@ -1,6 +1,5 @@ defmodule Cadet.StoriesTest do alias Cadet.Stories.{Story, Stories} - alias Cadet.Accounts.User use Cadet.ChangesetCase, entity: Story @@ -24,7 +23,8 @@ defmodule Cadet.StoriesTest do describe "Changesets" do test "valid params", %{valid_params: params} do - assert_changeset_db(params, :valid) + course = insert(:course) + assert_changeset_db(Map.put(params, :course_id, course.id), :valid) end test "invalid params", %{valid_params: params} do @@ -34,48 +34,140 @@ defmodule Cadet.StoriesTest do end describe "List stories" do - test "All stories" do - story1 = insert(:story) - story2 = insert(:story) - assert Stories.list_stories(%User{role: :staff}) == [story1, story2] + test "All stories from own course" do + course = insert(:course) + story1 = :story |> insert(%{course: course}) |> remove_course_assoc() + story2 = :story |> insert(%{course: course}) |> remove_course_assoc() + + assert Stories.list_stories(insert(:course_registration, %{course: course, role: :staff})) == + [story1, story2] + end + + test "Does not list stories from other courses" do + course = insert(:course) + insert(:story) + story2 = :story |> insert(%{course: course}) |> remove_course_assoc() + + assert Stories.list_stories(insert(:course_registration, %{course: course, role: :staff})) == + [story2] end test "Only show published and open stories", %{valid_params: params} do one_week_ago = Timex.shift(Timex.now(), weeks: -1) - insert(:story) - insert(:story, %{params | :is_published => true}) - insert(:story, %{params | :open_at => one_week_ago}) + course = insert(:course) + insert(:story, %{course: course}) + insert(:story, %{Map.put(params, :course, course) | :is_published => true}) + insert(:story, %{Map.put(params, :course, course) | :open_at => one_week_ago}) published_open_story = - insert(:story, %{params | :is_published => true, :open_at => one_week_ago}) - - assert Stories.list_stories(%User{role: :student}) == [published_open_story] + :story + |> insert(%{ + Map.put(params, :course, course) + | :is_published => true, + :open_at => one_week_ago + }) + |> remove_course_assoc() + + assert Stories.list_stories(insert(:course_registration, %{course: course, role: :student})) == + [published_open_story] end end describe "Create story" do - test "create story", %{valid_params: params} do - {:ok, story} = Stories.create_story(params, %User{role: :staff}) + test "create course story as staff", %{valid_params: params} do + course_registration = insert(:course_registration, %{role: :staff}) + {:ok, story} = Stories.create_story(params, course_registration) + params = Map.put(params, :course_id, course_registration.course_id) + assert story |> Map.take(params |> Map.keys()) == params end + + test "students not allowed to create story", %{valid_params: params} do + course_registration = insert(:course_registration, %{role: :student}) + + assert {:error, {:forbidden, "User not allowed to manage stories"}} = + Stories.create_story(params, course_registration) + end end describe "Update story" do - test "update story", %{updated_params: updated_params} do - story = insert(:story) - {:ok, story} = Stories.update_story(updated_params, story.id, %User{role: :staff}) + test "updating story as staff in own course", %{updated_params: updated_params} do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: course_registration.course}) + {:ok, updated_story} = Stories.update_story(updated_params, story.id, course_registration) + updated_params = Map.put(updated_params, :course_id, course_registration.course_id) + + assert updated_story |> Map.take(updated_params |> Map.keys()) == updated_params + end + + test "updating story that does not exist as staff", %{updated_params: updated_params} do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: course_registration.course}) + + {:error, {:not_found, "Story not found"}} = + Stories.update_story(updated_params, story.id + 1, course_registration) + end - assert story |> Map.take(updated_params |> Map.keys()) == updated_params + test "staff fails to update story of another course", %{updated_params: updated_params} do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: build(:course)}) + + assert {:error, {:forbidden, "User not allowed to manage stories from another course"}} = + Stories.update_story(updated_params, story.id, course_registration) + end + + test "student fails to update story of own course", %{updated_params: updated_params} do + course_registration = insert(:course_registration, %{role: :student}) + story = insert(:story, %{course: course_registration.course}) + + assert {:error, {:forbidden, "User not allowed to manage stories"}} = + Stories.update_story(updated_params, story.id, course_registration) end end describe "Delete story" do - test "delete story" do - story = insert(:story) - {:ok, story} = Stories.delete_story(story.id, %User{role: :staff}) + test "staff deleting course story from own course" do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: course_registration.course}) + {:ok, story} = Stories.delete_story(story.id, course_registration) assert Repo.get(Story, story.id) == nil end + + test "staff deleting course story that does not exist" do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: course_registration.course}) + + assert {:error, {:not_found, "Story not found"}} = + Stories.delete_story(story.id + 1, course_registration) + end + + test "staff fails to delete story from another course" do + course_registration = insert(:course_registration, %{role: :staff}) + story = insert(:story, %{course: build(:course)}) + + assert {:error, {:forbidden, "User not allowed to manage stories from another course"}} = + Stories.delete_story(story.id, course_registration) + end + + test "student fails to delete story from own course" do + course_registration = insert(:course_registration, %{role: :student}) + story = insert(:story, %{course: course_registration.course}) + + assert {:error, {:forbidden, "User not allowed to manage stories"}} = + Stories.delete_story(story.id, course_registration) + end + end + + defp remove_course_assoc(story) do + %{ + story + | :course => %Ecto.Association.NotLoaded{ + __field__: :course, + __owner__: story.__struct__, + __cardinality__: :one + } + } end end diff --git a/test/cadet/updater/xml_parser_test.exs b/test/cadet/updater/xml_parser_test.exs index 2d44cc02d..4e6183fa3 100644 --- a/test/cadet/updater/xml_parser_test.exs +++ b/test/cadet/updater/xml_parser_test.exs @@ -9,29 +9,59 @@ defmodule Cadet.Updater.XMLParserTest do import ExUnit.CaptureLog setup do + course = insert(:course) + + assessment_configs = [ + insert(:assessment_config, %{course: course, order: 1, type: "mission"}), + insert(:assessment_config, %{course: course, order: 2}), + insert(:assessment_config, %{ + course: course, + order: 3, + type: "path" + }), + insert(:assessment_config, %{course: course, order: 4}), + insert(:assessment_config, %{ + course: course, + order: 5, + type: "practical" + }) + ] + assessments = Enum.map( - Assessment.assessment_types(), - &build(:assessment, type: &1, is_published: true) + assessment_configs, + &build(:assessment, + course_id: course.id, + course: course, + config: &1, + config_id: &1.id, + is_published: true + ) ) - assessments_with_type = Enum.into(assessments, %{}, &{&1.type, &1}) + assessments_with_config = Enum.into(assessments, %{}, &{&1, &1.config}) - questions = build_list(5, :question, assessment: nil) + questions = [build(:programming_question), build(:mcq_question), build(:voting_question)] %{ assessments: assessments, questions: questions, - assessments_with_type: assessments_with_type + course: course, + assessment_configs: assessment_configs, + assessments_with_config: assessments_with_config } end describe "Pure XML Parser" do - test "XML Parser happy path", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "XML Parser happy path", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do xml = XMLGenerator.generate_xml_for(assessment, questions) - assert XMLParser.parse_xml(xml) == :ok + assert XMLParser.parse_xml(xml, course.id, assessment_config.id) == :ok number = assessment.number @@ -53,11 +83,13 @@ defmodule Cadet.Updater.XMLParserTest do |> Map.put(:open_at, open_at) |> Map.put(:close_at, close_at) |> Map.put(:is_published, false) + |> Map.put(:course_id, course.id) + |> Map.put(:config_id, assessment_config.id) assert_map_keys( Map.from_struct(expected_assesment), Map.from_struct(assessment_db), - ~w(title is_published type summary_short summary_long open_at close_at)a ++ + ~w(title is_published config_id course_id summary_short summary_long open_at close_at)a ++ ~w(number story reading password)a ) @@ -80,10 +112,11 @@ defmodule Cadet.Updater.XMLParserTest do end test "happy path existing still closed assessment", %{ - assessments: assessments, - questions: questions + questions: questions, + course: course, + assessments_with_config: assessments_with_config } do - for assessment <- assessments do + for {assessment, assessment_config} <- assessments_with_config do still_closed_assessment = Map.from_struct(%{ assessment @@ -97,18 +130,22 @@ defmodule Cadet.Updater.XMLParserTest do xml = XMLGenerator.generate_xml_for(assessment, questions) - assert XMLParser.parse_xml(xml) == :ok + assert XMLParser.parse_xml(xml, course.id, assessment_config.id) == :ok end end - test "PROBLEM with missing type", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "PROBLEM with missing type", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do xml = - XMLGenerator.generate_xml_for(assessment, questions, problem_permit_keys: ~w(maxgrade)a) + XMLGenerator.generate_xml_for(assessment, questions, problem_permit_keys: ~w(maxxp)a) assert capture_log(fn -> assert( - XMLParser.parse_xml(xml) == + XMLParser.parse_xml(xml, course.id, assessment_config.id) == {:error, {:bad_request, "Missing attribute(s) on PROBLEM"}} ) end) =~ @@ -116,13 +153,17 @@ defmodule Cadet.Updater.XMLParserTest do end end - test "PROBLEM with missing maxgrade", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "PROBLEM with missing maxxp", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do xml = XMLGenerator.generate_xml_for(assessment, questions, problem_permit_keys: ~w(type)a) assert capture_log(fn -> assert( - XMLParser.parse_xml(xml) == + XMLParser.parse_xml(xml, course.id, assessment_config.id) == {:error, {:bad_request, "Missing attribute(s) on PROBLEM"}} ) end) =~ @@ -130,21 +171,30 @@ defmodule Cadet.Updater.XMLParserTest do end end - test "Invalid question type", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "Invalid question type", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do xml = XMLGenerator.generate_xml_for(assessment, questions, override_type: "anu") assert capture_log(fn -> assert( - XMLParser.parse_xml(xml) == {:error, {:bad_request, "Invalid question type."}} + XMLParser.parse_xml(xml, course.id, assessment_config.id) == + {:error, {:bad_request, "Invalid question type."}} ) end) =~ "Invalid question type." end end - test "Invalid question changeset", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "Invalid question changeset", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do questions_without_content = Enum.map(questions, &%{&1 | question: %{&1.question | content: ""}}) @@ -152,27 +202,39 @@ defmodule Cadet.Updater.XMLParserTest do # the error message can be quite convoluted assert capture_log(fn -> - assert({:error, {:bad_request, _error_message}} = XMLParser.parse_xml(xml)) + assert( + {:error, {:bad_request, _error_message}} = + XMLParser.parse_xml(xml, course.id, assessment_config.id) + ) end) =~ ~r/Invalid \b.*\b changeset\./ end end - test "missing DEPLOYMENT", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "missing DEPLOYMENT", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do xml = XMLGenerator.generate_xml_for(assessment, questions, no_deployment: true) assert capture_log(fn -> assert( - XMLParser.parse_xml(xml) == {:error, {:bad_request, "Missing DEPLOYMENT"}} + XMLParser.parse_xml(xml, course.id, assessment_config.id) == + {:error, {:bad_request, "Missing DEPLOYMENT"}} ) end) =~ "Missing DEPLOYMENT" end end - test "existing assessment with submissions", %{assessments: assessments, questions: questions} do - for assessment <- assessments do + test "existing assessment with submissions", %{ + questions: questions, + course: course, + assessments_with_config: assessments_with_config + } do + for {assessment, assessment_config} <- assessments_with_config do already_open_assessment = Map.from_struct(%{ assessment @@ -197,7 +259,7 @@ defmodule Cadet.Updater.XMLParserTest do xml = XMLGenerator.generate_xml_for(assessment, questions) assert capture_log(fn -> - assert XMLParser.parse_xml(xml) == + assert XMLParser.parse_xml(xml, course.id, assessment_config.id) == {:ok, "Assessment has submissions, ignoring..."} end) =~ "Assessment has submissions, ignoring..." @@ -207,22 +269,26 @@ defmodule Cadet.Updater.XMLParserTest do describe "XML file processing" do test "happy path", %{ - assessments_with_type: assessments_with_type, - questions: questions + questions: questions, + course: course, + assessments_with_config: assessments_with_config } do - for {_, assessment} <- assessments_with_type do + for {assessment, assessment_config} <- assessments_with_config do xml = XMLGenerator.generate_xml_for(assessment, questions) - assert :ok == XMLParser.parse_xml(xml) + assert :ok == XMLParser.parse_xml(xml, course.id, assessment_config.id) end end - test "empty xml file" do + test "empty xml file", %{assessment_configs: [config | _], course: course} do assert capture_log(fn -> - assert {:error, {:bad_request, _}} = XMLParser.parse_xml("") + assert {:error, {:bad_request, _}} = XMLParser.parse_xml("", course.id, config.id) end) =~ ":expected_element_start_tag" end - test "valid xml file but invalid assessment xml" do + test "valid xml file but invalid assessment xml", %{ + assessment_configs: [config | _], + course: course + } do xml = """