Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d978828

Browse files
authored
Merge pull request #56
(#52) section metadata refactoring
2 parents 3d5e37d + 936217b commit d978828

24 files changed

Lines changed: 1058 additions & 0 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# frozen_string_literal: true
2+
3+
module Admin
4+
class SectionsController < AdminController
5+
include GoogleCredentials
6+
include Reimportable
7+
include Queryable
8+
9+
before_action :find_selected, only: %i(destroy_selected reimport_selected)
10+
before_action :set_query_params
11+
12+
QUERY_ATTRS = %i(
13+
grade
14+
search_term
15+
section_number
16+
sort_by
17+
subject
18+
unit_id
19+
).freeze
20+
QUERY_ATTRS_NESTED = {
21+
grades: []
22+
}.freeze
23+
QUERY_ATTRS_KEYS = QUERY_ATTRS + QUERY_ATTRS_NESTED.keys
24+
25+
def index
26+
@query = query_struct(@query_params)
27+
@sections = Admin::SectionsQuery.call(@query, page: params[:page])
28+
render_customized_view
29+
end
30+
31+
def create
32+
@section_form = SectionForm.new(form_params.except(:async).to_h)
33+
34+
return create_multiple if form_params[:link].match?(RE_GOOGLE_FOLDER)
35+
36+
form_params[:async].to_i.zero? ? create_sync : create_async
37+
end
38+
39+
def destroy
40+
section = Resource.sections.find(params[:id].to_i)
41+
section.destroy
42+
redirect_to admin_sections_path(query: @query_params), notice: t(".success")
43+
end
44+
45+
def destroy_selected
46+
count = @sections.destroy_all.count
47+
redirect_to admin_sections_path(query: @query_params), notice: t(".success", count:)
48+
end
49+
50+
def import_status
51+
data = import_status_for(SectionParseJob)
52+
render json: data, status: :ok
53+
end
54+
55+
def new
56+
@section_form = SectionForm.new
57+
end
58+
59+
def reimport_selected
60+
urls = []
61+
skipped = 0
62+
@sections.each do |section|
63+
if (url = section.links.dig("source", "gdoc", "url")).present?
64+
urls << url
65+
else
66+
skipped += 1
67+
end
68+
end
69+
flash.now[:alert] = t(".skipped", count: skipped) if skipped.positive?
70+
bulk_import urls
71+
render :import
72+
end
73+
74+
private
75+
76+
def bulk_import(file_urls)
77+
jobs =
78+
file_urls.each_with_object({}) do |url, jobs_|
79+
job_id = SectionParseJob.perform_later(url).job_id
80+
jobs_[job_id] = { link: url, status: "waiting" }
81+
end
82+
polling_path = import_status_admin_sections_path
83+
@props = { jobs:, links: view_links, polling_path:, type: :sections }
84+
.transform_keys! { _1.to_s.camelize(:lower).to_sym }
85+
end
86+
87+
def collect_errors
88+
@collect_errors ||=
89+
if @section_form.service_errors.empty?
90+
[]
91+
else
92+
@section_form.service_errors.map { "<li>#{_1}</li>" }.join
93+
end
94+
end
95+
96+
def create_async
97+
bulk_import Array.wrap(form_params[:link])
98+
render :import
99+
end
100+
101+
def create_sync
102+
if @section_form.save
103+
flash_message =
104+
if collect_errors.empty?
105+
{ notice: t("admin.sections.create.success", name: @section_form.section.title) }
106+
else
107+
{ alert: t("admin.sections.create.error", name: @section_form.section.title, errors: collect_errors) }
108+
end
109+
redirect_to admin_sections_path(anchor: "section_#{@section_form.section.id}", query: @query_params), **flash_message
110+
else
111+
render :new
112+
end
113+
end
114+
115+
def find_selected
116+
return head(:bad_request) unless params[:selected_ids].present?
117+
118+
ids = params[:selected_ids].split(",")
119+
@sections = Resource.sections.where(id: ids)
120+
end
121+
122+
def form_params
123+
@form_params ||= params.require(:section_form).permit(:async, :link)
124+
end
125+
126+
def gdoc_files_from(url)
127+
folder_id = ::Lt::Google::Api::Drive.folder_id_for(url)
128+
129+
::Lt::Google::Api::Drive.new(google_credentials)
130+
.list_file_ids_in(folder_id)
131+
.map { |id| ::Lt::Lcms::Lesson::Downloader::Gdoc.gdoc_file_url(id) }
132+
end
133+
end
134+
end

app/forms/section_form.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
class SectionForm < ImportForm
4+
attr_reader :section
5+
6+
def save
7+
super do
8+
service = SectionBuildService.new(google_credentials, import_retry: options[:import_retry])
9+
@section = service.build_for(link)
10+
@service_errors.push(*service.errors.uniq)
11+
end
12+
end
13+
end

app/jobs/section_parse_job.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
require "lt/google/api/auth/cli"
4+
5+
class SectionParseJob < ApplicationJob
6+
include ResqueJob
7+
include RetryDelayed
8+
9+
queue_as :default
10+
11+
def perform(id_or_url, options = {})
12+
url =
13+
if id_or_url.is_a?(String)
14+
id_or_url
15+
else
16+
Resource.sections.find(id_or_url).links.dig("source", "gdoc", "url")
17+
end
18+
19+
form = SectionForm.new({ link: url }, import_retry: true)
20+
res = if form.save
21+
{ ok: true, link: url, model: form.section }
22+
else
23+
{ ok: false, link: url, errors: form.errors[:link] }
24+
end
25+
store_result(res, options)
26+
rescue StandardError => e
27+
res = { ok: false, link: id_or_url, errors: [e.message] }
28+
store_result(res, options)
29+
end
30+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
class SectionPresenter < BasePresenter
4+
def section_number
5+
metadata["section_number"].presence || metadata["section"]
6+
end
7+
8+
def section_title
9+
metadata["section_title"].presence || title
10+
end
11+
12+
def section_title_spanish
13+
metadata["section_title_spanish"]
14+
end
15+
16+
def source_url
17+
links.dig("source", "gdoc", "url")
18+
end
19+
20+
def unit_id
21+
metadata["unit_id"].presence || metadata["unit"]
22+
end
23+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module Admin
4+
class SectionsQuery < BaseQuery
5+
def call
6+
@scope = Resource.sections.all
7+
@scope = apply_filters
8+
9+
if @pagination.present?
10+
sorted_scope.paginate(page: @pagination[:page])
11+
else
12+
sorted_scope
13+
end
14+
end
15+
16+
private
17+
18+
def apply_filters
19+
@scope = @scope.filter_by_subject(q.subject) if q.subject.present?
20+
@scope = @scope.filter_by_grade(q.grade) if q.respond_to?(:grade) && q.grade.present?
21+
grades = Array.wrap(q.grades&.filter_map(&:presence))
22+
@scope = @scope.where_grade(grades) if q.respond_to?(:grades) && grades.any?
23+
@scope = @scope.where("resources.metadata ->> 'unit_id' = ?", q.unit_id.to_s) if q.respond_to?(:unit_id) && q.unit_id.present?
24+
@scope = @scope.where("resources.metadata ->> 'section_number' = ?", q.section_number.to_s) if q.respond_to?(:section_number) && q.section_number.present?
25+
if q.respond_to?(:search_term) && q.search_term.present?
26+
term = "%#{q.search_term}%"
27+
@scope = @scope.where("resources.title ILIKE ? OR resources.description ILIKE ?", term, term)
28+
end
29+
@scope
30+
end
31+
32+
def sorted_scope
33+
@scope = @scope.ordered if q.sort_by.blank? || q.sort_by == "curriculum"
34+
@scope = @scope.order(updated_at: :desc) if q.sort_by == "last_update"
35+
@scope.distinct
36+
end
37+
end
38+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
require "lt/lcms/lesson/downloader/gdoc"
4+
5+
class SectionBuildService
6+
EVENT_BUILT = "section:built"
7+
8+
attr_reader :errors
9+
10+
def initialize(credentials, opts = {})
11+
@credentials = credentials
12+
@errors = []
13+
@options = opts
14+
end
15+
16+
def build_for(url)
17+
@content = download(url)
18+
@metadata = parse_metadata(content)
19+
@resource = SectionResourceUpsertService.call(metadata: metadata.as_json, source_link_data:)
20+
ActiveSupport::Notifications.instrument(EVENT_BUILT, id: resource.id)
21+
resource
22+
end
23+
24+
private
25+
26+
attr_reader :content, :credentials, :downloader, :metadata, :options, :resource
27+
28+
def download(url)
29+
@downloader = ::Lt::Lcms::Lesson::Downloader::Gdoc.new(credentials, url, options).download
30+
@downloader.content
31+
end
32+
33+
def parse_metadata(content)
34+
fragment = sanitized_fragment(content)
35+
table = DocTemplate::Tables::SectionMetadata.parse(fragment)
36+
@errors = table.errors.dup
37+
raise "No section metadata present" if !table.table_exist? || table.data.empty?
38+
raise "Invalid section metadata: #{@errors.join(', ')}" if @errors.any?
39+
40+
DocTemplate::Objects::SectionMetadata.build_from(table.data)
41+
end
42+
43+
def sanitized_fragment(content)
44+
doc = Nokogiri::HTML(content)
45+
body = DocTemplate.config["sanitizer"].constantize.sanitize(doc.xpath("//html/body/*").to_s)
46+
Nokogiri::HTML.fragment(body)
47+
end
48+
49+
def source_link_data
50+
{
51+
"source" => {
52+
"gdoc" => {
53+
"file_id" => downloader.file_id,
54+
"name" => downloader.file.name,
55+
"timestamp" => Time.current.to_i,
56+
"url" => ::Lt::Lcms::Lesson::Downloader::Gdoc.gdoc_file_url(downloader.file_id)
57+
}
58+
}
59+
}
60+
end
61+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
class SectionResourceUpsertService
4+
def self.call(...)
5+
new(...).call
6+
end
7+
8+
def initialize(metadata:, source_link_data: {})
9+
@metadata = metadata.with_indifferent_access
10+
@source_link_data = source_link_data.deep_stringify_keys
11+
end
12+
13+
def call
14+
ApplicationRecord.transaction do
15+
resource = context.new(context_metadata).find_or_create_resource
16+
resource.update!(
17+
description: description_summary,
18+
links: resource.links.deep_merge(source_link_data),
19+
metadata: resource.metadata.deep_merge(resource_metadata),
20+
short_title: short_title,
21+
title: title
22+
)
23+
Lt::Lcms::Metadata::Context.update_sections_level_position_for(resource.self_and_siblings) if resource.section?
24+
resource
25+
end
26+
end
27+
28+
private
29+
30+
attr_reader :metadata, :source_link_data
31+
32+
def context
33+
DocTemplate.config.dig("metadata", "context").constantize
34+
end
35+
36+
def context_metadata
37+
{
38+
description: description_summary,
39+
grade: metadata[:grade].to_s,
40+
section: metadata[:section_number].to_s,
41+
subject: metadata[:subject],
42+
title:,
43+
unit: metadata[:unit_id].to_s
44+
}
45+
end
46+
47+
def description_summary
48+
@description_summary ||= DocTemplate.sanitizer.strip_html_element(metadata[:description]).to_s
49+
end
50+
51+
def resource_metadata
52+
metadata
53+
.to_h
54+
.stringify_keys
55+
.merge(
56+
"section" => metadata[:section_number].to_s,
57+
"unit" => metadata[:unit_id].to_s.downcase
58+
)
59+
.reject { |_key, value| value.blank? }
60+
end
61+
62+
def short_title
63+
metadata[:section_number].to_s
64+
end
65+
66+
def title
67+
metadata[:section_title].presence || short_title
68+
end
69+
end

0 commit comments

Comments
 (0)