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