This commit is contained in:
Alex
2024-10-05 12:57:09 +02:00
parent 107be10b5c
commit 1977959efb
11 changed files with 717 additions and 0 deletions

215
lib/claper/quizzes.ex Normal file
View File

@@ -0,0 +1,215 @@
defmodule Claper.Quizzes do
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Quizzes.Quiz
alias Claper.Quizzes.QuizQuestion
alias Claper.Quizzes.QuizQuestionOpt
alias Claper.Quizzes.QuizResponse
@doc """
Returns the list of quizzes for a given presentation file.
## Examples
iex> list_quizzes(123)
[%Quiz{}, ...]
"""
def list_quizzes(presentation_file_id) do
from(p in Quiz,
where: p.presentation_file_id == ^presentation_file_id,
order_by: [asc: p.id, asc: p.position]
)
|> Repo.all()
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
end
@doc """
Returns the list of quizzes for a given presentation file and a given position.
## Examples
iex> list_quizzes_at_position(123, 0)
[%Quiz{}, ...]
"""
def list_polls_at_position(presentation_file_id, position) do
from(p in Quiz,
where: p.presentation_file_id == ^presentation_file_id and p.position == ^position,
order_by: [asc: p.id]
)
|> Repo.all()
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
end
@doc """
Gets a single quiz by ID.
Raises `Ecto.NoResultsError` if the Quiz does not exist.
## Parameters
- id: The ID of the quiz.
## Examples
iex> get_quiz!(123)
%Quiz{}
iex> get_quiz!(456)
** (Ecto.NoResultsError)
"""
def get_quiz!(id) do
Quiz
|> Repo.get!(id)
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
end
@doc """
Creates a quiz.
## Parameters
- attrs: A map of attributes for creating a quiz.
## Examples
iex> create_quiz(%{field: value})
{:ok, %Quiz{}}
iex> create_quiz(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_quiz(attrs \\ %{}) do
%Quiz{}
|> Quiz.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a quiz.
## Parameters
- quiz: The quiz struct to update.
- attrs: A map of attributes to update.
## Examples
iex> update_quiz(quiz, %{field: new_value})
{:ok, %Quiz{}}
iex> update_quiz(quiz, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_quiz(event_uuid, %Quiz{} = quiz, attrs) do
quiz
|> Quiz.changeset(attrs)
|> Repo.update()
|> case do
{:ok, quiz} ->
broadcast({:ok, quiz, event_uuid}, :quiz_updated)
{:error, changeset} ->
{:error, %{changeset | action: :update}}
end
end
@doc """
Deletes a quiz.
## Parameters
- event_uuid: The UUID of the event.
- quiz: The quiz struct to delete.
## Examples
iex> delete_quiz(event_uuid, quiz)
{:ok, %Quiz{}}
iex> delete_quiz(event_uuid, quiz)
{:error, %Ecto.Changeset{}}
"""
def delete_quiz(event_uuid, %Quiz{} = quiz) do
{:ok, quiz} = Repo.delete(quiz)
broadcast({:ok, quiz, event_uuid}, :quiz_deleted)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking quiz changes.
## Parameters
- quiz: The quiz struct to create a changeset for.
- attrs: A map of attributes (optional).
## Examples
iex> change_quiz(quiz)
%Ecto.Changeset{data: %Quiz{}}
"""
def change_quiz(%Quiz{} = quiz, attrs \\ %{}) do
Quiz.changeset(quiz, attrs)
end
def add_quiz_question(changeset) do
changeset
|> Ecto.Changeset.put_assoc(:quiz_questions, Ecto.Changeset.get_field(changeset, :quiz_questions) ++ [%QuizQuestion{
quiz_question_opts: [
%QuizQuestionOpt{},
%QuizQuestionOpt{}
]
}])
end
def remove_quiz_question(changeset, quiz_question) do
changeset
|> Ecto.Changeset.put_assoc(
:quiz_questions,
Ecto.Changeset.get_field(changeset, :quiz_questions) -- [quiz_question]
)
end
@doc """
Add an empty quiz opt to a quiz changeset.
"""
def add_quiz_question_opt(changeset, question_index) do
changeset
|> Ecto.Changeset.put_assoc(:quiz_questions, Ecto.Changeset.get_field(changeset, :quiz_questions) |> List.update_at(question_index, fn question ->
Ecto.Changeset.put_assoc(question, :quiz_question_opts,
(question.quiz_question_opts || []) ++ [%QuizQuestionOpt{}])
end))
end
@doc """
Remove a quiz question opt from a quiz question changeset.
"""
def remove_quiz_question_opt(changeset, quiz_question_opt) do
changeset
|> Ecto.Changeset.put_assoc(
:quiz_question_opts,
Ecto.Changeset.get_field(changeset, :quiz_question_opts) -- [quiz_question_opt]
)
end
defp broadcast({:error, _reason} = error, _quiz), do: error
defp broadcast({:ok, quiz, event_uuid}, event) do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event_uuid}",
{event, quiz}
)
{:ok, quiz}
end
end

