Skip to content

Commit 89a3ece

Browse files
committed
Improve quiz export
1 parent 94d9641 commit 89a3ece

File tree

12 files changed

+170
-54
lines changed

12 files changed

+170
-54
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
- Improve performance of presentation to load slides faster
66
- Fix manager layout on small screens
77
- Add clickable hyperlinks in messages
8+
- Improve quiz export
9+
- Add option to force login to submit quizzes
10+
- Fix url with question mark being flagged as a question
811

912
### v.2.3.0
1013

lib/claper/events.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ defmodule Claper.Events do
123123
query =
124124
from(e in Event,
125125
where: e.user_id == ^user_id and not is_nil(e.expired_at),
126-
order_by: [desc: e.inserted_at]
126+
order_by: [desc: e.expired_at]
127127
)
128128

129129
Repo.paginate(query, page: page, page_size: page_size, preload: preload)

lib/claper/quizzes.ex

+20
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,26 @@ defmodule Claper.Quizzes do
443443
end
444444
end
445445

446+
@doc """
447+
Get number of submissions for a given quiz_id
448+
449+
## Examples
450+
451+
iex> get_number_submissions(quiz_id)
452+
12
453+
454+
"""
455+
def get_submission_count(quiz_id) do
456+
from(r in QuizResponse,
457+
where: r.quiz_id == ^quiz_id,
458+
select:
459+
count(
460+
fragment("DISTINCT COALESCE(?, CAST(? AS varchar))", r.attendee_identifier, r.user_id)
461+
)
462+
)
463+
|> Repo.one()
464+
end
465+
446466
@doc """
447467
Calculate percentage of all quiz questions for a given quiz.
448468

lib/claper/quizzes/quiz.ex

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ defmodule Claper.Quizzes.Quiz do
66
field :title, :string
77
field :position, :integer, default: 0
88
field :enabled, :boolean, default: false
9-
field :show_results, :boolean, default: false
9+
field :show_results, :boolean, default: true
10+
field :allow_anonymous, :boolean, default: false
1011
field :lti_line_item_url, :string
1112

1213
belongs_to :presentation_file, Claper.Presentations.PresentationFile
@@ -30,6 +31,7 @@ defmodule Claper.Quizzes.Quiz do
3031
:presentation_file_id,
3132
:enabled,
3233
:show_results,
34+
:allow_anonymous,
3335
:lti_resource_id,
3436
:lti_line_item_url
3537
])

lib/claper_web/controllers/stat_controller.ex

+72-28
Original file line numberDiff line numberDiff line change
@@ -86,43 +86,87 @@ defmodule ClaperWeb.StatController do
8686
with quiz <-
8787
Quizzes.get_quiz!(quiz_id, [
8888
:quiz_questions,
89+
:quiz_responses,
8990
quiz_questions: :quiz_question_opts,
91+
quiz_responses: [:quiz_question_opt, :user],
9092
presentation_file: :event
9193
]),
9294
event <- quiz.presentation_file.event,
9395
:ok <- authorize_event_access(current_user, event) do
94-
# Create headers for the CSV
95-
headers = ["Question", "Correct Answers", "Total Responses", "Response Distribution (%)"]
96-
97-
# Format data rows
98-
data =
99-
quiz.quiz_questions
100-
|> Enum.map(fn question ->
101-
[
102-
question.content,
103-
# Correct answers
104-
question.quiz_question_opts
105-
|> Enum.filter(& &1.is_correct)
106-
|> Enum.map_join(", ", & &1.content),
107-
# Total responses
108-
question.quiz_question_opts
109-
|> Enum.map(& &1.response_count)
110-
|> Enum.sum()
111-
|> to_string(),
112-
# Response distribution
113-
question.quiz_question_opts
114-
|> Enum.map_join(", ", fn opt ->
115-
"#{opt.content}: #{opt.percentage}%"
116-
end)
117-
]
118-
end)
96+
questions = quiz.quiz_questions
97+
headers = build_quiz_headers(questions)
11998

120-
export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}")
121-
else
122-
:unauthorized -> send_resp(conn, 403, "Forbidden")
99+
# Group responses by user/attendee and question
100+
responses_by_user =
101+
Enum.group_by(
102+
quiz.quiz_responses,
103+
fn response -> response.user_id || response.attendee_identifier end
104+
)
105+
106+
# Format data rows - one row per user with their answers and score
107+
data = Enum.map(responses_by_user, &process_user_responses(&1, questions))
108+
109+
csv_content =
110+
CSV.encode([headers | data])
111+
|> Enum.to_list()
112+
|> to_string()
113+
114+
send_download(conn, {:binary, csv_content},
115+
filename: "quiz_#{quiz.id}_results.csv",
116+
content_type: "text/csv"
117+
)
123118
end
124119
end
125120

121+
defp build_quiz_headers(questions) do
122+
question_headers =
123+
questions
124+
|> Enum.with_index(1)
125+
|> Enum.map(fn {question, _index} -> question.content end)
126+
127+
["Attendee identifier", "User email"] ++ question_headers ++ ["Total"]
128+
end
129+
130+
defp process_user_responses({_user_id, responses}, questions) do
131+
user_identifier = format_attendee_identifier(List.first(responses).attendee_identifier)
132+
user_email = Map.get(List.first(responses).user || %{}, :email, "N/A")
133+
responses_by_question = Enum.group_by(responses, & &1.quiz_question_id)
134+
135+
answers_with_correctness = process_question_responses(questions, responses_by_question)
136+
answers = Enum.map(answers_with_correctness, fn {answer, _} -> answer || "" end)
137+
correct_count = Enum.count(answers_with_correctness, fn {_, correct} -> correct end)
138+
total = "#{correct_count}/#{length(questions)}"
139+
140+
[user_identifier, user_email] ++ answers ++ [total]
141+
end
142+
143+
defp process_question_responses(questions, responses_by_question) do
144+
Enum.map(questions, fn question ->
145+
question_responses = Map.get(responses_by_question, question.id, [])
146+
147+
correct_opt_ids =
148+
question.quiz_question_opts
149+
|> Enum.filter(& &1.is_correct)
150+
|> Enum.map(& &1.id)
151+
|> MapSet.new()
152+
153+
format_question_response(question_responses, correct_opt_ids)
154+
end)
155+
end
156+
157+
defp format_question_response([], _correct_opt_ids), do: {nil, false}
158+
159+
defp format_question_response(question_responses, correct_opt_ids) do
160+
answers = Enum.map(question_responses, & &1.quiz_question_opt.content)
161+
162+
all_correct =
163+
Enum.all?(question_responses, fn r ->
164+
MapSet.member?(correct_opt_ids, r.quiz_question_opt_id)
165+
end)
166+
167+
{Enum.join(answers, ", "), all_correct}
168+
end
169+
126170
@doc """
127171
Exports quiz as QTI format.
128172
Requires user to be either an event leader or the event owner.

