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""" +
<%= @rubric.criteria %>
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 />
+ + <%= 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 %> ++ + <%= assessment_point.curriculum_item.curriculum_component.name %> + + <%= assessment_point.curriculum_item.name %> +
+ <%= if @students_diff_rubrics_map[student.id][assessment_point.id] do %> + <.icon_button + name="hero-arrows-pointing-in" + theme="ghost" + rounded + sr_text="collapse" + phx-click={ + JS.toggle( + to: + "#goal-rubric-#{@students_diff_rubrics_map[student.id][assessment_point.id].id}" + ) + |> JS.toggle( + to: + "#strand-assessment-point-#{student.id}-#{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-#{@students_diff_rubrics_map[student.id][assessment_point.id].id}" + ) + |> JS.toggle( + to: + "#strand-assessment-point-#{student.id}-#{assessment_point.id} [data-toggle=true]" + ) + } + data-toggle="true" + /> + <% else %> + <.button + theme="ghost" + phx-click={ + JS.push("new_diff_rubric", + value: %{ + assessment_point_id: assessment_point.id, + student_id: student.id + } + ) + } + phx-target={@myself} + > + Add diff + + <% end %> ++ + <%= @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 + + + ++ Rubric criteria: <%= @rubric.criteria %> + +
+ <.rubric_descriptors rubric={@rubric} /> +