diff --git a/.credo.exs b/.credo.exs index 5cfff910a..9828a7ad2 100644 --- a/.credo.exs +++ b/.credo.exs @@ -94,7 +94,7 @@ {Credo.Check.Readability.SpaceAfterCommas}, {Credo.Check.Refactor.DoubleBooleanNegation}, {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.CyclomaticComplexity, max_complexity: 10}, {Credo.Check.Refactor.FunctionArity}, {Credo.Check.Refactor.LongQuoteBlocks}, {Credo.Check.Refactor.MatchInCondition}, diff --git a/.iex.exs b/.iex.exs index b4703b99b..6628fe0fe 100644 --- a/.iex.exs +++ b/.iex.exs @@ -1,5 +1,5 @@ import Ecto.Query alias Cadet.Repo -alias Cadet.Accounts.User +alias Cadet.Accounts.{User, Team, TeamMember} alias Cadet.Assessments.{Answer, Assessment, Question, Submission} alias Cadet.Courses.Group diff --git a/lib/cadet/accounts/notifications.ex b/lib/cadet/accounts/notifications.ex index 8ee558eb3..b6bd14d64 100644 --- a/lib/cadet/accounts/notifications.ex +++ b/lib/cadet/accounts/notifications.ex @@ -8,7 +8,7 @@ defmodule Cadet.Accounts.Notifications do import Ecto.Query alias Cadet.Repo - alias Cadet.Accounts.{Notification, CourseRegistration, CourseRegistration} + alias Cadet.Accounts.{Notification, CourseRegistration, CourseRegistration, Team, TeamMember} alias Cadet.Assessments.Submission alias Ecto.Multi @@ -169,17 +169,47 @@ defmodule Cadet.Accounts.Notifications do @spec write_notification_when_graded(integer(), any()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def write_notification_when_graded(submission_id, type) when type in [:graded, :autograded] do - submission = - Submission - |> Repo.get_by(id: submission_id) + case Repo.get(Submission, submission_id) do + nil -> + {:error, %Ecto.Changeset{}} - write(%{ - type: type, - read: false, - role: :student, - course_reg_id: submission.student_id, - assessment_id: submission.assessment_id - }) + submission -> + case submission.student_id do + nil -> + team = Repo.get(Team, submission.team_id) + + query = + from(t in Team, + join: tm in TeamMember, + on: t.id == tm.team_id, + join: cr in CourseRegistration, + on: tm.student_id == cr.id, + where: t.id == ^team.id, + select: cr.id + ) + + team_members = Repo.all(query) + + Enum.each(team_members, fn tm_id -> + write(%{ + type: type, + read: false, + role: :student, + course_reg_id: tm_id, + assessment_id: submission.assessment_id + }) + end) + + student_id -> + write(%{ + type: type, + read: false, + role: :student, + course_reg_id: student_id, + assessment_id: submission.assessment_id + }) + end + end end @doc """ @@ -223,7 +253,29 @@ defmodule Cadet.Accounts.Notifications do @spec write_notification_when_student_submits(Submission.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def write_notification_when_student_submits(submission = %Submission{}) do - avenger_id = get_avenger_id_of(submission.student_id) + id = + case submission.student_id do + nil -> + team_id = submission.team_id + + team = + Repo.one( + from(t in Team, + where: t.id == ^team_id, + preload: [:team_members] + ) + ) + + # Does not matter if team members have different Avengers + # Just require one of them to be notified of the submission + s_id = team.team_members |> hd() |> Map.get(:student_id) + s_id + + _ -> + submission.student_id + end + + avenger_id = get_avenger_id_of(id) if is_nil(avenger_id) do {:ok, nil} diff --git a/lib/cadet/accounts/team.ex b/lib/cadet/accounts/team.ex new file mode 100644 index 000000000..53384958d --- /dev/null +++ b/lib/cadet/accounts/team.ex @@ -0,0 +1,45 @@ +defmodule Cadet.Accounts.Team do + @moduledoc """ + This module defines the Ecto schema and changeset for teams in the Cadet.Accounts context. + Teams represent groups of students collaborating on an assessment within a course. + """ + + use Cadet, :model + + alias Cadet.Accounts.TeamMember + alias Cadet.Assessments.{Assessment, Submission} + + @doc """ + Ecto schema definition for teams. + This schema represents a group of students collaborating on a specific assessment within a course. + Fields: + - `assessment`: A reference to the assessment associated with the team. + - `submission`: A reference to the team's submission for the assessment. + - `team_members`: A list of team members associated with the team. + """ + schema "teams" do + belongs_to(:assessment, Assessment) + has_one(:submission, Submission, on_delete: :delete_all) + has_many(:team_members, TeamMember, on_delete: :delete_all) + + timestamps() + end + + @required_fields ~w(assessment_id)a + + @doc """ + Builds an Ecto changeset for a team. + This function is used to create or update a team record based on the provided attributes. + Args: + - `team`: The existing team struct. + - `attrs`: The attributes to be cast and validated for the changeset. + Returns: + A changeset struct with cast and validated attributes. + """ + def changeset(team, attrs) do + team + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:assessment_id) + end +end diff --git a/lib/cadet/accounts/team_member.ex b/lib/cadet/accounts/team_member.ex new file mode 100644 index 000000000..45b901a6d --- /dev/null +++ b/lib/cadet/accounts/team_member.ex @@ -0,0 +1,45 @@ +defmodule Cadet.Accounts.TeamMember do + @moduledoc """ + This module defines the Ecto schema and changeset for team members in the Cadet.Accounts context. + Team members represent the association between a student and a team within a course. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Cadet.Accounts.{CourseRegistration, Team} + + @doc """ + Ecto schema definition for team members. + This schema represents the relationship between a student and a team within a course. + Fields: + - `student`: A reference to the student's course registration. + - `team`: A reference to the team associated with the student. + """ + schema "team_members" do + belongs_to(:student, CourseRegistration) + belongs_to(:team, Team) + + timestamps() + end + + @required_fields ~w(student_id team_id)a + + @doc """ + Builds an Ecto changeset for a team member. + This function is used to create or update a team member record based on the provided attributes. + Args: + - `team_member`: The existing team member struct. + - `attrs`: The attributes to be cast and validated for the changeset. + Returns: + A changeset struct with cast and validated attributes. + """ + def changeset(team_member, attrs) do + team_member + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:team_id) + |> foreign_key_constraint(:student_id) + end +end diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex new file mode 100644 index 000000000..347a36e06 --- /dev/null +++ b/lib/cadet/accounts/teams.ex @@ -0,0 +1,327 @@ +defmodule Cadet.Accounts.Teams do + @moduledoc """ + This module provides functions to manage teams in the Cadet system. + """ + + use Cadet, [:context, :display] + use Ecto.Schema + + import Ecto.{Changeset, Query} + + alias Cadet.Repo + alias Cadet.Accounts.{Team, TeamMember, CourseRegistration, Notification} + alias Cadet.Assessments.{Answer, Assessment, Submission} + + @doc """ + Creates a new team and assigns an assessment and team members to it. + + ## Parameters + + * `attrs` - A map containing the attributes for assessment id and creating the team and its members. + + ## Returns + + Returns a tuple `{:ok, team}` on success; otherwise, an error tuple. + + """ + def create_team(attrs) do + assessment_id = attrs["assessment_id"] + teams = attrs["student_ids"] + assessment = Cadet.Repo.get(Cadet.Assessments.Assessment, assessment_id) + + cond do + !all_team_within_max_size?(teams, assessment.max_team_size) -> + {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + + !all_students_distinct?(teams) -> + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + + !all_student_enrolled_in_course?(teams, assessment.course_id) -> + {:error, {:conflict, "One or more students not enrolled in this course!"}} + + student_already_assigned?(teams, assessment_id) -> + {:error, {:conflict, "One or more students already in a team for this assessment!"}} + + true -> + Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> + student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) + + {:ok, team} = + %Team{} + |> Team.changeset(attrs) + |> Repo.insert() + + team_id = team.id + + Enum.each(team_attrs, fn student -> + student_id = Map.get(student, "userId") + attributes = %{student_id: student_id, team_id: team_id} + + %TeamMember{} + |> cast(attributes, [:student_id, :team_id]) + |> Repo.insert() + end) + + {:cont, {:ok, team}} + end) + end + end + + @doc """ + Validates whether there are student(s) who are already assigned to another group. + + ## Parameters + + * `team_attrs` - A list of all the teams and their members. + * `assessment_id` - Id of the target assessment. + + ## Returns + + Returns `true` on success; otherwise, `false`. + + """ + defp student_already_assigned?(team_attrs, assessment_id) do + Enum.all?(team_attrs, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + + unique_ids_count = ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = unique_ids_count == Enum.count(ids) + + student_already_in_team?(-1, ids, assessment_id) + end) + end + + @doc """ + Checks there is no duplicated student during team creation. + + ## Parameters + + * `team_attrs` - IDs of the team members being created + + ## Returns + + Returns `true` if all students in the list are distinct; otherwise, returns `false`. + + """ + defp all_students_distinct?(team_attrs) do + all_ids = + team_attrs + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + all_ids_count = all_ids |> Enum.uniq() |> Enum.count() + all_ids_distinct = all_ids_count == Enum.count(all_ids) + + all_ids_distinct + end + + @doc """ + Checks if all the teams satisfy the max team size constraint. + + ## Parameters + + * `teams` - IDs of the team members being created + * `max_team_size` - max team size of the team + + ## Returns + + Returns `true` if all the teams have size less or equal to the max team size; otherwise, returns `false`. + + """ + defp all_team_within_max_size?(teams, max_team_size) do + Enum.all?(teams, fn team -> + ids = Enum.map(team, &Map.get(&1, "userId")) + length(ids) <= max_team_size + end) + end + + @doc """ + Checks if one or more students are enrolled in the course. + + ## Parameters + + * `teams` - ID of the team being created + * `course_id` - ID of the course + + ## Returns + + Returns `true` if all students in the list enroll in the course; otherwise, returns `false`. + + """ + defp all_student_enrolled_in_course?(teams, course_id) do + all_ids = + teams + |> Enum.flat_map(fn team -> + Enum.map(team, fn row -> Map.get(row, "userId") end) + end) + + query = + from(cr in Cadet.Accounts.CourseRegistration, + where: cr.id in ^all_ids and cr.course_id == ^course_id, + select: count(cr.id) + ) + + count = Repo.one(query) + count == length(all_ids) + end + + @doc """ + Checks if one or more students are already in another team for the same assessment. + + ## Parameters + + * `team_id` - ID of the team being updated (use -1 for team creation) + * `student_ids` - List of student IDs + * `assessment_id` - ID of the assessment + + ## Returns + + Returns `true` if any student in the list is already a member of another team for the same assessment; otherwise, returns `false`. + + """ + defp student_already_in_team?(team_id, student_ids, assessment_id) do + query = + from(tm in TeamMember, + join: t in assoc(tm, :team), + where: + tm.student_id in ^student_ids and t.assessment_id == ^assessment_id and t.id != ^team_id, + select: tm.student_id + ) + + existing_student_ids = Repo.all(query) + + Enum.any?(student_ids, fn student_id -> Enum.member?(existing_student_ids, student_id) end) + end + + @doc """ + Updates an existing team, the corresponding assessment, and its members. + + ## Parameters + + * `team` - The existing team to be updated + * `new_assessment_id` - The ID of the updated assessment + * `student_ids` - List of student ids for team members + + ## Returns + + Returns a tuple `{:ok, updated_team}` on success, containing the updated team details; otherwise, an error tuple. + + """ + def update_team(team = %Team{}, new_assessment_id, student_ids) do + old_assessment_id = team.assessment_id + team_id = team.id + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + + if student_already_in_team?(team_id, new_student_ids, new_assessment_id) do + {:error, + {:conflict, "One or more students are already in another team for the same assessment!"}} + else + attrs = %{assessment_id: new_assessment_id} + + team + |> cast(attrs, [:assessment_id]) + |> validate_required([:assessment_id]) + |> foreign_key_constraint(:assessment_id) + |> Ecto.Changeset.change() + |> Repo.update() + |> case do + {:ok, updated_team} -> + update_team_members(updated_team, student_ids, team_id) + {:ok, updated_team} + end + end + end + + @doc """ + Updates team members based on the new list of student IDs. + + ## Parameters + + * `team` - The team being updated + * `student_ids` - List of student ids for team members + * `team_id` - ID of the team + + """ + defp update_team_members(team, student_ids, team_id) do + current_student_ids = team.team_members |> Enum.map(& &1.student_id) + new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) + + student_ids_to_add = + Enum.filter(new_student_ids, fn elem -> not Enum.member?(current_student_ids, elem) end) + + student_ids_to_remove = + Enum.filter(current_student_ids, fn elem -> not Enum.member?(new_student_ids, elem) end) + + Enum.each(student_ids_to_add, fn student_id -> + %TeamMember{} + # Change here + |> Ecto.Changeset.change(%{team_id: team_id, student_id: student_id}) + |> Repo.insert() + end) + + Enum.each(student_ids_to_remove, fn student_id -> + Repo.delete_all( + from(tm in TeamMember, where: tm.team_id == ^team_id and tm.student_id == ^student_id) + ) + end) + end + + @doc """ + Deletes a team along with its associated submissions and answers. + + ## Parameters + + * `team` - The team to be deleted + + """ + def delete_team(team = %Team{}) do + if has_submitted_answer?(team.id) do + {:error, {:conflict, "This team has submitted their answers! Unable to delete the team!"}} + else + submission = + Submission + |> where(team_id: ^team.id) + |> Repo.one() + + if submission do + Submission + |> where(team_id: ^team.id) + |> Repo.all() + |> Enum.each(fn x -> + Answer + |> where(submission_id: ^x.id) + |> Repo.delete_all() + end) + + Notification + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end + + team + |> Repo.delete() + end + end + + @doc """ + Check whether a team has subnitted submissions and answers. + + ## Parameters + + * `team_id` - The team id of the team to be checked + + ## Returns + + Returns `true` if any one of the submission has the status of "submitted", `false` otherwise + + """ + defp has_submitted_answer?(team_id) do + submission = + Submission + |> where([s], s.team_id == ^team_id and s.status == :submitted) + |> Repo.all() + + length(submission) > 0 + end +end diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index 933c9603a..a6794117b 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -24,6 +24,7 @@ defmodule Cadet.Assessments.Answer do field(:autograding_results, {:array, :map}, default: []) field(:answer, :map) field(:type, QuestionType, virtual: true) + field(:last_modified_at, :utc_datetime_usec) belongs_to(:grader, CourseRegistration) belongs_to(:submission, Submission) @@ -33,7 +34,7 @@ defmodule Cadet.Assessments.Answer do end @required_fields ~w(answer submission_id question_id type)a - @optional_fields ~w(xp xp_adjustment grader_id comments)a + @optional_fields ~w(xp xp_adjustment grader_id comments last_modified_at)a def changeset(answer, params) do answer diff --git a/lib/cadet/assessments/assessment.ex b/lib/cadet/assessments/assessment.ex index 77d0ef513..dc0ecd6b7 100644 --- a/lib/cadet/assessments/assessment.ex +++ b/lib/cadet/assessments/assessment.ex @@ -32,6 +32,7 @@ defmodule Cadet.Assessments.Assessment do field(:story, :string) field(:reading, :string) field(:password, :string, default: nil) + field(:max_team_size, :integer, default: 1) belongs_to(:config, AssessmentConfig) belongs_to(:course, Course) @@ -40,7 +41,7 @@ defmodule Cadet.Assessments.Assessment do timestamps() end - @required_fields ~w(title open_at close_at number course_id config_id)a + @required_fields ~w(title open_at close_at number course_id config_id max_team_size)a @optional_fields ~w(reading summary_short summary_long is_published story cover_picture access password)a @optional_file_fields ~w(mission_pdf)a @@ -61,6 +62,7 @@ defmodule Cadet.Assessments.Assessment do |> unique_constraint([:number, :course_id]) |> validate_config_course |> validate_open_close_date + |> validate_number(:max_team_size, greater_than_or_equal_to: 1) end defp validate_config_course(changeset) do diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 871b0fc7c..ca5595e51 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -12,6 +12,8 @@ defmodule Cadet.Assessments do Notification, Notifications, User, + Team, + TeamMember, CourseRegistration, CourseRegistrations } @@ -52,7 +54,7 @@ defmodule Cadet.Assessments do else Submission |> where(assessment_id: ^id) - |> delete_submission_assocation(id) + |> delete_submission_association(id) Question |> where(assessment_id: ^id) @@ -71,7 +73,7 @@ defmodule Cadet.Assessments do |> Repo.delete_all() end - defp delete_submission_assocation(submissions, assessment_id) do + defp delete_submission_association(submissions, assessment_id) do submissions |> Repo.all() |> Enum.each(fn submission -> @@ -104,9 +106,15 @@ defmodule Cadet.Assessments do end def assessments_total_xp(%CourseRegistration{id: cr_id}) do + teams = find_teams(cr_id) + submission_ids = get_submission_ids(cr_id, teams) + submission_xp = Submission - |> where(student_id: ^cr_id) + |> where( + [s], + s.id in ^submission_ids + ) |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) |> group_by([s], s.id) |> select([s, a], %{ @@ -259,11 +267,23 @@ defmodule Cadet.Assessments do assessment = %Assessment{id: id}, course_reg = %CourseRegistration{role: role} ) do + team_id = + case find_team(id, course_reg.id) do + {:ok, nil} -> + -1 + + {:ok, team} -> + team.id + + {:error, :team_not_found} -> + -1 + end + 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 == ^course_reg.id) + |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id) questions = Question @@ -297,10 +317,16 @@ defmodule Cadet.Assessments do by the supplied user """ def all_assessments(cr = %CourseRegistration{}) do + teams = find_teams(cr.id) + submission_ids = get_submission_ids(cr.id, teams) + submission_aggregates = Submission |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) + |> where( + [s], + s.id in ^submission_ids + ) |> group_by([s], s.assessment_id) |> select([s, ans], %{ assessment_id: s.assessment_id, @@ -311,7 +337,10 @@ defmodule Cadet.Assessments do submission_status = Submission - |> where([s], s.student_id == ^cr.id) + |> where( + [s], + s.id in ^submission_ids + ) |> select([s], [:assessment_id, :status]) assessments = @@ -339,6 +368,16 @@ defmodule Cadet.Assessments do {:ok, assessments} end + defp get_submission_ids(cr_id, teams) do + query = + from(s in Submission, + where: s.student_id == ^cr_id or s.team_id in ^Enum.map(teams, & &1.id), + select: s.id + ) + + Repo.all(query) + end + def filter_published_assessments(assessments, cr) do role = cr.role @@ -766,7 +805,8 @@ defmodule Cadet.Assessments do raw_answer, force_submit ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + with {:ok, team} <- find_team(question.assessment.id, cr_id), + {: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, cr_id) do update_submission_status_router(submission, question) @@ -779,6 +819,9 @@ defmodule Cadet.Assessments do {:error, :race_condition} -> {:error, {:internal_server_error, "Please try again later."}} + {:error, :team_not_found} -> + {:error, {:bad_request, "Your existing Team has been deleted!"}} + {:error, :invalid_vote} -> {:error, {:bad_request, "Invalid vote! Vote is not saved."}} @@ -787,14 +830,72 @@ defmodule Cadet.Assessments do end end + defp find_teams(cr_id) do + query = + from(t in Team, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id + ) + + Repo.all(query) + end + + defp find_team(assessment_id, cr_id) + when is_ecto_id(assessment_id) and is_ecto_id(cr_id) do + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr_id, + limit: 1 + ) + + assessment_team_size = + Map.get( + Repo.one( + from(a in Assessment, + where: a.id == ^assessment_id, + select: %{max_team_size: a.max_team_size} + ) + ), + :max_team_size, + 0 + ) + + case assessment_team_size > 1 do + true -> + case Repo.one(query) do + nil -> {:error, :team_not_found} + team -> {:ok, team} + end + + # team is nil for individual assessments + false -> + {:ok, nil} + end + end + 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: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() + {:ok, team} = find_team(assessment_id, cr_id) + + case team do + %Team{} -> + Submission + |> where(assessment_id: ^assessment_id) + |> where(team_id: ^team.id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + + nil -> + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end end def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do @@ -850,7 +951,7 @@ defmodule Cadet.Assessments do {:status, :submitted} <- {:status, submission.status}, {:allowed_to_unsubmit?, true} <- {:allowed_to_unsubmit?, - role == :admin or bypass or + role == :admin or bypass or is_nil(submission.student_id) or Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do Multi.new() |> Multi.run( @@ -893,10 +994,36 @@ defmodule Cadet.Assessments do end) |> Repo.transaction() - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) + case submission.student_id do + # Team submission, handle notifications for team members + nil -> + team = Repo.get(Team, submission.team_id) + + query = + from(t in Team, + join: tm in TeamMember, + on: t.id == tm.team_id, + join: cr in CourseRegistration, + on: tm.student_id == cr.id, + where: t.id == ^team.id, + select: cr.id + ) + + team_members = Repo.all(query) + + Enum.each(team_members, fn tm_id -> + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, tm_id) + ) + end) + + student_id -> + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, student_id) + ) + end {:ok, nil} else @@ -1342,7 +1469,13 @@ defmodule Cadet.Assessments do {:ok, %{ :count => integer, - :data => %{:assessments => [any()], :submissions => [any()], :users => [any()]} + :data => %{ + :assessments => [any()], + :submissions => [any()], + :users => [any()], + :teams => [any()], + :team_members => [any()] + } }} def submissions_by_grader_for_index( grader = %CourseRegistration{course_id: course_id}, @@ -1398,6 +1531,7 @@ defmodule Cadet.Assessments do unsubmitted_at: s.unsubmitted_at, unsubmitted_by_id: s.unsubmitted_by_id, student_id: s.student_id, + team_id: s.team_id, assessment_id: s.assessment_id, xp: ans.xp, xp_adjustment: ans.xp_adjustment, @@ -1570,10 +1704,24 @@ defmodule Cadet.Assessments do |> preload([a, q, ac], questions: q, config: ac) |> Repo.all() + team_ids = submissions |> Enum.map(& &1.team_id) |> Enum.uniq() + + teams = + Team + |> where([t], t.id in ^team_ids) + |> Repo.all() + + team_members = + TeamMember + |> where([tm], tm.team_id in ^team_ids) + |> Repo.all() + %{ users: users, assessments: assessments, - submissions: submissions + submissions: submissions, + teams: teams, + team_members: team_members } end @@ -1586,16 +1734,21 @@ 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(:inner, [..., ast], ac in assoc(ast, :config)) |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:left, [_, ..., g], gu in assoc(g, :user)) |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], + |> join(:left, [_, ..., s], st in assoc(s, :student)) + |> join(:left, [..., st], u in assoc(st, :user)) + |> join(:left, [..., s, _, _], t in assoc(s, :team)) + |> join(:left, [..., t], tm in assoc(t, :team_members)) + |> join(:left, [..., tm], tms in assoc(tm, :student)) + |> join(:left, [..., tms], tmu in assoc(tms, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u, t, tm, tms, tmu], question: {q, assessment: {ast, config: ac}}, grader: {g, user: gu}, - submission: {s, student: {st, user: u}} + submission: + {s, student: {st, user: u}, team: {t, team_members: {tm, student: {tms, user: tmu}}}} ) answers = @@ -1770,11 +1923,22 @@ defmodule Cadet.Assessments do end defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + {:ok, team} = find_team(assessment.id, cr.id) + submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() + case team do + %Team{} -> + Submission + |> where(team_id: ^team.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + nil -> + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + end if submission do {:ok, submission} @@ -1876,12 +2040,24 @@ defmodule Cadet.Assessments do end defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} + {:ok, team} = find_team(assessment.id, cr.id) + + case team do + %Team{} -> + %Submission{} + |> Submission.changeset(%{team: team, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + end + + nil -> + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + end end end @@ -1909,17 +2085,65 @@ defmodule Cadet.Assessments do answer: answer_content, question_id: question.id, submission_id: submission.id, - type: question.type + type: question.type, + last_modified_at: Timex.now() }) Repo.insert( answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + on_conflict: [ + set: [answer: get_change(answer_changeset, :answer), last_modified_at: Timex.now()] + ], conflict_target: [:submission_id, :question_id] ) end end + def has_last_modified_answer?( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + last_modified_at, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, is_modified} <- answer_last_modified?(submission, question, last_modified_at) do + {:ok, is_modified} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + defp answer_last_modified?( + submission = %Submission{}, + question = %Question{}, + last_modified_at + ) do + case Repo.get_by(Answer, submission_id: submission.id, question_id: question.id) do + %Answer{last_modified_at: existing_last_modified_at} -> + existing_iso8601 = DateTime.to_iso8601(existing_last_modified_at) + + if existing_iso8601 == last_modified_at do + {:ok, false} + else + {:ok, true} + end + + nil -> + {:ok, false} + end + end + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do set_score_to_nil = SubmissionVotes diff --git a/lib/cadet/assessments/submission.ex b/lib/cadet/assessments/submission.ex index eb8164065..db3a2b296 100644 --- a/lib/cadet/assessments/submission.ex +++ b/lib/cadet/assessments/submission.ex @@ -2,7 +2,7 @@ defmodule Cadet.Assessments.Submission do @moduledoc false use Cadet, :model - alias Cadet.Accounts.CourseRegistration + alias Cadet.Accounts.{CourseRegistration, Team} alias Cadet.Assessments.{Answer, Assessment, SubmissionStatus} @type t :: %__MODULE__{} @@ -20,14 +20,15 @@ defmodule Cadet.Assessments.Submission do belongs_to(:assessment, Assessment) belongs_to(:student, CourseRegistration) + belongs_to(:team, Team) belongs_to(:unsubmitted_by, CourseRegistration) - has_many(:answers, Answer) + has_many(:answers, Answer, on_delete: :delete_all) timestamps() end - @required_fields ~w(student_id assessment_id status)a - @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at)a + @required_fields ~w(assessment_id status)a + @optional_fields ~w(xp_bonus unsubmitted_by_id unsubmitted_at student_id team_id)a def changeset(submission, params) do submission @@ -36,10 +37,31 @@ defmodule Cadet.Assessments.Submission do :xp_bonus, greater_than_or_equal_to: 0 ) - |> add_belongs_to_id_from_model([:student, :assessment, :unsubmitted_by], params) + |> add_belongs_to_id_from_model([:team, :student, :assessment, :unsubmitted_by], params) + |> validate_xor_relationship |> validate_required(@required_fields) |> foreign_key_constraint(:student_id) |> foreign_key_constraint(:assessment_id) |> foreign_key_constraint(:unsubmitted_by_id) end + + defp validate_xor_relationship(changeset) do + case {get_field(changeset, :student_id), get_field(changeset, :team_id)} do + {nil, nil} -> + changeset + |> add_error(:student_id, "either student or team_id must be present") + |> add_error(:team_id, "either student_id or team must be present") + + {nil, _} -> + changeset + + {_, nil} -> + changeset + + {_student, _team} -> + changeset + |> add_error(:student_id, "student and team_id cannot be present at the same time") + |> add_error(:team_id, "student_id and team cannot be present at the same time") + end + end end diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 4a5076b33..83ca0b032 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -82,6 +82,7 @@ defmodule CadetWeb.AdminAssessmentsController do open_at = params |> Map.get("openAt") close_at = params |> Map.get("closeAt") is_published = params |> Map.get("isPublished") + max_team_size = params |> Map.get("maxTeamSize") updated_assessment = if is_nil(is_published) do @@ -90,6 +91,13 @@ defmodule CadetWeb.AdminAssessmentsController do %{:is_published => is_published} end + updated_assessment = + if is_nil(max_team_size) do + updated_assessment + else + Map.put(updated_assessment, :max_team_size, max_team_size) + end + with {:ok, assessment} <- check_dates(open_at, close_at, updated_assessment), {:ok, _nil} <- Assessments.update_assessment(assessment_id, assessment) do text(conn, "OK") @@ -113,7 +121,6 @@ defmodule CadetWeb.AdminAssessmentsController do else assessment = Map.put(assessment, :open_at, formatted_open_date) assessment = Map.put(assessment, :close_at, formatted_close_date) - {:ok, assessment} end end @@ -207,6 +214,7 @@ defmodule CadetWeb.AdminAssessmentsController do closeAt(:string, "Open date", required: false) openAt(:string, "Close date", required: false) isPublished(:boolean, "Whether the assessment is published", required: false) + maxTeamSize(:number, "Max team size of the assessment", required: false) end end } diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex new file mode 100644 index 000000000..c91974404 --- /dev/null +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -0,0 +1,225 @@ +defmodule CadetWeb.AdminTeamsController do + use CadetWeb, :controller + use PhoenixSwagger + alias Cadet.Repo + + alias Cadet.Accounts.{Teams, Team} + + def index(conn, _params) do + teams = + Team + |> Repo.all() + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) + + team_formation_overviews = + teams + |> Enum.map(&team_to_team_formation_overview/1) + + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("index.json", team_formation_overviews: team_formation_overviews) + end + + defp team_to_team_formation_overview(team) do + assessment = team.assessment + + team_formation_overview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: team.team_members |> Enum.map(& &1.student.user.id), + studentNames: team.team_members |> Enum.map(& &1.student.user.name) + } + + team_formation_overview + end + + def create(conn, %{"team" => team_params}) do + case Teams.create_team(team_params) do + {:ok, _team} -> + conn + |> put_status(:created) + |> text("Teams created successfully.") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def update(conn, %{ + "teamId" => teamId, + "assessmentId" => assessmentId, + "student_ids" => student_ids + }) do + team = + Team + |> Repo.get!(teamId) + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) + + case Teams.update_team(team, assessmentId, student_ids) do + {:ok, _updated_team} -> + conn + |> put_status(:ok) + |> text("Teams updated successfully.") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + end + end + + def delete(conn, %{"teamId" => team_id}) do + team = Repo.get(Team, team_id) + + if team do + case Teams.delete_team(team) do + {:error, {status, error_message}} -> + conn + |> put_status(status) + |> text(error_message) + + {:ok, _} -> + text(conn, "Team deleted successfully.") + end + else + conn + |> put_status(:not_found) + |> text("Team not found!") + end + end + + def delete(conn, %{"course_id" => course_id, "teamid" => team_id}) do + delete(conn, %{"teamId" => team_id}) + end + + swagger_path :index do + get("/admin/teams") + + summary("Fetches every team in the course") + + security([%{JWT: []}]) + + response(200, "OK", :Teams) + response(400, "Bad Request") + response(403, "Forbidden") + end + + swagger_path :create do + post("/courses/{course_id}/admin/teams") + + summary("Creates a new team") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + team_params(:body, :AdminCreateTeamPayload, "Team parameters", required: true) + end + + response(201, "Created") + response(400, "Bad Request") + response(401, "Unauthorised") + response(403, "Forbidden") + response(409, "Conflict") + end + + swagger_path :update do + post("/courses/{course_id}/admin/teams/{teamId}") + + summary("Updates an existing team") + + security([%{JWT: []}]) + + consumes("application/json") + + parameters do + teamId(:path, :integer, "Team ID", required: true) + + team(:body, Schema.ref(:AdminUpdateTeamPayload), "Updated team details", required: true) + end + + response(200, "OK") + response(400, "Bad Request") + response(401, "Unauthorised") + response(403, "Forbidden") + response(409, "Conflict") + end + + swagger_path :delete do + PhoenixSwagger.Path.delete("/courses/{course_id}/admin/teams/{teamId}") + + summary("Deletes an existing team") + + security([%{JWT: []}]) + + parameters do + teamId(:path, :integer, "Team ID", required: true) + end + + response(200, "OK") + response(400, "Bad Request") + response(401, "Unauthorised") + response(403, "Forbidden") + response(409, "Conflict") + end + + def swagger_definitions do + %{ + AdminCreateTeamPayload: + swagger_schema do + properties do + assessmentId(:integer, "Assessment ID") + studentIds(:array, "Student IDs", items: %{type: :integer}) + end + + required([:assessmentId, :studentIds]) + end, + AdminUpdateTeamPayload: + swagger_schema do + properties do + teamId(:integer, "Team ID") + assessmentId(:integer, "Assessment ID") + studentIds(:integer, "Student IDs", items: %{type: :integer}) + end + + required([:teamId, :assessmentId, :studentIds]) + end, + Teams: + swagger_schema do + type(:array) + items(Schema.ref(:Team)) + end, + Team: + swagger_schema do + properties do + id(:integer, "Team Id") + assessment(Schema.ref(:Assessment)) + team_members(Schema.ref(:TeamMembers)) + end + + required([:id, :assessment, :team_members]) + end, + TeamMembers: + swagger_schema do + type(:array) + items(Schema.ref(:TeamMember)) + end, + TeamMember: + swagger_schema do + properties do + id(:integer, "Team Member Id") + student(Schema.ref(:CourseRegistration)) + team(Schema.ref(:Team)) + end + + required([:id, :student, :team]) + 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 cc7dcaf5a..53add9133 100644 --- a/lib/cadet_web/admin_controllers/admin_user_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_user_controller.ex @@ -28,6 +28,14 @@ defmodule CadetWeb.AdminUserController do json(conn, %{totalXp: total_xp}) end + @add_users_role ~w(admin)a + def get_students(conn, filter) do + users = + filter |> try_keywordise_string_keys() |> Accounts.get_users_by(conn.assigns.course_reg) + + render(conn, "get_students.json", users: users) + end + @add_users_role ~w(admin)a def upsert_users_and_groups(conn, %{ "course_id" => course_id, diff --git a/lib/cadet_web/admin_views/admin_assessments_view.ex b/lib/cadet_web/admin_views/admin_assessments_view.ex index 4b1274d5d..477ea1b38 100644 --- a/lib/cadet_web/admin_views/admin_assessments_view.ex +++ b/lib/cadet_web/admin_views/admin_assessments_view.ex @@ -27,7 +27,8 @@ defmodule CadetWeb.AdminAssessmentsView do private: &password_protected?(&1.password), isPublished: :is_published, questionCount: :question_count, - gradedCount: &(&1.graded_count || 0) + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size }) end diff --git a/lib/cadet_web/admin_views/admin_grading_view.ex b/lib/cadet_web/admin_views/admin_grading_view.ex index e6a9089af..3f26e41d1 100644 --- a/lib/cadet_web/admin_views/admin_grading_view.ex +++ b/lib/cadet_web/admin_views/admin_grading_view.ex @@ -29,7 +29,9 @@ defmodule CadetWeb.AdminGradingView do data: %{ users: users, assessments: assessments, - submissions: submissions + submissions: submissions, + teams: teams, + team_members: team_members } }) do %{ @@ -38,6 +40,14 @@ defmodule CadetWeb.AdminGradingView do for submission <- submissions do user = users |> Enum.find(&(&1.id == submission.student_id)) assessment = assessments |> Enum.find(&(&1.id == submission.assessment_id)) + team = teams |> Enum.find(&(&1.id == submission.team_id)) + team_members = team_members |> Enum.filter(&(&1.team_id == submission.team_id)) + + team_member_users = + team_members + |> Enum.map(fn team_member -> + users |> Enum.find(&(&1.id == team_member.student_id)) + end) render( CadetWeb.AdminGradingView, @@ -46,6 +56,8 @@ defmodule CadetWeb.AdminGradingView do user: user, assessment: assessment, submission: submission, + team: team, + team_members: team_member_users, unsubmitter: case submission.unsubmitted_by_id do nil -> nil @@ -61,6 +73,8 @@ defmodule CadetWeb.AdminGradingView do user: user, assessment: a, submission: s, + team: team, + team_members: team_members, unsubmitter: unsubmitter }) do s @@ -81,6 +95,11 @@ defmodule CadetWeb.AdminGradingView do assessment: render_one(a, CadetWeb.AdminGradingView, "gradingsummaryassessment.json", as: :assessment), student: render_one(user, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr), + team: + render_one(team, CadetWeb.AdminGradingView, "gradingsummaryteam.json", + as: :team, + assigns: %{team_members: team_members} + ), unsubmittedBy: case unsubmitter do nil -> nil @@ -101,6 +120,14 @@ defmodule CadetWeb.AdminGradingView do } end + def render("gradingsummaryteam.json", %{team: team, assigns: %{team_members: team_members}}) do + %{ + id: team.id, + team_members: + render_many(team_members, CadetWeb.AdminGradingView, "gradingsummaryuser.json", as: :cr) + } + end + def render("gradingsummaryuser.json", %{cr: cr}) do %{ id: cr.id, @@ -121,12 +148,8 @@ 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: fn st -> st.user.name end, - id: :id, - username: fn st -> st.user.username end - }), + student: &extract_student_data(&1.submission.student), + team: &extract_team_data(&1.submission.team), question: &build_grading_question/1, solution: &(&1.question.question["solution"] || ""), grade: &build_grade/1 @@ -137,6 +160,35 @@ defmodule CadetWeb.AdminGradingView do %{cols: cols, rows: summary} end + defp extract_student_data(nil), do: %{} + + defp extract_student_data(student) do + transform_map_for_view(student, %{ + name: fn st -> st.user.name end, + id: :id, + username: fn st -> st.user.username end + }) + end + + defp extract_team_member_data(team_member) do + transform_map_for_view(team_member, %{ + name: & &1.student.user.name, + id: :id, + username: & &1.student.user.username + }) + end + + defp extract_team_data(nil), do: %{} + + defp extract_team_data(team) do + members = team.team_members + + case members do + [] -> nil + _ -> Enum.map(members, &extract_team_member_data/1) + end + end + defp build_grading_question(answer) do %{question: answer.question} |> build_question_by_question_config(true) diff --git a/lib/cadet_web/admin_views/admin_teams_view.ex b/lib/cadet_web/admin_views/admin_teams_view.ex new file mode 100644 index 000000000..1f6891093 --- /dev/null +++ b/lib/cadet_web/admin_views/admin_teams_view.ex @@ -0,0 +1,40 @@ +defmodule CadetWeb.AdminTeamsView do + @moduledoc """ + View module for rendering admin teams data in JSON format. + """ + + use CadetWeb, :view + + @doc """ + Renders a list of team formation overviews in JSON format. + + ## Parameters + + * `teamFormationOverviews` - A list of team formation overviews to be rendered. + + """ + def render("index.json", %{team_formation_overviews: team_formation_overviews}) do + render_many(team_formation_overviews, CadetWeb.AdminTeamsView, "team_formation_overview.json", + as: :team_formation_overview + ) + end + + @doc """ + Renders a single team formation overview in JSON format. + + ## Parameters + + * `team_formation_overview` - The team formation overview to be rendered. + + """ + def render("team_formation_overview.json", %{team_formation_overview: team_formation_overview}) do + %{ + teamId: team_formation_overview.teamId, + assessmentId: team_formation_overview.assessmentId, + assessmentName: team_formation_overview.assessmentName, + assessmentType: team_formation_overview.assessmentType, + studentIds: team_formation_overview.studentIds, + studentNames: team_formation_overview.studentNames + } + end +end diff --git a/lib/cadet_web/admin_views/admin_user_view.ex b/lib/cadet_web/admin_views/admin_user_view.ex index 062412e2d..6d6fb4403 100644 --- a/lib/cadet_web/admin_views/admin_user_view.ex +++ b/lib/cadet_web/admin_views/admin_user_view.ex @@ -5,6 +5,10 @@ defmodule CadetWeb.AdminUserView do render_many(users, CadetWeb.AdminUserView, "cr.json", as: :cr) end + def render("get_students.json", %{users: users}) do + render_many(users, CadetWeb.AdminUserView, "students.json", as: :students) + end + def render("cr.json", %{cr: cr}) do %{ courseRegId: cr.id, @@ -20,4 +24,12 @@ defmodule CadetWeb.AdminUserView do end } end + + def render("students.json", %{students: students}) do + %{ + userId: students.id, + name: students.user.name, + username: students.user.username + } + end end diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 5a5b22cb0..7e87ab22e 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -38,6 +38,47 @@ defmodule CadetWeb.AnswerController do end end + def check_last_modified(conn, %{ + "questionid" => question_id, + "lastModifiedAt" => last_modified_at + }) + when is_ecto_id(question_id) do + 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, last_modified} <- + Assessments.has_last_modified_answer?( + question, + course_reg, + last_modified_at, + can_bypass? + ) do + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("lastModified.json", lastModified: last_modified) + else + {:question, nil} -> + conn + |> put_status(:not_found) + |> text("Question not found") + + {:is_open?, false} -> + conn + |> put_status(:forbidden) + |> text("Assessment not open") + + {:error, _} -> + conn + |> put_status(:forbidden) + |> text("Forbidden") + end + end + def submit(conn, _params) do send_resp(conn, :bad_request, "Missing or invalid parameter(s)") end diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index 5fb0659ec..72a2e610a 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -195,6 +195,8 @@ defmodule CadetWeb.AssessmentsController do "The number of answers in the submission which have been graded", required: true ) + + maxTeamSize(:integer, "The maximum team size allowed", required: true) end end, Assessment: diff --git a/lib/cadet_web/controllers/team_controller.ex b/lib/cadet_web/controllers/team_controller.ex new file mode 100644 index 000000000..476790055 --- /dev/null +++ b/lib/cadet_web/controllers/team_controller.ex @@ -0,0 +1,106 @@ +defmodule CadetWeb.TeamController do + @moduledoc """ + Controller module for handling team-related actions. + """ + + use CadetWeb, :controller + use PhoenixSwagger + + import Ecto.Query + + alias Cadet.Repo + alias Cadet.Accounts.Team + + def index(conn, %{"assessmentid" => assessment_id}) when is_ecto_id(assessment_id) do + cr = conn.assigns.course_reg + + query = + from(t in Team, + where: t.assessment_id == ^assessment_id, + join: tm in assoc(t, :team_members), + where: tm.student_id == ^cr.id, + limit: 1 + ) + + team = + query + |> Repo.one() + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) + + if team == nil do + conn + |> put_status(:not_found) + |> text("Team is not found!") + else + team_formation_overview = team_to_team_formation_overview(team) + + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("index.json", teamFormationOverview: team_formation_overview) + end + end + + defp team_to_team_formation_overview(team) do + assessment = team.assessment + + team_formation_overview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: team.team_members |> Enum.map(& &1.student.user.id), + studentNames: team.team_members |> Enum.map(& &1.student.user.name) + } + + team_formation_overview + end + + swagger_path :index do + get("/admin/teams") + + summary("Fetches team formation overview based on assessment ID") + + security([%{JWT: []}]) + + parameters do + assessmentid(:query, :string, "Assessment ID", required: true) + end + + response(200, "OK", Schema.ref(:TeamFormationOverview)) + response(404, "Not Found") + response(403, "Forbidden") + end + + def swagger_definitions do + %{ + TeamFormationOverview: %{ + "type" => "object", + "properties" => %{ + "teamId" => %{"type" => "number", "description" => "The ID of the team"}, + "assessmentId" => %{"type" => "number", "description" => "The ID of the assessment"}, + "assessmentName" => %{"type" => "string", "description" => "The name of the assessment"}, + "assessmentType" => %{"type" => "string", "description" => "The type of the assessment"}, + "studentIds" => %{ + "type" => "array", + "items" => %{"type" => "number"}, + "description" => "List of student IDs" + }, + "studentNames" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "List of student names" + } + }, + "required" => [ + "teamId", + "assessmentId", + "assessmentName", + "assessmentType", + "studentIds", + "studentNames" + ] + } + } + end +end diff --git a/lib/cadet_web/helpers/assessments_helpers.ex b/lib/cadet_web/helpers/assessments_helpers.ex index 57d99f0a9..6a03dbff2 100644 --- a/lib/cadet_web/helpers/assessments_helpers.ex +++ b/lib/cadet_web/helpers/assessments_helpers.ex @@ -84,6 +84,7 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(answer, %{ answer: answer_builder_for(question_type), + lastModifiedAt: :last_modified_at, grader: grader_builder(grader), gradedAt: graded_at_builder(grader), xp: &((&1.xp || 0) + (&1.xp_adjustment || 0)), diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index aeebc8583..32e94eca2 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -80,6 +80,12 @@ defmodule CadetWeb.Router do post("/assessments/:assessmentid/submit", AssessmentsController, :submit) post("/assessments/question/:questionid/answer", AnswerController, :submit) + post( + "/assessments/question/:questionid/answerLastModified", + AnswerController, + :check_last_modified + ) + get("/achievements", IncentivesController, :index_achievements) get("/self/goals", IncentivesController, :index_goals) post("/self/goals/:uuid/progress", IncentivesController, :update_progress) @@ -94,6 +100,8 @@ defmodule CadetWeb.Router do put("/user/research_agreement", UserController, :update_research_agreement) get("/config", CoursesController, :index) + + get("/team/:assessmentid", TeamController, :index) end # Admin pages @@ -124,6 +132,7 @@ defmodule CadetWeb.Router do ) get("/users", AdminUserController, :index) + get("/users/teamformation", AdminUserController, :get_students) put("/users", AdminUserController, :upsert_users_and_groups) get("/users/:course_reg_id/assessments", AdminAssessmentsController, :index) @@ -165,6 +174,12 @@ defmodule CadetWeb.Router do AdminCoursesController, :delete_assessment_config ) + + get("/teams", AdminTeamsController, :index) + post("/teams", AdminTeamsController, :create) + delete("/teams/:teamid", AdminTeamsController, :delete) + put("/teams/:teamid", AdminTeamsController, :update) + post("/teams/upload", AdminTeamsController, :bulk_upload) end # Other scopes may use custom stacks. diff --git a/lib/cadet_web/views/answer_view.ex b/lib/cadet_web/views/answer_view.ex new file mode 100644 index 000000000..cb650739f --- /dev/null +++ b/lib/cadet_web/views/answer_view.ex @@ -0,0 +1,9 @@ +defmodule CadetWeb.AnswerView do + use CadetWeb, :view + + def render("lastModified.json", %{lastModified: lastModified}) do + %{ + lastModified: lastModified + } + end +end diff --git a/lib/cadet_web/views/assessments_view.ex b/lib/cadet_web/views/assessments_view.ex index 20aad951f..359f42454 100644 --- a/lib/cadet_web/views/assessments_view.ex +++ b/lib/cadet_web/views/assessments_view.ex @@ -28,7 +28,8 @@ defmodule CadetWeb.AssessmentsView do private: &password_protected?(&1.password), isPublished: :is_published, questionCount: :question_count, - gradedCount: &(&1.graded_count || 0) + gradedCount: &(&1.graded_count || 0), + maxTeamSize: :max_team_size }) end diff --git a/lib/cadet_web/views/team_view.ex b/lib/cadet_web/views/team_view.ex new file mode 100644 index 000000000..c93f684c1 --- /dev/null +++ b/lib/cadet_web/views/team_view.ex @@ -0,0 +1,26 @@ +defmodule CadetWeb.TeamView do + @moduledoc """ + View module for rendering team-related data as JSON. + """ + + use CadetWeb, :view + + @doc """ + Renders the JSON representation of team formation overview. + + ## Parameters + + * `teamFormationOverview` - A map containing team formation overview data. + + """ + def render("index.json", %{teamFormationOverview: teamFormationOverview}) do + %{ + teamId: teamFormationOverview.teamId, + assessmentId: teamFormationOverview.assessmentId, + assessmentName: teamFormationOverview.assessmentName, + assessmentType: teamFormationOverview.assessmentType, + studentIds: teamFormationOverview.studentIds, + studentNames: teamFormationOverview.studentNames + } + end +end diff --git a/priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs b/priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs new file mode 100644 index 000000000..d18892d50 --- /dev/null +++ b/priv/repo/migrations/20240214125701_add_max_team_size_to_assessments.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AddMaxTeamSizeToAssessments do + use Ecto.Migration + + def change do + alter table(:assessments) do + add(:max_team_size, :integer, null: false, default: 1) + end + end +end diff --git a/priv/repo/migrations/20240221032615_create_teams_table.exs b/priv/repo/migrations/20240221032615_create_teams_table.exs new file mode 100644 index 000000000..ae60a918f --- /dev/null +++ b/priv/repo/migrations/20240221032615_create_teams_table.exs @@ -0,0 +1,10 @@ +defmodule Cadet.Repo.Migrations.CreateTeamsTable do + use Ecto.Migration + + def change do + create table(:teams) do + add(:assessment_id, references(:assessments), null: false) + timestamps() + end + end +end diff --git a/priv/repo/migrations/20240221033554_create_team_members_table.exs b/priv/repo/migrations/20240221033554_create_team_members_table.exs new file mode 100644 index 000000000..cab248c63 --- /dev/null +++ b/priv/repo/migrations/20240221033554_create_team_members_table.exs @@ -0,0 +1,11 @@ +defmodule Cadet.Repo.Migrations.CreateTeamMembersTable do + use Ecto.Migration + + def change do + create table(:team_members) do + add(:team_id, references(:teams), null: false) + add(:student_id, references(:course_registrations), null: false) + timestamps() + end + end +end diff --git a/priv/repo/migrations/20240221033707_alter_submissions_table.exs b/priv/repo/migrations/20240221033707_alter_submissions_table.exs new file mode 100644 index 000000000..7ca8db90a --- /dev/null +++ b/priv/repo/migrations/20240221033707_alter_submissions_table.exs @@ -0,0 +1,26 @@ +defmodule Cadet.Repo.Migrations.AlterSubmissionsTable do + use Ecto.Migration + + def up do + execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_student_id_fkey;") + + alter table(:submissions) do + modify(:student_id, references(:course_registrations), null: true) + add(:team_id, references(:teams), null: true) + end + + execute("ALTER TABLE submissions ADD CONSTRAINT xor_constraint CHECK ( + (student_id IS NULL AND team_id IS NOT NULL) OR + (student_id IS NOT NULL AND team_id IS NULL) + );") + end + + def down do + execute("ALTER TABLE submissions DROP CONSTRAINT xor_constraint;") + + alter table(:submissions) do + modify(:student_id, references(:course_registrations), null: false) + drop(:team_id) + end + end +end diff --git a/priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs b/priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs new file mode 100644 index 000000000..ef89b3129 --- /dev/null +++ b/priv/repo/migrations/20240222094759_add_last_modified_to_answers.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AddLastModifiedToAnswers do + use Ecto.Migration + + def change do + alter table(:answers) do + add(:last_modified_at, :utc_datetime, default: fragment("CURRENT_TIMESTAMP")) + end + end +end diff --git a/test/cadet/accounts/notification_test.exs b/test/cadet/accounts/notification_test.exs index ba65bc7f2..7d0ece257 100644 --- a/test/cadet/accounts/notification_test.exs +++ b/test/cadet/accounts/notification_test.exs @@ -1,5 +1,5 @@ defmodule Cadet.Accounts.NotificationTest do - alias Cadet.Accounts.{Notification, Notifications} + alias Cadet.Accounts.{Notification, Notifications, TeamMember} use Cadet.ChangesetCase, entity: Notification @@ -11,7 +11,12 @@ defmodule Cadet.Accounts.NotificationTest do 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}) + individual_submission = insert(:submission, %{student: student, assessment: assessment}) + + team = insert(:team) + insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) + team_submission = insert(:submission, %{team: team, assessment: assessment, student: nil}) valid_params_for_student = %{ type: :new, @@ -27,7 +32,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } {:ok, @@ -35,7 +40,9 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission, + team: team, + individual_submission: individual_submission, + team_submission: team_submission, valid_params_for_student: valid_params_for_student, valid_params_for_avenger: valid_params_for_avenger }} @@ -106,7 +113,7 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission + individual_submission: individual_submission } do params_student = %{ type: :new, @@ -122,7 +129,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } assert {:ok, _} = Notifications.write(params_student) @@ -133,7 +140,7 @@ defmodule Cadet.Accounts.NotificationTest do assessment: assessment, avenger: avenger, student: student, - submission: submission + individual_submission: individual_submission } do params_student = %{ type: :new, @@ -149,7 +156,7 @@ defmodule Cadet.Accounts.NotificationTest do role: avenger.role, course_reg_id: avenger.id, assessment_id: assessment.id, - submission_id: submission.id + submission_id: individual_submission.id } Notifications.write(params_student) @@ -170,7 +177,7 @@ defmodule Cadet.Accounts.NotificationTest do from(n in Notification, where: n.type == ^:submitted and n.course_reg_id == ^avenger.id and - n.submission_id == ^submission.id + n.submission_id == ^individual_submission.id ) ) end @@ -277,12 +284,43 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :submitted} = notification end + test "receives notification when submitted [team submission]" do + assessment = insert(:assessment, %{is_published: true}) + avenger = insert(:course_registration, %{role: :staff}) + group = insert(:group, %{leader: avenger}) + team = insert(:team) + team_submission = insert(:submission, %{team: team, assessment: assessment, student: nil}) + + Enum.each(1..2, fn _ -> + student = insert(:course_registration, %{role: :student, group: group}) + insert(:team_member, %{team: team, student: student}) + end) + + Notifications.write_notification_when_student_submits(team_submission) + + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :submitted, + submission_id: team_submission.id + ) + + assert notification == nil + end) + end + test "receives notification when autograded", %{ assessment: assessment, student: student, - submission: submission + individual_submission: individual_submission } do - Notifications.write_notification_when_graded(submission.id, :autograded) + Notifications.write_notification_when_graded(individual_submission.id, :autograded) notification = Repo.get_by(Notification, @@ -294,12 +332,52 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :autograded} = notification end + test "no notification when no submission", %{ + assessment: assessment, + student: student + } do + Notifications.write_notification_when_graded(-1, :autograded) + + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :autograded, + assessment_id: assessment.id + ) + + assert notification == nil + end + + test "receives notification when autograded [team submission]", %{ + assessment: assessment, + team: team, + team_submission: team_submission + } do + Notifications.write_notification_when_graded(team_submission.id, :autograded) + + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :autograded, + assessment_id: assessment.id + ) + + assert %{type: :autograded} = notification + end) + end + test "receives notification when manually graded", %{ assessment: assessment, student: student, - submission: submission + individual_submission: individual_submission } do - Notifications.write_notification_when_graded(submission.id, :graded) + Notifications.write_notification_when_graded(individual_submission.id, :graded) notification = Repo.get_by(Notification, @@ -311,6 +389,30 @@ defmodule Cadet.Accounts.NotificationTest do assert %{type: :graded} = notification end + test "receives notification when maunally graded [team submission]", %{ + assessment: assessment, + team: team, + team_submission: team_submission + } do + Notifications.write_notification_when_graded(team_submission.id, :graded) + + team_members = + Repo.all(from(tm in TeamMember, where: tm.team_id == ^team.id, preload: :student)) + + students = Enum.map(team_members, & &1.student) + + Enum.each(students, fn student -> + notification = + Repo.get_by(Notification, + course_reg_id: student.id, + type: :graded, + assessment_id: assessment.id + ) + + assert %{type: :graded} = notification + end) + end + test "every student receives notifications when a new assessment is published", %{ assessment: assessment, student: student diff --git a/test/cadet/accounts/team_members_test.exs b/test/cadet/accounts/team_members_test.exs new file mode 100644 index 000000000..42f1dca89 --- /dev/null +++ b/test/cadet/accounts/team_members_test.exs @@ -0,0 +1,37 @@ +defmodule Cadet.Accounts.TeamMemberTest do + use Cadet.DataCase, async: true + + alias Cadet.Accounts.TeamMember + alias Cadet.Repo + + @valid_attrs %{student_id: 1, team_id: 1} + + describe "changeset/2" do + test "creates a valid changeset with valid attributes" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, @valid_attrs) + assert changeset.valid? + end + + test "returns an error when required fields are missing" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{}) + refute changeset.valid? + assert {:error, _changeset} = Repo.insert(changeset) + end + + test "returns an error when the team_id foreign key constraint is violated" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{student_id: 1}) + refute changeset.valid? + assert {:error, _changeset} = Repo.insert(changeset) + end + + test "returns an error when the student_id foreign key constraint is violated" do + team_member = %TeamMember{} + changeset = TeamMember.changeset(team_member, %{team_id: 1}) + refute changeset.valid? + assert {:error, _changeset} = Repo.insert(changeset) + end + end +end diff --git a/test/cadet/accounts/teams_test.exs b/test/cadet/accounts/teams_test.exs new file mode 100644 index 000000000..6b7c521f2 --- /dev/null +++ b/test/cadet/accounts/teams_test.exs @@ -0,0 +1,539 @@ +defmodule Cadet.Accounts.TeamTest do + use Cadet.DataCase + alias Cadet.Accounts.{Teams, TeamMember, CourseRegistrations} + alias Cadet.Assessments.{Submission, Answer} + alias Cadet.Repo + + setup do + user1 = insert(:user, %{name: "user 1"}) + user2 = insert(:user, %{name: "user 2"}) + user3 = insert(:user, %{name: "user 3"}) + course1 = insert(:course, %{course_short_name: "course 1"}) + course2 = insert(:course, %{course_short_name: "course 2"}) + assessment1 = insert(:assessment, %{title: "A1", max_team_size: 3, course: course1}) + assessment2 = insert(:assessment, %{title: "A2", max_team_size: 2, course: course1}) + + {:ok, + %{ + user1: user1, + user2: user2, + user3: user3, + course1: course1, + course2: course2, + assessment1: assessment1, + assessment2: assessment2 + }} + end + + test "creating a new team with valid attributes", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 2 + end + + test "creating a new team with duplicate students in the one row", %{ + user1: user1, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg1.id}] + ] + } + + result = Teams.create_team(attrs) + + assert result == + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a new team with duplicate students across the teams but not in one row", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}], + [%{"userId" => course_reg2.id}, %{"userId" => course_reg3.id}] + ] + } + + result = Teams.create_team(attrs) + + assert result == + {:error, {:conflict, "One or more students appear multiple times in a team!"}} + end + + test "creating a team with students already in another team for the same assessment", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs_valid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + assert {:ok, _team} = Teams.create_team(attrs_valid) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg3.id}] + ] + } + + result = Teams.create_team(attrs_invalid) + + assert result == + {:error, {:conflict, "One or more students already in a team for this assessment!"}} + end + + test "creating a team with students exceeding the maximum team size", %{ + user1: user1, + user2: user2, + user3: user3, + assessment2: assessment2, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment2.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more teams exceed the maximum team size!"}} + end + + test "inserting a team with non-exisiting student", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}, %{"userId" => 99_999}] + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "inserting a team with an exisiting student but not enrolled in this course", %{ + user1: user1, + user2: user2, + assessment1: assessment1, + course1: course1, + course2: course2 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course2.id, + role: :student + }) + + attrs_invalid = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + result = Teams.create_team(attrs_invalid) + assert result == {:error, {:conflict, "One or more students not enrolled in this course!"}} + end + + test "update an existing team with valid new team members", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + new_ids = [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + + assert {:ok, team} = Teams.create_team(attrs) + team = Repo.preload(team, :team_members) + assert {:ok, team} = Teams.update_team(team, team.assessment_id, new_ids) + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert length(team_members) == 3 + end + + test "update an existing team with new team members who are already in another team", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs1 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg1.id}, %{"userId" => course_reg2.id}] + ] + } + + attrs2 = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [%{"userId" => course_reg3.id}] + ] + } + + new_ids = [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + + assert {:ok, team1} = Teams.create_team(attrs1) + assert {:ok, _team2} = Teams.create_team(attrs2) + team1 = Repo.preload(team1, :team_members) + + result = Teams.update_team(team1, team1.assessment_id, new_ids) + + assert result == + {:error, + {:conflict, + "One or more students are already in another team for the same assessment!"}} + end + + test "delete an existing team", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + submission = + insert(:submission, %{ + team: team, + student: nil, + assessment: assessment1 + }) + + submission_id = submission.id + + _answer = %Answer{ + submission_id: submission_id + } + + assert {:ok, deleted_team} = Teams.delete_team(team) + assert deleted_team.id == team.id + + team_members = + TeamMember + |> where([tm], tm.team_id == ^team.id) + |> Repo.all() + + assert team_members == [] + + submissions = + Submission + |> where([s], s.team_id == ^team.id) + |> Repo.all() + + assert submissions == [] + + answers = + Answer + |> where(submission_id: ^submission_id) + |> Repo.all() + + assert answers == [] + end + + test "delete an existing team with submission", %{ + user1: user1, + user2: user2, + user3: user3, + assessment1: assessment1, + course1: course1 + } do + {:ok, course_reg1} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user1.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg2} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user2.id, + course_id: course1.id, + role: :student + }) + + {:ok, course_reg3} = + CourseRegistrations.insert_or_update_course_registration(%{ + user_id: user3.id, + course_id: course1.id, + role: :student + }) + + attrs = %{ + "assessment_id" => assessment1.id, + "student_ids" => [ + [ + %{"userId" => course_reg1.id}, + %{"userId" => course_reg2.id}, + %{"userId" => course_reg3.id} + ] + ] + } + + assert {:ok, team} = Teams.create_team(attrs) + + submission = %Submission{ + team_id: team.id, + assessment_id: assessment1.id, + status: :submitted + } + + {:ok, _inserted_submission} = Repo.insert(submission) + + result = Teams.delete_team(team) + + assert result == + {:error, + {:conflict, "This team has submitted their answers! Unable to delete the team!"}} + end +end diff --git a/test/cadet/assessments/assessment_test.exs b/test/cadet/assessments/assessment_test.exs index 5c52e2ed7..8b7d3763b 100644 --- a/test/cadet/assessments/assessment_test.exs +++ b/test/cadet/assessments/assessment_test.exs @@ -126,5 +126,27 @@ defmodule Cadet.Assessments.AssessmentTest do assert changeset.errors == [{:open_at, {"Open date must be before close date", []}}] refute changeset.valid? end + + test "invalid changeset with invalid team size", %{ + course1: course1, + config1: config1 + } do + changeset = + Assessment.changeset(%Assessment{}, %{ + config_id: config1.id, + course_id: course1.id, + title: "mission", + number: "M#{Enum.random(0..10)}", + max_team_size: -1, + open_at: Timex.now() |> Timex.to_unix() |> Integer.to_string(), + close_at: Timex.now() |> Timex.shift(days: 7) |> Timex.to_unix() |> Integer.to_string() + }) + + assert changeset.valid? == false + + assert changeset.errors[:max_team_size] == + {"must be greater than or equal to %{number}", + [validation: :number, kind: :greater_than_or_equal_to, number: 1]} + end end end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 7894ed5de..6b1793d0b 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -4,7 +4,7 @@ defmodule Cadet.AssessmentsTest do import Cadet.{Factory, TestEntityHelper} alias Cadet.Assessments - alias Cadet.Assessments.{Assessment, Question, SubmissionVotes} + alias Cadet.Assessments.{Assessment, Question, SubmissionVotes, Submission} test "create assessments of all types" do course = insert(:course) @@ -122,16 +122,73 @@ defmodule Cadet.AssessmentsTest do assert assessment.is_published == true end - test "update assessment" do - course = insert(:course) - config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{title: "assessment", course: course, config: config}) + describe "Update assessments" do + test "update assessment" do + 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"}) + + assessment = Repo.get(Assessment, assessment.id) + + assert assessment.title == "changed_assessment" + end + + test "update grading info for assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, is_published: false}) + + student = insert(:course_registration, %{course: course, role: :student}) + question = insert(:question, %{assessment: assessment}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: nil, + student: student, + status: :attempting + }) + + assert {:error, {:forbidden, "User is not permitted to grade."}} = + Assessments.update_grading_info( + %{submission: submission, question: question}, + %{}, + student + ) + end - Assessments.update_assessment(assessment.id, %{title: "changed_assessment"}) + test "force update assessment with invalid params" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) - assessment = Repo.get(Assessment, assessment.id) + assessment = + insert(:assessment, %{ + config: config, + course: course, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true + }) + + assessment_params = %{ + number: assessment.number, + course_id: course.id + } - assert assessment.title == "changed_assessment" + question_params = %{ + assessment: assessment, + type: :programming + } + + assert {:error, "Question count is different"} = + Assessments.insert_or_update_assessments_and_questions( + assessment_params, + question_params, + true + ) + end end test "update question" do @@ -147,6 +204,239 @@ defmodule Cadet.AssessmentsTest do assert Repo.get(Question, question.id) == nil end + describe "team assessments" do + test "cannot answer questions without a team" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + question = insert(:question, %{assessment: assessment}) + student = insert(:course_registration, %{course: course, role: :student}) + + assert Assessments.answer_question(question, student, "answer", false) == + {:error, {:bad_request, "Your existing Team has been deleted!"}} + end + + test "answer questions with a team" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + question = insert(:question, %{assessment: assessment, type: :programming}) + student1 = insert(:course_registration, %{course: course, role: :student}) + student2 = insert(:course_registration, %{course: course, role: :student}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :attempting + }) + + _answer = + insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + assert Assessments.answer_question(question, student1, "answer", false) == {:ok, nil} + end + + test "assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + assessment = insert(:assessment, %{config: config, course: course, max_team_size: 10}) + student = insert(:course_registration, %{course: course, role: :student}) + + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student) + end + + test "overdue assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: -5), + is_published: true, + password: "123" + }) + + student = insert(:course_registration, %{course: course, role: :student}) + + assert {:ok, _} = + Assessments.assessment_with_questions_and_answers(assessment, student, "123") + end + + test "team assessments with questions and answers" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -15), + close_at: Timex.shift(Timex.now(), days: +5), + is_published: true + }) + + group = insert(:group, %{name: "group"}) + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: assessment, team_members: [teammember1, teammember2]}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :submitted + }) + + assert {:ok, _} = Assessments.assessment_with_questions_and_answers(assessment, student1) + assert submission.id == Assessments.get_submission(assessment.id, student1).id + end + + test "create empty submission for team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true + }) + + group = insert(:group, %{name: "group"}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + + team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + question = insert(:question, %{assessment: team_assessment, type: :programming}) + + assert {:ok, _} = Assessments.answer_question(question, student1, "answer", false) + + submission = + Submission + |> where([s], s.team_id == ^team.id) + |> Repo.all() + + assert length(submission) == 1 + end + + @tag authenticate: :staff + test "unsubmit team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true + }) + + group = insert(:group, %{name: "group"}) + avenger = insert(:course_registration, %{course: course, role: :staff, group: group}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + + team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + submission = + insert(:submission, %{ + assessment: team_assessment, + team: team, + student: nil, + status: :submitted + }) + + assert {:ok, _} = Assessments.unsubmit_submission(submission.id, avenger) + end + + @tag authenticate: :staff + test "delete team assessment with associating submission" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + assessment = + insert(:assessment, %{ + config: config, + course: course, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true + }) + + student = insert(:course_registration, %{course: course, role: :student}) + question = insert(:question, %{assessment: assessment}) + + submission = + insert(:submission, %{ + assessment: assessment, + team: nil, + student: student, + status: :attempting + }) + + _answer = + insert(:answer, submission: submission, question: question, answer: %{code: "f => f(f);"}) + + assert {:ok, _} = Assessments.delete_assessment(assessment.id) + end + + test "get user xp for team assessment" do + course = insert(:course) + config = insert(:assessment_config, %{course: course}) + + team_assessment = + insert(:assessment, %{ + config: config, + course: course, + max_team_size: 10, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: +5), + is_published: true + }) + + group = insert(:group, %{name: "group"}) + + student1 = insert(:course_registration, %{course: course, role: :student, group: group}) + student2 = insert(:course_registration, %{course: course, role: :student, group: group}) + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + + _team = + insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) + + assert Assessments.assessments_total_xp(student1) == 0 + end + end + describe "contest voting" do test "inserts votes into submission_votes table if contest has closed" do course = insert(:course) diff --git a/test/cadet/assessments/submission_test.exs b/test/cadet/assessments/submission_test.exs index 4d7438d8d..4979a7543 100644 --- a/test/cadet/assessments/submission_test.exs +++ b/test/cadet/assessments/submission_test.exs @@ -3,22 +3,47 @@ defmodule Cadet.Assessments.SubmissionTest do use Cadet.ChangesetCase, entity: Submission - @required_fields ~w(student_id assessment_id)a + @required_fields ~w(assessment_id)a setup do course = insert(:course) config = insert(:assessment_config, %{course: course}) assessment = insert(:assessment, %{config: config, course: course}) + team_assessment = insert(:assessment, %{config: config, course: course}) student = insert(:course_registration, %{course: course, role: :student}) + student1 = insert(:course_registration, %{course: course, role: :student}) + student2 = insert(:course_registration, %{course: course, role: :student}) + + teammember1 = insert(:team_member, %{student: student1}) + teammember2 = insert(:team_member, %{student: student2}) + team = insert(:team, %{assessment: team_assessment, team_members: [teammember1, teammember2]}) valid_params = %{student_id: student.id, assessment_id: assessment.id} + valid_params_with_team = %{student_id: nil, team_id: team.id, assessment_id: assessment.id} + invalid_params_without_both = %{student_id: nil, team_id: nil, assessment_id: assessment.id} + + invalid_params_with_both = %{ + student_id: student1.id, + team_id: team.id, + assessment_id: assessment.id + } - {:ok, %{assessment: assessment, student: student, valid_params: valid_params}} + {:ok, + %{ + assessment: assessment, + student: student, + team: team, + valid_params: valid_params, + valid_params_with_team: valid_params_with_team, + invalid_params_without_both: invalid_params_without_both, + invalid_params_with_both: invalid_params_with_both + }} end describe "Changesets" do test "valid params", %{valid_params: params} do - assert_changeset_db(params, :valid) + params + |> assert_changeset_db(:valid) end test "converts valid params with models into ids", %{assessment: assessment, student: student} do @@ -49,5 +74,23 @@ defmodule Cadet.Assessments.SubmissionTest do |> Map.put(:student_id, new_student.id) |> assert_changeset_db(:invalid) end + + test "valid changeset with only team", %{ + valid_params_with_team: params + } do + assert_changeset_db(params, :valid) + end + + test "invalid changeset without team and student", %{ + invalid_params_without_both: params + } do + assert_changeset_db(params, :invalid) + end + + test "invalid changeset with both team and student", %{ + invalid_params_with_both: params + } do + assert_changeset_db(params, :invalid) + end end end diff --git a/test/cadet/auth/empty_guardian_test.exs b/test/cadet/auth/empty_guardian_test.exs new file mode 100644 index 000000000..54a3258b7 --- /dev/null +++ b/test/cadet/auth/empty_guardian_test.exs @@ -0,0 +1,24 @@ +defmodule Cadet.Auth.EmptyGuardianTest do + use ExUnit.Case + alias Cadet.Auth.EmptyGuardian + + describe "config/1" do + test "returns default value for allowed_drift" do + assert EmptyGuardian.config(:allowed_drift) == 10_000 + end + + test "returns nil for other keys" do + assert EmptyGuardian.config(:other_key) == nil + end + end + + describe "config/2" do + test "returns default value for allowed_drift regardless of second argument" do + assert EmptyGuardian.config(:allowed_drift, :default) == 10_000 + end + + test "returns second argument for other keys" do + assert EmptyGuardian.config(:other_key, :default) == :default + end + end +end diff --git a/test/cadet/jobs/notification_worker/notification_worker_test.exs b/test/cadet/jobs/notification_worker/notification_worker_test.exs index 9368a033c..f29a6e1c4 100644 --- a/test/cadet/jobs/notification_worker/notification_worker_test.exs +++ b/test/cadet/jobs/notification_worker/notification_worker_test.exs @@ -13,7 +13,7 @@ defmodule Cadet.NotificationWorker.NotificationWorkerTest do avenger_cr = assessments.course_regs.avenger1_cr # setup for assessment submission - asssub_ntype = Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") + _asssub_ntype = Cadet.Notifications.get_notification_type_by_name!("ASSESSMENT SUBMISSION") {_name, data} = Enum.at(assessments.assessments, 0) submission = List.first(List.first(data.mcq_answers)).submission 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 f1411ada3..a7c91ce4e 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -83,6 +83,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => 1, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, @@ -129,6 +130,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => 1, "maxXp" => 4800, "status" => get_assessment_status(view_as, &1), "private" => false, 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 db80273a2..c427a6478 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -141,7 +141,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) } @@ -214,7 +215,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) } @@ -325,7 +327,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "username" => &1.submission.student.user.username, "id" => &1.submission.student.id - } + }, + "team" => %{} } :mcq -> @@ -373,7 +376,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "username" => &1.submission.student.user.username, "id" => &1.submission.student.id - } + }, + "team" => %{} } :voting -> @@ -418,6 +422,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, + "team" => %{}, "solution" => "" } end @@ -838,7 +843,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) } @@ -891,7 +897,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "status" => Atom.to_string(submission.status), "gradedCount" => 5, "unsubmittedBy" => nil, - "unsubmittedAt" => nil + "unsubmittedAt" => nil, + "team" => nil } end) } @@ -1001,7 +1008,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "username" => &1.submission.student.user.username, "id" => &1.submission.student.id - } + }, + "team" => %{} } :mcq -> @@ -1049,7 +1057,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "name" => &1.submission.student.user.name, "username" => &1.submission.student.user.username, "id" => &1.submission.student.id - } + }, + "team" => %{} } :voting -> @@ -1094,6 +1103,7 @@ defmodule CadetWeb.AdminGradingControllerTest do "username" => &1.submission.student.user.username, "id" => &1.submission.student.id }, + "team" => %{}, "solution" => "" } end @@ -1331,7 +1341,8 @@ defmodule CadetWeb.AdminGradingControllerTest do title: "mission", course: course, config: assessment_config, - is_published: true + is_published: true, + max_team_size: 1 }) questions = diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs new file mode 100644 index 000000000..32d2a517e --- /dev/null +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -0,0 +1,297 @@ +defmodule CadetWeb.AdminTeamsControllerTest do + use CadetWeb.ConnCase + + alias Cadet.Repo + alias Cadet.Courses.Course + alias CadetWeb.AdminTeamsController + + test "swagger" do + AdminTeamsController.swagger_definitions() + AdminTeamsController.swagger_path_index(nil) + AdminTeamsController.swagger_path_create(nil) + AdminTeamsController.swagger_path_update(nil) + AdminTeamsController.swagger_path_delete(nil) + end + + describe "GET /admin/teams" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url(course.id)) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course_id = conn.assigns.course_id + conn = get(conn, build_url(course_id)) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "returns a list of teams", %{conn: conn} do + course_id = conn.assigns.course_id + team = insert(:team) + insert(:team_member, %{team: team}) + insert(:team_member, %{team: team}) + + conn = get(conn, build_url(course_id)) + assert response(conn, 200) + + response_body = conn.resp_body |> Jason.decode!() + assert Enum.any?(response_body, fn team_map -> team_map["teamId"] == team.id end) + end + end + + describe "POST /admin/teams" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + conn = post(conn, build_url(course.id), %{}) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "creates a new team", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] + } + } + + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 201) =~ "Teams created successfully." + end + + @tag authenticate: :staff + test "creates an invalid team with duplicate members", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student1.id}]] + } + } + + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students appear multiple times in a team!" + end + + @tag authenticate: :staff + test "creates an invalid team which exceeds max team size", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, + "student_ids" => [ + [%{userId: student1.id}, %{userId: student2.id}, %{userId: student3.id}] + ] + } + } + + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more teams exceed the maximum team size!" + end + + @tag authenticate: :staff + test "creates an invalid team where student not enrolled in course", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration) + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student2.id}]] + } + } + + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students not enrolled in this course!" + end + + @tag authenticate: :staff + test "creates an invalid team where student already has a team for this assessment", %{ + conn: conn + } do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) + + team_params = %{ + "team" => %{ + "assessment_id" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + } + + conn = post(conn, build_url(course_id), team_params) + assert response(conn, 409) =~ "One or more students already in a team for this assessment!" + end + end + + describe "PUT /admin/teams/{teamId}" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + conn = put(conn, build_url(course.id, 1), %{}) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + conn = put(conn, build_url(course.id, 1), %{}) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "updates a team", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) + insert(:team_member, %{team: team, student: student3}) + + updated_team_params = %{ + "course_id" => course.id, + "teamId" => team.id, + "assessmentId" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + + conn = put(conn, build_url(course_id, team.id), updated_team_params) + assert response(conn, 200) =~ "Teams updated successfully." + end + + @tag authenticate: :staff + test "updates a team which exceeds max team size", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + student3 = insert(:course_registration, %{course: course}) + team1 = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team1, student: student1}) + insert(:team_member, %{team: team1, student: student2}) + team2 = insert(:team, %{assessment: assessment}) + + updated_team_params = %{ + "course_id" => course.id, + "teamId" => team2.id, + "assessmentId" => assessment.id, + "student_ids" => [[%{userId: student1.id}, %{userId: student3.id}]] + } + + conn = put(conn, build_url(course_id, team2.id), updated_team_params) + + assert response(conn, 409) =~ + "One or more students are already in another team for the same assessment!" + end + end + + describe "DELETE /admin/teams/{teamId}" do + test "unauthenticated", %{conn: conn} do + course = insert(:course) + team = insert(:team) + conn = delete(conn, build_url(course.id, team.id)) + assert response(conn, 401) =~ "Unauthorised" + end + + @tag authenticate: :student + test "Forbidden", %{conn: conn} do + course = insert(:course) + team = insert(:team) + conn = delete(conn, build_url(course.id, team.id)) + assert response(conn, 403) =~ "Forbidden" + end + + @tag authenticate: :staff + test "deletes a team", %{conn: conn} do + course_id = conn.assigns.course_id + team = insert(:team) + conn = delete(conn, build_url(course_id, team.id)) + assert response(conn, 200) =~ "Team deleted successfully." + end + + @tag authenticate: :staff + test "delete a team that does not exist", %{conn: conn} do + course_id = conn.assigns.course_id + conn = delete(conn, build_url(course_id, -1)) + assert response(conn, 404) =~ "Team not found!" + end + + @tag authenticate: :staff + test "delete a team that has already submitted answers", %{conn: conn} do + course_id = conn.assigns.course_id + course = Repo.get(Course, course_id) + config = insert(:assessment_config, %{course: course}) + + assessment = + insert(:assessment, %{ + is_published: true, + course: course, + config: config, + max_team_size: 2 + }) + + student1 = insert(:course_registration, %{course: course}) + student2 = insert(:course_registration, %{course: course}) + team = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team, student: student1}) + insert(:team_member, %{team: team, student: student2}) + + insert(:submission, %{ + assessment: assessment, + team: team, + student: nil, + status: :submitted + }) + + conn = delete(conn, build_url(course_id, team.id)) + + assert response(conn, 409) =~ + "This team has submitted their answers! Unable to delete the team!" + end + end + + defp build_url(course_id), do: "/v2/courses/#{course_id}/admin/teams/" + + defp build_url(course_id, team_id), + do: "#{build_url(course_id)}#{team_id}" +end diff --git a/test/cadet_web/controllers/answer_controller_test.exs b/test/cadet_web/controllers/answer_controller_test.exs index bb1f4654a..3117b230e 100644 --- a/test/cadet_web/controllers/answer_controller_test.exs +++ b/test/cadet_web/controllers/answer_controller_test.exs @@ -327,6 +327,125 @@ defmodule CadetWeb.AnswerControllerTest do assert is_nil(get_answer_value(unpublished_question, unpublished_assessment, course_reg)) end + @tag authenticate: :student + test "check last modified false", %{conn: conn, programming_question: programming_question} do + course_id = conn.assigns.course_id + + question_id = programming_question.id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + check_last_modified_conn = + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", + %{ + lastModifiedAt: last_modified_at + } + ) + + assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":false}" + end + + @tag authenticate: :student + test "check last modified true", %{ + conn: conn, + assessment: assessment, + programming_question: programming_question + } do + course_id = conn.assigns.course_id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + question_id = programming_question.id + submission = insert(:submission, %{assessment: assessment, student: conn.assigns.test_cr}) + + _answer = + insert(:answer, %{ + question: programming_question, + last_modified_at: last_modified_at, + submission: submission + }) + + check_last_modified_conn = + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified/", + %{ + lastModifiedAt: last_modified_at + } + ) + + assert response(check_last_modified_conn, 200) =~ "{\"lastModified\":true}" + end + + # @tag authenticate: :student + # test "check last modified, invalid params", %{ + # conn: conn, + # assessment: assessment, + # mcq_question: mcq_question + # } do + # course_reg = conn.assigns.test_cr + # course_id = conn.assigns.course_id + + # question_id = mcq_question.id + # invalid_last_modified_at = "invalid_timestamp" + + # check_last_modified_conn = + # post( + # conn, + # "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", %{ + # lastModifiedAt: invalid_last_modified_at + # }) + + # assert response(check_last_modified_conn, 400) == "Invalid parameters" + # end + + @tag authenticate: :student + test "check last modified, missing question is unsuccessful", %{conn: conn} do + course_id = conn.assigns.course_id + question_id = -1 + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + check_last_modified_conn = + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", + %{ + lastModifiedAt: last_modified_at + } + ) + + assert response(check_last_modified_conn, 404) == "Question not found" + end + + @tag authenticate: :student + test "check last modified, not open submission is unsuccessful", %{conn: conn} do + course_id = conn.assigns.course_id + last_modified_at = DateTime.to_iso8601(DateTime.utc_now()) + + before_open_at_assessment = + insert(:assessment, %{ + open_at: Timex.shift(Timex.now(), days: 5), + close_at: Timex.shift(Timex.now(), days: 10) + }) + + before_open_at_question = + insert(:programming_question, %{assessment: before_open_at_assessment}) + + _unpublished_conn = post(conn, build_url(course_id, before_open_at_question.id), %{answer: 5}) + + question_id = before_open_at_question.id + + check_last_modified_conn = + post( + conn, + "/v2/courses/#{course_id}/assessments/question/#{question_id}/answerLastModified", + %{ + lastModifiedAt: last_modified_at + } + ) + + assert response(check_last_modified_conn, 403) == "Assessment not open" + end + defp build_url(course_id, question_id) do "/v2/courses/#{course_id}/assessments/question/#{question_id}/answer/" end diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index f9251d09b..d5fb65427 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -73,6 +73,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(course_reg, &1), "private" => false, @@ -156,6 +157,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(student, &1), "private" => false, @@ -266,6 +268,7 @@ defmodule CadetWeb.AssessmentsControllerTest do "type" => &1.config.type, "isManuallyGraded" => &1.config.is_manually_graded, "coverImage" => &1.cover_picture, + "maxTeamSize" => &1.max_team_size, "maxXp" => 4800, "status" => get_assessment_status(course_reg, &1), "private" => false, @@ -463,6 +466,7 @@ defmodule CadetWeb.AssessmentsControllerTest do |> Enum.map(&Map.delete(&1, "solution")) |> Enum.map(&Map.delete(&1, "library")) |> Enum.map(&Map.delete(&1, "xp")) + |> Enum.map(&Map.delete(&1, "lastModifiedAt")) |> Enum.map(&Map.delete(&1, "maxXp")) |> Enum.map(&Map.delete(&1, "grader")) |> Enum.map(&Map.delete(&1, "gradedAt")) diff --git a/test/cadet_web/controllers/teams_controller_test.exs b/test/cadet_web/controllers/teams_controller_test.exs new file mode 100644 index 000000000..e67324adb --- /dev/null +++ b/test/cadet_web/controllers/teams_controller_test.exs @@ -0,0 +1,101 @@ +defmodule CadetWeb.TeamsControllerTest do + use CadetWeb.ConnCase + + alias Cadet.Repo + alias Cadet.Courses.Course + alias CadetWeb.TeamController + + setup do + Cadet.Test.Seeds.assessments() + end + + test "swagger" do + TeamController.swagger_path_index(nil) + end + + describe "GET /v2/admin/teams" do + @tag authenticate: :student + test "unauthorized with student", %{conn: conn} do + course = insert(:course) + conn = get(conn, build_url_get(course.id)) + assert response(conn, 403) == "Forbidden" + end + + @tag authenticate: :admin + test "authorized with zero team", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + # assessment = insert(:assessment, %{course: course}) + conn = get(conn, build_url_get(course.id)) + assert response(conn, 200) == "[]" + end + + @tag authenticate: :admin + test "authorized with multiple teams", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course}) + team = insert(:team, %{assessment: assessment}) + conn = get(conn, build_url_get(course.id)) + + team_formation_overview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: [], + studentNames: [] + } + + assert response(conn, 200) == "[#{Jason.encode!(team_formation_overview)}]" + end + end + + describe "GET /v2/courses/:course_id/team/:assessment_id" do + @tag authenticate: :admin + test "team not found", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course}) + conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) + assert response(conn, 404) == "Team is not found!" + end + + @tag authenticate: :admin + test "team(s) found", %{conn: conn} do + course_id = conn.assigns[:course_id] + course = Repo.get(Course, course_id) + cr = conn.assigns[:test_cr] + cr1 = insert(:course_registration, %{course: course, role: :student}) + cr2 = insert(:course_registration, %{course: course, role: :student}) + assessment = insert(:assessment, %{course: course}) + teammember1 = insert(:team_member, %{student: cr1}) + teammember2 = insert(:team_member, %{student: cr2}) + teammember3 = insert(:team_member, %{student: cr}) + + team = + insert(:team, %{ + assessment: assessment, + team_members: [teammember1, teammember2, teammember3] + }) + + conn = get(conn, build_url_get_by_assessment(course.id, assessment.id)) + + team_formation_overview = %{ + teamId: team.id, + assessmentId: assessment.id, + assessmentName: assessment.title, + assessmentType: assessment.config.type, + studentIds: [cr1.user.id, cr2.user.id, cr.user.id], + studentNames: [cr1.user.name, cr2.user.name, cr.user.name] + } + + assert response(conn, 200) == "#{Jason.encode!(team_formation_overview)}" + end + end + + defp build_url_get(course_id), do: "/v2/courses/#{course_id}/admin/teams" + + defp build_url_get_by_assessment(course_id, assessment_id), + do: "/v2/courses/#{course_id}/team/#{assessment_id}" +end diff --git a/test/cadet_web/views/answer_view_test.exs b/test/cadet_web/views/answer_view_test.exs new file mode 100644 index 000000000..591c2e360 --- /dev/null +++ b/test/cadet_web/views/answer_view_test.exs @@ -0,0 +1,15 @@ +defmodule CadetWeb.AnswerViewTest do + use CadetWeb.ConnCase, async: true + + alias CadetWeb.AnswerView + + @last_modified ~U[2022-01-01T00:00:00Z] + + describe "render/2" do + test "renders last modified timestamp as JSON" do + json = AnswerView.render("lastModified.json", %{lastModified: @last_modified}) + + assert json[:lastModified] == @last_modified + end + end +end diff --git a/test/cadet_web/views/team_view_test.exs b/test/cadet_web/views/team_view_test.exs new file mode 100644 index 000000000..bc5d8a9ee --- /dev/null +++ b/test/cadet_web/views/team_view_test.exs @@ -0,0 +1,27 @@ +defmodule CadetWeb.TeamViewTest do + use CadetWeb.ConnCase, async: true + + alias CadetWeb.TeamView + + @team_formation_overview %{ + teamId: 1, + assessmentId: 2, + assessmentName: "Test Assessment", + assessmentType: "Test Type", + studentIds: [1, 2, 3], + studentNames: ["Alice", "Bob", "Charlie"] + } + + describe "render/2" do + test "renders team formation overview as JSON" do + json = TeamView.render("index.json", %{teamFormationOverview: @team_formation_overview}) + + assert json[:teamId] == @team_formation_overview.teamId + assert json[:assessmentId] == @team_formation_overview.assessmentId + assert json[:assessmentName] == @team_formation_overview.assessmentName + assert json[:assessmentType] == @team_formation_overview.assessmentType + assert json[:studentIds] == @team_formation_overview.studentIds + assert json[:studentNames] == @team_formation_overview.studentNames + end + end +end diff --git a/test/factories/accounts/team_factory.ex b/test/factories/accounts/team_factory.ex new file mode 100644 index 000000000..36a7adc5a --- /dev/null +++ b/test/factories/accounts/team_factory.ex @@ -0,0 +1,18 @@ +defmodule Cadet.Accounts.TeamFactory do + @moduledoc """ + Factory(ies) for Cadet.Accounts.Team entity + """ + + defmacro __using__(_opts) do + quote do + # alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.Team + + def team_factory do + %Team{ + assessment: build(:assessment) + } + end + end + end +end diff --git a/test/factories/accounts/team_member_factory.ex b/test/factories/accounts/team_member_factory.ex new file mode 100644 index 000000000..5db6c6796 --- /dev/null +++ b/test/factories/accounts/team_member_factory.ex @@ -0,0 +1,19 @@ +defmodule Cadet.Accounts.TeamMemberFactory do + @moduledoc """ + Factory(ies) for Cadet.Accounts.TeamMember entity + """ + + defmacro __using__(_opts) do + quote do + # alias Cadet.Accounts.{Role, User} + alias Cadet.Accounts.TeamMember + + def team_member_factory do + %TeamMember{ + student: build(:course_registration), + team: build(:team) + } + end + end + end +end diff --git a/test/factories/assessments/assessment_factory.ex b/test/factories/assessments/assessment_factory.ex index 71682e52e..e85267418 100644 --- a/test/factories/assessments/assessment_factory.ex +++ b/test/factories/assessments/assessment_factory.ex @@ -38,7 +38,8 @@ defmodule Cadet.Assessments.AssessmentFactory do course: course, open_at: Timex.now(), close_at: Timex.shift(Timex.now(), days: Enum.random(1..30)), - is_published: false + is_published: false, + max_team_size: 1 } end end diff --git a/test/factories/assessments/submission_factory.ex b/test/factories/assessments/submission_factory.ex index 971345a93..da94d87b2 100644 --- a/test/factories/assessments/submission_factory.ex +++ b/test/factories/assessments/submission_factory.ex @@ -10,6 +10,7 @@ defmodule Cadet.Assessments.SubmissionFactory do def submission_factory do %Submission{ student: build(:course_registration, %{role: :student}), + team: nil, assessment: build(:assessment) } end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 469346ff5..86059152b 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -4,7 +4,13 @@ defmodule Cadet.Factory do """ use ExMachina.Ecto, repo: Cadet.Repo - use Cadet.Accounts.{NotificationFactory, UserFactory, CourseRegistrationFactory} + use Cadet.Accounts.{ + NotificationFactory, + UserFactory, + CourseRegistrationFactory, + TeamFactory, + TeamMemberFactory + } use Cadet.Assessments.{ AnswerFactory,