diff --git a/lib/lanttern/assessments.ex b/lib/lanttern/assessments.ex index 45e0f7c5..9d687683 100644 --- a/lib/lanttern/assessments.ex +++ b/lib/lanttern/assessments.ex @@ -11,6 +11,7 @@ defmodule Lanttern.Assessments do alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Assessments.Feedback alias Lanttern.Conversation.Comment + alias Lanttern.Rubrics alias Lanttern.Schools.Student @doc """ @@ -19,6 +20,7 @@ defmodule Lanttern.Assessments do ### Options: `:preloads` – preloads associated data + `:preload_full_rubrics` – boolean, preloads full associated rubrics using `Rubrics.full_rubric_query/0` `:assessment_points_ids` – filter result by provided assessment points ids `:activities_ids` – filter result by provided activities ids `:activities_from_strand_id` – filter result by activities from provided strand id @@ -32,12 +34,22 @@ defmodule Lanttern.Assessments do """ def list_assessment_points(opts \\ []) do AssessmentPoint + |> maybe_preload_full_rubrics(Keyword.get(opts, :preload_full_rubrics)) |> filter_assessment_points(opts) |> order_assessment_points(opts) |> Repo.all() |> maybe_preload(opts) end + defp maybe_preload_full_rubrics(queryable, true) do + from( + ap in queryable, + preload: [rubric: ^Rubrics.full_rubric_query()] + ) + end + + defp maybe_preload_full_rubrics(queryable, nil), do: queryable + defp filter_assessment_points(queryable, opts) do Enum.reduce(opts, queryable, &apply_assessment_points_filter/2) end @@ -57,8 +69,8 @@ defmodule Lanttern.Assessments do ) end - defp apply_assessment_points_filter({:strands_ids, ids}, queryable), - do: from(ap in queryable, where: ap.strand_id in ^ids) + defp apply_assessment_points_filter({:strand_id, id}, queryable), + do: from(ap in queryable, where: ap.strand_id == ^id) defp apply_assessment_points_filter(_, queryable), do: queryable @@ -798,4 +810,55 @@ defmodule Lanttern.Assessments do {:error, "Something went wrong"} end end + + @doc """ + Creates a rubric and link it to the given assessment point. + + It's a wrapper around `Rubrics.create_rubric/2` with an assessment point update + in the same transaction (avoiding "orphans" rubrics). + + If some error happens during rubric creation, it returns a tuple with `:error` and rubric + error changeset. If the error happens elsewhere, it returns a tuple with `:error` and a message. + + ## Options + + - View `Rubrics.create_rubric/2` + + ## Examples + + iex> create_assessment_point_rubric(1, %{field: value}) + {:ok, %AssessmentPointEntry{}} + + iex> create_assessment_point_rubric(2, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + iex> create_assessment_point_rubric(999, %{field: value}) + {:ok, "Assessment point not found"} + + """ + def create_assessment_point_rubric(assessment_point_id, attrs \\ %{}, opts \\ []) do + Repo.transaction(fn -> + rubric = + case Rubrics.create_rubric(attrs, opts) do + {:ok, rubric} -> rubric + {:error, error_changeset} -> Repo.rollback(error_changeset) + end + + assessment_point = + case get_assessment_point(assessment_point_id) do + nil -> Repo.rollback("Assessment point not found") + assessment_point -> assessment_point + end + + case update_assessment_point(assessment_point, %{rubric_id: rubric.id}) do + {:ok, _assessment_point} -> + :ok + + {:error, _error_changeset} -> + Repo.rollback("Error linking rubric to the assessment point") + end + + rubric + end) + end end diff --git a/lib/lanttern/learning_context/strand.ex b/lib/lanttern/learning_context/strand.ex index 8ebec12a..353d0814 100644 --- a/lib/lanttern/learning_context/strand.ex +++ b/lib/lanttern/learning_context/strand.ex @@ -15,6 +15,7 @@ defmodule Lanttern.LearningContext.Strand do field :is_starred, :boolean, virtual: true has_many :activities, Lanttern.LearningContext.Activity + has_many :assessment_points, Lanttern.Assessments.AssessmentPoint many_to_many :subjects, Lanttern.Taxonomy.Subject, join_through: "strands_subjects", diff --git a/lib/lanttern/rubrics.ex b/lib/lanttern/rubrics.ex index 700fe9b6..ba5ec9b2 100644 --- a/lib/lanttern/rubrics.ex +++ b/lib/lanttern/rubrics.ex @@ -38,18 +38,52 @@ defmodule Lanttern.Rubrics do View `get_full_rubric!/1` for more details on descriptors sorting. + ## Options + + - `assessment_points_ids` - filter rubrics by linked assessment points + - `parent_rubrics_ids` - filter differentiation rubrics by parent rubrics + - `students_ids` - filter rubrics by linked students + ## Examples iex> list_full_rubrics() [%Rubric{}, ...] """ - def list_full_rubrics() do + def list_full_rubrics(opts \\ []) do full_rubric_query() + |> filter_rubrics(opts) |> Repo.all() - |> Enum.map(&sort_rubric_descriptors/1) end + defp filter_rubrics(queryable, opts) do + Enum.reduce(opts, queryable, &apply_rubrics_filter/2) + end + + defp apply_rubrics_filter({:rubrics_ids, ids}, queryable), + do: from(ap in queryable, where: ap.id in ^ids) + + defp apply_rubrics_filter({:parent_rubrics_ids, ids}, queryable), + do: from(ap in queryable, where: ap.diff_for_rubric_id in ^ids) + + defp apply_rubrics_filter({:assessment_points_ids, ids}, queryable) do + from( + r in queryable, + join: ap in assoc(r, :assessment_points), + where: ap.id in ^ids + ) + end + + defp apply_rubrics_filter({:students_ids, ids}, queryable) do + from( + r in queryable, + join: s in assoc(r, :students), + where: s.id in ^ids + ) + end + + defp apply_rubrics_filter(_, queryable), do: queryable + @doc """ Search rubrics by criteria. @@ -134,34 +168,31 @@ defmodule Lanttern.Rubrics do def get_full_rubric!(id) do full_rubric_query() |> Repo.get!(id) - |> sort_rubric_descriptors() end - defp full_rubric_query() do + @doc """ + Query used to load rubrics with descriptors + ordered using the following rules: + + - when scale type is "ordinal", we use ordinal value's normalized value + - when scale type is "numeric", we use descriptor's score + """ + def full_rubric_query() do + descriptors_query = + from( + d in RubricDescriptor, + left_join: ov in assoc(d, :ordinal_value), + order_by: [d.score, ov.normalized_value], + preload: [ordinal_value: ov] + ) + from(r in Rubric, join: s in assoc(r, :scale), - left_join: d in assoc(r, :descriptors), - left_join: ov in assoc(d, :ordinal_value), - preload: [:scale, descriptors: :ordinal_value], - group_by: r.id, + preload: [scale: s, descriptors: ^descriptors_query], order_by: r.id ) end - defp sort_rubric_descriptors(rubric) do - Map.update!(rubric, :descriptors, fn descriptors -> - case rubric.scale.type do - "numeric" -> - descriptors - |> Enum.sort_by(& &1.score) - - "ordinal" -> - descriptors - |> Enum.sort_by(& &1.ordinal_value.normalized_value) - end - end) - end - @doc """ Creates a rubric. @@ -291,7 +322,9 @@ defmodule Lanttern.Rubrics do """ def delete_rubric(%Rubric{} = rubric) do - Repo.delete(rubric) + rubric + |> Rubric.changeset(%{}) + |> Repo.delete() end @doc """ @@ -403,6 +436,107 @@ defmodule Lanttern.Rubrics do RubricDescriptor.changeset(rubric_descriptor, attrs) end + @doc """ + Links a differentiation rubric to a student. + + ## Examples + + iex> link_rubric_to_student(%Rubric{}, 1) + :ok + + iex> link_rubric_to_student(%Rubric{}, 1) + {:error, "Error message"} + + """ + def link_rubric_to_student(%Rubric{diff_for_rubric_id: nil}, _student_id), + do: {:error, "Only differentiation rubrics can be linked to students"} + + def link_rubric_to_student(%Rubric{id: rubric_id}, student_id) do + from("differentiation_rubrics_students", + where: [rubric_id: ^rubric_id, student_id: ^student_id], + select: true + ) + |> Repo.one() + |> case do + nil -> + {1, _} = + Repo.insert_all( + "differentiation_rubrics_students", + [[rubric_id: rubric_id, student_id: student_id]] + ) + + :ok + + _ -> + # rubric already linked to student + :ok + end + end + + @doc """ + Unlinks a rubric from a student. + + ## Examples + + iex> unlink_rubric_from_student(%Rubric{}, 1) + :ok + + iex> unlink_rubric_from_student(%Rubric{}, 1) + {:error, "Error message"} + + """ + def unlink_rubric_from_student(%Rubric{id: rubric_id}, student_id) do + from("differentiation_rubrics_students", + where: [rubric_id: ^rubric_id, student_id: ^student_id] + ) + |> Repo.delete_all() + + :ok + end + + @doc """ + Creates a differentiation rubric and link it to the student. + + This function executes `create_rubric/2` and `link_rubric_to_student/2` + inside a single transaction. + + ## Options + + - view `create_rubric/2` for opts + + ## Examples + + iex> create_diff_rubric_for_student(1, %{}) + {:ok, %Rubric{}} + + iex> create_diff_rubric_for_student(1, %{}) + {:error, %Ecto.Changeset{}} + + """ + def create_diff_rubric_for_student(student_id, attrs \\ %{}, opts \\ []) do + Repo.transaction(fn -> + rubric = + case create_rubric(attrs, opts) do + {:ok, rubric} -> rubric + {:error, error_changeset} -> Repo.rollback(error_changeset) + end + + case link_rubric_to_student(rubric, student_id) do + :ok -> + :ok + + {:error, msg} -> + rubric + |> change_rubric(%{}) + |> Ecto.Changeset.add_error(:diff_for_rubric_id, msg) + |> Map.put(:action, :insert) + |> Repo.rollback() + end + + rubric + end) + end + # helpers defp apply_filters(rubrics_query, opts) do diff --git a/lib/lanttern/rubrics/rubric.ex b/lib/lanttern/rubrics/rubric.ex index d2b5f207..9d72c5c6 100644 --- a/lib/lanttern/rubrics/rubric.ex +++ b/lib/lanttern/rubrics/rubric.ex @@ -7,7 +7,14 @@ defmodule Lanttern.Rubrics.Rubric do field :is_differentiation, :boolean, default: false belongs_to :scale, Lanttern.Grading.Scale + belongs_to :parent_rubric, __MODULE__, foreign_key: :diff_for_rubric_id + has_many :descriptors, Lanttern.Rubrics.RubricDescriptor, on_replace: :delete + has_many :differentiation_rubrics, __MODULE__, foreign_key: :diff_for_rubric_id + has_many :assessment_points, Lanttern.Assessments.AssessmentPoint + + many_to_many :students, Lanttern.Schools.Student, + join_through: "differentiation_rubrics_students" timestamps() end @@ -15,8 +22,14 @@ defmodule Lanttern.Rubrics.Rubric do @doc false def changeset(rubric, attrs) do rubric - |> cast(attrs, [:criteria, :is_differentiation, :scale_id]) + |> cast(attrs, [:criteria, :is_differentiation, :diff_for_rubric_id, :scale_id]) |> validate_required([:criteria, :is_differentiation, :scale_id]) |> cast_assoc(:descriptors) + |> foreign_key_constraint( + :diff_for_rubric_id, + name: :rubrics_diff_for_rubric_id_fkey, + message: + "This rubric has linked differentiation rubrics. Deleting it is not allowed, as it would cause data loss." + ) end end diff --git a/lib/lanttern/schools.ex b/lib/lanttern/schools.ex index d781f830..2a0ec93b 100644 --- a/lib/lanttern/schools.ex +++ b/lib/lanttern/schools.ex @@ -381,6 +381,7 @@ defmodule Lanttern.Schools do `:preloads` – preloads associated data `:classes_ids` – filter students by provided list of ids + `:check_diff_rubrics_for_strand_id` - used to check if student has any differentiation rubric for given strand id ## Examples @@ -389,24 +390,49 @@ defmodule Lanttern.Schools do """ def list_students(opts \\ []) do - Student - |> maybe_filter_students_by_class(opts) + from( + s in Student, + order_by: s.name + ) + |> filter_students(opts) + |> load_has_diff_rubric_flag(Keyword.get(opts, :check_diff_rubrics_for_strand_id)) |> Repo.all() |> maybe_preload(opts) end - defp maybe_filter_students_by_class(student_query, opts) do - case Keyword.get(opts, :classes_ids) do - nil -> - student_query + defp filter_students(queryable, opts), + do: Enum.reduce(opts, queryable, &apply_students_filter/2) - classes_ids -> - from( - s in student_query, - join: c in assoc(s, :classes), - where: c.id in ^classes_ids - ) - end + defp apply_students_filter({:classes_ids, ids}, queryable) do + from( + s in queryable, + join: c in assoc(s, :classes), + where: c.id in ^ids + ) + end + + defp apply_students_filter(_, queryable), do: queryable + + defp load_has_diff_rubric_flag(queryable, nil), do: queryable + + defp load_has_diff_rubric_flag(queryable, strand_id) do + has_diff_query = + from( + s in Student, + left_join: dr in assoc(s, :diff_rubrics), + left_join: r in assoc(dr, :parent_rubric), + left_join: ap in Lanttern.Assessments.AssessmentPoint, + on: ap.rubric_id == r.id and ap.strand_id == ^strand_id, + group_by: s.id, + select: %{student_id: s.id, has_diff_rubric: count(ap) > 0} + ) + + from( + s in queryable, + join: d in subquery(has_diff_query), + on: d.student_id == s.id, + select: %{s | has_diff_rubric: d.has_diff_rubric} + ) end @doc """ diff --git a/lib/lanttern/schools/student.ex b/lib/lanttern/schools/student.ex index 43ba51e3..e2fe3b47 100644 --- a/lib/lanttern/schools/student.ex +++ b/lib/lanttern/schools/student.ex @@ -8,6 +8,7 @@ defmodule Lanttern.Schools.Student do schema "students" do field :name, :string field :classes_ids, {:array, :id}, virtual: true + field :has_diff_rubric, :boolean, virtual: true, default: false belongs_to :school, Lanttern.Schools.School @@ -16,6 +17,9 @@ defmodule Lanttern.Schools.Student do on_replace: :delete, preload_order: [asc: :name] + many_to_many :diff_rubrics, Lanttern.Rubrics.Rubric, + join_through: "differentiation_rubrics_students" + timestamps() end diff --git a/lib/lanttern_web/components/navigation_components.ex b/lib/lanttern_web/components/navigation_components.ex index 308be9bf..328ea0b0 100644 --- a/lib/lanttern_web/components/navigation_components.ex +++ b/lib/lanttern_web/components/navigation_components.ex @@ -66,6 +66,7 @@ defmodule LantternWeb.NavigationComponents do attr :person, :map, required: true attr :container_selector, :string, required: true attr :theme, :string, default: "subtle", doc: "subtle | cyan" + attr :on_click, JS, default: %JS{} attr :rest, :global, doc: "aria-controls is required" def person_tab(assigns) do @@ -82,7 +83,8 @@ defmodule LantternWeb.NavigationComponents do person_tab_theme_style(@theme) ]} phx-click={ - JS.hide(to: "#{@container_selector} div[role=tabpanel]") + @on_click + |> JS.hide(to: "#{@container_selector} div[role=tabpanel]") |> JS.set_attribute({"aria-selected", "false"}, to: "#{@container_selector} button[role=tab]" ) diff --git a/lib/lanttern_web/components/rubrics_components.ex b/lib/lanttern_web/components/rubrics_components.ex new file mode 100644 index 00000000..e144576e --- /dev/null +++ b/lib/lanttern_web/components/rubrics_components.ex @@ -0,0 +1,53 @@ +defmodule LantternWeb.RubricsComponents do + use Phoenix.Component + + import LantternWeb.CoreComponents + + alias Lanttern.Rubrics.Rubric + + @doc """ + Renders rubric descriptors. + """ + attr :rubric, Rubric, required: true, doc: "Requires descriptors + ordinal_value preloads" + attr :direction, :string, default: "horizontal", doc: "horizontal | vertical" + attr :class, :any, default: nil + + def rubric_descriptors(%{direction: "vertical"} = assigns) do + ~H""" +
+
+ <%= if descriptor.scale_type == "ordinal" do %> + <.badge style_from_ordinal_value={descriptor.ordinal_value}> + <%= descriptor.ordinal_value.name %> + + <% else %> + <.badge> + <%= descriptor.score %> + + <% end %> + <.markdown class="mt-2" text={descriptor.descriptor} /> +
+
+ """ + end + + def rubric_descriptors(assigns) do + ~H""" +
+
+ <%= if descriptor.scale_type == "numeric" do %> + <.badge theme="dark"><%= descriptor.score %> + <% else %> + <.badge style_from_ordinal_value={descriptor.ordinal_value}> + <%= descriptor.ordinal_value.name %> + + <% end %> + <.markdown + text={descriptor.descriptor} + class="prose-sm flex-1 w-full p-2 border border-ltrn-lighter rounded-sm bg-ltrn-lightest" + /> +
+
+ """ + end +end diff --git a/lib/lanttern_web/live/pages/assessment_points/id/differentiation_rubric_component.ex b/lib/lanttern_web/live/pages/assessment_points/id/differentiation_rubric_component.ex index 84edb1a9..a402de6a 100644 --- a/lib/lanttern_web/live/pages/assessment_points/id/differentiation_rubric_component.ex +++ b/lib/lanttern_web/live/pages/assessment_points/id/differentiation_rubric_component.ex @@ -39,7 +39,6 @@ defmodule LantternWeb.AssessmentPointLive.DifferentiationRubricComponent do <.live_component module={RubricFormComponent} id={"entry-#{@entry.id}"} - action={:new} rubric={ %Rubric{ scale_id: @entry.scale_id, diff --git a/lib/lanttern_web/live/pages/assessment_points/id/rubrics_overlay_component.ex b/lib/lanttern_web/live/pages/assessment_points/id/rubrics_overlay_component.ex index b80dc4a7..280a1295 100644 --- a/lib/lanttern_web/live/pages/assessment_points/id/rubrics_overlay_component.ex +++ b/lib/lanttern_web/live/pages/assessment_points/id/rubrics_overlay_component.ex @@ -7,6 +7,7 @@ defmodule LantternWeb.AssessmentPointLive.RubricsOverlayComponent do alias LantternWeb.AssessmentPointLive.DifferentiationRubricComponent # shared components + import LantternWeb.RubricsComponents alias LantternWeb.Rubrics.RubricFormComponent alias LantternWeb.Rubrics.RubricSearchInputComponent @@ -41,13 +42,13 @@ defmodule LantternWeb.AssessmentPointLive.RubricsOverlayComponent do <.live_component module={RubricFormComponent} id={:new} - action={:new} rubric={ %Rubric{ scale_id: @assessment_point.scale_id, is_differentiation: false } } + link_to_assessment_point_id={@assessment_point.id} hide_diff_and_scale show_buttons on_cancel={JS.push("cancel_create_new", target: @myself)} @@ -70,7 +71,7 @@ defmodule LantternWeb.AssessmentPointLive.RubricsOverlayComponent do

<%= @rubric.criteria %>

Descriptors

- <.rubric_descriptors descriptors={@rubric.descriptors} class="mt-8" /> + <.rubric_descriptors rubric={@rubric} class="mt-8" direction="vertical" />

Differentiation

@@ -96,28 +97,6 @@ defmodule LantternWeb.AssessmentPointLive.RubricsOverlayComponent do """ end - # function components - - attr :descriptors, :list, required: true - attr :class, :any, default: nil - - defp rubric_descriptors(assigns) do - ~H""" -
- <%= if descriptor.scale_type == "ordinal" do %> - <.badge style_from_ordinal_value={descriptor.ordinal_value}> - <%= descriptor.ordinal_value.name %> - - <% else %> - <.badge> - <%= descriptor.score %> - - <% end %> - <.markdown class="mt-2" text={descriptor.descriptor} /> -
- """ - end - # lifecycle def mount(socket) do @@ -143,9 +122,11 @@ defmodule LantternWeb.AssessmentPointLive.RubricsOverlayComponent do do: {:ok, link_rubric_to_assessment_and_notify_parent(socket, rubric_id)} def update(%{action: {RubricFormComponent, {:created, rubric}}}, socket) do + notify_parent({:rubric_linked, rubric.id}) + {:ok, socket - |> link_rubric_to_assessment_and_notify_parent(rubric.id) + |> assign(:rubric, Rubrics.get_full_rubric!(rubric.id)) |> assign(:is_creating_rubric, false)} end diff --git a/lib/lanttern_web/live/pages/rubrics/rubrics_live.ex b/lib/lanttern_web/live/pages/rubrics/rubrics_live.ex index 9a552b81..e0e08ba1 100644 --- a/lib/lanttern_web/live/pages/rubrics/rubrics_live.ex +++ b/lib/lanttern_web/live/pages/rubrics/rubrics_live.ex @@ -4,6 +4,8 @@ defmodule LantternWeb.RubricsLive do alias Lanttern.Rubrics alias Lanttern.Rubrics.Rubric + import LantternWeb.RubricsComponents + # lifecycle def mount(_params, _session, socket) do diff --git a/lib/lanttern_web/live/pages/rubrics/rubrics_live.html.heex b/lib/lanttern_web/live/pages/rubrics/rubrics_live.html.heex index e03c1c61..d5ede147 100644 --- a/lib/lanttern_web/live/pages/rubrics/rubrics_live.html.heex +++ b/lib/lanttern_web/live/pages/rubrics/rubrics_live.html.heex @@ -33,24 +33,7 @@ -
-
- <%= if descriptor.scale_type == "numeric" do %> - <.badge theme="dark"><%= descriptor.score %> - <% else %> - <.badge style_from_ordinal_value={descriptor.ordinal_value}> - <%= descriptor.ordinal_value.name %> - - <% end %> - <.markdown - text={descriptor.descriptor} - class="prose-sm flex-1 w-full p-2 border border-ltrn-lighter rounded-sm bg-ltrn-lightest" - /> -
-
+ <.rubric_descriptors rubric={rubric} /> diff --git a/lib/lanttern_web/live/pages/strands/id/about_component.ex b/lib/lanttern_web/live/pages/strands/id/about_component.ex index 08af9ada..ebe09e5c 100644 --- a/lib/lanttern_web/live/pages/strands/id/about_component.ex +++ b/lib/lanttern_web/live/pages/strands/id/about_component.ex @@ -40,7 +40,7 @@ defmodule LantternWeb.StrandLive.AboutComponent do Under the hood, goals in Lanttern are defined by assessment points linked directly to the strand — when adding goals, we are adding assessment points which, in turn, hold the curriculum items we'll want to assess along the strand course.

-
+

diff --git a/lib/lanttern_web/live/pages/strands/id/assessment_component.ex b/lib/lanttern_web/live/pages/strands/id/assessment_component.ex index edaefefa..783f74d5 100644 --- a/lib/lanttern_web/live/pages/strands/id/assessment_component.ex +++ b/lib/lanttern_web/live/pages/strands/id/assessment_component.ex @@ -6,6 +6,7 @@ defmodule LantternWeb.StrandLive.AssessmentComponent do alias Lanttern.Schools # shared components + alias LantternWeb.StrandLive.StrandRubricsComponent alias LantternWeb.Schools.ClassFilterFormComponent @impl true @@ -90,6 +91,13 @@ defmodule LantternWeb.StrandLive.AssessmentComponent do />

+ <.live_component + module={StrandRubricsComponent} + id={:strand_rubrics} + strand={@strand} + live_action={@live_action} + params={@params} + /> <.slide_over id="classes-filter-overlay"> <:title>Classes <.live_component @@ -211,30 +219,34 @@ defmodule LantternWeb.StrandLive.AssessmentComponent do @impl true def mount(socket) do - {:ok, - socket - |> stream_configure( - :assessment_points, - dom_id: fn - {ap, _index} -> "assessment-point-#{ap.id}" - _ -> "" - end - ) - |> stream_configure( - :students_entries, - dom_id: fn {student, _entries} -> "student-#{student.id}" end - ) - |> assign(:classes, nil) - |> assign(:classes_ids, [])} + socket = + socket + |> stream_configure( + :assessment_points, + dom_id: fn + {ap, _index} -> "assessment-point-#{ap.id}" + _ -> "" + end + ) + |> stream_configure( + :students_entries, + dom_id: fn {student, _entries} -> "student-#{student.id}" end + ) + |> assign(:classes, nil) + |> assign(:classes_ids, []) + + {:ok, socket} end @impl true def update(%{strand: strand} = assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_classes(assigns.params) - |> core_assigns(strand.id)} + socket = + socket + |> assign(assigns) + |> assign_classes(assigns.params) + |> core_assigns(strand.id) + + {:ok, socket} end def update(_assigns, socket), do: {:ok, socket} diff --git a/lib/lanttern_web/live/pages/strands/id/strand_live.ex b/lib/lanttern_web/live/pages/strands/id/strand_live.ex index ee77b1c4..47737c20 100644 --- a/lib/lanttern_web/live/pages/strands/id/strand_live.ex +++ b/lib/lanttern_web/live/pages/strands/id/strand_live.ex @@ -22,10 +22,23 @@ defmodule LantternWeb.StrandLive do # lifecycle @impl true - def mount(_params, _session, socket) do - {:ok, assign(socket, :strand, nil), layout: {LantternWeb.Layouts, :app_logged_in_blank}} + def mount(params, _session, socket) do + socket = + socket + |> assign(:strand, nil) + |> maybe_redirect(params) + + {:ok, socket, layout: {LantternWeb.Layouts, :app_logged_in_blank}} end + # prevent user from navigating directly to nested views + + defp maybe_redirect(%{assigns: %{live_action: live_action}} = socket, params) + when live_action in [:manage_rubric], + do: redirect(socket, to: ~p"/strands/#{params["id"]}?tab=assessment") + + defp maybe_redirect(socket, _params), do: socket + @impl true def handle_params(%{"tab" => "assessment"} = params, _url, socket) do # when in assessment tab, sync classes_ids filter with profile @@ -55,6 +68,10 @@ defmodule LantternWeb.StrandLive do defp set_current_tab(socket, %{"tab" => tab}, _live_action), do: assign(socket, :current_tab, Map.get(@tabs, tab, :about)) + defp set_current_tab(socket, _params, live_action) + when live_action in [:manage_rubric], + do: assign(socket, :current_tab, :assessment) + defp set_current_tab(socket, _params, _live_action), do: assign(socket, :current_tab, :about) diff --git a/lib/lanttern_web/live/pages/strands/id/strand_live.html.heex b/lib/lanttern_web/live/pages/strands/id/strand_live.html.heex index 719d0a3d..aeb9cd1e 100644 --- a/lib/lanttern_web/live/pages/strands/id/strand_live.html.heex +++ b/lib/lanttern_web/live/pages/strands/id/strand_live.html.heex @@ -23,18 +23,18 @@ > About - <:tab - patch={~p"/strands/#{@strand}?#{%{tab: "activities"}}"} - is_current={@current_tab == :activities && "true"} - > - Activities - <:tab patch={~p"/strands/#{@strand}?#{%{tab: "assessment"}}"} is_current={@current_tab == :assessment && "true"} > Assessment + <:tab + patch={~p"/strands/#{@strand}?#{%{tab: "activities"}}"} + is_current={@current_tab == :activities && "true"} + > + Activities + <:tab patch={~p"/strands/#{@strand}?#{%{tab: "notes"}}"} is_current={@current_tab == :notes && "true"} @@ -71,13 +71,6 @@ strand={@strand} live_action={@live_action} /> - <.live_component - :if={@current_tab == :activities} - module={ActivitiesComponent} - id="strand-details-activities" - strand={@strand} - live_action={@live_action} - /> <.live_component :if={@current_tab == :assessment} module={AssessmentComponent} @@ -85,6 +78,14 @@ strand={@strand} params={@params} current_user={@current_user} + live_action={@live_action} + /> + <.live_component + :if={@current_tab == :activities} + module={ActivitiesComponent} + id="strand-details-activities" + strand={@strand} + live_action={@live_action} /> <.live_component :if={@current_tab == :notes} diff --git a/lib/lanttern_web/live/pages/strands/id/strand_rubrics_component.ex b/lib/lanttern_web/live/pages/strands/id/strand_rubrics_component.ex new file mode 100644 index 00000000..e556cfb6 --- /dev/null +++ b/lib/lanttern_web/live/pages/strands/id/strand_rubrics_component.ex @@ -0,0 +1,462 @@ +defmodule LantternWeb.StrandLive.StrandRubricsComponent do + use LantternWeb, :live_component + + alias Lanttern.Assessments + alias Lanttern.Rubrics + alias Lanttern.Rubrics.Rubric + alias Lanttern.Schools + + # shared components + import LantternWeb.RubricsComponents + alias LantternWeb.Rubrics.RubricFormComponent + + @impl true + def render(assigns) do + ~H""" +
+

