Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MER-2573] Task to create contained objectives for existing sections #4238

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ config :oli, Oban,
grades: 30,
auto_submit: 3,
analytics_export: 3,
datashop_export: 3
datashop_export: 3,
objectives: 3
]

config :ex_money,
Expand Down
52 changes: 52 additions & 0 deletions lib/mix/tasks/create_contained_objectives.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule Mix.Tasks.CreateContainedObjectives do
@shortdoc "Create contained objectives for all sections that were not migrated"

use Mix.Task

alias Oli.Delivery.Sections
alias Oli.Repo
alias Oli.Delivery.Sections.{ContainedObjectivesBuilder, Section}
alias Ecto.Multi

require Logger

import Ecto.Query, only: [from: 2]

@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")

run_now()
end

def run_now() do
Logger.info("Start enqueueing contained objectives creation")

Multi.new()
|> Multi.run(:sections, &get_not_started_sections(&1, &2))
|> Oban.insert_all(:jobs, &build_contained_objectives_jobs(&1))
|> Ecto.Multi.update_all(
:update_all_sections,
fn _ -> from(Section, where: [v25_migration: :not_started]) end,
set: [v25_migration: :pending]
)
|> Repo.transaction()
|> case do
{:ok, %{jobs: jobs}} ->
Logger.info("#{Enum.count(jobs)} jobs enqueued for contained objectives creation")

:ok

{:error, _, changeset, _} ->
Logger.error("Error enqueuing jobs: #{inspect(changeset)}")

:error
end
end

defp get_not_started_sections(_repo, _changes),
do: {:ok, Sections.get_sections_by([v25_migration: :not_started], [:slug])}

defp build_contained_objectives_jobs(%{sections: sections}),
do: Enum.map(sections, &ContainedObjectivesBuilder.new(%{section_slug: &1.slug}))
end
17 changes: 17 additions & 0 deletions lib/oli/delivery/sections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3364,4 +3364,21 @@ defmodule Oli.Delivery.Sections do
)
)
end

@doc """
Get all sections filtered by the clauses passed as the first argument.
The second argument is a list of fields to be selected from the Section table.
If the second argument is not passed, all fields will be selected.
"""
def get_sections_by(clauses, select_fields \\ nil) do
Section
|> from(where: ^clauses)
|> maybe_select_section_fields(select_fields)
|> Repo.all()
end

defp maybe_select_section_fields(query, nil), do: query

defp maybe_select_section_fields(query, select_fields),
do: select(query, [s], struct(s, ^select_fields))
end
39 changes: 39 additions & 0 deletions lib/oli/delivery/sections/contained_objective.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule Oli.Delivery.Sections.ContainedObjective do
@moduledoc """
The ContainedObjective schema represents an association between section containers and objectives linked to the pages within them.
It is created for optimization purposes since it provides a fast way of retrieving the objectives associated to the containers of a section.

Given a section, each objective will have as many entries in the table as containers it is linked to.
There will be always at least one entry per objective with the container_id being nil, which represents the inclusion of the objective in the root container.
"""

use Ecto.Schema
import Ecto.Changeset

alias Oli.Delivery.Sections.Section
alias Oli.Resources.Resource

schema "contained_objectives" do
belongs_to(:section, Section)
belongs_to(:container, Resource)
belongs_to(:objective, Resource)

timestamps(type: :utc_datetime)
end

@doc false
def changeset(contained_page, attrs) do
contained_page
|> cast(attrs, [
:section_id,
:container_id,
:objective_id
])
|> validate_required([
:section_id,
:objective_id
])
|> foreign_key_constraint(:section_id)
|> foreign_key_constraint(:objective_id)
end
end
125 changes: 125 additions & 0 deletions lib/oli/delivery/sections/contained_objectives_builder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
defmodule Oli.Delivery.Sections.ContainedObjectivesBuilder do
use Oban.Worker,
queue: :objectives,
unique: [keys: [:section_slug]],
max_attempts: 1

import Ecto.Query, only: [from: 2]

alias Oli.Publishing.DeliveryResolver
alias Oli.Delivery.Sections.{ContainedObjective, ContainedPage, Section}
alias Oli.Resources.{Revision, ResourceType}
alias Oli.Repo
alias Ecto.Multi

@impl Oban.Worker
def perform(%Oban.Job{args: %{"section_slug" => section_slug}}) do
timestamps = %{
inserted_at: {:placeholder, :now},
updated_at: {:placeholder, :now}
}

placeholders = %{
now: DateTime.utc_now() |> DateTime.truncate(:second)
}

Multi.new()
|> Multi.run(:contained_objectives, &build_contained_objectives(&1, &2, section_slug))
|> Multi.insert_all(
:inserted_contained_objectives,
ContainedObjective,
&objectives_with_timestamps(&1, timestamps),
placeholders: placeholders
)
|> Multi.run(:section, &find_section_by_slug(&1, &2, section_slug))
|> Multi.update(
:done_section,
&Section.changeset(&1.section, %{v25_migration: :done})
)
|> Repo.transaction()
|> case do
{:ok, res} ->
{:ok, res}

{:error, _, changeset, _} ->
{:error, changeset}
end
end

defp build_contained_objectives(repo, _changes, section_slug) do
page_type_id = ResourceType.get_id_by_type("page")
activity_type_id = ResourceType.get_id_by_type("activity")

section_resource_pages =
from(
[sr: sr, rev: rev, s: s] in DeliveryResolver.section_resource_revisions(section_slug),
where: not rev.deleted and rev.resource_type_id == ^page_type_id
)

section_resource_activities =
from(
[sr: sr, rev: rev, s: s] in DeliveryResolver.section_resource_revisions(section_slug),
where: not rev.deleted and rev.resource_type_id == ^activity_type_id,
select: rev
)

