2020-12-03 03:27:59 +08:00
|
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
|
|
#
|
|
|
|
|
# Copyright (C) 2021 - present Instructure, Inc.
|
|
|
|
|
#
|
|
|
|
|
# This file is part of Canvas.
|
|
|
|
|
#
|
|
|
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
|
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
|
|
|
# Software Foundation, version 3 of the License.
|
|
|
|
|
#
|
|
|
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
|
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
|
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
|
|
|
# details.
|
|
|
|
|
#
|
|
|
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
|
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
module Outcomes
|
2021-01-23 09:03:53 +08:00
|
|
|
|
class LearningOutcomeGroupChildren
|
2022-05-24 23:48:16 +08:00
|
|
|
|
include OutcomesFeaturesHelper
|
2020-12-03 03:27:59 +08:00
|
|
|
|
attr_reader :context
|
|
|
|
|
|
2021-03-04 00:00:06 +08:00
|
|
|
|
SHORT_DESCRIPTION = "coalesce(learning_outcomes.short_description, '')"
|
|
|
|
|
|
|
|
|
|
# E'<[^>]+>' -> removes html tags
|
|
|
|
|
# E'&\\w+;' -> removes html entities
|
|
|
|
|
DESCRIPTION = "regexp_replace(regexp_replace(coalesce(learning_outcomes.description, ''), E'<[^>]+>', '', 'gi'), E'&\\w+;', ' ', 'gi')"
|
2021-08-18 22:15:52 +08:00
|
|
|
|
MAP_CANVAS_POSTGRES_LOCALES = {
|
|
|
|
|
"ar" => "arabic", # العربية
|
|
|
|
|
"ca" => "spanish", # Català
|
|
|
|
|
"da" => "danish", # Dansk
|
|
|
|
|
"da-x-k12" => "danish", # Dansk GR/GY
|
|
|
|
|
"de" => "german", # Deutsch
|
|
|
|
|
"en-AU" => "english", # English (Australia)
|
|
|
|
|
"en-CA" => "english", # English (Canada)
|
|
|
|
|
"en-GB" => "english", # English (United Kingdom)
|
|
|
|
|
"en" => "english", # English (US)
|
|
|
|
|
"es" => "spanish", # Español
|
|
|
|
|
"fr" => "french", # Français
|
|
|
|
|
"fr-CA" => "french", # Français (Canada)
|
|
|
|
|
"it" => "italian", # Italiano
|
|
|
|
|
"hu" => "hungarian", # Magyar
|
|
|
|
|
"nl" => "dutch", # Nederlands
|
|
|
|
|
"nb" => "norwegian", # Norsk (Bokmål)
|
|
|
|
|
"nb-x-k12" => "norwegian", # Norsk (Bokmål) GS/VGS
|
|
|
|
|
"pt" => "portuguese", # Português
|
|
|
|
|
"pt-BR" => "portuguese", # Português do Brasil
|
|
|
|
|
"ru" => "russian", # pу́сский
|
|
|
|
|
"fi" => "finnish", # Suomi
|
|
|
|
|
"sv" => "swedish", # Svenska
|
|
|
|
|
"sv-x-k12" => "swedish", # Svenska GR/GY
|
|
|
|
|
"tr" => "turkish" # Türkçe
|
|
|
|
|
}.freeze
|
2021-03-04 00:00:06 +08:00
|
|
|
|
|
2020-12-03 03:27:59 +08:00
|
|
|
|
def initialize(context = nil)
|
|
|
|
|
@context = context
|
|
|
|
|
end
|
|
|
|
|
|
2021-03-04 00:00:06 +08:00
|
|
|
|
def total_outcomes(learning_outcome_group_id, args = {})
|
2021-06-15 23:58:32 +08:00
|
|
|
|
if args == {} && improved_outcomes_management?
|
2021-03-04 00:00:06 +08:00
|
|
|
|
cache_key = total_outcomes_cache_key(learning_outcome_group_id)
|
|
|
|
|
Rails.cache.fetch(cache_key) do
|
|
|
|
|
total_outcomes_for(learning_outcome_group_id, args)
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
total_outcomes_for(learning_outcome_group_id, args)
|
2021-01-23 09:03:53 +08:00
|
|
|
|
end
|
2020-12-03 03:27:59 +08:00
|
|
|
|
end
|
|
|
|
|
|
2021-09-28 03:44:44 +08:00
|
|
|
|
def not_imported_outcomes(learning_outcome_group_id, args = {})
|
|
|
|
|
if group_exists?(args[:target_group_id])
|
|
|
|
|
target_group = LearningOutcomeGroup.find_by(id: args[:target_group_id])
|
|
|
|
|
source_group_outcome_ids = outcome_links(learning_outcome_group_id).distinct.pluck(:content_id)
|
|
|
|
|
target_group_outcome_ids = outcome_links(target_group.id).distinct.pluck(:content_id)
|
|
|
|
|
(source_group_outcome_ids - (source_group_outcome_ids & target_group_outcome_ids)).size
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2021-03-04 00:00:06 +08:00
|
|
|
|
def suboutcomes_by_group_id(learning_outcome_group_id, args = {})
|
2022-03-21 23:27:43 +08:00
|
|
|
|
relation = outcome_links(learning_outcome_group_id)
|
2022-08-16 04:51:44 +08:00
|
|
|
|
relation = filter_outcomes(relation, args[:filter])
|
2022-03-21 23:27:43 +08:00
|
|
|
|
relation = relation.joins(:learning_outcome_content)
|
|
|
|
|
.joins("INNER JOIN #{LearningOutcomeGroup.quoted_table_name} AS logs
|
2021-03-04 00:00:06 +08:00
|
|
|
|
ON logs.id = content_tags.associated_asset_id")
|
|
|
|
|
|
|
|
|
|
if args[:search_query]
|
|
|
|
|
relation = add_search_query(relation, args[:search_query])
|
|
|
|
|
add_search_order(relation, args[:search_query])
|
|
|
|
|
else
|
|
|
|
|
relation.order(
|
|
|
|
|
LearningOutcomeGroup.best_unicode_collation_key("logs.title"),
|
|
|
|
|
LearningOutcome.best_unicode_collation_key("short_description")
|
|
|
|
|
)
|
|
|
|
|
end
|
2021-01-29 00:31:25 +08:00
|
|
|
|
end
|
|
|
|
|
|
2021-01-23 09:03:53 +08:00
|
|
|
|
def clear_total_outcomes_cache
|
|
|
|
|
Rails.cache.delete(context_timestamp_cache_key) if improved_outcomes_management?
|
|
|
|
|
end
|
|
|
|
|
|
2021-08-18 22:15:52 +08:00
|
|
|
|
def self.supported_languages
|
|
|
|
|
# cache this in the class since this won't change so much
|
|
|
|
|
@supported_languages ||= ContentTag.connection.execute(
|
|
|
|
|
"SELECT cfgname FROM pg_ts_config"
|
|
|
|
|
).to_a.map { |r| r["cfgname"] }
|
|
|
|
|
end
|
|
|
|
|
|
2020-12-03 03:27:59 +08:00
|
|
|
|
private
|
|
|
|
|
|
2021-09-28 03:44:44 +08:00
|
|
|
|
def outcome_links(learning_outcome_group_id)
|
|
|
|
|
group_ids = children_ids_with_self(learning_outcome_group_id)
|
2022-07-09 00:39:14 +08:00
|
|
|
|
relation = ContentTag.active.learning_outcome_links.where(associated_asset_id: group_ids)
|
|
|
|
|
# Exclude tags for which the aligned outcome is deleted
|
|
|
|
|
valid_outcome_ids = relation
|
|
|
|
|
.select("content_tags.content_id")
|
|
|
|
|
.joins("LEFT OUTER JOIN #{LearningOutcome.quoted_table_name} AS outcomes ON content_tags.content_id = outcomes.id")
|
|
|
|
|
.where("outcomes.workflow_state<>'deleted'")
|
|
|
|
|
relation.where(content_id: valid_outcome_ids)
|
2021-09-28 03:44:44 +08:00
|
|
|
|
end
|
2021-03-04 00:00:06 +08:00
|
|
|
|
|
2022-05-24 23:48:16 +08:00
|
|
|
|
def filter_outcomes(relation, filter)
|
2023-03-14 07:40:52 +08:00
|
|
|
|
if %w[WITH_ALIGNMENTS NO_ALIGNMENTS].include?(filter) && improved_outcomes_management_enabled?(@context)
|
2022-08-16 04:51:44 +08:00
|
|
|
|
outcomes_with_alignments_in_context = ContentTag
|
|
|
|
|
.not_deleted
|
|
|
|
|
.where(
|
|
|
|
|
tag_type: "learning_outcome",
|
Add alignments to artifacts to graphql stats endpoint
closes OUT-5268
flag=outcome_alignment_summary
Test plan:
- Enable Improved Outcomes Management FF
- Enable Outcome Alignment Summary FF
- Go to Course > Outcomes and copy course id from URL
- Create two outcomes, one rubric, one assignment, one
graded discussion and one classic quiz
- Align one outcome to the rubric and align the
rubric to the graded discussion and the quiz
- Open in browser canvas.docker/graphiql
- Execute query below replacing course id
query MyQuery {
course (id: 1) {
outcomeAlignmentStats {
totalOutcomes
alignedOutcomes
totalAlignments
totalArtifacts
alignedArtifacts
artifactAlignments
}
}
}
- Verify that query returns the following data
totalOutcomes: 2, alignedOutcomes: 1, totalAlignments: 3,
totalArtifacts: 3, alignedArtifacts: 2, artifactAlignments: 2
- Create a new rubric and align it with the second outcome
and with the assignment
- Rerun query and verify that it returns the following data
totalOutcomes: 2, alignedOutcomes: 2, totalAlignments: 5,
totalArtifacts: 3, alignedArtifacts: 3, artifactAlignments: 3
Change-Id: Ia082bf6679f99993e7a18f390b20af40d8c68a8b
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/299476
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Chrystal Langston <chrystal.langston@instructure.com>
Product-Review: Kyle Rosenbaum <krosenbaum@instructure.com>
Reviewed-by: Angela Gomba <angela.gomba@instructure.com>
Reviewed-by: Dave Wenzlick <david.wenzlick@instructure.com>
2022-08-24 06:30:55 +08:00
|
|
|
|
content_type: %w[Rubric Assignment AssessmentQuestionBank],
|
2022-08-16 04:51:44 +08:00
|
|
|
|
context: @context
|
|
|
|
|
)
|
|
|
|
|
.map(&:learning_outcome_id)
|
|
|
|
|
.uniq
|
|
|
|
|
|
|
|
|
|
return relation.where(content_id: outcomes_with_alignments_in_context) if filter == "WITH_ALIGNMENTS"
|
|
|
|
|
return relation.where.not(content_id: outcomes_with_alignments_in_context) if filter == "NO_ALIGNMENTS"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
relation
|
2022-05-24 23:48:16 +08:00
|
|
|
|
end
|
|
|
|
|
|
2021-09-28 03:44:44 +08:00
|
|
|
|
def total_outcomes_for(learning_outcome_group_id, args = {})
|
|
|
|
|
relation = outcome_links(learning_outcome_group_id)
|
2022-08-16 04:51:44 +08:00
|
|
|
|
relation = filter_outcomes(relation, args[:filter])
|
2021-03-04 00:00:06 +08:00
|
|
|
|
|
|
|
|
|
if args[:search_query]
|
2021-07-14 23:05:29 +08:00
|
|
|
|
relation = relation.joins(:learning_outcome_content)
|
2021-03-04 00:00:06 +08:00
|
|
|
|
relation = add_search_query(relation, args[:search_query])
|
|
|
|
|
end
|
|
|
|
|
|
2021-08-06 03:17:14 +08:00
|
|
|
|
relation.count
|
2021-03-04 00:00:06 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def add_search_query(relation, search_query)
|
2021-08-18 22:15:52 +08:00
|
|
|
|
# Tried to check if the lang is supported in the same query
|
|
|
|
|
# using a CASE WHEN but it wont work because it'll
|
|
|
|
|
# parse to_tsvector with the not supported lang, and it'll throw an error
|
|
|
|
|
|
|
|
|
|
sql = if self.class.supported_languages.include?(lang)
|
|
|
|
|
ContentTag.sanitize_sql_array([<<~SQL.squish, lang, search_query])
|
|
|
|
|
SELECT unnest(tsvector_to_array(to_tsvector(?, ?))) as token
|
|
|
|
|
SQL
|
|
|
|
|
else
|
|
|
|
|
ContentTag.sanitize_sql_array([<<~SQL.squish, search_query])
|
|
|
|
|
SELECT unnest(tsvector_to_array(to_tsvector(?))) as token
|
|
|
|
|
SQL
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
search_query_tokens = ContentTag.connection.execute(sql).to_a.map { |r| r["token"] }.uniq
|
2021-03-04 00:00:06 +08:00
|
|
|
|
|
2021-08-18 22:15:52 +08:00
|
|
|
|
short_description_query = ContentTag.sanitize_sql_array(["#{SHORT_DESCRIPTION} ~* ANY(array[?])",
|
|
|
|
|
search_query_tokens])
|
2021-03-04 00:00:06 +08:00
|
|
|
|
description_query = ContentTag.sanitize_sql_array(["#{DESCRIPTION} ~* ANY(array[?])", search_query_tokens])
|
|
|
|
|
|
|
|
|
|
relation.where("#{short_description_query} OR #{description_query}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def add_search_order(relation, search_query)
|
2021-11-13 03:01:16 +08:00
|
|
|
|
select_query = ContentTag.sanitize_sql_array([<<~SQL.squish, search_query, search_query])
|
2021-03-04 00:00:06 +08:00
|
|
|
|
"content_tags".*,
|
2021-08-18 22:15:52 +08:00
|
|
|
|
GREATEST(public.word_similarity(?, #{SHORT_DESCRIPTION}), public.word_similarity(?, #{DESCRIPTION})) as sim
|
2021-03-04 00:00:06 +08:00
|
|
|
|
SQL
|
|
|
|
|
|
|
|
|
|
relation.select(select_query).order(
|
|
|
|
|
"sim DESC",
|
|
|
|
|
LearningOutcomeGroup.best_unicode_collation_key("logs.title"),
|
|
|
|
|
LearningOutcome.best_unicode_collation_key("short_description")
|
|
|
|
|
)
|
|
|
|
|
end
|
|
|
|
|
|
2021-09-14 22:12:10 +08:00
|
|
|
|
def children_ids_with_self(learning_outcome_group_id)
|
2021-11-13 03:01:16 +08:00
|
|
|
|
sql = <<~SQL.squish
|
2021-01-23 09:03:53 +08:00
|
|
|
|
WITH RECURSIVE levels AS (
|
2021-09-14 22:12:10 +08:00
|
|
|
|
SELECT id, id AS parent_id
|
|
|
|
|
FROM (#{LearningOutcomeGroup.active.where(id: learning_outcome_group_id).to_sql}) AS data
|
2021-01-23 09:03:53 +08:00
|
|
|
|
UNION ALL
|
|
|
|
|
SELECT child.id AS id, parent.parent_id AS parent_id
|
|
|
|
|
FROM #{LearningOutcomeGroup.quoted_table_name} child
|
|
|
|
|
INNER JOIN levels parent ON parent.id = child.learning_outcome_group_id
|
|
|
|
|
WHERE child.workflow_state <> 'deleted'
|
|
|
|
|
)
|
2021-09-14 22:12:10 +08:00
|
|
|
|
SELECT id FROM levels
|
2021-01-23 09:03:53 +08:00
|
|
|
|
SQL
|
2021-09-14 22:12:10 +08:00
|
|
|
|
|
|
|
|
|
LearningOutcomeGroup.connection.execute(sql).as_json.map { |r| r["id"] }
|
2021-01-23 09:03:53 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def context_timestamp_cache
|
|
|
|
|
Rails.cache.fetch(context_timestamp_cache_key) do
|
|
|
|
|
(Time.zone.now.to_f * 1000).to_i
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def total_outcomes_cache_key(learning_outcome_group_id = nil)
|
|
|
|
|
["learning_outcome_group_total_outcomes",
|
|
|
|
|
context_asset_string,
|
|
|
|
|
context_timestamp_cache,
|
|
|
|
|
learning_outcome_group_id].cache_key
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def context_timestamp_cache_key
|
|
|
|
|
["learning_outcome_group_context_timestamp", context_asset_string].cache_key
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def context_asset_string
|
2021-08-18 22:15:52 +08:00
|
|
|
|
@context_asset_string ||= (context || LearningOutcomeGroup.global_root_outcome_group).global_asset_string
|
2021-01-23 09:03:53 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def improved_outcomes_management?
|
2021-08-18 22:15:52 +08:00
|
|
|
|
@improved_outcomes_management ||= if context
|
|
|
|
|
context.root_account.feature_enabled?(:improved_outcomes_management)
|
|
|
|
|
else
|
2021-01-23 09:03:53 +08:00
|
|
|
|
LoadAccount.default_domain_root_account.feature_enabled?(:improved_outcomes_management)
|
2020-12-03 03:27:59 +08:00
|
|
|
|
end
|
|
|
|
|
end
|
2021-08-18 22:15:52 +08:00
|
|
|
|
|
2021-09-28 03:44:44 +08:00
|
|
|
|
def group_exists?(learning_outcome_group_id)
|
|
|
|
|
LearningOutcomeGroup.find_by(id: learning_outcome_group_id) != nil
|
|
|
|
|
end
|
|
|
|
|
|
2021-08-18 22:15:52 +08:00
|
|
|
|
def lang
|
|
|
|
|
# lang can be nil, so we check with instance_variable_defined? method
|
|
|
|
|
unless instance_variable_defined?("@lang")
|
|
|
|
|
account = context&.root_account || LoadAccount.default_domain_root_account
|
|
|
|
|
@lang = MAP_CANVAS_POSTGRES_LOCALES[account.default_locale || "en"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@lang
|
|
|
|
|
end
|
2020-12-03 03:27:59 +08:00
|
|
|
|
end
|
|
|
|
|
end
|