mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-09-02 03:59:46 +02:00
Fix quiz submission to handle duplicate options and update average score in real-time
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
- Improve SMTP config and handling (#197)
|
- Improve SMTP config and handling (#197)
|
||||||
- Fix presentation slides URL (#200)
|
- Fix presentation slides URL (#200)
|
||||||
- Fix custom S3 endpoint (#199)
|
- Fix custom S3 endpoint (#199)
|
||||||
|
- Fix quizz real time average score update and id duplication
|
||||||
|
|
||||||
### v.2.4.0
|
### v.2.4.0
|
||||||
|
|
||||||
|
|||||||
@@ -299,8 +299,8 @@ defmodule Claper.Quizzes do
|
|||||||
# end
|
# end
|
||||||
# end
|
# end
|
||||||
|
|
||||||
def submit_quiz(%User{} = user, quiz_opts, quiz_id) do
|
def submit_quiz(%User{} = user, event_uuid, quiz_opts, quiz_id) do
|
||||||
quiz_opts = Enum.with_index(quiz_opts)
|
quiz_opts = quiz_opts |> Enum.uniq_by(& &1.id) |> Enum.with_index()
|
||||||
|
|
||||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
|
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
|
||||||
unique_key = "#{opt.id}_#{user.id + index}"
|
unique_key = "#{opt.id}_#{user.id + index}"
|
||||||
@@ -323,12 +323,15 @@ defmodule Claper.Quizzes do
|
|||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||||
Lti13.QuizScoreReporter.report_quiz_score(quiz, user.id)
|
Lti13.QuizScoreReporter.report_quiz_score(quiz, user.id)
|
||||||
|
broadcast({:ok, quiz, event_uuid}, :quiz_updated)
|
||||||
{:ok, quiz}
|
{:ok, quiz}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def submit_quiz(attendee_identifier, quiz_opts, quiz_id)
|
def submit_quiz(attendee_identifier, event_uuid, quiz_opts, quiz_id)
|
||||||
when is_binary(attendee_identifier) and is_list(quiz_opts) do
|
when is_binary(attendee_identifier) and is_list(quiz_opts) do
|
||||||
|
quiz_opts = Enum.uniq_by(quiz_opts, & &1.id)
|
||||||
|
|
||||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
||||||
multi
|
multi
|
||||||
|> Ecto.Multi.update(
|
|> Ecto.Multi.update(
|
||||||
@@ -348,6 +351,7 @@ defmodule Claper.Quizzes do
|
|||||||
|> Repo.transact() do
|
|> Repo.transact() do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||||
|
broadcast({:ok, quiz, event_uuid}, :quiz_updated)
|
||||||
{:ok, quiz}
|
{:ok, quiz}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -475,7 +479,7 @@ defmodule Claper.Quizzes do
|
|||||||
|
|
||||||
n ->
|
n ->
|
||||||
avg = Enum.sum(scores) / n
|
avg = Enum.sum(scores) / n
|
||||||
if avg == trunc(avg), do: trunc(avg), else: avg
|
if avg == trunc(avg), do: trunc(avg), else: Float.round(avg, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,24 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
|||||||
@impl true
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket |> assign(current_question_idx: -1) |> assign_new(:current_question, fn -> nil end)}
|
socket
|
||||||
|
|> assign(current_question_idx: -1)
|
||||||
|
|> assign_new(:current_question, fn -> nil end)
|
||||||
|
|> assign_new(:average_score, fn -> 0 end)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
socket = assign(socket, assigns)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
if Map.has_key?(assigns, :quiz) do
|
||||||
|
assign(socket, :average_score, Claper.Quizzes.calculate_average_score(assigns.quiz.id))
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -28,7 +45,7 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
|||||||
>
|
>
|
||||||
<p class="font-semibold text-2xl">{gettext("Average score")}:</p>
|
<p class="font-semibold text-2xl">{gettext("Average score")}:</p>
|
||||||
<p class="font-semibold text-7xl">
|
<p class="font-semibold text-7xl">
|
||||||
{Claper.Quizzes.calculate_average_score(@quiz.id)}/{length(@quiz.quiz_questions)}
|
{@average_score}/{length(@quiz.quiz_questions)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
|||||||
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3 items-center z-10 text-left">
|
<div class="flex space-x-3 items-center z-10 text-left">
|
||||||
<%= if Enum.member?(@selected_quiz_question_opts, opt) do %>
|
<%= if Enum.any?(@selected_quiz_question_opts, fn x -> x.id == opt.id end) do %>
|
||||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></span>
|
<span class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||||
|
|||||||
@@ -686,7 +686,9 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
quiz_question_opt =
|
quiz_question_opt =
|
||||||
Enum.find(current_quiz_question.quiz_question_opts, fn x -> x.id == opt end)
|
Enum.find(current_quiz_question.quiz_question_opts, fn x -> x.id == opt end)
|
||||||
|
|
||||||
if Enum.member?(socket.assigns.selected_quiz_question_opts, quiz_question_opt) do
|
if Enum.any?(socket.assigns.selected_quiz_question_opts, fn x ->
|
||||||
|
x.id == quiz_question_opt.id
|
||||||
|
end) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(
|
||||||
@@ -713,6 +715,7 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
when is_map(current_user) do
|
when is_map(current_user) do
|
||||||
case Claper.Quizzes.submit_quiz(
|
case Claper.Quizzes.submit_quiz(
|
||||||
current_user,
|
current_user,
|
||||||
|
socket.assigns.event.uuid,
|
||||||
opts,
|
opts,
|
||||||
socket.assigns.current_interaction.id
|
socket.assigns.current_interaction.id
|
||||||
) do
|
) do
|
||||||
@@ -720,6 +723,7 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> load_current_interaction(quiz, true)
|
|> load_current_interaction(quiz, true)
|
||||||
|
|> assign(:selected_quiz_question_opts, [])
|
||||||
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -733,6 +737,7 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
) do
|
) do
|
||||||
case Claper.Quizzes.submit_quiz(
|
case Claper.Quizzes.submit_quiz(
|
||||||
attendee_identifier,
|
attendee_identifier,
|
||||||
|
socket.assigns.event.uuid,
|
||||||
opts,
|
opts,
|
||||||
socket.assigns.current_interaction.id
|
socket.assigns.current_interaction.id
|
||||||
) do
|
) do
|
||||||
@@ -740,6 +745,7 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> load_current_interaction(quiz, true)
|
|> load_current_interaction(quiz, true)
|
||||||
|
|> assign(:selected_quiz_question_opts, [])
|
||||||
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -883,7 +889,7 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id)
|
socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_current_interaction(socket, %Quizzes.Quiz{} = interaction, _same_interaction) do
|
defp load_current_interaction(socket, %Quizzes.Quiz{} = interaction, same_interaction) do
|
||||||
quiz = Quizzes.set_percentages(interaction)
|
quiz = Quizzes.set_percentages(interaction)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
@@ -891,11 +897,17 @@ defmodule ClaperWeb.EventLive.Show do
|
|||||||
|> assign(:current_interaction, quiz)
|
|> assign(:current_interaction, quiz)
|
||||||
|> get_current_quiz_reponses(interaction.id)
|
|> get_current_quiz_reponses(interaction.id)
|
||||||
|
|
||||||
if length(socket.assigns.current_quiz_responses) > 0 do
|
if same_interaction do
|
||||||
socket
|
socket
|
||||||
|> assign(:current_quiz_question_idx, length(interaction.quiz_questions))
|
|
||||||
else
|
else
|
||||||
socket
|
if length(socket.assigns.current_quiz_responses) > 0 do
|
||||||
|
socket
|
||||||
|
|> assign(:current_quiz_question_idx, length(interaction.quiz_questions))
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
|> assign(:current_quiz_question_idx, 0)
|
||||||
|
|> assign(:selected_quiz_question_opts, [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -113,14 +113,15 @@ defmodule Claper.QuizzesTest do
|
|||||||
assert length(new_question.quiz_question_opts) == 2
|
assert length(new_question.quiz_question_opts) == 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "submit_quiz/3 with user records responses and updates counts" do
|
test "submit_quiz/4 with user records responses and updates counts" do
|
||||||
quiz = quiz_fixture()
|
quiz = quiz_fixture()
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
|
event_uuid = Ecto.UUID.generate()
|
||||||
question = List.first(quiz.quiz_questions)
|
question = List.first(quiz.quiz_questions)
|
||||||
option = List.first(question.quiz_question_opts)
|
option = List.first(question.quiz_question_opts)
|
||||||
|
|
||||||
assert {:ok, updated_quiz} =
|
assert {:ok, updated_quiz} =
|
||||||
Quizzes.submit_quiz(user, [option], quiz.id)
|
Quizzes.submit_quiz(user, event_uuid, [option], quiz.id)
|
||||||
|
|
||||||
updated_option =
|
updated_option =
|
||||||
updated_quiz.quiz_questions
|
updated_quiz.quiz_questions
|
||||||
@@ -131,14 +132,15 @@ defmodule Claper.QuizzesTest do
|
|||||||
assert updated_option.response_count == 1
|
assert updated_option.response_count == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "submit_quiz/3 with attendee_identifier records responses" do
|
test "submit_quiz/4 with attendee_identifier records responses" do
|
||||||
quiz = quiz_fixture()
|
quiz = quiz_fixture()
|
||||||
|
event_uuid = Ecto.UUID.generate()
|
||||||
question = List.first(quiz.quiz_questions)
|
question = List.first(quiz.quiz_questions)
|
||||||
option = List.first(question.quiz_question_opts)
|
option = List.first(question.quiz_question_opts)
|
||||||
attendee_id = "test-attendee"
|
attendee_id = "test-attendee"
|
||||||
|
|
||||||
assert {:ok, _updated_quiz} =
|
assert {:ok, _updated_quiz} =
|
||||||
Quizzes.submit_quiz(attendee_id, [option], quiz.id)
|
Quizzes.submit_quiz(attendee_id, event_uuid, [option], quiz.id)
|
||||||
|
|
||||||
responses = Quizzes.get_quiz_responses(attendee_id, quiz.id)
|
responses = Quizzes.get_quiz_responses(attendee_id, quiz.id)
|
||||||
assert length(responses) == 1
|
assert length(responses) == 1
|
||||||
@@ -151,7 +153,7 @@ defmodule Claper.QuizzesTest do
|
|||||||
correct_option = Enum.find(question.quiz_question_opts, & &1.is_correct)
|
correct_option = Enum.find(question.quiz_question_opts, & &1.is_correct)
|
||||||
|
|
||||||
# Submit correct answer
|
# Submit correct answer
|
||||||
{:ok, _} = Quizzes.submit_quiz(user, [correct_option], quiz.id)
|
{:ok, _} = Quizzes.submit_quiz(user, Ecto.UUID.generate(), [correct_option], quiz.id)
|
||||||
assert {1, 1} = Quizzes.calculate_user_score(user.id, quiz.id)
|
assert {1, 1} = Quizzes.calculate_user_score(user.id, quiz.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -175,5 +177,37 @@ defmodule Claper.QuizzesTest do
|
|||||||
assert {:ok, updated_quiz} = Quizzes.set_disabled(quiz.id)
|
assert {:ok, updated_quiz} = Quizzes.set_disabled(quiz.id)
|
||||||
refute updated_quiz.enabled
|
refute updated_quiz.enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "submit_quiz/4 with duplicate opts deduplicates by id" do
|
||||||
|
quiz = quiz_fixture()
|
||||||
|
event_uuid = Ecto.UUID.generate()
|
||||||
|
question = List.first(quiz.quiz_questions)
|
||||||
|
option = List.first(question.quiz_question_opts)
|
||||||
|
attendee_id = "test-attendee-dedup"
|
||||||
|
|
||||||
|
# Simulate the bug: same opt appears twice (e.g. due to stale struct comparison)
|
||||||
|
duplicate_opts = [option, %{option | response_count: option.response_count + 1}]
|
||||||
|
|
||||||
|
assert {:ok, _updated_quiz} =
|
||||||
|
Quizzes.submit_quiz(attendee_id, event_uuid, duplicate_opts, quiz.id)
|
||||||
|
|
||||||
|
# Should only create one response despite duplicate opts
|
||||||
|
responses = Quizzes.get_quiz_responses(attendee_id, quiz.id)
|
||||||
|
assert length(responses) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submit_quiz/4 with user and duplicate opts deduplicates by id" do
|
||||||
|
quiz = quiz_fixture()
|
||||||
|
user = user_fixture()
|
||||||
|
event_uuid = Ecto.UUID.generate()
|
||||||
|
question = List.first(quiz.quiz_questions)
|
||||||
|
option = List.first(question.quiz_question_opts)
|
||||||
|
|
||||||
|
# Simulate the bug: same opt appears twice with different response_count
|
||||||
|
duplicate_opts = [option, %{option | response_count: option.response_count + 1}]
|
||||||
|
|
||||||
|
assert {:ok, _updated_quiz} =
|
||||||
|
Quizzes.submit_quiz(user, event_uuid, duplicate_opts, quiz.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user