diff --git a/fly.toml b/fly.toml index 5d3f31e7..322fd1f1 100644 --- a/fly.toml +++ b/fly.toml @@ -24,7 +24,7 @@ internal_port = 8080 force_https = true auto_stop_machines = true auto_start_machines = true -min_machines_running = 0 +min_machines_running = 1 processes = ["app"] [http_service.concurrency] type = "connections" diff --git a/lib/lanttern/personalization/profile_settings.ex b/lib/lanttern/personalization/profile_settings.ex index 6196b2d6..cc2e96c8 100644 --- a/lib/lanttern/personalization/profile_settings.ex +++ b/lib/lanttern/personalization/profile_settings.ex @@ -16,7 +16,8 @@ defmodule Lanttern.Personalization.ProfileSettings do @type current_filters() :: %__MODULE__.CurrentFilters{ classes_ids: [pos_integer()], subjects_ids: [pos_integer()], - years_ids: [pos_integer()] + years_ids: [pos_integer()], + cycles_ids: [pos_integer()] } schema "profile_settings" do @@ -26,6 +27,7 @@ defmodule Lanttern.Personalization.ProfileSettings do field :classes_ids, {:array, :id} field :subjects_ids, {:array, :id} field :years_ids, {:array, :id} + field :cycles_ids, {:array, :id} end timestamps() @@ -41,6 +43,6 @@ defmodule Lanttern.Personalization.ProfileSettings do defp current_filters_changeset(current_filters, attrs) do current_filters - |> cast(attrs, [:classes_ids, :subjects_ids, :years_ids]) + |> cast(attrs, [:classes_ids, :subjects_ids, :years_ids, :cycles_ids]) end end diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 59ccde7a..0e17efc0 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -6,10 +6,14 @@ defmodule Lanttern.Reporting do import Ecto.Query, warn: false alias Lanttern.Repo import Lanttern.RepoHelpers - import LantternWeb.Gettext + alias Lanttern.Utils alias Lanttern.Reporting.ReportCard + alias Lanttern.Reporting.StrandReport alias Lanttern.Reporting.StudentReportCard + alias Lanttern.Reporting.GradesReport + alias Lanttern.Reporting.GradesReportSubject + alias Lanttern.Reporting.GradesReportCycle alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Schools @@ -24,8 +28,10 @@ defmodule Lanttern.Reporting do ## Options: - - `:preloads` – preloads associated data - - `:strands_ids` – filter report cards by strands + - `:preloads` – preloads associated data + - `:strands_ids` – filter report cards by strands + - `:cycles_ids` - filter report cards by cycles + - `:years_ids` - filter report cards by year ## Examples @@ -36,28 +42,40 @@ defmodule Lanttern.Reporting do def list_report_cards(opts \\ []) do from(rc in ReportCard, join: sc in assoc(rc, :school_cycle), - order_by: [desc: sc.end_at, asc: rc.name] + order_by: [desc: sc.end_at, asc: sc.start_at, asc: rc.name] ) - |> filter_report_cards(opts) + |> apply_list_report_cards_opts(opts) |> Repo.all() |> maybe_preload(opts) end - defp filter_report_cards(queryable, opts) do - Enum.reduce(opts, queryable, &apply_report_cards_filter/2) - end + defp apply_list_report_cards_opts(queryable, []), do: queryable - defp apply_report_cards_filter({:strands_ids, ids}, queryable) do + defp apply_list_report_cards_opts(queryable, [{:strands_ids, ids} | opts]) + when is_list(ids) and ids != [] do from( rc in queryable, join: sr in assoc(rc, :strand_reports), join: s in assoc(sr, :strand), where: s.id in ^ids ) + |> apply_list_report_cards_opts(opts) + end + + defp apply_list_report_cards_opts(queryable, [{:cycles_ids, ids} | opts]) + when is_list(ids) and ids != [] do + from(rc in queryable, where: rc.school_cycle_id in ^ids) + |> apply_list_report_cards_opts(opts) + end + + defp apply_list_report_cards_opts(queryable, [{:years_ids, ids} | opts]) + when is_list(ids) and ids != [] do + from(rc in queryable, where: rc.year_id in ^ids) + |> apply_list_report_cards_opts(opts) end - defp apply_report_cards_filter(_, queryable), - do: queryable + defp apply_list_report_cards_opts(queryable, [_ | opts]), + do: apply_list_report_cards_opts(queryable, opts) @doc """ Returns the list of report cards, grouped by cycle. @@ -66,6 +84,8 @@ defmodule Lanttern.Reporting do in each group ordered by name (asc) — it's the same order returned by `list_report_cards/1`, which is used internally. + See `list_report_cards/1` for options. + ## Examples iex> list_report_cards_by_cycle() @@ -73,12 +93,12 @@ defmodule Lanttern.Reporting do """ - @spec list_report_cards_by_cycle() :: [ + @spec list_report_cards_by_cycle(Keyword.t()) :: [ {Cycle.t(), [ReportCard.t()]} ] - def list_report_cards_by_cycle() do + def list_report_cards_by_cycle(opts \\ []) do report_cards_by_cycle_map = - list_report_cards() + list_report_cards(opts) |> Enum.group_by(& &1.school_cycle_id) Schools.list_cycles(order_by: [desc: :end_at]) @@ -175,8 +195,6 @@ defmodule Lanttern.Reporting do ReportCard.changeset(report_card, attrs) end - alias Lanttern.Reporting.StrandReport - @doc """ Returns the list of strand reports. @@ -329,29 +347,8 @@ defmodule Lanttern.Reporting do """ @spec update_strands_reports_positions([integer()]) :: :ok | {:error, String.t()} - def update_strands_reports_positions(strands_reports_ids) do - strands_reports_ids - |> Enum.with_index() - |> Enum.reduce( - Ecto.Multi.new(), - fn {id, i}, multi -> - multi - |> Ecto.Multi.update_all( - "update-#{id}", - from( - sr in StrandReport, - where: sr.id == ^id - ), - set: [position: i] - ) - end - ) - |> Repo.transaction() - |> case do - {:ok, _} -> :ok - _ -> {:error, gettext("Something went wrong")} - end - end + def update_strands_reports_positions(strands_reports_ids), + do: Utils.update_positions(StrandReport, strands_reports_ids) @doc """ Deletes a strand_report. @@ -382,8 +379,6 @@ defmodule Lanttern.Reporting do StrandReport.changeset(strand_report, attrs) end - alias Lanttern.Reporting.StudentReportCard - @doc """ Returns the list of student_report_cards. @@ -601,4 +596,359 @@ defmodule Lanttern.Reporting do strand_reports |> Enum.map(&{&1, Map.get(ast_entries_map, &1.id, [])}) end + + @doc """ + Returns the list of grade reports. + + ## Options + + - `:preloads` – preloads associated data + - `:load_grid` – (bool) preloads school cycle and grades report cycles/subjects (with school cycle/subject preloaded) + + ## Examples + + iex> list_grades_reports() + [%GradesReport{}, ...] + + """ + def list_grades_reports(opts \\ []) do + GradesReport + |> apply_list_grades_reports_opts(opts) + |> Repo.all() + |> maybe_preload(opts) + end + + defp apply_list_grades_reports_opts(queryable, []), do: queryable + + defp apply_list_grades_reports_opts(queryable, [{:load_grid, true} | opts]), + do: apply_list_grades_reports_opts(grid_query(queryable), opts) + + defp apply_list_grades_reports_opts(queryable, [_ | opts]), + do: apply_list_grades_reports_opts(queryable, opts) + + defp grid_query(queryable) do + from( + gr in queryable, + join: sc in assoc(gr, :school_cycle), + left_join: grc in assoc(gr, :grades_report_cycles), + left_join: grc_sc in assoc(grc, :school_cycle), + left_join: grs in assoc(gr, :grades_report_subjects), + left_join: grs_s in assoc(grs, :subject), + order_by: [asc: grc_sc.end_at, desc: grc_sc.start_at, asc: grs.position], + preload: [ + school_cycle: sc, + grades_report_cycles: {grc, [school_cycle: grc_sc]}, + grades_report_subjects: {grs, [subject: grs_s]} + ] + ) + end + + @doc """ + Gets a single grade report. + + Returns `nil` if the grade report does not exist. + + ## Options: + + - `:preloads` – preloads associated data + - `:load_grid` – (bool) preloads school cycle and grades report cycles/subjects (with school cycle/subject preloaded) + + ## Examples + + iex> get_grades_report!(123) + %GradesReport{} + + iex> get_grades_report!(456) + nil + + """ + def get_grades_report(id, opts \\ []) do + GradesReport + |> apply_get_grades_report_opts(opts) + |> Repo.get(id) + |> maybe_preload(opts) + end + + defp apply_get_grades_report_opts(queryable, []), do: queryable + + defp apply_get_grades_report_opts(queryable, [{:load_grid, true} | opts]), + do: apply_get_grades_report_opts(grid_query(queryable), opts) + + defp apply_get_grades_report_opts(queryable, [_ | opts]), + do: apply_get_grades_report_opts(queryable, opts) + + @doc """ + Gets a single grade report. + + Same as `get_grades_report/2`, but raises `Ecto.NoResultsError` if the grade report does not exist. + + ## Examples + + iex> get_grades_report!(123) + %GradesReport{} + + iex> get_grades_report!(456) + ** (Ecto.NoResultsError) + + """ + def get_grades_report!(id, opts \\ []) do + GradesReport + |> apply_get_grades_report_opts(opts) + |> Repo.get!(id) + |> maybe_preload(opts) + end + + @doc """ + Creates a grade report. + + ## Examples + + iex> create_grades_report(%{field: value}) + {:ok, %GradesReport{}} + + iex> create_grades_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_grades_report(attrs \\ %{}) do + %GradesReport{} + |> GradesReport.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a grade report. + + ## Examples + + iex> update_grades_report(grades_report, %{field: new_value}) + {:ok, %ReportCard{}} + + iex> update_grades_report(grades_report, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_grades_report(%GradesReport{} = grades_report, attrs) do + grades_report + |> GradesReport.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a grade report. + + ## Examples + + iex> delete_grades_report(grades_report) + {:ok, %ReportCard{}} + + iex> delete_grades_report(grades_report) + {:error, %Ecto.Changeset{}} + + """ + def delete_grades_report(%GradesReport{} = grades_report) do + Repo.delete(grades_report) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking grade report changes. + + ## Examples + + iex> change_grades_report(grades_report) + %Ecto.Changeset{data: %ReportCard{}} + + """ + def change_grades_report(%GradesReport{} = grades_report, attrs \\ %{}) do + GradesReport.changeset(grades_report, attrs) + end + + @doc """ + Returns the list of grades report subjects. + + Results are ordered by position and preloaded subjects. + + ## Examples + + iex> list_grades_report_subjects(1) + [%GradesReportSubject{}, ...] + + """ + @spec list_grades_report_subjects(grades_report_id :: integer()) :: [ + GradesReportSubject.t() + ] + + def list_grades_report_subjects(grades_report_id) do + from(grs in GradesReportSubject, + order_by: grs.position, + join: s in assoc(grs, :subject), + preload: [subject: s], + where: grs.grades_report_id == ^grades_report_id + ) + |> Repo.all() + end + + @doc """ + Add a subject to a grades report. + + Result has subject preloaded. + + ## Examples + + iex> add_subject_to_grades_report(%{field: value}) + {:ok, %GradesReportSubject{}} + + iex> add_subject_to_grades_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_subject_to_grades_report(map()) :: + {:ok, GradesReportSubject.t()} | {:error, Ecto.Changeset.t()} + + def add_subject_to_grades_report(attrs \\ %{}) do + %GradesReportSubject{} + |> GradesReportSubject.changeset(attrs) + |> set_grades_report_subject_position() + |> Repo.insert() + |> maybe_preload(preloads: :subject) + end + + # skip if not valid + defp set_grades_report_subject_position(%Ecto.Changeset{valid?: false} = changeset), + do: changeset + + # skip if changeset already has position change + defp set_grades_report_subject_position( + %Ecto.Changeset{changes: %{position: _position}} = changeset + ), + do: changeset + + defp set_grades_report_subject_position(%Ecto.Changeset{} = changeset) do + grades_report_id = + Ecto.Changeset.get_field(changeset, :grades_report_id) + + position = + from( + grs in GradesReportSubject, + where: grs.grades_report_id == ^grades_report_id, + select: grs.position, + order_by: [desc: grs.position], + limit: 1 + ) + |> Repo.one() + |> case do + nil -> 0 + pos -> pos + 1 + end + + changeset + |> Ecto.Changeset.put_change(:position, position) + end + + @doc """ + Update grades report subjects positions based on ids list order. + + ## Examples + + iex> update_grades_report_subjects_positions([3, 2, 1]) + :ok + + """ + @spec update_grades_report_subjects_positions([integer()]) :: :ok | {:error, String.t()} + def update_grades_report_subjects_positions(grades_report_subjects_ids), + do: Utils.update_positions(GradesReportSubject, grades_report_subjects_ids) + + @doc """ + Deletes a grades report subject. + + ## Examples + + iex> delete_grades_report_subject(grades_report_subject) + {:ok, %GradesReportSubject{}} + + iex> delete_grades_report_subject(grades_report_subject) + {:error, %Ecto.Changeset{}} + + """ + def delete_grades_report_subject(%GradesReportSubject{} = grades_report_subject), + do: Repo.delete(grades_report_subject) + + @doc """ + Returns the list of grades report cycles. + + Results are ordered asc by cycle `end_at` and desc by cycle `start_at`, and have preloaded school cycles. + + ## Examples + + iex> list_grades_report_cycles(1) + [%GradesReportCycle{}, ...] + + """ + @spec list_grades_report_cycles(grades_report_id :: integer()) :: [ + GradesReportCycle.t() + ] + + def list_grades_report_cycles(grades_report_id) do + from(grc in GradesReportCycle, + join: sc in assoc(grc, :school_cycle), + preload: [school_cycle: sc], + where: grc.grades_report_id == ^grades_report_id, + order_by: [asc: sc.end_at, desc: sc.start_at] + ) + |> Repo.all() + end + + @doc """ + Add a cycle to a grades report. + + ## Examples + + iex> add_cycle_to_grades_report(%{field: value}) + {:ok, %GradesReportCycle{}} + + iex> add_cycle_to_grades_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_cycle_to_grades_report(map()) :: + {:ok, GradesReportCycle.t()} | {:error, Ecto.Changeset.t()} + + def add_cycle_to_grades_report(attrs \\ %{}) do + %GradesReportCycle{} + |> GradesReportCycle.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a grades_report_cycle. + + ## Examples + + iex> update_grades_report_cycle(grades_report_cycle, %{field: new_value}) + {:ok, %GradesReportCycle{}} + + iex> update_grades_report_cycle(grades_report_cycle, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_grades_report_cycle(%GradesReportCycle{} = grades_report_cycle, attrs) do + grades_report_cycle + |> GradesReportCycle.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a grades report cycle. + + ## Examples + + iex> delete_grades_report_cycle(grades_report_cycle) + {:ok, %GradesReportCycle{}} + + iex> delete_grades_report_cycle(grades_report_cycle) + {:error, %Ecto.Changeset{}} + + """ + def delete_grades_report_cycle(%GradesReportCycle{} = grades_report_cycle), + do: Repo.delete(grades_report_cycle) end diff --git a/lib/lanttern/reporting/grades_report.ex b/lib/lanttern/reporting/grades_report.ex new file mode 100644 index 00000000..604a1034 --- /dev/null +++ b/lib/lanttern/reporting/grades_report.ex @@ -0,0 +1,45 @@ +defmodule Lanttern.Reporting.GradesReport do + use Ecto.Schema + import Ecto.Changeset + + alias Lanttern.Grading.Scale + alias Lanttern.Schools.Cycle + alias Lanttern.Reporting.GradesReportCycle + alias Lanttern.Reporting.GradesReportSubject + + @type t :: %__MODULE__{ + id: pos_integer(), + name: String.t(), + info: String.t(), + is_differentiation: boolean(), + school_cycle: Cycle.t(), + school_cycle_id: pos_integer(), + scale: Scale.t(), + scale_id: pos_integer(), + grades_report_cycles: [GradesReportCycle.t()], + grades_report_subjects: [GradesReportSubject.t()], + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grades_reports" do + field :name, :string + field :info, :string + field :is_differentiation, :boolean, default: false + + belongs_to :school_cycle, Cycle + belongs_to :scale, Scale + + has_many :grades_report_cycles, GradesReportCycle + has_many :grades_report_subjects, GradesReportSubject + + timestamps() + end + + @doc false + def changeset(grades_report, attrs) do + grades_report + |> cast(attrs, [:name, :info, :is_differentiation, :school_cycle_id, :scale_id]) + |> validate_required([:name, :school_cycle_id, :scale_id]) + end +end diff --git a/lib/lanttern/reporting/grades_report_cycle.ex b/lib/lanttern/reporting/grades_report_cycle.ex new file mode 100644 index 00000000..686100b4 --- /dev/null +++ b/lib/lanttern/reporting/grades_report_cycle.ex @@ -0,0 +1,40 @@ +defmodule Lanttern.Reporting.GradesReportCycle do + use Ecto.Schema + import Ecto.Changeset + + import LantternWeb.Gettext + + alias Lanttern.Schools.Cycle + alias Lanttern.Reporting.GradesReport + + @type t :: %__MODULE__{ + id: pos_integer(), + weight: float(), + school_cycle: Cycle.t(), + school_cycle_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grades_report_cycles" do + field :weight, :float, default: 1.0 + + belongs_to :school_cycle, Cycle + belongs_to :grades_report, GradesReport + + timestamps() + end + + @doc false + def changeset(grades_report_cycle, attrs) do + grades_report_cycle + |> cast(attrs, [:weight, :school_cycle_id, :grades_report_id]) + |> validate_required([:school_cycle_id, :grades_report_id]) + |> unique_constraint(:school_cycle_id, + name: "grades_report_cycles_grades_report_id_school_cycle_id_index", + message: gettext("Cycle already added to this grade report") + ) + end +end diff --git a/lib/lanttern/reporting/grades_report_subject.ex b/lib/lanttern/reporting/grades_report_subject.ex new file mode 100644 index 00000000..7e0f4e26 --- /dev/null +++ b/lib/lanttern/reporting/grades_report_subject.ex @@ -0,0 +1,40 @@ +defmodule Lanttern.Reporting.GradesReportSubject do + use Ecto.Schema + import Ecto.Changeset + + import LantternWeb.Gettext + + alias Lanttern.Taxonomy.Subject + alias Lanttern.Reporting.GradesReport + + @type t :: %__MODULE__{ + id: pos_integer(), + position: non_neg_integer(), + subject: Subject.t(), + subject_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grades_report_subjects" do + field :position, :integer, default: 0 + + belongs_to :subject, Subject + belongs_to :grades_report, GradesReport + + timestamps() + end + + @doc false + def changeset(grades_report_subject, attrs) do + grades_report_subject + |> cast(attrs, [:position, :subject_id, :grades_report_id]) + |> validate_required([:subject_id, :grades_report_id]) + |> unique_constraint(:subject_id, + name: "grades_report_subjects_grades_report_id_subject_id_index", + message: gettext("Cycle already added to this grade report") + ) + end +end diff --git a/lib/lanttern/reporting/report_card.ex b/lib/lanttern/reporting/report_card.ex index cb1d18a5..8d1366b3 100644 --- a/lib/lanttern/reporting/report_card.ex +++ b/lib/lanttern/reporting/report_card.ex @@ -3,7 +3,9 @@ defmodule Lanttern.Reporting.ReportCard do import Ecto.Changeset alias Lanttern.Reporting.StrandReport + alias Lanttern.Reporting.GradesReport alias Lanttern.Schools.Cycle + alias Lanttern.Taxonomy.Year @type t :: %__MODULE__{ id: pos_integer(), @@ -11,6 +13,10 @@ defmodule Lanttern.Reporting.ReportCard do description: String.t(), school_cycle: Cycle.t(), school_cycle_id: pos_integer(), + year: Year.t(), + year_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), strand_reports: [StrandReport.t()], inserted_at: DateTime.t(), updated_at: DateTime.t() @@ -21,6 +27,8 @@ defmodule Lanttern.Reporting.ReportCard do field :description, :string belongs_to :school_cycle, Cycle + belongs_to :year, Year + belongs_to :grades_report, GradesReport has_many :strand_reports, StrandReport, preload_order: [asc: :position] @@ -30,7 +38,7 @@ defmodule Lanttern.Reporting.ReportCard do @doc false def changeset(report_card, attrs) do report_card - |> cast(attrs, [:name, :description, :school_cycle_id]) - |> validate_required([:name, :school_cycle_id]) + |> cast(attrs, [:name, :description, :school_cycle_id, :year_id, :grades_report_id]) + |> validate_required([:name, :school_cycle_id, :year_id]) end end diff --git a/lib/lanttern/schools.ex b/lib/lanttern/schools.ex index cc854c69..e8e32cb5 100644 --- a/lib/lanttern/schools.ex +++ b/lib/lanttern/schools.ex @@ -114,7 +114,7 @@ defmodule Lanttern.Schools do ## Options: - - `:schools_ids` – filter classes by schools + - `:schools_ids` – filter cycles by schools - `:order_by` - an order by query expression ([ref](https://hexdocs.pm/ecto/Ecto.Query.html#order_by/3)) ## Examples @@ -130,7 +130,10 @@ defmodule Lanttern.Schools do |> Repo.all() end - defp order_cycles(queryable, nil), do: queryable + defp order_cycles(queryable, nil) do + from c in queryable, + order_by: [asc: :end_at, desc: :start_at] + end defp order_cycles(queryable, order_by_expression) do from c in queryable, diff --git a/lib/lanttern/utils.ex b/lib/lanttern/utils.ex index 4c3f3360..d838de6f 100644 --- a/lib/lanttern/utils.ex +++ b/lib/lanttern/utils.ex @@ -3,6 +3,11 @@ defmodule Lanttern.Utils do Collection of utils functions. """ + import Ecto.Query, warn: false + alias Lanttern.Repo + + import LantternWeb.Gettext + @doc """ Swaps two items in a list, based on the given indexes. @@ -16,4 +21,38 @@ defmodule Lanttern.Utils do |> List.replace_at(i1, e2) |> List.replace_at(i2, e1) end + + @doc """ + Update schema positions based on ids list order. + + ## Examples + + iex> update_positions(queryable, [3, 2, 1]) + :ok + + """ + @spec update_positions(Ecto.Queryable.t(), [integer()]) :: :ok | {:error, String.t()} + def update_positions(queryable, ids) do + ids + |> Enum.with_index() + |> Enum.reduce( + Ecto.Multi.new(), + fn {id, i}, multi -> + multi + |> Ecto.Multi.update_all( + "update-#{id}", + from( + q in queryable, + where: q.id == ^id + ), + set: [position: i] + ) + end + ) + |> Repo.transaction() + |> case do + {:ok, _} -> :ok + _ -> {:error, gettext("Something went wrong")} + end + end end diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index b346865a..238cf584 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -83,6 +83,7 @@ defmodule LantternWeb.CoreComponents do @badge_themes %{ "default" => "bg-ltrn-lightest text-ltrn-dark", + "primary" => "bg-ltrn-primary text-ltrn-dark", "secondary" => "bg-ltrn-secondary text-white", "cyan" => "bg-ltrn-mesh-cyan text-ltrn-dark", "dark" => "bg-ltrn-dark text-ltrn-lighter", @@ -91,6 +92,7 @@ defmodule LantternWeb.CoreComponents do @badge_themes_hover %{ "default" => "hover:bg-ltrn-lightest/50", + "primary" => "hover:bg-ltrn-primary/50", "secondary" => "hover:bg-ltrn-secondary/50", "cyan" => "hover:bg-ltrn-mesh-cyan/50", "dark" => "hover:bg-ltrn-dark/50" @@ -102,6 +104,7 @@ defmodule LantternWeb.CoreComponents do @badge_icon_themes %{ "default" => "text-ltrn-subtle", + "primary" => "text-ltrn-dark", "secondary" => "text-white", "cyan" => "text-ltrn-subtle", "dark" => "text-ltrn-lighter" @@ -229,7 +232,7 @@ defmodule LantternWeb.CoreComponents do """ def get_button_styles(theme \\ "default", size \\ "normal", rounded \\ false) do [ - "inline-flex items-center font-display text-sm font-bold", + "inline-flex items-center justify-center font-display text-sm font-bold", if(size == "sm", do: "gap-1 p-1", else: "gap-2 p-2"), if(rounded, do: "rounded-full", else: "rounded-sm"), "phx-submit-loading:opacity-50 phx-click-loading:opacity-50 phx-click-loading:pointer-events-none", @@ -377,6 +380,56 @@ defmodule LantternWeb.CoreComponents do """ end + @doc """ + Renders a filter text button. + """ + + attr :items, :list, required: true + attr :item_key, :any, default: :name + attr :type, :string, required: true + attr :max_items, :integer, default: 3 + attr :class, :any, default: nil + attr :on_click, JS, default: %JS{} + + def filter_text_button(%{items: []} = assigns) do + ~H""" + + """ + end + + def filter_text_button(assigns) do + %{ + items: items, + type: type, + max_items: max_items, + item_key: item_key + } = assigns + + items = + if length(items) > max_items do + {initial, rest} = Enum.split(items, max_items - 1) + + initial + |> Enum.map(&Map.get(&1, item_key)) + |> Enum.join(" / ") + |> Kernel.<>(" / + #{length(rest)} #{type}") + else + items + |> Enum.map(&Map.get(&1, item_key)) + |> Enum.join(" / ") + end + + assigns = assign(assigns, :items, items) + + ~H""" + + """ + end + @doc """ Renders flash notices. @@ -976,6 +1029,7 @@ defmodule LantternWeb.CoreComponents do @doc """ Renders a sortable card """ + attr :id, :string, default: nil attr :class, :any, default: nil attr :is_move_up_disabled, :boolean, default: false attr :on_move_up, JS, required: true @@ -986,7 +1040,7 @@ defmodule LantternWeb.CoreComponents do def sortable_card(assigns) do ~H""" -
+
<%= render_slot(@inner_block) %>
diff --git a/lib/lanttern_web/components/form_components.ex b/lib/lanttern_web/components/form_components.ex index e180d25b..5ab6ff5f 100644 --- a/lib/lanttern_web/components/form_components.ex +++ b/lib/lanttern_web/components/form_components.ex @@ -99,9 +99,11 @@ defmodule LantternWeb.FormComponents do include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength multiple pattern placeholder readonly required rows size step) - slot :inner_block slot :custom_label + slot :description, + doc: "works for type select, textarea and text variations (e.g. number, email)" + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns |> assign(field: nil, id: assigns.id || field.id) @@ -175,6 +177,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
+ <%= render_slot(@description) %> +
<.select id={@id} name={@name} @@ -219,6 +224,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
+ <%= render_slot(@description) %> +
<.textarea id={@id} name={@name} errors={@errors} value={@value} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
@@ -238,6 +246,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
+ <%= render_slot(@description) %> +
<.base_input type={@type} name={@name} id={@id} value={@value} errors={@errors} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
diff --git a/lib/lanttern_web/components/reporting_components.ex b/lib/lanttern_web/components/reporting_components.ex index 2f20bdee..d3983987 100644 --- a/lib/lanttern_web/components/reporting_components.ex +++ b/lib/lanttern_web/components/reporting_components.ex @@ -8,14 +8,17 @@ defmodule LantternWeb.ReportingComponents do alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Grading.Scale alias Lanttern.Reporting.ReportCard + alias Lanttern.Reporting.GradesReport alias Lanttern.Rubrics.Rubric alias Lanttern.Schools.Cycle + alias Lanttern.Taxonomy.Year @doc """ Renders a report card card (yes, card card, 2x). """ attr :report_card, ReportCard, required: true attr :cycle, Cycle, default: nil + attr :year, Year, default: nil attr :navigate, :string, default: nil attr :id, :string, default: nil attr :class, :any, default: nil @@ -43,10 +46,13 @@ defmodule LantternWeb.ReportingComponents do <%= @report_card.name %> <% end %> -
- <.badge> +
+ <.badge :if={@cycle}> <%= gettext("Cycle") %>: <%= @cycle.name %> + <.badge :if={@year}> + <%= @year.name %> +
@@ -176,4 +182,110 @@ defmodule LantternWeb.ReportingComponents do """ end + + @doc """ + Renders a grades report grid. + + Expects `[:school_cycle, grades_report_cycles: :school_cycle, grades_report_subjects: :subject]` preloads. + """ + + attr :grades_report, GradesReport, required: true + attr :class, :any, default: nil + attr :id, :string, default: nil + attr :on_setup, JS, default: nil + + def grades_report_grid(assigns) do + %{ + grades_report_cycles: grades_report_cycles, + grades_report_subjects: grades_report_subjects + } = assigns.grades_report + + grid_template_columns_style = + case length(grades_report_cycles) do + n when n > 0 -> + "grid-template-columns: 160px repeat(#{n + 1}, minmax(0, 1fr))" + + _ -> + "grid-template-columns: 160px minmax(0, 1fr)" + end + + grid_column_style = + case length(grades_report_cycles) do + 0 -> "grid-column: span 2 / span 2" + n -> "grid-column: span #{n + 2} / span #{n + 2}" + end + + assigns = + assigns + |> assign(:grid_template_columns_style, grid_template_columns_style) + |> assign(:grid_column_style, grid_column_style) + |> assign(:has_subjects, length(grades_report_subjects) > 0) + |> assign(:has_cycles, length(grades_report_cycles) > 0) + + ~H""" +
+ <%= if @on_setup do %> + <.button type="button" theme="ghost" icon_name="hero-cog-6-tooth-mini" phx-click={@on_setup}> + <%= gettext("Setup") %> + + <% else %> +
+ <% end %> + <%= if @has_cycles do %> +
+ <%= grades_report_cycle.school_cycle.name %> +
+
+ <%= @grades_report.school_cycle.name %> +
+ <% else %> +
+ <%= gettext("No cycles linked to this grades report") %> +
+ <% end %> + <%= if @has_subjects do %> +
+
+ <%= grades_report_subject.subject.name %> +
+ <%= if @has_cycles do %> +
+
+
+ <% else %> +
+ <% end %> +
+ <% else %> +
+
+ <%= gettext("No subjects linked to this grades report") %> +
+ <%= if @has_cycles do %> +
+
+
+ <% else %> +
+ <% end %> +
+ <% end %> +
+ """ + end end diff --git a/lib/lanttern_web/helpers/personalization_helpers.ex b/lib/lanttern_web/helpers/personalization_helpers.ex new file mode 100644 index 00000000..01e1986f --- /dev/null +++ b/lib/lanttern_web/helpers/personalization_helpers.ex @@ -0,0 +1,213 @@ +defmodule LantternWeb.PersonalizationHelpers do + import Phoenix.Component, only: [assign: 3] + + alias Lanttern.Personalization + alias Lanttern.Personalization.ProfileSettings + + alias Lanttern.Identity.User + alias Lanttern.Schools + alias Lanttern.Taxonomy + + import LantternWeb.LocalizationHelpers + + @doc """ + Handle filter related assigns in socket. + + ## Filter types and assigns + + ### `:subjects`' assigns + + - :subjects + - :selected_subjects_ids + - :selected_subjects + + ### `:years`' assigns + + - :years + - :selected_years_ids + - :selected_years + + ### `:cycles`' assigns + + - :cycles + - :selected_cycles_ids + - :selected_cycles + + ## Examples + + iex> assign_user_filters(socket, [:subjects], user) + socket + """ + @spec assign_user_filters(Phoenix.LiveView.Socket.t(), [atom()], User.t()) :: + Phoenix.LiveView.Socket.t() + def assign_user_filters(socket, filter_types, %User{} = current_user) do + current_filters = + case Personalization.get_profile_settings(current_user.current_profile_id) do + %{current_filters: current_filters} -> current_filters + _ -> %{} + end + + socket + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, _current_user, _current_filters, []), do: socket + + defp assign_filter_type(socket, current_user, current_filters, [:subjects | filter_types]) do + subjects = + Taxonomy.list_subjects() + |> translate_struct_list("taxonomy", :name, reorder: true) + + selected_subjects_ids = Map.get(current_filters, :subjects_ids) || [] + selected_subjects = Enum.filter(subjects, &(&1.id in selected_subjects_ids)) + + socket + |> assign(:subjects, subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:selected_subjects, selected_subjects) + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, current_user, current_filters, [:years | filter_types]) do + years = + Taxonomy.list_years() + |> translate_struct_list("taxonomy") + + selected_years_ids = Map.get(current_filters, :years_ids) || [] + selected_years = Enum.filter(years, &(&1.id in selected_years_ids)) + + socket + |> assign(:years, years) + |> assign(:selected_years_ids, selected_years_ids) + |> assign(:selected_years, selected_years) + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, current_user, current_filters, [:cycles | filter_types]) do + cycles = + Schools.list_cycles(schools_ids: [current_user.current_profile.school_id]) + + selected_cycles_ids = Map.get(current_filters, :cycles_ids) || [] + selected_cycles = Enum.filter(cycles, &(&1.id in selected_cycles_ids)) + + socket + |> assign(:cycles, cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + |> assign(:selected_cycles, selected_cycles) + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, current_user, current_filters, [_ | filter_types]), + do: assign_filter_type(socket, current_user, current_filters, filter_types) + + @type_to_type_ids_key_map %{ + subjects: :subjects_ids, + years: :years_ids, + cycles: :cycles_ids + } + + @type_to_selected_ids_key_map %{ + subjects: :selected_subjects_ids, + years: :selected_years_ids, + cycles: :selected_cycles_ids + } + + @doc """ + Handle toggling of filter related assigns in socket. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> handle_filter_toggle(socket, :subjects, 1) + %Phoenix.LiveView.Socket{} + """ + + @spec handle_filter_toggle(Phoenix.LiveView.Socket.t(), atom(), pos_integer()) :: + Phoenix.LiveView.Socket.t() + + def handle_filter_toggle(socket, type, id) do + selected_ids_key = @type_to_selected_ids_key_map[type] + selected_ids = socket.assigns[selected_ids_key] + + selected_ids = + case id in selected_ids do + true -> + selected_ids + |> Enum.filter(&(&1 != id)) + + false -> + [id | selected_ids] + end + + assign(socket, selected_ids_key, selected_ids) + end + + @doc """ + Handle clearing of profile filters. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> clear_profile_filters(user, [:subjects]) + {:ok, %ProfileSettings{}} + + iex> clear_profile_filters(user, bad_value) + {:error, %Ecto.Changeset{}} + """ + + @spec clear_profile_filters(User.t(), [atom()]) :: + {:ok, ProfileSettings.t()} | {:error, Ecto.Changeset.t()} + + def clear_profile_filters(current_user, types) do + attrs = + types + |> Enum.map(&{@type_to_type_ids_key_map[&1], []}) + |> Enum.into(%{}) + + Personalization.set_profile_current_filters(current_user, attrs) + end + + @doc """ + Handle saving of profile filters. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> save_profile_filters(socket, user, [:subjects]) + %Phoenix.LiveView.Socket{} + """ + + @spec save_profile_filters(Phoenix.LiveView.Socket.t(), User.t(), [atom()]) :: + Phoenix.LiveView.Socket.t() + + def save_profile_filters(socket, current_user, types) do + attrs = + types + |> Enum.map(fn type -> + selected_ids_key = @type_to_selected_ids_key_map[type] + selected_ids = socket.assigns[selected_ids_key] + + {@type_to_type_ids_key_map[type], selected_ids} + end) + |> Enum.into(%{}) + + Personalization.set_profile_current_filters(current_user, attrs) + + socket + end +end diff --git a/lib/lanttern_web/helpers/reporting_helpers.ex b/lib/lanttern_web/helpers/reporting_helpers.ex new file mode 100644 index 00000000..1ba891dd --- /dev/null +++ b/lib/lanttern_web/helpers/reporting_helpers.ex @@ -0,0 +1,16 @@ +defmodule LantternWeb.ReportingHelpers do + alias Lanttern.Reporting + + @doc """ + Generate list of grades reports to use as `Phoenix.HTML.Form.options_for_select/2` arg + + ## Examples + + iex> generate_grades_report_options() + ["grades report name": 1, ...] + """ + def generate_grades_report_options() do + Reporting.list_grades_reports() + |> Enum.map(fn gr -> {gr.name, gr.id} end) + end +end diff --git a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex index a1958a5e..e3d73410 100644 --- a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex +++ b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex @@ -4,38 +4,18 @@ defmodule LantternWeb.CurriculumComponentLive do alias Lanttern.Curricula alias Lanttern.Curricula.CurriculumItem alias Lanttern.Personalization - alias Lanttern.Taxonomy + + import LantternWeb.PersonalizationHelpers # shared components alias LantternWeb.Curricula.CurriculumItemFormComponent - alias LantternWeb.Taxonomy.SubjectPickerComponent - alias LantternWeb.Taxonomy.YearPickerComponent + alias LantternWeb.BadgeButtonPickerComponent @impl true def mount(_params, _session, socket) do - subjects = Taxonomy.list_subjects() - years = Taxonomy.list_years() - - current_filters = - case Personalization.get_profile_settings(socket.assigns.current_user.current_profile_id) do - %{current_filters: current_filters} -> current_filters - _ -> %{} - end - - selected_subjects_ids = Map.get(current_filters, :subjects_ids) || [] - selected_years_ids = Map.get(current_filters, :years_ids) || [] - - selected_subjects = Enum.filter(subjects, &(&1.id in selected_subjects_ids)) - selected_years = Enum.filter(years, &(&1.id in selected_years_ids)) - socket = socket - |> assign(:subjects, subjects) - |> assign(:years, years) - |> assign(:selected_subjects_ids, selected_subjects_ids) - |> assign(:selected_years_ids, selected_years_ids) - |> assign(:selected_subjects, selected_subjects) - |> assign(:selected_years, selected_years) + |> assign_user_filters([:subjects, :years], socket.assigns.current_user) |> assign(:show_subjects_filter, false) |> assign(:show_years_filter, false) diff --git a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex index c6c4b251..dde33878 100644 --- a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex +++ b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex @@ -159,9 +159,10 @@ <%= gettext("Filter curriculum items by subject") %> <.live_component - module={SubjectPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-subjects-filter" on_select={&JS.push("toggle_subject_id", value: %{"id" => &1})} + items={@subjects} selected_ids={@selected_subjects_ids} class="mt-6" /> @@ -189,9 +190,10 @@ <%= gettext("Filter curriculum items by year") %> <.live_component - module={YearPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-years-filter" on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} selected_ids={@selected_years_ids} class="mt-6" /> diff --git a/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex b/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex new file mode 100644 index 00000000..2acf6ea0 --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex @@ -0,0 +1,342 @@ +defmodule LantternWeb.ReportCardLive.GradesReportGridSetupOverlayComponent do + use LantternWeb, :live_component + + alias Lanttern.Reporting + alias Lanttern.Reporting.GradesReportCycle + alias Lanttern.Schools + alias Lanttern.Taxonomy + + import Lanttern.Utils, only: [swap: 3] + + @impl true + def render(assigns) do + ~H""" +
+ <.slide_over id={@id} show={true} on_cancel={@on_cancel}> + <:title> + <%= gettext("%{grades_report} grid setup", grades_report: @grades_report.name) %> + +
<%= gettext("Grid sub cycles") %>
+
+ <.badge_button + :for={cycle <- @cycles} + theme={if cycle.id in @selected_cycles_ids, do: "primary", else: "default"} + icon_name={ + if cycle.id in @selected_cycles_ids, do: "hero-check-mini", else: "hero-plus-mini" + } + phx-click={JS.push("toggle_cycle", value: %{"id" => cycle.id}, target: @myself)} + > + <%= cycle.name %> + +
+ <%= if @grades_report_cycles == [] do %> +
+ <%= gettext("No sub cycles linked") %> +
+ <% else %> +
+ <%= gettext("Sub cycle") %> + <%= gettext("Grading weight") %> +
+ <.grades_report_cycle_form + :for={grades_report_cycle <- @grades_report_cycles} + id={"grades-report-cycle-#{grades_report_cycle.id}"} + grades_report_cycle={grades_report_cycle} + myself={@myself} + /> + <% end %> +
<%= gettext("Grid subjects") %>
+
+ <.badge_button + :for={subject <- @subjects} + theme={if subject.id in @selected_subjects_ids, do: "primary", else: "default"} + icon_name={ + if subject.id in @selected_subjects_ids, + do: "hero-check-mini", + else: "hero-plus-mini" + } + phx-click={JS.push("toggle_subject", value: %{"id" => subject.id}, target: @myself)} + > + <%= subject.name %> + +
+ <%= if @sortable_grades_report_subjects == [] do %> +
+ <%= gettext("No subjects linked") %> +
+ <% else %> + <.sortable_card + :for={{grades_report_subject, i} <- @sortable_grades_report_subjects} + id={"sortable-grades-report-subject-#{grades_report_subject.id}"} + class="mt-4" + is_move_up_disabled={i == 0} + on_move_up={ + JS.push("swap_grades_report_subjects_position", + value: %{from: i, to: i - 1}, + target: @myself + ) + } + is_move_down_disabled={i + 1 == length(@sortable_grades_report_subjects)} + on_move_down={ + JS.push("swap_grades_report_subjects_position", + value: %{from: i, to: i + 1}, + target: @myself + ) + } + > + <%= grades_report_subject.subject.name %> + + <% end %> + +
+ """ + end + + # function components + + attr :id, :string, required: true + attr :grades_report_cycle, GradesReportCycle, required: true + attr :myself, :any, required: true + + def grades_report_cycle_form(assigns) do + form = + assigns.grades_report_cycle + |> GradesReportCycle.changeset(%{}) + |> to_form(as: "grades_report_cycle_#{assigns.grades_report_cycle.id}") + + # we use :as option to avoid using hidden id input (which is easy to "hack") + + assigns = + assigns + |> assign(:form, form) + + ~H""" + <.form + id={@id} + for={@form} + class="flex items-center justify-between p-4 rounded mt-4 bg-white shadow-lg" + phx-change={JS.push("update_grades_report_cycle_weight", target: @myself)} + > + <%= @grades_report_cycle.school_cycle.name %> + + + """ + end + + # lifecycle + + @impl true + def update(assigns, socket) do + %{grades_report: grades_report} = assigns + + cycles = + Schools.list_cycles() + |> Enum.filter(&(&1.id != grades_report.school_cycle_id)) + + grades_report_cycles = Reporting.list_grades_report_cycles(grades_report.id) + selected_cycles_ids = grades_report_cycles |> Enum.map(& &1.school_cycle_id) + + subjects = Taxonomy.list_subjects() + grades_report_subjects = Reporting.list_grades_report_subjects(grades_report.id) + selected_subjects_ids = grades_report_subjects |> Enum.map(& &1.subject.id) + sortable_grades_report_subjects = grades_report_subjects |> Enum.with_index() + + socket = + socket + |> assign(assigns) + |> assign(:cycles, cycles) + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + |> assign(:subjects, subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + + {:ok, socket} + end + + # event handlers + + @impl true + def handle_event("toggle_cycle", %{"id" => cycle_id}, socket) do + socket = + case cycle_id in socket.assigns.selected_cycles_ids do + true -> remove_grades_report_cycle(socket, cycle_id) + false -> add_grades_report_cycle(socket, cycle_id) + end + + {:noreply, socket} + end + + def handle_event("toggle_subject", %{"id" => subject_id}, socket) do + # get grades report subjects without index + # (both functions — add and remove — will need it) + grades_report_subjects = + socket.assigns.sortable_grades_report_subjects + |> Enum.map(fn {grades_report_subject, _i} -> grades_report_subject end) + + socket = + case subject_id in socket.assigns.selected_subjects_ids do + true -> remove_grades_report_subject(socket, grades_report_subjects, subject_id) + false -> add_grades_report_subject(socket, grades_report_subjects, subject_id) + end + + {:noreply, socket} + end + + def handle_event("swap_grades_report_subjects_position", %{"from" => i, "to" => j}, socket) do + sortable_grades_report_subjects = + socket.assigns.sortable_grades_report_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject end) + |> swap(i, j) + |> Enum.with_index() + + sortable_grades_report_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject.id end) + |> Reporting.update_grades_report_subjects_positions() + |> case do + :ok -> + socket = + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + + {:noreply, socket} + + {:error, msg} -> + {:noreply, put_flash(socket, :error, msg)} + end + end + + def handle_event("update_grades_report_cycle_weight", params, socket) do + # we use :as option to avoid using hidden id input (which is easy to "hack") + # here we need to "extract" the id from params key — which we do with reduce + {grades_report_cycle, weight} = + Enum.reduce(params, fn + {"grades_report_cycle_" <> id, %{"weight" => weight_str}}, _acc -> + grades_report_cycle = + socket.assigns.grades_report_cycles + |> Enum.find(&("#{&1.id}" == id)) + + weight = + case Float.parse(weight_str) do + :error -> grades_report_cycle.weight + {weight, _} -> weight + end + + {grades_report_cycle, weight} + + _, acc -> + acc + end) + + Reporting.update_grades_report_cycle(grades_report_cycle, %{weight: weight}) + |> case do + {:ok, _grades_report_cycle} -> + {:noreply, socket} + + {:error, _changeset} -> + {:noreply, + put_flash(socket, :error, gettext("Error updating grades report cycle weight"))} + end + end + + defp add_grades_report_cycle(socket, cycle_id) do + %{ + grades_report_id: socket.assigns.grades_report.id, + school_cycle_id: cycle_id + } + |> Reporting.add_cycle_to_grades_report() + |> case do + {:ok, _grades_report_cycle} -> + grades_report_cycles = + Reporting.list_grades_report_cycles(socket.assigns.grades_report.id) + + selected_cycles_ids = grades_report_cycles |> Enum.map(& &1.school_cycle_id) + + socket + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding cycle to grades report")) + end + end + + defp remove_grades_report_cycle(socket, cycle_id) do + socket.assigns.grades_report_cycles + |> Enum.find(&(&1.school_cycle_id == cycle_id)) + |> Reporting.delete_grades_report_cycle() + |> case do + {:ok, _grades_report_cycle} -> + grades_report_cycles = + socket.assigns.grades_report_cycles + |> Enum.filter(&(&1.school_cycle_id != cycle_id)) + + selected_cycles_ids = + socket.assigns.selected_cycles_ids + |> Enum.filter(&(&1 != cycle_id)) + + socket + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing cycle from grades report")) + end + end + + defp add_grades_report_subject(socket, grades_report_subjects, subject_id) do + %{ + grades_report_id: socket.assigns.grades_report.id, + subject_id: subject_id + } + |> Reporting.add_subject_to_grades_report() + |> case do + {:ok, grades_report_subject} -> + sortable_grades_report_subjects = + (grades_report_subjects ++ [grades_report_subject]) + |> Enum.with_index() + + selected_subjects_ids = + [grades_report_subject.subject_id | socket.assigns.selected_subjects_ids] + + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding subject to grades report")) + end + end + + defp remove_grades_report_subject(socket, grades_report_subjects, subject_id) do + grades_report_subjects + |> Enum.find(&(&1.subject_id == subject_id)) + |> Reporting.delete_grades_report_subject() + |> case do + {:ok, grades_report_subject} -> + sortable_grades_report_subjects = + grades_report_subjects + |> Enum.filter(&(&1.id != grades_report_subject.id)) + |> Enum.with_index() + + selected_subjects_ids = + sortable_grades_report_subjects + |> Enum.map(fn {grs, _i} -> grs.subject_id end) + + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing subject from grades report")) + end + end +end diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex new file mode 100644 index 00000000..e78896cb --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex @@ -0,0 +1,109 @@ +defmodule LantternWeb.GradesReportsLive do + use LantternWeb, :live_view + + alias Lanttern.Reporting + alias Lanttern.Reporting.GradesReport + + # local view components + alias LantternWeb.ReportCardLive.GradesReportGridSetupOverlayComponent + + # live components + alias LantternWeb.Reporting.GradesReportFormComponent + + # shared + import LantternWeb.GradingComponents + import LantternWeb.ReportingComponents + + # lifecycle + + @impl true + def handle_params(params, _uri, socket) do + grades_reports = + Reporting.list_grades_reports( + preloads: [scale: :ordinal_values], + load_grid: true + ) + + socket = + socket + |> stream(:grades_reports, grades_reports) + |> assign(:has_grades_reports, length(grades_reports) > 0) + |> assign_show_grades_report_form(params) + |> assign_show_grades_report_grid_editor(params) + + {:noreply, socket} + end + + defp assign_show_grades_report_form(socket, %{"is_creating" => "true"}) do + socket + |> assign(:grades_report, %GradesReport{}) + |> assign(:form_overlay_title, gettext("Create grade report")) + |> assign(:show_grades_report_form, true) + end + + defp assign_show_grades_report_form(socket, %{"is_editing" => id}) do + cond do + String.match?(id, ~r/[0-9]+/) -> + case Reporting.get_grades_report(id) do + %GradesReport{} = grades_report -> + socket + |> assign(:form_overlay_title, gettext("Edit grade report")) + |> assign(:grades_report, grades_report) + |> assign(:show_grades_report_form, true) + + _ -> + assign(socket, :show_grades_report_form, false) + end + + true -> + assign(socket, :show_grades_report_form, false) + end + end + + defp assign_show_grades_report_form(socket, _), + do: assign(socket, :show_grades_report_form, false) + + defp assign_show_grades_report_grid_editor(socket, %{"is_editing_grid" => id}) do + cond do + String.match?(id, ~r/[0-9]+/) -> + case Reporting.get_grades_report(id) do + %GradesReport{} = grades_report -> + socket + # |> assign(:form_overlay_title, gettext("Edit grade report")) + |> assign(:grades_report, grades_report) + |> assign(:show_grades_report_grid_editor, true) + + _ -> + assign(socket, :show_grades_report_grid_editor, false) + end + + true -> + assign(socket, :show_grades_report_grid_editor, false) + end + end + + defp assign_show_grades_report_grid_editor(socket, _), + do: assign(socket, :show_grades_report_grid_editor, false) + + # event handlers + + @impl true + def handle_event("delete_grades_report", _params, socket) do + case Reporting.delete_grades_report(socket.assigns.grades_report) do + {:ok, _grades_report} -> + socket = + socket + |> put_flash(:info, gettext("Grade report deleted")) + |> push_navigate(to: ~p"/grading") + + {:noreply, socket} + + {:error, _changeset} -> + socket = + socket + |> put_flash(:error, gettext("Error deleting grade report")) + + {:noreply, socket} + end + end +end diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex new file mode 100644 index 00000000..cee6bad1 --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex @@ -0,0 +1,109 @@ +
+ <.page_title_with_menu><%= gettext("Grades reports") %> +
+

+ <%= gettext("Viewing all grades reports") %> +

+ <.collection_action + type="link" + icon_name="hero-plus-circle" + patch={~p"/grading?is_creating=true"} + > + <%= gettext("Create new grades report") %> + +
+ <%= if @has_grades_reports do %> +
+
+
+

<%= grades_report.name %>

+ <.button + type="button" + theme="ghost" + icon_name="hero-pencil-mini" + phx-click={JS.patch(~p"/grading?is_editing=#{grades_report.id}")} + > + <%= gettext("Edit") %> + +
+
+
+ <.icon name="hero-calendar" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> + <%= gettext("Cycle") %>: <%= grades_report.school_cycle.name %> +
+
+ <.icon name="hero-view-columns" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> + <%= gettext("Scale") %>: <%= grades_report.scale.name %> +
+ <%= for ov <- grades_report.scale.ordinal_values do %> + <.ordinal_value_badge ordinal_value={ov}> + <%= ov.name %> + + <% end %> +
+
+
+ <.markdown :if={grades_report.info} text={grades_report.info} class="mb-6" size="sm" /> + <.grades_report_grid + grades_report={grades_report} + on_setup={JS.patch(~p"/grading?is_editing_grid=#{grades_report.id}")} + /> +
+
+ <% else %> + <.empty_state class="mt-12"> + <%= gettext("No grade reports created yet") %> + + <% end %> +
+<.slide_over + :if={@show_grades_report_form} + id="grade-report-form-overlay" + show={true} + on_cancel={JS.patch(~p"/grading")} +> + <:title><%= @form_overlay_title %> + <.live_component + module={GradesReportFormComponent} + id={@grades_report.id || :new} + grades_report={@grades_report} + navigate={fn _ -> ~p"/grading" end} + hide_submit + /> + <:actions_left :if={@grades_report.id}> + <.button + type="button" + theme="ghost" + phx-click="delete_grades_report" + data-confirm={gettext("Are you sure?")} + > + <%= gettext("Delete") %> + + + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#grade-report-form-overlay")} + > + <%= gettext("Cancel") %> + + <.button type="submit" form="grade-report-form"> + <%= gettext("Save") %> + + + +<.live_component + :if={@show_grades_report_grid_editor} + module={GradesReportGridSetupOverlayComponent} + id="grades-report-grid-overlay" + grades_report={@grades_report} + on_cancel={JS.patch(~p"/grading")} +/> diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex new file mode 100644 index 00000000..64066f27 --- /dev/null +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -0,0 +1,50 @@ +defmodule LantternWeb.ReportCardLive.GradesComponent do + use LantternWeb, :live_component + + alias Lanttern.Reporting + + # shared + import LantternWeb.ReportingComponents + + @impl true + def render(assigns) do + ~H""" +
+
+
+ <%= if @grades_report do %> +