View File

@@ -0,0 +1,24 @@
defmodule Claper.Quizzes.Quiz do
use Ecto.Schema
import Ecto.Changeset
schema "quizzes" do
field :title, :string
field :position, :integer, default: 0
field :enabled, :boolean, default: false
field :show_results, :boolean, default: false
belongs_to :presentation_file, Claper.Presentations.PresentationFile
has_many :quiz_questions, Claper.Quizzes.QuizQuestion
timestamps()
end
@doc false
def changeset(quiz, attrs) do
quiz
|> cast(attrs, [:title, :position, :presentation_file_id, :enabled, :show_results])
|> validate_required([:title, :position, :presentation_file_id])
|> cast_assoc(:quiz_questions)
end
end

View File

@@ -0,0 +1,22 @@
defmodule Claper.Quizzes.QuizQuestion do
use Ecto.Schema
import Ecto.Changeset
schema "quiz_questions" do
field :content, :string
field :type, :string, default: "qcm"
belongs_to :quiz, Claper.Quizzes.Quiz
has_many :quiz_question_opts, Claper.Quizzes.QuizQuestionOpt
timestamps()
end
@doc false
def changeset(quiz_question, attrs) do
quiz_question
|> cast(attrs, [:content, :type, :quiz_id])
|> validate_required([:content, :type, :quiz_id])
|> cast_assoc(:quiz_question_opts)
end
end

View File

@@ -0,0 +1,21 @@
defmodule Claper.Quizzes.QuizQuestionOpt do
use Ecto.Schema
import Ecto.Changeset
schema "quiz_question_opts" do
field :content, :string
field :is_correct, :boolean, default: false
field :response_count, :integer, default: 0
belongs_to :quiz_question, Claper.Quizzes.QuizQuestion
timestamps()
end
@doc false
def changeset(quiz_question_opt, attrs) do
quiz_question_opt
|> cast(attrs, [:content, :is_correct, :response_count, :quiz_question_id])
|> validate_required([:content, :is_correct, :quiz_question_id])
end
end

View File

@@ -0,0 +1,22 @@
defmodule Claper.Quizzes.QuizResponse do
use Ecto.Schema
import Ecto.Changeset
schema "quiz_responses" do
field :attendee_identifier, :string
belongs_to :quiz, Claper.Quizzes.Quiz
belongs_to :quiz_question, Claper.Quizzes.QuizQuestion
belongs_to :quiz_question_opt, Claper.Quizzes.QuizQuestionOpt
belongs_to :user, Claper.Accounts.User
timestamps()
end
@doc false
def changeset(quiz_response, attrs) do
quiz_response
|> cast(attrs, [:attendee_identifier, :quiz_id, :quiz_question_id, :quiz_question_opt_id, :user_id])
|> validate_required([:quiz_id, :quiz_question_id, :quiz_question_opt_id])
end
end

View File

