diff --git a/lib/lanttern/schools.ex b/lib/lanttern/schools.ex index 238223b8..b26c9f34 100644 --- a/lib/lanttern/schools.ex +++ b/lib/lanttern/schools.ex @@ -7,6 +7,7 @@ defmodule Lanttern.Schools do import Lanttern.RepoHelpers alias Lanttern.Repo alias Lanttern.Schools.School + alias Lanttern.Schools.Cycle alias Lanttern.Schools.Class alias Lanttern.Schools.Student alias Lanttern.Schools.Teacher @@ -108,6 +109,106 @@ defmodule Lanttern.Schools do School.changeset(school, attrs) end + @doc """ + Returns the list of school cycles. + + ### Options: + + `:schools_ids` – filter classes by schools + + ## Examples + + iex> list_cycles() + [%Cycle{}, ...] + + """ + def list_cycles(opts \\ []) do + Cycle + |> maybe_filter_by_schools(opts) + |> Repo.all() + end + + @doc """ + Gets a single cycle. + + Raises `Ecto.NoResultsError` if the Cycle does not exist. + + ## Examples + + iex> get_cycle!(123) + %Cycle{} + + iex> get_cycle!(456) + ** (Ecto.NoResultsError) + + """ + def get_cycle!(id), do: Repo.get!(Cycle, id) + + @doc """ + Creates a cycle. + + ## Examples + + iex> create_cycle(%{field: value}) + {:ok, %Cycle{}} + + iex> create_cycle(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_cycle(attrs \\ %{}) do + %Cycle{} + |> Cycle.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a cycle. + + ## Examples + + iex> update_cycle(cycle, %{field: new_value}) + {:ok, %Cycle{}} + + iex> update_cycle(cycle, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_cycle(%Cycle{} = cycle, attrs) do + cycle + |> Cycle.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a cycle. + + ## Examples + + iex> delete_cycle(cycle) + {:ok, %Cycle{}} + + iex> delete_cycle(cycle) + {:error, %Ecto.Changeset{}} + + """ + def delete_cycle(%Cycle{} = cycle) do + Repo.delete(cycle) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking cycle changes. + + ## Examples + + iex> change_cycle(cycle) + %Ecto.Changeset{data: %Cycle{}} + + """ + def change_cycle(%Cycle{} = cycle, attrs \\ %{}) do + Cycle.changeset(cycle, attrs) + end + @doc """ Returns the list of classes. @@ -124,28 +225,43 @@ defmodule Lanttern.Schools do """ def list_classes(opts \\ []) do Class - |> maybe_filter_classes_by_schools(opts) + |> maybe_filter_by_schools(opts) |> Repo.all() |> maybe_preload(opts) end - defp maybe_filter_classes_by_schools(classes_query, opts) do - case Keyword.get(opts, :schools_ids) do - nil -> - classes_query + @doc """ + Returns the list of user's school classes. - schools_ids -> - from( - c in classes_query, - where: c.school_id in ^schools_ids - ) - end + The list is sorted by cycle end date (desc), class year (asc), and class name (asc). + + ## Examples + + iex> list_user_classes() + [%Class{}, ...] + + """ + def list_user_classes(%{current_profile: %{teacher: %{school_id: school_id}}} = _current_user) do + from( + cl in Class, + join: cy in assoc(cl, :cycle), + left_join: s in assoc(cl, :students), + left_join: y in assoc(cl, :years), + group_by: [cl.id, cy.end_at], + order_by: [desc: cy.end_at, asc: min(y.id), asc: cl.name], + where: cl.school_id == ^school_id, + preload: [:cycle, :students, :years] + ) + |> Repo.all() end + def list_user_classes(_current_user), + do: {:error, "User not allowed to list classes"} + @doc """ Gets a single class. - Raises `Ecto.NoResultsError` if the Class does not exist. + Returns nil if the Class does not exist. ### Options: @@ -157,8 +273,18 @@ defmodule Lanttern.Schools do %Class{} iex> get_class!(456) - ** (Ecto.NoResultsError) + nil + """ + def get_class(id, opts \\ []) do + Repo.get(Class, id) + |> maybe_preload(opts) + end + + @doc """ + Gets a single class. + + Same as `get_class/2`, but raises `Ecto.NoResultsError` if the Class does not exist. """ def get_class!(id, opts \\ []) do Repo.get!(Class, id) @@ -197,7 +323,7 @@ defmodule Lanttern.Schools do """ def update_class(%Class{} = class, attrs) do class - |> Repo.preload(:students) + |> Repo.preload([:students, :years]) |> Class.changeset(attrs) |> Repo.update() end @@ -269,7 +395,7 @@ defmodule Lanttern.Schools do @doc """ Gets a single student. - Raises `Ecto.NoResultsError` if the Student does not exist. + Returns `nil` if the Student does not exist. ### Options: @@ -277,12 +403,22 @@ defmodule Lanttern.Schools do ## Examples - iex> get_student!(123) + iex> get_student(123) %Student{} - iex> get_student!(456) - ** (Ecto.NoResultsError) + iex> get_student(456) + nil + + """ + def get_student(id, opts \\ []) do + Repo.get(Student, id) + |> maybe_preload(opts) + end + @doc """ + Gets a single student. + + Same as `get_student/2`, but raises `Ecto.NoResultsError` if the Student does not exist. """ def get_student!(id, opts \\ []) do Repo.get!(Student, id) @@ -482,10 +618,10 @@ defmodule Lanttern.Schools do [{csv_student, {:ok, %Student{}}}, ...] """ - def create_students_from_csv(csv_rows, class_name_id_map, school_id) do + def create_students_from_csv(csv_rows, class_name_id_map, school_id, cycle_id) do Ecto.Multi.new() |> Ecto.Multi.run(:classes, fn _repo, _changes -> - insert_csv_classes(class_name_id_map, school_id) + insert_csv_classes(class_name_id_map, school_id, cycle_id) end) |> Ecto.Multi.run(:students, fn _repo, changes -> insert_csv_students(changes, csv_rows, school_id) @@ -506,26 +642,27 @@ defmodule Lanttern.Schools do end end - defp insert_csv_classes(class_name_id_map, school_id) do + defp insert_csv_classes(class_name_id_map, school_id, cycle_id) do name_class_map = class_name_id_map - |> Enum.map(&get_or_insert_csv_class(&1, school_id)) + |> Enum.map(&get_or_insert_csv_class(&1, school_id, cycle_id)) |> Enum.into(%{}) {:ok, name_class_map} end - defp get_or_insert_csv_class({csv_class_name, ""}, school_id) do + defp get_or_insert_csv_class({csv_class_name, ""}, school_id, cycle_id) do {:ok, class} = create_class(%{ name: csv_class_name, - school_id: school_id + school_id: school_id, + cycle_id: cycle_id }) {csv_class_name, class} end - defp get_or_insert_csv_class({csv_class_name, class_id}, _school_id), + defp get_or_insert_csv_class({csv_class_name, class_id}, _school_id, _cycle_id), do: {csv_class_name, get_class!(class_id)} defp insert_csv_students(%{classes: name_class_map} = _changes, csv_rows, school_id) do @@ -715,4 +852,19 @@ defmodule Lanttern.Schools do {:ok, response} end + + # Helpers + + defp maybe_filter_by_schools(query, opts) do + case Keyword.get(opts, :schools_ids) do + nil -> + query + + schools_ids -> + from( + q in query, + where: q.school_id in ^schools_ids + ) + end + end end diff --git a/lib/lanttern/schools/class.ex b/lib/lanttern/schools/class.ex index d8c0327c..703f09d4 100644 --- a/lib/lanttern/schools/class.ex +++ b/lib/lanttern/schools/class.ex @@ -8,12 +8,20 @@ defmodule Lanttern.Schools.Class do schema "classes" do field :name, :string field :students_ids, {:array, :id}, virtual: true + field :years_ids, {:array, :id}, virtual: true belongs_to :school, Lanttern.Schools.School + belongs_to :cycle, Lanttern.Schools.Cycle many_to_many :students, Lanttern.Schools.Student, join_through: "classes_students", - on_replace: :delete + on_replace: :delete, + preload_order: [asc: :name] + + many_to_many :years, Lanttern.Taxonomy.Year, + join_through: "classes_years", + on_replace: :delete, + preload_order: [asc: :id] timestamps() end @@ -21,9 +29,15 @@ defmodule Lanttern.Schools.Class do @doc false def changeset(class, attrs) do class - |> cast(attrs, [:name, :school_id, :students_ids]) - |> validate_required([:name, :school_id]) + |> cast(attrs, [:name, :school_id, :students_ids, :years_ids, :cycle_id]) + |> validate_required([:name, :school_id, :cycle_id]) + |> foreign_key_constraint( + :cycle_id, + name: :classes_cycle_id_fkey, + message: "Check if the cycle exists and belongs to the same school" + ) |> put_students() + |> put_years() end defp put_students(changeset) do @@ -43,4 +57,22 @@ defmodule Lanttern.Schools.Class do changeset |> put_assoc(:students, students) end + + defp put_years(changeset) do + put_years( + changeset, + get_change(changeset, :years_ids) + ) + end + + defp put_years(changeset, nil), do: changeset + + defp put_years(changeset, years_ids) do + years = + from(y in Lanttern.Taxonomy.Year, where: y.id in ^years_ids) + |> Repo.all() + + changeset + |> put_assoc(:years, years) + end end diff --git a/lib/lanttern/schools/cycle.ex b/lib/lanttern/schools/cycle.ex new file mode 100644 index 00000000..630988be --- /dev/null +++ b/lib/lanttern/schools/cycle.ex @@ -0,0 +1,25 @@ +defmodule Lanttern.Schools.Cycle do + use Ecto.Schema + import Ecto.Changeset + + schema "school_cycles" do + field :name, :string + field :start_at, :date + field :end_at, :date + belongs_to :school, Lanttern.Schools.School + + timestamps() + end + + @doc false + def changeset(cycle, attrs) do + cycle + |> cast(attrs, [:name, :start_at, :end_at, :school_id]) + |> validate_required([:name, :start_at, :end_at, :school_id]) + |> check_constraint( + :end_at, + name: :cycle_end_date_is_greater_than_start_date, + message: "End date should be greater than start date" + ) + end +end diff --git a/lib/lanttern/schools/student.ex b/lib/lanttern/schools/student.ex index 0a70996c..43ba51e3 100644 --- a/lib/lanttern/schools/student.ex +++ b/lib/lanttern/schools/student.ex @@ -13,7 +13,8 @@ defmodule Lanttern.Schools.Student do many_to_many :classes, Lanttern.Schools.Class, join_through: "classes_students", - on_replace: :delete + on_replace: :delete, + preload_order: [asc: :name] timestamps() end diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index 11035f8d..b990f784 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -368,6 +368,62 @@ defmodule LantternWeb.CoreComponents do """ end + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.stream_table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :stream, Phoenix.LiveView.LiveStream, required: true + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def stream_table(assigns) do + ~H""" + + + + + + + + + + + + + +
<%= col[:label] %> + <%= gettext("Actions") %> +
+ <%= render_slot(col, row) %> + +
+ + <%= render_slot(action, row) %> + +
+
+ """ + end + @doc """ Renders a data list. @@ -535,6 +591,40 @@ defmodule LantternWeb.CoreComponents do |> Map.get(theme, "text-ltrn-subtle") end + @doc """ + Renders a student or teacher badge. + + ## Examples + + <.person_badge person={student} /> + + """ + attr :id, :string, default: nil + attr :person, :map, required: true + attr :theme, :string, default: "subtle", doc: "subtle | cyan" + attr :rest, :global + + def person_badge(assigns) do + ~H""" + + <.profile_icon profile_name={@person.name} size="xs" theme={@theme} /> + + <%= @person.name %> + + + """ + end + + defp person_badge_theme_style("cyan"), do: "text-ltrn-dark bg-ltrn-mesh-cyan" + defp person_badge_theme_style(_subtle), do: "text-ltrn-subtle bg-ltrn-lighter" + @doc """ Creates a style attr based on ordinal values `bg_color` and `text_color` """ @@ -581,7 +671,7 @@ defmodule LantternWeb.CoreComponents do ~H"""
+ + """ + attr :class, :any, default: nil + + slot :item, required: true do + attr :link, :string + end + + def breadcrumbs(assigns) do + ~H""" + + """ + end end diff --git a/lib/lanttern_web/controllers/admin_html/home.html.heex b/lib/lanttern_web/controllers/admin_html/home.html.heex index 49f19507..663bd7eb 100644 --- a/lib/lanttern_web/controllers/admin_html/home.html.heex +++ b/lib/lanttern_web/controllers/admin_html/home.html.heex @@ -27,6 +27,7 @@ <.link_list title="Schools"> <:item link={~p"/admin/schools"}>Schools + <:item link={~p"/admin/school_cycles"}>Cycles <:item link={~p"/admin/classes"}>Classes <:item link={~p"/admin/students"}>Students <:item link={~p"/admin/import_students"}>Import students diff --git a/lib/lanttern_web/controllers/class_controller.ex b/lib/lanttern_web/controllers/class_controller.ex index 4588de4a..b77f0afe 100644 --- a/lib/lanttern_web/controllers/class_controller.ex +++ b/lib/lanttern_web/controllers/class_controller.ex @@ -2,11 +2,12 @@ defmodule LantternWeb.ClassController do use LantternWeb, :controller import LantternWeb.SchoolsHelpers + import LantternWeb.TaxonomyHelpers alias Lanttern.Schools alias Lanttern.Schools.Class def index(conn, _params) do - classes = Schools.list_classes(preloads: [:school, :students]) + classes = Schools.list_classes(preloads: [:school, :cycle, :years, :students]) render(conn, :index, classes: classes) end @@ -14,9 +15,13 @@ defmodule LantternWeb.ClassController do changeset = Schools.change_class(%Class{}) student_options = generate_student_options() school_options = generate_school_options() + cycle_options = generate_cycle_options() + year_options = generate_year_options() render(conn, :new, school_options: school_options, + cycle_options: cycle_options, + year_options: year_options, student_options: student_options, changeset: changeset ) @@ -32,9 +37,13 @@ defmodule LantternWeb.ClassController do {:error, %Ecto.Changeset{} = changeset} -> student_options = generate_student_options() school_options = generate_school_options() + cycle_options = generate_cycle_options() + year_options = generate_year_options() render(conn, :new, school_options: school_options, + cycle_options: cycle_options, + year_options: year_options, student_options: student_options, changeset: changeset ) @@ -42,25 +51,34 @@ defmodule LantternWeb.ClassController do end def show(conn, %{"id" => id}) do - class = Schools.get_class!(id, preloads: [:school, :students]) + class = Schools.get_class!(id, preloads: [:school, :cycle, :years, :students]) render(conn, :show, class: class) end def edit(conn, %{"id" => id}) do school_options = generate_school_options() student_options = generate_student_options() + cycle_options = generate_cycle_options() + year_options = generate_year_options() - class = Schools.get_class!(id, preloads: :students) + class = Schools.get_class!(id, preloads: [:students, :years]) - # insert existing students_ids + # insert existing students_ids and years students_ids = Enum.map(class.students, & &1.id) - class = class |> Map.put(:students_ids, students_ids) + years_ids = Enum.map(class.years, & &1.id) + + class = + class + |> Map.put(:students_ids, students_ids) + |> Map.put(:years_ids, years_ids) changeset = Schools.change_class(class) render(conn, :edit, class: class, school_options: school_options, + cycle_options: cycle_options, + year_options: year_options, student_options: student_options, changeset: changeset ) @@ -77,11 +95,15 @@ defmodule LantternWeb.ClassController do {:error, %Ecto.Changeset{} = changeset} -> school_options = generate_school_options() + cycle_options = generate_cycle_options() + year_options = generate_year_options() student_options = generate_student_options() render(conn, :edit, class: class, school_options: school_options, + cycle_options: cycle_options, + year_options: year_options, student_options: student_options, changeset: changeset ) diff --git a/lib/lanttern_web/controllers/class_html.ex b/lib/lanttern_web/controllers/class_html.ex index 44a0b3b5..2d6526ff 100644 --- a/lib/lanttern_web/controllers/class_html.ex +++ b/lib/lanttern_web/controllers/class_html.ex @@ -9,6 +9,8 @@ defmodule LantternWeb.ClassHTML do attr :changeset, Ecto.Changeset, required: true attr :action, :string, required: true attr :school_options, :list, required: true + attr :year_options, :list, required: true + attr :cycle_options, :list, required: true attr :student_options, :list, required: true def class_form(assigns) diff --git a/lib/lanttern_web/controllers/class_html/class_form.html.heex b/lib/lanttern_web/controllers/class_html/class_form.html.heex index 7aebad91..3f499e2b 100644 --- a/lib/lanttern_web/controllers/class_html/class_form.html.heex +++ b/lib/lanttern_web/controllers/class_html/class_form.html.heex @@ -9,6 +9,21 @@ options={@school_options} prompt="Select school" /> + <.input + field={f[:cycle_id]} + type="select" + label="Cycle" + options={@cycle_options} + prompt="Select cycle" + /> + <.input + field={f[:years_ids]} + type="select" + label="Years" + options={@year_options} + prompt="Select years" + multiple + /> <.input field={f[:name]} type="text" label="Name" /> <.input field={f[:students_ids]} diff --git a/lib/lanttern_web/controllers/class_html/edit.html.heex b/lib/lanttern_web/controllers/class_html/edit.html.heex index 29632a54..a099aec3 100644 --- a/lib/lanttern_web/controllers/class_html/edit.html.heex +++ b/lib/lanttern_web/controllers/class_html/edit.html.heex @@ -5,6 +5,8 @@ <.class_form school_options={@school_options} + cycle_options={@cycle_options} + year_options={@year_options} student_options={@student_options} changeset={@changeset} action={~p"/admin/classes/#{@class}"} diff --git a/lib/lanttern_web/controllers/class_html/index.html.heex b/lib/lanttern_web/controllers/class_html/index.html.heex index f6595964..334a427b 100644 --- a/lib/lanttern_web/controllers/class_html/index.html.heex +++ b/lib/lanttern_web/controllers/class_html/index.html.heex @@ -10,6 +10,10 @@ <.table id="classes" rows={@classes} row_click={&JS.navigate(~p"/admin/classes/#{&1}")}> <:col :let={class} label="Name"><%= class.name %> <:col :let={class} label="School"><%= class.school.name %> + <:col :let={class} label="Cycle"><%= class.cycle.name %> + <:col :let={class} label="Years"> + <%= class.years |> Enum.map(& &1.name) |> Enum.join(", ") %> + <:col :let={class} label="Students"><%= length(class.students) %> <:action :let={class}>
diff --git a/lib/lanttern_web/controllers/class_html/new.html.heex b/lib/lanttern_web/controllers/class_html/new.html.heex index 277f8334..184474e8 100644 --- a/lib/lanttern_web/controllers/class_html/new.html.heex +++ b/lib/lanttern_web/controllers/class_html/new.html.heex @@ -5,6 +5,8 @@ <.class_form school_options={@school_options} + cycle_options={@cycle_options} + year_options={@year_options} student_options={@student_options} changeset={@changeset} action={~p"/admin/classes"} diff --git a/lib/lanttern_web/controllers/class_html/show.html.heex b/lib/lanttern_web/controllers/class_html/show.html.heex index 0aad0a21..b2216bd7 100644 --- a/lib/lanttern_web/controllers/class_html/show.html.heex +++ b/lib/lanttern_web/controllers/class_html/show.html.heex @@ -11,6 +11,12 @@ <.list> <:item title="Name"><%= @class.name %> <:item title="School"><%= @class.school.name %> + <:item title="Cycle"><%= @class.cycle.name %> + <:item title="Years"> + <%= @class.years + |> Enum.map(& &1.name) + |> Enum.join(", ") %> + <:item title="Students"> <%= @class.students |> Enum.map(& &1.name) diff --git a/lib/lanttern_web/helpers/schools_helpers.ex b/lib/lanttern_web/helpers/schools_helpers.ex index a542794c..a705ae10 100644 --- a/lib/lanttern_web/helpers/schools_helpers.ex +++ b/lib/lanttern_web/helpers/schools_helpers.ex @@ -14,6 +14,21 @@ defmodule LantternWeb.SchoolsHelpers do |> Enum.map(fn s -> {s.name, s.id} end) end + @doc """ + Generate list of cycles to use as `Phoenix.HTML.Form.options_for_select/2` arg + + Accepts `list_opts` arg, which will be forwarded to `Schools.list_cycles/1`. + + ## Examples + + iex> generate_cycle_options() + [{"cycle name", 1}, ...] + """ + def generate_cycle_options(list_opts \\ []) do + Schools.list_cycles(list_opts) + |> Enum.map(fn c -> {c.name, c.id} end) + end + @doc """ Generate list of classes to use as `Phoenix.HTML.Form.options_for_select/2` arg diff --git a/lib/lanttern_web/live/admin/cycle_live/form_component.ex b/lib/lanttern_web/live/admin/cycle_live/form_component.ex new file mode 100644 index 00000000..fc603e49 --- /dev/null +++ b/lib/lanttern_web/live/admin/cycle_live/form_component.ex @@ -0,0 +1,101 @@ +defmodule LantternWeb.Admin.CycleLive.FormComponent do + use LantternWeb, :live_component + + alias Lanttern.Schools + import LantternWeb.SchoolsHelpers + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage cycle records in your database. + + + <.simple_form + for={@form} + id="cycle-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input + field={@form[:school_id]} + type="select" + label="School" + prompt="Select school" + options={@school_options} + /> + <.input field={@form[:name]} type="text" label="Name" /> + <.input field={@form[:start_at]} type="date" label="Start at" /> + <.input field={@form[:end_at]} type="date" label="End at" /> + <:actions> + <.button phx-disable-with="Saving...">Save Cycle + + +
+ """ + end + + @impl true + def update(%{cycle: cycle} = assigns, socket) do + changeset = Schools.change_cycle(cycle) + + {:ok, + socket + |> assign(assigns) + |> assign(:school_options, generate_school_options()) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"cycle" => cycle_params}, socket) do + changeset = + socket.assigns.cycle + |> Schools.change_cycle(cycle_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"cycle" => cycle_params}, socket) do + save_cycle(socket, socket.assigns.action, cycle_params) + end + + defp save_cycle(socket, :edit, cycle_params) do + case Schools.update_cycle(socket.assigns.cycle, cycle_params) do + {:ok, cycle} -> + notify_parent({:saved, cycle}) + + {:noreply, + socket + |> put_flash(:info, "Cycle updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_cycle(socket, :new, cycle_params) do + case Schools.create_cycle(cycle_params) do + {:ok, cycle} -> + notify_parent({:saved, cycle}) + + {:noreply, + socket + |> put_flash(:info, "Cycle created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/lanttern_web/live/admin/cycle_live/index.ex b/lib/lanttern_web/live/admin/cycle_live/index.ex new file mode 100644 index 00000000..1902104b --- /dev/null +++ b/lib/lanttern_web/live/admin/cycle_live/index.ex @@ -0,0 +1,47 @@ +defmodule LantternWeb.Admin.CycleLive.Index do + use LantternWeb, {:live_view, layout: :admin} + + alias Lanttern.Schools + alias Lanttern.Schools.Cycle + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :school_cycles, Schools.list_cycles())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Cycle") + |> assign(:cycle, Schools.get_cycle!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Cycle") + |> assign(:cycle, %Cycle{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing School cycles") + |> assign(:cycle, nil) + end + + @impl true + def handle_info({LantternWeb.Admin.CycleLive.FormComponent, {:saved, cycle}}, socket) do + {:noreply, stream_insert(socket, :school_cycles, cycle)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + cycle = Schools.get_cycle!(id) + {:ok, _} = Schools.delete_cycle(cycle) + + {:noreply, stream_delete(socket, :school_cycles, cycle)} + end +end diff --git a/lib/lanttern_web/live/admin/cycle_live/index.html.heex b/lib/lanttern_web/live/admin/cycle_live/index.html.heex new file mode 100644 index 00000000..1086f96c --- /dev/null +++ b/lib/lanttern_web/live/admin/cycle_live/index.html.heex @@ -0,0 +1,48 @@ +<.header> + Listing School cycles + <:actions> + <.link patch={~p"/admin/school_cycles/new"}> + <.button>New Cycle + + + + +<.table + id="school_cycles" + rows={@streams.school_cycles} + row_click={fn {_id, cycle} -> JS.navigate(~p"/admin/school_cycles/#{cycle}") end} +> + <:col :let={{_id, cycle}} label="Name"><%= cycle.name %> + <:col :let={{_id, cycle}} label="Start at"><%= cycle.start_at %> + <:col :let={{_id, cycle}} label="End at"><%= cycle.end_at %> + <:action :let={{_id, cycle}}> +
+ <.link navigate={~p"/admin/school_cycles/#{cycle}"}>Show +
+ <.link patch={~p"/admin/school_cycles/#{cycle}/edit"}>Edit + + <:action :let={{id, cycle}}> + <.link + phx-click={JS.push("delete", value: %{id: cycle.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal + :if={@live_action in [:new, :edit]} + id="cycle-modal" + show + on_cancel={JS.patch(~p"/admin/school_cycles")} +> + <.live_component + module={LantternWeb.Admin.CycleLive.FormComponent} + id={@cycle.id || :new} + title={@page_title} + action={@live_action} + cycle={@cycle} + patch={~p"/admin/school_cycles"} + /> + diff --git a/lib/lanttern_web/live/admin/cycle_live/show.ex b/lib/lanttern_web/live/admin/cycle_live/show.ex new file mode 100644 index 00000000..92a71606 --- /dev/null +++ b/lib/lanttern_web/live/admin/cycle_live/show.ex @@ -0,0 +1,21 @@ +defmodule LantternWeb.Admin.CycleLive.Show do + use LantternWeb, {:live_view, layout: :admin} + + alias Lanttern.Schools + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:cycle, Schools.get_cycle!(id))} + end + + defp page_title(:show), do: "Show Cycle" + defp page_title(:edit), do: "Edit Cycle" +end diff --git a/lib/lanttern_web/live/admin/cycle_live/show.html.heex b/lib/lanttern_web/live/admin/cycle_live/show.html.heex new file mode 100644 index 00000000..0cafe2ef --- /dev/null +++ b/lib/lanttern_web/live/admin/cycle_live/show.html.heex @@ -0,0 +1,33 @@ +<.header> + Cycle <%= @cycle.id %> + <:subtitle>This is a cycle record from your database. + <:actions> + <.link patch={~p"/admin/school_cycles/#{@cycle}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit cycle + + + + +<.list> + <:item title="Name"><%= @cycle.name %> + <:item title="Start at"><%= @cycle.start_at %> + <:item title="End at"><%= @cycle.end_at %> + + +<.back navigate={~p"/admin/school_cycles"}>Back to school_cycles + +<.modal + :if={@live_action == :edit} + id="cycle-modal" + show + on_cancel={JS.patch(~p"/admin/school_cycles/#{@cycle}")} +> + <.live_component + module={LantternWeb.Admin.CycleLive.FormComponent} + id={@cycle.id} + title={@page_title} + action={@live_action} + cycle={@cycle} + patch={~p"/admin/school_cycles/#{@cycle}"} + /> + diff --git a/lib/lanttern_web/live/admin/school_live/import_students.ex b/lib/lanttern_web/live/admin/school_live/import_students.ex index 4b87eb87..e05daa1a 100644 --- a/lib/lanttern_web/live/admin/school_live/import_students.ex +++ b/lib/lanttern_web/live/admin/school_live/import_students.ex @@ -18,15 +18,23 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do phx-change="validate" class="flex items-start gap-10" > - <.input - field={@form[:school_id]} - type="select" - label="Select school" - options={@school_options} - prompt="No school selected" - class="flex-1" - /> - +
+ <.input + field={@form[:school_id]} + type="select" + label="Select school" + options={@school_options} + prompt="No school selected" + class="mb-4" + /> + <.input + field={@form[:cycle_id]} + type="select" + label="Select cycle" + options={@cycle_options} + prompt="No cycle selected" + /> +
assign(:state, "uploading") - |> assign(:form, to_form(%{"school_id" => "", "csv" => ""})) + |> assign(:form, to_form(%{"school_id" => "", "cycle_id" => "", "csv" => ""})) |> assign(:csv_error, nil) |> assign(:school_options, SchoolsHelpers.generate_school_options()) + |> assign(:cycle_options, []) |> allow_upload(:csv, accept: ~w(.csv), max_entries: 1) {:ok, socket} @@ -299,8 +308,32 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do # event handlers @impl true + def handle_event( + "validate", + %{"_target" => ["school_id"], "school_id" => ""} = params, + socket + ) do + {:noreply, + socket + |> assign(:cycle_options, []) + # without this assign the input fields are reset + |> assign(:form, to_form(params |> Map.put("cycle_id", "")))} + end + + def handle_event( + "validate", + %{"_target" => ["school_id"], "school_id" => school_id} = params, + socket + ) do + {:noreply, + socket + |> assign(:cycle_options, SchoolsHelpers.generate_cycle_options(schools_ids: [school_id])) + # without this assign the input fields are reset + |> assign(:form, to_form(params))} + end + def handle_event("validate", params, socket) do - # without this assign the school_id field is reset + # without this assign the input fields are reset {:noreply, assign(socket, :form, to_form(params))} end @@ -320,6 +353,16 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do {:noreply, socket} end + def handle_event("upload", %{"cycle_id" => ""} = params, socket) do + errors = [cycle_id: {"Can't be blank", []}] + + socket = + socket + |> assign(:form, to_form(params, errors: errors)) + + {:noreply, socket} + end + def handle_event("upload", params, %{assigns: %{uploads: %{csv: %{entries: []}}}} = socket) do errors = [csv: "Can't be blank"] @@ -330,7 +373,7 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do {:noreply, socket} end - def handle_event("upload", %{"school_id" => school_id}, socket) do + def handle_event("upload", %{"school_id" => school_id, "cycle_id" => cycle_id}, socket) do case parse_upload_entry(socket, hd(socket.assigns.uploads.csv.entries)) do {:ok, csv_rows} -> school_classes = Schools.list_classes(schools_ids: [school_id]) @@ -340,6 +383,7 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do socket = socket |> assign(:school_id, school_id) + |> assign(:cycle_id, cycle_id) |> assign(:school_classes, school_classes) |> assign(:class_options, class_options) |> assign(:csv_class_name_id_map, csv_class_name_id_map) @@ -366,7 +410,8 @@ defmodule LantternWeb.Admin.SchoolLive.ImportStudents do case Schools.create_students_from_csv( socket.assigns.csv_rows, socket.assigns.csv_class_name_id_map, - socket.assigns.school_id + socket.assigns.school_id, + socket.assigns.cycle_id ) do {:ok, import_result} -> socket = diff --git a/lib/lanttern_web/live/assessment_point_live/details.html.heex b/lib/lanttern_web/live/assessment_point_live/details.html.heex index d55670ee..501149e6 100644 --- a/lib/lanttern_web/live/assessment_point_live/details.html.heex +++ b/lib/lanttern_web/live/assessment_point_live/details.html.heex @@ -1,10 +1,9 @@
<.page_title_with_menu>Assessment point details -
- <.link navigate={~p"/assessment_points"} class="underline">Assessment points explorer - / - Details -
+ <.breadcrumbs class="mt-2"> + <:item link={~p"/assessment_points"}>Assessment points explorer + <:item>Details +
<.link navigate={~p"/assessment_points"} class="flex items-center text-sm text-ltrn-subtle"> diff --git a/lib/lanttern_web/live/menu_component.ex b/lib/lanttern_web/live/menu_component.ex index 6774eaf8..48da1e44 100644 --- a/lib/lanttern_web/live/menu_component.ex +++ b/lib/lanttern_web/live/menu_component.ex @@ -16,6 +16,9 @@ defmodule LantternWeb.MenuComponent do <.nav_item active={@active_nav == :dashboard} path={~p"/dashboard"}> Dashboard + <.nav_item active={@active_nav == :school} path={~p"/school"}> + School + <.nav_item active={@active_nav == :assessment_points} path={~p"/assessment_points"}> Assessment points @@ -47,10 +50,9 @@ defmodule LantternWeb.MenuComponent do
  • <.link href={~p"/admin"} - target="_blank" class="flex items-center gap-2 underline hover:text-ltrn-dark" > - Admin <.icon name="hero-arrow-top-right-on-square" /> + Admin
  • @@ -100,7 +102,7 @@ defmodule LantternWeb.MenuComponent do ~H"""
  • <.link - navigate={@path} + patch={@path} class={[ "group relative block p-10 font-display font-black text-lg", if(@active, do: "text-ltrn-dark", else: "text-ltrn-subtle underline hover:text-ltrn-dark") @@ -198,9 +200,15 @@ defmodule LantternWeb.MenuComponent do :dashboard socket.view in [ - LantternWeb.AssessmentPointsLive, - LantternWeb.AssessmentPointsExplorerLive, - LantternWeb.AssessmentPointLive + LantternWeb.SchoolLive.Show, + LantternWeb.SchoolLive.Class, + LantternWeb.SchoolLive.Student + ] -> + :school + + socket.view in [ + LantternWeb.AssessmentPointLive.Explorer, + LantternWeb.AssessmentPointLive.Details ] -> :assessment_points diff --git a/lib/lanttern_web/live/school_live/class.ex b/lib/lanttern_web/live/school_live/class.ex new file mode 100644 index 00000000..5e1038f5 --- /dev/null +++ b/lib/lanttern_web/live/school_live/class.ex @@ -0,0 +1,42 @@ +defmodule LantternWeb.SchoolLive.Class do + use LantternWeb, :live_view + + alias Lanttern.Schools + + # lifecycle + + def mount(_params, _session, socket) do + user_school = + case socket.assigns.current_user.current_profile.type do + "student" -> + socket.assigns.current_user.current_profile.student.school + + "teacher" -> + socket.assigns.current_user.current_profile.teacher.school + end + + {:ok, + socket + |> assign(:user_school, user_school)} + end + + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, %{"id" => id}) do + case Schools.get_class(id, preloads: :students) do + class when is_nil(class) or class.school_id != socket.assigns.user_school.id -> + socket + |> put_flash(:error, "Couldn't find class") + |> redirect(to: ~p"/school") + + class -> + socket + |> assign(:class_name, class.name) + |> stream(:students, class.students) + end + end + + defp apply_action(socket, _live_action, _params), do: socket +end diff --git a/lib/lanttern_web/live/school_live/class.html.heex b/lib/lanttern_web/live/school_live/class.html.heex new file mode 100644 index 00000000..32284154 --- /dev/null +++ b/lib/lanttern_web/live/school_live/class.html.heex @@ -0,0 +1,26 @@ +
    + <.page_title_with_menu><%= @class_name %> + <.breadcrumbs class="mt-2"> + <:item link={~p"/school"}>School + <:item>Class + + +

    + Students +

    +
    +
    + <.link + patch={~p"/school/student/#{student.id}"} + class="flex-1 font-display font-black text-2xl underline hover:text-ltrn-subtle" + > + <%= student.name %> + + <.profile_icon profile_name={student.name} class="-mt-2 -mr-2" /> +
    +
    +
    diff --git a/lib/lanttern_web/live/school_live/show.ex b/lib/lanttern_web/live/school_live/show.ex new file mode 100644 index 00000000..c74f6cad --- /dev/null +++ b/lib/lanttern_web/live/school_live/show.ex @@ -0,0 +1,63 @@ +defmodule LantternWeb.SchoolLive.Show do + use LantternWeb, :live_view + + alias Lanttern.Schools + + # function components + + attr :students, :list, required: true + + def class_students(%{students: students} = assigns) when length(students) > 5 do + assigns = + assigns + |> assign(:len, "+ #{length(students) - 3} students") + |> assign(:students, students |> Enum.take(3)) + + ~H""" +
    + <.person_badge :for={std <- @students} person={std} theme="cyan" /> + <%= @len %> +
    + """ + end + + def class_students(%{students: []} = assigns) do + ~H""" + No students in this class + """ + end + + def class_students(assigns) do + ~H""" +
    + <.person_badge :for={std <- @students} person={std} theme="cyan" /> +
    + """ + end + + # lifecycle + + def mount(_params, _session, socket) do + school = + case socket.assigns.current_user.current_profile.type do + "student" -> + socket.assigns.current_user.current_profile.student.school + + "teacher" -> + socket.assigns.current_user.current_profile.teacher.school + end + + classes = Schools.list_user_classes(socket.assigns.current_user) + + {:ok, + socket + |> assign(:school, school) + |> stream(:classes, classes)} + end + + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, _live_action, _params), do: socket +end diff --git a/lib/lanttern_web/live/school_live/show.html.heex b/lib/lanttern_web/live/school_live/show.html.heex new file mode 100644 index 00000000..b3f62d1e --- /dev/null +++ b/lib/lanttern_web/live/school_live/show.html.heex @@ -0,0 +1,32 @@ +
    + <.page_title_with_menu><%= @school.name %> +
    +

    + All classes +

    + <%!-- <.link + type="button" + class="shrink-0 flex items-center gap-2 font-display text-base underline hover:text-ltrn-primary" + patch={~p"/school/new_class"} + > + Create new class <.icon name="hero-plus-circle" class="w-6 h-6 text-ltrn-primary" /> + --%> +
    + + <.stream_table + id="classes" + stream={@streams.classes} + row_click={&JS.navigate(~p"/school/class/#{&1}")} + > + <:col :let={class} label="Class"><%= class.name %> + <:col :let={class} label="Students"> + <.class_students students={class.students} /> + + <:col :let={class} label="Years"> + <%= class.years + |> Enum.map(& &1.name) + |> Enum.join(", ") %> + + <:col :let={class} label="Cycle"><%= class.cycle.name %> + +
    diff --git a/lib/lanttern_web/live/school_live/student.ex b/lib/lanttern_web/live/school_live/student.ex new file mode 100644 index 00000000..201db883 --- /dev/null +++ b/lib/lanttern_web/live/school_live/student.ex @@ -0,0 +1,42 @@ +defmodule LantternWeb.SchoolLive.Student do + use LantternWeb, :live_view + + alias Lanttern.Schools + + # lifecycle + + def mount(_params, _session, socket) do + user_school = + case socket.assigns.current_user.current_profile.type do + "student" -> + socket.assigns.current_user.current_profile.student.school + + "teacher" -> + socket.assigns.current_user.current_profile.teacher.school + end + + {:ok, + socket + |> assign(:user_school, user_school)} + end + + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, %{"id" => id}) do + case Schools.get_student(id, preloads: [classes: [:cycle, :years]]) do + student when is_nil(student) or student.school_id != socket.assigns.user_school.id -> + socket + |> put_flash(:error, "Couldn't find student") + |> redirect(to: ~p"/school") + + student -> + socket + |> assign(:student_name, student.name) + |> stream(:classes, student.classes) + end + end + + defp apply_action(socket, _live_action, _params), do: socket +end diff --git a/lib/lanttern_web/live/school_live/student.html.heex b/lib/lanttern_web/live/school_live/student.html.heex new file mode 100644 index 00000000..a969fbf2 --- /dev/null +++ b/lib/lanttern_web/live/school_live/student.html.heex @@ -0,0 +1,29 @@ +
    + <.page_title_with_menu><%= @student_name %> + <.breadcrumbs class="mt-2"> + <:item link={~p"/school"}>School + <:item>Student + + +

    + Classes +

    +
    +
    + <.link + patch={~p"/school/class/#{class}"} + class="font-display font-black text-2xl underline hover:text-ltrn-subtle" + > + <%= class.name %> + +
    + <.badge><%= class.cycle.name %> + <.badge :for={year <- class.years}><%= year.name %> +
    +
    +
    +
    diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index dcfbcfc3..1a0e89a6 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -51,6 +51,10 @@ defmodule LantternWeb.Router do DashboardLive.Index, :edit_filter_view + live "/school", SchoolLive.Show, :show + live "/school/class/:id", SchoolLive.Class, :show + live "/school/student/:id", SchoolLive.Student, :show + live "/assessment_points", AssessmentPointLive.Explorer, :index live "/assessment_points/new", AssessmentPointLive.Explorer, :new @@ -104,9 +108,17 @@ defmodule LantternWeb.Router do resources "/classes", ClassController resources "/students", StudentController resources "/teachers", TeacherController + live "/import_students", Admin.SchoolLive.ImportStudents live "/import_teachers", Admin.SchoolLive.ImportTeachers + live "/school_cycles", Admin.CycleLive.Index, :index + live "/school_cycles/new", Admin.CycleLive.Index, :new + + live "/school_cycles/:id/edit", Admin.CycleLive.Index, :edit + live "/school_cycles/:id", Admin.CycleLive.Show, :show + live "/school_cycles/:id/show/edit", Admin.CycleLive.Show, :edit + # Taxonomy context resources "/subjects", SubjectController resources "/years", YearController diff --git a/priv/repo/migrations/20231110160629_create_school_cycles.exs b/priv/repo/migrations/20231110160629_create_school_cycles.exs new file mode 100644 index 00000000..2b57043d --- /dev/null +++ b/priv/repo/migrations/20231110160629_create_school_cycles.exs @@ -0,0 +1,22 @@ +defmodule Lanttern.Repo.Migrations.CreateSchoolCycles do + use Ecto.Migration + + def change do + create table(:school_cycles) do + add :name, :text, null: false + add :start_at, :date, null: false + add :end_at, :date, null: false + add :school_id, references(:schools, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:school_cycles, [:school_id]) + + create constraint( + :school_cycles, + :cycle_end_date_is_greater_than_start_date, + check: "start_at < end_at" + ) + end +end diff --git a/priv/repo/migrations/20231113115932_add_cycle_to_classes.exs b/priv/repo/migrations/20231113115932_add_cycle_to_classes.exs new file mode 100644 index 00000000..0fee3357 --- /dev/null +++ b/priv/repo/migrations/20231113115932_add_cycle_to_classes.exs @@ -0,0 +1,52 @@ +defmodule Lanttern.Repo.Migrations.AddCycleToClasses do + use Ecto.Migration + + def change do + # creating unique constraints to allow composite foreign keys. + # this guarantees, in the database level, that the selected cycle + # belongs to the same school of the class. + + # removing existing "school_cycles_school_id_index" to prevent unnecessary index + drop index(:school_cycles, [:school_id]) + create unique_index(:school_cycles, [:school_id, :id]) + + alter table(:classes) do + # `cycle_id` is `null: false`. + # we'll add this in the execute blocks below + # after we add a scale to all classes + + add :cycle_id, + references(:school_cycles, + with: [school_id: :school_id], + on_delete: :nothing + ) + end + + create index(:classes, [:cycle_id]) + + # creating one temp cycle to each school in the database + execute """ + INSERT INTO school_cycles (name, start_at, end_at, school_id, inserted_at, updated_at) + SELECT + 'TEMP ' || date_part('year', CURRENT_DATE)::text, + make_date(date_part('year', CURRENT_DATE)::int, 1, 1), + make_date(date_part('year', CURRENT_DATE)::int, 12, 31), + schools.id, + now() AT time zone 'utc', + now() AT time zone 'utc' + FROM schools + """, + "" + + # link temp cycles to existing classes + execute """ + UPDATE classes SET cycle_id = school_cycles.id + FROM school_cycles + WHERE school_cycles.school_id = classes.school_id + """, + "" + + # add not null constraints to classes' cycle_id + execute "ALTER TABLE classes ALTER COLUMN cycle_id SET NOT NULL", "" + end +end diff --git a/priv/repo/migrations/20231114122320_create_classes_years.exs b/priv/repo/migrations/20231114122320_create_classes_years.exs new file mode 100644 index 00000000..7b4e743c --- /dev/null +++ b/priv/repo/migrations/20231114122320_create_classes_years.exs @@ -0,0 +1,13 @@ +defmodule Lanttern.Repo.Migrations.CreateClassesYears do + use Ecto.Migration + + def change do + create table(:classes_years, primary_key: false) do + add :class_id, references(:classes) + add :year_id, references(:years) + end + + create index(:classes_years, [:class_id]) + create unique_index(:classes_years, [:year_id, :class_id]) + end +end diff --git a/test/lanttern/schools_test.exs b/test/lanttern/schools_test.exs index c80ff5ed..d3121848 100644 --- a/test/lanttern/schools_test.exs +++ b/test/lanttern/schools_test.exs @@ -57,6 +57,87 @@ defmodule Lanttern.SchoolsTest do end end + describe "school_cycles" do + alias Lanttern.Schools.Cycle + + import Lanttern.SchoolsFixtures + + @invalid_attrs %{name: nil, start_at: nil, end_at: nil} + + test "list_cycles/1 returns all school_cycles" do + cycle = cycle_fixture() + assert Schools.list_cycles() == [cycle] + end + + test "list_cycles/1 with school filter returns all cycles as expected" do + school = school_fixture() + cycle = cycle_fixture(%{school_id: school.id}) + + # extra cycles for school filter validation + class_fixture() + class_fixture() + + assert [cycle] == Schools.list_cycles(schools_ids: [school.id]) + end + + test "get_cycle!/1 returns the cycle with given id" do + cycle = cycle_fixture() + assert Schools.get_cycle!(cycle.id) == cycle + end + + test "create_cycle/1 with valid data creates a cycle" do + school = school_fixture() + + valid_attrs = %{ + name: "some name", + start_at: ~D[2023-11-09], + end_at: ~D[2023-12-09], + school_id: school.id + } + + assert {:ok, %Cycle{} = cycle} = Schools.create_cycle(valid_attrs) + assert cycle.name == "some name" + assert cycle.start_at == ~D[2023-11-09] + assert cycle.end_at == ~D[2023-12-09] + end + + test "create_cycle/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Schools.create_cycle(@invalid_attrs) + end + + test "update_cycle/2 with valid data updates the cycle" do + cycle = cycle_fixture() + + update_attrs = %{ + name: "some updated name", + start_at: ~D[2023-11-10], + end_at: ~D[2023-12-10] + } + + assert {:ok, %Cycle{} = cycle} = Schools.update_cycle(cycle, update_attrs) + assert cycle.name == "some updated name" + assert cycle.start_at == ~D[2023-11-10] + assert cycle.end_at == ~D[2023-12-10] + end + + test "update_cycle/2 with invalid data returns error changeset" do + cycle = cycle_fixture() + assert {:error, %Ecto.Changeset{}} = Schools.update_cycle(cycle, @invalid_attrs) + assert cycle == Schools.get_cycle!(cycle.id) + end + + test "delete_cycle/1 deletes the cycle" do + cycle = cycle_fixture() + assert {:ok, %Cycle{}} = Schools.delete_cycle(cycle) + assert_raise Ecto.NoResultsError, fn -> Schools.get_cycle!(cycle.id) end + end + + test "change_cycle/1 returns a cycle changeset" do + cycle = cycle_fixture() + assert %Ecto.Changeset{} = Schools.change_cycle(cycle) + end + end + describe "classes" do alias Lanttern.Schools.Class @@ -70,18 +151,63 @@ defmodule Lanttern.SchoolsTest do test "list_classes/1 with preloads and school filter returns all classes as expected" do school = school_fixture() student = student_fixture() - class = class_fixture(%{school_id: school.id, students_ids: [student.id]}) + year = Lanttern.TaxonomyFixtures.year_fixture() + + class = + class_fixture(%{school_id: school.id, students_ids: [student.id], years_ids: [year.id]}) # extra classes for school filter validation class_fixture() class_fixture() [expected_class] = - Schools.list_classes(preloads: [:school, :students], schools_ids: [school.id]) + Schools.list_classes(preloads: [:school, :students, :years], schools_ids: [school.id]) assert expected_class.id == class.id assert expected_class.school == school assert expected_class.students == [student] + assert expected_class.years == [year] + end + + test "list_user_classes/1 returns all classes from user's school with preloaded data and correct order" do + school = school_fixture() + class_b = class_fixture(%{school_id: school.id, name: "BBB"}) + class_a = class_fixture(%{school_id: school.id, name: "AAA"}) + teacher = teacher_fixture(%{school_id: school.id}) + profile = Lanttern.IdentityFixtures.teacher_profile_fixture(%{teacher_id: teacher.id}) + user = %{current_profile: Lanttern.Identity.get_profile!(profile.id, preloads: :teacher)} + + # extra classes for school filter validation + class_fixture() + class_fixture() + + [expected_a, expected_b] = Schools.list_user_classes(user) + + assert expected_a.id == class_a.id + assert expected_b.id == class_b.id + end + + test "list_user_classes/1 returns error tuple when user is student" do + school = school_fixture() + student = student_fixture(%{school_id: school.id}) + profile = Lanttern.IdentityFixtures.student_profile_fixture(%{student_id: student.id}) + + user = %{ + current_profile: + Lanttern.Identity.get_profile!(profile.id, preloads: [:teacher, :student]) + } + + assert {:error, "User not allowed to list classes"} == Schools.list_user_classes(user) + end + + test "get_class/2 returns the class with given id" do + class = class_fixture() + assert Schools.get_class(class.id) == class + end + + test "get_class/2 returns nil if class with given id does not exist" do + class_fixture() + assert Schools.get_class(99999) == nil end test "get_class!/2 returns the class with given id" do @@ -102,7 +228,8 @@ defmodule Lanttern.SchoolsTest do test "create_class/1 with valid data creates a class" do school = school_fixture() - valid_attrs = %{school_id: school.id, name: "some name"} + cycle = cycle_fixture(%{school_id: school.id}) + valid_attrs = %{school_id: school.id, cycle_id: cycle.id, name: "some name"} assert {:ok, %Class{} = class} = Schools.create_class(valid_attrs) assert class.name == "some name" @@ -111,6 +238,7 @@ defmodule Lanttern.SchoolsTest do test "create_class/1 with valid data containing students creates a class with students" do school = school_fixture() + cycle = cycle_fixture(%{school_id: school.id}) student_1 = student_fixture() student_2 = student_fixture() student_3 = student_fixture() @@ -118,6 +246,7 @@ defmodule Lanttern.SchoolsTest do valid_attrs = %{ name: "some name", school_id: school.id, + cycle_id: cycle.id, students_ids: [ student_1.id, student_2.id, @@ -224,6 +353,16 @@ defmodule Lanttern.SchoolsTest do end end + test "get_student/2 returns the student with given id" do + student = student_fixture() + assert Schools.get_student(student.id) == student + end + + test "get_student/2 returns nil if student with given id does not exist" do + student_fixture() + assert Schools.get_student(99999) == nil + end + test "get_student!/2 returns the student with given id" do student = student_fixture() assert Schools.get_student!(student.id) == student @@ -418,7 +557,8 @@ defmodule Lanttern.SchoolsTest do describe "csv parsing" do test "create_students_from_csv/3 creates classes and students, and returns a list with the registration status for each row" do school = school_fixture() - class = class_fixture(name: "existing class", school_id: school.id) + cycle = cycle_fixture(%{school_id: school.id}) + class = class_fixture(name: "existing class", school_id: school.id, cycle_id: cycle.id) user = Lanttern.IdentityFixtures.user_fixture(email: "existing-user@school.com") csv_std_1 = %{ @@ -466,7 +606,7 @@ defmodule Lanttern.SchoolsTest do } {:ok, expected} = - Schools.create_students_from_csv(csv_students, class_name_id_map, school.id) + Schools.create_students_from_csv(csv_students, class_name_id_map, school.id, cycle.id) [ {returned_csv_std_1, {:ok, std_1}}, diff --git a/test/lanttern_web/controllers/class_controller_test.exs b/test/lanttern_web/controllers/class_controller_test.exs index 63ea9bfb..0d8688da 100644 --- a/test/lanttern_web/controllers/class_controller_test.exs +++ b/test/lanttern_web/controllers/class_controller_test.exs @@ -3,7 +3,6 @@ defmodule LantternWeb.ClassControllerTest do import Lanttern.SchoolsFixtures - @create_attrs %{name: "some name"} @update_attrs %{name: "some updated name"} @invalid_attrs %{name: nil} @@ -26,7 +25,8 @@ defmodule LantternWeb.ClassControllerTest do describe "create class" do test "redirects to show when data is valid", %{conn: conn} do school = school_fixture() - create_attrs = @create_attrs |> Map.put_new(:school_id, school.id) + cycle = cycle_fixture(%{school_id: school.id}) + create_attrs = %{name: "some name", school_id: school.id, cycle_id: cycle.id} conn = post(conn, ~p"/admin/classes", class: create_attrs) assert %{id: id} = redirected_params(conn) diff --git a/test/lanttern_web/live/admin/cycle_live_test.exs b/test/lanttern_web/live/admin/cycle_live_test.exs new file mode 100644 index 00000000..3cf65ae5 --- /dev/null +++ b/test/lanttern_web/live/admin/cycle_live_test.exs @@ -0,0 +1,140 @@ +defmodule LantternWeb.Admin.CycleLiveTest do + use LantternWeb.ConnCase + + import Phoenix.LiveViewTest + import Lanttern.SchoolsFixtures + + @invalid_attrs %{name: nil, start_at: nil, end_at: nil} + + defp create_cycle(_) do + cycle = cycle_fixture() + %{cycle: cycle} + end + + setup :register_and_log_in_root_admin + + describe "Index" do + setup [:create_cycle] + + test "lists all school_cycles", %{conn: conn, cycle: cycle} do + {:ok, _index_live, html} = live(conn, ~p"/admin/school_cycles") + + assert html =~ "Listing School cycles" + assert html =~ cycle.name + end + + test "saves new cycle", %{conn: conn} do + school = school_fixture() + + create_attrs = %{ + name: "some name", + start_at: "2023-11-09", + end_at: "2023-12-09", + school_id: school.id + } + + {:ok, index_live, _html} = live(conn, ~p"/admin/school_cycles") + + assert index_live |> element("a", "New Cycle") |> render_click() =~ + "New Cycle" + + assert_patch(index_live, ~p"/admin/school_cycles/new") + + assert index_live + |> form("#cycle-form", cycle: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#cycle-form", cycle: create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/admin/school_cycles") + + html = render(index_live) + assert html =~ "Cycle created successfully" + assert html =~ "some name" + end + + test "updates cycle in listing", %{conn: conn, cycle: cycle} do + school = school_fixture() + + update_attrs = %{ + name: "some updated name", + start_at: "2023-11-10", + end_at: "2023-12-10", + school_id: school.id + } + + {:ok, index_live, _html} = live(conn, ~p"/admin/school_cycles") + + assert index_live |> element("#school_cycles-#{cycle.id} a", "Edit") |> render_click() =~ + "Edit Cycle" + + assert_patch(index_live, ~p"/admin/school_cycles/#{cycle}/edit") + + assert index_live + |> form("#cycle-form", cycle: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#cycle-form", cycle: update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/admin/school_cycles") + + html = render(index_live) + assert html =~ "Cycle updated successfully" + assert html =~ "some updated name" + end + + test "deletes cycle in listing", %{conn: conn, cycle: cycle} do + {:ok, index_live, _html} = live(conn, ~p"/admin/school_cycles") + + assert index_live |> element("#school_cycles-#{cycle.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#school_cycles-#{cycle.id}") + end + end + + describe "Show" do + setup [:create_cycle] + + test "displays cycle", %{conn: conn, cycle: cycle} do + {:ok, _show_live, html} = live(conn, ~p"/admin/school_cycles/#{cycle}") + + assert html =~ "Show Cycle" + assert html =~ cycle.name + end + + test "updates cycle within modal", %{conn: conn, cycle: cycle} do + school = school_fixture() + + update_attrs = %{ + name: "some updated name", + start_at: "2023-11-10", + end_at: "2023-12-10", + school_id: school.id + } + + {:ok, show_live, _html} = live(conn, ~p"/admin/school_cycles/#{cycle}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Cycle" + + assert_patch(show_live, ~p"/admin/school_cycles/#{cycle}/show/edit") + + assert show_live + |> form("#cycle-form", cycle: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#cycle-form", cycle: update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/admin/school_cycles/#{cycle}") + + html = render(show_live) + assert html =~ "Cycle updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test/lanttern_web/live/school_live/class_test.exs b/test/lanttern_web/live/school_live/class_test.exs new file mode 100644 index 00000000..92cb5f0f --- /dev/null +++ b/test/lanttern_web/live/school_live/class_test.exs @@ -0,0 +1,54 @@ +defmodule LantternWeb.SchoolLive.ClassTest do + use LantternWeb.ConnCase + + alias Lanttern.SchoolsFixtures + + @live_view_base_path "/school/class" + + setup [:register_and_log_in_user] + + describe "Class live view basic navigation" do + test "disconnected and connected mount", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + class = SchoolsFixtures.class_fixture(%{school_id: school.id, name: "some class abc xyz"}) + conn = get(conn, "#{@live_view_base_path}/#{class.id}") + + assert html_response(conn, 200) =~ ~r/

    \s*some class abc xyz\s*<\/h1>/ + + {:ok, _view, _html} = live(conn) + end + + test "list students", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + std_b = SchoolsFixtures.student_fixture(%{school_id: school.id, name: "bbb"}) + std_a = SchoolsFixtures.student_fixture(%{school_id: school.id, name: "aaa"}) + + class = + SchoolsFixtures.class_fixture(%{school_id: school.id, students_ids: [std_a.id, std_b.id]}) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{class.id}") + + assert view |> has_element?("a", std_a.name) + assert view |> has_element?("a", std_b.name) + end + + test "navigate to student", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + student = SchoolsFixtures.student_fixture(%{school_id: school.id}) + + class = + SchoolsFixtures.class_fixture(%{ + school_id: school.id, + students_ids: [student.id] + }) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{class.id}") + + view + |> element("a", student.name) + |> render_click() + + assert_patch(view, "/school/student/#{student.id}") + end + end +end diff --git a/test/lanttern_web/live/school_live/show_test.exs b/test/lanttern_web/live/school_live/show_test.exs new file mode 100644 index 00000000..4006bfa1 --- /dev/null +++ b/test/lanttern_web/live/school_live/show_test.exs @@ -0,0 +1,31 @@ +defmodule LantternWeb.SchoolLive.ShowTest do + use LantternWeb.ConnCase + + alias Lanttern.SchoolsFixtures + + @live_view_path "/school" + + setup [:register_and_log_in_user] + + describe "School live view basic navigation" do + test "disconnected and connected mount", %{conn: conn} do + conn = get(conn, @live_view_path) + + school = conn.assigns.current_user.current_profile.teacher.school + {:ok, regex} = Regex.compile("

    \\s*#{school.name}\\s*<\/h1>") + + assert html_response(conn, 200) =~ regex + + {:ok, _view, _html} = live(conn) + end + + test "list classes", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + class = SchoolsFixtures.class_fixture(%{school_id: school.id, name: "school abc"}) + + {:ok, view, _html} = live(conn, @live_view_path) + + assert view |> has_element?("td", class.name) + end + end +end diff --git a/test/lanttern_web/live/school_live/student_test.exs b/test/lanttern_web/live/school_live/student_test.exs new file mode 100644 index 00000000..95ccf4a4 --- /dev/null +++ b/test/lanttern_web/live/school_live/student_test.exs @@ -0,0 +1,60 @@ +defmodule LantternWeb.SchoolLive.StudentTest do + use LantternWeb.ConnCase + + alias Lanttern.SchoolsFixtures + + @live_view_base_path "/school/student" + + setup [:register_and_log_in_user] + + describe "Student live view basic navigation" do + test "disconnected and connected mount", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + + student = + SchoolsFixtures.student_fixture(%{school_id: school.id, name: "some student abc xyz"}) + + conn = get(conn, "#{@live_view_base_path}/#{student.id}") + + assert html_response(conn, 200) =~ ~r/

    \s*some student abc xyz\s*<\/h1>/ + + {:ok, _view, _html} = live(conn) + end + + test "list classes", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + class_b = SchoolsFixtures.class_fixture(%{school_id: school.id, name: "bbb"}) + class_a = SchoolsFixtures.class_fixture(%{school_id: school.id, name: "aaa"}) + + student = + SchoolsFixtures.student_fixture(%{ + school_id: school.id, + classes_ids: [class_a.id, class_b.id] + }) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{student.id}") + + assert view |> has_element?("a", class_a.name) + assert view |> has_element?("a", class_b.name) + end + + test "navigate to class", %{conn: conn, user: user} do + school = user.current_profile.teacher.school + class = SchoolsFixtures.class_fixture(%{school_id: school.id}) + + student = + SchoolsFixtures.student_fixture(%{ + school_id: school.id, + classes_ids: [class.id] + }) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{student.id}") + + view + |> element("a", class.name) + |> render_click() + + assert_patch(view, "/school/class/#{class.id}") + end + end +end diff --git a/test/support/fixtures/schools_fixtures.ex b/test/support/fixtures/schools_fixtures.ex index b6550d69..9496ac52 100644 --- a/test/support/fixtures/schools_fixtures.ex +++ b/test/support/fixtures/schools_fixtures.ex @@ -18,15 +18,34 @@ defmodule Lanttern.SchoolsFixtures do school end + @doc """ + Generate a cycle. + """ + def cycle_fixture(attrs \\ %{}) do + {:ok, cycle} = + attrs + |> Enum.into(%{ + school_id: maybe_gen_school_id(attrs), + name: "some name", + start_at: ~D[2023-11-09], + end_at: ~D[2024-11-09] + }) + |> Lanttern.Schools.create_cycle() + + cycle + end + @doc """ Generate a class. """ def class_fixture(attrs \\ %{}) - def class_fixture(%{school_id: _school_id} = attrs) do + def class_fixture(%{cycle_id: cycle_id} = attrs) do {:ok, class} = attrs |> Enum.into(%{ + school_id: maybe_gen_school_id(attrs), + cycle_id: cycle_id, name: "some class name #{Ecto.UUID.generate()}" }) |> Lanttern.Schools.create_class() @@ -35,13 +54,15 @@ defmodule Lanttern.SchoolsFixtures do end def class_fixture(attrs) do - school = school_fixture() + school_id = maybe_gen_school_id(attrs) + cycle = cycle_fixture(%{school_id: school_id}) {:ok, class} = attrs |> Enum.into(%{ - school_id: school.id, - name: "some class name" + school_id: school_id, + cycle_id: cycle.id, + name: "some class name #{Ecto.UUID.generate()}" }) |> Lanttern.Schools.create_class() @@ -51,12 +72,11 @@ defmodule Lanttern.SchoolsFixtures do @doc """ Generate a student. """ - def student_fixture(attrs \\ %{}) - - def student_fixture(%{school_id: _school_id} = attrs) do + def student_fixture(attrs \\ %{}) do {:ok, student} = attrs |> Enum.into(%{ + school_id: maybe_gen_school_id(attrs), name: "some full name #{Ecto.UUID.generate()}" }) |> Lanttern.Schools.create_student() @@ -64,29 +84,14 @@ defmodule Lanttern.SchoolsFixtures do student end - def student_fixture(attrs) do - school = school_fixture() - - {:ok, student} = - attrs - |> Enum.into(%{ - name: "some full name #{Ecto.UUID.generate()}", - school_id: school.id - }) - |> Lanttern.Schools.create_student() - - student - end - @doc """ Generate a teacher. """ - def teacher_fixture(attrs \\ %{}) - - def teacher_fixture(%{school_id: _school_id} = attrs) do + def teacher_fixture(attrs \\ %{}) do {:ok, teacher} = attrs |> Enum.into(%{ + school_id: maybe_gen_school_id(attrs), name: "some full name #{Ecto.UUID.generate()}" }) |> Lanttern.Schools.create_teacher() @@ -94,17 +99,11 @@ defmodule Lanttern.SchoolsFixtures do teacher end - def teacher_fixture(attrs) do - school = school_fixture() + # helpers - {:ok, teacher} = - attrs - |> Enum.into(%{ - name: "some full name #{Ecto.UUID.generate()}", - school_id: school.id - }) - |> Lanttern.Schools.create_teacher() + defp maybe_gen_school_id(%{school_id: school_id} = _attrs), + do: school_id - teacher - end + defp maybe_gen_school_id(_attrs), + do: school_fixture().id end