Skip to content

Commit

Permalink
[FEATURE] [MER-3790] core schema for certificates (#5297)
Browse files Browse the repository at this point in the history
This PR adds the certificate and granted_certificate models, along with their migrations and Ecto schema.

There is additional work required for MER-3790, but this PR enables progress on other tickets dependent on the database models.
  • Loading branch information
Francisco-Castro authored Dec 13, 2024
1 parent 0ed8488 commit 88650d4
Show file tree
Hide file tree
Showing 8 changed files with 461 additions and 0 deletions.
80 changes: 80 additions & 0 deletions lib/oli/delivery/sections/certificate.ex
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions lib/oli/delivery/sections/granted_certificate.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions lib/oli/delivery/sections/section.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
25 changes: 25 additions & 0 deletions lib/oli/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 74 additions & 0 deletions priv/repo/migrations/20241203184246_create_certificates_tables.exs
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions test/oli/delivery/sections/certificate_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 88650d4

Please sign in to comment.