lib/claper_web/helpers.ex

+5
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,9 @@ defmodule ClaperWeb.Helpers do
1414
text
1515
end)
1616
end
17+
18+
def body_without_links(text) do
19+
url_regex = ~r/(https?:\/\/[^\s]+)/
20+
String.replace(text, url_regex, "")
21+
end
1722
end

lib/claper_web/live/event_live/manage.ex

+3-2
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ defmodule ClaperWeb.EventLive.Manage do
9292
|> stream_insert(:posts, post)
9393
|> update(:post_count, fn post_count -> post_count + 1 end)
9494

95-
case post.body =~ "?" do
95+
case ClaperWeb.Helpers.body_without_links(post.body) =~ "?" do
9696
true ->
9797
{:noreply,
9898
socket
@@ -130,7 +130,7 @@ defmodule ClaperWeb.EventLive.Manage do
130130
end)
131131
|> update(:post_count, fn post_count -> post_count - 1 end)
132132

133-
case deleted_post.body =~ "?" do
133+
case ClaperWeb.Helpers.body_without_links(deleted_post.body) =~ "?" do
134134
true ->
135135
{:noreply,
136136
socket
@@ -920,6 +920,7 @@ defmodule ClaperWeb.EventLive.Manage do
920920

921921
defp list_all_questions(_socket, event_id, sort \\ "date") do
922922
Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort))
923+
|> Enum.filter(&(ClaperWeb.Helpers.body_without_links(&1.body) =~ "?"))
923924
end
924925

