Improve quiz export

This commit is contained in:
Alex Lion
2025-01-02 21:27:36 +01:00
parent 94d9641d96
commit 89a3eced83
12 changed files with 169 additions and 53 deletions

View File

@@ -5,6 +5,9 @@
- Improve performance of presentation to load slides faster - Improve performance of presentation to load slides faster
- Fix manager layout on small screens - Fix manager layout on small screens
- Add clickable hyperlinks in messages - Add clickable hyperlinks in messages
- Improve quiz export
- Add option to force login to submit quizzes
- Fix url with question mark being flagged as a question
### v.2.3.0 ### v.2.3.0

View File

@@ -123,7 +123,7 @@ defmodule Claper.Events do
query = query =
from(e in Event, from(e in Event,
where: e.user_id == ^user_id and not is_nil(e.expired_at), where: e.user_id == ^user_id and not is_nil(e.expired_at),
order_by: [desc: e.inserted_at] order_by: [desc: e.expired_at]
) )
Repo.paginate(query, page: page, page_size: page_size, preload: preload) Repo.paginate(query, page: page, page_size: page_size, preload: preload)

View File

@@ -443,6 +443,26 @@ defmodule Claper.Quizzes do
end end
end end
@doc """
Get number of submissions for a given quiz_id
## Examples
iex> get_number_submissions(quiz_id)
12
"""
def get_submission_count(quiz_id) do
from(r in QuizResponse,
where: r.quiz_id == ^quiz_id,
select:
count(
fragment("DISTINCT COALESCE(?, CAST(? AS varchar))", r.attendee_identifier, r.user_id)
)
)
|> Repo.one()
end
@doc """ @doc """
Calculate percentage of all quiz questions for a given quiz. Calculate percentage of all quiz questions for a given quiz.

View File

@@ -6,7 +6,8 @@ defmodule Claper.Quizzes.Quiz do
field :title, :string field :title, :string
field :position, :integer, default: 0 field :position, :integer, default: 0
field :enabled, :boolean, default: false field :enabled, :boolean, default: false
field :show_results, :boolean, default: false field :show_results, :boolean, default: true
field :allow_anonymous, :boolean, default: false
field :lti_line_item_url, :string field :lti_line_item_url, :string
belongs_to :presentation_file, Claper.Presentations.PresentationFile belongs_to :presentation_file, Claper.Presentations.PresentationFile
@@ -30,6 +31,7 @@ defmodule Claper.Quizzes.Quiz do
:presentation_file_id, :presentation_file_id,
:enabled, :enabled,
:show_results, :show_results,
:allow_anonymous,
:lti_resource_id, :lti_resource_id,
:lti_line_item_url :lti_line_item_url
]) ])

View File

