canvas-lms/app/models/release_note.rb

391 lines
10 KiB
Ruby

# 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/>.
#
# This is a dynamo single-table backed model
# Schema documentation:
# All records have a PartitionKey and a RangeKey, and optionally additional fields named appropriately for the record
# type. There are the following types with the following schemas:
# | Type | Notes | PartitionKey | RangeKey |
# |--|--|--|--|--|
# | All ids | used to list notes for the index | `all_notes` | `{created_at}|{id}` |
# | Note release times | powers the sidebar recent notes | `{env}_release|{role}` | `{release_time}|{id}` |
# | Release note | Authoritative copy of record | `{id}` | `info` |
# | Release note translation | translated resource for a single locale | `{id}` | `lang|{code}` |
#
# The first three types all have the same additional fields:
# * `Id` (String)
# * `Published` (boolean)
# * `ShowAts` (map of times to show notes, per env)
# * `TargetRoles` (list of roles to view note)
# * `CreatedAt` (creation timestamp)
# Release note translation additional fields:
# * `Title` (string)
# * `Description` (string)
# * `Url` (string)
class ReleaseNote
include ActiveModel::Dirty
include ActiveModel::Serializers::JSON
attr_reader :id, :published, :created_at
define_attribute_methods :id, :show_ats, :target_roles, :published
def initialize(ddb_item = nil)
@langs = {}
@target_roles = []
@show_ats = {}
@published = false
@created_at = nil
return if ddb_item.nil?
@id = ddb_item["Id"]
@show_ats = ddb_item["ShowAts"].transform_values { |v| Time.parse(v).utc }
@target_roles = ddb_item["TargetRoles"]
@published = ddb_item["Published"]
@created_at = Time.parse(ddb_item["CreatedAt"]).utc
end
def attributes
{ "id" => nil, "show_ats" => nil, "target_roles" => nil, "published" => nil, "langs" => nil }
end
def target_roles
@target_roles.freeze
end
def target_roles=(new_roles)
target_roles_will_change! unless new_roles == @target_roles
@target_roles = new_roles
end
def published=(new_published)
published_will_change! unless new_published == @published
@published = new_published
end
def show_ats
@show_ats.dup.freeze
end
def set_show_at(env, time)
show_ats_will_change!
@show_ats[env] = time
end
def save
@id ||= SecureRandom.uuid
@created_at ||= Time.now.utc
common_attributes = {
"Id" => id,
"ShowAts" => show_ats.transform_values { |v| v.utc.iso8601 },
"TargetRoles" => target_roles,
"Published" => published,
"CreatedAt" => created_at.utc.iso8601
}
payload = [
{
put_request: {
item: {
"PartitionKey" => id,
"RangeKey" => "info"
}.merge(common_attributes)
}
},
{
put_request: {
item: {
"PartitionKey" => "all_notes",
"RangeKey" => "#{created_at.utc.iso8601}|#{id}"
}.merge(common_attributes)
}
}
]
payload += @langs.map do |lang, translations|
{
put_request: {
item: {
"PartitionKey" => id,
"RangeKey" => "lang|#{lang}",
"Title" => translations[:title],
"Description" => translations[:description],
"Url" => translations[:url]
}
}
}
end
current_values = published ? show_ats.to_a.product(target_roles) : []
old_values = published_was ? show_ats_was.to_a.product(target_roles_was) : []
payload += current_values.map do |pair|
env = pair[0]
role = pair[1]
{
put_request: {
item: {
"PartitionKey" => "#{env[0]}_release|#{role}",
"RangeKey" => "#{env[1].iso8601}|#{id}"
}.merge(common_attributes)
}
}
end
to_delete = old_values - current_values
payload += to_delete.map do |pair|
env = pair[0]
role = pair[1]
{
delete_request: {
key: {
"PartitionKey" => "#{env[0]}_release|#{role}",
"RangeKey" => "#{env[1].iso8601}|#{id}"
}
}
}
end
self.class.ddb_client.batch_write_item(
request_items: { self.class.ddb_table_name => payload }
)
changes_applied
end
def delete
load_all_langs
payload = [
{
delete_request: {
key: {
"PartitionKey" => id,
"RangeKey" => "info"
}
}
},
{
delete_request: {
key: {
"PartitionKey" => "all_notes",
"RangeKey" => "#{created_at.utc.iso8601}|#{id}"
}
}
}
]
payload += @langs.map do |lang, _translations|
{
delete_request: {
key: {
"PartitionKey" => id,
"RangeKey" => "lang|#{lang}"
}
}
}
end
current_values = show_ats.to_a.product(target_roles)
payload += current_values.map do |pair|
env = pair[0]
role = pair[1]
{
delete_request: {
key: {
"PartitionKey" => "#{env[0]}_release|#{role}",
"RangeKey" => "#{env[1].iso8601}|#{id}"
}
}
}
end
self.class.ddb_client.batch_write_item(
request_items: { self.class.ddb_table_name => payload }
)
changes_applied
end
def [](lang)
@langs[lang] ||= fetch_i18n(lang)
end
def []=(lang, translations)
@langs[lang] = translations
end
def langs
load_all_langs
@langs
end
private
def load_all_langs
return if @all_langs_loaded
res = self.class.ddb_client.query(
expression_attribute_values: {
":id" => id,
":sort" => "lang|",
},
key_condition_expression: "PartitionKey = :id AND begins_with(RangeKey, :sort)",
table_name: self.class.ddb_table_name
)
res.items.each do |translations|
lang = translations["RangeKey"].split("|")[1]
@langs[lang] ||= {
title: translations["Title"],
description: translations["Description"],
url: translations["Url"]
}
end
@all_langs_loaded = true
end
def fetch_i18n(lang)
res = self.class.ddb_client.query(
expression_attribute_values: {
":id" => id,
":sort" => "lang|#{lang}",
},
key_condition_expression: "PartitionKey = :id AND RangeKey = :sort",
table_name: self.class.ddb_table_name
)
return nil unless res.items.length.positive?
translations = res.items.first
{
title: translations["Title"],
description: translations["Description"],
url: translations["Url"]
}
end
def to_hash
{
id:,
show_ats:,
target_roles:,
published:,
langs: @langs
}
end
class << self
def find(ids, include_langs: false)
ids_arr = Array.wrap(ids)
return [] if ids_arr.empty?
res = ddb_client.batch_get_item(request_items: { ddb_table_name => {
keys: ids_arr.map do |id|
{
PartitionKey: id,
RangeKey: "info"
}
end
} })
raise ActiveRecord::RecordNotFound unless res.responses[ddb_table_name].length == ids_arr.length
ret = load_raw_records(res.responses[ddb_table_name], include_langs:)
ids.is_a?(Array) ? ret.sort_by { |note| ids_arr.index(note.id) } : ret.first
end
def paginated(include_langs: false)
BookmarkedCollection.build(Bookmarker) do |pager|
start = nil
if pager.current_bookmark
start = { PartitionKey: "all_notes", RangeKey: pager.current_bookmark }
end
res = ddb_client.query(
expression_attribute_values: {
":id" => "all_notes"
},
key_condition_expression: "PartitionKey = :id",
table_name: ddb_table_name,
limit: pager.per_page,
scan_index_forward: false,
exclusive_start_key: start
)
pager.replace(load_raw_records(res.items, include_langs:))
pager.has_more! unless res.last_evaluated_key.nil?
pager
end
end
def latest(env:, role:, limit: 10)
res = ddb_client.query(
expression_attribute_values: {
":id" => "#{env}_release|#{role}",
":sort" => Time.now.utc.iso8601,
},
key_condition_expression: "PartitionKey = :id AND RangeKey <= :sort",
table_name: ddb_table_name,
limit:,
scan_index_forward: false
)
load_raw_records(res.items)
end
def load_raw_records(records, include_langs: false)
ret = records.map { |it| ReleaseNote.new(it) }
ret.each { |it| it.send(:load_all_langs) } if include_langs
ret
end
def enabled?
!ddb_table_name.nil?
end
def settings
YAML.safe_load(DynamicSettings.find(tree: :private)["release_notes.yml", failsafe: "{}"] || "{}")
end
def ddb_table_name
settings["ddb_table_name"]
end
def ddb_client
@ddb_client ||= begin
config = {
region: settings["ddb_region"] || "us-east-1"
}
config[:endpoint] = settings["ddb_endpoint"] if settings["ddb_endpoint"]
config[:credentials] = Canvas::AwsCredentialProvider.new("release_notes_creds", settings["vault_credential_path"])
aws_client = Aws::DynamoDB::Client.new(config)
CanvasDynamoDB::Database.new("release_notes:#{Rails.env}", client_opts: { client: aws_client }, logger: Rails.logger)
end
end
def reset!
# if we reload everything, we don't want a cached
# ddb client with old settings
@ddb = nil
end
end
Canvas::Reloader.on_reload { reset! }
module Bookmarker
def self.bookmark_for(note)
"#{note.created_at.utc.iso8601}|#{note.id}"
end
def self.validate(bookmark)
bookmark.is_a?(String)
end
end
end