Skip to content

Commit

Permalink
Improve quiz export
Browse files Browse the repository at this point in the history
  • Loading branch information
alxlion committed Jan 2, 2025
1 parent 94d9641 commit 89a3ece
Show file tree
Hide file tree
Showing 12 changed files with 170 additions and 54 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/claper/events.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions lib/claper/quizzes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion lib/claper/quizzes/quiz.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@ defmodule Claper.Quizzes.Quiz do
:presentation_file_id,
:enabled,
:show_results,
:allow_anonymous,
:lti_resource_id,
:lti_line_item_url
])
Expand Down
100 changes: 72 additions & 28 deletions lib/claper_web/controllers/stat_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 (%)"]

# 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)
questions = quiz.quiz_questions
headers = build_quiz_headers(questions)

export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}")
else
:unauthorized -> send_resp(conn, 403, "Forbidden")
# 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
)

# 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.
Expand Down
5 changes: 5 additions & 0 deletions lib/claper_web/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions lib/claper_web/live/event_live/manage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/claper_web/live/event_live/manageable_post_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 32 additions & 19 deletions lib/claper_web/live/event_live/quiz_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/claper_web/live/quiz_live/quiz_component.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/claper_web/live/stat_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -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]">
Expand Down
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 89a3ece

Please sign in to comment.