mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-05-18 05:05:39 +02:00
Improve quiz export
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user