+ <%= gettext("Grades report grid") %>: <%= @grades_report.name %> +

+ <.grades_report_grid grades_report={@grades_report} /> + <% else %> +

+ <%= gettext("Grades report grid") %> +

+ <.empty_state> + <%= gettext("No grades report linked to this report card.") %> + + <% end %> +
+
+
+ """ + end + + # lifecycle + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:grades_report, fn %{report_card: report_card} -> + case report_card.grades_report_id do + nil -> nil + id -> Reporting.get_grades_report(id, load_grid: true) + end + end) + + {:ok, socket} + end +end diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex index 6d89cf0a..805f7bbe 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex @@ -6,58 +6,50 @@ defmodule LantternWeb.ReportCardLive do # page components alias __MODULE__.StudentsComponent alias __MODULE__.StrandsReportsComponent + alias __MODULE__.GradesComponent # shared components alias LantternWeb.Reporting.ReportCardFormComponent @tabs %{ "students" => :students, - "strands" => :strands + "strands" => :strands, + "grades" => :grades } # lifecycle @impl true - def mount(params, _session, socket) do - socket = - socket - |> maybe_redirect(params) - + def mount(_params, _session, socket) do {:ok, socket, layout: {LantternWeb.Layouts, :app_logged_in_blank}} end - # prevent user from navigating directly to nested views - - defp maybe_redirect(%{assigns: %{live_action: :edit}} = socket, params), - do: redirect(socket, to: ~p"/report_cards/#{params["id"]}?tab=students") - - defp maybe_redirect(%{assigns: %{live_action: :edit_strand_report}} = socket, params), - do: redirect(socket, to: ~p"/report_cards/#{params["id"]}?tab=strands") - - defp maybe_redirect(socket, _params), do: socket - @impl true def handle_params(%{"id" => id} = params, _url, socket) do socket = socket |> assign(:params, params) |> assign_new(:report_card, fn -> - Reporting.get_report_card!(id, preloads: :school_cycle) + Reporting.get_report_card!(id, preloads: [:school_cycle, :year]) end) - |> set_current_tab(params, socket.assigns.live_action) + |> assign_current_tab(params) + |> assign_is_editing(params) {:noreply, socket} end - defp set_current_tab(socket, _params, :edit_strand_report), - do: assign(socket, :current_tab, @tabs["strands"]) - - defp set_current_tab(socket, %{"tab" => tab}, _live_action), + defp assign_current_tab(socket, %{"tab" => tab}), do: assign(socket, :current_tab, Map.get(@tabs, tab, :students)) - defp set_current_tab(socket, _params, _live_action), + defp assign_current_tab(socket, _params), do: assign(socket, :current_tab, :students) + defp assign_is_editing(socket, %{"is_editing" => "true"}), + do: assign(socket, :is_editing, true) + + defp assign_is_editing(socket, _), + do: assign(socket, :is_editing, false) + # event handlers @impl true diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex index 79aa6eb2..672369b0 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex @@ -9,9 +9,14 @@

<%= @report_card.name %>

- <.badge theme="dark" class="mt-6"> - <%= gettext("Cycle") %>: <%= @report_card.school_cycle.name %> - +
+ <.badge theme="dark"> + <%= gettext("Cycle") %>: <%= @report_card.school_cycle.name %> + + <.badge theme="dark"> + <%= @report_card.year.name %> + +
@@ -28,12 +33,20 @@ > <%= gettext("Strands") %> + <:tab + patch={~p"/report_cards/#{@report_card}?#{%{tab: "grades"}}"} + is_current={@current_tab == :grades && "true"} + > + <%= gettext("Grades") %> + <.menu_button id={@report_card.id}> <:menu_items> <.menu_button_item id={"edit-report-card-#{@report_card.id}"} - phx-click={JS.patch(~p"/report_cards/#{@report_card}/edit")} + phx-click={ + JS.patch(~p"/report_cards/#{@report_card}?tab=#{@current_tab}&is_editing=true") + } > <%= gettext("Edit report card") %> @@ -50,16 +63,15 @@
-
- <.live_component - module={StudentsComponent} - id="report-card-students" - report_card={@report_card} - current_user={@current_user} - params={@params} - /> -
-
+ <.live_component + :if={@current_tab == :students} + module={StudentsComponent} + id="report-card-students" + report_card={@report_card} + current_user={@current_user} + params={@params} + /> +
<.markdown text={@report_card.description} /> <.live_component module={StrandsReportsComponent} @@ -68,19 +80,27 @@ params={@params} />
+ <.live_component + :if={@current_tab == :grades} + module={GradesComponent} + id="report-card-grades" + report_card={@report_card} + current_user={@current_user} + params={@params} + />
<.slide_over - :if={@live_action == :edit} + :if={@is_editing} id="report-card-form-overlay" show={true} - on_cancel={JS.patch(~p"/report_cards/#{@report_card}")} + on_cancel={JS.patch(~p"/report_cards/#{@report_card}?tab=#{@current_tab}")} > <:title><%= gettext("Edit card") %> <.live_component module={ReportCardFormComponent} id={@report_card.id} report_card={@report_card} - navigate={~p"/report_cards/#{@report_card}"} + navigate={~p"/report_cards/#{@report_card}?tab=#{@current_tab}"} hide_submit /> <:actions> diff --git a/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex b/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex index ad9f4c6e..404e92c2 100644 --- a/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex @@ -3,9 +3,11 @@ defmodule LantternWeb.ReportCardsLive do alias Lanttern.Reporting alias Lanttern.Reporting.ReportCard + import LantternWeb.PersonalizationHelpers # live components alias LantternWeb.Reporting.ReportCardFormComponent + alias LantternWeb.BadgeButtonPickerComponent # shared components import LantternWeb.ReportingComponents @@ -17,6 +19,7 @@ defmodule LantternWeb.ReportCardsLive do socket = socket |> maybe_redirect(params) + |> assign_user_filters([:cycles, :years], socket.assigns.current_user) |> stream_configure( :cycles_and_report_cards, dom_id: fn @@ -38,7 +41,13 @@ defmodule LantternWeb.ReportCardsLive do @impl true def handle_params(_params, _url, socket) do - cycles_and_report_cards = Reporting.list_report_cards_by_cycle() + cycles_and_report_cards = + Reporting.list_report_cards_by_cycle( + cycles_ids: socket.assigns.selected_cycles_ids, + years_ids: socket.assigns.selected_years_ids, + preloads: :year + ) + has_report_cards = length(cycles_and_report_cards) > 0 socket = @@ -48,4 +57,31 @@ defmodule LantternWeb.ReportCardsLive do {:noreply, socket} end + + # event handlers + + @impl true + def handle_event("toggle_cycle_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :cycles, id)} + + def handle_event("toggle_year_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :years, id)} + + def handle_event("clear_filters", _, socket) do + clear_profile_filters( + socket.assigns.current_user, + [:cycles, :years] + ) + + {:noreply, push_navigate(socket, to: ~p"/report_cards")} + end + + def handle_event("apply_filters", _, socket) do + socket = + socket + |> save_profile_filters(socket.assigns.current_user, [:cycles, :years]) + |> push_navigate(to: ~p"/report_cards") + + {:noreply, socket} + end end diff --git a/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex b/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex index 74d1e7bf..22db8b96 100644 --- a/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex @@ -2,7 +2,17 @@ <.page_title_with_menu><%= gettext("Report Cards") %>

- <%= gettext("Viewing report cards") %> + <%= gettext("Showing report cards from") %>
+ <.filter_text_button + type={gettext("cycles")} + items={@selected_cycles} + on_click={JS.exec("data-show", to: "#report-cards-filters")} + />, + <.filter_text_button + type={gettext("years")} + items={@selected_years} + on_click={JS.exec("data-show", to: "#report-cards-filters")} + />

<.collection_action type="link" icon_name="hero-plus-circle" patch={~p"/report_cards/new"}> <%= gettext("Create new report card") %> @@ -22,6 +32,7 @@ id={"report-card-#{report_card.id}"} report_card={report_card} navigate={~p"/report_cards/#{report_card}"} + year={report_card.year} />
@@ -32,6 +43,52 @@ <% end %> +<.slide_over id="report-cards-filters"> + <:title><%= gettext("Filter report cards") %> +
+ <%= gettext("By cycle") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="report-cards-cycles-filter" + on_select={&JS.push("toggle_cycle_id", value: %{"id" => &1})} + items={@cycles} + selected_ids={@selected_cycles_ids} + class="mt-4" + /> +
+ <%= gettext("By year") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="report-cards-years-filter" + on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} + selected_ids={@selected_years_ids} + class="mt-4" + /> + <:actions_left> + <.button type="button" theme="ghost" phx-click="clear_filters"> + <%= gettext("Clear filters") %> + + + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#report-cards-filters")} + > + <%= gettext("Cancel") %> + + <.button + type="button" + phx-disable-with={gettext("Applying filters...")} + phx-click="apply_filters" + > + <%= gettext("Apply filters") %> + + + <.slide_over :if={@live_action == :new} id="report-card-form-overlay" diff --git a/lib/lanttern_web/live/pages/strands/strands_live.ex b/lib/lanttern_web/live/pages/strands/strands_live.ex index 32e9f91d..edccf85a 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.ex +++ b/lib/lanttern_web/live/pages/strands/strands_live.ex @@ -3,52 +3,18 @@ defmodule LantternWeb.StrandsLive do alias Lanttern.LearningContext alias Lanttern.LearningContext.Strand - alias Lanttern.Taxonomy - import LantternWeb.LocalizationHelpers + + import LantternWeb.PersonalizationHelpers # live components alias LantternWeb.LearningContext.StrandFormComponent # shared components import LantternWeb.LearningContextComponents + alias LantternWeb.BadgeButtonPickerComponent # function components - attr :items, :list, required: true - attr :type, :string, required: true - - def filter_buttons(%{items: []} = assigns) do - ~H""" - - """ - end - - def filter_buttons(%{items: items, type: type} = assigns) do - items = - if length(items) > 3 do - {first_two, rest} = Enum.split(items, 2) - - first_two - |> Enum.map(& &1.name) - |> Enum.join(" / ") - |> Kernel.<>(" / + #{length(rest)} #{type}") - else - items - |> Enum.map(& &1.name) - |> Enum.join(" / ") - end - - assigns = assign(assigns, :items, items) - - ~H""" - - """ - end - attr :id, :string, required: true attr :strands, :list, required: true @@ -75,149 +41,24 @@ defmodule LantternWeb.StrandsLive do @impl true def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:is_creating_strand, false) - |> assign(:current_subjects, []) - |> assign(:current_years, [])} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, - socket - |> assign( - :subjects, - Taxonomy.list_subjects() |> translate_struct_list("taxonomy", :name, reorder: true) - ) - |> assign(:years, Taxonomy.list_years() |> translate_struct_list("taxonomy")) - # sync subjects_ids and years_ids filters with profile - |> handle_params_and_profile_filters_sync( - params, - [:subjects_ids, :years_ids], - &handle_assigns/2, - fn params -> ~p"/strands?#{params}" end - )} - end - - # event handlers - - @impl true - def handle_event("create-strand", _params, socket) do - {:noreply, assign(socket, :is_creating_strand, true)} - end - - def handle_event("cancel-strand-creation", _params, socket) do - {:noreply, assign(socket, :is_creating_strand, false)} - end - - def handle_event("load-more", _params, socket) do - {:noreply, load_strands(socket)} - end - - def handle_event("star-strand", %{"id" => id, "name" => name}, socket) do - profile_id = socket.assigns.current_user.current_profile.id - - with {:ok, _} <- LearningContext.star_strand(id, profile_id) do - {:noreply, - socket - |> put_flash(:info, "\"#{name}\" added to your starred strands") - |> push_navigate(to: ~p"/strands", replace: true)} - end - end - - def handle_event("unstar-strand", %{"id" => id, "name" => name}, socket) do - profile_id = socket.assigns.current_user.current_profile.id - - with {:ok, _} <- LearningContext.unstar_strand(id, profile_id) do - {:noreply, - socket - |> put_flash(:info, "\"#{name}\" removed from your starred strands") - |> push_navigate(to: ~p"/strands", replace: true)} - end - end - - def handle_event("filter", params, socket) do - # enforce years and subjects in params, - # required to clear profile filters when none is selected - params = %{ - subjects_ids: Map.get(params, "subjects_ids"), - years_ids: Map.get(params, "years_ids") - } - - {:noreply, - socket - |> push_navigate(to: ~p"/strands?#{params}")} - end - - def handle_event("clear-filters", _params, socket) do - params = %{ - subjects_ids: nil, - years_ids: nil - } - - {:noreply, - socket - |> push_navigate(to: path(socket, ~p"/strands?#{params}"))} - end - - # helpers - - defp handle_assigns(socket, params) do - params_years_ids = - case Map.get(params, "years_ids") do - ids when is_list(ids) -> ids - _ -> nil - end - - params_subjects_ids = - case Map.get(params, "subjects_ids") do - ids when is_list(ids) -> ids - _ -> nil - end - - form = - %{ - "years_ids" => params_years_ids || [], - "subjects_ids" => params_subjects_ids || [] - } - |> Phoenix.Component.to_form() - - socket - |> assign_subjects(params) - |> assign_years(params) - |> assign(:form, form) - |> load_strands() - end - - defp assign_subjects(socket, %{"subjects_ids" => subjects_ids}) when subjects_ids != "" do - current_subjects = - socket.assigns.subjects - |> Enum.filter(&("#{&1.id}" in subjects_ids)) - - assign(socket, :current_subjects, current_subjects) - end - - defp assign_subjects(socket, _params), do: socket - - defp assign_years(socket, %{"years_ids" => years_ids}) when years_ids != "" do - current_years = - socket.assigns.years - |> Enum.filter(&("#{&1.id}" in years_ids)) + socket = + socket + |> assign_user_filters( + [:subjects, :years], + socket.assigns.current_user + ) + |> assign(:is_creating_strand, false) + |> stream_strands() - assign(socket, :current_years, current_years) + {:ok, socket} end - defp assign_years(socket, _params), do: socket - - defp load_strands(socket) do - subjects_ids = - socket.assigns.current_subjects - |> Enum.map(& &1.id) - - years_ids = - socket.assigns.current_years - |> Enum.map(& &1.id) + defp stream_strands(socket) do + %{ + selected_subjects_ids: subjects_ids, + selected_years_ids: years_ids + } = + socket.assigns {strands, meta} = LearningContext.list_strands( @@ -249,21 +90,61 @@ defmodule LantternWeb.StrandsLive do |> assign(:starred_strands_count, starred_strands_count) end - defp show_filter(js \\ %JS{}) do - js - # |> JS.push("show-filter") - |> JS.exec("data-show", to: "#strands-filters") + # event handlers + + @impl true + def handle_event("create-strand", _params, socket), + do: {:noreply, assign(socket, :is_creating_strand, true)} + + def handle_event("cancel-strand-creation", _params, socket), + do: {:noreply, assign(socket, :is_creating_strand, false)} + + def handle_event("load-more", _params, socket), + do: {:noreply, stream_strands(socket)} + + def handle_event("star-strand", %{"id" => id, "name" => name}, socket) do + profile_id = socket.assigns.current_user.current_profile.id + + with {:ok, _} <- LearningContext.star_strand(id, profile_id) do + {:noreply, + socket + |> put_flash(:info, "\"#{name}\" added to your starred strands") + |> push_navigate(to: ~p"/strands", replace: true)} + end + end + + def handle_event("unstar-strand", %{"id" => id, "name" => name}, socket) do + profile_id = socket.assigns.current_user.current_profile.id + + with {:ok, _} <- LearningContext.unstar_strand(id, profile_id) do + {:noreply, + socket + |> put_flash(:info, "\"#{name}\" removed from your starred strands") + |> push_navigate(to: ~p"/strands", replace: true)} + end end - defp filter(js \\ %JS{}) do - js - |> JS.push("filter") - |> JS.exec("data-cancel", to: "#strands-filters") + def handle_event("toggle_subject_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :subjects, id)} + + def handle_event("toggle_year_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :years, id)} + + def handle_event("clear_filters", _, socket) do + clear_profile_filters( + socket.assigns.current_user, + [:subjects, :years] + ) + + {:noreply, push_navigate(socket, to: ~p"/strands")} end - defp clear_filters(js \\ %JS{}) do - js - |> JS.push("clear-filters") - |> JS.exec("data-cancel", to: "#strands-filters") + def handle_event("apply_filters", _, socket) do + socket = + socket + |> save_profile_filters(socket.assigns.current_user, [:subjects, :years]) + |> push_navigate(to: ~p"/strands") + + {:noreply, socket} end end diff --git a/lib/lanttern_web/live/pages/strands/strands_live.html.heex b/lib/lanttern_web/live/pages/strands/strands_live.html.heex index 32003292..bf196e34 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.html.heex +++ b/lib/lanttern_web/live/pages/strands/strands_live.html.heex @@ -3,8 +3,16 @@

<%= gettext("I want to explore strands in") %>
- <.filter_buttons type={gettext("years")} items={@current_years} />, - <.filter_buttons type={gettext("subjects")} items={@current_subjects} /> + <.filter_text_button + type={gettext("years")} + items={@selected_years} + on_click={JS.exec("data-show", to: "#strands-filters")} + />, + <.filter_text_button + type={gettext("subjects")} + items={@selected_subjects} + on_click={JS.exec("data-show", to: "#strands-filters")} + />

<.collection_action type="button" icon_name="hero-plus-circle" phx-click="create-strand"> <%= gettext("Create new strand") %> @@ -51,36 +59,30 @@
<.slide_over id="strands-filters"> <:title><%= gettext("Filter Strands") %> - <.form id="strands-filters-form" for={@form} phx-submit={filter()} class="flex gap-6"> -
- - <%= gettext("Years") %> - -
- <.check_field - :for={opt <- @years} - id={"year-#{opt.id}"} - field={@form[:years_ids]} - opt={opt} - /> -
-
-
- - <%= gettext("Subjects") %> - -
- <.check_field - :for={opt <- @subjects} - id={"subject-#{opt.id}"} - field={@form[:subjects_ids]} - opt={opt} - /> -
-
- +
+ <%= gettext("By subject") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="strands-subjects-filter" + on_select={&JS.push("toggle_subject_id", value: %{"id" => &1})} + items={@subjects} + selected_ids={@selected_subjects_ids} + class="mt-4" + /> +
+ <%= gettext("By year") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="strands-years-filter" + on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} + selected_ids={@selected_years_ids} + class="mt-4" + /> <:actions_left> - <.button type="button" theme="ghost" phx-click={clear_filters()}> + <.button type="button" theme="ghost" phx-click="clear_filters"> <%= gettext("Clear filters") %> @@ -93,9 +95,9 @@ <%= gettext("Cancel") %> <.button - type="submit" - form="strands-filters-form" + type="button" phx-disable-with={gettext("Applying filters...")} + phx-click="apply_filters" > <%= gettext("Apply filters") %> @@ -113,8 +115,8 @@ id={:new} strand={ %Strand{ - subjects: @current_subjects, - years: @current_years + subjects: @selected_subjects, + years: @selected_years } } action={:new} diff --git a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex b/lib/lanttern_web/live/shared/badge_button_picker.ex similarity index 59% rename from lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex rename to lib/lanttern_web/live/shared/badge_button_picker.ex index 66929e8d..27ca3a3b 100644 --- a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex +++ b/lib/lanttern_web/live/shared/badge_button_picker.ex @@ -1,19 +1,17 @@ -defmodule LantternWeb.Taxonomy.YearPickerComponent do +defmodule LantternWeb.BadgeButtonPickerComponent do use LantternWeb, :live_component - alias Lanttern.Taxonomy - @impl true def render(assigns) do ~H"""
<.badge_button - :for={year <- @years} - theme={if year.id in @selected_ids, do: "cyan", else: "default"} - icon_name={if year.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} - phx-click={@on_select.(year.id)} + :for={item <- @items} + theme={if item.id in @selected_ids, do: "primary", else: "default"} + icon_name={if item.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} + phx-click={@on_select.(item.id)} > - <%= year.name %> + <%= Map.get(item, @item_key) %>
""" @@ -24,6 +22,8 @@ defmodule LantternWeb.Taxonomy.YearPickerComponent do socket = socket |> assign(:class, nil) + |> assign(:items, []) + |> assign(:item_key, :name) |> assign(:selected_ids, []) |> assign(:on_select, fn _id -> %JS{} end) @@ -35,9 +35,6 @@ defmodule LantternWeb.Taxonomy.YearPickerComponent do socket = socket |> assign(assigns) - |> assign_new(:years, fn -> - Taxonomy.list_years() - end) {:ok, socket} end diff --git a/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex b/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex index 145b0239..0ace6127 100644 --- a/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex +++ b/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex @@ -2,12 +2,10 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do use LantternWeb, :live_component alias Lanttern.Curricula - # alias Lanttern.Taxonomy - # import LantternWeb.TaxonomyHelpers + alias Lanttern.Taxonomy # live components - alias LantternWeb.Taxonomy.YearPickerComponent - alias LantternWeb.Taxonomy.SubjectPickerComponent + alias LantternWeb.BadgeButtonPickerComponent @impl true def render(assigns) do @@ -38,18 +36,20 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do
<.label><%= gettext("Subjects") %> <.live_component - module={SubjectPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-subjects-select" on_select={&JS.push("toggle_subject", value: %{"id" => &1}, target: @myself)} + items={@subjects} selected_ids={@selected_subjects_ids} />
<.label><%= gettext("Years") %> <.live_component - module={YearPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-years-select" on_select={&JS.push("toggle_year", value: %{"id" => &1}, target: @myself)} + items={@years} selected_ids={@selected_years_ids} />
@@ -76,12 +76,12 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do def update(%{curriculum_item: curriculum_item} = assigns, socket) do changeset = Curricula.change_curriculum_item(curriculum_item) - subjects_ids = + selected_subjects_ids = curriculum_item |> Map.get(:subjects, []) |> Enum.map(& &1.id) - years_ids = + selected_years_ids = curriculum_item |> Map.get(:years, []) |> Enum.map(& &1.id) @@ -89,8 +89,10 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do socket = socket |> assign(assigns) - |> assign(:selected_subjects_ids, subjects_ids) - |> assign(:selected_years_ids, years_ids) + |> assign(:subjects, Taxonomy.list_subjects()) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:years, Taxonomy.list_years()) + |> assign(:selected_years_ids, selected_years_ids) |> assign_form(changeset) {:ok, socket} diff --git a/lib/lanttern_web/live/shared/menu_component.ex b/lib/lanttern_web/live/shared/menu_component.ex index 92d5022c..5d8fabac 100644 --- a/lib/lanttern_web/live/shared/menu_component.ex +++ b/lib/lanttern_web/live/shared/menu_component.ex @@ -31,17 +31,19 @@ defmodule LantternWeb.MenuComponent do <.nav_item active={@active_nav == :curriculum} path={~p"/curriculum"}> <%= gettext("Curriculum") %> - <.nav_item active={@active_nav == :reporting} path={~p"/report_cards"}> - <%= gettext("Reporting") %> + <.nav_item active={@active_nav == :report_cards} path={~p"/report_cards"}> + <%= gettext("Report cards") %> + + <.nav_item active={@active_nav == :grading} path={~p"/grading"}> + <%= gettext("Grading") %> <%!-- use this li as placeholder when nav items % 3 != 0--%>
  • -
  • - lanttern + Lanttern
    @@ -249,26 +251,27 @@ defmodule LantternWeb.MenuComponent do :dashboard socket.view in [ - LantternWeb.StrandLive.List, - LantternWeb.StrandLive.Details + LantternWeb.StrandsLive, + LantternWeb.StrandLive, + LantternWeb.MomentLive ] -> :strands socket.view in [ - LantternWeb.SchoolLive.Show, - LantternWeb.SchoolLive.Class, - LantternWeb.SchoolLive.Student + LantternWeb.SchoolLive, + LantternWeb.ClassLive, + LantternWeb.StudentLive ] -> :school socket.view in [ - LantternWeb.AssessmentPointLive.Explorer, - LantternWeb.AssessmentPointLive.Details + LantternWeb.AssessmentPointsLive, + LantternWeb.AssessmentPointLive ] -> :assessment_points socket.view in [ - LantternWeb.RubricsLive.Explorer + LantternWeb.RubricsLive ] -> :rubrics @@ -281,7 +284,10 @@ defmodule LantternWeb.MenuComponent do :curriculum socket.view in [LantternWeb.ReportCardsLive, LantternWeb.ReportCardLive] -> - :reporting + :report_cards + + socket.view in [LantternWeb.GradesReportsLive] -> + :grading true -> nil diff --git a/lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex b/lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex new file mode 100644 index 00000000..d1110a0c --- /dev/null +++ b/lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex @@ -0,0 +1,146 @@ +defmodule LantternWeb.Reporting.GradesReportFormComponent do + use LantternWeb, :live_component + + alias Lanttern.Reporting + + alias LantternWeb.GradingHelpers + alias LantternWeb.SchoolsHelpers + + @impl true + def render(assigns) do + ~H""" +
    + <.form + for={@form} + id="grade-report-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input + field={@form[:name]} + type="text" + label={gettext("Name")} + class="mb-6" + phx-debounce="1500" + /> + <.input + field={@form[:info]} + type="textarea" + label={gettext("Info")} + phx-debounce="1500" + class="mb-1" + show_optional + /> + <.markdown_supported class="mb-6" /> + <.input + field={@form[:school_cycle_id]} + type="select" + label="Parent cycle" + options={@cycle_options} + prompt="Select a cycle" + class="mb-6" + > + <:description> +

    + <%= gettext( + "The parent cycle grade is calculated based on it's children cycles. E.g. 2024 grade is based on 2024 Q1, Q2, Q3, and Q4 grades." + ) %> +

    + + + <.input + field={@form[:scale_id]} + type="select" + label="Scale" + options={@scale_options} + prompt="Select a scale" + class={if !@hide_submit, do: "mb-6"} + /> + <.button :if={!@hide_submit} phx-disable-with={gettext("Saving...")}> + <%= gettext("Save grade report") %> + + +
    + """ + end + + @impl true + def mount(socket) do + scale_options = GradingHelpers.generate_scale_options() + cycle_options = SchoolsHelpers.generate_cycle_options() + + socket = + socket + |> assign(:class, nil) + |> assign(:hide_submit, false) + |> assign(:scale_options, scale_options) + |> assign(:cycle_options, cycle_options) + + {:ok, socket} + end + + @impl true + def update(%{grades_report: grades_report} = assigns, socket) do + changeset = Reporting.change_grades_report(grades_report) + + socket = + socket + |> assign(assigns) + |> assign_form(changeset) + + {:ok, socket} + end + + @impl true + def handle_event("validate", %{"grades_report" => grades_report_params}, socket) do + changeset = + socket.assigns.grades_report + |> Reporting.change_grades_report(grades_report_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"grades_report" => grades_report_params}, socket) do + save_grades_report(socket, socket.assigns.grades_report.id, grades_report_params) + end + + defp save_grades_report(socket, nil, grades_report_params) do + case Reporting.create_grades_report(grades_report_params) do + {:ok, grades_report} -> + notify_parent(__MODULE__, {:saved, grades_report}, socket.assigns) + + socket = + socket + |> put_flash(:info, gettext("Grades report created successfully")) + |> handle_navigation(grades_report) + + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_grades_report(socket, _grades_report_id, grades_report_params) do + case Reporting.update_grades_report(socket.assigns.grades_report, grades_report_params) do + {:ok, grades_report} -> + notify_parent(__MODULE__, {:saved, grades_report}, socket.assigns) + + socket = + socket + |> put_flash(:info, gettext("Grades report updated successfully")) + |> handle_navigation(grades_report) + + {:noreply, socket} + + {: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 +end diff --git a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex index fac2129c..6b11c2f0 100644 --- a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex +++ b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex @@ -2,7 +2,9 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do use LantternWeb, :live_component alias Lanttern.Reporting + alias LantternWeb.ReportingHelpers alias LantternWeb.SchoolsHelpers + alias LantternWeb.TaxonomyHelpers @impl true def render(assigns) do @@ -24,6 +26,15 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do phx-target={@myself} class="mb-6" /> + <.input + field={@form[:year_id]} + type="select" + label={gettext("Year")} + options={@year_options} + prompt={gettext("Select year")} + phx-target={@myself} + class="mb-6" + /> <.input field={@form[:name]} type="text" @@ -38,7 +49,16 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do phx-debounce="1500" class="mb-1" /> - <.markdown_supported class={if !@hide_submit, do: "mb-6"} /> + <.markdown_supported class="mb-6" /> + <.input + field={@form[:grades_report_id]} + type="select" + label={gettext("Grades report")} + options={@grades_report_options} + prompt={gettext("Select grades report")} + phx-target={@myself} + class={if !@hide_submit, do: "mb-6"} + /> <.button :if={!@hide_submit} phx-disable-with={gettext("Saving...")}> <%= gettext("Save Report card") %> @@ -50,11 +70,15 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do @impl true def mount(socket) do cycle_options = SchoolsHelpers.generate_cycle_options() + year_options = TaxonomyHelpers.generate_year_options() + grades_report_options = ReportingHelpers.generate_grades_report_options() socket = socket |> assign(:class, nil) |> assign(:cycle_options, cycle_options) + |> assign(:year_options, year_options) + |> assign(:grades_report_options, grades_report_options) |> assign(:hide_submit, false) {:ok, socket} diff --git a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex b/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex deleted file mode 100644 index 090115b9..00000000 --- a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule LantternWeb.Taxonomy.SubjectPickerComponent do - use LantternWeb, :live_component - - alias Lanttern.Taxonomy - - @impl true - def render(assigns) do - ~H""" -
    - <.badge_button - :for={subject <- @subjects} - theme={if subject.id in @selected_ids, do: "cyan", else: "default"} - icon_name={if subject.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} - phx-click={@on_select.(subject.id)} - > - <%= subject.name %> - -
    - """ - end - - @impl true - def mount(socket) do - socket = - socket - |> assign(:class, nil) - |> assign(:selected_ids, []) - |> assign(:on_select, fn _id -> %JS{} end) - - {:ok, socket} - end - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(assigns) - |> assign_new(:subjects, fn -> - Taxonomy.list_subjects() - end) - - {:ok, socket} - end -end diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 288a4d92..cbec3f11 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -95,18 +95,21 @@ defmodule LantternWeb.Router do live "/curriculum/:id", CurriculumLive, :show live "/curriculum/component/:id", CurriculumComponentLive, :show - # reporting + # report cards live "/report_cards", ReportCardsLive, :index live "/report_cards/new", ReportCardsLive, :new live "/report_cards/:id", ReportCardLive, :show - live "/report_cards/:id/edit", ReportCardLive, :edit live "/student_report_card/:id", StudentReportCardLive, :show live "/student_report_card/:id/strand_report/:strand_report_id", StudentStrandReportLive, :show + + # grading + + live "/grading", GradesReportsLive, :index end end diff --git a/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs b/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs new file mode 100644 index 00000000..590887a6 --- /dev/null +++ b/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs @@ -0,0 +1,16 @@ +defmodule Lanttern.Repo.Migrations.CreateReportCardGradesSubjects do + use Ecto.Migration + + def change do + create table(:report_card_grades_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_subjects, [:subject_id]) + create unique_index(:report_card_grades_subjects, [:report_card_id, :subject_id]) + end +end diff --git a/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs b/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs new file mode 100644 index 00000000..b40c9ac3 --- /dev/null +++ b/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs @@ -0,0 +1,15 @@ +defmodule Lanttern.Repo.Migrations.CreateReportCardGradesCycles do + use Ecto.Migration + + def change do + create table(:report_card_grades_cycles) do + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_cycles, [:school_cycle_id]) + create unique_index(:report_card_grades_cycles, [:report_card_id, :school_cycle_id]) + end +end diff --git a/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs b/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs new file mode 100644 index 00000000..ba26dbe3 --- /dev/null +++ b/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs @@ -0,0 +1,18 @@ +defmodule Lanttern.Repo.Migrations.AddYearToReportCards do + use Ecto.Migration + + def change do + alter table(:report_cards) do + add :year_id, references(:years, on_delete: :nothing) + end + + create index(:report_cards, [:year_id]) + + # link existing report cards to an existing year, + # enabling to set the field as not null + execute "UPDATE report_cards SET year_id = years.id FROM years", "" + + # add not null constraints to report_cards' year_id + execute "ALTER TABLE report_cards ALTER COLUMN year_id SET NOT NULL", "" + end +end diff --git a/priv/repo/migrations/20240306174041_create_grade_reports.exs b/priv/repo/migrations/20240306174041_create_grade_reports.exs new file mode 100644 index 00000000..3940678a --- /dev/null +++ b/priv/repo/migrations/20240306174041_create_grade_reports.exs @@ -0,0 +1,21 @@ +defmodule Lanttern.Repo.Migrations.CreateGradeReports do + use Ecto.Migration + + def change do + create table(:grade_reports) do + add :info, :text + add :is_differentiation, :boolean, null: false, default: false + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :year_id, references(:years, on_delete: :nothing), null: false + add :scale_id, references(:grading_scales, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grade_reports, [:school_cycle_id]) + create index(:grade_reports, [:subject_id]) + create index(:grade_reports, [:year_id]) + create index(:grade_reports, [:scale_id]) + end +end diff --git a/priv/repo/migrations/20240307173152_adjust_grade_reports.exs b/priv/repo/migrations/20240307173152_adjust_grade_reports.exs new file mode 100644 index 00000000..085e6a36 --- /dev/null +++ b/priv/repo/migrations/20240307173152_adjust_grade_reports.exs @@ -0,0 +1,15 @@ +defmodule Lanttern.Repo.Migrations.AdjustGradeReports do + use Ecto.Migration + + def change do + # "clear" table before migrating + execute "DELETE FROM grade_reports", "" + + alter table(:grade_reports) do + remove :subject_id, references(:subjects, on_delete: :nothing), null: false + remove :year_id, references(:years, on_delete: :nothing), null: false + + add :name, :text, null: false + end + end +end diff --git a/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs b/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs new file mode 100644 index 00000000..51eaf965 --- /dev/null +++ b/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs @@ -0,0 +1,28 @@ +defmodule Lanttern.Repo.Migrations.RenameGradeReportsToGradesReports do + use Ecto.Migration + + def change do + # grade_reports to gradeS_reports + + # table + execute "ALTER TABLE grade_reports RENAME TO grades_reports", + "ALTER TABLE grades_reports RENAME TO grade_reports" + + # indexes + execute "ALTER INDEX grade_reports_pkey RENAME TO grades_reports_pkey", + "ALTER INDEX grades_reports_pkey RENAME TO grade_reports_pkey" + + execute "ALTER INDEX grade_reports_scale_id_index RENAME TO grades_reports_scale_id_index", + "ALTER INDEX grades_reports_scale_id_index RENAME TO grade_reports_scale_id_index" + + execute "ALTER INDEX grade_reports_school_cycle_id_index RENAME TO grades_reports_school_cycle_id_index", + "ALTER INDEX grades_reports_school_cycle_id_index RENAME TO grade_reports_school_cycle_id_index" + + # constraints + execute "ALTER TABLE grades_reports RENAME CONSTRAINT grade_reports_scale_id_fkey TO grades_reports_scale_id_fkey", + "ALTER TABLE grades_reports RENAME CONSTRAINT grades_reports_scale_id_fkey TO grade_reports_scale_id_fkey" + + execute "ALTER TABLE grades_reports RENAME CONSTRAINT grade_reports_school_cycle_id_fkey TO grades_reports_school_cycle_id_fkey", + "ALTER TABLE grades_reports RENAME CONSTRAINT grades_reports_school_cycle_id_fkey TO grade_reports_school_cycle_id_fkey" + end +end diff --git a/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs b/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs new file mode 100644 index 00000000..b694a432 --- /dev/null +++ b/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs @@ -0,0 +1,27 @@ +defmodule Lanttern.Repo.Migrations.CreateGradesReportCyclesAndSubjects do + use Ecto.Migration + + def change do + create table(:grades_report_cycles) do + add :weight, :float, null: false, default: 1.0 + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :grades_report_id, references(:grades_reports, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grades_report_cycles, [:school_cycle_id]) + create unique_index(:grades_report_cycles, [:grades_report_id, :school_cycle_id]) + + create table(:grades_report_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :grades_report_id, references(:grades_reports, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grades_report_subjects, [:subject_id]) + create unique_index(:grades_report_subjects, [:grades_report_id, :subject_id]) + end +end diff --git a/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs b/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs new file mode 100644 index 00000000..22754580 --- /dev/null +++ b/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs @@ -0,0 +1,11 @@ +defmodule Lanttern.Repo.Migrations.AddGradesReportIdToReportCards do + use Ecto.Migration + + def change do + alter table(:report_cards) do + add :grades_report_id, references(:grades_reports, on_delete: :nothing) + end + + create index(:report_cards, [:grades_report_id]) + end +end diff --git a/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs b/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs new file mode 100644 index 00000000..dec1b737 --- /dev/null +++ b/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs @@ -0,0 +1,33 @@ +defmodule Lanttern.Repo.Migrations.DropReportCardGradesCyclesAndSubjects do + use Ecto.Migration + + def up do + drop table(:report_card_grades_subjects) + drop table(:report_card_grades_cycles) + end + + def down do + # report card grades subjects + create table(:report_card_grades_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_subjects, [:subject_id]) + create unique_index(:report_card_grades_subjects, [:report_card_id, :subject_id]) + + # report card grades cycles + create table(:report_card_grades_cycles) do + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_cycles, [:school_cycle_id]) + create unique_index(:report_card_grades_cycles, [:report_card_id, :school_cycle_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 9a600543..113109a5 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -1,6 +1,7 @@ defmodule Lanttern.ReportingTest do use Lanttern.DataCase + alias Lanttern.Repo alias Lanttern.Reporting describe "report_cards" do @@ -26,7 +27,7 @@ defmodule Lanttern.ReportingTest do assert expected.school_cycle.id == school_cycle.id end - test "list_report_cards/1 with filters returns all filtered report_cards" do + test "list_report_cards/1 with strand filters returns all filtered report_cards" do report_card = report_card_fixture() strand = Lanttern.LearningContextFixtures.strand_fixture() strand_report_fixture(%{report_card_id: report_card.id, strand_id: strand.id}) @@ -40,6 +41,21 @@ defmodule Lanttern.ReportingTest do assert expected.id == report_card.id end + test "list_report_cards/1 with year/cycle filters returns all filtered report_cards" do + year = Lanttern.TaxonomyFixtures.year_fixture() + cycle = Lanttern.SchoolsFixtures.cycle_fixture() + report_card = report_card_fixture(%{school_cycle_id: cycle.id, year_id: year.id}) + + # extra report cards for filtering test + report_card_fixture() + report_card_fixture(%{year_id: year.id}) + report_card_fixture(%{school_cycle_id: cycle.id}) + + [expected] = Reporting.list_report_cards(years_ids: [year.id], cycles_ids: [cycle.id]) + + assert expected.id == report_card.id + end + test "list_report_cards_by_cycle/0 returns report_cards grouped by cycle" do school = SchoolsFixtures.school_fixture() @@ -91,11 +107,13 @@ defmodule Lanttern.ReportingTest do test "create_report_card/1 with valid data creates a report_card" do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + year = Lanttern.TaxonomyFixtures.year_fixture() valid_attrs = %{ name: "some name", description: "some description", - school_cycle_id: school_cycle.id + school_cycle_id: school_cycle.id, + year_id: year.id } assert {:ok, %ReportCard{} = report_card} = Reporting.create_report_card(valid_attrs) @@ -576,4 +594,455 @@ defmodule Lanttern.ReportingTest do assert expected_entry_2_1.score == 5 end end + + describe "grades_reports" do + alias Lanttern.Reporting.GradesReport + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + @invalid_attrs %{info: "blah", scale_id: nil} + + test "list_grades_reports/1 returns all grades_reports" do + grades_report = grades_report_fixture() + assert Reporting.list_grades_reports() == [grades_report] + end + + test "list_grades_reports/1 with load grid opt returns all grades_reports with linked and ordered cycles and subjects" do + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + cycle_2024_1 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-06-30]}) + + cycle_2024_2 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-07-01], end_at: ~D[2024-12-31]}) + + subject_a = TaxonomyFixtures.subject_fixture() + subject_b = TaxonomyFixtures.subject_fixture() + subject_c = TaxonomyFixtures.subject_fixture() + + grades_report = grades_report_fixture(%{school_cycle_id: cycle_2024.id}) + + grades_report_cycle_2024_1 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_1.id + }) + + grades_report_cycle_2024_2 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_2.id + }) + + # subjects order c, b, a + + grades_report_subject_c = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_c.id + }) + + grades_report_subject_b = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_b.id + }) + + grades_report_subject_a = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_a.id + }) + + assert [expected_grades_report] = Reporting.list_grades_reports(load_grid: true) + assert expected_grades_report.id == grades_report.id + assert expected_grades_report.school_cycle.id == cycle_2024.id + + # check cycles + assert [expected_grc_2024_1, expected_grc_2024_2] = + expected_grades_report.grades_report_cycles + + assert expected_grc_2024_1.id == grades_report_cycle_2024_1.id + assert expected_grc_2024_1.school_cycle.id == cycle_2024_1.id + assert expected_grc_2024_2.id == grades_report_cycle_2024_2.id + assert expected_grc_2024_2.school_cycle.id == cycle_2024_2.id + + # check subjects + assert [expected_grs_c, expected_grs_b, expected_grs_a] = + expected_grades_report.grades_report_subjects + + assert expected_grs_a.id == grades_report_subject_a.id + assert expected_grs_a.subject.id == subject_a.id + assert expected_grs_b.id == grades_report_subject_b.id + assert expected_grs_b.subject.id == subject_b.id + assert expected_grs_c.id == grades_report_subject_c.id + assert expected_grs_c.subject.id == subject_c.id + end + + test "get_grades_report!/2 returns the grades_report with given id" do + grades_report = grades_report_fixture() + assert Reporting.get_grades_report!(grades_report.id) == grades_report + end + + test "get_grades_report!/2 with preloads returns the grade report with given id and preloaded data" do + school_cycle = SchoolsFixtures.cycle_fixture() + grades_report = grades_report_fixture(%{school_cycle_id: school_cycle.id}) + + expected = Reporting.get_grades_report!(grades_report.id, preloads: :school_cycle) + + assert expected.id == grades_report.id + assert expected.school_cycle == school_cycle + end + + test "get_grades_report!/2 with load grid opt returns the grades report with linked and ordered cycles and subjects" do + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + cycle_2024_1 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-06-30]}) + + cycle_2024_2 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-07-01], end_at: ~D[2024-12-31]}) + + subject_a = TaxonomyFixtures.subject_fixture() + subject_b = TaxonomyFixtures.subject_fixture() + subject_c = TaxonomyFixtures.subject_fixture() + + grades_report = grades_report_fixture(%{school_cycle_id: cycle_2024.id}) + + grades_report_cycle_2024_1 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_1.id + }) + + grades_report_cycle_2024_2 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_2.id + }) + + # subjects order c, b, a + + grades_report_subject_c = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_c.id + }) + + grades_report_subject_b = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_b.id + }) + + grades_report_subject_a = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_a.id + }) + + assert expected_grades_report = + Reporting.get_grades_report!(grades_report.id, load_grid: true) + + assert expected_grades_report.id == grades_report.id + assert expected_grades_report.school_cycle.id == cycle_2024.id + + # check sub cycles + assert [expected_grc_2024_1, expected_grc_2024_2] = + expected_grades_report.grades_report_cycles + + assert expected_grc_2024_1.id == grades_report_cycle_2024_1.id + assert expected_grc_2024_1.school_cycle.id == cycle_2024_1.id + assert expected_grc_2024_2.id == grades_report_cycle_2024_2.id + assert expected_grc_2024_2.school_cycle.id == cycle_2024_2.id + + # check subjects + assert [expected_grs_c, expected_grs_b, expected_grs_a] = + expected_grades_report.grades_report_subjects + + assert expected_grs_a.id == grades_report_subject_a.id + assert expected_grs_a.subject.id == subject_a.id + assert expected_grs_b.id == grades_report_subject_b.id + assert expected_grs_b.subject.id == subject_b.id + assert expected_grs_c.id == grades_report_subject_c.id + assert expected_grs_c.subject.id == subject_c.id + end + + test "create_grades_report/1 with valid data creates a grades_report" do + school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + scale = Lanttern.GradingFixtures.scale_fixture() + + valid_attrs = %{ + name: "grade report name abc", + school_cycle_id: school_cycle.id, + scale_id: scale.id + } + + assert {:ok, %GradesReport{} = grades_report} = Reporting.create_grades_report(valid_attrs) + assert grades_report.name == "grade report name abc" + assert grades_report.school_cycle_id == school_cycle.id + assert grades_report.scale_id == scale.id + end + + test "create_grades_report/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Reporting.create_grades_report(@invalid_attrs) + end + + test "update_grades_report/2 with valid data updates the grades_report" do + grades_report = grades_report_fixture() + update_attrs = %{info: "some updated info", is_differentiation: "true"} + + assert {:ok, %GradesReport{} = grades_report} = + Reporting.update_grades_report(grades_report, update_attrs) + + assert grades_report.info == "some updated info" + assert grades_report.is_differentiation + end + + test "update_grades_report/2 with invalid data returns error changeset" do + grades_report = grades_report_fixture() + + assert {:error, %Ecto.Changeset{}} = + Reporting.update_grades_report(grades_report, @invalid_attrs) + + assert grades_report == Reporting.get_grades_report!(grades_report.id) + end + + test "delete_grades_report/1 deletes the grades_report" do + grades_report = grades_report_fixture() + assert {:ok, %GradesReport{}} = Reporting.delete_grades_report(grades_report) + assert_raise Ecto.NoResultsError, fn -> Reporting.get_grades_report!(grades_report.id) end + end + + test "change_report_card/1 returns a report_card changeset" do + report_card = report_card_fixture() + assert %Ecto.Changeset{} = Reporting.change_report_card(report_card) + end + end + + describe "grades report subjects" do + alias Lanttern.Reporting.GradesReportSubject + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + test "list_grades_report_subjects/1 returns all grades report subjects ordered by position and subjects preloaded" do + grades_report = grades_report_fixture() + subject_1 = TaxonomyFixtures.subject_fixture() + subject_2 = TaxonomyFixtures.subject_fixture() + + grades_report_subject_1 = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_1.id + }) + + grades_report_subject_2 = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_2.id + }) + + assert [expected_grs_1, expected_grs_2] = + Reporting.list_grades_report_subjects(grades_report.id) + + assert expected_grs_1.id == grades_report_subject_1.id + assert expected_grs_1.subject.id == subject_1.id + + assert expected_grs_2.id == grades_report_subject_2.id + assert expected_grs_2.subject.id == subject_2.id + end + + test "add_subject_to_grades_report/1 with valid data creates a report card grade subject" do + grades_report = grades_report_fixture() + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + subject_id: subject.id + } + + assert {:ok, %GradesReportSubject{} = grades_report_subject} = + Reporting.add_subject_to_grades_report(valid_attrs) + + assert grades_report_subject.grades_report_id == grades_report.id + assert grades_report_subject.subject_id == subject.id + assert grades_report_subject.position == 0 + + # insert one more grades report subject in a different grades report to test position auto increment scope + + # extra fixture in different grades report + grades_report_subject_fixture() + + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + subject_id: subject.id + } + + assert {:ok, %GradesReportSubject{} = grades_report_subject} = + Reporting.add_subject_to_grades_report(valid_attrs) + + assert grades_report_subject.grades_report_id == grades_report.id + assert grades_report_subject.subject_id == subject.id + assert grades_report_subject.position == 1 + end + + test "update_grades_report_subjects_positions/1 update grades report subjects positions based on list order" do + grades_report = grades_report_fixture() + + grades_report_subject_1 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_2 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_3 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_4 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + sorted_grades_report_subjects_ids = + [ + grades_report_subject_2.id, + grades_report_subject_3.id, + grades_report_subject_1.id, + grades_report_subject_4.id + ] + + assert :ok == + Reporting.update_grades_report_subjects_positions( + sorted_grades_report_subjects_ids + ) + + assert [ + expected_grs_2, + expected_grs_3, + expected_grs_1, + expected_grs_4 + ] = + Reporting.list_grades_report_subjects(grades_report.id) + + assert expected_grs_1.id == grades_report_subject_1.id + assert expected_grs_2.id == grades_report_subject_2.id + assert expected_grs_3.id == grades_report_subject_3.id + assert expected_grs_4.id == grades_report_subject_4.id + end + + test "delete_grades_report_subject/1 deletes the grades_report_subject" do + grades_report_subject = grades_report_subject_fixture() + + assert {:ok, %GradesReportSubject{}} = + Reporting.delete_grades_report_subject(grades_report_subject) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(GradesReportSubject, grades_report_subject.id) + end + end + end + + describe "grades report cycles" do + alias Lanttern.Reporting.GradesReportCycle + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + test "list_grades_report_cycles/1 returns all grades report cycles ordered by dates and preloaded cycles" do + grades_report = grades_report_fixture() + + cycle_2023 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2023-01-01], end_at: ~D[2023-12-31]}) + + cycle_2024_q4 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-09-01], end_at: ~D[2024-12-31]}) + + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + grades_report_cycle_2023 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2023.id + }) + + grades_report_cycle_2024_q4 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_q4.id + }) + + grades_report_cycle_2024 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024.id + }) + + assert [expected_grc_2023, expected_grc_2024_q4, expected_grc_2024] = + Reporting.list_grades_report_cycles(grades_report.id) + + assert expected_grc_2023.id == grades_report_cycle_2023.id + assert expected_grc_2023.school_cycle.id == cycle_2023.id + + assert expected_grc_2024_q4.id == grades_report_cycle_2024_q4.id + assert expected_grc_2024_q4.school_cycle.id == cycle_2024_q4.id + + assert expected_grc_2024.id == grades_report_cycle_2024.id + assert expected_grc_2024.school_cycle.id == cycle_2024.id + end + + test "add_cycle_to_grades_report/1 with valid data creates a grades report cycle" do + grades_report = grades_report_fixture() + school_cycle = SchoolsFixtures.cycle_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + school_cycle_id: school_cycle.id + } + + assert {:ok, %GradesReportCycle{} = grades_report_cycle} = + Reporting.add_cycle_to_grades_report(valid_attrs) + + assert grades_report_cycle.grades_report_id == grades_report.id + assert grades_report_cycle.school_cycle_id == school_cycle.id + end + + test "update_grades_report_cycle/2 with valid data updates the grades_report_cycle" do + grades_report_cycle = grades_report_cycle_fixture() + update_attrs = %{weight: 123.0} + + assert {:ok, %GradesReportCycle{} = grades_report_cycle} = + Reporting.update_grades_report_cycle(grades_report_cycle, update_attrs) + + assert grades_report_cycle.weight == 123.0 + end + + test "update_grades_report_cycle/2 with invalid data returns error changeset" do + grades_report_cycle = grades_report_cycle_fixture() + invalid_attrs = %{weight: "abc"} + + assert {:error, %Ecto.Changeset{}} = + Reporting.update_grades_report_cycle(grades_report_cycle, invalid_attrs) + + assert grades_report_cycle == Repo.get!(GradesReportCycle, grades_report_cycle.id) + end + + test "delete_grades_report_cycle/1 deletes the grades_report_cycle" do + grades_report_cycle = grades_report_cycle_fixture() + + assert {:ok, %GradesReportCycle{}} = + Reporting.delete_grades_report_cycle(grades_report_cycle) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(GradesReportCycle, grades_report_cycle.id) + end + end + end end diff --git a/test/lanttern_web/live/admin/report_card_live_test.exs b/test/lanttern_web/live/admin/report_card_live_test.exs index 73711c15..3ab1a4d4 100644 --- a/test/lanttern_web/live/admin/report_card_live_test.exs +++ b/test/lanttern_web/live/admin/report_card_live_test.exs @@ -26,6 +26,7 @@ defmodule LantternWeb.Admin.ReportCardLiveTest do test "saves new report_card", %{conn: conn} do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + year = Lanttern.TaxonomyFixtures.year_fixture() {:ok, index_live, _html} = live(conn, ~p"/admin/report_cards") @@ -37,7 +38,8 @@ defmodule LantternWeb.Admin.ReportCardLiveTest do create_attrs = %{ name: "some name", description: "some description", - school_cycle_id: school_cycle.id + school_cycle_id: school_cycle.id, + year_id: year.id } assert index_live diff --git a/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs new file mode 100644 index 00000000..a2b78840 --- /dev/null +++ b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs @@ -0,0 +1,45 @@ +defmodule LantternWeb.GradesReportsLiveTest do + use LantternWeb.ConnCase + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.GradingFixtures + + @live_view_path "/grading" + + setup [:register_and_log_in_user] + + describe "Grade reports live view basic navigation" do + test "disconnected and connected mount", %{conn: conn} do + conn = get(conn, @live_view_path) + + assert html_response(conn, 200) =~ ~r"

    \s*Grades reports\s*<\/h1>" + + {:ok, _view, _html} = live(conn) + end + + test "list grade reports", %{conn: conn} do + cycle = SchoolsFixtures.cycle_fixture(%{name: "Some cycle 000"}) + scale = GradingFixtures.scale_fixture(%{name: "Some scale AZ", type: "ordinal"}) + + _ordinal_value = + GradingFixtures.ordinal_value_fixture(%{name: "Ordinal value A", scale_id: scale.id}) + + _grades_report = + grades_report_fixture(%{ + name: "Some grade report ABC", + info: "Some info XYZ", + school_cycle_id: cycle.id, + scale_id: scale.id + }) + + {:ok, view, _html} = live(conn, @live_view_path) + + assert view |> has_element?("h3", "Some grade report ABC") + assert view |> has_element?("p", "Some info XYZ") + assert view |> has_element?("div", "Some cycle 000") + assert view |> has_element?("div", "Some scale AZ") + assert view |> has_element?("span", "Ordinal value A") + end + end +end diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index 43847121..a5bfcc7a 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -13,7 +13,8 @@ defmodule Lanttern.ReportingFixtures do |> Enum.into(%{ name: "some name", description: "some description", - school_cycle_id: maybe_gen_school_cycle_id(attrs) + school_cycle_id: maybe_gen_school_cycle_id(attrs), + year_id: maybe_gen_year_id(attrs) }) |> Lanttern.Reporting.create_report_card() @@ -26,6 +27,12 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_school_cycle_id(_attrs), do: Lanttern.SchoolsFixtures.cycle_fixture().id + defp maybe_gen_year_id(%{year_id: year_id} = _attrs), + do: year_id + + defp maybe_gen_year_id(_attrs), + do: Lanttern.TaxonomyFixtures.year_fixture().id + @doc """ Generate a strand_report. """ @@ -77,4 +84,69 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_student_id(_attrs), do: Lanttern.SchoolsFixtures.student_fixture().id + + @doc """ + Generate a grade report. + """ + def grades_report_fixture(attrs \\ %{}) do + {:ok, grades_report} = + attrs + |> Enum.into(%{ + name: "some name", + info: "some info", + school_cycle_id: maybe_gen_school_cycle_id(attrs), + scale_id: maybe_gen_scale_id(attrs) + }) + |> Lanttern.Reporting.create_grades_report() + + grades_report + end + + defp maybe_gen_scale_id(%{scale_id: scale_id} = _attrs), + do: scale_id + + defp maybe_gen_scale_id(_attrs), + do: Lanttern.GradingFixtures.scale_fixture().id + + @doc """ + Generate a grades_report_subject. + """ + def grades_report_subject_fixture(attrs \\ %{}) do + {:ok, grades_report_subject} = + attrs + |> Enum.into(%{ + grades_report_id: maybe_gen_grades_report_id(attrs), + subject_id: maybe_gen_subject_id(attrs) + }) + |> Lanttern.Reporting.add_subject_to_grades_report() + + grades_report_subject + end + + defp maybe_gen_grades_report_id(%{grades_report_id: grades_report_id} = _attrs), + do: grades_report_id + + defp maybe_gen_grades_report_id(_attrs), + do: grades_report_fixture().id + + defp maybe_gen_subject_id(%{subject_id: subject_id} = _attrs), + do: subject_id + + defp maybe_gen_subject_id(_attrs), + do: Lanttern.TaxonomyFixtures.subject_fixture().id + + @doc """ + Generate a grades_report_cycle. + """ + def grades_report_cycle_fixture(attrs \\ %{}) do + {:ok, grades_report_cycle} = + attrs + |> Enum.into(%{ + grades_report_id: maybe_gen_grades_report_id(attrs), + school_cycle_id: maybe_gen_school_cycle_id(attrs) + }) + |> Lanttern.Reporting.add_cycle_to_grades_report() + + grades_report_cycle + end end