Goal rubrics

+
+
+
+

+ + <%= assessment_point.curriculum_item.curriculum_component.name %> + + <%= assessment_point.curriculum_item.name %> +

+ <%= if assessment_point.rubric do %> + <.icon_button + name="hero-arrows-pointing-in" + theme="ghost" + rounded + sr_text="collapse" + phx-click={ + JS.toggle(to: "#goal-rubric-#{assessment_point.rubric_id}") + |> JS.toggle( + to: "#strand-assessment-point-#{assessment_point.id} [data-toggle=true]" + ) + } + data-toggle="true" + /> + <.icon_button + name="hero-arrows-pointing-out" + class="hidden" + theme="ghost" + rounded + sr_text="expand" + phx-click={ + JS.toggle(to: "#goal-rubric-#{assessment_point.rubric_id}") + |> JS.toggle( + to: "#strand-assessment-point-#{assessment_point.id} [data-toggle=true]" + ) + } + data-toggle="true" + /> + <% else %> + <.button + theme="ghost" + phx-click={ + JS.push("new_rubric", + value: %{assessment_point_id: assessment_point.id} + ) + } + phx-target={@myself} + > + Add rubric + + <% end %> +
+ <.rubric + :if={assessment_point.rubric} + class="pt-6 border-t border-ltrn-lighter mt-6" + id={"goal-rubric-#{assessment_point.rubric_id}"} + assessment_point_id={assessment_point.id} + rubric={assessment_point.rubric} + on_edit={ + JS.push("edit_rubric", + value: %{assessment_point_id: assessment_point.id}, + target: @myself + ) + } + /> +
+
+
+

