defmodule ClaperWeb.StatController do
@moduledoc """
Controller responsible for exporting various statistics and data in CSV format.
Handles form submissions, messages, and poll results exports.
"""
use ClaperWeb, :controller
alias Claper.{Forms, Events, Polls, Presentations, Quizzes}
@doc """
Exports form submissions as a CSV file.
"""
def export_form(conn, %{"form_id" => form_id}) do
form = Forms.get_form!(form_id, [:form_submits])
headers = form.fields |> Enum.map(& &1.name)
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,
: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
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"
)
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.
"""
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
if Events.led_by?(user.email, event) || event.user_id == user.id do
: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()
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", "attachment; filename=\"#{filename}.csv\"")
|> put_root_layout(false)
|> send_resp(200, csv_data)
end
defp generate_qti_content(quiz) do
"""
#{Enum.map_join(quiz.quiz_questions, "\n", &generate_qti_item/1)}
"""
end
defp generate_qti_item(question) do
"""
-
#{question.content}
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_option/1)}
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_condition/1)}
"""
end
defp generate_qti_option(option) do
"""
#{option.content}
"""
end
defp generate_qti_condition(option) do
if option.is_correct do
"""
#{option.id}
1
"""
else
""
end
end
defp sanitize(string),
do: string |> String.replace(~r/[^\w\s-]/, "") |> String.replace(~r/\s+/, "-")
end