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
- Fix manager layout on small screens
- 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

View File

@@ -123,7 +123,7 @@ defmodule Claper.Events do
query =
from(e in Event,
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)

View File

@@ -443,6 +443,26 @@ defmodule Claper.Quizzes do
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 """
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 :position, :integer, default: 0
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
belongs_to :presentation_file, Claper.Presentations.PresentationFile
@@ -30,6 +31,7 @@ defmodule Claper.Quizzes.Quiz do
:presentation_file_id,
:enabled,
:show_results,
:allow_anonymous,
:lti_resource_id,
:lti_line_item_url
])

View File

@@ -86,43 +86,87 @@ defmodule ClaperWeb.StatController do
with quiz <-
Quizzes.get_quiz!(quiz_id, [
:quiz_questions,
:quiz_responses,
quiz_questions: :quiz_question_opts,
quiz_responses: [:quiz_question_opt, :user],
presentation_file: :event
]),
event <- quiz.presentation_file.event,
:ok <- authorize_event_access(current_user, event) do
# Create headers for the CSV
headers = ["Question", "Correct Answers", "Total Responses", "Response Distribution (%)"]
questions = quiz.quiz_questions
headers = build_quiz_headers(questions)
# Format data rows
data =
quiz.quiz_questions
|> Enum.map(fn question ->
[
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)
# Group responses by user/attendee and question
responses_by_user =
Enum.group_by(
quiz.quiz_responses,
fn response -> response.user_id || response.attendee_identifier end
)
export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}")
else
:unauthorized -> send_resp(conn, 403, "Forbidden")
# Format data rows - one row per user with their answers and score
data = Enum.map(responses_by_user, &process_user_responses(&1, questions))
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
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 """
Exports quiz as QTI format.
Requires user to be either an event leader or the event owner.

View File

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

View File

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

View File

@@ -7,10 +7,10 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
~H"""
<div
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
: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"
>
<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">
<%= gettext("Back") %>
</button>
<% else %>
<div class="w-1/2"></div>
<% end %>
<button
:if={@current_quiz_question_idx < length(@quiz.quiz_questions) - 1}
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"}"}
>
<%= gettext("Next") %>
</button>
<button
:if={@current_quiz_question_idx == length(@quiz.quiz_questions) - 1}
phx-click="submit-quiz"
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"}"}
>
<%= gettext("Submit") %>
</button>
<%= if @current_quiz_question_idx < length(@quiz.quiz_questions) - 1 do %>
<button
phx-click="next-question"
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") %>
</button>
<% else %>
<%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
<div class="w-full flex items-center justify-between">
<div class="text-white text-sm font-semibold">
<%= gettext("Please sign in to submit your answers") %>
</div>
<%= link(
gettext("Sign in"),
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

View File

@@ -50,7 +50,7 @@
<%= inputs_for f, :quiz_questions, fn q -> %>
<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: "")
]}>
<div class="flex gap-x-3 mt-3 items-center justify-start">
@@ -169,6 +169,18 @@
<% end %>
</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 space-x-3">
<button

View File

@@ -430,6 +430,13 @@
) %>
</span>
</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 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