mirror of
https://github.com/ClaperCo/Claper.git
synced 2025-12-16 11:57:58 +01:00
Improve quiz export
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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