Differentiation

+
+ <.person_tab + :for={student <- @students} + aria-controls={"student-#{student.id}-diff-panel"} + person={student} + container_selector="#differentiation-rubrics-section" + on_click={JS.push("load_diff_rubrics", value: %{student_id: student.id})} + phx-target={@myself} + theme={if student.has_diff_rubric, do: "cyan", else: "subtle"} + /> +
+ +
+ <.slide_over + :if={@live_action == :manage_rubric} + id="rubric-form-overlay" + show={true} + on_cancel={JS.patch(~p"/strands/#{@strand}?tab=assessment")} + > + <:title>Rubric +

+ + <%= @assessment_point.curriculum_item.curriculum_component.name %> + + <%= @assessment_point.curriculum_item.name %> +

+

+ Differentiation for <%= @student.name %> +

+ <.live_component + module={RubricFormComponent} + id={@rubric.id || :new} + rubric={@rubric} + link_to_assessment_point_id={@assessment_point && @assessment_point.id} + diff_for_student_id={@student && @student.id} + hide_diff_and_scale + navigate={~p"/strands/#{@strand}?tab=assessment"} + class="mt-6" + /> + <:actions_left :if={@rubric.id}> + <.button + type="button" + theme="ghost" + phx-click="delete_rubric" + phx-target={@myself} + data-confirm="Are you sure?" + > + Delete + + + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#rubric-form-overlay")} + > + Cancel + + <.button + type="submit" + form={"rubric-form-#{@rubric.id || :new}"} + phx-disable-with="Saving..." + > + Save + + + +
+ """ + end + + attr :assessment_point_id, :integer, required: true + attr :class, :any, default: nil + attr :id, :string, required: true + attr :rubric, :any, required: true + attr :on_edit, JS, required: true + + def rubric(assigns) do + ~H""" +
+