925926
defp list_form_submits(_socket, presentation_file_id) do

lib/claper_web/live/event_live/manageable_post_component.ex

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
77
~H"""
88
<div
99
id={"#{@id}"}
10-
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"}
10+
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"}
1111
>
1212
<div
13-
:if={@post.body =~ "?"}
13+
:if={ClaperWeb.Helpers.body_without_links(@post.body) =~ "?"}
1414
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"
1515
>
1616
<svg

lib/claper_web/live/event_live/quiz_component.ex

+32-19
Original file line numberDiff line numberDiff line change
@@ -150,27 +150,40 @@ defmodule ClaperWeb.EventLive.QuizComponent do
150150
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
151151
<%= gettext("Back") %>
152152
</button>
153-
<% else %>
154-
<div class="w-1/2"></div>
155153
<% end %>
156154
157-
<button
158-
:if={@current_quiz_question_idx < length(@quiz.quiz_questions) - 1}
159-
phx-click="next-question"
160-
disabled={not @has_selection}
161-
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"}"}
162-
>
163-
<%= gettext("Next") %>
164-
</button>
165-
166-
<button
167-
:if={@current_quiz_question_idx == length(@quiz.quiz_questions) - 1}
168-
phx-click="submit-quiz"
169-
disabled={not @has_selection}
170-
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"}"}
171-
>
172-
<%= gettext("Submit") %>
173-
</button>
155+
<%= if @current_quiz_question_idx < length(@quiz.quiz_questions) - 1 do %>
156+
<button
157+
phx-click="next-question"
158+
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"}"}
159+
disabled={not @has_selection}
160+
>
161+
<%= gettext("Next") %>
162+
</button>
163+
<% else %>
164+
<%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
165+
<div class="w-full flex items-center justify-between">
166+
<div class="text-white text-sm font-semibold">
167+
<%= gettext("Please sign in to submit your answers") %>
168+
</div>
169+
<%= link(
170+
gettext("Sign in"),
171+
target: "_blank",
172+
to: ~p"/users/log_in",
173+
class:
174+
"inline px-3 py-2 text-white font-medium rounded-md h-full bg-primary-400 hover:bg-primary-500"
175+
) %>
176+
</div>
177+
<% else %>
178+
<button
179+
phx-click="submit-quiz"
180+
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"}"}
181+
disabled={not @has_selection}
182+
>
183+
<%= gettext("Submit") %>
184+
</button>
185+
<% end %>
186+
<% end %>
174187
</div>
175188
176189
<div

lib/claper_web/live/quiz_live/quiz_component.html.heex

+13-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050

5151
<%= inputs_for f, :quiz_questions, fn q -> %>
5252
<div class={[
53-
"mb-8 p-4 border rounded-b-md",
53+
"mb-4 p-4 border rounded-b-md",
5454
if(@current_quiz_question_index != q.index, do: "hidden", else: "")
5555
]}>
5656
<div class="flex gap-x-3 mt-3 items-center justify-start">
@@ -169,6 +169,18 @@
169169
<% end %>
170170
</div>
171171

172+
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
173+
174+
<div class="flex gap-x-2 mb-5 mt-3">
175+
<%= checkbox(f, :allow_anonymous, class: "h-4 w-4") %>
176+
<%= label(
177+
f,
178+
:allow_anonymous,
179+
gettext("Allow anonymous submissions"),
180+
class: "text-sm font-medium"
181+
) %>
182+
</div>
183+
172184
<div class="flex justify-between items-center">
173185
<div class="flex space-x-3">
174186
<button

lib/claper_web/live/stat_live/index.html.heex

+7
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,13 @@
430430
) %>
431431
</span>
432432
</p>
433+
434+
<p class="text-gray-400 text-sm">
435+
<%= gettext("Total submissions") %>:
436+
<span class="font-semibold">
437+
<%= Claper.Quizzes.get_submission_count(quiz.id) %>
438+
</span>
439+
</p>
433440
</div>
434441

435442
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defmodule Claper.Repo.Migrations.AddAllowAnonymousToQuizzes do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:quizzes) do
6+
add :allow_anonymous, :boolean, default: true
7+
end
8+
end
9+
end

0 commit comments

Comments
 (0)