Skip to content
This repository has been archived by the owner on Jan 30, 2025. It is now read-only.

Commit

Permalink
Make UserSelection schema more flexible (#194)
Browse files Browse the repository at this point in the history
  • Loading branch information
Will Ceolin authored Jul 30, 2024
1 parent eede215 commit 6158f00
Show file tree
Hide file tree
Showing 23 changed files with 346 additions and 193 deletions.
47 changes: 16 additions & 31 deletions lib/content/content_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -621,9 +621,8 @@ defmodule Zoonk.Content do
defp maybe_preload_user_selections(query, user_id, true) do
user_selections_query =
UserSelection
|> join(:left, [us], o in assoc(us, :option))
|> where([us, o], us.user_id == ^user_id)
|> where([us, o], not is_nil(us.answer) or not o.correct?)
|> where([us], us.user_id == ^user_id)
|> where([us], not is_nil(us.answer) or us.total > us.correct)
|> preload([:step, :option])

preload(query, [l], user_selections: ^user_selections_query)
Expand Down Expand Up @@ -818,7 +817,7 @@ defmodule Zoonk.Content do
end

@doc """
Count how many times all options from a lesson step have been selected.
Count how many times each option from a lesson step have been selected.
## Examples
Expand All @@ -835,24 +834,6 @@ defmodule Zoonk.Content do
|> Repo.all()
end

@doc """
Get the count of steps containing at least one option.
## Examples
iex> count_lesson_steps_with_options(lesson_id)
1
"""
@spec count_lesson_steps_with_options(non_neg_integer()) :: non_neg_integer()
def count_lesson_steps_with_options(lesson_id) do
LessonStep
|> where([ls], ls.lesson_id == ^lesson_id)
|> preload(:options)
|> Repo.all()
|> Enum.filter(fn ls -> Enum.count(ls.options) > 0 end)
|> length()
end

@doc """
Update lesson steps order.
Expand Down Expand Up @@ -993,12 +974,9 @@ defmodule Zoonk.Content do
def list_user_selections_by_lesson(user_id, lesson_id, steps) do
UserSelection
|> where([us], us.user_id == ^user_id)
|> join(:inner, [us], so in assoc(us, :option))
|> join(:inner, [us, so], ls in assoc(so, :lesson_step))
|> where([us, so, ls], ls.lesson_id == ^lesson_id)
|> where([us], us.lesson_id == ^lesson_id)
|> order_by([us], desc: us.inserted_at)
|> limit(^steps)
|> preload([:option])
|> Repo.all()
end

Expand Down Expand Up @@ -1090,15 +1068,22 @@ defmodule Zoonk.Content do
"""
@spec mark_lesson_as_completed(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: user_lesson_changeset()
def mark_lesson_as_completed(user_id, lesson_id, duration) do
steps = count_lesson_steps_with_options(lesson_id)
steps = count_lesson_steps(lesson_id)
selections = list_user_selections_by_lesson(user_id, lesson_id, steps)
correct = get_correct_selections(selections)
attrs = %{user_id: user_id, lesson_id: lesson_id, attempts: 1, correct: correct, total: steps, duration: duration}
correct = sum_correct_selections(selections)
total = sum_total_selections(selections)
attrs = %{user_id: user_id, lesson_id: lesson_id, attempts: 1, correct: correct, total: total, duration: duration}
add_user_lesson(attrs)
end

defp get_correct_selections(selections) do
Enum.count(selections, fn selection -> selection.option.correct? end)
# sum all correct answers a user has given in a lesson
defp sum_correct_selections(selections) do
Enum.reduce(selections, 0, fn selection, acc -> acc + selection.correct end)
end

# sum all total answers a user has given in a lesson
defp sum_total_selections(selections) do
Enum.reduce(selections, 0, fn selection, acc -> acc + selection.total end)
end

@doc """
Expand Down
43 changes: 25 additions & 18 deletions lib/content/course_live/lesson_play.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule ZoonkWeb.Live.LessonPlay do
@moduledoc false
use ZoonkWeb, :live_view

import Zoonk.Shared.Utilities, only: [boolean_to_integer: 1]
import ZoonkWeb.Components.Content.LessonStep

alias Zoonk.Accounts.User
Expand Down Expand Up @@ -33,34 +34,34 @@ defmodule ZoonkWeb.Live.LessonPlay do

@impl Phoenix.LiveView
def handle_event("next", %{"selected_option" => selected_option}, socket) when is_nil(socket.assigns.selected_option) do
%{current_user: user, lesson: lesson, current_step: step, step_start: step_start} = socket.assigns
%{current_step: step} = socket.assigns

step_duration = DateTime.diff(DateTime.utc_now(), step_start, :second)
option_id = String.to_integer(selected_option)
attrs = %{user_id: user.id, option_id: option_id, lesson_id: lesson.id, step_id: step.id, duration: step_duration}

case Content.add_user_selection(attrs) do
{:ok, _} ->
selected_option = get_option(step.options, option_id)

socket =
socket
|> maybe_play_sound_effect(selected_option.correct?)
|> assign(:selected_option, selected_option)
selected_option = get_option(step.options, option_id)

{:noreply, socket}
socket =
socket
|> maybe_play_sound_effect(selected_option.correct?)
|> assign(:selected_option, selected_option)

{:error, _} ->
{:noreply, put_flash(socket, :error, dgettext("courses", "Unable to select option"))}
end
{:noreply, socket}
end

def handle_event("next", params, socket) do
%{current_user: user, lesson: lesson, current_step: current_step, step_start: step_start} = socket.assigns
%{current_user: user, lesson: lesson, current_step: current_step, step_start: step_start, selected_option: selected_option} = socket.assigns
step_duration = DateTime.diff(DateTime.utc_now(), step_start, :second)
next_step = Content.get_next_step(lesson, current_step.order)

attrs = %{user_id: user.id, lesson_id: lesson.id, step_id: current_step.id, answer: params["answer"], duration: step_duration}
attrs = %{
user_id: user.id,
correct: get_correct_value(selected_option),
total: 1,
lesson_id: lesson.id,
step_id: current_step.id,
option_id: get_selected_option_id(selected_option),
answer: [params["answer"]],
duration: step_duration
}

case Content.add_user_selection(attrs) do
{:ok, _} ->
Expand Down Expand Up @@ -96,6 +97,12 @@ defmodule ZoonkWeb.Live.LessonPlay do

defp get_option(options, option_id), do: Enum.find(options, &(&1.id == option_id))

defp get_selected_option_id(nil), do: nil
defp get_selected_option_id(selected_option), do: selected_option.id

defp get_correct_value(nil), do: 1
defp get_correct_value(selected_option), do: boolean_to_integer(selected_option.correct?)

defp user_selected_wrong_option?(%StepOption{correct?: false} = selected, option) when selected.id == option.id, do: true
defp user_selected_wrong_option?(_selected, _option), do: false

Expand Down
46 changes: 42 additions & 4 deletions lib/content/user_selection_schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,33 @@ defmodule Zoonk.Content.UserSelection do
@moduledoc """
User selection schema.
Keeps track of user selections when playing courses. For example: which steps they played and what options they've selected.
Keeps track of user selections when playing courses.
For example: which steps they played and what options they've selected.
## Fields
* `:duration` - How long it took for a user to complete a step.
* `:answer` - Users can select multiple answers for some steps.
* `:correct` - The count of correct answers.
* `:total` - The total amount of possible correct answers.
For some steps like drag-and-drop and word search, the user can select multiple answers.
This means a selection can have both correct and incorrect answers in the same step.
By keeping track of the `correct` and `total` number of possible answers,
we can easily calculate a lesson score by querying the `user_selections` table.
## Associations
* `:user` - The user making the selection.
* `:lesson` - The lesson associated with the selection.
* `:step` - The step in the lesson.
* `:option` - The option selected in the step.
"""
use Ecto.Schema

import Ecto.Changeset
import ZoonkWeb.Gettext

alias Zoonk.Accounts.User
alias Zoonk.Content.Lesson
Expand All @@ -17,7 +39,9 @@ defmodule Zoonk.Content.UserSelection do

schema "user_selections" do
field :duration, :integer
field :answer, :string
field :answer, {:array, :string}
field :correct, :integer
field :total, :integer

belongs_to :user, User
belongs_to :lesson, Lesson
Expand All @@ -31,7 +55,21 @@ defmodule Zoonk.Content.UserSelection do
@spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t()
def changeset(user_selection, attrs \\ %{}) do
user_selection
|> cast(attrs, [:answer, :duration, :user_id, :option_id, :lesson_id, :step_id])
|> validate_required([:duration, :user_id, :lesson_id, :step_id])
|> cast(attrs, [:answer, :correct, :total, :duration, :user_id, :option_id, :lesson_id, :step_id])
|> validate_required([:duration, :correct, :total, :user_id, :lesson_id, :step_id])
|> validate_correct_total()
end

# We need to ensure that the `correct` value is not greater than the `total` value.
defp validate_correct_total(changeset) do
correct = get_field(changeset, :correct)
total = get_field(changeset, :total)
validate_correct_total(changeset, correct, total)
end

defp validate_correct_total(changeset, correct, total) when correct > total do
add_error(changeset, :correct, dgettext("errors", "cannot be larger than total"))
end

defp validate_correct_total(changeset, _correct, _total), do: changeset
end
2 changes: 1 addition & 1 deletion lib/dashboard/courses/course_user_view.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<div class="w-fit rounded-2xl bg-gray-50 p-4 leading-6 text-gray-900">
<%= step.content %>

<p :if={selection.answer} class="mt-2 font-semibold text-indigo-900"><%= selection.answer %></p>
<p :if={selection.answer} class="mt-2 font-semibold text-indigo-900"><%= hd(selection.answer) %></p>

<p :if={selection.option} class="mt-2 font-semibold text-pink-700"><%= selection.option.title %></p>
</div>
Expand Down
15 changes: 15 additions & 0 deletions lib/shared/utilities.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ defmodule Zoonk.Shared.Utilities do
def string_to_boolean("false"), do: false
def string_to_boolean(_str), do: true

@doc """
Converts a boolean to an integer.
## Examples
iex> boolean_to_integer(true)
1
iex> boolean_to_integer(false)
0
"""
@spec boolean_to_integer(boolean()) :: integer()
def boolean_to_integer(true), do: 1
def boolean_to_integer(false), do: 0

@doc """
Generates a random password.
Expand Down
9 changes: 2 additions & 7 deletions priv/gettext/courses.pot
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,11 @@ msgstr ""
msgid "There's room for improvement"
msgstr ""

#: lib/content/course_live/lesson_play.ex:91
#: lib/content/course_live/lesson_play.ex:92
#, elixir-autogen, elixir-format
msgid "Unable to complete lesson"
msgstr ""

#: lib/content/course_live/lesson_play.ex:54
#, elixir-autogen, elixir-format
msgid "Unable to select option"
msgstr ""

#: lib/content/course_live/lesson_completed.ex:30
#, elixir-autogen, elixir-format
msgid "Very good!"
Expand Down Expand Up @@ -151,7 +146,7 @@ msgstr ""
msgid "No courses"
msgstr ""

#: lib/content/course_live/lesson_play.ex:77
#: lib/content/course_live/lesson_play.ex:78
#, elixir-autogen, elixir-format
msgid "Unable to send answer"
msgstr ""
Expand Down
9 changes: 2 additions & 7 deletions priv/gettext/de/LC_MESSAGES/courses.po
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,11 @@ msgstr "Das ist nicht richtig."
msgid "There's room for improvement"
msgstr "Es gibt Raum für Verbesserungen"

#: lib/content/course_live/lesson_play.ex:91
#: lib/content/course_live/lesson_play.ex:92
#, elixir-autogen, elixir-format
msgid "Unable to complete lesson"
msgstr "Lektion kann nicht abgeschlossen werden"

#: lib/content/course_live/lesson_play.ex:54
#, elixir-autogen, elixir-format
msgid "Unable to select option"
msgstr "Option kann nicht ausgewählt werden"

#: lib/content/course_live/lesson_completed.ex:30
#, elixir-autogen, elixir-format
msgid "Very good!"
Expand Down Expand Up @@ -151,7 +146,7 @@ msgstr "Beginnen Sie mit der Teilnahme an einem Kurs."
msgid "No courses"
msgstr "Keine Kurse"

#: lib/content/course_live/lesson_play.ex:77
#: lib/content/course_live/lesson_play.ex:78
#, elixir-autogen, elixir-format
msgid "Unable to send answer"
msgstr "Senden der Antwort fehlgeschlagen"
Expand Down
7 changes: 6 additions & 1 deletion priv/gettext/de/LC_MESSAGES/errors.po
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ msgstr "Ihre Anmeldung muss noch genehmigt werden"
msgid "must start with https://"
msgstr "muss mit https:// beginnen"

#: lib/content/content_context.ex:757
#: lib/content/content_context.ex:756
#, elixir-autogen, elixir-format, fuzzy
msgid "cannot delete the only step"
msgstr "Der einzige Schritt kann nicht gelöscht werden"
Expand Down Expand Up @@ -225,3 +225,8 @@ msgstr "Sie haben zu viele Dateien ausgewählt"
#, elixir-autogen, elixir-format
msgid "Failed to remove file"
msgstr "Datei konnte nicht gelöscht werden"

#: lib/content/user_selection_schema.ex:71
#, elixir-autogen, elixir-format
msgid "cannot be larger than total"
msgstr "darf nicht größer sein als total"
9 changes: 2 additions & 7 deletions priv/gettext/en/LC_MESSAGES/courses.po
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,11 @@ msgstr ""
msgid "There's room for improvement"
msgstr ""

#: lib/content/course_live/lesson_play.ex:91
#: lib/content/course_live/lesson_play.ex:92
#, elixir-autogen, elixir-format
msgid "Unable to complete lesson"
msgstr ""

#: lib/content/course_live/lesson_play.ex:54
#, elixir-autogen, elixir-format
msgid "Unable to select option"
msgstr ""

#: lib/content/course_live/lesson_completed.ex:30
#, elixir-autogen, elixir-format
msgid "Very good!"
Expand Down Expand Up @@ -151,7 +146,7 @@ msgstr ""
msgid "No courses"
msgstr ""

#: lib/content/course_live/lesson_play.ex:77
#: lib/content/course_live/lesson_play.ex:78
#, elixir-autogen, elixir-format
msgid "Unable to send answer"
msgstr ""
Expand Down
7 changes: 6 additions & 1 deletion priv/gettext/en/LC_MESSAGES/errors.po
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ msgstr ""
msgid "must start with https://"
msgstr ""

#: lib/content/content_context.ex:757
#: lib/content/content_context.ex:756
#, elixir-autogen, elixir-format, fuzzy
msgid "cannot delete the only step"
msgstr ""
Expand Down Expand Up @@ -225,3 +225,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Failed to remove file"
msgstr ""

#: lib/content/user_selection_schema.ex:71
#, elixir-autogen, elixir-format
msgid "cannot be larger than total"
msgstr ""
7 changes: 6 additions & 1 deletion priv/gettext/errors.pot
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ msgstr ""
msgid "must start with https://"
msgstr ""

#: lib/content/content_context.ex:757
#: lib/content/content_context.ex:756
#, elixir-autogen, elixir-format
msgid "cannot delete the only step"
msgstr ""
Expand Down Expand Up @@ -222,3 +222,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Failed to remove file"
msgstr ""

#: lib/content/user_selection_schema.ex:71
#, elixir-autogen, elixir-format
msgid "cannot be larger than total"
msgstr ""
Loading

0 comments on commit 6158f00

Please sign in to comment.