+ Rubric criteria: <%= @rubric.criteria %> + +

+ <.rubric_descriptors rubric={@rubric} /> +
+ """ + end + + # lifecycle + + @impl true + def mount(socket) do + socket = + socket + |> assign(:rubric, nil) + |> assign(:curriculum_item, nil) + + {:ok, socket} + end + + @impl true + def update(%{strand: strand} = assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:assessment_points, fn -> + Assessments.list_assessment_points( + strand_id: strand.id, + preload_full_rubrics: true, + preloads: [curriculum_item: :curriculum_component] + ) + end) + |> assign_new(:students, fn -> + case assigns.params do + %{"classes_ids" => classes_ids} when is_list(classes_ids) and classes_ids != [] -> + Schools.list_students( + classes_ids: classes_ids, + check_diff_rubrics_for_strand_id: strand.id + ) + + _ -> + [] + end + end) + + # diff rubrics + socket = + socket + |> assign_new(:assessment_points_with_rubrics, fn -> + socket.assigns.assessment_points + |> Enum.filter(& &1.rubric) + end) + |> assign_new(:students_diff_rubrics_map, fn -> %{} end) + + {:ok, socket} + end + + def update(_assigns, socket), do: {:ok, socket} + + # event handlers + + @impl true + def handle_event("new_rubric", params, socket) do + assessment_point = + socket.assigns.assessment_points + |> Enum.find(&(&1.id == params["assessment_point_id"])) + + socket = + socket + |> assign(:assessment_point, assessment_point) + |> assign(:student, nil) + |> assign(:rubric, %Rubric{scale_id: assessment_point.scale_id}) + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + end + + def handle_event("new_diff_rubric", params, socket) do + assessment_point = + socket.assigns.assessment_points + |> Enum.find(&(&1.id == params["assessment_point_id"])) + + student = + socket.assigns.students + |> Enum.find(&(&1.id == params["student_id"])) + + rubric = + %Rubric{ + scale_id: assessment_point.scale_id, + diff_for_rubric_id: assessment_point.rubric_id + } + + socket = + socket + |> assign(:assessment_point, assessment_point) + |> assign(:student, student) + |> assign(:rubric, rubric) + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + end + + def handle_event("edit_rubric", params, socket) do + assessment_point = + socket.assigns.assessment_points + |> Enum.find(&(&1.id == params["assessment_point_id"])) + + socket = + socket + |> assign(:assessment_point, assessment_point) + |> assign(:student, nil) + |> assign(:rubric, assessment_point.rubric) + |> clear_flash() + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + end + + def handle_event("edit_diff_rubric", params, socket) do + assessment_point = + socket.assigns.assessment_points + |> Enum.find(&(&1.id == params["assessment_point_id"])) + + student = + socket.assigns.students + |> Enum.find(&(&1.id == params["student_id"])) + + rubric = + socket.assigns.students_diff_rubrics_map[student.id][assessment_point.id] + + socket = + socket + |> assign(:assessment_point, assessment_point) + |> assign(:student, student) + |> assign(:rubric, rubric) + |> clear_flash() + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + end + + def handle_event("delete_rubric", _, socket) do + case Rubrics.delete_rubric(socket.assigns.rubric) do + {:ok, _rubric} -> + {:noreply, + push_navigate(socket, to: ~p"/strands/#{socket.assigns.strand}?tab=assessment")} + + {:error, %Ecto.Changeset{errors: [diff_for_rubric_id: {msg, _}]}} -> + socket = + socket + |> put_flash(:error, msg) + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + + {:error, _changeset} -> + socket = + socket + |> put_flash(:error, "Something went wrong") + |> push_patch(to: ~p"/strands/#{socket.assigns.strand}/rubric/manage") + + {:noreply, socket} + end + end + + def handle_event("load_diff_rubrics", params, socket) do + %{"student_id" => student_id} = params + + with nil <- socket.assigns.students_diff_rubrics_map[student_id] do + parent_rubrics_ids = + socket.assigns.assessment_points_with_rubrics + |> Enum.map(& &1.rubric_id) + + # key = parent rubric id + diff_rubrics_map = + Rubrics.list_full_rubrics( + parent_rubrics_ids: parent_rubrics_ids, + students_ids: [student_id] + ) + |> Enum.map(&{&1.diff_for_rubric_id, &1}) + |> Enum.into(%{}) + + # key = assessment point id, value = diff rubric or nil + # we'll use it like `students_diff_rubrics_map[student_id][assessment_point_id]` + student_diff_rubrics_map = + socket.assigns.assessment_points_with_rubrics + |> Enum.map(&{&1.id, diff_rubrics_map[&1.rubric_id]}) + |> Enum.into(%{}) + + students_diff_rubrics_map = + socket.assigns.students_diff_rubrics_map + |> Map.put(student_id, student_diff_rubrics_map) + + {:noreply, assign(socket, :students_diff_rubrics_map, students_diff_rubrics_map)} + else + # if students_diff_rubrics_map[student_id] already exists, just skip + _ -> {:noreply, socket} + end + end +end diff --git a/lib/lanttern_web/live/shared/assessments/assessment_point_form_component.ex b/lib/lanttern_web/live/shared/assessments/assessment_point_form_component.ex index 55a87c77..10c31f3b 100644 --- a/lib/lanttern_web/live/shared/assessments/assessment_point_form_component.ex +++ b/lib/lanttern_web/live/shared/assessments/assessment_point_form_component.ex @@ -190,7 +190,6 @@ defmodule LantternWeb.Assessments.AssessmentPointFormComponent do |> handle_navigation()} {:error, %Ecto.Changeset{} = changeset} -> - IO.inspect(changeset) {:noreply, assign(socket, form: to_form(changeset))} end end diff --git a/lib/lanttern_web/live/shared/rubrics/rubric_form_component.ex b/lib/lanttern_web/live/shared/rubrics/rubric_form_component.ex index 65d28e69..2ea90445 100644 --- a/lib/lanttern_web/live/shared/rubrics/rubric_form_component.ex +++ b/lib/lanttern_web/live/shared/rubrics/rubric_form_component.ex @@ -8,6 +8,7 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do use LantternWeb, :live_component alias Lanttern.Rubrics + alias Lanttern.Assessments alias Lanttern.Grading import LantternWeb.GradingHelpers @@ -22,6 +23,11 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do phx-change="validate" phx-submit="save" > + <.error_block :if={@form.source.action == :insert} class="mb-6"> + Oops, something went wrong! Please check the errors below. + + <.input field={@form[:id]} type="hidden" /> + <.input field={@form[:diff_for_rubric_id]} type="hidden" /> <.input field={@form[:criteria]} type="text" label="Criteria" phx-debounce="1500" /> <.input field={@form[:is_differentiation]} @@ -147,9 +153,9 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do |> assign(:scale, nil) |> assign(:hide_diff_and_scale, false) |> assign(:class, nil) - |> assign(:patch, nil) |> assign(:show_buttons, false) |> assign(:notify_parent, true) + |> assign(:link_to_assessment_point_id, nil) {:ok, socket} end @@ -280,7 +286,13 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do rubric_params |> Map.put_new("descriptors", %{}) - save_rubric(socket, socket.assigns.action, rubric_params) + action = + case rubric_params["id"] do + "" -> :new + _id -> :edit + end + + save_rubric(socket, action, rubric_params) end defp generate_new_descriptors(scale) do @@ -325,7 +337,7 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do {:noreply, socket |> put_flash(:info, "Rubric updated successfully") - |> maybe_push_patch(socket.assigns.patch)} + |> handle_navigation()} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} @@ -333,23 +345,35 @@ defmodule LantternWeb.Rubrics.RubricFormComponent do end defp save_rubric(socket, :new, rubric_params) do - case Rubrics.create_rubric(rubric_params, preloads: :scale) do + case socket.assigns do + %{diff_for_student_id: student_id} when not is_nil(student_id) -> + Rubrics.create_diff_rubric_for_student(student_id, rubric_params) + + %{link_to_assessment_point_id: assessment_point_id} when not is_nil(assessment_point_id) -> + Assessments.create_assessment_point_rubric(assessment_point_id, rubric_params) + + _ -> + Rubrics.create_rubric(rubric_params, preloads: :scale) + end + |> case do {:ok, rubric} -> notify(socket.assigns, {:created, rubric}) {:noreply, socket |> put_flash(:info, "Rubric created successfully") - |> maybe_push_patch(socket.assigns.patch)} + |> handle_navigation()} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} + + {:error, msg} -> + {:noreply, + socket + |> put_flash(:error, msg)} end end - defp maybe_push_patch(socket, nil), do: socket - defp maybe_push_patch(socket, patch_assign), do: push_patch(socket, to: patch_assign) - defp assign_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :form, to_form(changeset)) end diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 7a4bd4ab..1cfb7bbe 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -73,6 +73,7 @@ defmodule LantternWeb.Router do live "/strands/:id/goal/new", StrandLive, :new_goal live "/strands/:id/goal/edit", StrandLive, :edit_goal live "/strands/:id/new_activity", StrandLive, :new_activity + live "/strands/:id/rubric/manage", StrandLive, :manage_rubric live "/strands/activity/:id", ActivityLive, :show live "/strands/activity/:id/edit", ActivityLive, :edit diff --git a/priv/repo/migrations/20240119182019_adjust_assessment_points_rubric_id_fkey_constraint.exs b/priv/repo/migrations/20240119182019_adjust_assessment_points_rubric_id_fkey_constraint.exs new file mode 100644 index 00000000..8d8957fd --- /dev/null +++ b/priv/repo/migrations/20240119182019_adjust_assessment_points_rubric_id_fkey_constraint.exs @@ -0,0 +1,18 @@ +defmodule Lanttern.Repo.Migrations.AdjustAssessmentPointsRubricIdFkeyConstraint do + use Ecto.Migration + + def change do + execute """ + ALTER TABLE assessment_points + DROP CONSTRAINT assessment_points_rubric_id_fkey, + ADD CONSTRAINT assessment_points_rubric_id_fkey FOREIGN KEY (rubric_id) + REFERENCES rubrics (id) ON DELETE SET NULL; + """, + """ + ALTER TABLE assessment_points + DROP CONSTRAINT assessment_points_rubric_id_fkey, + ADD CONSTRAINT assessment_points_rubric_id_fkey FOREIGN KEY (rubric_id) + REFERENCES rubrics (id) ON DELETE CASCADE; + """ + end +end diff --git a/priv/repo/migrations/20240119191821_add_diff_for_rubric_id_to_rubrics.exs b/priv/repo/migrations/20240119191821_add_diff_for_rubric_id_to_rubrics.exs new file mode 100644 index 00000000..3d6f928b --- /dev/null +++ b/priv/repo/migrations/20240119191821_add_diff_for_rubric_id_to_rubrics.exs @@ -0,0 +1,11 @@ +defmodule Lanttern.Repo.Migrations.AddDiffForRubricIdToRubrics do + use Ecto.Migration + + def change do + alter table(:rubrics) do + add :diff_for_rubric_id, references(:rubrics) + end + + create index(:rubrics, [:diff_for_rubric_id]) + end +end diff --git a/priv/repo/migrations/20240119192944_create_differentiation_rubrics_students.exs b/priv/repo/migrations/20240119192944_create_differentiation_rubrics_students.exs new file mode 100644 index 00000000..2d7aebcd --- /dev/null +++ b/priv/repo/migrations/20240119192944_create_differentiation_rubrics_students.exs @@ -0,0 +1,13 @@ +defmodule Lanttern.Repo.Migrations.CreateDifferentiationRubricsStudents do + use Ecto.Migration + + def change do + create table(:differentiation_rubrics_students, primary_key: false) do + add :rubric_id, references(:rubrics, on_delete: :delete_all), null: false + add :student_id, references(:students, on_delete: :delete_all), null: false + end + + create index(:differentiation_rubrics_students, [:rubric_id]) + create unique_index(:differentiation_rubrics_students, [:student_id, :rubric_id]) + end +end diff --git a/test/lanttern/assessments_test.exs b/test/lanttern/assessments_test.exs index fa508050..14fcad4d 100644 --- a/test/lanttern/assessments_test.exs +++ b/test/lanttern/assessments_test.exs @@ -1195,4 +1195,30 @@ defmodule Lanttern.AssessmentsTest do assert %Ecto.Changeset{} = Assessments.change_feedback(feedback) end end + + describe "assessment point rubrics" do + alias Lanttern.Rubrics.Rubric + import Lanttern.AssessmentsFixtures + + test "create_assessment_point_rubric/3 with valid data creates a rubric linked to the given assessment point" do + assessment_point = assessment_point_fixture() + + valid_attrs = %{ + criteria: "some criteria", + scale_id: assessment_point.scale_id + } + + assert {:ok, %Rubric{} = rubric} = + Assessments.create_assessment_point_rubric(assessment_point.id, valid_attrs, + preloads: :scale + ) + + assert rubric.criteria == "some criteria" + assert rubric.scale.id == assessment_point.scale_id + + # get updated assessment point + assessment_point = Assessments.get_assessment_point(assessment_point.id) + assert assessment_point.rubric_id == rubric.id + end + end end diff --git a/test/lanttern/rubrics_test.exs b/test/lanttern/rubrics_test.exs index 061855d6..fd7be3d9 100644 --- a/test/lanttern/rubrics_test.exs +++ b/test/lanttern/rubrics_test.exs @@ -7,6 +7,8 @@ defmodule Lanttern.RubricsTest do describe "rubrics" do alias Lanttern.Rubrics.Rubric + import Lanttern.AssessmentsFixtures + import Lanttern.SchoolsFixtures @invalid_attrs %{criteria: nil, is_differentiation: nil} @@ -37,7 +39,7 @@ defmodule Lanttern.RubricsTest do assert [rubric] == Rubrics.list_rubrics(scale_id: scale.id, is_differentiation: true) end - test "list_full_rubrics/0 returns all rubrics with descriptors preloaded and ordered correctly" do + test "list_full_rubrics/1 returns all rubrics with descriptors preloaded and ordered correctly" do scale = GradingFixtures.scale_fixture(%{type: "ordinal"}) ov_1 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.1}) ov_2 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.2}) @@ -68,6 +70,94 @@ defmodule Lanttern.RubricsTest do assert expected_descriptor_2.id == descriptor_2.id end + test "list_full_rubrics/1 with assessment_points_ids opts returns all filtered rubrics with descriptors preloaded and ordered correctly" do + scale = GradingFixtures.scale_fixture(%{type: "ordinal"}) + ov_1 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.1}) + ov_2 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.2}) + + rubric = rubric_fixture(%{scale_id: scale.id}) + + descriptor_2 = + rubric_descriptor_fixture(%{ + rubric_id: rubric.id, + scale_id: scale.id, + scale_type: scale.type, + ordinal_value_id: ov_2.id + }) + + descriptor_1 = + rubric_descriptor_fixture(%{ + rubric_id: rubric.id, + scale_id: scale.id, + scale_type: scale.type, + ordinal_value_id: ov_1.id + }) + + assessment_point = assessment_point_fixture(%{rubric_id: rubric.id}) + + # extra fixtures for filter test + rubric_fixture(%{scale_id: scale.id}) + assessment_point_fixture(%{rubric_id: rubric.id}) + + [expected] = Rubrics.list_full_rubrics(assessment_points_ids: [assessment_point.id]) + assert expected.id == rubric.id + + [expected_descriptor_1, expected_descriptor_2] = expected.descriptors + assert expected_descriptor_1.id == descriptor_1.id + assert expected_descriptor_2.id == descriptor_2.id + end + + test "list_full_rubrics/1 with students_ids and parent_rubrics_ids opts returns all filtered rubrics with descriptors preloaded and ordered correctly" do + scale = GradingFixtures.scale_fixture(%{type: "ordinal"}) + ov_1 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.1}) + ov_2 = GradingFixtures.ordinal_value_fixture(%{scale_id: scale.id, normalized_value: 0.2}) + + parent_rubric = rubric_fixture(%{scale_id: scale.id}) + rubric = rubric_fixture(%{scale_id: scale.id, diff_for_rubric_id: parent_rubric.id}) + + descriptor_2 = + rubric_descriptor_fixture(%{ + rubric_id: rubric.id, + scale_id: scale.id, + scale_type: scale.type, + ordinal_value_id: ov_2.id + }) + + descriptor_1 = + rubric_descriptor_fixture(%{ + rubric_id: rubric.id, + scale_id: scale.id, + scale_type: scale.type, + ordinal_value_id: ov_1.id + }) + + student = student_fixture() + Rubrics.link_rubric_to_student(rubric, student.id) + + # extra fixtures for filter test + rubric_fixture(%{scale_id: scale.id}) + other_student = student_fixture() + Rubrics.link_rubric_to_student(rubric, other_student.id) + other_parent_rubric = rubric_fixture(%{scale_id: scale.id}) + + other_rubric = + rubric_fixture(%{scale_id: scale.id, diff_for_rubric_id: other_parent_rubric.id}) + + Rubrics.link_rubric_to_student(other_rubric, student.id) + + [expected] = + Rubrics.list_full_rubrics( + students_ids: [student.id], + parent_rubrics_ids: [parent_rubric.id] + ) + + assert expected.id == rubric.id + + [expected_descriptor_1, expected_descriptor_2] = expected.descriptors + assert expected_descriptor_1.id == descriptor_1.id + assert expected_descriptor_2.id == descriptor_2.id + end + test "search_rubrics/2 returns all rubrics matched by search" do _rubric_1 = rubric_fixture(%{criteria: "lorem ipsum xolor sit amet"}) rubric_2 = rubric_fixture(%{criteria: "lorem ipsum dolor sit amet"}) @@ -361,6 +451,74 @@ defmodule Lanttern.RubricsTest do end end + describe "differentiation rubrics" do + alias Lanttern.Rubrics.Rubric + import Lanttern.SchoolsFixtures + + test "link_rubric_to_student/2 links the differentiation rubric to the student" do + student = student_fixture() + parent_rubric = rubric_fixture() + diff_rubric = rubric_fixture(%{diff_for_rubric_id: parent_rubric.id}) + + assert Rubrics.link_rubric_to_student(diff_rubric, student.id) == :ok + expected = Rubrics.get_rubric!(diff_rubric.id, preloads: :students) + assert expected.students == [student] + + # when linking the rubric to a student twice, it should return :ok + # (the function handles this duplication internally, avoiding a second insert) + assert Rubrics.link_rubric_to_student(diff_rubric, student.id) == :ok + expected = Rubrics.get_rubric!(diff_rubric.id, preloads: :students) + assert expected.students == [student] + + # only diff rubrics can be linked to students + assert Rubrics.link_rubric_to_student(parent_rubric, student.id) == + {:error, "Only differentiation rubrics can be linked to students"} + + expected = Rubrics.get_rubric!(parent_rubric.id, preloads: :students) + assert expected.students == [] + end + + test "unlink_rubric_from_student/2 unlinks the rubric from the student" do + student = student_fixture() + rubric = rubric_fixture() + diff_rubric = rubric_fixture(%{diff_for_rubric_id: rubric.id}) + + assert Rubrics.link_rubric_to_student(diff_rubric, student.id) == :ok + expected = Rubrics.get_rubric!(diff_rubric.id, preloads: :students) + assert expected.students == [student] + + assert Rubrics.unlink_rubric_from_student(diff_rubric, student.id) == :ok + expected = Rubrics.get_rubric!(diff_rubric.id, preloads: :students) + assert expected.students == [] + + # when trying to unlink rubrics and students that are not linked + # the function returns ok and nothing changes + assert Rubrics.unlink_rubric_from_student(rubric, student.id) == :ok + end + + test "create_diff_rubric_for_student/3 creates a differentiation rubric and links it the student" do + student = student_fixture() + parent_rubric = rubric_fixture() + scale = GradingFixtures.scale_fixture() + + valid_attrs = %{ + criteria: "diff rubric criteria", + scale_id: scale.id, + diff_for_rubric_id: parent_rubric.id + } + + assert {:ok, %Rubric{} = rubric} = + Rubrics.create_diff_rubric_for_student(student.id, valid_attrs) + + assert rubric.criteria == "diff rubric criteria" + assert rubric.scale_id == scale.id + assert rubric.diff_for_rubric_id == parent_rubric.id + + expected = Rubrics.get_rubric!(rubric.id, preloads: :students) + assert expected.students == [student] + end + end + describe "rubric_descriptors" do alias Lanttern.Rubrics.RubricDescriptor diff --git a/test/lanttern/schools_test.exs b/test/lanttern/schools_test.exs index ec99a57c..1bb4c688 100644 --- a/test/lanttern/schools_test.exs +++ b/test/lanttern/schools_test.exs @@ -361,6 +361,51 @@ defmodule Lanttern.SchoolsTest do end end + test "list_students/1 with diff rubrics opts returns all students as expected" do + student_1 = student_fixture(%{name: "AAA"}) + student_2 = student_fixture(%{name: "BBB"}) + + strand = Lanttern.LearningContextFixtures.strand_fixture() + scale = Lanttern.GradingFixtures.scale_fixture() + parent_rubric = Lanttern.RubricsFixtures.rubric_fixture(%{scale_id: scale.id}) + + Lanttern.Rubrics.create_diff_rubric_for_student(student_1.id, %{ + criteria: "diff rubric for std 1", + scale_id: scale.id, + diff_for_rubric_id: parent_rubric.id + }) + + Lanttern.AssessmentsFixtures.assessment_point_fixture(%{ + strand_id: strand.id, + rubric_id: parent_rubric.id + }) + + # other rubrics for testing + other_strand = Lanttern.LearningContextFixtures.strand_fixture() + other_parent_rubric = Lanttern.RubricsFixtures.rubric_fixture(%{scale_id: scale.id}) + + Lanttern.Rubrics.create_diff_rubric_for_student(student_2.id, %{ + criteria: "diff rubric for std 2", + scale_id: scale.id, + diff_for_rubric_id: other_parent_rubric.id + }) + + Lanttern.AssessmentsFixtures.assessment_point_fixture(%{ + strand_id: other_strand.id, + rubric_id: other_parent_rubric.id + }) + + # assert + [expected_1, expected_2] = + Schools.list_students(check_diff_rubrics_for_strand_id: strand.id) + + assert expected_1.id == student_1.id + assert expected_1.has_diff_rubric + + assert expected_2.id == student_2.id + assert !expected_2.has_diff_rubric + end + test "get_student/2 returns the student with given id" do student = student_fixture() assert Schools.get_student(student.id) == student