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 = """ Best markup language! @@ -233,7 +299,8 @@ defmodule Cadet.Updater.XMLParserTest do """ assert capture_log(fn -> - {:error, {:bad_request, "Missing TASK"}} == XMLParser.parse_xml(xml) + {:error, {:bad_request, "Missing TASK"}} == + XMLParser.parse_xml(xml, course.id, config.id) end) =~ "Missing TASK" end end diff --git a/test/cadet_web/admin_controllers/admin_achievements_controller_test.exs b/test/cadet_web/admin_controllers/admin_achievements_controller_test.exs index 92eb3d502..7bedaaaed 100644 --- a/test/cadet_web/admin_controllers/admin_achievements_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_achievements_controller_test.exs @@ -14,13 +14,14 @@ defmodule CadetWeb.AdminAchievementsControllerTest do assert is_map(AdminAchievementsController.swagger_path_delete(nil)) end - describe "PUT /admin/achievements/:uuid" do + describe "PUT v2/courses/:course_id/admin/achievements/:uuid" do @tag authenticate: :staff test "succeeds for staff", %{conn: conn} do + course_id = conn.assigns.course_id uuid = UUID.generate() conn - |> put(build_path(uuid), %{"achievement" => achievement_json_literal(0)}) + |> put(build_path(course_id, uuid), %{"achievement" => achievement_json_literal(0)}) |> response(204) ach = Repo.get(Achievement, uuid) @@ -30,10 +31,13 @@ defmodule CadetWeb.AdminAchievementsControllerTest do @tag authenticate: :staff test "succeeds without view", %{conn: conn} do + course_id = conn.assigns.course_id uuid = UUID.generate() conn - |> put(build_path(uuid), %{"achievement" => Map.drop(achievement_json_literal(0), ["view"])}) + |> put(build_path(course_id, uuid), %{ + "achievement" => Map.drop(achievement_json_literal(0), ["view"]) + }) |> response(204) ach = Repo.get(Achievement, uuid) @@ -47,10 +51,11 @@ defmodule CadetWeb.AdminAchievementsControllerTest do @tag authenticate: :student test "403 for student", %{conn: conn} do + course = insert(:course) uuid = UUID.generate() conn - |> put(build_path(uuid), %{"achievement" => achievement_json_literal(0)}) + |> put(build_path(course.id, uuid), %{"achievement" => achievement_json_literal(0)}) |> response(403) assert Achievement |> Repo.get(uuid) |> is_nil() @@ -67,7 +72,7 @@ defmodule CadetWeb.AdminAchievementsControllerTest do end end - describe "PUT /admin/achievements" do + describe "PUT v2/courses/:course_id/admin/achievements" do setup do %{ achievements: [ @@ -79,8 +84,10 @@ defmodule CadetWeb.AdminAchievementsControllerTest do @tag authenticate: :staff test "succeeds for staff", %{conn: conn, achievements: achs = [a1, a2]} do + course_id = conn.assigns.course_id + conn - |> put(build_path(), %{ + |> put(build_path(course_id), %{ "achievements" => achs }) |> response(204) @@ -91,8 +98,10 @@ defmodule CadetWeb.AdminAchievementsControllerTest do @tag authenticate: :student test "403 for student", %{conn: conn, achievements: achs = [a1, a2]} do + course_id = conn.assigns.course_id + conn - |> put(build_path(), %{ + |> put(build_path(course_id), %{ "achievements" => achs }) |> response(403) @@ -102,8 +111,10 @@ defmodule CadetWeb.AdminAchievementsControllerTest do end test "401 if unauthenticated", %{conn: conn, achievements: achs = [a1, a2]} do + course = insert(:course) + conn - |> put(build_path(), %{ + |> put(build_path(course.id), %{ "achievements" => achs }) |> response(401) @@ -113,48 +124,62 @@ defmodule CadetWeb.AdminAchievementsControllerTest do end end - describe "DELETE /admin/achievements/:uuid" do - setup do - {:ok, a} = - %Achievement{uuid: UUID.generate()} |> Map.merge(achievement_literal(5)) |> Repo.insert() + describe "DELETE v2/courses/:course_id/admin/achievements/:uuid" do + @tag authenticate: :staff + test "succeeds for staff", %{conn: conn} do + course_id = conn.assigns.course_id - %{achievement: a} - end + {:ok, a} = + %Achievement{course_id: course_id, uuid: UUID.generate()} + |> Map.merge(achievement_literal(5)) + |> Repo.insert() - @tag authenticate: :staff - test "succeeds for staff", %{conn: conn, achievement: a} do conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course_id, a.uuid)) |> response(204) assert Achievement |> Repo.get(a.uuid) |> is_nil() end @tag authenticate: :student - test "403 for student", %{conn: conn, achievement: a} do + test "403 for student", %{conn: conn} do + course_id = conn.assigns.course_id + + {:ok, a} = + %Achievement{course_id: course_id, uuid: UUID.generate()} + |> Map.merge(achievement_literal(5)) + |> Repo.insert() + conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course_id, a.uuid)) |> response(403) assert achievement_literal(5) = Repo.get(Achievement, a.uuid) end - test "401 if unauthenticated", %{conn: conn, achievement: a} do + test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) + + {:ok, a} = + %Achievement{course_id: course.id, uuid: UUID.generate()} + |> Map.merge(achievement_literal(5)) + |> Repo.insert() + conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course.id, a.uuid)) |> response(401) assert achievement_literal(5) = Repo.get(Achievement, a.uuid) end end - defp build_path(uuid \\ nil) + defp build_path(course_id, uuid \\ nil) - defp build_path(nil) do - "/v2/admin/achievements" + defp build_path(course_id, nil) do + "/v2/courses/#{course_id}/admin/achievements" end - defp build_path(uuid) do - "/v2/admin/achievements/#{uuid}" + defp build_path(course_id, uuid) do + "/v2/courses/#{course_id}/admin/achievements/#{uuid}" end end diff --git a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs index af331a334..0f310020d 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -29,14 +29,26 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end describe "POST /, unauthenticated" do - test "unauthorized", %{conn: conn} do - assessment = build(:assessment, type: "mission", is_published: true) + test "unauthorized", %{ + conn: conn, + courses: %{course1: course1}, + assessment_configs: [config | _] + } do + assessment = + build(:assessment, + course_id: course1.id, + course: course1, + config: config, + config_id: config.id, + is_published: true + ) + questions = build_list(5, :question, assessment: nil) xml = XMLGenerator.generate_xml_for(assessment, questions) file = File.write("test/fixtures/local_repo/test.xml", xml) force_update = "false" body = %{assessment: file, forceUpdate: force_update} - conn = post(conn, build_url(), body) + conn = post(conn, build_url(course1.id), body) assert response(conn, 401) =~ "Unauthorised" end end @@ -44,12 +56,25 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /, student only" do @tag authenticate: :student test "unauthorized", %{conn: conn} do - assessment = build(:assessment, type: "mission", is_published: true) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + + assessment = + build(:assessment, + course: course, + course_id: course.id, + config: config, + config_id: config.id, + is_published: true + ) + questions = build_list(5, :question, assessment: nil) + xml = XMLGenerator.generate_xml_for(assessment, questions) force_update = "false" - body = %{assessment: xml, forceUpdate: force_update} - conn = post(conn, build_url(), body) + body = %{assessment: xml, forceUpdate: force_update, assessmentConfigId: config.id} + conn = post(conn, build_url(course.id), body) assert response(conn, 403) == "Forbidden" end end @@ -57,7 +82,19 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /, staff only" do @tag authenticate: :staff test "successful", %{conn: conn} do - assessment = build(:assessment, type: "mission", is_published: true) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + + assessment = + build(:assessment, + course: course, + course_id: course.id, + config: config, + config_id: config.id, + is_published: true + ) + questions = build_list(5, :question, assessment: nil) xml = XMLGenerator.generate_xml_for(assessment, questions) @@ -74,8 +111,13 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do path: location } - body = %{assessment: %{file: formdata}, forceUpdate: force_update} - conn = post(conn, build_url(), body) + body = %{ + assessment: %{file: formdata}, + forceUpdate: force_update, + assessmentConfigId: config.id + } + + conn = post(conn, build_url(course.id), body) number = assessment.number expected_assessment = @@ -89,6 +131,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "upload empty xml", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + xml = "" force_update = "true" path = Path.join(@local_name, "connTest") @@ -103,13 +149,17 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do path: location } - body = %{assessment: %{file: formdata}, forceUpdate: force_update} + body = %{ + assessment: %{file: formdata}, + forceUpdate: force_update, + assessmentConfigId: config.id + } err_msg = "Invalid XML fatal expected_element_start_tag file file_name_unknown line 1 col 1 " assert capture_log(fn -> - conn = post(conn, build_url(), body) + conn = post(conn, build_url(course.id), body) assert(response(conn, 400) == err_msg) end) =~ ~r/.*fatal: :expected_element_start_tag.*/ end @@ -118,7 +168,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "DELETE /:assessment_id, unauthenticated" do test "unauthorized", %{conn: conn} do assessment = insert(:assessment) - conn = delete(conn, build_url(assessment.id)) + conn = delete(conn, build_url(assessment.course.id, assessment.id)) assert response(conn, 401) =~ "Unauthorised" end end @@ -126,8 +176,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "DELETE /:assessment_id, student only" do @tag authenticate: :student test "unauthorized", %{conn: conn} do - assessment = insert(:assessment) - conn = delete(conn, build_url(assessment.id)) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + conn = delete(conn, build_url(course.id, assessment.id)) assert response(conn, 403) == "Forbidden" end end @@ -135,8 +188,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "DELETE /:assessment_id, staff only" do @tag authenticate: :staff test "successful", %{conn: conn} do - assessment = insert(:assessment) - conn = delete(conn, build_url(assessment.id)) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + conn = delete(conn, build_url(course.id, assessment.id)) assert response(conn, 200) == "OK" assert is_nil(Repo.get(Assessment, assessment.id)) end @@ -145,7 +201,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, unauthenticated, publish" do test "unauthorized", %{conn: conn} do assessment = insert(:assessment) - conn = post(conn, build_url(assessment.id), %{isPublished: true}) + conn = post(conn, build_url(assessment.course.id, assessment.id), %{isPublished: true}) assert response(conn, 401) =~ "Unauthorised" end end @@ -153,8 +209,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, student only, publish" do @tag authenticate: :student test "forbidden", %{conn: conn} do - assessment = insert(:assessment) - conn = post(conn, build_url(assessment.id), %{isPublished: true}) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + conn = post(conn, build_url(course.id, assessment.id), %{isPublished: true}) assert response(conn, 403) == "Forbidden" end end @@ -162,8 +221,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, staff only, publish" do @tag authenticate: :staff test "successful toggle from published to unpublished", %{conn: conn} do - assessment = insert(:assessment, is_published: true) - conn = post(conn, build_url(assessment.id), %{isPublished: false}) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + conn = post(conn, build_url(course.id, assessment.id), %{isPublished: false}) expected = Repo.get(Assessment, assessment.id).is_published assert response(conn, 200) == "OK" refute expected @@ -171,8 +233,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "successful toggle from unpublished to published", %{conn: conn} do - assessment = insert(:assessment, is_published: false) - conn = post(conn, build_url(assessment.id), %{isPublished: true}) + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config, is_published: false}) + conn = post(conn, build_url(course.id, assessment.id), %{isPublished: true}) expected = Repo.get(Assessment, assessment.id).is_published assert response(conn, 200) == "OK" assert expected @@ -182,7 +247,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, unauthenticated" do test "unauthorized", %{conn: conn} do assessment = insert(:assessment) - conn = post(conn, build_url(assessment.id)) + conn = post(conn, build_url(assessment.course.id, assessment.id)) assert response(conn, 401) =~ "Unauthorised" end end @@ -190,6 +255,11 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, student only" do @tag authenticate: :student test "forbidden", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{course: course, config: config}) + new_open_at = Timex.now() |> Timex.beginning_of_day() @@ -207,8 +277,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.format!("{ISO:Extended}") new_dates = %{openAt: new_open_at_string, closeAt: new_close_at_string} - assessment = insert(:assessment) - conn = post(conn, build_url(assessment.id), new_dates) + conn = post(conn, build_url(course.id, assessment.id), new_dates) assert response(conn, 403) == "Forbidden" end end @@ -216,6 +285,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do describe "POST /:assessment_id, staff only" do @tag authenticate: :staff test "successful", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + open_at = Timex.now() |> Timex.beginning_of_day() @@ -223,7 +296,14 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.shift(hours: 4) close_at = Timex.shift(open_at, days: 7) - assessment = insert(:assessment, %{open_at: open_at, close_at: close_at}) + + assessment = + insert(:assessment, %{ + course: course, + config: config, + open_at: open_at, + close_at: close_at + }) new_open_at = open_at @@ -245,7 +325,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do conn = conn - |> post(build_url(assessment.id), new_dates) + |> post(build_url(course.id, assessment.id), new_dates) assessment = Repo.get(Assessment, assessment.id) assert response(conn, 200) == "OK" @@ -254,6 +334,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "allowed to change open time of opened assessments", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + open_at = Timex.now() |> Timex.beginning_of_day() @@ -261,7 +345,14 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.shift(hours: 4) close_at = Timex.shift(open_at, days: 7) - assessment = insert(:assessment, %{open_at: open_at, close_at: close_at}) + + assessment = + insert(:assessment, %{ + course: course, + config: config, + open_at: open_at, + close_at: close_at + }) new_open_at = open_at @@ -279,7 +370,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do conn = conn - |> post(build_url(assessment.id), new_dates) + |> post(build_url(course.id, assessment.id), new_dates) assessment = Repo.get(Assessment, assessment.id) assert response(conn, 200) == "OK" @@ -288,6 +379,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "not allowed to set close time to before open time", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + open_at = Timex.now() |> Timex.beginning_of_day() @@ -295,7 +390,14 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.shift(hours: 4) close_at = Timex.shift(open_at, days: 7) - assessment = insert(:assessment, %{open_at: open_at, close_at: close_at}) + + assessment = + insert(:assessment, %{ + course: course, + config: config, + open_at: open_at, + close_at: close_at + }) new_close_at = open_at @@ -313,7 +415,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do conn = conn - |> post(build_url(assessment.id), new_dates) + |> post(build_url(course.id, assessment.id), new_dates) assessment = Repo.get(Assessment, assessment.id) assert response(conn, 400) == "New end date should occur after new opening date" @@ -322,6 +424,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "successful, set close time to before current time", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + open_at = Timex.now() |> Timex.beginning_of_day() @@ -329,7 +435,14 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.shift(hours: 4) close_at = Timex.shift(open_at, days: 7) - assessment = insert(:assessment, %{open_at: open_at, close_at: close_at}) + + assessment = + insert(:assessment, %{ + course: course, + config: config, + open_at: open_at, + close_at: close_at + }) new_close_at = Timex.now() @@ -347,7 +460,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do conn = conn - |> post(build_url(assessment.id), new_dates) + |> post(build_url(course.id, assessment.id), new_dates) assessment = Repo.get(Assessment, assessment.id) assert response(conn, 200) == "OK" @@ -356,6 +469,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do @tag authenticate: :staff test "successful, set open time to before current time", %{conn: conn} do + test_cr = conn.assigns.test_cr + course = test_cr.course + config = insert(:assessment_config, %{course: course}) + open_at = Timex.now() |> Timex.beginning_of_day() @@ -363,7 +480,14 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do |> Timex.shift(hours: 4) close_at = Timex.shift(open_at, days: 7) - assessment = insert(:assessment, %{open_at: open_at, close_at: close_at}) + + assessment = + insert(:assessment, %{ + course: course, + config: config, + open_at: open_at, + close_at: close_at + }) new_open_at = Timex.now() @@ -381,7 +505,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do conn = conn - |> post(build_url(assessment.id), new_dates) + |> post(build_url(course.id, assessment.id), new_dates) assessment = Repo.get(Assessment, assessment.id) assert response(conn, 200) == "OK" @@ -389,6 +513,8 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - defp build_url, do: "/v2/admin/assessments/" - defp build_url(assessment_id), do: "/v2/admin/assessments/#{assessment_id}" + defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/assessments/" + + defp build_url(course_id, assessment_id), + do: "/v2/courses/#{course_id}/admin/assessments/#{assessment_id}" end diff --git a/test/cadet_web/admin_controllers/admin_assets_controller_test.exs b/test/cadet_web/admin_controllers/admin_assets_controller_test.exs index 04e424275..7cddf0562 100644 --- a/test/cadet_web/admin_controllers/admin_assets_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assets_controller_test.exs @@ -17,17 +17,20 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "public access, unauthenticated" do test "GET /assets/:foldername", %{conn: conn} do - conn = get(conn, build_url("random_folder"), %{}) + course = insert(:course) + conn = get(conn, build_url(course.id, "random_folder"), %{}) assert response(conn, 401) =~ "Unauthorised" end test "DELETE /assets/:foldername/*filename", %{conn: conn} do - conn = delete(conn, build_url("random_folder/random_file"), %{}) + course = insert(:course) + conn = delete(conn, build_url(course.id, "random_folder/random_file"), %{}) assert response(conn, 401) =~ "Unauthorised" end test "POST /assets/:foldername/*filename", %{conn: conn} do - conn = post(conn, build_url("random_folder/random_file"), %{}) + course = insert(:course) + conn = post(conn, build_url(course.id, "random_folder/random_file"), %{}) assert response(conn, 401) =~ "Unauthorised" end end @@ -35,21 +38,25 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "student permission, forbidden" do @tag authenticate: :student test "GET /assets/:foldername", %{conn: conn} do - conn = get(conn, build_url("testFolder"), %{}) + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id, "testFolder"), %{}) assert response(conn, 403) =~ "Forbidden" end @tag authenticate: :student test "DELETE /assets/:foldername/*filename", %{conn: conn} do - conn = delete(conn, build_url("testFolder/testFile.png")) + course_id = conn.assigns.course_id + conn = delete(conn, build_url(course_id, "testFolder/testFile.png")) assert response(conn, 403) =~ "Forbidden" end @tag authenticate: :student test "POST /assets/:foldername/*filename", %{conn: conn} do + course_id = conn.assigns.course_id + conn = - post(conn, build_url("testFolder/testFile.png"), %{ + post(conn, build_url(course_id, "testFolder/testFile.png"), %{ :upload => build_upload("test/fixtures/upload.png") }) @@ -60,21 +67,25 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "inaccessible folder name" do @tag authenticate: :staff test "index files", %{conn: conn} do - conn = get(conn, build_url("wrongFolder")) + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id, "wrongFolder")) assert response(conn, 400) =~ "Invalid top-level folder name" end @tag authenticate: :staff test "delete file", %{conn: conn} do - conn = delete(conn, build_url("wrongFolder/randomFile")) + course_id = conn.assigns.course_id + conn = delete(conn, build_url(course_id, "wrongFolder/randomFile")) assert response(conn, 400) =~ "Invalid top-level folder name" end @tag authenticate: :staff test "upload file", %{conn: conn} do + course_id = conn.assigns.course_id + conn = - post(conn, build_url("wrongFolder/wrongUpload.png"), %{ + post(conn, build_url(course_id, "wrongFolder/wrongUpload.png"), %{ "upload" => build_upload("test/fixtures/upload.png") }) @@ -85,8 +96,10 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "ok request" do @tag authenticate: :staff test "index file", %{conn: conn} do + course_id = conn.assigns.course_id + use_cassette "aws/controller_list_assets#1" do - conn = get(conn, build_url("testFolder"), %{}) + conn = get(conn, build_url(course_id, "testFolder"), %{}) assert json_response(conn, 200) === ["testFolder/", "testFolder/test.png", "testFolder/test2.png"] @@ -95,8 +108,10 @@ defmodule CadetWeb.AdminAssetsControllerTest do @tag authenticate: :staff test "delete file", %{conn: conn} do + course_id = conn.assigns.course_id + use_cassette "aws/controller_delete_asset#1" do - conn = delete(conn, build_url("testFolder/test2.png")) + conn = delete(conn, build_url(course_id, "testFolder/test2.png")) assert response(conn, 204) end @@ -104,9 +119,11 @@ defmodule CadetWeb.AdminAssetsControllerTest do @tag authenticate: :staff test "upload file", %{conn: conn} do + course_id = conn.assigns.course_id + use_cassette "aws/controller_upload_asset#1" do conn = - post(conn, build_url("testFolder/test.png"), %{ + post(conn, build_url(course_id, "testFolder/test.png"), %{ "upload" => build_upload("test/fixtures/upload.png") }) @@ -119,8 +136,10 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "wrong file type" do @tag authenticate: :staff test "upload file", %{conn: conn} do + course_id = conn.assigns.course_id + conn = - post(conn, build_url("testFolder/test.pdf"), %{ + post(conn, build_url(course_id, "testFolder/test.pdf"), %{ "upload" => build_upload("test/fixtures/upload.pdf") }) @@ -131,8 +150,10 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "empty file name" do @tag authenticate: :staff test "upload file", %{conn: conn} do + course_id = conn.assigns.course_id + conn = - post(conn, build_url("testFolder"), %{ + post(conn, build_url(course_id, "testFolder"), %{ "upload" => build_upload("test/fixtures/upload.png") }) @@ -141,7 +162,8 @@ defmodule CadetWeb.AdminAssetsControllerTest do @tag authenticate: :staff test "delete file", %{conn: conn} do - conn = delete(conn, build_url("testFolder")) + course_id = conn.assigns.course_id + conn = delete(conn, build_url(course_id, "testFolder")) assert response(conn, 400) =~ "Empty file name" end end @@ -149,8 +171,10 @@ defmodule CadetWeb.AdminAssetsControllerTest do describe "nested filename request" do @tag authenticate: :staff test "delete file", %{conn: conn} do + course_id = conn.assigns.course_id + use_cassette "aws/controller_delete_asset#2" do - conn = delete(conn, build_url("testFolder/nestedFolder/test2.png")) + conn = delete(conn, build_url(course_id, "testFolder/nestedFolder/test2.png")) assert response(conn, 204) end @@ -158,9 +182,11 @@ defmodule CadetWeb.AdminAssetsControllerTest do @tag authenticate: :staff test "upload file", %{conn: conn} do + course_id = conn.assigns.course_id + use_cassette "aws/controller_upload_asset#2" do conn = - post(conn, build_url("testFolder/nestedFolder/test.png"), %{ + post(conn, build_url(course_id, "testFolder/nestedFolder/test.png"), %{ "upload" => build_upload("test/fixtures/upload.png") }) @@ -170,8 +196,8 @@ defmodule CadetWeb.AdminAssetsControllerTest do end end - defp build_url, do: "/v2/admin/assets/" - defp build_url(url), do: "#{build_url()}/#{url}" + defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/assets/" + defp build_url(course_id, url), do: "#{build_url(course_id)}/#{url}" defp build_upload(path, content_type \\ "image/png") do %Plug.Upload{path: path, filename: Path.basename(path), content_type: content_type} diff --git a/test/cadet_web/admin_controllers/admin_courses_controller_test.exs b/test/cadet_web/admin_controllers/admin_courses_controller_test.exs new file mode 100644 index 000000000..eaa8f1e4d --- /dev/null +++ b/test/cadet_web/admin_controllers/admin_courses_controller_test.exs @@ -0,0 +1,358 @@ +defmodule CadetWeb.AdminCoursesControllerTest do + use CadetWeb.ConnCase + + import Cadet.SharedHelper + + alias Cadet.{Repo, Courses} + alias Cadet.Courses.Course + alias CadetWeb.AdminCoursesController + + test "swagger" do + AdminCoursesController.swagger_definitions() + AdminCoursesController.swagger_path_update_course_config(nil) + AdminCoursesController.swagger_path_update_assessment_configs(nil) + end + + describe "PUT /v2/courses/{course_id}/admin/config" do + @tag authenticate: :admin + test "succeeds 1", %{conn: conn} do + course_id = conn.assigns[:course_id] + old_course = to_map(Repo.get(Course, course_id)) + + params = %{ + "sourceChapter" => 2, + "sourceVariant" => "lazy" + } + + resp = put(conn, build_url_course_config(course_id), params) + + assert response(resp, 200) == "OK" + updated_course = to_map(Repo.get(Course, course_id)) + refute old_course == updated_course + assert update_map(old_course, params) == updated_course + end + + @tag authenticate: :admin + test "succeeds 2", %{conn: conn} do + course_id = conn.assigns[:course_id] + old_course = to_map(Repo.get(Course, course_id)) + + params = %{ + "courseName" => "Data Structures and Algorithms", + "courseShortName" => "CS2040S", + "enableGame" => false, + "enableAchievements" => false, + "enableSourcecast" => true, + "sourceChapter" => 1, + "sourceVariant" => "default", + "moduleHelpText" => "help" + } + + resp = put(conn, build_url_course_config(course_id), params) + + assert response(resp, 200) == "OK" + updated_course = to_map(Repo.get(Course, course_id)) + refute old_course == updated_course + assert update_map(old_course, params) == updated_course + end + + @tag authenticate: :admin + test "succeeds 3", %{conn: conn} do + course_id = conn.assigns[:course_id] + old_course = to_map(Repo.get(Course, course_id)) + + params = %{ + "courseName" => "Data Structures and Algorithms", + "courseShortName" => "CS2040S", + "enableGame" => false, + "enableAchievements" => false, + "enableSourcecast" => true, + "moduleHelpText" => "help" + } + + resp = put(conn, build_url_course_config(course_id), params) + + assert response(resp, 200) == "OK" + updated_course = to_map(Repo.get(Course, course_id)) + refute old_course == updated_course + assert update_map(old_course, params) == updated_course + end + + @tag authenticate: :student + test "rejects forbidden request for non-staff users", %{conn: conn} do + course_id = conn.assigns[:course_id] + old_course = Repo.get(Course, course_id) + + conn = + put(conn, build_url_course_config(course_id), %{ + "sourceChapter" => 3, + "sourceVariant" => "concurrent" + }) + + same_course = Repo.get(Course, course_id) + + assert response(conn, 403) == "Forbidden" + assert old_course == same_course + end + + @tag authenticate: :staff + test "rejects requests if user does not belong to the specified course", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_course_config(course_id + 1), %{ + "sourceChapter" => 3, + "sourceVariant" => "concurrent" + }) + + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :staff + test "rejects requests with invalid params", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_course_config(course_id), %{ + "sourceChapter" => 4, + "sourceVariant" => "wasm" + }) + + assert response(conn, 400) == "Invalid parameter(s)" + end + + @tag authenticate: :staff + test "rejects requests with missing params", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_course_config(course_id), %{ + "courseName" => "Data Structures and Algorithms", + "courseShortName" => "CS2040S", + "enableGame" => false, + "enableAchievements" => false, + "enableSourcecast" => true, + "moduleHelpText" => "help", + "sourceVariant" => "default" + }) + + assert response(conn, 400) == "Invalid parameter(s)" + end + end + + describe "GET /v2/courses/{course_id}/admin/configs/assessment_configs" do + @tag authenticate: :admin + test "succeeds", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + config1 = insert(:assessment_config, %{order: 1, type: "Mission1", course: course}) + config3 = insert(:assessment_config, %{order: 3, type: "Mission3", course: course}) + + config2 = + insert(:assessment_config, %{ + show_grading_summary: false, + is_manually_graded: false, + order: 2, + type: "Mission2", + course: course + }) + + resp = + conn + |> get(build_url_assessment_configs(course_id)) + |> json_response(200) + + expected = [ + %{ + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48, + "displayInDashboard" => true, + "isManuallyGraded" => true, + "type" => "Mission1", + "assessmentConfigId" => config1.id + }, + %{ + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48, + "displayInDashboard" => false, + "isManuallyGraded" => false, + "type" => "Mission2", + "assessmentConfigId" => config2.id + }, + %{ + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48, + "displayInDashboard" => true, + "isManuallyGraded" => true, + "type" => "Mission3", + "assessmentConfigId" => config3.id + } + ] + + assert expected == resp + end + + @tag authenticate: :student + test "rejects forbidden request for non-staff users", %{conn: conn} do + course_id = conn.assigns[:course_id] + + resp = get(conn, build_url_assessment_configs(course_id)) + + assert response(resp, 403) == "Forbidden" + end + end + + describe "PUT /v2/courses/{course_id}/admin/config/assessment_configs" do + @tag authenticate: :admin + test "succeeds", %{conn: conn} do + course_id = conn.assigns[:course_id] + config = insert(:assessment_config, %{course: Repo.get(Course, course_id)}) + + old_configs = course_id |> Courses.get_assessment_configs() |> Enum.map(& &1.type) + + params = %{ + "assessmentConfigs" => [ + %{ + "assessmentConfigId" => config.id, + "courseId" => course_id, + "type" => "Missions", + "displayInDashboard" => true, + "earlySubmissionXp" => 100, + "hoursBeforeEarlyXpDecay" => 24 + }, + %{ + "assessmentConfigId" => -1, + "courseId" => course_id, + "type" => "Paths", + "displayInDashboard" => true, + "earlySubmissionXp" => 100, + "hoursBeforeEarlyXpDecay" => 24 + } + ] + } + + resp = + conn + |> put(build_url_assessment_configs(course_id), params) + |> response(200) + + assert resp == "OK" + + new_configs = course_id |> Courses.get_assessment_configs() |> Enum.map(& &1.type) + refute old_configs == new_configs + assert new_configs == ["Missions", "Paths"] + end + + @tag authenticate: :student + test "rejects forbidden request for non-staff users", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_assessment_configs(course_id), %{ + "assessmentConfigs" => [] + }) + + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :staff + test "rejects request if user is not in specified course", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_assessment_configs(course_id + 1), %{ + "assessmentConfigs" => [] + }) + + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :staff + test "rejects requests with invalid params 1", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_assessment_configs(course_id), %{ + "assessmentConfigs" => "Missions" + }) + + assert response(conn, 400) == "missing assessmentConfig" + end + + @tag authenticate: :staff + test "rejects requests with invalid params 2", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + put(conn, build_url_assessment_configs(course_id), %{ + "assessmentConfigs" => [1, "Missions", "Quests"] + }) + + assert response(conn, 400) == + "assessmentConfigs should be a list of assessment configuration objects" + end + + @tag authenticate: :staff + test "rejects requests with missing params", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = put(conn, build_url_assessment_configs(course_id), %{}) + + assert response(conn, 400) == "missing assessmentConfig" + end + end + + describe "DELETE /v2/courses/{course_id}/admin/config/assessment_config" do + @tag authenticate: :admin + test "succeeds", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + config1 = insert(:assessment_config, %{order: 1, course: course, type: "Missions"}) + _config2 = insert(:assessment_config, %{order: 2, course: course, type: "Paths"}) + + old_configs = course_id |> Courses.get_assessment_configs() |> Enum.map(& &1.type) + + resp = + conn + |> delete(build_url_assessment_config(course_id, config1.id)) + |> response(200) + + assert resp == "OK" + + new_configs = course_id |> Courses.get_assessment_configs() |> Enum.map(& &1.type) + refute old_configs == new_configs + assert new_configs == ["Paths"] + end + + @tag authenticate: :student + test "rejects forbidden request for non-staff users", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = delete(conn, build_url_assessment_config(course_id, 1)) + + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :staff + test "rejects request if user is not in specified course", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = delete(conn, build_url_assessment_config(course_id + 1, 1)) + + assert response(conn, 403) == "Forbidden" + end + end + + defp build_url_course_config(course_id), do: "/v2/courses/#{course_id}/admin/config" + + defp build_url_assessment_configs(course_id), + do: "/v2/courses/#{course_id}/admin/config/assessment_configs" + + defp build_url_assessment_config(course_id, config_id), + do: "/v2/courses/#{course_id}/admin/config/assessment_config/#{config_id}" + + defp to_map(schema), do: schema |> Map.from_struct() |> Map.drop([:updated_at]) + + defp update_map(map1, params), + do: Map.merge(map1, to_snake_case_atom_keys(params), fn _k, _v1, v2 -> v2 end) +end diff --git a/test/cadet_web/admin_controllers/admin_goals_controller_test.exs b/test/cadet_web/admin_controllers/admin_goals_controller_test.exs index 1059952c8..c501faf95 100644 --- a/test/cadet_web/admin_controllers/admin_goals_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_goals_controller_test.exs @@ -4,7 +4,6 @@ defmodule CadetWeb.AdminGoalsControllerTest do import Cadet.TestEntityHelper alias Cadet.Repo - alias Cadet.Accounts.User alias Cadet.Incentives.{Goal, Goals} alias CadetWeb.AdminGoalsController alias Ecto.UUID @@ -18,18 +17,19 @@ defmodule CadetWeb.AdminGoalsControllerTest do assert is_map(AdminGoalsController.swagger_path_update_progress(nil)) end - describe "GET /admin/goals" do - setup do - {:ok, g} = %Goal{uuid: UUID.generate()} |> Map.merge(goal_literal(5)) |> Repo.insert() + describe "GET v2/courses/:course_id/admin/goals" do + @tag authenticate: :staff + test "succeeds for staff", %{conn: conn} do + course_id = conn.assigns.course_id - %{goal: g} - end + {:ok, goal} = + %Goal{course_id: course_id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() - @tag authenticate: :staff - test "succeeds for staff", %{conn: conn, goal: goal} do [resp_goal] = conn - |> get(build_path()) + |> get(build_path(course_id)) |> json_response(200) assert goal_json_literal(5) = resp_goal @@ -38,25 +38,30 @@ defmodule CadetWeb.AdminGoalsControllerTest do @tag authenticate: :student test "403 for student", %{conn: conn} do + course_id = conn.assigns.course_id + conn - |> get(build_path()) + |> get(build_path(course_id)) |> response(403) end test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) + conn - |> get(build_path()) + |> get(build_path(course.id)) |> response(401) end end - describe "PUT /admin/goals/:uuid" do + describe "PUT v2/courses/:course_id/admin/goals/:uuid" do @tag authenticate: :staff test "succeeds for staff", %{conn: conn} do + course_id = conn.assigns.course_id uuid = UUID.generate() conn - |> put(build_path(uuid), %{"goal" => goal_json_literal(0)}) + |> put(build_path(course_id, uuid), %{"goal" => goal_json_literal(0)}) |> response(204) ach = Repo.get(Goal, uuid) @@ -66,27 +71,29 @@ defmodule CadetWeb.AdminGoalsControllerTest do @tag authenticate: :student test "403 for student", %{conn: conn} do + course_id = conn.assigns.course_id uuid = UUID.generate() conn - |> put(build_path(uuid), %{"goal" => goal_json_literal(0)}) + |> put(build_path(course_id, uuid), %{"goal" => goal_json_literal(0)}) |> response(403) assert Goal |> Repo.get(uuid) |> is_nil() end test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) uuid = UUID.generate() conn - |> put(build_path(uuid), %{"goal" => goal_json_literal(0)}) + |> put(build_path(course.id, uuid), %{"goal" => goal_json_literal(0)}) |> response(401) assert Goal |> Repo.get(uuid) |> is_nil() end end - describe "PUT /admin/goals" do + describe "PUT v2/courses/:course_id/admin/goals" do setup do %{ goals: [ @@ -98,8 +105,10 @@ defmodule CadetWeb.AdminGoalsControllerTest do @tag authenticate: :staff test "succeeds for staff", %{conn: conn, goals: goals = [a1, a2]} do + course_id = conn.assigns.course_id + conn - |> put(build_path(), %{ + |> put(build_path(course_id), %{ "goals" => goals }) |> response(204) @@ -110,8 +119,10 @@ defmodule CadetWeb.AdminGoalsControllerTest do @tag authenticate: :student test "403 for student", %{conn: conn, goals: goals = [a1, a2]} do + course_id = conn.assigns.course_id + conn - |> put(build_path(), %{ + |> put(build_path(course_id), %{ "goals" => goals }) |> response(403) @@ -121,8 +132,10 @@ defmodule CadetWeb.AdminGoalsControllerTest do end test "401 if unauthenticated", %{conn: conn, goals: goals = [a1, a2]} do + course = insert(:course) + conn - |> put(build_path(), %{ + |> put(build_path(course.id), %{ "goals" => goals }) |> response(401) @@ -132,85 +145,124 @@ defmodule CadetWeb.AdminGoalsControllerTest do end end - describe "DELETE /admin/goals/:uuid" do - setup do - {:ok, a} = %Goal{uuid: UUID.generate()} |> Map.merge(goal_literal(5)) |> Repo.insert() + describe "DELETE v2/courses/:course_id/admin/goals/:uuid" do + @tag authenticate: :staff + test "succeeds for staff", %{conn: conn} do + course_id = conn.assigns.course_id - %{goal: a} - end + {:ok, a} = + %Goal{course_id: course_id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() - @tag authenticate: :staff - test "succeeds for staff", %{conn: conn, goal: a} do conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course_id, a.uuid)) |> response(204) assert Goal |> Repo.get(a.uuid) |> is_nil() end @tag authenticate: :student - test "403 for student", %{conn: conn, goal: a} do + test "403 for student", %{conn: conn} do + course_id = conn.assigns.course_id + + {:ok, a} = + %Goal{course_id: course_id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() + conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course_id, a.uuid)) |> response(403) assert goal_literal(5) = Repo.get(Goal, a.uuid) end - test "401 if unauthenticated", %{conn: conn, goal: a} do + test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) + + {:ok, a} = + %Goal{course_id: course.id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() + conn - |> delete(build_path(a.uuid)) + |> delete(build_path(course.id, a.uuid)) |> response(401) assert goal_literal(5) = Repo.get(Goal, a.uuid) end end - describe "POST /admin/users/:userid/goals/:uuid/progress" do - setup do - {:ok, g} = %Goal{uuid: UUID.generate()} |> Map.merge(goal_literal(5)) |> Repo.insert() - {:ok, u} = %User{name: "a", role: :student} |> Repo.insert() + describe "POST v2/courses/:course_id/goals/:uuid/progress/:course_reg_id" do + @tag authenticate: :staff + test "succeeds for staff", %{conn: conn} do + course = conn.assigns.test_cr.course - %{goal: g, user: u} - end + {:ok, g} = + %Goal{course_id: course.id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() + + course_reg = insert(:course_registration, %{course: course, role: :student}) - @tag authenticate: :staff - test "succeeds for staff", %{conn: conn, goal: g, user: u} do conn - |> post("/v2/admin/users/#{u.id}/goals/#{g.uuid}/progress", %{ - "progress" => %{count: 100, completed: false, userid: u.id, uuid: g.uuid} + |> post(build_path(course.id, g.uuid, course_reg.id), %{ + "progress" => %{count: 100, completed: false, course_reg_id: course_reg.id, uuid: g.uuid} }) |> response(204) - retrieved_goal = Goals.get_with_progress(u) + retrieved_goal = Goals.get_with_progress(course_reg) assert [%{progress: [%{count: 100, completed: false}]}] = retrieved_goal end @tag authenticate: :student - test "403 for student", %{conn: conn, goal: g, user: u} do + test "403 for student", %{conn: conn} do + course = conn.assigns.test_cr.course + + {:ok, g} = + %Goal{course_id: course.id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() + + course_reg = insert(:course_registration, %{course: course, role: :student}) + conn - |> post("/v2/admin/users/#{u.id}/goals/#{g.uuid}/progress", %{ - "progress" => %{count: 100, completed: false, userid: u.id, uuid: g.uuid} + |> post(build_path(course.id, g.uuid, course_reg.id), %{ + "progress" => %{count: 100, completed: false, course_reg_id: course_reg.id, uuid: g.uuid} }) |> response(403) end - test "401 if unauthenticated", %{conn: conn, goal: g, user: u} do + test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) + + {:ok, g} = + %Goal{course_id: course.id, uuid: UUID.generate()} + |> Map.merge(goal_literal(5)) + |> Repo.insert() + + course_reg = insert(:course_registration, %{course: course, role: :student}) + conn - |> post("/v2/admin/users/#{u.id}/goals/#{g.uuid}/progress", %{ - "progress" => %{count: 100, completed: false, userid: u.id, uuid: g.uuid} + |> post(build_path(course.id, g.uuid, course_reg.id), %{ + "progress" => %{count: 100, completed: false, course_reg_id: course_reg.id, uuid: g.uuid} }) |> response(401) end end - defp build_path(uuid \\ nil) + defp build_path(course_id, uuid \\ nil) + + defp build_path(course_id, nil) do + "/v2/courses/#{course_id}/admin/goals" + end - defp build_path(nil) do - "/v2/admin/goals" + defp build_path(course_id, uuid) do + "/v2/courses/#{course_id}/admin/goals/#{uuid}" end - defp build_path(uuid) do - "/v2/admin/goals/#{uuid}" + defp build_path(course_id, uuid, course_reg_id) do + "/v2/courses/#{course_id}/admin/users/#{course_reg_id}/goals/#{uuid}/progress/" end end diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index ca45617d8..ae3a1b29c 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -20,74 +20,84 @@ defmodule CadetWeb.AdminGradingControllerTest do describe "GET /, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = get(conn, build_url()) + course = insert(:course) + conn = get(conn, build_url(course.id)) assert response(conn, 401) =~ "Unauthorised" end end describe "GET /:submissionid, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = get(conn, build_url(1)) + course = insert(:course) + conn = get(conn, build_url(course.id, 1)) assert response(conn, 401) =~ "Unauthorised" end end describe "POST /:submissionid/:questionid, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = post(conn, build_url(1, 3), %{}) + course = insert(:course) + conn = post(conn, build_url(course.id, 1, 3), %{}) assert response(conn, 401) =~ "Unauthorised" end end describe "GET /:submissionid/unsubmit, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = post(conn, build_url_unsubmit(1)) + course = insert(:course) + conn = post(conn, build_url_unsubmit(course.id, 1)) assert response(conn, 401) =~ "Unauthorised" end end describe "GET /, student" do @tag authenticate: :student - test "unauthorized", %{conn: conn} do - conn = get(conn, build_url()) + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id)) assert response(conn, 403) =~ "Forbidden" end end describe "GET /?group=true, student" do @tag authenticate: :student - test "unauthorized", %{conn: conn} do - conn = get(conn, build_url(), %{"group" => "true"}) + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id), %{"group" => "true"}) assert response(conn, 403) =~ "Forbidden" end end describe "GET /:submissionid, student" do @tag authenticate: :student - test "unauthorized", %{conn: conn} do - conn = get(conn, build_url(1)) + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id, 1)) assert response(conn, 403) =~ "Forbidden" end end describe "POST /:submissionid/:questionid, student" do @tag authenticate: :student - test "unauthorized", %{conn: conn} do - conn = post(conn, build_url(1, 3), %{"grading" => %{}}) + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = post(conn, build_url(course_id, 1, 3), %{"grading" => %{}}) assert response(conn, 403) =~ "Forbidden" end @tag authenticate: :student test "missing parameter", %{conn: conn} do - conn = post(conn, build_url(1, 3), %{}) + course_id = conn.assigns.course_id + conn = post(conn, build_url(course_id, 1, 3), %{}) assert response(conn, 403) =~ "Forbidden" end end describe "GET /:submissionid/unsubmit, student" do @tag authenticate: :student - test "unauthorized", %{conn: conn} do - conn = post(conn, build_url_unsubmit(1)) + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = post(conn, build_url_unsubmit(course_id, 1)) assert response(conn, 403) =~ "Forbidden" end end @@ -96,11 +106,12 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :staff test "avenger gets to see all students submissions", %{conn: conn} do %{ + course: course, mission: mission, submissions: submissions } = seed_db(conn) - conn = get(conn, build_url()) + conn = get(conn, build_url(course.id)) expected = Enum.map(submissions, fn submission -> @@ -108,60 +119,16 @@ defmodule CadetWeb.AdminGradingControllerTest do "xp" => 5000, "xpAdjustment" => -2500, "xpBonus" => 100, - "grade" => 1000, - "adjustment" => -500, "id" => submission.id, "student" => %{ - "name" => submission.student.name, + "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id }, "assessment" => %{ - "type" => "mission", - "maxGrade" => 1000, - "maxXp" => 5000, - "id" => mission.id, - "title" => mission.title, - "questionCount" => 5 - }, - "status" => Atom.to_string(submission.status), - "gradedCount" => 5, - "unsubmittedBy" => nil, - "unsubmittedAt" => nil - } - end) - - assert expected == Enum.sort_by(json_response(conn, 200), & &1["id"]) - end - - @tag authenticate: :staff - test "pure mentor gets to see all students submissions", %{conn: conn} do - %{mentor: mentor, submissions: submissions, mission: mission} = seed_db(conn) - - conn = - conn - |> sign_in(mentor) - |> get(build_url()) - - expected = - Enum.map(submissions, fn submission -> - %{ - "xp" => 5000, - "xpAdjustment" => -2500, - "xpBonus" => 100, - "grade" => 1000, - "adjustment" => -500, - "id" => submission.id, - "student" => %{ - "name" => submission.student.name, - "id" => submission.student.id, - "groupName" => submission.student.group.name, - "groupLeaderId" => submission.student.group.leader_id - }, - "assessment" => %{ - "type" => "mission", - "maxGrade" => 1000, + "type" => mission.config.type, + "isManuallyGraded" => mission.config.is_manually_graded, "maxXp" => 5000, "id" => mission.id, "title" => mission.title, @@ -183,10 +150,13 @@ defmodule CadetWeb.AdminGradingControllerTest do test "staff not leading a group to get empty", %{conn: conn} do seed_db(conn) + test_cr = conn.assigns.test_cr + new_staff = insert(:course_registration, %{course: test_cr.course, role: :staff}) + resp = conn - |> sign_in(insert(:user, role: :staff)) - |> get(build_url(), %{"group" => "true"}) + |> sign_in(new_staff.user) + |> get(build_url(test_cr.course_id), %{"group" => "true"}) |> json_response(200) assert resp == [] @@ -195,14 +165,16 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :staff test "filtered by its own group", %{conn: conn} do %{ + course: course, mission: mission, submissions: submissions } = seed_db(conn) # just to insert more submissions - seed_db(conn, insert(:user, role: :staff)) + new_staff = insert(:course_registration, %{course: course, role: :staff}) + seed_db(conn, new_staff) - conn = get(conn, build_url(), %{"group" => "true"}) + conn = get(conn, build_url(course.id), %{"group" => "true"}) expected = Enum.map(submissions, fn submission -> @@ -210,18 +182,16 @@ defmodule CadetWeb.AdminGradingControllerTest do "xp" => 5000, "xpAdjustment" => -2500, "xpBonus" => 100, - "grade" => 1000, - "adjustment" => -500, "id" => submission.id, "student" => %{ - "name" => submission.student.name, + "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id }, "assessment" => %{ - "type" => "mission", - "maxGrade" => 1000, + "type" => mission.config.type, + "isManuallyGraded" => mission.config.is_manually_graded, "maxXp" => 5000, "id" => mission.id, "title" => mission.title, @@ -242,6 +212,7 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :staff test "successful", %{conn: conn} do %{ + course: course, grader: grader, submissions: submissions, answers: answers @@ -249,7 +220,7 @@ defmodule CadetWeb.AdminGradingControllerTest do submission = List.first(submissions) - conn = get(conn, build_url(submission.id)) + conn = get(conn, build_url(course.id, submission.id)) expected = answers @@ -272,187 +243,24 @@ defmodule CadetWeb.AdminGradingControllerTest do end ) ++ Enum.map( - &1.question.question.private, + &1.question.question.opaque, fn testcase -> for {k, v} <- testcase, - into: %{"type" => "private"}, + into: %{"type" => "opaque"}, do: {Atom.to_string(k), v} end - ), - "solutionTemplate" => &1.question.question.template, - "type" => "#{&1.question.type}", - "id" => &1.question.id, - "library" => %{ - "chapter" => &1.question.library.chapter, - "globals" => &1.question.library.globals, - "external" => %{ - "name" => "#{&1.question.library.external.name}", - "symbols" => &1.question.library.external.symbols - } - }, - "maxGrade" => &1.question.max_grade, - "maxXp" => &1.question.max_xp, - "content" => &1.question.question.content, - "answer" => &1.answer.code, - "autogradingStatus" => Atom.to_string(&1.autograding_status), - "autogradingResults" => &1.autograding_results - }, - "solution" => &1.question.question.solution, - "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, - "xp" => &1.xp, - "xpAdjustment" => &1.xp_adjustment, - "grader" => %{ - "name" => grader.name, - "id" => grader.id - }, - "gradedAt" => format_datetime(&1.updated_at), - "comments" => &1.comments - }, - "student" => %{ - "name" => &1.submission.student.name, - "id" => &1.submission.student.id - } - } - - :mcq -> - %{ - "question" => %{ - "type" => "#{&1.question.type}", - "id" => &1.question.id, - "library" => %{ - "chapter" => &1.question.library.chapter, - "globals" => &1.question.library.globals, - "external" => %{ - "name" => "#{&1.question.library.external.name}", - "symbols" => &1.question.library.external.symbols - } - }, - "maxGrade" => &1.question.max_grade, - "maxXp" => &1.question.max_xp, - "content" => &1.question.question.content, - "answer" => &1.answer.choice_id, - "choices" => - for choice <- &1.question.question.choices do - %{ - "content" => choice.content, - "hint" => choice.hint, - "id" => choice.choice_id - } - end, - "autogradingStatus" => Atom.to_string(&1.autograding_status), - "autogradingResults" => &1.autograding_results - }, - "solution" => "", - "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, - "xp" => &1.xp, - "xpAdjustment" => &1.xp_adjustment, - "grader" => %{ - "name" => grader.name, - "id" => grader.id - }, - "gradedAt" => format_datetime(&1.updated_at), - "comments" => &1.comments - }, - "student" => %{ - "name" => &1.submission.student.name, - "id" => &1.submission.student.id - } - } - - :voting -> - %{ - "question" => %{ - "prepend" => &1.question.question.prepend, - "solutionTemplate" => &1.question.question.template, - "type" => "#{&1.question.type}", - "id" => &1.question.id, - "library" => %{ - "chapter" => &1.question.library.chapter, - "globals" => &1.question.library.globals, - "external" => %{ - "name" => "#{&1.question.library.external.name}", - "symbols" => &1.question.library.external.symbols - } - }, - "maxGrade" => &1.question.max_grade, - "maxXp" => &1.question.max_xp, - "content" => &1.question.question.content, - "autogradingStatus" => Atom.to_string(&1.autograding_status), - "autogradingResults" => &1.autograding_results, - "answer" => nil, - "contestEntries" => [], - "contestLeaderboard" => [] - }, - "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, - "xp" => &1.xp, - "xpAdjustment" => &1.xp_adjustment, - "grader" => %{ - "name" => grader.name, - "id" => grader.id - }, - "gradedAt" => format_datetime(&1.updated_at), - "comments" => &1.comments - }, - "student" => %{ - "name" => &1.submission.student.name, - "id" => &1.submission.student.id - }, - "solution" => "" - } - end - ) - - assert expected == json_response(conn, 200) - end - - @tag authenticate: :staff - test "pure mentor gets to view all submissions", %{conn: conn} do - %{mentor: mentor, grader: grader, submissions: submissions, answers: answers} = - seed_db(conn) - - submission = List.first(submissions) - - conn = - conn - |> sign_in(mentor) - |> get(build_url(submission.id)) - - expected = - answers - |> Enum.filter(&(&1.submission.id == submission.id)) - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map( - &case &1.question.type do - :programming -> - %{ - "question" => %{ - "prepend" => &1.question.question.prepend, - "postpend" => &1.question.question.postpend, - "testcases" => - Enum.map( - &1.question.question.public, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "public"}, - do: {Atom.to_string(k), v} - end - ) ++ + ) ++ Enum.map( - &1.question.question.private, + &1.question.question.secret, fn testcase -> for {k, v} <- testcase, - into: %{"type" => "private"}, + into: %{"type" => "secret"}, do: {Atom.to_string(k), v} end ), "solutionTemplate" => &1.question.question.template, "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -462,7 +270,6 @@ defmodule CadetWeb.AdminGradingControllerTest do "symbols" => &1.question.library.external.symbols } }, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "content" => &1.question.question.content, "answer" => &1.answer.code, @@ -471,19 +278,17 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "solution" => &1.question.question.solution, "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id } } @@ -492,6 +297,7 @@ defmodule CadetWeb.AdminGradingControllerTest do %{ "question" => %{ "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -501,7 +307,6 @@ defmodule CadetWeb.AdminGradingControllerTest do "symbols" => &1.question.library.external.symbols } }, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "content" => &1.question.question.content, "answer" => &1.answer.choice_id, @@ -518,19 +323,17 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "solution" => "", "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id } } @@ -541,6 +344,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "prepend" => &1.question.question.prepend, "solutionTemplate" => &1.question.question.template, "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -550,7 +354,6 @@ defmodule CadetWeb.AdminGradingControllerTest do "symbols" => &1.question.library.external.symbols } }, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "content" => &1.question.question.content, "autogradingStatus" => Atom.to_string(&1.autograding_status), @@ -560,19 +363,17 @@ defmodule CadetWeb.AdminGradingControllerTest do "contestLeaderboard" => [] }, "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id }, "solution" => "" @@ -587,16 +388,15 @@ defmodule CadetWeb.AdminGradingControllerTest do describe "POST /:submissionid/:questionid, staff" do @tag authenticate: :staff test "successful", %{conn: conn} do - %{grader: grader, answers: answers} = seed_db(conn) + %{course: course, grader: grader, answers: answers} = seed_db(conn) grader_id = grader.id answer = List.first(answers) conn = - post(conn, build_url(answer.submission.id, answer.question.id), %{ + post(conn, build_url(course.id, answer.submission.id, answer.question.id), %{ "grading" => %{ - "adjustment" => -10, "xpAdjustment" => -10 } }) @@ -604,35 +404,19 @@ defmodule CadetWeb.AdminGradingControllerTest do assert response(conn, 200) == "OK" assert %{ - adjustment: -10, xp_adjustment: -10, grader_id: ^grader_id } = Repo.get(Answer, answer.id) end - @tag authenticate: :staff - test "invalid adjustment fails", %{conn: conn} do - %{answers: answers} = seed_db(conn) - - answer = List.first(answers) - - conn = - post(conn, build_url(answer.submission.id, answer.question.id), %{ - "grading" => %{"adjustment" => -9_999_999_999} - }) - - assert response(conn, 400) == - "adjustment must make total be between 0 and question.max_grade" - end - @tag authenticate: :staff test "invalid xp_adjustment fails", %{conn: conn} do - %{answers: answers} = seed_db(conn) + %{course: course, answers: answers} = seed_db(conn) answer = List.first(answers) conn = - post(conn, build_url(answer.submission.id, answer.question.id), %{ + post(conn, build_url(course.id, answer.submission.id, answer.question.id), %{ "grading" => %{"xpAdjustment" => -9_999_999_999} }) @@ -640,43 +424,16 @@ defmodule CadetWeb.AdminGradingControllerTest do "xp_adjustment must make total be between 0 and question.max_xp" end - @tag authenticate: :staff - test "staff who isn't the grader of said answer can still grade submission and grader field is updated correctly", - %{conn: conn} do - %{mentor: mentor, answers: answers} = seed_db(conn) - - mentor_id = mentor.id - - answer = List.first(answers) - - conn = - conn - |> sign_in(mentor) - |> post(build_url(answer.submission.id, answer.question.id), %{ - "grading" => %{ - "adjustment" => -100, - "xpAdjustment" => -100 - } - }) - - assert response(conn, 200) == "OK" - - assert %{ - adjustment: -100, - xp_adjustment: -100, - grader_id: ^mentor_id - } = Repo.get(Answer, answer.id) - end - @tag authenticate: :staff test "missing parameter", %{conn: conn} do - conn = post(conn, build_url(1, 3), %{}) + course_id = conn.assigns.course_id + conn = post(conn, build_url(course_id, 1, 3), %{}) assert response(conn, 400) =~ "Missing parameter" end @tag authenticate: :staff test "submission is not :submitted", %{conn: conn} do - %{grader: grader, mission: mission, questions: questions} = seed_db(conn) + %{course: course, grader: grader, mission: mission, questions: questions} = seed_db(conn) submission = insert(:submission, %{assessment: mission, status: :attempting}) @@ -685,8 +442,6 @@ defmodule CadetWeb.AdminGradingControllerTest do answer = insert(:answer, %{ grader_id: grader.id, - grade: 200, - adjustment: -100, xp: 1000, xp_adjustment: -500, question: question, @@ -700,9 +455,8 @@ defmodule CadetWeb.AdminGradingControllerTest do }) conn = - post(conn, build_url(answer.submission_id, answer.question_id), %{ + post(conn, build_url(course.id, answer.submission_id, answer.question_id), %{ "grading" => %{ - "adjustment" => -100, "xpAdjustment" => -100 } }) @@ -714,7 +468,7 @@ defmodule CadetWeb.AdminGradingControllerTest do describe "POST /:submissionid/unsubmit, staff" do @tag authenticate: :staff test "succeeds", %{conn: conn} do - %{grader: grader, students: students} = seed_db(conn) + %{course: course, config: config, grader: grader, students: students} = seed_db(conn) assessment = insert( @@ -722,7 +476,8 @@ defmodule CadetWeb.AdminGradingControllerTest do open_at: Timex.shift(Timex.now(), hours: -1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + config: config, + course: course ) question = insert(:programming_question, assessment: assessment) @@ -741,8 +496,7 @@ defmodule CadetWeb.AdminGradingControllerTest do ) conn - |> sign_in(grader) - |> post(build_url_unsubmit(submission.id)) + |> post(build_url_unsubmit(course.id, submission.id)) |> response(200) submission_db = Repo.get(Submission, submission.id) @@ -757,8 +511,6 @@ defmodule CadetWeb.AdminGradingControllerTest do assert answer_db.grader_id == grader.id assert answer_db.xp == 0 assert answer_db.xp_adjustment == 0 - assert answer_db.grade == 0 - assert answer_db.adjustment == 0 assert answer_db.comments == answer.comments end @@ -766,7 +518,7 @@ defmodule CadetWeb.AdminGradingControllerTest do test "assessments which have not been submitted should not be allowed to unsubmit", %{ conn: conn } do - %{grader: grader, students: students} = seed_db(conn) + %{course: course, config: config, students: students} = seed_db(conn) assessment = insert( @@ -774,7 +526,8 @@ defmodule CadetWeb.AdminGradingControllerTest do open_at: Timex.shift(Timex.now(), hours: -1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + config: config, + course: course ) question = insert(:programming_question, assessment: assessment) @@ -792,15 +545,14 @@ defmodule CadetWeb.AdminGradingControllerTest do conn = conn - |> sign_in(grader) - |> post(build_url_unsubmit(submission.id)) + |> post(build_url_unsubmit(course.id, submission.id)) assert response(conn, 400) =~ "Assessment has not been submitted" end @tag authenticate: :staff test "assessment that is not open anymore cannot be unsubmitted", %{conn: conn} do - %{grader: grader, students: students} = seed_db(conn) + %{course: course, config: config, students: students} = seed_db(conn) assessment = insert( @@ -808,7 +560,8 @@ defmodule CadetWeb.AdminGradingControllerTest do open_at: Timex.shift(Timex.now(), hours: 1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + course: course, + config: config ) question = insert(:programming_question, assessment: assessment) @@ -826,8 +579,7 @@ defmodule CadetWeb.AdminGradingControllerTest do conn = conn - |> sign_in(grader) - |> post(build_url_unsubmit(submission.id)) + |> post(build_url_unsubmit(course.id, submission.id)) assert response(conn, 403) =~ "Assessment not open" end @@ -836,7 +588,7 @@ defmodule CadetWeb.AdminGradingControllerTest do test "avenger should not be allowed to unsubmit for students outside of their group", %{ conn: conn } do - %{students: students} = seed_db(conn) + %{course: course, config: config, students: students} = seed_db(conn) assessment = insert( @@ -844,10 +596,11 @@ defmodule CadetWeb.AdminGradingControllerTest do open_at: Timex.shift(Timex.now(), hours: -1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + course: course, + config: config ) - other_grader = insert(:user, role: :staff) + other_grader = insert(:course_registration, %{role: :staff, course: course}) question = insert(:programming_question, assessment: assessment) student = List.first(students) @@ -863,8 +616,8 @@ defmodule CadetWeb.AdminGradingControllerTest do conn = conn - |> sign_in(other_grader) - |> post(build_url_unsubmit(submission.id)) + |> sign_in(other_grader.user) + |> post(build_url_unsubmit(course.id, submission.id)) assert response(conn, 403) =~ "Only Avenger of student or Admin is permitted to unsubmit" end @@ -873,16 +626,18 @@ defmodule CadetWeb.AdminGradingControllerTest do test "avenger should be allowed to unsubmit own submissions", %{ conn: conn } do + %{course: course, config: config, grader: grader} = seed_db(conn) + assessment = insert( :assessment, open_at: Timex.shift(Timex.now(), hours: -1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + course: course, + config: config ) - grader = conn.assigns.current_user question = insert(:programming_question, assessment: assessment) submission = @@ -897,7 +652,7 @@ defmodule CadetWeb.AdminGradingControllerTest do conn = conn - |> post(build_url_unsubmit(submission.id)) + |> post(build_url_unsubmit(course.id, submission.id)) assert response(conn, 200) =~ "OK" end @@ -906,16 +661,18 @@ defmodule CadetWeb.AdminGradingControllerTest do test "avenger should be allowed to unsubmit own closed submissions", %{ conn: conn } do + %{course: course, config: config, grader: grader} = seed_db(conn) + assessment = insert( :assessment, open_at: Timex.shift(Timex.now(), hours: 1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + course: course, + config: config ) - grader = conn.assigns.current_user question = insert(:programming_question, assessment: assessment) submission = @@ -930,7 +687,7 @@ defmodule CadetWeb.AdminGradingControllerTest do conn = conn - |> post(build_url_unsubmit(submission.id)) + |> post(build_url_unsubmit(course.id, submission.id)) assert response(conn, 200) =~ "OK" end @@ -939,9 +696,9 @@ defmodule CadetWeb.AdminGradingControllerTest do test "admin should be allowed to unsubmit", %{ conn: conn } do - %{students: students} = seed_db(conn) + %{course: course, config: config, students: students} = seed_db(conn) - admin = insert(:user, %{role: :admin}) + admin = insert(:course_registration, %{role: :admin, course: course}) assessment = insert( @@ -949,7 +706,8 @@ defmodule CadetWeb.AdminGradingControllerTest do open_at: Timex.shift(Timex.now(), hours: -1), close_at: Timex.shift(Timex.now(), hours: 500), is_published: true, - type: "mission" + course: course, + config: config ) question = insert(:programming_question, assessment: assessment) @@ -967,8 +725,8 @@ defmodule CadetWeb.AdminGradingControllerTest do ) conn - |> sign_in(admin) - |> post(build_url_unsubmit(submission.id)) + |> sign_in(admin.user) + |> post(build_url_unsubmit(course.id, submission.id)) submission_db = Repo.get(Submission, submission.id) answer_db = Repo.get(Answer, answer.id) @@ -982,8 +740,6 @@ defmodule CadetWeb.AdminGradingControllerTest do assert answer_db.grader_id == nil assert answer_db.xp == 0 assert answer_db.xp_adjustment == 0 - assert answer_db.grade == 0 - assert answer_db.adjustment == 0 end end @@ -991,16 +747,17 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :staff test "can see all submissions", %{conn: conn} do %{ + course: course, mission: mission, submissions: submissions } = seed_db(conn) - admin = insert(:user, role: :admin) + admin = insert(:course_registration, course: course, role: :admin) conn = conn - |> sign_in(admin) - |> get(build_url()) + |> sign_in(admin.user) + |> get(build_url(course.id)) expected = Enum.map(submissions, fn submission -> @@ -1008,18 +765,16 @@ defmodule CadetWeb.AdminGradingControllerTest do "xp" => 5000, "xpAdjustment" => -2500, "xpBonus" => 100, - "grade" => 1000, - "adjustment" => -500, "id" => submission.id, "student" => %{ - "name" => submission.student.name, + "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id }, "assessment" => %{ - "type" => "mission", - "maxGrade" => 1000, + "type" => mission.config.type, + "isManuallyGraded" => mission.config.is_manually_graded, "maxXp" => 5000, "id" => mission.id, "title" => mission.title, @@ -1040,11 +795,12 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :admin test "successful", %{conn: conn} do %{ + course: course, mission: mission, submissions: submissions } = seed_db(conn) - conn = get(conn, build_url(), %{"group" => "true"}) + conn = get(conn, build_url(course.id), %{"group" => "true"}) expected = Enum.map(submissions, fn submission -> @@ -1052,18 +808,16 @@ defmodule CadetWeb.AdminGradingControllerTest do "xp" => 5000, "xpAdjustment" => -2500, "xpBonus" => 100, - "grade" => 1000, - "adjustment" => -500, "id" => submission.id, "student" => %{ - "name" => submission.student.name, + "name" => submission.student.user.name, "id" => submission.student.id, "groupName" => submission.student.group.name, "groupLeaderId" => submission.student.group.leader_id }, "assessment" => %{ - "type" => "mission", - "maxGrade" => 1000, + "type" => mission.config.type, + "isManuallyGraded" => mission.config.is_manually_graded, "maxXp" => 5000, "id" => mission.id, "title" => mission.title, @@ -1084,6 +838,7 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :admin test "successful", %{conn: conn} do %{ + course: course, grader: grader, submissions: submissions, answers: answers @@ -1091,7 +846,7 @@ defmodule CadetWeb.AdminGradingControllerTest do submission = List.first(submissions) - conn = get(conn, build_url(submission.id)) + conn = get(conn, build_url(course.id, submission.id)) expected = answers @@ -1114,15 +869,24 @@ defmodule CadetWeb.AdminGradingControllerTest do end ) ++ Enum.map( - &1.question.question.private, + &1.question.question.opaque, fn testcase -> for {k, v} <- testcase, - into: %{"type" => "private"}, + into: %{"type" => "opaque"}, + do: {Atom.to_string(k), v} + end + ) ++ + Enum.map( + &1.question.question.secret, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "secret"}, do: {Atom.to_string(k), v} end ), "solutionTemplate" => &1.question.question.template, "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -1132,7 +896,6 @@ defmodule CadetWeb.AdminGradingControllerTest do "symbols" => &1.question.library.external.symbols } }, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "content" => &1.question.question.content, "answer" => &1.answer.code, @@ -1141,19 +904,17 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "solution" => &1.question.question.solution, "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id } } @@ -1162,6 +923,7 @@ defmodule CadetWeb.AdminGradingControllerTest do %{ "question" => %{ "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -1173,7 +935,6 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "content" => &1.question.question.content, "answer" => &1.answer.choice_id, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "choices" => for choice <- &1.question.question.choices do @@ -1188,19 +949,17 @@ defmodule CadetWeb.AdminGradingControllerTest do }, "solution" => "", "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id } } @@ -1211,6 +970,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "prepend" => &1.question.question.prepend, "solutionTemplate" => &1.question.question.template, "type" => "#{&1.question.type}", + "blocking" => &1.question.blocking, "id" => &1.question.id, "library" => %{ "chapter" => &1.question.library.chapter, @@ -1220,7 +980,6 @@ defmodule CadetWeb.AdminGradingControllerTest do "symbols" => &1.question.library.external.symbols } }, - "maxGrade" => &1.question.max_grade, "maxXp" => &1.question.max_xp, "content" => &1.question.question.content, "autogradingStatus" => Atom.to_string(&1.autograding_status), @@ -1230,19 +989,17 @@ defmodule CadetWeb.AdminGradingControllerTest do "contestLeaderboard" => [] }, "grade" => %{ - "grade" => &1.grade, - "adjustment" => &1.adjustment, "xp" => &1.xp, "xpAdjustment" => &1.xp_adjustment, "grader" => %{ - "name" => grader.name, + "name" => grader.user.name, "id" => grader.id }, "gradedAt" => format_datetime(&1.updated_at), "comments" => &1.comments }, "student" => %{ - "name" => &1.submission.student.name, + "name" => &1.submission.student.user.name, "id" => &1.submission.student.id }, "solution" => "" @@ -1257,22 +1014,23 @@ defmodule CadetWeb.AdminGradingControllerTest do describe "POST /:submissionid/:questionid, admin" do @tag authenticate: :admin test "succeeds", %{conn: conn} do - %{answers: answers} = seed_db(conn) + %{course: course, answers: answers} = seed_db(conn) answer = List.first(answers) conn = - post(conn, build_url(answer.submission.id, answer.question.id), %{ - "grading" => %{"adjustment" => -10} + post(conn, build_url(course.id, answer.submission.id, answer.question.id), %{ + "grading" => %{"xpAdjustment" => -10} }) assert response(conn, 200) == "OK" - assert %{adjustment: -10} = Repo.get(Answer, answer.id) + assert %{xp_adjustment: -10} = Repo.get(Answer, answer.id) end @tag authenticate: :admin test "missing parameter", %{conn: conn} do - conn = post(conn, build_url(1, 3), %{}) + course_id = conn.assigns.course_id + conn = post(conn, build_url(course_id, 1, 3), %{}) assert response(conn, 400) =~ "Missing parameter" end end @@ -1281,31 +1039,66 @@ defmodule CadetWeb.AdminGradingControllerTest do @tag authenticate: :admin test "admin can see summary", %{conn: conn} do %{ + course: course, + config: config1, submissions: submissions, group: group, grader: grader, answers: answers } = seed_db(conn) - conn = get(conn, build_url_summary()) - - expected = [ - %{ - "groupName" => group.name, - "leaderName" => grader.name, - "submittedMissions" => count_submissions(submissions, answers, "mission"), - "submittedSidequests" => count_submissions(submissions, answers, "sidequest"), - "ungradedMissions" => count_submissions(submissions, answers, "mission", true), - "ungradedSidequests" => count_submissions(submissions, answers, "sidequest", true) - } - ] + %{ + submissions: submissions2, + config: config2, + group: group2, + grader: grader2, + answers: answers2 + } = seed_db(conn, insert(:course_registration, %{course: course, role: :staff})) + + resp = conn |> get(build_url_summary(course.id)) |> json_response(200) + + expected = %{ + "cols" => [ + "groupName", + "leaderName", + "submitted" <> config1.type, + "ungraded" <> config1.type, + "submitted" <> config2.type, + "ungraded" <> config2.type + ], + "rows" => [ + %{ + "groupName" => group.name, + "leaderName" => grader.user.name, + ("submitted" <> config1.type) => count_submissions(submissions, answers, config1.id), + ("submitted" <> config2.type) => count_submissions(submissions, answers, config2.id), + ("ungraded" <> config1.type) => + count_submissions(submissions, answers, config1.id, true), + ("ungraded" <> config2.type) => + count_submissions(submissions, answers, config2.id, true) + }, + %{ + "groupName" => group2.name, + "leaderName" => grader2.user.name, + ("submitted" <> config1.type) => + count_submissions(submissions2, answers2, config1.id), + ("submitted" <> config2.type) => + count_submissions(submissions2, answers2, config2.id), + ("ungraded" <> config1.type) => + count_submissions(submissions2, answers2, config1.id, true), + ("ungraded" <> config2.type) => + count_submissions(submissions2, answers2, config2.id, true) + } + ] + } - assert expected == Enum.sort_by(json_response(conn, 200), & &1["groupName"]) + assert expected["cols"] == resp["cols"] + assert expected["rows"] == Enum.sort_by(resp["rows"], & &1["groupName"]) end @tag authenticate: :student test "student cannot see summary", %{conn: conn} do - conn = get(conn, build_url_summary()) + conn = get(conn, build_url_summary(conn.assigns.course_id)) assert response(conn, 403) =~ "Forbidden" end end @@ -1313,44 +1106,51 @@ defmodule CadetWeb.AdminGradingControllerTest do describe "POST /grading/:submissionid/autograde" do setup %{conn: conn} do %{ + course: course, submissions: [submission, _] } = seed_db(conn) - %{submission: submission} + %{course: course, submission: submission} end @tag authenticate: :staff - test "staff can re-autograde submissions", %{conn: conn, submission: submission} do + test "staff can re-autograde submissions", %{ + conn: conn, + course: course, + submission: submission + } do with_mock Cadet.Autograder.GradingJob, force_grade_individual_submission: fn in_sub, _ -> assert submission.id == in_sub.id end do - assert conn |> post(build_url_autograde(submission.id)) |> response(204) + assert conn |> post(build_url_autograde(course.id, submission.id)) |> response(204) end end @tag authenticate: :student - test "student cannot re-autograde", %{conn: conn, submission: submission} do - assert conn |> post(build_url_autograde(submission.id)) |> response(403) + test "student cannot re-autograde", %{conn: conn, course: course, submission: submission} do + assert conn |> post(build_url_autograde(course.id, submission.id)) |> response(403) end @tag authenticate: :student - test "fails if not found", %{conn: conn} do - assert conn |> post(build_url_autograde(2_147_483_647)) |> response(403) + test "fails if not found", %{conn: conn, course: course} do + assert conn |> post(build_url_autograde(course.id, 2_147_483_647)) |> response(403) end end describe "POST /grading/:submissionid/:questionid/autograde" do setup %{conn: conn} do %{ + course: course, submissions: [submission | _], questions: [question | _] } = seed_db(conn) - %{submission: submission, question: question} + %{course: course, submission: submission, question: question} end @tag authenticate: :staff test "staff can re-autograde questions", %{ conn: conn, + course: course, submission: submission, question: question } do @@ -1359,25 +1159,34 @@ defmodule CadetWeb.AdminGradingControllerTest do assert question.id == in_q.id assert question.id == in_a.question_id end do - assert conn |> post(build_url_autograde(submission.id, question.id)) |> response(204) + assert conn + |> post(build_url_autograde(course.id, submission.id, question.id)) + |> response(204) end end @tag authenticate: :student - test "student cannot re-autograde", %{conn: conn, submission: submission, question: question} do - assert conn |> post(build_url_autograde(submission.id, question.id)) |> response(403) + test "student cannot re-autograde", %{ + conn: conn, + course: course, + submission: submission, + question: question + } do + assert conn + |> post(build_url_autograde(course.id, submission.id, question.id)) + |> response(403) end @tag authenticate: :student - test "fails if not found", %{conn: conn} do - assert conn |> post(build_url_autograde(2_147_483_647, 123_456)) |> response(403) + test "fails if not found", %{conn: conn, course: course} do + assert conn |> post(build_url_autograde(course.id, 2_147_483_647, 123_456)) |> response(403) end end - defp count_submissions(submissions, answers, type, only_ungraded \\ false) do + defp count_submissions(submissions, answers, config_id, only_ungraded \\ false) do submissions |> Enum.filter(fn s -> - s.status == :submitted and s.assessment.type == type and + s.status == :submitted and s.assessment.config_id == config_id and (not only_ungraded or answers |> Enum.filter(fn a -> a.submission == s and is_nil(a.grader_id) end) @@ -1386,32 +1195,45 @@ defmodule CadetWeb.AdminGradingControllerTest do |> length() end - defp build_url, do: "/v2/admin/grading/" - defp build_url_summary, do: "/v2/admin/grading/summary" - defp build_url(submission_id), do: "#{build_url()}#{submission_id}" - defp build_url(submission_id, question_id), do: "#{build_url(submission_id)}/#{question_id}" - defp build_url_unsubmit(submission_id), do: "#{build_url(submission_id)}/unsubmit" - defp build_url_autograde(submission_id), do: "#{build_url(submission_id)}/autograde" + defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/grading/" + defp build_url_summary(course_id), do: "/v2/courses/#{course_id}/admin/grading/summary" + defp build_url(course_id, submission_id), do: "#{build_url(course_id)}#{submission_id}" + + defp build_url(course_id, submission_id, question_id), + do: "#{build_url(course_id, submission_id)}/#{question_id}" + + defp build_url_unsubmit(course_id, submission_id), + do: "#{build_url(course_id, submission_id)}/unsubmit" - defp build_url_autograde(submission_id, question_id), - do: "#{build_url(submission_id, question_id)}/autograde" + defp build_url_autograde(course_id, submission_id), + do: "#{build_url(course_id, submission_id)}/autograde" + + defp build_url_autograde(course_id, submission_id, question_id), + do: "#{build_url(course_id, submission_id, question_id)}/autograde" defp seed_db(conn, override_grader \\ nil) do - grader = override_grader || conn.assigns[:current_user] - mentor = insert(:user, role: :staff) + grader = override_grader || conn.assigns[:test_cr] + + course = grader.course + assessment_config = insert(:assessment_config, %{course: course}) + + group = insert(:group, %{course: course, leader_id: grader.id, leader: grader}) - group = - insert(:group, %{leader_id: grader.id, leader: grader, mentor_id: mentor.id, mentor: mentor}) + students = insert_list(5, :course_registration, %{course: course, group: group}) - students = insert_list(5, :student, %{group: group}) - mission = insert(:assessment, %{title: "mission", type: "mission", is_published: true}) + mission = + insert(:assessment, %{ + title: "mission", + course: course, + config: assessment_config, + is_published: true + }) questions = for index <- 0..2 do # insert with display order in reverse insert(:programming_question, %{ assessment: mission, - max_grade: 200, max_xp: 1000, display_order: 5 - index }) @@ -1419,7 +1241,6 @@ defmodule CadetWeb.AdminGradingControllerTest do [ insert(:mcq_question, %{ assessment: mission, - max_grade: 200, max_xp: 1000, display_order: 2 }) @@ -1427,7 +1248,6 @@ defmodule CadetWeb.AdminGradingControllerTest do [ insert(:voting_question, %{ assessment: mission, - max_grade: 200, max_xp: 1000, display_order: 1 }) @@ -1450,8 +1270,6 @@ defmodule CadetWeb.AdminGradingControllerTest do question <- questions do insert(:answer, %{ grader_id: grader.id, - grade: 200, - adjustment: -100, xp: 1000, xp_adjustment: -500, question: question, @@ -1466,8 +1284,9 @@ defmodule CadetWeb.AdminGradingControllerTest do end %{ + course: course, + config: assessment_config, grader: grader, - mentor: mentor, group: group, students: students, mission: mission, diff --git a/test/cadet_web/admin_controllers/admin_settings_controller_test.exs b/test/cadet_web/admin_controllers/admin_settings_controller_test.exs deleted file mode 100644 index 2f27288b5..000000000 --- a/test/cadet_web/admin_controllers/admin_settings_controller_test.exs +++ /dev/null @@ -1,59 +0,0 @@ -defmodule CadetWeb.AdminSettingsControllerTest do - use CadetWeb.ConnCase - - alias CadetWeb.AdminSettingsController - - test "swagger" do - AdminSettingsController.swagger_definitions() - AdminSettingsController.swagger_path_update(nil) - end - - describe "PUT /settings/sublanguage" do - @tag authenticate: :admin - test "succeeds", %{conn: conn} do - insert(:sublanguage, %{chapter: 4, variant: "gpu"}) - - conn = - put(conn, build_url(), %{ - "chapter" => Enum.random(1..4), - "variant" => "default" - }) - - assert response(conn, 200) == "OK" - end - - @tag authenticate: :staff - test "succeeds when no default sublanguage entry exists", %{conn: conn} do - conn = - put(conn, build_url(), %{ - "chapter" => Enum.random(1..4), - "variant" => "default" - }) - - assert response(conn, 200) == "OK" - end - - @tag authenticate: :student - test "rejects forbidden request for non-staff users", %{conn: conn} do - conn = put(conn, build_url(), %{"chapter" => 3, "variant" => "concurrent"}) - - assert response(conn, 403) == "Forbidden" - end - - @tag authenticate: :staff - test "rejects requests with invalid params", %{conn: conn} do - conn = put(conn, build_url(), %{"chapter" => 4, "variant" => "wasm"}) - - assert response(conn, 400) == "Invalid parameter(s)" - end - - @tag authenticate: :staff - test "rejects requests with missing params", %{conn: conn} do - conn = put(conn, build_url(), %{"variant" => "default"}) - - assert response(conn, 400) == "Missing parameter(s)" - end - end - - defp build_url, do: "/v2/admin/settings/sublanguage" -end diff --git a/test/cadet_web/admin_controllers/admin_user_controller_test.exs b/test/cadet_web/admin_controllers/admin_user_controller_test.exs index 1ea56aae6..e70b3508f 100644 --- a/test/cadet_web/admin_controllers/admin_user_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_user_controller_test.exs @@ -2,34 +2,46 @@ defmodule CadetWeb.AdminUserControllerTest do use CadetWeb.ConnCase import Cadet.Factory + import Ecto.Query alias CadetWeb.AdminUserController + alias Cadet.Repo + alias Cadet.Courses.{Course, Group} + alias Cadet.Accounts.CourseRegistration test "swagger" do assert is_map(AdminUserController.swagger_definitions()) assert is_map(AdminUserController.swagger_path_index(nil)) end - describe "GET /admin/users" do + describe "GET /v2/courses/{course_id}/admin/users" do @tag authenticate: :staff - test "success, when staff retrieves users", %{conn: conn} do - insert(:student) + test "success, when staff retrieves all users", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + group = insert(:group, %{course: course}) + insert(:course_registration, %{role: :student, course: course, group: group}) + insert(:course_registration, %{role: :staff, course: course, group: group}) resp = conn - |> get("/v2/admin/users") + |> get(build_url_users(course_id)) |> json_response(200) - assert 2 == Enum.count(resp) + assert 3 == Enum.count(resp) end @tag authenticate: :staff test "can filter by role", %{conn: conn} do - insert(:student) + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + group = insert(:group, %{course: course}) + insert(:course_registration, %{role: :student, course: course, group: group}) + insert(:course_registration, %{role: :staff, course: course, group: group}) resp = conn - |> get("/v2/admin/users?role=student") + |> get(build_url_users(course_id) <> "?role=student") |> json_response(200) assert 1 == Enum.count(resp) @@ -38,29 +50,462 @@ defmodule CadetWeb.AdminUserControllerTest do @tag authenticate: :staff test "can filter by group", %{conn: conn} do - group = insert(:group) - insert(:student, group: group) + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + group = insert(:group, %{course: course}) + insert(:course_registration, %{role: :student, course: course, group: group}) + insert(:course_registration, %{role: :staff, course: course, group: group}) resp = conn - |> get("/v2/admin/users?group=#{group.name}") + |> get(build_url_users(course_id) <> "?group=#{group.name}") |> json_response(200) - assert 1 == Enum.count(resp) + assert 2 == Enum.count(resp) assert group.name == List.first(resp)["group"] end @tag authenticate: :student test "forbidden, when student retrieves users", %{conn: conn} do + course_id = conn.assigns[:course_id] + assert conn - |> get("/v2/admin/users") + |> get(build_url_users(course_id)) |> response(403) end test "401 when not logged in", %{conn: conn} do + course = insert(:course) + assert conn - |> get("/v2/admin/users") + |> get(build_url_users(course.id)) |> response(401) end end + + describe "PUT /v2/courses/{course_id}/admin/users" do + @tag authenticate: :admin + test "successfully namespaces and inserts users, and assign groups", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user = insert(:user, %{username: "test/existing-user"}) + insert(:course_registration, %{course: course, user: user}) + + assert CourseRegistration |> where(course_id: ^course_id) |> Repo.all() |> Enum.count() == 2 + assert Group |> Repo.all() |> Enum.count() == 0 + + params = %{ + users: [ + %{"username" => "existing-user", "role" => "student", "group" => "group1"}, + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student", "group" => "group2"}, + %{"username" => "student3", "role" => "student", "group" => "group2"}, + %{"username" => "staff", "role" => "staff", "group" => "group1"}, + %{"username" => "admin", "role" => "admin", "group" => "group2"} + ], + provider: "test" + } + + resp = put(conn, build_url_users(course_id), params) + + assert response(resp, 200) == "OK" + + # Users inserted + assert CourseRegistration |> where(course_id: ^course_id) |> Repo.all() |> Enum.count() == 7 + + # Groups created + assert Group |> Repo.all() |> Enum.count() == 2 + end + + @tag authenticate: :admin + test "successful with duplicate inputs", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user = insert(:user, %{username: "test/existing-user"}) + insert(:course_registration, %{course: course, user: user}) + + assert CourseRegistration |> where(course_id: ^course_id) |> Repo.all() |> Enum.count() == 2 + + params = %{ + users: [ + %{"username" => "existing-user", "role" => "student", "group" => "group1"}, + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student", "group" => "group2"}, + %{"username" => "student2", "role" => "student", "group" => "group2"}, + %{"username" => "staff", "role" => "staff", "group" => "group1"}, + %{"username" => "admin", "role" => "admin", "group" => "group2"} + ], + provider: "test" + } + + resp = put(conn, build_url_users(course_id), params) + + assert response(resp, 200) == "OK" + + assert CourseRegistration |> where(course_id: ^course_id) |> Repo.all() |> Enum.count() == 6 + end + + @tag authenticate: :staff + test "fails when not admin", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => "admin", "role" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 403) == "User is not permitted to add users" + end + + @tag authenticate: :admin + test "fails when invalid provider is specified", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => "admin", "role" => "admin"} + ], + provider: "invalid-provider" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid authentication provider" + end + + @tag authenticate: :admin + test "fails when no username is specified for at least one input", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"role" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid username(s) provided" + end + + @tag authenticate: :admin + test "fails when invalid username is specified (not string)", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => nil, "role" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid username(s) provided" + end + + @tag authenticate: :admin + test "fails when invalid username is specified (empty string)", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => "", "role" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid username(s) provided" + end + + @tag authenticate: :admin + test "fails when no role is specified for at least one input", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "student"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid role(s) provided" + end + + @tag authenticate: :admin + test "fails when invalid role is specified for at least one input", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + users: [ + %{"username" => "student1", "role" => "student"}, + %{"username" => "student2", "role" => "student"}, + %{"username" => "student3", "role" => "invalid-role"}, + %{"username" => "staff", "role" => "staff"}, + %{"username" => "admin", "role" => "admin"} + ], + provider: "test" + } + + conn = put(conn, build_url_users(course_id), params) + + assert response(conn, 400) == "Invalid role(s) provided" + end + end + + describe "PUT /v2/courses/{course_id}/admin/users/{course_reg_id}/role" do + @tag authenticate: :admin + test "success (student to staff), when admin is admin of the course the user is in", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :student, course: course}) + + params = %{ + "role" => "staff" + } + + resp = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(resp, 200) == "OK" + updated_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert updated_course_reg.role == :staff + end + + @tag authenticate: :admin + test "success (staff to student), when admin is admin of the course the user is in", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :staff, course: course}) + + params = %{ + "role" => "student" + } + + resp = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(resp, 200) == "OK" + updated_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert updated_course_reg.role == :student + end + + @tag authenticate: :admin + test "success (admin to staff), when admin is admin of the course the user is in", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :admin, course: course}) + + params = %{ + "role" => "staff" + } + + resp = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(resp, 200) == "OK" + updated_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert updated_course_reg.role == :staff + end + + @tag authenticate: :admin + test "fails, when course registration does not exist", %{conn: conn} do + course_id = conn.assigns[:course_id] + + params = %{ + "role" => "staff" + } + + conn = put(conn, build_url_users_role(course_id, 10_000), params) + + assert response(conn, 400) == "User course registration does not exist" + end + + @tag authenticate: :admin + test "fails, when admin is NOT admin of the course the user is in", %{conn: conn} do + course_id = conn.assigns[:course_id] + user_course_reg = insert(:course_registration, %{role: :student}) + + params = %{ + "role" => "staff" + } + + conn = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(conn, 403) == "User is in a different course" + unchanged_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert unchanged_course_reg.role == :student + end + + @tag authenticate: :staff + test "fails, when staff attempts to make a role change", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :student, course: course}) + + params = %{ + "role" => "staff" + } + + conn = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(conn, 403) == "User is not permitted to change others' roles" + unchanged_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert unchanged_course_reg.role == :student + end + + @tag authenticate: :admin + test "fails, when invalid role is provided", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :student, course: course}) + + params = %{ + "role" => "avenger" + } + + conn = put(conn, build_url_users_role(course_id, user_course_reg.id), params) + + assert response(conn, 400) == "role is invalid" + unchanged_course_reg = Repo.get(CourseRegistration, user_course_reg.id) + assert unchanged_course_reg.role == :student + end + end + + describe "DELETE /v2/courses/{course_id}/admin/users/{course_reg_id}" do + @tag authenticate: :admin + test "success (delete student), when admin is admin of the course the user is in", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :student, course: course}) + + resp = delete(conn, build_url_users(course_id, user_course_reg.id)) + + assert response(resp, 200) == "OK" + assert Repo.get(CourseRegistration, user_course_reg.id) == nil + end + + @tag authenticate: :admin + test "success (delete staff), when admin is admin of the course the user is in", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :staff, course: course}) + + resp = delete(conn, build_url_users(course_id, user_course_reg.id)) + + assert response(resp, 200) == "OK" + assert Repo.get(CourseRegistration, user_course_reg.id) == nil + end + + @tag authenticate: :staff + test "fails when staff tries to delete user", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :student, course: course}) + + conn = delete(conn, build_url_users(course_id, user_course_reg.id)) + + assert response(conn, 403) == "User is not permitted to delete other users" + assert Repo.get(CourseRegistration, user_course_reg.id) != nil + end + + @tag authenticate: :admin + test "fails when admin tries to delete ownself", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + own_course_reg = conn.assigns[:test_cr] + + conn = delete(conn, build_url_users(course_id, own_course_reg.id)) + + assert response(conn, 400) == "Admin not allowed to delete ownself from course" + assert Repo.get(CourseRegistration, own_course_reg.id) != nil + end + + @tag authenticate: :admin + test "fails when user course registration does not exist", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + + conn = delete(conn, build_url_users(course_id, 1)) + + assert response(conn, 400) == "User course registration does not exist" + end + + @tag authenticate: :admin + test "fails when deleting an admin", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + user_course_reg = insert(:course_registration, %{role: :admin, course: course}) + + conn = delete(conn, build_url_users(course_id, user_course_reg.id)) + + assert response(conn, 400) == "Admins cannot be deleted" + end + + @tag authenticate: :admin + test "fails when deleting a user from another course", %{ + conn: conn + } do + course_id = conn.assigns[:course_id] + user_course_reg = insert(:course_registration, %{role: :student}) + + conn = delete(conn, build_url_users(course_id, user_course_reg.id)) + + assert response(conn, 403) == "User is in a different course" + end + end + + defp build_url_users(course_id), do: "/v2/courses/#{course_id}/admin/users" + + defp build_url_users(course_id, course_reg_id), + do: "/v2/courses/#{course_id}/admin/users/#{course_reg_id}" + + defp build_url_users_role(course_id, course_reg_id), + do: build_url_users(course_id, course_reg_id) <> "/role" end diff --git a/test/cadet_web/controllers/answer_controller_test.exs b/test/cadet_web/controllers/answer_controller_test.exs index f541f0650..4bd8c8162 100644 --- a/test/cadet_web/controllers/answer_controller_test.exs +++ b/test/cadet_web/controllers/answer_controller_test.exs @@ -13,7 +13,9 @@ defmodule CadetWeb.AnswerControllerTest do end setup do - assessment = insert(:assessment, %{is_published: true}) + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{is_published: true, course: course, config: config}) mcq_question = insert(:mcq_question, %{assessment: assessment}) programming_question = insert(:programming_question, %{assessment: assessment}) voting_question = insert(:voting_question, %{assessment: assessment}) @@ -28,7 +30,8 @@ defmodule CadetWeb.AnswerControllerTest do describe "POST /assessments/question/{questionId}/answer/, Unauthenticated" do test "is disallowed", %{conn: conn, mcq_question: question} do - conn = post(conn, build_url(question.id), %{answer: 5}) + course = insert(:course) + conn = post(conn, build_url(course.id, question.id), %{answer: 5}) assert response(conn, 401) =~ "Unauthorised" end @@ -44,34 +47,35 @@ defmodule CadetWeb.AnswerControllerTest do programming_question: programming_question, voting_question: voting_question } do - user = conn.assigns.current_user - mcq_conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id + mcq_conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(mcq_conn, 200) =~ "OK" - assert get_answer_value(mcq_question, assessment, user) == 5 + assert get_answer_value(mcq_question, assessment, course_reg) == 5 programming_conn = - post(conn, build_url(programming_question.id), %{answer: "hello world"}) + post(conn, build_url(course_id, programming_question.id), %{answer: "hello world"}) assert response(programming_conn, 200) =~ "OK" - assert get_answer_value(programming_question, assessment, user) == "hello world" + assert get_answer_value(programming_question, assessment, course_reg) == "hello world" contest_submission = insert(:submission) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: contest_submission.id }) voting_conn = - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{"answer" => "hello world", "submission_id" => contest_submission.id, "rank" => 3} ] }) assert response(voting_conn, 200) =~ "OK" - rank = get_rank_for_submission_vote(voting_question, user, contest_submission) + rank = get_rank_for_submission_vote(voting_question, course_reg, contest_submission) assert rank == 3 end @@ -83,37 +87,38 @@ defmodule CadetWeb.AnswerControllerTest do programming_question: programming_question, voting_question: voting_question } do - user = conn.assigns.current_user - mcq_conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id + mcq_conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(mcq_conn, 200) =~ "OK" - assert get_answer_value(mcq_question, assessment, user) == 5 + assert get_answer_value(mcq_question, assessment, course_reg) == 5 - updated_mcq_conn = post(conn, build_url(mcq_question.id), %{answer: 6}) + updated_mcq_conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 6}) assert response(updated_mcq_conn, 200) =~ "OK" - assert get_answer_value(mcq_question, assessment, user) == 6 + assert get_answer_value(mcq_question, assessment, course_reg) == 6 programming_conn = - post(conn, build_url(programming_question.id), %{answer: "hello world"}) + post(conn, build_url(course_id, programming_question.id), %{answer: "hello world"}) assert response(programming_conn, 200) =~ "OK" - assert get_answer_value(programming_question, assessment, user) == "hello world" + assert get_answer_value(programming_question, assessment, course_reg) == "hello world" updated_programming_conn = - post(conn, build_url(programming_question.id), %{answer: "hello_world"}) + post(conn, build_url(course_id, programming_question.id), %{answer: "hello_world"}) assert response(updated_programming_conn, 200) =~ "OK" - assert get_answer_value(programming_question, assessment, user) == "hello_world" + assert get_answer_value(programming_question, assessment, course_reg) == "hello_world" contest_submission = insert(:submission) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: contest_submission.id }) voting_conn = - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{"answer" => "hello world", "submission_id" => contest_submission.id, "rank" => 3} ] @@ -122,7 +127,7 @@ defmodule CadetWeb.AnswerControllerTest do assert response(voting_conn, 200) =~ "OK" updated_voting_conn = - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{"answer" => "hello world", "submission_id" => contest_submission.id, "rank" => 5} ] @@ -130,7 +135,7 @@ defmodule CadetWeb.AnswerControllerTest do assert response(updated_voting_conn, 200) =~ "OK" - rank = get_rank_for_submission_vote(voting_question, user, contest_submission) + rank = get_rank_for_submission_vote(voting_question, course_reg, contest_submission) assert rank == 5 end @@ -142,18 +147,19 @@ defmodule CadetWeb.AnswerControllerTest do programming_question: programming_question, voting_question: voting_question } do - user = conn.assigns.current_user - post(conn, build_url(mcq_question.id), %{answer: 5}) - post(conn, build_url(programming_question.id), %{answer: "hello world"}) + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id + post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) + post(conn, build_url(course_id, programming_question.id), %{answer: "hello world"}) contest_submission = insert(:submission) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: contest_submission.id }) - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{"answer" => "hello world", "submission_id" => contest_submission.id, "rank" => 3} ] @@ -163,14 +169,14 @@ defmodule CadetWeb.AnswerControllerTest do submission = Submission - |> where(student_id: ^user.id) + |> where(student_id: ^course_reg.id) |> where(assessment_id: ^assessment.id) |> Repo.one!() assert submission.status == :attempted # should not affect submission changes - conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(conn, 200) =~ "OK" end @@ -181,10 +187,11 @@ defmodule CadetWeb.AnswerControllerTest do assessment: assessment, mcq_question: mcq_question } do - user = conn.assigns.current_user + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id - insert(:submission, %{assessment: assessment, student: user, status: :submitted}) - conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + insert(:submission, %{assessment: assessment, student: course_reg, status: :submitted}) + conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(conn, 403) == "Assessment submission already finalised" end @@ -195,10 +202,11 @@ defmodule CadetWeb.AnswerControllerTest do assessment: assessment, mcq_question: mcq_question } do - user = conn.assigns.current_user + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id - insert(:submission, %{assessment: assessment, student: user, status: :submitted}) - conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + insert(:submission, %{assessment: assessment, student: course_reg, status: :submitted}) + conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(conn, 200) == "OK" end @@ -212,29 +220,35 @@ defmodule CadetWeb.AnswerControllerTest do programming_question: programming_question, voting_question: voting_question } do - user = conn.assigns.current_user - missing_answer_conn = post(conn, build_url(programming_question.id), %{answ: 5}) + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id + + missing_answer_conn = + post(conn, build_url(course_id, programming_question.id), %{answ: 5}) + assert response(missing_answer_conn, 400) == "Missing or invalid parameter(s)" - assert is_nil(get_answer_value(mcq_question, assessment, user)) + assert is_nil(get_answer_value(mcq_question, assessment, course_reg)) - mcq_conn = post(conn, build_url(programming_question.id), %{answer: 5}) + mcq_conn = post(conn, build_url(course_id, programming_question.id), %{answer: 5}) assert response(mcq_conn, 400) == "Missing or invalid parameter(s)" - assert is_nil(get_answer_value(mcq_question, assessment, user)) + assert is_nil(get_answer_value(mcq_question, assessment, course_reg)) + + programming_conn = + post(conn, build_url(course_id, mcq_question.id), %{answer: "hello world"}) - programming_conn = post(conn, build_url(mcq_question.id), %{answer: "hello world"}) assert response(programming_conn, 400) == "Missing or invalid parameter(s)" - assert is_nil(get_answer_value(programming_question, assessment, user)) + assert is_nil(get_answer_value(programming_question, assessment, course_reg)) contest_submission = insert(:submission) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: contest_submission.id }) voting_conn = - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{ "answer" => "hello world", @@ -253,27 +267,28 @@ defmodule CadetWeb.AnswerControllerTest do conn: conn, voting_question: voting_question } do - user = conn.assigns.current_user + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id first_contest_submission = insert(:submission) second_contest_submission = insert(:submission) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: first_contest_submission.id, rank: 1 }) Repo.insert(%SubmissionVotes{ - user_id: user.id, + voter_id: course_reg.id, question_id: voting_question.id, submission_id: second_contest_submission.id, rank: 2 }) voting_conn = - post(conn, build_url(voting_question.id), %{ + post(conn, build_url(course_id, voting_question.id), %{ answer: [ %{ "answer" => "hello world", @@ -300,17 +315,19 @@ defmodule CadetWeb.AnswerControllerTest do assessment: assessment, mcq_question: mcq_question } do - user = conn.assigns.current_user + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id {:ok, _} = Repo.delete(mcq_question) - conn = post(conn, build_url(mcq_question.id), %{answer: 5}) + conn = post(conn, build_url(course_id, mcq_question.id), %{answer: 5}) assert response(conn, 404) == "Question not found" - assert is_nil(get_answer_value(mcq_question, assessment, user)) + assert is_nil(get_answer_value(mcq_question, assessment, course_reg)) end @tag authenticate: :student test "invalid params not open submission is unsuccessful", %{conn: conn} do - user = conn.assigns.current_user + course_reg = conn.assigns.test_cr + course_id = conn.assigns.course_id before_open_at_assessment = insert(:assessment, %{ @@ -320,9 +337,14 @@ defmodule CadetWeb.AnswerControllerTest do before_open_at_question = insert(:mcq_question, %{assessment: before_open_at_assessment}) - before_open_at_conn = post(conn, build_url(before_open_at_question.id), %{answer: 5}) + before_open_at_conn = + post(conn, build_url(course_id, before_open_at_question.id), %{answer: 5}) + assert response(before_open_at_conn, 403) == "Assessment not open" - assert is_nil(get_answer_value(before_open_at_question, before_open_at_assessment, user)) + + assert is_nil( + get_answer_value(before_open_at_question, before_open_at_assessment, course_reg) + ) after_close_at_assessment = insert(:assessment, %{ @@ -332,29 +354,34 @@ defmodule CadetWeb.AnswerControllerTest do after_close_at_question = insert(:mcq_question, %{assessment: after_close_at_assessment}) - after_close_at_conn = post(conn, build_url(after_close_at_question.id), %{answer: 5}) + after_close_at_conn = + post(conn, build_url(course_id, after_close_at_question.id), %{answer: 5}) + assert response(after_close_at_conn, 403) == "Assessment not open" - assert is_nil(get_answer_value(after_close_at_question, after_close_at_assessment, user)) + + assert is_nil( + get_answer_value(after_close_at_question, after_close_at_assessment, course_reg) + ) unpublished_assessment = insert(:assessment, %{is_published: false}) unpublished_question = insert(:mcq_question, %{assessment: unpublished_assessment}) - unpublished_conn = post(conn, build_url(unpublished_question.id), %{answer: 5}) + unpublished_conn = post(conn, build_url(course_id, unpublished_question.id), %{answer: 5}) assert response(unpublished_conn, 403) == "Assessment not open" - assert is_nil(get_answer_value(unpublished_question, unpublished_assessment, user)) + assert is_nil(get_answer_value(unpublished_question, unpublished_assessment, course_reg)) end - defp build_url(question_id) do - "/v2/assessments/question/#{question_id}/answer/" + defp build_url(course_id, question_id) do + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answer/" end - defp get_answer_value(question, assessment, user) do + defp get_answer_value(question, assessment, course_reg) do answer = Answer |> where(question_id: ^question.id) |> join(:inner, [a], s in assoc(a, :submission)) - |> where([a, s], s.student_id == ^user.id) + |> where([a, s], s.student_id == ^course_reg.id) |> where([a, s], s.assessment_id == ^assessment.id) |> Repo.one() @@ -366,10 +393,10 @@ defmodule CadetWeb.AnswerControllerTest do end end - defp get_rank_for_submission_vote(question, user, submission) do + defp get_rank_for_submission_vote(question, course_reg, submission) do SubmissionVotes |> where(question_id: ^question.id) - |> where(user_id: ^user.id) + |> where(voter_id: ^course_reg.id) |> where(submission_id: ^submission.id) |> select([sv], sv.rank) |> Repo.one() diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 320b0277b..f7980ea86 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -6,7 +6,7 @@ defmodule CadetWeb.AssessmentsControllerTest do import Mock alias Cadet.{Assessments, Repo} - alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.{Role, CourseRegistration} alias Cadet.Assessments.{Assessment, Submission, SubmissionStatus} alias Cadet.Autograder.GradingJob alias CadetWeb.AssessmentsController @@ -23,9 +23,6 @@ defmodule CadetWeb.AssessmentsControllerTest do Cadet.Test.Seeds.assessments() end - @xp_early_submission_max_bonus 100 - @xp_bonus_assessment_type ~w(mission sidequest) - test "swagger" do AssessmentsController.swagger_definitions() AssessmentsController.swagger_path_index(nil) @@ -35,23 +32,28 @@ defmodule CadetWeb.AssessmentsControllerTest do end describe "GET /, unauthenticated" do - test "unauthorized", %{conn: conn} do - conn = get(conn, build_url()) + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id)) assert response(conn, 401) =~ "Unauthorised" end end describe "GET /:assessment_id, unauthenticated" do - test "unauthorized", %{conn: conn} do - conn = get(conn, build_url(1)) + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + conn = get(conn, build_url(course1.id, 1)) assert response(conn, 401) =~ "Unauthorised" end end # All roles should see almost the same overview describe "GET /, all roles" do - test "renders assessments overview", %{conn: conn, users: users, assessments: assessments} do - for {_role, user} <- users do + test "renders assessments overview", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs, + assessments: assessments + } do + for {_role, course_reg} <- role_crs do expected = assessments |> Map.values() @@ -59,6 +61,7 @@ defmodule CadetWeb.AssessmentsControllerTest do |> Enum.sort(&open_at_asc_comparator/2) |> Enum.map( &%{ + "courseId" => &1.course_id, "id" => &1.id, "title" => &1.title, "shortSummary" => &1.summary_short, @@ -67,11 +70,11 @@ defmodule CadetWeb.AssessmentsControllerTest do "reading" => &1.reading, "openAt" => format_datetime(&1.open_at), "closeAt" => format_datetime(&1.close_at), - "type" => &1.type, + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, - "maxGrade" => 750, "maxXp" => 4800, - "status" => get_assessment_status(user, &1), + "status" => get_assessment_status(course_reg, &1), "private" => false, "isPublished" => &1.is_published, "gradedCount" => 0, @@ -81,11 +84,10 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url()) + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "grade")) assert expected == resp end @@ -93,11 +95,13 @@ defmodule CadetWeb.AssessmentsControllerTest do test "render password protected assessments properly", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, assessments: assessments } do - for {_role, user} <- users do - mission = assessments["mission"] + for {_role, course_reg} <- role_crs do + mission = assessments[hd(configs).type] {:ok, _} = mission.assessment @@ -106,10 +110,10 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url()) + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) |> json_response(200) - |> Enum.find(&(&1["type"] == "mission")) + |> Enum.find(&(&1["type"] == hd(configs).type)) |> Map.get("private") assert resp == true @@ -120,10 +124,12 @@ defmodule CadetWeb.AssessmentsControllerTest do describe "GET /, student only" do test "does not render unpublished assessments", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, assessments: assessments } do - mission = assessments["mission"] + mission = assessments[hd(configs).type] {:ok, _} = mission.assessment @@ -132,12 +138,13 @@ defmodule CadetWeb.AssessmentsControllerTest do expected = assessments - |> Map.delete("mission") + |> Map.delete(hd(configs).type) |> Map.values() |> Enum.map(fn a -> a.assessment end) |> Enum.sort(&open_at_asc_comparator/2) |> Enum.map( &%{ + "courseId" => &1.course_id, "id" => &1.id, "title" => &1.title, "shortSummary" => &1.summary_short, @@ -146,9 +153,9 @@ defmodule CadetWeb.AssessmentsControllerTest do "reading" => &1.reading, "openAt" => format_datetime(&1.open_at), "closeAt" => format_datetime(&1.close_at), - "type" => &1.type, + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, - "maxGrade" => 750, "maxXp" => 4800, "status" => get_assessment_status(student, &1), "private" => false, @@ -160,22 +167,23 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(student) - |> get(build_url()) + |> sign_in(student.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "grade")) assert expected == resp end test "renders student submission status in overview", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, assessments: assessments } do - assessment = assessments["mission"].assessment - [submission | _] = assessments["mission"].submissions + assessment = assessments[hd(configs).type].assessment + [submission | _] = assessments[hd(configs).type].submissions for status <- SubmissionStatus.__enum_map__() do submission @@ -184,8 +192,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(student) - |> get(build_url()) + |> sign_in(student.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.find(&(&1["id"] == assessment.id)) |> Map.get("status") @@ -196,50 +204,36 @@ defmodule CadetWeb.AssessmentsControllerTest do test "renders xp for students", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, + assessment_configs: configs, assessments: assessments } do - assessment = assessments["mission"].assessment + assessment = assessments[hd(configs).type].assessment resp = conn - |> sign_in(student) - |> get(build_url()) + |> sign_in(student.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.find(&(&1["id"] == assessment.id)) |> Map.get("xp") assert resp == 1000 * 3 + 500 * 3 + 100 * 3 end - - test "renders grade for students", %{ - conn: conn, - users: %{student: student}, - assessments: assessments - } do - assessment = assessments["mission"].assessment - - resp = - conn - |> sign_in(student) - |> get(build_url()) - |> json_response(200) - |> Enum.find(&(&1["id"] == assessment.id)) - |> Map.get("grade") - - assert resp == 200 * 3 + 40 * 3 + 10 * 3 - end end describe "GET /, non-students" do test "renders unpublished assessments", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, + assessment_configs: configs, assessments: assessments } do for role <- ~w(staff admin)a do - user = Map.get(users, role) - mission = assessments["mission"] + course_reg = Map.get(role_crs, role) + mission = assessments[hd(configs).type] {:ok, _} = mission.assessment @@ -248,11 +242,10 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url()) + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "grade")) expected = assessments @@ -262,6 +255,7 @@ defmodule CadetWeb.AssessmentsControllerTest do |> Enum.map( &%{ "id" => &1.id, + "courseId" => &1.course_id, "title" => &1.title, "shortSummary" => &1.summary_short, "story" => &1.story, @@ -269,16 +263,16 @@ defmodule CadetWeb.AssessmentsControllerTest do "reading" => &1.reading, "openAt" => format_datetime(&1.open_at), "closeAt" => format_datetime(&1.close_at), - "type" => &1.type, + "type" => &1.config.type, + "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, - "maxGrade" => 750, "maxXp" => 4800, - "status" => get_assessment_status(user, &1), + "status" => get_assessment_status(course_reg, &1), "private" => false, "gradedCount" => 0, "questionCount" => 9, "isPublished" => - if &1.type == "mission" do + if &1.config.type == hd(configs).type do false else &1.is_published @@ -294,17 +288,19 @@ defmodule CadetWeb.AssessmentsControllerTest do describe "GET /assessment_id, all roles" do test "it renders assessment details", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) - for {_type, %{assessment: assessment}} <- assessments do + for {type, %{assessment: assessment}} <- assessments do expected_assessments = %{ + "courseId" => assessment.course_id, "id" => assessment.id, "title" => assessment.title, - "type" => "#{assessment.type}", + "type" => type, "story" => assessment.story, "number" => assessment.number, "reading" => assessment.reading, @@ -314,8 +310,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp_assessments = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.delete("questions") @@ -326,11 +322,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it renders assessment questions", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) for {_type, %{ @@ -346,15 +343,11 @@ defmodule CadetWeb.AssessmentsControllerTest do &%{ "id" => &1.id, "type" => "#{&1.type}", + "blocking" => &1.blocking, "content" => &1.question.content, "solutionTemplate" => &1.question.template, "prepend" => &1.question.prepend, - "postpend" => - if assessment.type == "path" do - &1.question.postpend - else - "" - end, + "postpend" => &1.question.postpend, "testcases" => Enum.map( &1.question.public, @@ -364,18 +357,14 @@ defmodule CadetWeb.AssessmentsControllerTest do do: {Atom.to_string(k), v} end ) ++ - if assessment.type == "path" do - Enum.map( - &1.question.private, - fn testcase -> - for {k, v} <- testcase, - into: %{"type" => "hidden"}, - do: {Atom.to_string(k), v} - end - ) - else - [] - end + Enum.map( + &1.question.opaque, + fn testcase -> + for {k, v} <- testcase, + into: %{"type" => "opaque"}, + do: {Atom.to_string(k), v} + end + ) } ) @@ -385,6 +374,7 @@ defmodule CadetWeb.AssessmentsControllerTest do &%{ "id" => &1.id, "type" => "#{&1.type}", + "blocking" => &1.blocking, "content" => &1.question.content, "choices" => Enum.map( @@ -406,6 +396,7 @@ defmodule CadetWeb.AssessmentsControllerTest do &%{ "id" => &1.id, "type" => "#{&1.type}", + "blocking" => &1.blocking, "content" => &1.question.content, "solutionTemplate" => &1.question.template, "prepend" => &1.question.prepend @@ -430,7 +421,11 @@ defmodule CadetWeb.AssessmentsControllerTest do |> Enum.zip(contests_submissions) |> Enum.map(fn {question, contest_submissions} -> Enum.map(contest_submissions, fn submission -> - insert(:submission_vote, %{user: user, submission: submission, question: question}) + insert(:submission_vote, %{ + voter: course_reg, + submission: submission, + question: question + }) end) end) @@ -458,17 +453,15 @@ defmodule CadetWeb.AssessmentsControllerTest do resp_questions = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.delete(&1, "answer")) |> Enum.map(&Map.delete(&1, "solution")) |> Enum.map(&Map.delete(&1, "library")) |> Enum.map(&Map.delete(&1, "xp")) - |> Enum.map(&Map.delete(&1, "grade")) |> Enum.map(&Map.delete(&1, "maxXp")) - |> Enum.map(&Map.delete(&1, "maxGrade")) |> Enum.map(&Map.delete(&1, "grader")) |> Enum.map(&Map.delete(&1, "gradedAt")) |> Enum.map(&Map.delete(&1, "autogradingResults")) @@ -482,11 +475,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it renders contest leaderboards", %{ conn: conn, - accounts: accounts, - users: users, + course_regs: course_regs, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do - voting_question = assessments["contest"].voting_questions |> List.first() + voting_question = assessments["practical"].voting_questions |> List.first() contest_assessment_number = voting_question.question.contest_number contest_assessment = Repo.get_by(Assessment, number: contest_assessment_number) @@ -496,20 +490,18 @@ defmodule CadetWeb.AssessmentsControllerTest do insert(:programming_question, %{ display_order: 1, assessment: contest_assessment, - max_grade: 0, max_xp: 1000 }) # insert contest submissions and answers contest_submissions = - for student <- Enum.take(accounts.students, 2) do + for student <- Enum.take(course_regs.students, 2) do insert(:submission, %{assessment: contest_assessment, student: student}) end contest_answers = for {submission, score} <- Enum.with_index(contest_submissions, 1) do insert(:answer, %{ - grade: 0, xp: 1000, question: contest_question, submission: submission, @@ -523,19 +515,19 @@ defmodule CadetWeb.AssessmentsControllerTest do %{ "answer" => %{"code" => answer.answer.code}, "score" => answer.relative_score, - "student_name" => answer.submission.student.name, + "student_name" => answer.submission.student.user.name, "submission_id" => answer.submission.id } end |> Enum.sort_by(& &1["score"], &>=/2) for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) resp_leaderboard = conn - |> sign_in(user) - |> get(build_url(voting_question.assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, voting_question.assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.find(&(&1["id"] == voting_question.id)) @@ -547,11 +539,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it renders assessment question libraries", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) for {_type, %{ @@ -578,8 +571,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp_libraries = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.get(&1, "library")) @@ -591,11 +584,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it renders solutions for ungraded assessments (path)", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) %{ assessment: assessment, @@ -604,6 +598,8 @@ defmodule CadetWeb.AssessmentsControllerTest do voting_questions: voting_questions } = assessments["path"] + # This is the case cuz the seed set "path" to build_soultion = true + # Seeds set solution as 0 expected_mcq_solutions = Enum.map(mcq_questions, fn _ -> %{"solution" => 0} end) @@ -620,8 +616,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp_solutions = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.take(&1, ["solution"])) @@ -633,11 +629,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it renders xp, grade for students", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) for {_type, %{ @@ -651,12 +648,11 @@ defmodule CadetWeb.AssessmentsControllerTest do Enum.map( programming_answers ++ mcq_answers ++ voting_answers, &%{ - "xp" => &1.xp + &1.xp_adjustment, - "grade" => &1.grade + &1.adjustment + "xp" => &1.xp + &1.xp_adjustment } ) else - fn -> %{"xp" => 0, "grade" => 0} end + fn -> %{"xp" => 0} end |> Stream.repeatedly() |> Enum.take( length(programming_answers) + length(mcq_answers) + length(voting_answers) @@ -665,11 +661,11 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) - |> Enum.map(&Map.take(&1, ~w(xp grade))) + |> Enum.map(&Map.take(&1, ~w(xp))) assert expected == resp end @@ -678,11 +674,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it does not render solutions for ungraded assessments (path)", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- Role.__enum_map__() do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) for {_type, %{ @@ -690,8 +687,8 @@ defmodule CadetWeb.AssessmentsControllerTest do }} <- Map.delete(assessments, "path") do resp_solutions = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.get(&1, ["solution"])) @@ -705,7 +702,8 @@ defmodule CadetWeb.AssessmentsControllerTest do describe "GET /assessment_id, student" do test "it renders previously submitted answers", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, assessments: assessments } do for {_type, @@ -729,8 +727,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp_answers = conn - |> sign_in(student) - |> get(build_url(assessment.id)) + |> sign_in(student.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.take(&1, ["answer"])) @@ -741,7 +739,8 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it does not permit access to not yet open assessments", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, assessments: %{"mission" => mission} } do mission.assessment @@ -753,15 +752,16 @@ defmodule CadetWeb.AssessmentsControllerTest do conn = conn - |> sign_in(student) - |> get(build_url(mission.assessment.id)) + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) assert response(conn, 401) == "Assessment not open" end test "it does not permit access to unpublished assessments", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, assessments: %{"mission" => mission} } do {:ok, _} = @@ -771,8 +771,8 @@ defmodule CadetWeb.AssessmentsControllerTest do conn = conn - |> sign_in(student) - |> get(build_url(mission.assessment.id)) + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) assert response(conn, 400) == "Assessment not found" end @@ -781,17 +781,18 @@ defmodule CadetWeb.AssessmentsControllerTest do describe "GET /assessment_id, non-students" do test "it renders empty answers", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do for role <- ~w(staff admin)a do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) for {_type, %{assessment: assessment}} <- assessments do resp_answers = conn - |> sign_in(user) - |> get(build_url(assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, assessment.id)) |> json_response(200) |> Map.get("questions", []) |> Enum.map(&Map.get(&1, ["answer"])) @@ -803,11 +804,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it permits access to not yet open assessments", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: %{"mission" => mission} } do for role <- ~w(staff admin)a do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) mission.assessment |> Assessment.changeset(%{ @@ -818,8 +820,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url(mission.assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) |> json_response(200) assert resp["id"] == mission.assessment.id @@ -828,11 +830,12 @@ defmodule CadetWeb.AssessmentsControllerTest do test "it permits access to unpublished assessments", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: %{"mission" => mission} } do for role <- ~w(staff admin)a do - user = Map.get(users, role) + course_reg = Map.get(role_crs, role) {:ok, _} = mission.assessment @@ -841,8 +844,8 @@ defmodule CadetWeb.AssessmentsControllerTest do resp = conn - |> sign_in(user) - |> get(build_url(mission.assessment.id)) + |> sign_in(course_reg.user) + |> get(build_url(course1.id, mission.assessment.id)) |> json_response(200) assert resp["id"] == mission.assessment.id @@ -851,8 +854,12 @@ defmodule CadetWeb.AssessmentsControllerTest do end describe "GET /assessment_id/submit unauthenticated" do - test "is not permitted", %{conn: conn, assessments: %{"mission" => %{assessment: assessment}}} do - conn = post(conn, build_url_submit(assessment.id)) + test "is not permitted", %{ + conn: conn, + courses: %{course1: course1}, + assessments: %{"mission" => %{assessment: assessment}} + } do + conn = post(conn, build_url_submit(course1.id, assessment.id)) assert response(conn, 401) == "Unauthorised" end end @@ -862,21 +869,28 @@ defmodule CadetWeb.AssessmentsControllerTest do @tag role: role test "is successful for attempted assessments for #{role}", %{ conn: conn, + courses: %{course1: course1}, assessments: %{"mission" => %{assessment: assessment}}, + role_crs: role_crs, role: role } do with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - group = if(role == :student, do: insert(:group), else: nil) - user = insert(:user, %{role: role, group: group}) + group = + if(role == :student, + do: insert(:group, %{course: course1, leader: role_crs.staff}), + else: nil + ) + + course_reg = insert(:course_registration, %{role: role, group: group, course: course1}) submission = - insert(:submission, %{student: user, assessment: assessment, status: :attempted}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempted}) conn = conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) assert response(conn, 200) == "OK" @@ -890,67 +904,93 @@ defmodule CadetWeb.AssessmentsControllerTest do end end - test "submission of answer within 2 days of opening grants full XP bonus", %{conn: conn} do + test "submission of answer within early hours(seeded 48) of opening grants full XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for type <- @xp_bonus_assessment_type do - assessment = - insert( - :assessment, - open_at: Timex.shift(Timex.now(), hours: -40), - close_at: Timex.shift(Timex.now(), days: 7), - is_published: true, - type: type - ) + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) - question = insert(:programming_question, assessment: assessment) + assessment = + insert( + :assessment, + open_at: Timex.shift(Timex.now(), hours: -40), + close_at: Timex.shift(Timex.now(), days: 7), + is_published: true, + config: assessment_config, + course: course1 + ) - group = insert(:group) - user = insert(:user, %{role: :student, group: group}) + question = insert(:programming_question, assessment: assessment) - submission = - insert(:submission, assessment: assessment, student: user, status: :attempted) + group = insert(:group, leader: role_crs.staff) - insert( - :answer, - submission: submission, - question: question, - answer: %{code: "f => f(f);"} - ) + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) - conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) - |> response(200) + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) - submission_db = Repo.get(Submission, submission.id) + insert( + :answer, + submission: submission, + question: question, + answer: %{code: "f => f(f);"} + ) - assert submission_db.status == :submitted - assert submission_db.xp_bonus == @xp_early_submission_max_bonus - end + conn + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) + |> response(200) + + submission_db = Repo.get(Submission, submission.id) + + assert submission_db.status == :submitted + assert submission_db.xp_bonus == 100 end end - test "submission of answer after 2 days within the next 100 hours of opening grants decaying XP bonus", - %{conn: conn} do + test "submission of answer after early hours before deadline get decaying XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for hours_after <- 48..148, - type <- @xp_bonus_assessment_type do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + assessment = insert( :assessment, open_at: Timex.shift(Timex.now(), hours: -hours_after), - close_at: Timex.shift(Timex.now(), hours: 500), + close_at: Timex.shift(Timex.now(), hours: 100), is_published: true, - type: type + config: assessment_config, + course: course1 ) question = insert(:programming_question, assessment: assessment) - group = insert(:group) - user = insert(:user, %{role: :student, group: group}) + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) submission = - insert(:submission, assessment: assessment, student: user, status: :attempted) + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) insert( :answer, @@ -960,39 +1000,55 @@ defmodule CadetWeb.AssessmentsControllerTest do ) conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) |> response(200) submission_db = Repo.get(Submission, submission.id) + proportion = + Timex.diff(assessment.close_at, Timex.now(), :hours) / (100 + hours_after - 48) + assert submission_db.status == :submitted - assert submission_db.xp_bonus == @xp_early_submission_max_bonus - (hours_after - 48) + assert submission_db.xp_bonus == round(proportion * 100) end end end - test "submission of answer after 2 days and after the next 100 hours yield 0 XP bonus", %{ - conn: conn + test "submission of answer at the last hour yield 0 XP bonus", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs } do with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - for type <- @xp_bonus_assessment_type do + for hours_after <- 48..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 100, + hours_before_early_xp_decay: 48, + course: course1 + ) + assessment = insert( :assessment, - open_at: Timex.shift(Timex.now(), hours: -150), - close_at: Timex.shift(Timex.now(), days: 7), + open_at: Timex.shift(Timex.now(), hours: -hours_after), + close_at: Timex.shift(Timex.now(), hours: 1), is_published: true, - type: type + config: assessment_config, + course: course1 ) question = insert(:programming_question, assessment: assessment) - group = insert(:group) - user = insert(:user, %{role: :student, group: group}) + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) submission = - insert(:submission, assessment: assessment, student: user, status: :attempted) + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) insert( :answer, @@ -1002,8 +1058,8 @@ defmodule CadetWeb.AssessmentsControllerTest do ) conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) |> response(200) submission_db = Repo.get(Submission, submission.id) @@ -1014,29 +1070,40 @@ defmodule CadetWeb.AssessmentsControllerTest do end end - test "does not give bonus for non-bonus eligible assessment types", %{conn: conn} do + test "give 0 bonus for configs with 0 max", %{ + conn: conn, + courses: %{course1: course1}, + role_crs: role_crs + } do with_mock GradingJob, force_grade_individual_submission: fn _ -> nil end do - non_eligible_types = - Enum.filter(Assessment.assessment_types(), &(&1 not in @xp_bonus_assessment_type)) + for hours_after <- 0..148 do + assessment_config = + insert( + :assessment_config, + early_submission_xp: 0, + hours_before_early_xp_decay: 48, + course: course1 + ) - for hours_after <- 0..148, - type <- non_eligible_types do assessment = insert( :assessment, open_at: Timex.shift(Timex.now(), hours: -hours_after), close_at: Timex.shift(Timex.now(), days: 7), is_published: true, - type: type + config: assessment_config, + course: course1 ) question = insert(:programming_question, assessment: assessment) - group = insert(:group) - user = insert(:user, %{role: :student, group: group}) + group = insert(:group, leader: role_crs.staff) + + course_reg = + insert(:course_registration, %{role: :student, group: group, course: course1}) submission = - insert(:submission, assessment: assessment, student: user, status: :attempted) + insert(:submission, assessment: assessment, student: course_reg, status: :attempted) insert( :answer, @@ -1046,8 +1113,8 @@ defmodule CadetWeb.AssessmentsControllerTest do ) conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) |> response(200) submission_db = Repo.get(Submission, submission.id) @@ -1062,77 +1129,115 @@ defmodule CadetWeb.AssessmentsControllerTest do # answered. test "is not permitted for unattempted assessments", %{ conn: conn, + courses: %{course1: course1}, assessments: %{"mission" => %{assessment: assessment}} } do - user = insert(:user, %{role: :student}) + course_reg = insert(:course_registration, %{role: :student, course: course1}) conn = conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) assert response(conn, 404) == "Submission not found" end test "is not permitted for incomplete assessments", %{ conn: conn, + courses: %{course1: course1}, assessments: %{"mission" => %{assessment: assessment}} } do - user = insert(:user, %{role: :student}) - insert(:submission, %{student: user, assessment: assessment, status: :attempting}) + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :attempting}) conn = conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) assert response(conn, 400) == "Some questions have not been attempted" end test "is not permitted for already submitted assessments", %{ conn: conn, + courses: %{course1: course1}, assessments: %{"mission" => %{assessment: assessment}} } do - user = insert(:user, %{role: :student}) - insert(:submission, %{student: user, assessment: assessment, status: :submitted}) + course_reg = insert(:course_registration, %{role: :student, course: course1}) + insert(:submission, %{student: course_reg, assessment: assessment, status: :submitted}) conn = conn - |> sign_in(user) - |> post(build_url_submit(assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, assessment.id)) assert response(conn, 403) == "Assessment has already been submitted" end - test "is not permitted for closed assessments", %{conn: conn} do - user = insert(:user, %{role: :student}) + test "is not permitted for closed assessments", %{conn: conn, courses: %{course1: course1}} do + course_reg = insert(:course_registration, %{role: :student, course: course1}) # Only check for after-closing because submission shouldn't exist if unpublished or # before opening and would fall under "Submission not found" after_close_at_assessment = insert(:assessment, %{ open_at: Timex.shift(Timex.now(), days: -10), - close_at: Timex.shift(Timex.now(), days: -5) + close_at: Timex.shift(Timex.now(), days: -5), + course: course1 }) insert(:submission, %{ - student: user, + student: course_reg, assessment: after_close_at_assessment, status: :attempted }) conn = conn - |> sign_in(user) - |> post(build_url_submit(after_close_at_assessment.id)) + |> sign_in(course_reg.user) + |> post(build_url_submit(course1.id, after_close_at_assessment.id)) assert response(conn, 403) == "Assessment not open" end + + test "not found if not in same course", %{ + conn: conn, + courses: %{course2: course2}, + role_crs: %{student: student}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is in both course, but assessment belongs to a course and no submission will be found + conn = + conn + |> sign_in(student.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 404) == "Submission not found" + end + + test "forbidden if not in course", %{ + conn: conn, + courses: %{course2: course2}, + course_regs: %{students: students}, + assessments: %{"mission" => %{assessment: assessment}} + } do + # user is not in the course + student2 = hd(tl(students)) + + conn = + conn + |> sign_in(student2.user) + |> post(build_url_submit(course2.id, assessment.id)) + + assert response(conn, 403) == "Forbidden" + end end test "graded count is updated when assessment is graded", %{ conn: conn, - users: %{staff: avenger} + courses: %{course1: course1}, + assessment_configs: [config | _], + role_crs: %{staff: avenger} } do assessment = insert( @@ -1140,14 +1245,16 @@ defmodule CadetWeb.AssessmentsControllerTest do open_at: Timex.shift(Timex.now(), hours: -2), close_at: Timex.shift(Timex.now(), days: 7), is_published: true, - type: "mission" + config: config, + course: course1 ) [question_one, question_two] = insert_list(2, :programming_question, assessment: assessment) - user = insert(:user, role: :student) + course_reg = insert(:course_registration, role: :student, course: course1) - submission = insert(:submission, assessment: assessment, student: user, status: :submitted) + submission = + insert(:submission, assessment: assessment, student: course_reg, status: :submitted) Enum.each( [question_one, question_two], @@ -1156,8 +1263,8 @@ defmodule CadetWeb.AssessmentsControllerTest do get_graded_count = fn -> conn - |> sign_in(user) - |> get(build_url()) + |> sign_in(course_reg.user) + |> get(build_url(course1.id)) |> json_response(200) |> Enum.find(&(&1["id"] == assessment.id)) |> Map.get("gradedCount") @@ -1166,7 +1273,7 @@ defmodule CadetWeb.AssessmentsControllerTest do grade_question = fn question -> Assessments.update_grading_info( %{submission_id: submission.id, question_id: question.id}, - %{"adjustment" => 0}, + %{"xp_adjustment" => 0}, avenger ) end @@ -1183,12 +1290,9 @@ defmodule CadetWeb.AssessmentsControllerTest do end describe "Password protected assessments render properly" do - test "returns 403 when trying to access a password protected assessment without a password", - %{ - conn: conn, - users: users - } do - assessment = insert(:assessment, %{type: "practical", is_published: true}) + setup %{courses: %{course1: course1}, assessment_configs: configs} do + assessment = + insert(:assessment, %{config: Enum.at(configs, 4), course: course1, is_published: true}) assessment |> Assessment.changeset(%{ @@ -1198,68 +1302,65 @@ defmodule CadetWeb.AssessmentsControllerTest do }) |> Repo.update!() - for {_role, user} <- users do - conn = conn |> sign_in(user) |> get(build_url(assessment.id)) + {:ok, protected_assessment: assessment} + end + + test "returns 403 when trying to access a password protected assessment without a password", + %{ + conn: conn, + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs + } do + for {_role, course_reg} <- role_crs do + conn = + conn |> sign_in(course_reg.user) |> get(build_url(course1.id, protected_assessment.id)) + assert response(conn, 403) == "Missing Password." end end test "returns 403 when password is wrong/invalid", %{ conn: conn, - users: users + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs } do - assessment = insert(:assessment, %{type: "practical", is_published: true}) - - assessment - |> Assessment.changeset(%{ - password: "mysupersecretpassword", - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: +1) - }) - |> Repo.update!() - - for {_role, user} <- users do + for {_role, course_reg} <- role_crs do conn = conn - |> sign_in(user) - |> post(build_url_unlock(assessment.id), %{:password => "wrong"}) + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{:password => "wrong"}) assert response(conn, 403) == "Invalid Password." end end - test "allow users with preexisting submission to access private assessment without a password", + test "allow role_crs with preexisting submission to access private assessment without a password", %{ conn: conn, - users: %{student: student} + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: %{student: student} } do - assessment = insert(:assessment, %{type: "practical", is_published: true}) - - assessment - |> Assessment.changeset(%{ - password: "mysupersecretpassword", - open_at: Timex.shift(Timex.now(), days: -2), - close_at: Timex.shift(Timex.now(), days: +1) - }) - |> Repo.update!() - - insert(:submission, %{assessment: assessment, student: student}) - conn = conn |> sign_in(student) |> get(build_url(assessment.id)) + insert(:submission, %{assessment: protected_assessment, student: student}) + conn = conn |> sign_in(student.user) |> get(build_url(course1.id, protected_assessment.id)) assert response(conn, 200) end test "ignore password when assessment is not password protected", %{ conn: conn, - users: users, + courses: %{course1: course1}, + role_crs: role_crs, assessments: assessments } do assessment = assessments["mission"].assessment - for {_role, user} <- users do + for {_role, course_reg} <- role_crs do conn = conn - |> sign_in(user) - |> post(build_url_unlock(assessment.id), %{:password => "wrong"}) + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, assessment.id), %{:password => "wrong"}) |> json_response(200) assert conn["id"] == assessment.id @@ -1268,30 +1369,27 @@ defmodule CadetWeb.AssessmentsControllerTest do test "render assessment when password is correct", %{ conn: conn, - users: users, - assessments: assessments + courses: %{course1: course1}, + protected_assessment: protected_assessment, + role_crs: role_crs } do - assessment = assessments["mission"].assessment - - {:ok, _} = - assessment - |> Assessment.changeset(%{password: "mysupersecretpassword"}) - |> Repo.update() - - for {_role, user} <- users do + for {_role, course_reg} <- role_crs do conn = conn - |> sign_in(user) - |> post(build_url_unlock(assessment.id), %{:password => "mysupersecretpassword"}) + |> sign_in(course_reg.user) + |> post(build_url_unlock(course1.id, protected_assessment.id), %{ + :password => "mysupersecretpassword" + }) |> json_response(200) - assert conn["id"] == assessment.id + assert conn["id"] == protected_assessment.id end end test "permit global access to private assessment after closed", %{ conn: conn, - users: %{student: student}, + courses: %{course1: course1}, + role_crs: %{student: student}, assessments: %{"mission" => mission} } do mission.assessment @@ -1303,24 +1401,30 @@ defmodule CadetWeb.AssessmentsControllerTest do conn = conn - |> sign_in(student) - |> get(build_url(mission.assessment.id)) + |> sign_in(student.user) + |> get(build_url(course1.id, mission.assessment.id)) assert response(conn, 200) end end - defp build_url, do: "/v2/assessments/" - defp build_url(assessment_id), do: "/v2/assessments/#{assessment_id}" - defp build_url_submit(assessment_id), do: "/v2/assessments/#{assessment_id}/submit" - defp build_url_unlock(assessment_id), do: "/v2/assessments/#{assessment_id}/unlock" + defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" + + defp build_url(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}" + + defp build_url_submit(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/submit" + + defp build_url_unlock(course_id, assessment_id), + do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) - defp get_assessment_status(user = %User{}, assessment = %Assessment{}) do + defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do submission = Submission - |> where(student_id: ^user.id) + |> where(student_id: ^course_reg.id) |> where(assessment_id: ^assessment.id) |> Repo.one() diff --git a/test/cadet_web/controllers/auth_controller_test.exs b/test/cadet_web/controllers/auth_controller_test.exs index 1b2295903..1a4a9a45e 100644 --- a/test/cadet_web/controllers/auth_controller_test.exs +++ b/test/cadet_web/controllers/auth_controller_test.exs @@ -63,7 +63,8 @@ defmodule CadetWeb.AuthControllerTest do "client_id" => "" }) - assert response(conn, 400) == "Unable to retrieve token from ADFS: Upstream error" + assert response(conn, 400) == + "Unable to retrieve token from authentication provider: Upstream error" end test_with_mock "unknown error from Provider.authorise", diff --git a/test/cadet_web/controllers/courses_controller_test.exs b/test/cadet_web/controllers/courses_controller_test.exs new file mode 100644 index 000000000..0d8d04abf --- /dev/null +++ b/test/cadet_web/controllers/courses_controller_test.exs @@ -0,0 +1,133 @@ +defmodule CadetWeb.CoursesControllerTest do + use CadetWeb.ConnCase + + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.CourseRegistration + alias Cadet.Courses.Course + alias CadetWeb.CoursesController + + test "swagger" do + CoursesController.swagger_definitions() + CoursesController.swagger_path_get_course_config(nil) + end + + describe "POST /v2/config/create" do + @tag authenticate: :student + test "succeeds", %{conn: conn} do + user = conn.assigns.current_user + assert CourseRegistration |> where(user_id: ^user.id) |> Repo.all() |> length() == 1 + + 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" + } + + resp = post(conn, build_url_create(), params) + + assert response(resp, 200) == "OK" + assert CourseRegistration |> where(user_id: ^user.id) |> Repo.all() |> length() == 2 + end + + @tag authenticate: :student + test "fails when there are missing parameters", %{conn: conn} do + user = conn.assigns.current_user + assert CourseRegistration |> where(user_id: ^user.id) |> Repo.all() |> length() == 1 + + params = %{ + "course_name" => "CS1101S Programming Methodology (AY20/21 Sem 1)", + "course_short_name" => "CS1101S", + "viewable" => "true", + "enable_achievements" => "true", + "enable_sourcecast" => "true", + "source_variant" => "default", + "module_help_text" => "Help Text" + } + + conn = post(conn, build_url_create(), params) + + assert response(conn, 400) == "Invalid parameter(s)" + end + + @tag authenticate: :student + test "fails when there are invalid parameters", %{conn: conn} do + user = conn.assigns.current_user + assert CourseRegistration |> where(user_id: ^user.id) |> Repo.all() |> length() == 1 + + params = %{ + "course_name" => "CS1101S Programming Methodology (AY20/21 Sem 1)", + "course_short_name" => "CS1101S", + "viewable" => "boolean", + "enable_game" => "true", + "enable_achievements" => "true", + "enable_sourcecast" => "true", + "source_chapter" => "1", + "source_variant" => "default", + "module_help_text" => "Help Text" + } + + conn = post(conn, build_url_create(), params) + + assert response(conn, 400) == "Invalid parameter(s)" + end + end + + describe "GET /v2/courses/course_id/config, unauthenticated" do + test "unauthorized", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url_config(course.id)) + assert response(conn, 401) == "Unauthorised" + end + end + + describe "GET /v2/courses/course_id/config" do + @tag authenticate: :student + test "succeeds", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + + insert(:assessment_config, %{order: 3, type: "Paths", course: course}) + insert(:assessment_config, %{order: 1, type: "Missions", course: course}) + insert(:assessment_config, %{order: 2, type: "Quests", course: course}) + + resp = conn |> get(build_url_config(course_id)) |> json_response(200) + + assert %{ + "config" => %{ + "courseName" => "Programming Methodology", + "courseShortName" => "CS1101S", + "viewable" => true, + "enableGame" => true, + "enableAchievements" => true, + "enableSourcecast" => true, + "sourceChapter" => 1, + "sourceVariant" => "default", + "moduleHelpText" => "Help Text", + "assessmentTypes" => ["Missions", "Quests", "Paths"] + } + } = resp + end + + @tag authenticate: :student + test "returns with error for user not belonging to the specified course", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + conn + |> get(build_url_config(course_id + 1)) + + assert response(conn, 403) == "Forbidden" + end + end + + defp build_url_create, do: "/v2/config/create" + defp build_url_config(course_id), do: "/v2/courses/#{course_id}/config" +end diff --git a/test/cadet_web/controllers/incentives_controller_test.exs b/test/cadet_web/controllers/incentives_controller_test.exs index e66e78242..4883f9d1f 100644 --- a/test/cadet_web/controllers/incentives_controller_test.exs +++ b/test/cadet_web/controllers/incentives_controller_test.exs @@ -5,7 +5,7 @@ defmodule CadetWeb.IncentivesControllerTest do alias Cadet.Repo alias CadetWeb.IncentivesController - alias Cadet.Incentives.{Goal, Goals, GoalProgress} + alias Cadet.Incentives.{Goals, GoalProgress} alias Ecto.UUID test "swagger" do @@ -15,46 +15,49 @@ defmodule CadetWeb.IncentivesControllerTest do assert is_map(IncentivesController.swagger_path_update_progress(nil)) end - describe "GET /achievements" do + describe "GET v2/coures/:course_id/achievements" do @tag authenticate: :student test "succeeds if authenticated", %{conn: conn} do - insert(:achievement, achievement_literal(0)) + course = conn.assigns.test_cr.course + insert(:achievement, Map.merge(achievement_literal(0), %{course: course})) - resp = conn |> get("/v2/achievements") |> json_response(200) + resp = conn |> get(build_url_achievements(course.id)) |> json_response(200) assert [achievement_json_literal(0)] = resp end test "401 if unauthenticated", %{conn: conn} do - conn |> get("/v2/achievements") |> response(401) + course = insert(:course) + conn |> get(build_url_achievements(course.id)) |> response(401) end end - describe "GET /self/goals" do + describe "GET v2/coures/:course_id/self/goals" do @tag authenticate: :student test "succeeds if authenticated", %{conn: conn} do - insert(:goal, goal_literal(0)) + course = conn.assigns.test_cr.course + insert(:goal, Map.merge(goal_literal(0), %{course: course})) - resp = conn |> get("/v2/self/goals") |> json_response(200) + resp = conn |> get(build_url_goals(course.id)) |> json_response(200) assert [goal_json_literal(0)] = resp end @tag authenticate: :student test "includes user's progress", %{conn: conn} do - user = conn.assigns.current_user - goal = insert(:goal, goal_literal(0)) + course_reg = conn.assigns.test_cr + goal = insert(:goal, Map.merge(goal_literal(0), %{course: course_reg.course})) {:ok, progress} = %GoalProgress{ goal_uuid: goal.uuid, - user_id: user.id, + course_reg_id: course_reg.id, count: 123, completed: true } |> Repo.insert() - [resp_goal] = conn |> get("/v2/self/goals") |> json_response(200) + [resp_goal] = conn |> get(build_url_goals(course_reg.course_id)) |> json_response(200) assert goal_json_literal(0) = resp_goal assert resp_goal["count"] == progress.count @@ -62,37 +65,66 @@ defmodule CadetWeb.IncentivesControllerTest do end test "401 if unauthenticated", %{conn: conn} do - conn |> get("/v2/self/goals") |> response(401) + course = insert(:course) + conn |> get(build_url_goals(course.id)) |> response(401) end end - describe "POST /self/goals/:uuid/progress" do - setup do - {:ok, g} = %Goal{uuid: UUID.generate()} |> Map.merge(goal_literal(5)) |> Repo.insert() - - %{goal: g} - end - + describe "POST v2/coures/:course_id/self/goals/:uuid/progress" do @tag authenticate: :student - test "succeeds if authenticated", %{conn: conn, goal: g} do - user = conn.assigns.current_user + test "succeeds if authenticated", %{conn: conn} do + course_reg = conn.assigns.test_cr + uuid = UUID.generate() + + goal = + insert( + :goal, + Map.merge(goal_literal(5), %{ + course: course_reg.course, + course_id: course_reg.course_id, + uuid: uuid + }) + ) conn - |> post("/v2/self/goals/#{g.uuid}/progress", %{ - "progress" => %{count: 100, completed: false, userid: user.id, uuid: g.uuid} + |> post(build_url_goals(course_reg.course_id, goal.uuid), %{ + "progress" => %{ + count: 100, + completed: false, + course_reg_id: course_reg.id, + uuid: goal.uuid + } }) |> response(204) - retrieved_goal = Goals.get_with_progress(user) + retrieved_goal = Goals.get_with_progress(course_reg) assert [%{progress: [%{count: 100, completed: false}]}] = retrieved_goal end - test "401 if unauthenticated", %{conn: conn, goal: g} do + test "401 if unauthenticated", %{conn: conn} do + course = insert(:course) + uuid = UUID.generate() + + goal = + insert( + :goal, + Map.merge(goal_literal(5), %{course: course, course_id: course.id, uuid: uuid}) + ) + conn - |> post("/v2/self/goals/#{g.uuid}/progress", %{ - "progress" => %{count: 100, completed: false, userid: 1, uuid: g.uuid} + |> post(build_url_goals(course.id, goal.uuid), %{ + "progress" => %{count: 100, completed: false, course_reg_id: 1, uuid: goal.uuid} }) |> response(401) end end + + defp build_url_achievements(course_id), + do: "/v2/courses/#{course_id}/achievements" + + defp build_url_goals(course_id), + do: "/v2/courses/#{course_id}/self/goals" + + defp build_url_goals(course_id, uuid), + do: "/v2/courses/#{course_id}/self/goals/#{uuid}/progress" end diff --git a/test/cadet_web/controllers/notifications_controller_test.exs b/test/cadet_web/controllers/notifications_controller_test.exs index e4fd10794..b55665d7d 100644 --- a/test/cadet_web/controllers/notifications_controller_test.exs +++ b/test/cadet_web/controllers/notifications_controller_test.exs @@ -10,25 +10,27 @@ defmodule CadetWeb.NotificationsControllerTest do end setup do - assessment = insert(:assessment, %{is_published: true}) - avenger = insert(:user, %{role: :staff}) - student = insert(:user, %{role: :student}) + course = insert(:course) + assessment = insert(:assessment, %{is_published: true, course: course}) + avenger = insert(:course_registration, %{role: :staff, course: course}) + student = insert(:course_registration, %{role: :student, course: course}) submission = insert(:submission, %{student: student, assessment: assessment}) notifications = insert_list(3, :notification, %{ read: false, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) ++ insert_list(3, :notification, %{ read: true, assessment_id: assessment.id, - user_id: student.id + course_reg_id: student.id }) {:ok, %{ + course: course, assessment: assessment, avenger: avenger, student: student, @@ -38,16 +40,16 @@ defmodule CadetWeb.NotificationsControllerTest do end describe "GET /, unauthenticated" do - test "/notifications", %{conn: conn} do - conn = get(conn, build_url()) + test "/notifications", %{course: course, conn: conn} do + conn = get(conn, build_url(course.id)) assert response(conn, 401) =~ "Unauthorised" end end describe "POST /, unaunthenticated" do - test "/notifications/acknowledge", %{conn: conn} do + test "/notifications/acknowledge", %{course: course, conn: conn} do conn = - post(conn, build_acknowledge_url(), %{ + post(conn, build_acknowledge_url(course.id), %{ "notificationIds" => [1] }) @@ -58,6 +60,7 @@ defmodule CadetWeb.NotificationsControllerTest do describe "GET /notifications" do test "student fetches unread notifications", %{ conn: conn, + course: course, student: student, assessment: assessment, notifications: notifications @@ -73,7 +76,7 @@ defmodule CadetWeb.NotificationsControllerTest do "submission_id" => nil, "type" => Atom.to_string(&1.type), "assessment" => %{ - "type" => assessment.type, + "type" => assessment.config.type, "title" => assessment.title } } @@ -81,8 +84,8 @@ defmodule CadetWeb.NotificationsControllerTest do results = conn - |> sign_in(student) - |> get(build_url()) + |> sign_in(student.user) + |> get(build_url(course.id)) |> json_response(200) |> Enum.sort(&(&1["id"] < &2["id"])) @@ -91,6 +94,7 @@ defmodule CadetWeb.NotificationsControllerTest do test "avenger fetches unread notifications", %{ conn: conn, + course: course, avenger: avenger, assessment: assessment, submission: submission @@ -101,7 +105,7 @@ defmodule CadetWeb.NotificationsControllerTest do read: false, assessment_id: assessment.id, submission_id: submission.id, - user_id: avenger.id + course_reg_id: avenger.id }) expected = @@ -115,7 +119,7 @@ defmodule CadetWeb.NotificationsControllerTest do "submission_id" => &1.submission_id, "type" => Atom.to_string(&1.type), "assessment" => %{ - "type" => assessment.type, + "type" => assessment.config.type, "title" => assessment.title } } @@ -123,8 +127,8 @@ defmodule CadetWeb.NotificationsControllerTest do results = conn - |> sign_in(avenger) - |> get(build_url()) + |> sign_in(avenger.user) + |> get(build_url(course.id)) |> json_response(200) |> Enum.sort(&(&1["id"] < &2["id"])) @@ -135,13 +139,14 @@ defmodule CadetWeb.NotificationsControllerTest do describe "POST /notifications/acknowledge" do test "student acknowledges own notification", %{ conn: conn, + course: course, student: student, notifications: notifications } do conn = conn - |> sign_in(student) - |> post(build_acknowledge_url(), %{ + |> sign_in(student.user) + |> post(build_acknowledge_url(course.id), %{ "notificationIds" => [Enum.random(notifications).id] }) @@ -150,13 +155,14 @@ defmodule CadetWeb.NotificationsControllerTest do test "other user not allowed to acknowledge notification that is not theirs", %{ conn: conn, + course: course, avenger: avenger, notifications: notifications } do conn = conn - |> sign_in(avenger) - |> post(build_acknowledge_url(), %{ + |> sign_in(avenger.user) + |> post(build_acknowledge_url(course.id), %{ "notificationIds" => [Enum.random(notifications).id] }) @@ -164,6 +170,6 @@ defmodule CadetWeb.NotificationsControllerTest do end end - defp build_url, do: "/v2/notifications" - defp build_acknowledge_url, do: "/v2/notifications/acknowledge" + defp build_url(course_id), do: "/v2/courses/#{course_id}/notifications" + defp build_acknowledge_url(course_id), do: "/v2/courses/#{course_id}/notifications/acknowledge" end diff --git a/test/cadet_web/controllers/settings_controller_test.exs b/test/cadet_web/controllers/settings_controller_test.exs deleted file mode 100644 index 6a3692a4c..000000000 --- a/test/cadet_web/controllers/settings_controller_test.exs +++ /dev/null @@ -1,28 +0,0 @@ -defmodule CadetWeb.SettingsControllerTest do - use CadetWeb.ConnCase - - alias CadetWeb.SettingsController - - test "swagger" do - SettingsController.swagger_definitions() - SettingsController.swagger_path_index(nil) - end - - describe "GET /settings/sublanguage" do - test "succeeds", %{conn: conn} do - insert(:sublanguage, %{chapter: 2, variant: "lazy"}) - - resp = conn |> get(build_url()) |> json_response(200) - - assert %{"sublanguage" => %{"chapter" => 2, "variant" => "lazy"}} = resp - end - - test "succeeds when no default sublanguage entry exists", %{conn: conn} do - resp = conn |> get(build_url()) |> json_response(200) - - assert %{"sublanguage" => %{"chapter" => 1, "variant" => "default"}} = resp - end - end - - defp build_url, do: "/v2/settings/sublanguage" -end diff --git a/test/cadet_web/controllers/sourcecast_controller_test.exs b/test/cadet_web/controllers/sourcecast_controller_test.exs index 7603a612e..6ba74aa20 100644 --- a/test/cadet_web/controllers/sourcecast_controller_test.exs +++ b/test/cadet_web/controllers/sourcecast_controller_test.exs @@ -1,6 +1,10 @@ defmodule CadetWeb.SourcecastControllerTest do use CadetWeb.ConnCase + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Courses.Course alias CadetWeb.SourcecastController test "swagger" do @@ -10,11 +14,13 @@ defmodule CadetWeb.SourcecastControllerTest do SourcecastController.swagger_path_delete(nil) end - describe "GET /sourcecast, unauthenticated" do - test "renders a list of all sourcecast entries for public", %{ + describe "GET /v2/sourcecast, unauthenticated" do + test "renders a list of all sourcecast entries for public (those without course_id)", %{ conn: conn } do %{sourcecasts: sourcecasts} = seed_db() + course = insert(:course) + seed_db(course.id) expected = sourcecasts @@ -29,7 +35,8 @@ defmodule CadetWeb.SourcecastControllerTest do "name" => &1.uploader.name, "id" => &1.uploader.id }, - "url" => Cadet.Courses.SourcecastUpload.url({&1.audio, &1}) + "url" => Cadet.Courses.SourcecastUpload.url({&1.audio, &1}), + "courseId" => nil } ) @@ -45,25 +52,31 @@ defmodule CadetWeb.SourcecastControllerTest do end end - describe "POST /sourcecast, unauthenticated" do + describe "POST /v2/courses/{course_id}/sourcecast, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = post(conn, build_url(), %{}) + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) assert response(conn, 401) =~ "Unauthorised" end end - describe "DELETE /sourcecast, unauthenticated" do + describe "DELETE /v2/courses/{course_id}/sourcecast, unauthenticated" do test "unauthorized", %{conn: conn} do - conn = delete(conn, build_url(1), %{}) + course = insert(:course) + seed_db(course.id) + conn = delete(conn, build_url(course.id, 1), %{}) assert response(conn, 401) =~ "Unauthorised" end end - describe "GET /sourcecast, all roles" do - test "renders a list of all sourcecast entries", %{ + describe "GET /v2/courses/{course_id}/sourcecast, returns course sourcecasts" do + @tag authenticate: :student + test "renders a list of all course sourcecast entries", %{ conn: conn } do - %{sourcecasts: sourcecasts} = seed_db() + course_id = conn.assigns[:course_id] + seed_db() + %{sourcecasts: sourcecasts} = seed_db(course_id) expected = sourcecasts @@ -78,13 +91,14 @@ defmodule CadetWeb.SourcecastControllerTest do "name" => &1.uploader.name, "id" => &1.uploader.id }, - "url" => Cadet.Courses.SourcecastUpload.url({&1.audio, &1}) + "url" => Cadet.Courses.SourcecastUpload.url({&1.audio, &1}), + "courseId" => course_id } ) res = conn - |> get(build_url()) + |> get(build_url(course_id)) |> json_response(200) |> Enum.map(&Map.delete(&1, "audio")) |> Enum.map(&Map.delete(&1, "inserted_at")) @@ -94,11 +108,13 @@ defmodule CadetWeb.SourcecastControllerTest do end end - describe "POST /sourcecast, student" do + describe "POST /v2/courses/{course_id}/sourcecast, student" do @tag authenticate: :student test "prohibited", %{conn: conn} do + course_id = conn.assigns[:course_id] + conn = - post(conn, build_url(), %{ + post(conn, build_url(course_id), %{ "sourcecast" => %{ "title" => "Title", "description" => "Description", @@ -116,20 +132,74 @@ defmodule CadetWeb.SourcecastControllerTest do end end - describe "DELETE /sourcecast, student" do + describe "DELETE /v2/courses/{course_id}/sourcecast, student" do @tag authenticate: :student test "prohibited", %{conn: conn} do - conn = delete(conn, build_url(1), %{}) + course_id = conn.assigns[:course_id] + + conn = delete(conn, build_url(course_id, 1), %{}) assert response(conn, 403) =~ "User is not permitted to delete" end end - describe "POST /sourcecast, staff" do + describe "POST /v2/courses/{course_id}/sourcecast, staff" do @tag authenticate: :staff - test "successful", %{conn: conn} do - conn = - post(conn, build_url(), %{ + test "successful for public sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + + post_conn = + post(conn, build_url(course_id), %{ + "sourcecast" => %{ + "title" => "Title", + "description" => "Description", + "playbackData" => + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}", + "audio" => %Plug.Upload{ + content_type: "audio/wav", + filename: "upload.wav", + path: "test/fixtures/upload.wav" + } + }, + "public" => true + }) + + assert response(post_conn, 200) == "OK" + + expected = [ + %{ + "title" => "Title", + "description" => "Description", + "playbackData" => + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}", + "uploader" => %{ + "id" => conn.assigns[:current_user].id, + "name" => conn.assigns[:current_user].name + }, + "courseId" => nil + } + ] + + res = + conn + |> get(build_url()) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "audio")) + |> Enum.map(&Map.delete(&1, "inserted_at")) + |> Enum.map(&Map.delete(&1, "updated_at")) + |> Enum.map(&Map.delete(&1, "id")) + |> Enum.map(&Map.delete(&1, "uid")) + |> Enum.map(&Map.delete(&1, "url")) + + assert expected == res + end + + @tag authenticate: :staff + test "successful for course sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + + post_conn = + post(conn, build_url(course_id), %{ "sourcecast" => %{ "title" => "Title", "description" => "Description", @@ -143,33 +213,101 @@ defmodule CadetWeb.SourcecastControllerTest do } }) - assert response(conn, 200) == "OK" + assert response(post_conn, 200) == "OK" + + expected = [ + %{ + "title" => "Title", + "description" => "Description", + "playbackData" => + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}", + "uploader" => %{ + "id" => conn.assigns[:current_user].id, + "name" => conn.assigns[:current_user].name + }, + "courseId" => course_id + } + ] + + res = + conn + |> get(build_url(course_id)) + |> json_response(200) + |> Enum.map(&Map.delete(&1, "audio")) + |> Enum.map(&Map.delete(&1, "inserted_at")) + |> Enum.map(&Map.delete(&1, "updated_at")) + |> Enum.map(&Map.delete(&1, "id")) + |> Enum.map(&Map.delete(&1, "uid")) + |> Enum.map(&Map.delete(&1, "url")) + + assert expected == res end @tag authenticate: :staff test "missing parameter", %{conn: conn} do - conn = post(conn, build_url(), %{}) + course_id = conn.assigns[:course_id] + + conn = post(conn, build_url(course_id), %{}) assert response(conn, 400) =~ "Missing or invalid parameter(s)" end end - describe "DELETE /sourcecast, staff" do + describe "DELETE /v2/courses/{course_id}/sourcecast, staff" do @tag authenticate: :staff - test "successful", %{conn: conn} do + test "successful for public sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + %{sourcecasts: sourcecasts} = seed_db() sourcecast = List.first(sourcecasts) - conn = delete(conn, build_url(sourcecast.id), %{}) + conn = delete(conn, build_url(course_id, sourcecast.id), %{}) + + assert response(conn, 200) =~ "OK" + end + + @tag authenticate: :staff + test "successful for course sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + + %{sourcecasts: sourcecasts} = seed_db(course_id) + sourcecast = List.first(sourcecasts) + + conn = delete(conn, build_url(course_id, sourcecast.id), %{}) assert response(conn, 200) =~ "OK" end end - describe "POST /sourcecast, admin" do + describe "POST /v2/courses/{course_id}/sourcecast, admin" do @tag authenticate: :admin - test "successful", %{conn: conn} do + test "successful for public sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + conn = - post(conn, build_url(), %{ + post(conn, build_url(course_id), %{ + "sourcecast" => %{ + "title" => "Title", + "description" => "Description", + "playbackData" => + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}", + "audio" => %Plug.Upload{ + content_type: "audio/wav", + filename: "upload.wav", + path: "test/fixtures/upload.wav" + } + }, + "public" => true + }) + + assert response(conn, 200) == "OK" + end + + @tag authenticate: :admin + test "successful for course sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + + conn = + post(conn, build_url(course_id), %{ "sourcecast" => %{ "title" => "Title", "description" => "Description", @@ -188,29 +326,69 @@ defmodule CadetWeb.SourcecastControllerTest do @tag authenticate: :admin test "missing parameter", %{conn: conn} do - conn = post(conn, build_url(), %{}) + course_id = conn.assigns[:course_id] + + conn = post(conn, build_url(course_id), %{}) assert response(conn, 400) =~ "Missing or invalid parameter(s)" end end - describe "DELETE /sourcecast, admin" do + describe "DELETE /v2/courses/{course_id}/sourcecast, admin" do @tag authenticate: :admin - test "successful", %{conn: conn} do + test "successful for public sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + %{sourcecasts: sourcecasts} = seed_db() sourcecast = List.first(sourcecasts) - conn = delete(conn, build_url(sourcecast.id), %{}) + conn = delete(conn, build_url(course_id, sourcecast.id), %{}) + + assert response(conn, 200) =~ "OK" + end + + @tag authenticate: :admin + test "successful for course sourcecast", %{conn: conn} do + course_id = conn.assigns[:course_id] + + %{sourcecasts: sourcecasts} = seed_db(course_id) + sourcecast = List.first(sourcecasts) + + conn = delete(conn, build_url(course_id, sourcecast.id), %{}) assert response(conn, 200) =~ "OK" end end - defp build_url, do: "v2/sourcecast/" - defp build_url(sourcecast_id), do: "#{build_url()}#{sourcecast_id}/" + defp build_url, do: "/v2/sourcecast/" + defp build_url(course_id), do: "/v2/courses/#{course_id}/sourcecast/" + defp build_url(course_id, sourcecast_id), do: "#{build_url(course_id)}#{sourcecast_id}/" + + defp seed_db(course_id) do + course = Course |> where(id: ^course_id) |> Repo.one() - defp seed_db do sourcecasts = for i <- 0..4 do + insert(:sourcecast, %{ + title: "Title#{i}", + description: "Description#{i}", + uid: "unique_id#{i}", + playbackData: + "{\"init\":{\"editorValue\":\"// Type your program in here!\"},\"inputs\":[]}", + audio: %Plug.Upload{ + content_type: "audio/wav", + filename: "upload#{i}.wav", + path: "test/fixtures/upload.wav" + }, + course: course + }) + end + + %{sourcecasts: sourcecasts} + end + + defp seed_db do + sourcecasts = + for i <- 5..9 do insert(:sourcecast, %{ title: "Title#{i}", description: "Description#{i}", diff --git a/test/cadet_web/controllers/stories_controller_test.exs b/test/cadet_web/controllers/stories_controller_test.exs index df81f1b98..9019f0f55 100644 --- a/test/cadet_web/controllers/stories_controller_test.exs +++ b/test/cadet_web/controllers/stories_controller_test.exs @@ -4,6 +4,7 @@ defmodule CadetWeb.StoriesControllerTest do import Ecto.Query + alias Cadet.Courses.Course alias Cadet.Repo alias Cadet.Stories.Story alias CadetWeb.StoriesController @@ -34,44 +35,61 @@ defmodule CadetWeb.StoriesControllerTest do StoriesController.swagger_path_update(nil) end - describe "public access, unauthenticated" do - test "GET /stories/", %{conn: conn} do - conn = get(conn, build_url(), %{}) + describe "unauthenticated" do + test "GET /v2/courses/{course_id}/stories/", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url(course.id), %{}) assert response(conn, 401) =~ "Unauthorised" end - test "POST /stories/new", %{conn: conn} do - conn = post(conn, build_url("new"), %{}) + test "POST /v2/courses/{course_id}/stories/", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) assert response(conn, 401) =~ "Unauthorised" end - test "DELETE /stories/:storyid", %{conn: conn} do - conn = delete(conn, build_url("storyid"), %{}) + test "DELETE /v2/courses/{course_id}/stories/:storyid", %{conn: conn} do + course = insert(:course) + conn = delete(conn, build_url(course.id, "storyid"), %{}) assert response(conn, 401) =~ "Unauthorised" end - test "POST /stories/:storyid", %{conn: conn} do - conn = post(conn, build_url("storyid"), %{}) + test "POST /v2/courses/{course_id}/stories/:storyid", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id, "storyid"), %{}) assert response(conn, 401) =~ "Unauthorised" end end - describe "GET /stories" do + describe "GET /v2/courses/{course_id}/stories" do @tag authenticate: :student - test "student permission, only obtain published open stories", %{ + test "student permission, only obtain published open stories from own course", %{ conn: conn, valid_params: params } do + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() 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}) - insert(:story, %{params | :is_published => true, :open_at => one_week_ago}) + 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}) + + insert(:story, %{ + Map.put(params, :course, course) + | :is_published => true, + :open_at => one_week_ago + }) + + insert(:story, %{ + Map.put(params, :course, build(:course)) + | :is_published => true, + :open_at => one_week_ago + }) {:ok, resp} = conn - |> get(build_url()) + |> get(build_url(course_id)) |> response(200) |> Jason.decode() @@ -79,17 +97,26 @@ defmodule CadetWeb.StoriesControllerTest do end @tag authenticate: :staff - test "obtain all stories", %{conn: conn, valid_params: params} do + test "obtain all stories from own course", %{conn: conn, valid_params: params} do + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() 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}) - insert(:story, %{params | :is_published => true, :open_at => one_week_ago}) + 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}) + + insert(:story, %{ + Map.put(params, :course, course) + | :is_published => true, + :open_at => one_week_ago + }) + + insert(:story, %{course: build(:course)}) {:ok, resp} = conn - |> get(build_url()) + |> get(build_url(course_id)) |> response(200) |> Jason.decode() @@ -98,15 +125,18 @@ defmodule CadetWeb.StoriesControllerTest do @tag authenticate: :staff test "All fields are present and in the right format", %{conn: conn} do - insert(:story) + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() + + insert(:story, %{course: course}) {:ok, [resp]} = conn - |> get(build_url()) + |> get(build_url(course_id)) |> response(200) |> Jason.decode() - required_fields = ~w(openAt closeAt isPublished id title filenames imageUrl) + required_fields = ~w(openAt closeAt isPublished id title filenames imageUrl courseId) Enum.each(required_fields, fn required_field -> value = resp[required_field] @@ -116,78 +146,126 @@ defmodule CadetWeb.StoriesControllerTest do "id" -> assert is_integer(value) "filenames" -> assert is_list(value) "isPublished" -> assert is_boolean(value) + "courseId" -> assert is_integer(value) _ -> assert is_binary(value) end end) end end - describe "DELETE /stories/:storyid" do + describe "DELETE /v2/courses/{course_id}/stories/:storyid" do @tag authenticate: :student test "student permission, forbidden", %{conn: conn} do - conn = delete(conn, build_url(1), %{}) + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() + story = insert(:story, %{course: course}) + + conn = delete(conn, build_url(course_id, story.id), %{}) assert response(conn, 403) =~ "User not allowed to manage stories" end @tag authenticate: :staff - test "deletes story", %{conn: conn} do - to_be_deleted = insert(:story) - resp = delete(conn, build_url(to_be_deleted.id), %{}) + test "staff successfully deletes story from own course", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() + story = insert(:story, %{course: course}) + + resp = delete(conn, build_url(course_id, story.id), %{}) assert Story - |> where(id: ^to_be_deleted.id) + |> where(id: ^story.id) |> Repo.one() == nil assert response(resp, 204) == "" end + + @tag authenticate: :staff + test "staff fails to delete story from another course", %{conn: conn} do + course_id = conn.assigns[:course_id] + story = insert(:story, %{course: build(:course)}) + + resp = delete(conn, build_url(course_id, story.id), %{}) + + assert response(resp, 403) == "User not allowed to manage stories from another course" + end end - describe "POST /stories/new" do + describe "POST /v2/courses/{course_id}/stories/" do @tag authenticate: :student test "student permission, forbidden", %{conn: conn, valid_params: params} do - conn = post(conn, build_url(), params) + course_id = conn.assigns[:course_id] + + conn = post(conn, build_url(course_id), params) assert response(conn, 403) =~ "User not allowed to manage stories" end @tag authenticate: :staff test "creates a new story", %{conn: conn, valid_params: params} do - conn = post(conn, build_url(), stringify_camelise_keys(params)) + course_id = conn.assigns[:course_id] + + conn = post(conn, build_url(course_id), stringify_camelise_keys(params)) inserted_story = Story |> where(title: ^params.title) |> Repo.one() + params = Map.put(params, :course_id, course_id) assert inserted_story |> Map.take(Map.keys(params)) == params - assert response(conn, 200) == "" end end - describe "POST /stories/:storyid" do + describe "POST /v2/courses/{course_id}/stories/:storyid" do @tag authenticate: :student test "student permission, forbidden", %{conn: conn, valid_params: params} do - conn = post(conn, build_url(1), %{"story" => params}) + course_id = conn.assigns[:course_id] + + conn = post(conn, build_url(course_id), %{"story" => params}) assert response(conn, 403) =~ "User not allowed to manage stories" end @tag authenticate: :staff - test "updates a story", %{conn: conn, updated_params: updated_params} do - story = insert(:story) + test "staff successfully updates a story from own course", %{ + conn: conn, + updated_params: updated_params + } do + course_id = conn.assigns[:course_id] + course = Course |> where(id: ^course_id) |> Repo.one() + story = insert(:story, %{course: course}) conn = - post(conn, build_url(story.id), %{"story" => stringify_camelise_keys(updated_params)}) + post(conn, build_url(course_id, story.id), %{ + "story" => stringify_camelise_keys(updated_params) + }) updated_story = Repo.get(Story, story.id) + updated_params = Map.put(updated_params, :course_id, course_id) assert updated_story |> Map.take(Map.keys(updated_params)) == updated_params assert response(conn, 200) == "" end + + @tag authenticate: :staff + test "staff fails to update a story from another course", %{ + conn: conn, + updated_params: updated_params + } do + course_id = conn.assigns[:course_id] + story = insert(:story, %{course: build(:course)}) + + resp = + post(conn, build_url(course_id, story.id), %{ + "story" => stringify_camelise_keys(updated_params) + }) + + assert response(resp, 403) == "User not allowed to manage stories from another course" + end end - defp build_url, do: "/v2/stories" - defp build_url(url), do: "#{build_url()}/#{url}" + defp build_url(course_id), do: "/v2/courses/#{course_id}/stories" + defp build_url(course_id, story_id), do: "#{build_url(course_id)}/#{story_id}" defp stringify_camelise_keys(map) do for {key, value} <- map, into: %{}, do: {key |> Atom.to_string() |> Recase.to_camel(), value} diff --git a/test/cadet_web/controllers/user_controller_test.exs b/test/cadet_web/controllers/user_controller_test.exs index e1bd36ccf..15603e316 100644 --- a/test/cadet_web/controllers/user_controller_test.exs +++ b/test/cadet_web/controllers/user_controller_test.exs @@ -5,25 +5,31 @@ defmodule CadetWeb.UserControllerTest do alias Cadet.Repo alias CadetWeb.UserController - alias Cadet.Assessments.{Assessment, Submission} - alias Cadet.Accounts.User + # alias Cadet.Assessments.{Assessment, Submission} + alias Cadet.Accounts.{User, CourseRegistration} test "swagger" do assert is_map(UserController.swagger_definitions()) assert is_map(UserController.swagger_path_index(nil)) end - describe "GET /user" do + describe "GET v2/user" do @tag authenticate: :student test "success, student non-story fields", %{conn: conn} do user = conn.assigns.current_user - assessment = insert(:assessment, %{is_published: true}) + course = user.latest_viewed_course + config2 = insert(:assessment_config, %{order: 2, type: "test type 2", course: course}) + config3 = insert(:assessment_config, %{order: 3, type: "test type 3", course: course}) + config1 = insert(:assessment_config, %{order: 1, type: "test type 1", course: course}) + cr = Repo.get_by(CourseRegistration, course_id: course.id, user_id: user.id) + another_cr = insert(:course_registration, %{user: user, role: :admin}) + assessment = insert(:assessment, %{is_published: true, course: course}) question = insert(:question, %{assessment: assessment}) submission = insert(:submission, %{ assessment: assessment, - student: user, + student: cr, status: :submitted, xp_bonus: 100 }) @@ -31,230 +37,245 @@ defmodule CadetWeb.UserControllerTest do insert(:answer, %{ question: question, submission: submission, - grade: 50, - adjustment: -10, xp: 20, xp_adjustment: -10 }) - not_submitted_assessment = insert(:assessment, is_published: true) + not_submitted_assessment = insert(:assessment, %{is_published: true, course: course}) not_submitted_question = insert(:question, assessment: not_submitted_assessment) not_submitted_submission = - insert(:submission, assessment: not_submitted_assessment, student: user) + insert(:submission, %{assessment: not_submitted_assessment, student: cr}) insert( :answer, question: not_submitted_question, - submission: not_submitted_submission, - grade: 0, - adjustment: 0 + submission: not_submitted_submission ) resp = conn |> get("/v2/user") |> json_response(200) - |> Map.delete("story") + |> put_in(["courseRegistration", "story"], nil) expected = %{ - "name" => user.name, - "role" => "#{user.role}", - "group" => nil, - "xp" => 110, - "grade" => 40, - "maxGrade" => question.max_grade, - "gameStates" => %{}, - "userId" => user.id + "user" => %{ + "userId" => user.id, + "name" => user.name, + "courses" => [ + %{ + "courseId" => user.latest_viewed_course_id, + "courseShortName" => "CS1101S", + "courseName" => "Programming Methodology", + "viewable" => true, + "role" => "#{cr.role}" + }, + %{ + "courseId" => another_cr.course_id, + "courseShortName" => "CS1101S", + "courseName" => "Programming Methodology", + "viewable" => true, + "role" => "#{another_cr.role}" + } + ] + }, + "courseRegistration" => %{ + "courseRegId" => cr.id, + "courseId" => course.id, + "role" => "#{cr.role}", + "group" => nil, + "xp" => 110, + "maxXp" => question.max_xp, + "gameStates" => %{}, + "story" => nil + }, + "courseConfiguration" => %{ + "enableAchievements" => true, + "enableGame" => true, + "enableSourcecast" => true, + "courseShortName" => "CS1101S", + "moduleHelpText" => "Help Text", + "courseName" => "Programming Methodology", + "sourceChapter" => 1, + "sourceVariant" => "default", + "viewable" => true + }, + "assessmentConfigurations" => [ + %{ + "type" => "test type 1", + "displayInDashboard" => true, + "isManuallyGraded" => true, + "assessmentConfigId" => config1.id, + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48 + }, + %{ + "type" => "test type 2", + "displayInDashboard" => true, + "isManuallyGraded" => true, + "assessmentConfigId" => config2.id, + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48 + }, + %{ + "type" => "test type 3", + "displayInDashboard" => true, + "isManuallyGraded" => true, + "assessmentConfigId" => config3.id, + "earlySubmissionXp" => 200, + "hoursBeforeEarlyXpDecay" => 48 + } + ] } assert expected == resp end - # This also tests for the case where assessment has no submission - @tag authenticate: :student - test "success, student story ordering", %{conn: conn} do - early_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -3)) - late_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -1)) - - for assessment <- early_assessments ++ late_assessments do - resp_story = - conn - |> get("/v2/user") - |> json_response(200) - |> Map.get("story") - - expected_story = %{ - "story" => assessment.story, - "playStory" => true - } - - assert resp_story == expected_story - - {:ok, _} = Repo.delete(assessment) - end - end - - @tag authenticate: :student - test "success, student story skips assessment without story", %{conn: conn} do - assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -1)) - - assessments - |> List.first() - |> Assessment.changeset(%{story: nil}) - |> Repo.update() + @tag sign_in: %{latest_viewed_course: nil} + test "success, no latest_viewed_course", %{conn: conn} do + user = conn.assigns.current_user - resp_story = + resp = conn |> get("/v2/user") |> json_response(200) - |> Map.get("story") - expected_story = %{ - "story" => Enum.fetch!(assessments, 1).story, - "playStory" => true + expected = %{ + "user" => %{ + "userId" => user.id, + "name" => user.name, + "courses" => [] + }, + "courseRegistration" => nil, + "courseConfiguration" => nil, + "assessmentConfigurations" => nil } - assert resp_story == expected_story + assert expected == resp end - @tag authenticate: :student - test "success, student story skips unopen assessments", %{conn: conn} do - build_assessments_starting_at(Timex.shift(Timex.now(), days: 1)) - build_assessments_starting_at(Timex.shift(Timex.now(), months: -1)) - - valid_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -1)) - - for assessment <- valid_assessments do - assessment - |> Assessment.changeset(%{is_published: false}) - |> Repo.update!() - end - - resp_story = - conn - |> get("/v2/user") - |> json_response(200) - |> Map.get("story") - - expected_story = %{ - "story" => nil, - "playStory" => false - } - - assert resp_story == expected_story + test "unauthorized", %{conn: conn} do + conn = get(conn, "/v2/user", nil) + assert response(conn, 401) =~ "Unauthorised" end + end + describe "GET /v2/user/latest_viewed_course" do @tag authenticate: :student - test "success, student story skips attempting/attempted/submitted", %{conn: conn} do + test "success, student non-story fields", %{conn: conn} do user = conn.assigns.current_user + course = user.latest_viewed_course + cr = Repo.get_by(CourseRegistration, course_id: course.id, user_id: user.id) + _another_cr = insert(:course_registration, %{user: user}) + assessment = insert(:assessment, %{is_published: true, course: course}) + question = insert(:question, %{assessment: assessment}) - early_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -3)) - late_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -1)) - - # Submit for i-th assessment, expect (i+1)th story to be returned - for status <- [:attempting, :attempted, :submitted] do - for [tester, checker] <- - Enum.chunk_every(early_assessments ++ late_assessments, 2, 1, :discard) do - insert(:submission, %{student: user, assessment: tester, status: status}) - - resp_story = - conn - |> get("/v2/user") - |> json_response(200) - |> Map.get("story") - - expected_story = %{ - "story" => checker.story, - "playStory" => true - } - - assert resp_story == expected_story - end + submission = + insert(:submission, %{ + assessment: assessment, + student: cr, + status: :submitted, + xp_bonus: 100 + }) - Repo.delete_all(Submission) - end - end + insert(:answer, %{ + question: question, + submission: submission, + xp: 20, + xp_adjustment: -10 + }) - @tag authenticate: :student - test "success, return most recent assessment when all are attempted", %{conn: conn} do - user = conn.assigns.current_user + not_submitted_assessment = insert(:assessment, %{is_published: true, course: course}) + not_submitted_question = insert(:question, assessment: not_submitted_assessment) - early_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -3)) - late_assessments = build_assessments_starting_at(Timex.shift(Timex.now(), days: -1)) + not_submitted_submission = + insert(:submission, %{assessment: not_submitted_assessment, student: cr}) - for assessment <- early_assessments ++ late_assessments do - insert(:submission, %{student: user, assessment: assessment, status: :attempted}) - end + insert( + :answer, + question: not_submitted_question, + submission: not_submitted_submission + ) - resp_story = + resp = conn - |> get("/v2/user") + |> get("/v2/user/latest_viewed_course") |> json_response(200) - |> Map.get("story") + |> put_in(["courseRegistration", "story"], nil) - expected_story = %{ - "story" => late_assessments |> List.first() |> Map.get(:story), - "playStory" => false + expected = %{ + "courseRegistration" => %{ + "courseRegId" => cr.id, + "courseId" => course.id, + "role" => "#{cr.role}", + "group" => nil, + "xp" => 110, + "maxXp" => question.max_xp, + "gameStates" => %{}, + "story" => nil + }, + "courseConfiguration" => %{ + "enableAchievements" => true, + "enableGame" => true, + "enableSourcecast" => true, + "courseShortName" => "CS1101S", + "moduleHelpText" => "Help Text", + "courseName" => "Programming Methodology", + "sourceChapter" => 1, + "sourceVariant" => "default", + "viewable" => true + }, + "assessmentConfigurations" => [] } - assert resp_story == expected_story + assert expected == resp end - @tag authenticate: :staff - test "success, staff", %{conn: conn} do - user = conn.assigns.current_user - + @tag sign_in: %{latest_viewed_course: nil} + test "success, no latest_viewed_course", %{conn: conn} do resp = conn - |> get("/v2/user") + |> get("/v2/user/latest_viewed_course") |> json_response(200) - |> Map.delete("story") expected = %{ - "name" => user.name, - "role" => "#{user.role}", - "group" => nil, - "grade" => 0, - "maxGrade" => 0, - "xp" => 0, - "gameStates" => %{}, - "userId" => user.id + "courseRegistration" => nil, + "courseConfiguration" => nil, + "assessmentConfigurations" => nil } assert expected == resp end test "unauthorized", %{conn: conn} do - conn = get(conn, "/v2/user", nil) + conn = get(conn, "/v2/user/latest_viewed_course", nil) assert response(conn, 401) =~ "Unauthorised" end + end - defp build_assessments_starting_at(time) do - type_order_map = - Assessment.assessment_types() - |> Enum.with_index() - |> Enum.reduce(%{}, fn {type, idx}, acc -> Map.put(acc, type, idx) end) - - Assessment.assessment_types() - |> Enum.map( - &build(:assessment, %{ - type: &1, - is_published: true, - open_at: time, - close_at: Timex.shift(time, days: 10) - }) - ) - |> Enum.shuffle() - |> Enum.map(&insert(&1)) - |> Enum.sort(&(type_order_map[&1.type] < type_order_map[&2.type])) + describe "PUT /v2/user/latest_viewed_course/{course_id}" do + @tag authenticate: :student + test "success, updating game state", %{conn: conn} do + user = conn.assigns.current_user + new_course = insert(:course) + insert(:course_registration, %{user: user, course: new_course}) + + conn + |> put("/v2/user/latest_viewed_course", %{"courseId" => new_course.id}) + |> response(200) + + updated_user = Repo.get(User, user.id) + + assert new_course.id == updated_user.latest_viewed_course_id end end - describe "PUT /user/game_states" do + describe "PUT /v2/courses/{course_id}/user/game_states" do @tag authenticate: :student test "success, updating game state", %{conn: conn} do user = conn.assigns.current_user + course_id = conn.assigns.course_id new_game_states = %{ "gameSaveStates" => %{"1" => %{}, "2" => %{}}, @@ -262,22 +283,14 @@ defmodule CadetWeb.UserControllerTest do } conn - |> put("/v2/user/game_states", %{"gameStates" => new_game_states}) + |> put(build_url(course_id) <> "/game_states", %{"gameStates" => new_game_states}) |> response(200) - updated_user = Repo.get(User, user.id) - - assert new_game_states == updated_user.game_states - end - - @tag authenticate: :student - test "success, retrieving student game state", %{conn: conn} do - resp = - conn - |> get("/v2/user") - |> json_response(200) + updated_cr = Repo.get_by(CourseRegistration, course_id: course_id, user_id: user.id) - assert %{} == resp["gameStates"] + assert new_game_states == updated_cr.game_states end end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/user" end diff --git a/test/factories/accounts/course_registration_factory.ex b/test/factories/accounts/course_registration_factory.ex new file mode 100644 index 000000000..e1d75d126 --- /dev/null +++ b/test/factories/accounts/course_registration_factory.ex @@ -0,0 +1,22 @@ +defmodule Cadet.Accounts.CourseRegistrationFactory do + @moduledoc """ + Factory(ies) for Cadet.Accounts.CourseRegistration entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Accounts.{Role, CourseRegistration} + # alias Cadet.Courses.{Course, Group} + + def course_registration_factory do + %CourseRegistration{ + user: build(:user), + course: build(:course), + # group: build(:group), + role: Enum.random(Role.__enum_map__()), + game_states: %{} + } + end + end + end +end diff --git a/test/factories/accounts/user_factory.ex b/test/factories/accounts/user_factory.ex index ebc2950ff..4dc203262 100644 --- a/test/factories/accounts/user_factory.ex +++ b/test/factories/accounts/user_factory.ex @@ -5,31 +5,30 @@ defmodule Cadet.Accounts.UserFactory do defmacro __using__(_opts) do quote do - alias Cadet.Accounts.{Role, User} + # alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.User def user_factory do %User{ name: Faker.Person.En.name(), - role: Enum.random(Role.__enum_map__()), username: sequence( :nusnet_id, - &"E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" + &"test/E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" ), - game_states: %{} + latest_viewed_course: build(:course) } end def student_factory do %User{ name: Faker.Person.En.name(), - role: :student, username: sequence( :nusnet_id, - &"E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" + &"test/E#{&1 |> Integer.to_string() |> String.pad_leading(7, "0")}" ), - game_states: %{} + latest_viewed_course: build(:course) } end end diff --git a/test/factories/achievements/achievement_factory.ex b/test/factories/achievements/achievement_factory.ex index 6d2ab4761..f859cd956 100644 --- a/test/factories/achievements/achievement_factory.ex +++ b/test/factories/achievements/achievement_factory.ex @@ -11,6 +11,7 @@ defmodule Cadet.Incentives.AchievementFactory do def achievement_factory do %Achievement{ uuid: UUID.generate(), + course: insert(:course), title: Faker.Food.dish(), ability: Enum.random(Achievement.valid_abilities()), is_task: false, diff --git a/test/factories/achievements/goal_factory.ex b/test/factories/achievements/goal_factory.ex index 4305117d5..c5238da36 100644 --- a/test/factories/achievements/goal_factory.ex +++ b/test/factories/achievements/goal_factory.ex @@ -14,6 +14,7 @@ defmodule Cadet.Incentives.GoalFactory do text: "Score earned from Curve Introduction mission", target_count: Faker.random_between(1, 1000), type: "test_type", + course: insert(:course), meta: %{} } end diff --git a/test/factories/assessments/answer_factory.ex b/test/factories/assessments/answer_factory.ex index c7faf2aee..de614ca64 100644 --- a/test/factories/assessments/answer_factory.ex +++ b/test/factories/assessments/answer_factory.ex @@ -17,7 +17,7 @@ defmodule Cadet.Assessments.AnswerFactory do def programming_answer_factory do %{ - code: sequence(:code, &"alert(#{&1})") + code: sequence(:code, &"return #{&1};") } end diff --git a/test/factories/assessments/assessment_factory.ex b/test/factories/assessments/assessment_factory.ex index b756ce94b..71682e52e 100644 --- a/test/factories/assessments/assessment_factory.ex +++ b/test/factories/assessments/assessment_factory.ex @@ -8,7 +8,9 @@ defmodule Cadet.Assessments.AssessmentFactory do alias Cadet.Assessments.Assessment def assessment_factory do - type = Enum.random(Assessment.assessment_types() -- ["practical"]) + course = build(:course) + config = build(:assessment_config, %{course: course}) + type_title = config.type # These are actual story identifiers so front-end can use seeds to test more effectively valid_stories = [ @@ -28,11 +30,12 @@ defmodule Cadet.Assessments.AssessmentFactory do number: sequence( :number, - &"#{type |> String.first() |> String.upcase()}#{&1}" + &"#{type_title |> String.first() |> String.upcase()}#{&1}" ), story: Enum.random(valid_stories), reading: Faker.Lorem.sentence(), - type: type, + config: config, + course: course, open_at: Timex.now(), close_at: Timex.shift(Timex.now(), days: Enum.random(1..30)), is_published: false diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 4685406f5..f65ad05ec 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -16,7 +16,6 @@ defmodule Cadet.Assessments.QuestionFactory do %Question{ type: :programming, - max_grade: 10, max_xp: 100, assessment: build(:assessment, %{is_published: true}), library: library, @@ -39,7 +38,14 @@ defmodule Cadet.Assessments.QuestionFactory do program: Faker.Lorem.Shakespeare.king_richard_iii() } ], - private: [ + opaque: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ], + secret: [ %{ score: :rand.uniform(5), answer: Faker.StarWars.character(), @@ -54,7 +60,6 @@ defmodule Cadet.Assessments.QuestionFactory do %Question{ type: :mcq, - max_grade: 10, max_xp: 100, assessment: build(:assessment, %{is_published: true}), library: build(:library), @@ -79,7 +84,6 @@ defmodule Cadet.Assessments.QuestionFactory do %Question{ type: :voting, - max_grade: 10, max_xp: 100, assessment: build(:assessment, %{is_published: true}), library: build(:library), diff --git a/test/factories/assessments/submission_factory.ex b/test/factories/assessments/submission_factory.ex index 9e9ec1581..971345a93 100644 --- a/test/factories/assessments/submission_factory.ex +++ b/test/factories/assessments/submission_factory.ex @@ -9,7 +9,7 @@ defmodule Cadet.Assessments.SubmissionFactory do def submission_factory do %Submission{ - student: build(:user, %{role: :student}), + student: build(:course_registration, %{role: :student}), assessment: build(:assessment) } end diff --git a/test/factories/assessments/submission_vote_factory.ex b/test/factories/assessments/submission_vote_factory.ex index e65ac9192..ec1e1b927 100644 --- a/test/factories/assessments/submission_vote_factory.ex +++ b/test/factories/assessments/submission_vote_factory.ex @@ -9,7 +9,7 @@ defmodule Cadet.Assessments.SubmissionVotesFactory do def submission_vote_factory do %SubmissionVotes{ - user: build(:user, %{role: :student}), + voter: build(:course_registration, %{role: :student}), question: build(:voting_question), submission: build(:submission) } diff --git a/test/factories/courses/assessment_config_factory.ex b/test/factories/courses/assessment_config_factory.ex new file mode 100644 index 000000000..d422d0ad0 --- /dev/null +++ b/test/factories/courses/assessment_config_factory.ex @@ -0,0 +1,21 @@ +defmodule Cadet.Courses.AssessmentConfigFactory do + @moduledoc """ + Factory for the AssessmentConfig entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Courses.AssessmentConfig + + def assessment_config_factory do + %AssessmentConfig{ + order: 1, + type: Faker.Pokemon.En.name(), + early_submission_xp: 200, + hours_before_early_xp_decay: 48, + course: build(:course) + } + end + end + end +end diff --git a/test/factories/courses/course_factory.ex b/test/factories/courses/course_factory.ex new file mode 100644 index 000000000..db9ccb0b8 --- /dev/null +++ b/test/factories/courses/course_factory.ex @@ -0,0 +1,25 @@ +defmodule Cadet.Courses.CourseFactory do + @moduledoc """ + Factory for the Course entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Courses.Course + + def course_factory do + %Course{ + 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" + } + end + end + end +end diff --git a/test/factories/course/group_factory.ex b/test/factories/courses/group_factory.ex similarity index 78% rename from test/factories/course/group_factory.ex rename to test/factories/courses/group_factory.ex index b4acf3e6b..92e6a5c99 100644 --- a/test/factories/course/group_factory.ex +++ b/test/factories/courses/group_factory.ex @@ -10,7 +10,8 @@ defmodule Cadet.Courses.GroupFactory do def group_factory do %Group{ name: sequence("group"), - leader: build(:user, role: :staff) + leader: build(:course_registration), + course: build(:course) } end end diff --git a/test/factories/course/sourcecast_factory.ex b/test/factories/courses/sourcecast_factory.ex similarity index 89% rename from test/factories/course/sourcecast_factory.ex rename to test/factories/courses/sourcecast_factory.ex index 048371dbe..ada1ff7fe 100644 --- a/test/factories/course/sourcecast_factory.ex +++ b/test/factories/courses/sourcecast_factory.ex @@ -13,7 +13,7 @@ defmodule Cadet.Courses.SourcecastFactory do description: Faker.StarWars.planet(), audio: build(:upload), playbackData: Faker.StarWars.planet(), - uploader: build(:user, %{role: :staff}) + uploader: build(:user) } end end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 0a43563a8..6d8fe077c 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -4,7 +4,7 @@ defmodule Cadet.Factory do """ use ExMachina.Ecto, repo: Cadet.Repo - use Cadet.Accounts.{NotificationFactory, UserFactory} + use Cadet.Accounts.{NotificationFactory, UserFactory, CourseRegistrationFactory} use Cadet.Assessments.{ AnswerFactory, @@ -22,9 +22,12 @@ defmodule Cadet.Factory do GoalFactory } - use Cadet.Settings.{SublanguageFactory} - - use Cadet.Courses.{GroupFactory, SourcecastFactory} + use Cadet.Courses.{ + AssessmentConfigFactory, + CourseFactory, + GroupFactory, + SourcecastFactory + } use Cadet.Devices.DeviceFactory diff --git a/test/factories/settings/sublanguage_factory.ex b/test/factories/settings/sublanguage_factory.ex deleted file mode 100644 index 21987b96c..000000000 --- a/test/factories/settings/sublanguage_factory.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Cadet.Settings.SublanguageFactory do - @moduledoc """ - Factory for the Sublanguage entity - """ - - defmacro __using__(_opts) do - quote do - alias Cadet.Settings.Sublanguage - - def sublanguage_factory do - %Sublanguage{ - chapter: 1, - variant: "default" - } - end - end - end -end diff --git a/test/factories/stories/story_factory.ex b/test/factories/stories/story_factory.ex index 48d985721..fc4c9fa6e 100644 --- a/test/factories/stories/story_factory.ex +++ b/test/factories/stories/story_factory.ex @@ -14,7 +14,8 @@ defmodule Cadet.Stories.StoryFactory do is_published: false, filenames: ["mission-1.txt"], title: "Mission1", - image_url: "http://example.com" + image_url: "http://example.com", + course: build(:course) } end end diff --git a/test/fixtures/custom_cassettes/autograder/errors#1.json b/test/fixtures/custom_cassettes/autograder/errors#1.json index 639ac9d49..6539101e2 100644 --- a/test/fixtures/custom_cassettes/autograder/errors#1.json +++ b/test/fixtures/custom_cassettes/autograder/errors#1.json @@ -21,7 +21,7 @@ "response": { "binary": false, "body": - "{\r\n \"totalScore\": 0,\r\n \"results\": [\r\n {\r\n \"resultType\": \"error\",\r\n \"errors\": [\r\n {\r\n \"errorType\": \"syntax\",\r\n \"line\": 1,\r\n \"location\": \"student\",\r\n \"errorLine\": \"consst f = i => i === 0 ? 0 : i < 3 ? 1 : f(i-1) + f(i-2);\",\r\n \"errorExplanation\": \"SyntaxError: Unexpected token (2:7)\"\r\n }\r\n ]\r\n },\r\n {\r\n \"resultType\": \"error\",\r\n \"errors\": [\r\n {\r\n \"errorType\": \"syntax\",\r\n \"line\": 1,\r\n \"location\": \"student\",\r\n \"errorLine\": \"consst f = i => i === 0 ? 0 : i < 3 ? 1 : f(i-1) + f(i-2);\",\r\n \"errorExplanation\": \"SyntaxError: Unexpected token (2:7)\"\r\n }\r\n ]\r\n }\r\n ]\r\n}", + "{\r\n \"totalScore\": 0,\r\n \"maxScore\": 2,\r\n \"results\": [\r\n {\r\n \"resultType\": \"error\",\r\n \"errors\": [\r\n {\r\n \"errorType\": \"syntax\",\r\n \"line\": 1,\r\n \"location\": \"student\",\r\n \"errorLine\": \"consst f = i => i === 0 ? 0 : i < 3 ? 1 : f(i-1) + f(i-2);\",\r\n \"errorExplanation\": \"SyntaxError: Unexpected token (2:7)\"\r\n }\r\n ]\r\n },\r\n {\r\n \"resultType\": \"error\",\r\n \"errors\": [\r\n {\r\n \"errorType\": \"syntax\",\r\n \"line\": 1,\r\n \"location\": \"student\",\r\n \"errorLine\": \"consst f = i => i === 0 ? 0 : i < 3 ? 1 : f(i-1) + f(i-2);\",\r\n \"errorExplanation\": \"SyntaxError: Unexpected token (2:7)\"\r\n }\r\n ]\r\n }\r\n ]\r\n}", "headers": { "Date": "Thu, 09 Aug 2018 09:53:29 GMT", "Content-Type": "application/json", diff --git a/test/fixtures/custom_cassettes/autograder/success#1.json b/test/fixtures/custom_cassettes/autograder/success#1.json index 68f3f6845..e0257695d 100644 --- a/test/fixtures/custom_cassettes/autograder/success#1.json +++ b/test/fixtures/custom_cassettes/autograder/success#1.json @@ -22,7 +22,7 @@ "response": { "binary": false, "body": - "{\r\n \"totalScore\": 2,\r\n \"results\": [\r\n {\r\n \"resultType\": \"pass\",\r\n \"score\": 1\r\n },\r\n {\r\n \"resultType\": \"pass\",\r\n \"score\": 1\r\n }\r\n ]\r\n}", + "{\r\n \"totalScore\": 2,\r\n \"maxScore\": 2,\r\n \"results\": [\r\n {\r\n \"resultType\": \"pass\",\r\n \"score\": 1\r\n },\r\n {\r\n \"resultType\": \"pass\",\r\n \"score\": 1\r\n }\r\n ]\r\n}", "headers": { "Date": "Thu, 09 Aug 2018 09:50:53 GMT", "Content-Type": "application/json", diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index eb6c06858..ef8da28c5 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -17,6 +17,8 @@ defmodule CadetWeb.ConnCase do import Plug.Conn + alias Cadet.Factory + using do quote do # Import conveniences for testing with connections @@ -50,23 +52,45 @@ defmodule CadetWeb.ConnCase do conn = Phoenix.ConnTest.build_conn() if tags[:authenticate] do - user = + course = Factory.insert(:course) + user = Factory.insert(:user, %{latest_viewed_course: course}) + + course_registration = cond do is_atom(tags[:authenticate]) -> - Cadet.Factory.insert(:user, %{role: tags[:authenticate]}) + Factory.insert(:course_registration, %{ + user: user, + course: course, + role: tags[:authenticate] + }) + # :TODO: This is_map case has not been handled. To recheck in the future. is_map(tags[:authenticate]) -> - tags[:authenticate] + Factory.insert(:course_registration, tags[:authenticate]) true -> nil end - conn = sign_in(conn, user) + # We assign course_id to the conn during testing, so that we can generate the correct + # course URL for the user created during the test. The course_id is assigned here instead + # of the course_registration since we want the router plug to assign the course_registration + # when actually accessing the endpoint during the test. + conn = + conn + |> sign_in(course_registration.user) + |> assign(:course_id, course_registration.course_id) + |> assign(:test_cr, course_registration) {:ok, conn: conn} else - {:ok, conn: conn} + if tags[:sign_in] do + user = Factory.insert(:user, tags[:sign_in]) + conn = sign_in(conn, user) + {:ok, conn: conn} + else + {:ok, conn: conn} + end end end diff --git a/test/support/seeds.ex b/test/support/seeds.ex index 22c8ef0ad..eca9847f7 100644 --- a/test/support/seeds.ex +++ b/test/support/seeds.ex @@ -10,7 +10,6 @@ defmodule Cadet.Test.Seeds do %{ accounts: %{ avenger: avenger, - mentor: mentor, group: group, students: students, admin: admin @@ -38,48 +37,113 @@ defmodule Cadet.Test.Seeds do def assessments do if Cadet.Env.env() == :test 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}) + + assessment_configs = [ + insert(:assessment_config, %{course: course1, order: 1, type: "mission"}), + insert(:assessment_config, %{course: course1, order: 2}), + insert(:assessment_config, %{ + course: course1, + order: 3, + show_grading_summary: false, + is_manually_graded: false, + type: "path" + }), + insert(:assessment_config, %{course: course1, order: 4}), + insert(:assessment_config, %{ + course: course1, + order: 5, + type: "practical" + }) + ] + + # 1..5 |> Enum.map(&insert(:assessment_config, %{course: course1, order: &1})) assessments = - Enum.reduce( - Cadet.Assessments.Assessment.assessment_types(), + assessment_configs + |> Enum.reduce( %{}, - fn type, acc -> Map.put(acc, type, insert_assessments(type, students)) end + fn config, acc -> + Map.put(acc, config.type, insert_assessments(config, students, course1)) + end ) %{ - accounts: %{ - avenger: avenger, - mentor: mentor, + courses: %{ + course1: course1, + course2: course2 + }, + course_regs: %{ + avenger1_cr: avenger1_cr, group: group, students: students, - admin: admin + admin1_cr: admin1_cr }, - users: %{ - staff: avenger, - student: List.first(students), - admin: admin + role_crs: %{ + staff: avenger1_cr, + student: student1a_cr, + admin: admin1_cr }, + assessment_configs: assessment_configs, assessments: assessments } end end - defp insert_assessments(assessment_type, students) do - assessment = insert(:assessment, %{type: assessment_type, is_published: true}) + defp insert_assessments(assessment_config, students, course) do + assessment = + insert(:assessment, %{course: course, config: assessment_config, is_published: true}) programming_questions = Enum.map(1..3, fn id -> insert(:programming_question, %{ display_order: id, assessment: assessment, - max_grade: 200, - max_xp: 1000 + max_xp: 1000, + show_solution: assessment.config.type == "path" }) end) @@ -88,8 +152,8 @@ defmodule Cadet.Test.Seeds do insert(:mcq_question, %{ display_order: id, assessment: assessment, - max_grade: 40, - max_xp: 500 + max_xp: 500, + show_solution: assessment.config.type == "path" }) end) @@ -98,8 +162,8 @@ defmodule Cadet.Test.Seeds do insert(:voting_question, %{ display_order: id, assessment: assessment, - max_grade: 10, - max_xp: 100 + max_xp: 100, + show_solution: assessment.config.type == "path" }) end) @@ -113,7 +177,6 @@ defmodule Cadet.Test.Seeds do Enum.map(submissions, fn submission -> Enum.map(programming_questions, fn question -> insert(:answer, %{ - grade: 200, xp: 1000, question: question, submission: submission, @@ -126,7 +189,6 @@ defmodule Cadet.Test.Seeds do Enum.map(submissions, fn submission -> Enum.map(mcq_questions, fn question -> insert(:answer, %{ - grade: 40, xp: 500, question: question, submission: submission, @@ -139,7 +201,6 @@ defmodule Cadet.Test.Seeds do Enum.map(submissions, fn submission -> Enum.map(voting_questions, fn question -> insert(:answer, %{ - grade: 10, xp: 100, question: question, submission: submission, diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index 3e07b4e9b..b5df37648 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -40,7 +40,6 @@ defmodule Cadet.Test.XMLGenerator do task( map_convert_keys(assessment, %{ access: :access, - type: :kind, number: :number, open_at: :startdate, close_at: :duedate, @@ -85,8 +84,8 @@ defmodule Cadet.Test.XMLGenerator do type = override_type || question.type map_permit_keys( - %{type: type, maxgrade: question.max_grade}, - permit_keys || ~w(type maxgrade)a + %{type: type, maxxp: question.max_xp}, + permit_keys || ~w(type maxxp)a ) end @@ -131,8 +130,13 @@ defmodule Cadet.Test.XMLGenerator do end ] ++ [ - for testcase <- question.question[:private] do - private(%{score: testcase.score, answer: testcase.answer}, testcase.program) + for testcase <- question.question[:opaque] do + opaque(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] ++ + [ + for testcase <- question.question[:secret] do + secret(%{score: testcase.score, answer: testcase.answer}, testcase.program) end ] ) @@ -224,13 +228,7 @@ defmodule Cadet.Test.XMLGenerator do end defp task(raw_attrs, children) do - attrs = - Map.update!(raw_attrs, :kind, fn - "sidequest" -> "quest" - type -> type - end) - - {"TASK", map_permit_keys(attrs, ~w(kind number startdate duedate title story access)a), + {"TASK", map_permit_keys(raw_attrs, ~w(number startdate duedate title story access)a), children} end @@ -251,7 +249,7 @@ defmodule Cadet.Test.XMLGenerator do end defp problem(raw_attrs, children) do - {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxgrade type)a), children} + {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxxp type)a), children} end defp text(content) do @@ -290,8 +288,12 @@ defmodule Cadet.Test.XMLGenerator do {"PUBLIC", map_permit_keys(raw_attrs, ~w(score answer)a), content} end - defp private(raw_attrs, content) do - {"PRIVATE", map_permit_keys(raw_attrs, ~w(score answer)a), content} + defp opaque(raw_attrs, content) do + {"OPAQUE", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp secret(raw_attrs, content) do + {"SECRET", map_permit_keys(raw_attrs, ~w(score answer)a), content} end defp map_permit_keys(map, keys) when is_map(map) and is_list(keys) do