diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index b8f8695b5..3fa6baeb3 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -9,7 +9,7 @@ defmodule Cadet.Assessments.Answer do alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.Answer.AutogradingStatus alias Cadet.Assessments.AnswerTypes.{MCQAnswer, ProgrammingAnswer, VotingAnswer} - alias Cadet.Assessments.{Question, QuestionType, Submission} + alias Cadet.Assessments.{Question, QuestionType, Submission, Version} alias Cadet.AIComments.AIComment @type t :: %__MODULE__{} @@ -31,6 +31,7 @@ defmodule Cadet.Assessments.Answer do belongs_to(:submission, Submission) belongs_to(:question, Question) has_many(:ai_comments, AIComment, on_delete: :delete_all) + has_many(:versions, Version, on_delete: :delete_all) timestamps() end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 426fc8914..aa9208d48 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -20,7 +20,16 @@ defmodule Cadet.Assessments do CourseRegistrations } - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Assessments.{ + Answer, + Assessment, + Query, + Question, + Submission, + SubmissionVotes, + Version + } + alias Cadet.Autograder.GradingJob alias Cadet.Courses.{Group, AssessmentConfig} alias Cadet.Jobs.Log @@ -1284,7 +1293,8 @@ defmodule Cadet.Assessments do 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 + {:ok, answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id), + {:ok, _version} <- insert_version(question, answer, raw_answer) do Logger.info("Successfully answered question #{question.id} for user #{cr_id}") update_submission_status_router(submission, question) @@ -3610,4 +3620,162 @@ defmodule Cadet.Assessments do Repo.one(query) end + + def get_versions( + question = %Question{}, + cr = %CourseRegistration{} + ) do + with {:ok, team} <- find_team(question.assessment.id, cr.id) do + base_query = + Version + |> join(:inner, [v], a in assoc(v, :answer)) + |> join(:inner, [v, a], s in assoc(a, :submission)) + |> where([v, a, s], a.question_id == ^question.id) + + query = + case team do + %Team{} -> + where(base_query, [_v, _a, s], s.team_id == ^team.id) + + nil -> + where(base_query, [_v, _a, s], s.student_id == ^cr.id) + end + + {:ok, Repo.all(query)} + else + {:error, :team_not_found} -> + Logger.error("Team not found for question #{question.id} and user #{cr.id}") + {:error, {:bad_request, "Your existing Team has been deleted!"}} + + error -> + error + end + end + + def save_version( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_content + ) do + if question.type == :voting do + {:error, {:bad_request, "Cannot save version for voting question"}} + else + with {:ok, _team} <- find_team(question.assessment.id, cr_id), + {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:ok, answer} <- find_or_create_answer(question, submission, raw_content), + {:ok, _version} <- insert_version(question, answer, raw_content) do + Logger.info("Successfully saved version for answer #{question.id} for user #{cr_id}") + {:ok, nil} + else + {:error, :team_not_found} -> + Logger.error("Team not found for question #{question.id} and user #{cr_id}") + {:error, {:bad_request, "Your existing Team has been deleted!"}} + + error -> + error + end + end + end + + defp find_or_create_answer( + question = %Question{}, + submission = %Submission{}, + raw_content + ) do + case find_answer(question, submission) do + {:ok, answer} -> {:ok, answer} + {:error, _} -> create_new_answer(question, submission, raw_content) + end + end + + defp find_answer(question = %Question{}, submission = %Submission{}) do + answer = + Answer + |> where(submission_id: ^submission.id) + |> where(question_id: ^question.id) + |> Repo.one() + + if answer do + {:ok, answer} + else + {:error, nil} + end + end + + defp create_new_answer( + question = %Question{}, + submission = %Submission{}, + raw_answer + ) do + answer_content = build_answer_content(raw_answer, question.type) + + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + |> Repo.insert() + end + + defp insert_version( + question = %Question{}, + answer = %Answer{}, + raw_content + ) do + content = build_answer_content(raw_content, question.type) + + %Version{} + |> Version.changeset(%{ + content: content, + answer_id: answer.id + }) + |> Repo.insert() + end + + def name_version( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + version_id, + name + ) do + with {:ok, team} <- find_team(question.assessment.id, cr.id) do + base_query = + Version + |> join(:inner, [v], a in assoc(v, :answer)) + |> join(:inner, [v, a], s in assoc(a, :submission)) + |> where([v, a, s], v.id == ^version_id) + + version = + case team do + %Team{} -> + base_query + |> where([v, a, s], s.team_id == ^team.id) + |> Repo.one() + + nil -> + base_query + |> where([v, a, s], s.student_id == ^cr.id) + |> Repo.one() + end + + case version do + nil -> + {:error, {:not_found, "Version not found"}} + + version -> + version + |> Version.changeset(%{name: name}) + |> Repo.update() + end + else + {:error, :team_not_found} -> + Logger.error("Team not found for question #{question.id} and user #{cr.id}") + {:error, {:bad_request, "Your existing Team has been deleted!"}} + + error -> + error + end + end end diff --git a/lib/cadet/assessments/version.ex b/lib/cadet/assessments/version.ex new file mode 100644 index 000000000..8e4eca823 --- /dev/null +++ b/lib/cadet/assessments/version.ex @@ -0,0 +1,24 @@ +defmodule Cadet.Assessments.Version do + use Ecto.Schema + import Ecto.Changeset + + alias Cadet.Assessments.Answer + + schema "versions" do + field(:content, :map) + field(:name, :string) + field(:restored, :boolean, default: false) + field(:restored_from, :id) + + belongs_to(:answer, Answer) + + timestamps() + end + + @doc false + def changeset(version, attrs) do + version + |> cast(attrs, [:content, :name, :restored, :answer_id]) + |> validate_required([:content, :restored, :answer_id]) + end +end diff --git a/lib/cadet_web/controllers/versions_controller.ex b/lib/cadet_web/controllers/versions_controller.ex new file mode 100644 index 000000000..ec9b27484 --- /dev/null +++ b/lib/cadet_web/controllers/versions_controller.ex @@ -0,0 +1,102 @@ +defmodule CadetWeb.VersionsController do + @moduledoc """ + Handles code versioning and history + """ + use CadetWeb, :controller + use PhoenixSwagger + require Logger + + alias Cadet.Assessments + + def history(conn, %{"questionid" => question_id}) do + course_reg = conn.assigns[:course_reg] + + Logger.info( + "Fetching all versions for question #{question_id} for user #{course_reg.id} in course #{course_reg.course_id}" + ) + + with {:question, question} when not is_nil(question) <- + {:question, Assessments.get_question(question_id)}, + {:ok, versions} <- Assessments.get_versions(question, course_reg) do + conn + |> put_status(:ok) + |> put_resp_content_type("application/json") + |> render("index.json", versions: versions) + else + {:question, nil} -> + conn + |> put_status(:not_found) + |> text("Question not found") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + + other -> + Logger.error("Unexpected error in versions controller: #{inspect(other)}") + + conn + |> put_status(:internal_server_error) + |> text("An unexpected error occurred.") + end + end + + def save(conn, %{"questionid" => question_id, "content" => content}) do + course_reg = conn.assigns[:course_reg] + + with {:question, question} when not is_nil(question) <- + {:question, Assessments.get_question(question_id)}, + {:ok, _nil} <- Assessments.save_version(question, course_reg, content) do + text(conn, "OK") + else + {:question, nil} -> + conn + |> put_status(:not_found) + |> text("Question not found") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + + other -> + Logger.error("Unexpected error in versions controller: #{inspect(other)}") + + conn + |> put_status(:internal_server_error) + |> text("An unexpected error occurred.") + end + end + + def name(conn, %{ + "questionid" => question_id, + "versionid" => version_id, + "name" => name + }) do + course_reg = conn.assigns[:course_reg] + + with {:question, question} when not is_nil(question) <- + {:question, Assessments.get_question(question_id)}, + {:ok, _nil} <- Assessments.name_version(question, course_reg, version_id, name) do + text(conn, "OK") + else + {:question, nil} -> + conn + |> put_status(:not_found) + |> text("Question not found") + + {:error, {status, message}} -> + conn + |> put_status(status) + |> text(message) + + other -> + Logger.error("Unexpected error in versions controller: #{inspect(other)}") + + conn + |> put_status(:internal_server_error) + |> text("An unexpected error occurred.") + end + end +end diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index e045dc637..733af820d 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -107,6 +107,10 @@ defmodule CadetWeb.Router do :check_last_modified ) + get("/assessments/question/:questionid/version/history", VersionsController, :history) + post("/assessments/question/:questionid/version/save", VersionsController, :save) + put("/assessments/question/:questionid/version/:versionid/name", VersionsController, :name) + get("/achievements", IncentivesController, :index_achievements) get("/self/goals", IncentivesController, :index_goals) post("/self/goals/:uuid/progress", IncentivesController, :update_progress) diff --git a/lib/cadet_web/views/versions_view.ex b/lib/cadet_web/views/versions_view.ex new file mode 100644 index 000000000..79ea39652 --- /dev/null +++ b/lib/cadet_web/views/versions_view.ex @@ -0,0 +1,20 @@ +defmodule CadetWeb.VersionsView do + use CadetWeb, :view + + def render("index.json", %{versions: versions}) do + render_many(versions, CadetWeb.VersionsView, "show.json", as: :version) + end + + def render("show.json", %{version: version}) do + transform_map_for_view(version, %{ + id: :id, + name: :name, + restored: :restored, + restored_from: :restored_from, + answer_id: :answer_id, + inserted_at: :inserted_at, + updated_at: :updated_at, + content: :content + }) + end +end diff --git a/lib/mix/tasks/token.ex b/lib/mix/tasks/token.ex index f2dd660a5..60d3c0d96 100644 --- a/lib/mix/tasks/token.ex +++ b/lib/mix/tasks/token.ex @@ -108,6 +108,13 @@ defmodule Mix.Tasks.Cadet.Token do course_id: course.id, role: role }) + + %User{} + |> User.changeset(%{ + name: "Test#{role_capitalized}", + username: "test_#{role}", + provider: "test" + }) |> Repo.insert!() new_user diff --git a/mix.lock b/mix.lock index 8c286b55f..3739abe41 100644 --- a/mix.lock +++ b/mix.lock @@ -23,6 +23,8 @@ "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "diff_match_patch": {:hex, :diff_match_patch, "0.2.0", "5d7e3ef8fd6b5806a778f7f2630a2347b25a0b32501bc073a062912f1415c62c", [:mix], [], "hexpm", "7ffd724cad3419b826f59f4e466e714ca83ff471542ad0f897d1c6f6980e3012"}, + "differ": {:hex, :differ, "0.1.1", "581a90ced623e5f3949d115959251f200062538274ec624484f3373af62d824e", [:mix], [], "hexpm", "f1f9d3dd4509a5c1e505c9556e6b0d80f20db2826a06c4bd6a044f77424c0db3"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, diff --git a/priv/repo/migrations/20260219073155_create_versions.exs b/priv/repo/migrations/20260219073155_create_versions.exs new file mode 100644 index 000000000..a51840c50 --- /dev/null +++ b/priv/repo/migrations/20260219073155_create_versions.exs @@ -0,0 +1,41 @@ +defmodule Cadet.Repo.Migrations.CreateVersions do + use Ecto.Migration + import Ecto.Query, only: [from: 2] + + def up do + create table(:versions) do + add(:content, :map) + add(:name, :string) + add(:restored, :boolean, default: false, null: false) + add(:answer_id, references(:answers, on_delete: :delete_all)) + add(:restored_from, references(:versions, on_delete: :nothing)) + + timestamps() + end + + create(index(:versions, [:answer_id])) + create(index(:versions, [:restored_from])) + + # Backfill data from answers table + flush() + + source_query = + from(a in "answers", + join: q in "questions", + on: q.id == a.question_id, + where: q.type != "voting", + select: %{ + content: a.answer, + answer_id: a.id, + inserted_at: a.inserted_at, + updated_at: a.updated_at + } + ) + + repo().insert_all("versions", source_query) + end + + def down do + drop(table(:versions)) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 37be60527..6a30d9f32 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -158,19 +158,31 @@ if Cadet.Env.env() == :dev do question <- questions do case question.type do :programming -> - insert(:answer, %{ - xp: Enum.random(0..1_000), - question: question, - submission: submission, - answer: build(:programming_answer) + %{id: id, answer: content} = + insert(:answer, %{ + xp: Enum.random(0..1_000), + question: question, + submission: submission, + answer: build(:programming_answer) + }) + + insert(:version, %{ + answer_id: id, + content: content }) :mcq -> - insert(:answer, %{ - xp: Enum.random(0..500), - question: question, - submission: submission, - answer: build(:mcq_answer) + %{id: id, answer: content} = + insert(:answer, %{ + xp: Enum.random(0..500), + question: question, + submission: submission, + answer: build(:mcq_answer) + }) + + insert(:version, %{ + answer_id: id, + content: content }) end end diff --git a/test/factories/assessments/version_factory.ex b/test/factories/assessments/version_factory.ex new file mode 100644 index 000000000..43e911f6f --- /dev/null +++ b/test/factories/assessments/version_factory.ex @@ -0,0 +1,14 @@ +defmodule Cadet.Assessments.VersionFactory do + @moduledoc """ + Factory for the Version entity + """ + defmacro __using__(_opts) do + quote do + alias Cadet.Assessments.Version + + def version_factory do + %Version{} + end + end + end +end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 35d844ddf..ee2f97f0c 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -18,7 +18,8 @@ defmodule Cadet.Factory do LibraryFactory, QuestionFactory, SubmissionFactory, - SubmissionVotesFactory + SubmissionVotesFactory, + VersionFactory } use Cadet.Chatbot.{ConversationFactory}