activity_references =
from(
rev in Revision,
join: content_elem in fragment("jsonb_array_elements(?->'model')", rev.content),
select: %{
revision_id: rev.id,
activity_id: fragment("(?->>'activity_id')::integer", content_elem)
},
where: fragment("?->>'type'", content_elem) == "activity-reference"
)

activity_objectives =
from(
rev in Revision,
join: obj in fragment("jsonb_each_text(?)", rev.objectives),
select: %{
objective_revision_id: rev.id,
objective_resource_id:
fragment("jsonb_array_elements_text(?::jsonb)::integer", obj.value)
},
where: rev.deleted == false and rev.resource_type_id == ^activity_type_id
)

contained_objectives =
from(
[sr: sr, rev: rev, s: s] in section_resource_pages,
join: cp in ContainedPage,
on: cp.page_id == rev.resource_id and cp.section_id == s.id,
join: ar in subquery(activity_references),
on: ar.revision_id == rev.id,
join: act in subquery(section_resource_activities),
on: act.resource_id == ar.activity_id,
join: ao in subquery(activity_objectives),
on: ao.objective_revision_id == act.id,
group_by: [cp.section_id, cp.container_id, ao.objective_resource_id],
select: %{
section_id: cp.section_id,
container_id: cp.container_id,
objective_id: ao.objective_resource_id
}
)
|> repo.all()

{:ok, contained_objectives}
end

defp objectives_with_timestamps(%{contained_objectives: contained_objectives}, timestamps) do
Enum.map(contained_objectives, &Map.merge(&1, timestamps))
end

defp find_section_by_slug(repo, _changes, section_slug) do
case repo.get_by(Section, slug: section_slug) do
nil ->
{:error, :section_not_found}

section ->
{:ok, section}
end
end
end
8 changes: 7 additions & 1 deletion lib/oli/delivery/sections/section.ex
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ defmodule Oli.Delivery.Sections.Section do

field(:preferred_scheduling_time, :time, default: ~T[23:59:59])

field(:v25_migration, Ecto.Enum,
values: [:not_started, :done, :pending],
default: :done
)

timestamps(type: :utc_datetime)
end

Expand Down Expand Up @@ -179,7 +184,8 @@ defmodule Oli.Delivery.Sections.Section do
:class_modality,
:class_days,
:course_section_number,
:preferred_scheduling_time
:preferred_scheduling_time,
:v25_migration
])
|> cast_embed(:customizations, required: false)
|> validate_required([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Oli.Repo.Migrations.CreateContainedObjectives do
use Ecto.Migration

def change do
create table(:contained_objectives) do
add(:section_id, references(:sections, on_delete: :delete_all), null: false)
add(:container_id, references(:resources))
add(:objective_id, references(:resources), null: false)

timestamps(type: :timestamptz)
end

create(unique_index(:contained_objectives, [:section_id, :container_id, :objective_id]))
create(index(:contained_objectives, [:container_id]))
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Oli.Repo.Migrations.AddV25MigrationToSections do
use Ecto.Migration

def change do
alter table(:sections) do
add(:v25_migration, :string, default: "not_started", null: false)
end

create(index(:sections, :v25_migration))
end
end
61 changes: 61 additions & 0 deletions test/mix/tasks/create_contained_objectives_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
defmodule Mix.Tasks.CreateContainedObjectivesTest do
use Oban.Testing, repo: Oli.Repo
use Oli.DataCase

alias Mix.Tasks.CreateContainedObjectives
alias Oli.Delivery.Sections.ContainedObjectivesBuilder
alias Oli.Delivery.Sections
alias Oli.Factory

describe "run/1" do
test "enqueues not started sections and set them as pending" do
[section_1, section_2] = Factory.insert_list(2, :section, v25_migration: :not_started)

assert :ok == CreateContainedObjectives.run([])

assert_enqueued(
worker: ContainedObjectivesBuilder,
args: %{
"section_slug" => section_1.slug
}
)

assert_enqueued(
worker: ContainedObjectivesBuilder,
args: %{
"section_slug" => section_2.slug
}
)

assert Sections.get_section_by(slug: section_1.slug).v25_migration == :pending
assert Sections.get_section_by(slug: section_2.slug).v25_migration == :pending
end

test "does not enqueue pending or done sections" do
section_1 = Factory.insert(:section, v25_migration: :pending)
section_2 = Factory.insert(:section, v25_migration: :done)

assert :ok == CreateContainedObjectives.run([])

refute_enqueued(
worker: ContainedObjectivesBuilder,
args: %{
"section_slug" => section_1.slug
}
)

refute_enqueued(
worker: ContainedObjectivesBuilder,
args: %{
"section_slug" => section_2.slug
}
)

assert Sections.get_section_by(slug: section_1.slug).v25_migration ==
section_1.v25_migration

assert Sections.get_section_by(slug: section_2.slug).v25_migration ==
section_2.v25_migration
end
end
end
25 changes: 25 additions & 0 deletions test/oli/delivery/sections/contained_objective_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Oli.Delivery.Sections.ContainedObjectiveTest do
use Oli.DataCase

import Oli.Factory

alias Oli.Delivery.Sections.ContainedObjective

describe "changeset/2" do
test "changeset should be invalid if section_id is nil" do
changeset =
build(:contained_objective)
|> ContainedObjective.changeset(%{section_id: nil})

refute changeset.valid?
end

test "changeset should be invalid if container_id is nil" do
changeset =
build(:contained_objective)
|> ContainedObjective.changeset(%{container_id: nil})

refute changeset.valid?
end
end
end
Loading