Skip to content

Commit

Permalink
[FEATURE] [MER-3189] Learning Objectives Proficiency distribution cha…
Browse files Browse the repository at this point in the history
…rt (#5251)

* wip: unify proficiency summary query

* fix

* rename

* remove unused

* proficiency_per_student_for_objective

* Add test

* temp component changes

* add fn spec

* Basic vega lite chart

* remove unnecessary code (for now)

* move proficiency distribution to another part of the code so we do not calculate it for places we do not need it

* show actual data

* hover tooltip

* transparent background

* case when no proficiencies are available

* remove subobjectives from test

* add test

* bring back subobjectives but for student dashboard

* shrink data stored in socket and only show top-level objectives on initial table in instructor dashboard

* fix colors

* fix test
  • Loading branch information
manelli authored Dec 2, 2024
1 parent 5d2aacf commit 25507c5
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 120 deletions.
167 changes: 94 additions & 73 deletions lib/oli/delivery/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,10 @@ defmodule Oli.Delivery.Metrics do
|> Enum.uniq()

raw_proficiency_per_learning_objective =
raw_proficiency_for_student_per_learning_objective(
section,
student_id,
unique_objective_and_subobjective_ids
raw_proficiency_per_learning_objective(
section.id,
student_id: student_id,
objective_ids: unique_objective_and_subobjective_ids
)

Enum.into(learning_objectives, %{}, fn rev ->
Expand Down Expand Up @@ -796,88 +796,59 @@ defmodule Oli.Delivery.Metrics do
proficiency_range(proficiency_value, first_count)
end

def raw_proficiency_per_learning_objective(%Section{id: section_id}) do
objective_type_id = Oli.Resources.ResourceType.id_for_objective()
@doc """
Retrieves raw proficiency data for a specific section and set of learning objectives,
optionally filtering by a list of objective IDs or a specific student ID.
query =
from(summary in Oli.Analytics.Summary.ResourceSummary,
where:
summary.section_id == ^section_id and
summary.project_id == -1 and
summary.publication_id == -1 and
summary.user_id == -1 and
summary.resource_type_id == ^objective_type_id,
select: {
summary.resource_id,
summary.num_first_attempts_correct,
summary.num_first_attempts,
summary.num_correct,
summary.num_attempts
}
)
## Options
Repo.all(query)
|> Enum.reduce(%{}, fn {objective_id, num_first_attempts_correct, num_first_attempts,
num_correct, num_total},
acc ->
Map.put(
acc,
objective_id,
{num_first_attempts_correct, num_first_attempts, num_correct, num_total}
)
end)
end
* `:objective_ids` - (optional) a list of objective IDs to filter the data by specific objectives.
* `:student_id` - (optional) an ID of a student to filter data by a specific student.
def raw_proficiency_for_student_per_learning_objective(
section,
studend_id,
objective_ids \\ nil
)
## Examples
def raw_proficiency_for_student_per_learning_objective(
%Section{analytics_version: _both, id: section_id},
student_id,
objective_ids
) do
iex> raw_proficiency_per_learning_objective(123, objective_ids: [1, 2, 3], student_id: 42)
# Query result with raw proficiency data for section 123, filtered by objectives [1, 2, 3] and student ID 42
iex> raw_proficiency_per_learning_objective(123)
# Query result with raw proficiency data for all objectives in section 123
"""
@spec raw_proficiency_per_learning_objective(section_id :: integer, opts :: Keyword.t()) :: %{
integer => tuple
}
def raw_proficiency_per_learning_objective(section_id, opts \\ []) do
objective_type_id = Oli.Resources.ResourceType.id_for_objective()

filter_by_objective_id =
case objective_ids do
nil ->
true
maybe_filter_by_objective_id =
if opts[:objective_ids],
do: dynamic([s], s.resource_id in ^opts[:objective_ids]),
else: true

_ ->
dynamic([summary], summary.resource_id in ^objective_ids)
end
maybe_filter_by_student_id =
if opts[:student_id],
do: dynamic([s], s.user_id == ^opts[:student_id]),
else: dynamic([s], s.user_id == -1)

query =
from(summary in Oli.Analytics.Summary.ResourceSummary,
where:
summary.section_id == ^section_id and
summary.project_id == -1 and
summary.publication_id == -1 and
summary.user_id == ^student_id and
summary.resource_type_id == ^objective_type_id,
where: ^filter_by_objective_id,
select: {
summary.resource_id,
from(summary in Oli.Analytics.Summary.ResourceSummary,
where:
summary.section_id == ^section_id and
summary.project_id == -1 and
summary.publication_id == -1 and
summary.resource_type_id == ^objective_type_id,
where: ^maybe_filter_by_objective_id,
where: ^maybe_filter_by_student_id,
select: {
summary.resource_id,
{
summary.num_first_attempts_correct,
summary.num_first_attempts,
summary.num_correct,
summary.num_attempts
}
)

Repo.all(query)
|> Enum.reduce(%{}, fn {objective_id, num_first_attempts_correct, num_first_attempts,
num_correct, num_total},
acc ->
Map.put(
acc,
objective_id,
{num_first_attempts_correct, num_first_attempts, num_correct, num_total}
)
end)
}
)
|> Repo.all()
|> Map.new()
end

@doc """
Expand Down Expand Up @@ -1378,4 +1349,54 @@ defmodule Oli.Delivery.Metrics do
|> Repo.all()
|> Enum.into(%{})
end

@doc """
Retrieves proficiency data for a specific learning objective, aggregated by user.
Returns a map with each key being a student_id and the value being the proficiency.
## Examples
iex> proficiency_per_student_for_objective(1, 42)
# Query result with raw proficiency data for objective 42 in section 1
# => %{123 => "Low", 456 => "Medium", "789" => "Not enough data"}
"""
@spec proficiency_per_student_for_objective(section_id :: integer, objective_id :: integer) ::
%{
integer => String.t()
}
def proficiency_per_student_for_objective(section_id, objective_id) do
objective_type_id = Oli.Resources.ResourceType.id_for_objective()

query =
from(summary in Oli.Analytics.Summary.ResourceSummary,
where:
summary.section_id == ^section_id and
summary.project_id == -1 and
summary.publication_id == -1 and
summary.user_id != -1 and
summary.resource_type_id == ^objective_type_id and
summary.resource_id == ^objective_id,
group_by: summary.user_id,
select:
{summary.user_id,
fragment(
"""
(
(1 * NULLIF(CAST(SUM(?) as float), 0.0001)) +
(0.2 * (NULLIF(CAST(SUM(?) as float), 0.0001) - NULLIF(CAST(SUM(?) as float), 0.0001)))
) /
NULLIF(CAST(SUM(?) as float), 0.0001)
""",
summary.num_first_attempts_correct,
summary.num_first_attempts,
summary.num_first_attempts_correct,
summary.num_first_attempts
), sum(summary.num_first_attempts)}
)

Repo.all(query)
|> Enum.into(%{}, fn {student_id, proficiency, num_first_attempts} ->
{student_id, proficiency_range(proficiency, num_first_attempts)}
end)
end
end
27 changes: 18 additions & 9 deletions lib/oli/delivery/sections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4774,10 +4774,10 @@ defmodule Oli.Delivery.Sections do
proficiency_per_learning_objective =
case student_id do
nil ->
Metrics.raw_proficiency_per_learning_objective(section)
Metrics.raw_proficiency_per_learning_objective(section.id)

student_id ->
Metrics.raw_proficiency_for_student_per_learning_objective(section, student_id)
Metrics.raw_proficiency_per_learning_objective(section.id, student_id: student_id)
end

# get the minimal fields for all objectives from the database
Expand Down Expand Up @@ -4832,11 +4832,18 @@ defmodule Oli.Delivery.Sections do
top_level_aggregation =
Enum.reduce(top_level_objectives, %{}, fn obj, map ->
aggregation =
Enum.reduce(obj.children, {0, 0}, fn child, {correct, total} ->
{child_correct, child_total, _, _} =
Enum.reduce(obj.children, {0, 0, 0, 0}, fn child,
{first_attempts_correct, first_attempts,
correct, attempts} ->
{child_first_attempts_correct, child_first_attempts, child_correct, child_attempts} =
Map.get(proficiency_per_learning_objective, child, {0, 0, 0, 0})

{correct + child_correct, total + child_total}
{
first_attempts_correct + child_first_attempts_correct,
first_attempts + child_first_attempts,
correct + child_correct,
attempts + child_attempts
}
end)

Map.put(map, obj.resource_id, aggregation)
Expand All @@ -4849,11 +4856,12 @@ defmodule Oli.Delivery.Sections do
case Map.has_key?(parent_map, objective.resource_id) do
# this is a top-level objective
false ->
{correct, total, _, _} =
{_, _, correct, total} =
Map.get(proficiency_per_learning_objective, objective.resource_id, {0, 0, 0, 0})

objective =
Map.merge(objective, %{
section_id: section.id,
objective: objective.title,
objective_resource_id: objective.resource_id,
student_proficiency_obj: Metrics.proficiency_range(calc.(correct, total), total),
Expand All @@ -4862,21 +4870,22 @@ defmodule Oli.Delivery.Sections do
student_proficiency_subobj: ""
})

{parent_correct, parent_total} =
Map.get(top_level_aggregation, objective.resource_id, {0, 0})
{_, _, parent_correct, parent_total} =
Map.get(top_level_aggregation, objective.resource_id, {0, 0, 0, 0})

sub_objectives =
Enum.map(objective.children, fn child ->
sub_objective = Map.get(lookup_map, child)

{correct, total, _, _} =
{_, _, correct, total} =
Map.get(
proficiency_per_learning_objective,
sub_objective.resource_id,
{0, 0, 0, 0}
)

Map.merge(sub_objective, %{
section_id: section.id,
objective: objective.title,
objective_resource_id: objective.resource_id,
student_proficiency_obj:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
alias OliWeb.Common.Params
alias OliWeb.Common.SearchInput
alias OliWeb.Components.Delivery.CardHighlights
alias Oli.Delivery.Sections.Section
alias OliWeb.Delivery.LearningObjectives.ObjectivesTableModel
alias OliWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.JS
Expand Down Expand Up @@ -35,15 +34,17 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
%{
objectives_tab: objectives_tab,
params: params,
section: section
section_slug: section_slug,
v25_migration: v25_migration
} = assigns,
socket
) do
params = decode_params(params)

{total_count, rows} = apply_filters(objectives_tab.objectives, params)
{total_count, rows} =
apply_filters(objectives_tab.objectives, params, assigns[:patch_url_type])

{:ok, objectives_table_model} = ObjectivesTableModel.new(rows)
{:ok, objectives_table_model} = ObjectivesTableModel.new(rows, assigns[:patch_url_type])

objectives_table_model =
Map.merge(objectives_table_model, %{
Expand Down Expand Up @@ -94,9 +95,9 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
params: params,
student_id: assigns[:student_id],
patch_url_type: assigns.patch_url_type,
section_slug: section.slug,
section_slug: section_slug,
units_modules: objectives_tab.filter_options,
filter_disabled?: filter_by_module_disabled?(section),
filter_disabled?: filter_by_module_disabled?(v25_migration),
view: assigns[:view],
proficiency_options: proficiency_options,
selected_proficiency_options: selected_proficiency_options,
Expand Down Expand Up @@ -558,7 +559,16 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
end)
end

defp apply_filters(objectives, params) do
defp apply_filters(objectives, params, :instructor_dashboard) do
# Only show top-level objectives on initial table
objectives
|> Enum.filter(fn o -> o.resource_id == o.objective_resource_id end)
|> do_apply_filters(params)
end

defp apply_filters(objectives, params, _), do: do_apply_filters(objectives, params)

defp do_apply_filters(objectives, params) do
objectives =
objectives
|> maybe_filter_by_text(params.text_search)
Expand All @@ -567,7 +577,14 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
|> maybe_filter_by_card(params.selected_card_value)
|> sort_by(params.sort_by, params.sort_order)

{length(objectives), objectives |> Enum.drop(params.offset) |> Enum.take(params.limit)}
total_count = length(objectives)

rows =
objectives
|> Enum.drop(params.offset)
|> Enum.take(params.limit)

{total_count, rows}
end

defp sort_by(objectives, sort_by, sort_order) do
Expand Down Expand Up @@ -663,11 +680,9 @@ defmodule OliWeb.Components.Delivery.LearningObjectives do
}
end

_docp = """
Returns true if the filter by module feature is disabled for the section.
This happens when the contained objectives for the section were not yet created.
"""

defp filter_by_module_disabled?(%Section{v25_migration: :done}), do: false
# Returns true if the filter by module feature is disabled for the section.
# This happens when the contained objectives for the section were not yet created.
defp filter_by_module_disabled?(v25_migration)
defp filter_by_module_disabled?(:done), do: false
defp filter_by_module_disabled?(_), do: true
end
Loading

0 comments on commit 25507c5

Please sign in to comment.