diff --git a/lib/oli/delivery/sections/certificate.ex b/lib/oli/delivery/sections/certificate.ex new file mode 100644 index 0000000000..ed370a7488 --- /dev/null +++ b/lib/oli/delivery/sections/certificate.ex @@ -0,0 +1,80 @@ +defmodule Oli.Delivery.Sections.Certificate do + use Ecto.Schema + import Ecto.Changeset + import Oli.Utils, only: [validate_greater_than_or_equal: 4] + + alias Oli.Delivery.Sections.Section + + @assessments_options [:all, :custom] + + schema "certificates" do + field :required_discussion_posts, :integer + field :required_class_notes, :integer + field :min_percentage_for_completion, :float + field :min_percentage_for_distinction, :float + field :assessments_apply_to, Ecto.Enum, values: @assessments_options, default: :all + field :custom_assessments, {:array, :integer}, default: [] + field :requires_instructor_approval, :boolean, default: false + + field :title, :string + field :description, :string + + field :admin_name1, :string + field :admin_title1, :string + field :admin_name2, :string + field :admin_title2, :string + field :admin_name3, :string + field :admin_title3, :string + + field :logo1, :string + field :logo2, :string + field :logo3, :string + + belongs_to :section, Section + + timestamps(type: :utc_datetime) + end + + @required_fields [ + :required_discussion_posts, + :required_class_notes, + :min_percentage_for_completion, + :min_percentage_for_distinction, + :assessments_apply_to, + :title, + :description, + :section_id + ] + + @optional_fields [ + :custom_assessments, + :requires_instructor_approval, + :admin_name1, + :admin_title1, + :admin_name2, + :admin_title2, + :admin_name3, + :admin_title3, + :logo1, + :logo2, + :logo3 + ] + + @all_fields @required_fields ++ @optional_fields + + def changeset(params \\ %{}) do + changeset(%__MODULE__{}, params) + end + + def changeset(certificate, params) do + certificate + |> cast(params, @all_fields) + |> validate_required(@required_fields) + |> validate_greater_than_or_equal( + :min_percentage_for_completion, + :min_percentage_for_distinction, + allow_equal: true + ) + |> assoc_constraint(:section) + end +end diff --git a/lib/oli/delivery/sections/granted_certificate.ex b/lib/oli/delivery/sections/granted_certificate.ex new file mode 100644 index 0000000000..8deeb1f315 --- /dev/null +++ b/lib/oli/delivery/sections/granted_certificate.ex @@ -0,0 +1,41 @@ +defmodule Oli.Delivery.Sections.GrantedCertificate do + use Ecto.Schema + import Ecto.Changeset + + alias Oli.Accounts.User + alias Oli.Delivery.Sections.Certificate + + @state_enum [:pending, :earned, :denied] + @issued_by_type_enum [:user, :author] + + schema "granted_certificates" do + field :state, Ecto.Enum, values: @state_enum + field :with_distinction, :boolean + field :guid, :string + field :issued_by, :integer + field :issued_by_type, Ecto.Enum, values: @issued_by_type_enum, default: :user + field :issued_at, :utc_datetime + + belongs_to :certificate, Certificate + belongs_to :user, User + + timestamps(type: :utc_datetime) + end + + @required_fields [:user_id, :certificate_id, :state, :with_distinction, :guid] + @optional_fields [:issued_by, :issued_by_type, :issued_at] + + @all_fields @required_fields ++ @optional_fields + + def changeset(params \\ %{}) do + changeset(%__MODULE__{}, params) + end + + def changeset(granted_certificates, params) do + granted_certificates + |> cast(params, @all_fields) + |> validate_required(@required_fields) + |> assoc_constraint(:certificate) + |> assoc_constraint(:user) + end +end diff --git a/lib/oli/delivery/sections/section.ex b/lib/oli/delivery/sections/section.ex index 11bd1a9020..049cf5b084 100644 --- a/lib/oli/delivery/sections/section.ex +++ b/lib/oli/delivery/sections/section.ex @@ -75,6 +75,9 @@ defmodule Oli.Delivery.Sections.Section do field(:contains_explorations, :boolean, default: false) field(:contains_deliberate_practice, :boolean, default: false) + field(:certificate_enabled, :boolean, default: false) + has_one(:certificate, Oli.Delivery.Sections.Certificate) + belongs_to(:required_survey, Oli.Resources.Resource, foreign_key: :required_survey_resource_id ) diff --git a/lib/oli/utils.ex b/lib/oli/utils.ex index f1f48ed75a..2921f6ca57 100644 --- a/lib/oli/utils.ex +++ b/lib/oli/utils.ex @@ -504,4 +504,29 @@ defmodule Oli.Utils do """ @spec identity(any) :: any def identity(x), do: x + + @doc """ + Validate the inequality between two numbers + + ## Examples + + validate_greater_than_or_equal(changeset, :from, :to) + validate_greater_than_or_equal(changeset, :from, :to, allow_equal: true) + + """ + def validate_greater_than_or_equal(changeset, from, to, opts \\ []) do + {_, from_value} = fetch_field(changeset, from) + {_, to_value} = fetch_field(changeset, to) + allow_equal = Keyword.get(opts, :allow_equal, false) + + if compare(from_value, to_value, allow_equal) do + changeset + else + message = "#{to} must be greater than #{from}" + add_error(changeset, from, message, to_field: to) + end + end + + defp compare(f, t, true), do: f <= t + defp compare(f, t, false), do: f < t end diff --git a/priv/repo/migrations/20241203184246_create_certificates_tables.exs b/priv/repo/migrations/20241203184246_create_certificates_tables.exs new file mode 100644 index 0000000000..ad2e6ca3dc --- /dev/null +++ b/priv/repo/migrations/20241203184246_create_certificates_tables.exs @@ -0,0 +1,74 @@ +defmodule Oli.Repo.Migrations.CreateCertificatesTables do + use Ecto.Migration + + def change do + execute_types() + + create table(:certificates) do + add :required_discussion_posts, :integer + add :required_class_notes, :integer + add :min_percentage_for_completion, :float + add :min_percentage_for_distinction, :float + add :assessments_apply_to, :certificates_assessments_apply_to, default: "all" + add :custom_assessments, {:array, :integer}, default: [] + add :requires_instructor_approval, :boolean, default: false + + add :title, :string + add :description, :string + + add :admin_name1, :string + add :admin_title1, :string + add :admin_name2, :string + add :admin_title2, :string + add :admin_name3, :string + add :admin_title3, :string + + add :logo1, :string + add :logo2, :string + add :logo3, :string + + add :section_id, references(:sections, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create table(:granted_certificates) do + add :state, :granted_certificates_state + add :with_distinction, :boolean + add :guid, :string + add :issued_by, :integer, allow_nil: true + add :issued_by_type, :granted_certificates_issued_by_type + add :issued_at, :utc_datetime, allow_nil: true + + add :certificate_id, references(:certificates) + add :user_id, references(:users) + + timestamps(type: :utc_datetime) + end + + create unique_index(:granted_certificates, [:user_id, :certificate_id], + name: :unique_user_certificate + ) + + alter table(:sections) do + add :certificate_enabled, :boolean, default: false + end + end + + defp execute_types() do + execute( + "CREATE TYPE public.certificates_assessments_apply_to AS ENUM ('all', 'custom');", + "DROP TYPE public.certificates_assessments_apply_to;" + ) + + execute( + "CREATE TYPE public.granted_certificates_issued_by_type AS ENUM ('user', 'autor');", + "DROP TYPE public.granted_certificates_issued_by_type;" + ) + + execute( + "CREATE TYPE public.granted_certificates_state AS ENUM ('pending', 'earned', 'denied');", + "DROP TYPE public.granted_certificates_state;" + ) + end +end diff --git a/test/oli/delivery/sections/certificate_test.exs b/test/oli/delivery/sections/certificate_test.exs new file mode 100644 index 0000000000..1add59432b --- /dev/null +++ b/test/oli/delivery/sections/certificate_test.exs @@ -0,0 +1,126 @@ +defmodule Oli.Delivery.Sections.CertificateTest do + use Oli.DataCase, async: true + + alias Oli.Delivery.Sections.Certificate + + describe "changeset/2" do + setup [:valid_params] + + test "success: with valid params", %{params: params} do + changeset = %Ecto.Changeset{} = Certificate.changeset(params) + + assert changeset.valid? + end + + test "check: assessments_apply_to default value", %{params: params} do + params = Map.delete(params, :assessments_apply_to) + refute Map.has_key?(params, :assessments_apply_to) + + certificate = Certificate.changeset(params) |> apply_changes() + + assert certificate.assessments_apply_to == :all + end + + test "check: custom_assessments default value", %{params: params} do + params = Map.delete(params, :custom_assessments) + refute Map.has_key?(params, :custom_assessments) + + certificate = Certificate.changeset(params) |> apply_changes() + + assert certificate.custom_assessments == [] + end + + test "check: requires_instructor_approval default value", %{params: params} do + params = Map.delete(params, :requires_instructor_approval) + refute Map.has_key?(params, :requires_instructor_approval) + + certificate = Certificate.changeset(params) |> apply_changes() + + assert certificate.requires_instructor_approval == false + end + + test "error: fails when doesn't have the required fields" do + params = %{} + + changeset = %Ecto.Changeset{errors: errors} = Certificate.changeset(params) + + refute changeset.valid? + assert length(errors) == 7 + + assert %{title: ["can't be blank"]} = errors_on(changeset) + assert %{description: ["can't be blank"]} = errors_on(changeset) + assert %{section_id: ["can't be blank"]} = errors_on(changeset) + assert %{required_discussion_posts: ["can't be blank"]} = errors_on(changeset) + assert %{required_class_notes: ["can't be blank"]} = errors_on(changeset) + assert %{min_percentage_for_completion: ["can't be blank"]} = errors_on(changeset) + assert %{min_percentage_for_distinction: ["can't be blank"]} = errors_on(changeset) + end + + test "success: when distinction > completion", %{params: params} do + params = %{params | min_percentage_for_distinction: 0.7, min_percentage_for_completion: 0.6} + + changeset = %Ecto.Changeset{} = Certificate.changeset(params) + + assert changeset.valid? + end + + test "success: when distinction = completion", %{params: params} do + params = %{params | min_percentage_for_distinction: 0.7, min_percentage_for_completion: 0.7} + + changeset = %Ecto.Changeset{} = Certificate.changeset(params) + + assert changeset.valid? + end + + test "error: when distinction < completion", %{params: params} do + params = %{params | min_percentage_for_distinction: 0.6, min_percentage_for_completion: 0.7} + + changeset = %Ecto.Changeset{} = Certificate.changeset(params) + + refute changeset.valid? + + assert %{ + min_percentage_for_completion: [ + "min_percentage_for_distinction must be greater than min_percentage_for_completion" + ] + } = errors_on(changeset) + end + + test "error: incorrect assessments_apply_to type definition", %{params: params} do + params = %{params | assessments_apply_to: "incorrect_type"} + + changeset = %Ecto.Changeset{} = Certificate.changeset(params) + + refute changeset.valid? + + assert %{assessments_apply_to: ["is invalid"]} = errors_on(changeset) + end + end + + describe "insert" do + setup [:valid_params] + + test "error: missing section association", %{params: params} do + {:error, changeset = %Ecto.Changeset{}} = Certificate.changeset(params) |> Oli.Repo.insert() + + refute changeset.valid? + + assert %{section: ["does not exist"]} = errors_on(changeset) + end + end + + defp valid_params(_) do + params = %{ + required_discussion_posts: 11, + min_percentage_for_completion: 0.6, + min_percentage_for_distinction: 0.7, + required_class_notes: 12, + title: "My Certificate", + description: "Some description", + section_id: 123, + assessments_apply_to: :all + } + + %{params: params} + end +end diff --git a/test/oli/delivery/sections/granted_certificate_test.exs b/test/oli/delivery/sections/granted_certificate_test.exs new file mode 100644 index 0000000000..8af0db634b --- /dev/null +++ b/test/oli/delivery/sections/granted_certificate_test.exs @@ -0,0 +1,104 @@ +defmodule Oli.Delivery.Sections.GrantedCertificateTest do + use Oli.DataCase, async: true + + import Oli.Factory + + alias Oli.Delivery.Sections.GrantedCertificate + + describe "changeset" do + setup [:valid_params] + + test "success: with valid params", %{params: params} do + changeset = GrantedCertificate.changeset(params) + + assert changeset.valid? + end + + test "check: issued_by_type default value", %{params: params} do + params = Map.delete(params, :issued_by_type) + refute Map.has_key?(params, :issued_by_type) + + granted_certificate = GrantedCertificate.changeset(params) |> apply_changes() + + assert granted_certificate.issued_by_type == :user + end + + test "error: fails with invalid params" do + params = %{} + + changeset = %Ecto.Changeset{errors: errors} = GrantedCertificate.changeset(params) + + refute changeset.valid? + assert length(errors) == 5 + + assert %{user_id: ["can't be blank"]} = errors_on(changeset) + assert %{certificate_id: ["can't be blank"]} = errors_on(changeset) + assert %{state: ["can't be blank"]} = errors_on(changeset) + assert %{with_distinction: ["can't be blank"]} = errors_on(changeset) + assert %{guid: ["can't be blank"]} = errors_on(changeset) + end + + test "error: incorrect state type definition", %{params: params} do + params = %{params | state: "incorrect_state"} + + changeset = %Ecto.Changeset{} = GrantedCertificate.changeset(params) + + refute changeset.valid? + + assert %{state: ["is invalid"]} = errors_on(changeset) + end + + test "error: incorrect issued_by_type type definition", %{params: params} do + params = %{params | issued_by_type: "incorrect_issue_by_type"} + + changeset = %Ecto.Changeset{} = GrantedCertificate.changeset(params) + + refute changeset.valid? + + assert %{issued_by_type: ["is invalid"]} = errors_on(changeset) + end + end + + describe "insert" do + setup [:valid_params] + + test "error: missing certificate association", %{params: params} do + user = insert(:user) + params = %{params | user_id: user.id} + + {:error, changeset = %Ecto.Changeset{}} = + GrantedCertificate.changeset(params) |> Oli.Repo.insert() + + refute changeset.valid? + + assert %{certificate: ["does not exist"]} = errors_on(changeset) + end + + test "error: missing user association", %{params: params} do + certificate = insert(:certificate) + params = %{params | certificate_id: certificate.id} + + {:error, changeset = %Ecto.Changeset{}} = + GrantedCertificate.changeset(params) |> Oli.Repo.insert() + + refute changeset.valid? + + assert %{user: ["does not exist"]} = errors_on(changeset) + end + end + + defp valid_params(_) do + params = %{ + state: :pending, + with_distinction: false, + guid: "some_guid", + issued_by: 11, + issued_by_type: :user, + issued_at: DateTime.utc_now(), + certificate_id: 123, + user_id: 22 + } + + %{params: params} + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index ba36ab68c9..40a5dd4d48 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -16,6 +16,7 @@ defmodule Oli.Factory do alias Oli.Branding.Brand alias Oli.Delivery.Page.PageContext + alias Oli.Delivery.Sections.Certificate alias Oli.Delivery.Sections.ContainedObjective alias Oli.Delivery.Attempts.Core.{ @@ -647,6 +648,13 @@ defmodule Oli.Factory do } end + def certificate_factory() do + %Certificate{ + title: "#{sequence("certificate")}", + section: anonymous_build(:section) + } + end + # HELPERS defp anonymous_build(entity_name, attrs \\ %{}),