@@ -5,6 +5,8 @@ defmodule ClaperWeb.EventLive.Manage do
alias Claper.Polls alias Claper.Polls
alias Claper.Forms alias Claper.Forms
alias Claper.Embeds alias Claper.Embeds
# Add this line
alias Claper.Quizzes
@impl true @impl true
def mount(%{"code" => code}, session, socket) do def mount(%{"code" => code}, session, socket) do
@@ -403,6 +405,38 @@ defmodule ClaperWeb.EventLive.Manage do
|> interactions_at_position(socket.assigns.state.position)} |> interactions_at_position(socket.assigns.state.position)}
end end
@impl true
def handle_event("quiz-set-active", %{"id" => id}, socket) do
with quiz <- Quizzes.get_quiz!(id), :ok <- Claper.Interactions.enable_interaction(quiz) do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_interaction, quiz}
)
{:noreply,
socket
|> assign(:current_interaction, quiz)
|> interactions_at_position(socket.assigns.state.position)}
end
end
def handle_event("quiz-set-inactive", %{"id" => id}, socket) do
with quiz <- Quizzes.get_quiz!(id),
{:ok, _} <- Claper.Interactions.disable_interaction(quiz) do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event.uuid}",
{:current_interaction, nil}
)
end
{:noreply,
socket
|> assign(:current_interaction, nil)
|> interactions_at_position(socket.assigns.state.position)}
end
@impl true @impl true
def handle_event( def handle_event(
"ban", "ban",
@@ -645,6 +679,14 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("delete-quiz", %{"id" => id}, socket) do
quiz = Quizzes.get_quiz!(id)
{:ok, _} = Quizzes.delete_quiz(socket.assigns.event.uuid, quiz)
{:noreply, socket}
end
@impl true @impl true
def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do
{:noreply, socket |> assign(:preview, !preview)} {:noreply, socket |> assign(:preview, !preview)}
@@ -736,6 +778,35 @@ defmodule ClaperWeb.EventLive.Manage do
|> assign(:embed, embed) |> assign(:embed, embed)
end end
defp apply_action(socket, :add_quiz, _params) do
socket
|> assign(:create, "quiz")
|> assign(:quiz, %Quizzes.Quiz{
quiz_questions: [
%Quizzes.QuizQuestion{
id: 0,
quiz_question_opts: [
%Quizzes.QuizQuestionOpt{
id: 0
},
%Quizzes.QuizQuestionOpt{
id: 1
}
]
}
]
})
end
defp apply_action(socket, :edit_quiz, %{"id" => id}) do
quiz = Quizzes.get_quiz!(id)
socket
|> assign(:create, "quiz")
|> assign(:create_action, :edit)
|> assign(:quiz, quiz)
end
defp pin(post, socket) do defp pin(post, socket) do
{:ok, _updated_post} = Claper.Posts.toggle_pin_post(post) {:ok, _updated_post} = Claper.Posts.toggle_pin_post(post)

View File

@@ -234,6 +234,38 @@
</div> </div>
</a> </a>
</li> </li>
<li id="option-4" role="option" tabindex="-1">
<a
data-phx-link="patch"
data-phx-link-state="push"
href={~p"/e/#{@event.code}/manage/add/quiz"}
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
>
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-7 h-7"
>
<path
stroke-linecap="round"
stroke-
linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="ml-4 flex-auto text-left">
<p class="font-medium text-gray-700"><%= gettext("Quiz") %></p>
<p class="text-gray-500">
<%= gettext("Add a quiz to test your public.") %>
</p>
</div>
</a>
</li>
</ul> </ul>
<% end %> <% end %>
<%= if @create=="poll" do %> <%= if @create=="poll" do %>
@@ -299,6 +331,27 @@
</div> </div>
<% end %> <% end %>
<%= if @create=="quiz" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= case @create_action do
:new -> gettext("New quiz")
:edit -> gettext("Edit quiz")
end %>
</p>
<.live_component
module={ClaperWeb.QuizLive.QuizComponent}
id="quiz-create"
event_uuid={@event.uuid}
presentation_file={@event.presentation_file}
quiz={@quiz}
live_action={@create_action}
position={@state.position}
return_to={~p"/e/#{@event.code}/manage"}
/>
</div>
<% end %>
<%= if @create == "import" do %> <%= if @create == "import" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3"> <div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold"> <p class="text-xl font-bold">

View File

@@ -0,0 +1,124 @@
defmodule ClaperWeb.QuizLive.QuizComponent do
alias Claper.Quizzes.QuizQuestionOpt
use ClaperWeb, :live_component
alias Claper.Quizzes
@impl true
def update(%{quiz: quiz} = assigns, socket) do
changeset = Quizzes.change_quiz(quiz)
{:ok,
socket
|> assign(assigns)
|> assign_new(:dark, fn -> false end)
|> assign(:changeset, changeset)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
quiz = Quizzes.get_quiz!(id)
{:ok, _} = Quizzes.delete_quiz(socket.assigns.event_uuid, quiz)
{:noreply, socket |> push_navigate(to: socket.assigns.return_to)}
end
@impl true
def handle_event("validate", %{"quiz" => quiz_params}, socket) do
changeset =
socket.assigns.quiz
|> Quizzes.change_quiz(quiz_params)
|> Map.put(:action, :validate)
{:noreply, socket |> assign(:changeset, changeset)}
end
@impl true
def handle_event("save", %{"quiz" => quiz_params}, socket) do
save_quiz(socket, socket.assigns.live_action, quiz_params)
end
@impl true
def handle_event("add_quiz_question", _params, %{assigns: %{changeset: changeset}} = socket) do
{:noreply, assign(socket, :changeset, changeset |> Quizzes.add_quiz_question())}
end
@impl true
def handle_event("add_quiz_question_opt", %{"question_index" => index}, %{assigns: %{changeset: changeset}} = socket) do
index = String.to_integer(index)
{:noreply, assign(socket, :changeset, changeset |> Quizzes.add_quiz_question_opt(index))}
end
@impl true
def handle_event("remove_quiz_question", %{"index" => index}, socket) do
index = String.to_integer(index)
changeset =
socket.assigns.changeset
|> Ecto.Changeset.update_change(:quiz_questions, fn questions ->
List.delete_at(questions, index)
end)
{:noreply, assign(socket, :changeset, changeset)}
end
@impl true
def handle_event("remove_opt",
%{"opt" => opt} = _params,
%{assigns: %{changeset: changeset}} = socket
) do
{opt, _} = Integer.parse(opt)
quiz_opt = Enum.at(Ecto.Changeset.get_field(changeset, :quiz_question_opts), opt)
{:noreply, assign(socket, :changeset, changeset |> Quizzes.remove_quiz_opt(quiz_opt))}
end
defp save_quiz(socket, :edit, quiz_params) do
case Quizzes.update_quiz(
socket.assigns.event_uuid,
socket.assigns.quiz,
quiz_params
) do
{:ok, _quiz} ->
{:noreply,
socket
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_quiz(socket, :new, quiz_params) do
case Quizzes.create_quiz(
quiz_params
|> Map.put("presentation_file_id", socket.assigns.presentation_file.id)
|> Map.put("position", socket.assigns.position)
|> Map.put("enabled", false)
) do
{:ok, quiz} ->
{:noreply,
socket
|> maybe_change_current_quiz(quiz)
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
defp maybe_change_current_quiz(socket, %{enabled: true} = quiz) do
quiz = Quizzes.get_quiz!(quiz.id)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{socket.assigns.event_uuid}",
{:current_quiz, quiz}
)
socket
end
defp maybe_change_current_quiz(socket, _), do: socket
end

View File

@@ -0,0 +1,113 @@
<div>
<.form
:let={f}
for={@changeset}
id="form-quiz"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="my-3 mb-10">
<ClaperWeb.Component.Input.text
form={f}
key={:title}
name={gettext("Title")}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
</div>
<%= inputs_for f, :quiz_questions, fn q -> %>
<div class="mb-8 p-4 border rounded-md">
<div class="flex gap-x-3 mt-3 items-center justify-start">
<ClaperWeb.Component.Input.text
form={q}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:content}
name={gettext("Question")}
autofocus="true"
required="true"
/>
</div>
<%= inputs_for q, :quiz_question_opts, fn o -> %>
<div class="ml-4 mt-2">
<ClaperWeb.Component.Input.text
form={o}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:content}
name={gettext("Option %{index}", index: o.index + 1)}
required="true"
/>
<%= checkbox(o, :is_correct, class: "mr-2") %>
<%= label(o, :is_correct, gettext("Correct answer"), class: if(@dark, do: "text-white")) %>
</div>
<% end %>
<button
type="button"
phx-click="add_quiz_question_opt"
phx-value-question_index={q.index}
phx-target={@myself}
class="mt-2 px-3 py-1 text-sm rounded-md bg-secondary-500 hover:bg-secondary-600 text-white transition"
>
<%= gettext("Add Option") %>
</button>
<%= if q.index > 0 do %>
<button
type="button"
phx-click="remove_quiz_question"
phx-value-index={q.index}
phx-target={@myself}
class="mt-2 ml-2 px-3 py-1 text-sm rounded-md bg-red-500 hover:bg-red-600 text-white transition"
>
<%= gettext("Remove Question") %>
</button>
<% end %>
</div>
<% end %>
<button
type="button"
phx-click="add_quiz_question"
phx-target={@myself}
class="mb-4 px-4 py-2 rounded-md bg-primary-500 hover:bg-primary-600 text-white transition"
>
<%= gettext("Add Question") %>
</button>
<div class="flex space-x-3">
<button
type="submit"
phx_disable_with="Loading..."
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
>
<%= case @live_action do
:new -> gettext("Create")
:edit -> gettext("Save")
end %>
</button>
<%= if @live_action == :edit do %>
<%= link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_target: @myself,
phx_value_id: @quiz.id,
data: [
confirm:
gettext(
"This will delete all responses associated and the quiz itself, are you sure?"
)
],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
<% end %>
</div>
</.form>
</div>

View File

@@ -78,6 +78,8 @@ defmodule ClaperWeb.Router do
live("/e/:code/manage/edit/form/:id", EventLive.Manage, :edit_form) live("/e/:code/manage/edit/form/:id", EventLive.Manage, :edit_form)
live("/e/:code/manage/add/embed", EventLive.Manage, :add_embed) live("/e/:code/manage/add/embed", EventLive.Manage, :add_embed)
live("/e/:code/manage/edit/embed/:id", EventLive.Manage, :edit_embed) live("/e/:code/manage/edit/embed/:id", EventLive.Manage, :edit_embed)
live("/e/:code/manage/add/quiz", EventLive.Manage, :add_quiz)
live("/e/:code/manage/edit/quiz/:id", EventLive.Manage, :edit_quiz)
end end
end end

View File

@@ -0,0 +1,50 @@
defmodule Claper.Repo.Migrations.CreateQuizzes do
use Ecto.Migration
def change do
create table(:quizzes) do
add :title, :string, size: 255
add :position, :integer, default: 0
add :presentation_file_id, references(:presentation_files, on_delete: :delete_all)
add :enabled, :boolean, default: false
add :show_results, :boolean, default: false
timestamps()
end
create table(:quiz_questions) do
add :content, :string, size: 255
add :type, :string, default: "qcm"
add :quiz_id, references(:quizzes, on_delete: :delete_all)
timestamps()
end
create table(:quiz_question_opts) do
add :content, :string, size: 255
add :is_correct, :boolean, default: false
add :response_count, :integer, default: 0
add :quiz_question_id, references(:quiz_questions, on_delete: :delete_all)
timestamps()
end
create table(:quiz_responses) do
add :attendee_identifier, :string
add :quiz_id, references(:quizzes, on_delete: :delete_all)
add :quiz_question_id, references(:quiz_questions, on_delete: :delete_all)
add :quiz_question_opt_id, references(:quiz_question_opts, on_delete: :delete_all)
add :user_id, references(:users, on_delete: :delete_all)
timestamps()
end
create index(:quizzes, [:presentation_file_id])
create index(:quiz_questions, [:quiz_id])
create index(:quiz_question_opts, [:quiz_question_id])
create index(:quiz_responses, [:quiz_id])
create index(:quiz_responses, [:quiz_question_id])
create index(:quiz_responses, [:quiz_question_opt_id])
create index(:quiz_responses, [:user_id])
end
end