@@ -86,43 +86,87 @@ defmodule ClaperWeb.StatController do
with quiz <- with quiz <-
Quizzes.get_quiz!(quiz_id, [ Quizzes.get_quiz!(quiz_id, [
:quiz_questions, :quiz_questions,
:quiz_responses,
quiz_questions: :quiz_question_opts, quiz_questions: :quiz_question_opts,
quiz_responses: [:quiz_question_opt, :user],
presentation_file: :event presentation_file: :event
]), ]),
event <- quiz.presentation_file.event, event <- quiz.presentation_file.event,
:ok <- authorize_event_access(current_user, event) do :ok <- authorize_event_access(current_user, event) do
# Create headers for the CSV questions = quiz.quiz_questions
headers = ["Question", "Correct Answers", "Total Responses", "Response Distribution (%)"] headers = build_quiz_headers(questions)
# Format data rows # Group responses by user/attendee and question
data = responses_by_user =
quiz.quiz_questions Enum.group_by(
|> Enum.map(fn question -> quiz.quiz_responses,
[ fn response -> response.user_id || response.attendee_identifier end
question.content, )
# Correct answers
question.quiz_question_opts
|> Enum.filter(& &1.is_correct)
|> Enum.map_join(", ", & &1.content),
# Total responses
question.quiz_question_opts
|> Enum.map(& &1.response_count)
|> Enum.sum()
|> to_string(),
# Response distribution
question.quiz_question_opts
|> Enum.map_join(", ", fn opt ->
"#{opt.content}: #{opt.percentage}%"
end)
]
end)
export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}") # Format data rows - one row per user with their answers and score
else data = Enum.map(responses_by_user, &process_user_responses(&1, questions))
:unauthorized -> send_resp(conn, 403, "Forbidden")
csv_content =
CSV.encode([headers | data])
|> Enum.to_list()
|> to_string()
send_download(conn, {:binary, csv_content},
filename: "quiz_#{quiz.id}_results.csv",
content_type: "text/csv"
)
end end
end end
defp build_quiz_headers(questions) do
question_headers =
questions
|> Enum.with_index(1)
|> Enum.map(fn {question, _index} -> question.content end)
["Attendee identifier", "User email"] ++ question_headers ++ ["Total"]
end
defp process_user_responses({_user_id, responses}, questions) do
user_identifier = format_attendee_identifier(List.first(responses).attendee_identifier)
user_email = Map.get(List.first(responses).user || %{}, :email, "N/A")
responses_by_question = Enum.group_by(responses, & &1.quiz_question_id)
answers_with_correctness = process_question_responses(questions, responses_by_question)
answers = Enum.map(answers_with_correctness, fn {answer, _} -> answer || "" end)
correct_count = Enum.count(answers_with_correctness, fn {_, correct} -> correct end)
total = "#{correct_count}/#{length(questions)}"
[user_identifier, user_email] ++ answers ++ [total]
end
defp process_question_responses(questions, responses_by_question) do
Enum.map(questions, fn question ->
question_responses = Map.get(responses_by_question, question.id, [])
correct_opt_ids =
question.quiz_question_opts
|> Enum.filter(& &1.is_correct)
|> Enum.map(& &1.id)
|> MapSet.new()
format_question_response(question_responses, correct_opt_ids)
end)
end
defp format_question_response([], _correct_opt_ids), do: {nil, false}
defp format_question_response(question_responses, correct_opt_ids) do
answers = Enum.map(question_responses, & &1.quiz_question_opt.content)
all_correct =
Enum.all?(question_responses, fn r ->
MapSet.member?(correct_opt_ids, r.quiz_question_opt_id)
end)
{Enum.join(answers, ", "), all_correct}
end
@doc """ @doc """
Exports quiz as QTI format. Exports quiz as QTI format.
Requires user to be either an event leader or the event owner. Requires user to be either an event leader or the event owner.

View File

@@ -14,4 +14,9 @@ defmodule ClaperWeb.Helpers do
text text
end) end)
end end
def body_without_links(text) do
url_regex = ~r/(https?:\/\/[^\s]+)/
String.replace(text, url_regex, "")
end
end end

View File

@@ -92,7 +92,7 @@ defmodule ClaperWeb.EventLive.Manage do
|> stream_insert(:posts, post) |> stream_insert(:posts, post)
|> update(:post_count, fn post_count -> post_count + 1 end) |> update(:post_count, fn post_count -> post_count + 1 end)
case post.body =~ "?" do case ClaperWeb.Helpers.body_without_links(post.body) =~ "?" do
true -> true ->
{:noreply, {:noreply,
socket socket
@@ -130,7 +130,7 @@ defmodule ClaperWeb.EventLive.Manage do
end) end)
|> update(:post_count, fn post_count -> post_count - 1 end) |> update(:post_count, fn post_count -> post_count - 1 end)
case deleted_post.body =~ "?" do case ClaperWeb.Helpers.body_without_links(deleted_post.body) =~ "?" do
true -> true ->
{:noreply, {:noreply,
socket socket
@@ -920,6 +920,7 @@ defmodule ClaperWeb.EventLive.Manage do
defp list_all_questions(_socket, event_id, sort \\ "date") do defp list_all_questions(_socket, event_id, sort \\ "date") do
Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort)) Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort))
|> Enum.filter(&(ClaperWeb.Helpers.body_without_links(&1.body) =~ "?"))
end end
defp list_form_submits(_socket, presentation_file_id) do defp list_form_submits(_socket, presentation_file_id) do

