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"""
+
+ <%= gettext("all %{type}", type: @type) %>
+
+ """
+ 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"""
+
+ <%= @items %>
+
+ """
+ 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"""
-
- <%= gettext("all %{type}", type: @type) %>
-
- """
- 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"""
-
- <%= @items %>
-
- """
- 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