2023-02-26 22:40:34 +01:00
|
|
|
defmodule ClaperWeb.StatController do
|
2024-12-21 10:09:29 -05:00
|
|
|
@moduledoc """
|
|
|
|
|
Controller responsible for exporting various statistics and data in CSV format.
|
|
|
|
|
Handles form submissions, messages, and poll results exports.
|
|
|
|
|
"""
|
2023-02-26 22:40:34 +01:00
|
|
|
use ClaperWeb, :controller
|
|
|
|
|
|
2024-12-21 10:09:29 -05:00
|
|
|
alias Claper.{Forms, Events, Polls, Presentations, Quizzes}
|
2023-02-26 22:40:34 +01:00
|
|
|
|
2024-12-21 10:09:29 -05:00
|
|
|
@doc """
|
|
|
|
|
Exports form submissions as a CSV file.
|
|
|
|
|
"""
|
|
|
|
|
def export_form(conn, %{"form_id" => form_id}) do
|
2023-02-26 22:40:34 +01:00
|
|
|
form = Forms.get_form!(form_id, [:form_submits])
|
2024-04-06 11:48:47 +02:00
|
|
|
headers = form.fields |> Enum.map(& &1.name)
|
2024-12-21 10:09:29 -05:00
|
|
|
|
|
|
|
|
data =
|
|
|
|
|
form.form_submits
|
|
|
|
|
|> Enum.map(fn submit ->
|
|
|
|
|
form.fields
|
|
|
|
|
|> Enum.map(fn field ->
|
|
|
|
|
Map.get(submit.response, field.name, "")
|
|
|
|
|
end)
|
|
|
|
|
end)
|
|
|
|
|
|
|
|
|
|
export_as_csv(conn, headers, data, "form-#{sanitize(form.title)}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Exports all messages from an event as a CSV file.
|
|
|
|
|
Requires user to be either an event leader or the event owner.
|
|
|
|
|
"""
|
|
|
|
|
def export_all_messages(%{assigns: %{current_user: current_user}} = conn, %{
|
|
|
|
|
"event_id" => event_id
|
|
|
|
|
}) do
|
|
|
|
|
event = Events.get_event!(event_id, posts: [:user])
|
|
|
|
|
|
|
|
|
|
case authorize_event_access(current_user, event) do
|
|
|
|
|
:ok ->
|
|
|
|
|
headers = [
|
|
|
|
|
"Attendee identifier",
|
|
|
|
|
"User email",
|
|
|
|
|
"Name",
|
|
|
|
|
"Message",
|
|
|
|
|
"Pinned",
|
|
|
|
|
"Slide #",
|
|
|
|
|
"Sent at (UTC)"
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
content = format_messages_for_export(event.posts)
|
|
|
|
|
|
|
|
|
|
export_as_csv(conn, headers, content, "messages-#{sanitize(event.name)}")
|
|
|
|
|
|
|
|
|
|
:unauthorized ->
|
|
|
|
|
send_resp(conn, 403, "Forbidden")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Exports poll results as a CSV file.
|
|
|
|
|
Requires user to be either an event leader or the event owner.
|
|
|
|
|
"""
|
|
|
|
|
def export_poll(%{assigns: %{current_user: current_user}} = conn, %{"poll_id" => poll_id}) do
|
|
|
|
|
with poll <- Polls.get_poll!(poll_id),
|
|
|
|
|
presentation_file <-
|
|
|
|
|
Presentations.get_presentation_file!(poll.presentation_file_id, [:event]),
|
|
|
|
|
event <- presentation_file.event,
|
|
|
|
|
:ok <- authorize_event_access(current_user, event) do
|
|
|
|
|
headers = ["Name", "Multiple choice", "Slide #"] ++ Enum.map(poll.poll_opts, & &1.content)
|
|
|
|
|
|
|
|
|
|
content =
|
|
|
|
|
[poll.title, poll.multiple, poll.position + 1] ++
|
|
|
|
|
Enum.map(poll.poll_opts, & &1.vote_count)
|
|
|
|
|
|
|
|
|
|
export_as_csv(conn, headers, [content], "poll-#{sanitize(poll.title)}")
|
|
|
|
|
else
|
|
|
|
|
:unauthorized -> send_resp(conn, 403, "Forbidden")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Exports quiz results as a CSV file.
|
|
|
|
|
Requires user to be either an event leader or the event owner.
|
|
|
|
|
"""
|
|
|
|
|
def export_quiz(%{assigns: %{current_user: current_user}} = conn, %{"quiz_id" => quiz_id}) do
|
|
|
|
|
with quiz <-
|
|
|
|
|
Quizzes.get_quiz!(quiz_id, [
|
|
|
|
|
:quiz_questions,
|
2025-01-02 21:27:36 +01:00
|
|
|
:quiz_responses,
|
2024-12-21 10:09:29 -05:00
|
|
|
quiz_questions: :quiz_question_opts,
|
2025-01-02 21:27:36 +01:00
|
|
|
quiz_responses: [:quiz_question_opt, :user],
|
2024-12-21 10:09:29 -05:00
|
|
|
presentation_file: :event
|
|
|
|
|
]),
|
|
|
|
|
event <- quiz.presentation_file.event,
|
|
|
|
|
:ok <- authorize_event_access(current_user, event) do
|
2025-01-02 21:27:36 +01:00
|
|
|
questions = quiz.quiz_questions
|
|
|
|
|
headers = build_quiz_headers(questions)
|
|
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
)
|
2024-12-21 10:09:29 -05:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-01-02 21:27:36 +01:00
|
|
|
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
|
|
|
|
|
|
2024-12-21 10:09:29 -05:00
|
|
|
@doc """
|
|
|
|
|
Exports quiz as QTI format.
|
|
|
|
|
Requires user to be either an event leader or the event owner.
|
|
|
|
|
"""
|
|
|
|
|
def export_quiz_qti(%{assigns: %{current_user: current_user}} = conn, %{"quiz_id" => quiz_id}) do
|
|
|
|
|
with quiz <-
|
|
|
|
|
Quizzes.get_quiz!(quiz_id, [
|
|
|
|
|
:quiz_questions,
|
|
|
|
|
quiz_questions: :quiz_question_opts,
|
|
|
|
|
presentation_file: :event
|
|
|
|
|
]),
|
|
|
|
|
event <- quiz.presentation_file.event,
|
|
|
|
|
:ok <- authorize_event_access(current_user, event) do
|
|
|
|
|
qti_content = generate_qti_content(quiz)
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_resp_content_type("application/xml")
|
|
|
|
|
|> put_resp_header(
|
|
|
|
|
"content-disposition",
|
|
|
|
|
~s(attachment; filename="#{sanitize(quiz.title)}_qti.xml")
|
|
|
|
|
)
|
|
|
|
|
|> send_resp(200, qti_content)
|
|
|
|
|
else
|
|
|
|
|
:unauthorized -> send_resp(conn, 403, "Forbidden")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Private functions
|
|
|
|
|
|
|
|
|
|
defp authorize_event_access(user, event) do
|
2025-11-20 10:44:06 +01:00
|
|
|
if Events.led_by?(user.email, event) || event.user_id == user.id do
|
2024-12-21 10:09:29 -05:00
|
|
|
:ok
|
|
|
|
|
else
|
|
|
|
|
:unauthorized
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp format_messages_for_export(posts) do
|
|
|
|
|
posts
|
|
|
|
|
|> Enum.map(fn post ->
|
|
|
|
|
[
|
|
|
|
|
format_attendee_identifier(post.attendee_identifier),
|
|
|
|
|
format_user_email(post.user),
|
|
|
|
|
post.name || "N/A",
|
|
|
|
|
post.body,
|
|
|
|
|
post.pinned,
|
|
|
|
|
post.position + 1,
|
|
|
|
|
post.inserted_at
|
|
|
|
|
]
|
|
|
|
|
end)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp format_attendee_identifier(nil), do: "N/A"
|
|
|
|
|
defp format_attendee_identifier(identifier), do: Base.encode16(identifier)
|
|
|
|
|
|
|
|
|
|
defp format_user_email(nil), do: "N/A"
|
|
|
|
|
defp format_user_email(user), do: user.email
|
|
|
|
|
|
|
|
|
|
defp export_as_csv(conn, headers, data, filename) do
|
|
|
|
|
csv_data =
|
|
|
|
|
([headers] ++ data)
|
|
|
|
|
|> CSV.encode()
|
|
|
|
|
|> Enum.to_list()
|
|
|
|
|
|> to_string()
|
2023-02-26 22:40:34 +01:00
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_resp_content_type("text/csv")
|
2024-12-21 10:09:29 -05:00
|
|
|
|> put_resp_header("content-disposition", "attachment; filename=\"#{filename}.csv\"")
|
2023-02-26 22:40:34 +01:00
|
|
|
|> put_root_layout(false)
|
|
|
|
|
|> send_resp(200, csv_data)
|
|
|
|
|
end
|
|
|
|
|
|
2024-12-21 10:09:29 -05:00
|
|
|
defp generate_qti_content(quiz) do
|
|
|
|
|
"""
|
|
|
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
|
|
|
|
|
<assessment title="#{quiz.title}">
|
|
|
|
|
<section>
|
|
|
|
|
#{Enum.map_join(quiz.quiz_questions, "\n", &generate_qti_item/1)}
|
|
|
|
|
</section>
|
|
|
|
|
</assessment>
|
|
|
|
|
</questestinterop>
|
|
|
|
|
"""
|
|
|
|
|
end
|
2024-04-06 11:48:47 +02:00
|
|
|
|
2024-12-21 10:09:29 -05:00
|
|
|
defp generate_qti_item(question) do
|
|
|
|
|
"""
|
|
|
|
|
<item>
|
|
|
|
|
<presentation>
|
|
|
|
|
<material>
|
|
|
|
|
<mattext>#{question.content}</mattext>
|
|
|
|
|
</material>
|
|
|
|
|
<response_lid ident="RESPONSE" rcardinality="Multiple">
|
|
|
|
|
<render_choice>
|
|
|
|
|
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_option/1)}
|
|
|
|
|
</render_choice>
|
|
|
|
|
</response_lid>
|
|
|
|
|
</presentation>
|
|
|
|
|
<resprocessing>
|
|
|
|
|
<outcomes>
|
|
|
|
|
<decvar vartype="Integer" defaultval="0"/>
|
|
|
|
|
</outcomes>
|
|
|
|
|
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_condition/1)}
|
|
|
|
|
</resprocessing>
|
|
|
|
|
</item>
|
|
|
|
|
"""
|
2023-02-26 22:40:34 +01:00
|
|
|
end
|
2024-12-21 10:09:29 -05:00
|
|
|
|
|
|
|
|
defp generate_qti_option(option) do
|
|
|
|
|
"""
|
|
|
|
|
<response_label ident="#{option.id}">
|
|
|
|
|
<material>
|
|
|
|
|
<mattext>#{option.content}</mattext>
|
|
|
|
|
</material>
|
|
|
|
|
</response_label>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp generate_qti_condition(option) do
|
|
|
|
|
if option.is_correct do
|
|
|
|
|
"""
|
|
|
|
|
<respcondition>
|
|
|
|
|
<conditionvar>
|
|
|
|
|
<varequal respident="RESPONSE">#{option.id}</varequal>
|
|
|
|
|
</conditionvar>
|
|
|
|
|
<setvar action="Set">1</setvar>
|
|
|
|
|
</respcondition>
|
|
|
|
|
"""
|
|
|
|
|
else
|
|
|
|
|
""
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp sanitize(string),
|
|
|
|
|
do: string |> String.replace(~r/[^\w\s-]/, "") |> String.replace(~r/\s+/, "-")
|
2023-02-26 22:40:34 +01:00
|
|
|
end
|