View File

@@ -7,10 +7,10 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
~H""" ~H"""
<div <div
id={"#{@id}"} id={"#{@id}"}
class={"#{if @post.body =~ "?", do: "border-supporting-yellow-400 border-2"} flex flex-col md:block px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-2"} class={"#{if ClaperWeb.Helpers.body_without_links(@post.body) =~ "?", do: "border-supporting-yellow-400 border-2"} flex flex-col md:block px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-2"}
> >
<div <div
:if={@post.body =~ "?"} :if={ClaperWeb.Helpers.body_without_links(@post.body) =~ "?"}
class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-400 text-white mb-2" class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-400 text-white mb-2"
> >
<svg <svg

View File

@@ -150,27 +150,40 @@ defmodule ClaperWeb.EventLive.QuizComponent do
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium"> <button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
<%= gettext("Back") %> <%= gettext("Back") %>
</button> </button>
<% else %>
<div class="w-1/2"></div>
<% end %> <% end %>
<button <%= if @current_quiz_question_idx < length(@quiz.quiz_questions) - 1 do %>
:if={@current_quiz_question_idx < length(@quiz.quiz_questions) - 1} <button
phx-click="next-question" phx-click="next-question"
disabled={not @has_selection} class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"} disabled={not @has_selection}
> >
<%= gettext("Next") %> <%= gettext("Next") %>
</button> </button>
<% else %>
<button <%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
:if={@current_quiz_question_idx == length(@quiz.quiz_questions) - 1} <div class="w-full flex items-center justify-between">
phx-click="submit-quiz" <div class="text-white text-sm font-semibold">
disabled={not @has_selection} <%= gettext("Please sign in to submit your answers") %>
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"} </div>
> <%= link(
<%= gettext("Submit") %> gettext("Sign in"),
</button> target: "_blank",
to: ~p"/users/log_in",
class:
"inline px-3 py-2 text-white font-medium rounded-md h-full bg-primary-400 hover:bg-primary-500"
) %>
</div>
<% else %>
<button
phx-click="submit-quiz"
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
disabled={not @has_selection}
>
<%= gettext("Submit") %>
</button>
<% end %>
<% end %>
</div> </div>
<div <div

View File

@@ -50,7 +50,7 @@
<%= inputs_for f, :quiz_questions, fn q -> %> <%= inputs_for f, :quiz_questions, fn q -> %>
<div class={[ <div class={[
"mb-8 p-4 border rounded-b-md", "mb-4 p-4 border rounded-b-md",
if(@current_quiz_question_index != q.index, do: "hidden", else: "") if(@current_quiz_question_index != q.index, do: "hidden", else: "")
]}> ]}>
<div class="flex gap-x-3 mt-3 items-center justify-start"> <div class="flex gap-x-3 mt-3 items-center justify-start">
@@ -169,6 +169,18 @@
<% end %> <% end %>
</div> </div>
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
<div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :allow_anonymous, class: "h-4 w-4") %>
<%= label(
f,
:allow_anonymous,
gettext("Allow anonymous submissions"),
class: "text-sm font-medium"
) %>
</div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex space-x-3"> <div class="flex space-x-3">
<button <button

View File

@@ -430,6 +430,13 @@
) %> ) %>
</span> </span>
</p> </p>
<p class="text-gray-400 text-sm">
<%= gettext("Total submissions") %>:
<span class="font-semibold">
<%= Claper.Quizzes.get_submission_count(quiz.id) %>
</span>
</p>
</div> </div>
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]"> <div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">

View File

@@ -0,0 +1,9 @@
defmodule Claper.Repo.Migrations.AddAllowAnonymousToQuizzes do
use Ecto.Migration
def change do
alter table(:quizzes) do
add :allow_anonymous, :boolean, default: true
end
end
end