Skip to content

Commit

Permalink
[FEATURE] [MER-4139] Restore user progress tool (#5335)
Browse files Browse the repository at this point in the history
* admin tool to restore affected progress

* ensure true user enrolled in section

* refine conflict graded page handling

* add warning, and display user details in tabular form

* finalize, docs

* Auto format

---------

Co-authored-by: darrensiegel <[email protected]>
  • Loading branch information
darrensiegel and darrensiegel authored Jan 6, 2025
1 parent ebcf40a commit 11a71ca
Show file tree
Hide file tree
Showing 2 changed files with 312 additions and 0 deletions.
309 changes: 309 additions & 0 deletions lib/oli_web/live/admin/restore_user_progress.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
defmodule OliWeb.Admin.RestoreUserProgress do
alias Oli.Delivery.Attempts.Core.ResourceAttempt
use OliWeb, :live_view

import Ecto.Query
alias Oli.Repo
alias Oli.Accounts.User
alias Oli.Delivery.Attempts.Core.ResourceAccess
alias Oli.Delivery.Sections.Enrollment
alias Oli.Resources.Revision

def mount(_, _, socket) do
{:ok,
assign(socket,
title: "Restore User Progress",
email: nil,
result: "",
true_user: nil,
all_users: [],
changes: []
)}
end

def render(assigns) do
~H"""
<div class="alert alert-danger" role="alert">
<strong>Warning!</strong>
This is a developer tool and should only be used by developers. This can result in data loss.
</div>
<div>
<input
type="text"
id="email"
phx-hook="TextInputListener"
phx-value-change="email"
class="w-full p-2 border border-gray-300 rounded"
/>
<button
phx-click="preview"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Preview Changes
</button>
<button
phx-click="commit"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Commit Changes
</button>
<div><%= @result %></div>
<h3>User Records</h3>
<table>
<thead>
<tr>
<th></th>
<th>ID</th>
<th>SUB</th>
<th>INSTITUTION</th>
<th>INSERTED</th>
</tr>
</thead>
<tbody>
<%= for user <- @all_users do %>
<tr>
<td>
<%= if user.id == @true_user.id do
"TRUE"
else
""
end %>
</td>
<td><%= user.id %></td>
<td><%= user.sub %></td>
<td><%= user.lti_institution_id %></td>
<td><%= user.inserted_at %></td>
</tr>
<% end %>
</tbody>
</table>
<h3>Changes</h3>
<%= for change <- @changes do %>
<div><%= inspect(change) %></div>
<% end %>
</div>
"""
end

def handle_event("email", %{"value" => email}, socket) do
{:noreply, assign(socket, email: email)}
end

def handle_event("preview", _, socket) do
{all_users, true_user, changes} = preview(socket.assigns.email)
{:noreply, assign(socket, all_users: all_users, true_user: true_user, changes: changes)}
end

def handle_event("commit", _, socket) do
result =
case Repo.transaction(fn -> process(socket.assigns.true_user, socket.assigns.changes) end) do
{:ok, _} -> "success"
{_, reason} -> "failed: #{inspect(reason)}"
end

{:noreply, assign(socket, changes: [], result: result)}
end

defp preview(email) do
# Get all of the LMS users for this email, sorting so the most recently
# created user is first. This represents the true user, the user record
# created at last launch.
[true_user | rest] = fetch_users(email)
all_users = [true_user | rest]

enrollments = Enum.map(all_users, & &1.id) |> fetch_enrollments()

# We process the progress restoration on a per section basis, being careful
# to only consider the users that are actually enrolled in the section.
changes =
Enum.group_by(enrollments, & &1.section_id)
|> Enum.map(fn {section_id, enrollments} ->
# If the true user is not enrolled in the section OR if there is only a single enrollment
# then there is nothing to restore, as this is likely a previous semester section.
case Enum.any?(enrollments, fn e -> e.user_id == true_user.id end) and
Enum.count(enrollments) > 1 do
true ->
enrolled_user_ids = Enum.map(enrollments, & &1.user_id) |> MapSet.new()

enrolled_users =
Enum.reduce(all_users, [], fn user, acc ->
if MapSet.member?(enrolled_user_ids, user.id) do
[user | acc]
else
acc
end
end)

fetch_resource_accesses(section_id, Enum.map(enrolled_users, & &1.id))
|> preview_section_changes(true_user)

false ->
nil
end
end)
|> List.flatten()
|> Enum.filter(&(&1 != nil))

{all_users, true_user, changes}
end

defp preview_section_changes(resource_accesses, true_user) do
Enum.group_by(resource_accesses, & &1.resource_id)
|> Enum.map(fn {resource_id, resource_accesses} ->
# Look up in the most recent revision to see if this is graded or practice
is_graded = is_graded?(resource_id)

access_for_true = access_for(resource_accesses, true_user)

other_accesses =
Enum.filter(resource_accesses, fn ra ->
is_nil(access_for_true) or ra.id != access_for_true.id
end)

# Here now is the core logic for progress restoration. We handle graded and
# practice pages differently but both driven from an initial step where we look
# to see if the true user has a resource access record for this resource. For graded
# pages, having a resource access can simply mean that they visited the prologue page - but
# of course it can also mean that they have completed one or more attempts OR even that
# they have their first attempt in progress. For practice pages, having a resource access
# simply means that they have viewed the page.

if is_graded do
# Is there a resource access for the true user?
case access_for_true do
# Case 1: No access for the true user, so we need to find the most recently scored access
# and make it the true user's access
nil ->
get_most_recently_scored(other_accesses)

# Case 2: There is an access for the true user, but there isn't a score. In this case
# we need to check if another access does have a score and if so, we need to shift
# the score and all resource attempts to the true user's access record.
%{score: nil, id: to_shift_to} ->
case get_most_recently_scored(other_accesses) do
nil -> nil
to_shift_from -> {to_shift_from, to_shift_to}
end

# Case 3: This must be a case where there is a score on the true user's access
# so we do nothing
_ ->
nil
end
else
most_recent_other_id =
case other_accesses do
[] -> nil
[one | _rest] -> one.id
end

# Is there a resource access for the true user?
case access_for_true do
# Case 1: No access for the true user, so we take the most recent other access
nil -> most_recent_other_id
# Case 2: There is an access for the true user, so we do nothing
_ -> nil
end
end
end)
end

defp process(true_user, changes) do
Enum.map(changes, fn change ->
case change do
{shift_from, shift_to} ->
# We need to edit the shift_to access score and out_of to match the shift_from
access_to_edit = Repo.get!(ResourceAccess, shift_to)
access_to_read = Repo.get!(ResourceAccess, shift_from)

changeset =
ResourceAccess.changeset(access_to_edit, %{
score: access_to_read.score,
out_of: access_to_read.out_of
})

{:ok, _} = Repo.update(changeset)

# We also need to shift all resource attempts to the shift_to access record BUT only
# if there are no attempts for the shift_to access record
if fetch_resource_attempts(shift_to) == [] do
resource_attempts_from = fetch_resource_attempts(shift_from)

Enum.map(resource_attempts_from, fn ra ->
changeset = ResourceAttempt.changeset(ra, %{resource_access_id: shift_to})
{:ok, _} = Repo.update(changeset)
end)
end

id ->
access_to_edit = Repo.get!(ResourceAccess, id)
changeset = ResourceAccess.changeset(access_to_edit, %{user_id: true_user.id})

{:ok, _} = Repo.update(changeset)
end
end)
end

defp get_most_recently_scored(resource_accesses) do
case Enum.filter(resource_accesses, fn ra -> !is_nil(ra.score) end) do
[] -> nil
[one | _rest] -> one.id
end
end

defp access_for(resource_accesses, user) do
Enum.find(resource_accesses, fn ra -> ra.user_id == user.id end)
end

defp fetch_users(email) do
query =
from u in User,
where: u.email == ^email and u.independent_learner == false,
order_by: [desc: :inserted_at]

Repo.all(query)
end

defp fetch_enrollments(user_ids) do
query =
from e in Enrollment,
where: e.user_id in ^user_ids

Repo.all(query)
end

defp fetch_resource_accesses(section_id, user_ids) do
query =
from ra in ResourceAccess,
where: ra.section_id == ^section_id and ra.user_id in ^user_ids,
order_by: [desc: :inserted_at]

Repo.all(query)
end

defp fetch_resource_attempts(resource_access_id) do
query =
from ra in ResourceAttempt,
where: ra.resource_access_id == ^resource_access_id

Repo.all(query)
end

defp is_graded?(resource_id) do
query =
from r in Revision,
where: r.resource_id == ^resource_id,
limit: 1,
select: r.graded

Repo.one(query)
end
end
3 changes: 3 additions & 0 deletions lib/oli_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,9 @@ defmodule OliWeb.Router do
pipe_through([:reject_content_or_account_admin])
get("/activity_review", ActivityReviewController, :index)
live("/part_attempts", Admin.PartAttemptsView)

live("/restore_progress", Admin.RestoreUserProgress)

live("/xapi", Admin.UploadPipelineView)
get("/spot_check/:activity_attempt_id", SpotCheckController, :index)

Expand Down

0 comments on commit 11a71ca

Please sign in to comment.