Add create content share API

closes ADMIN-2809
flag=direct_share

Test plan
- Ensure you can create a content export
  and share content between users
- Users cannot create shares for other
  users or share content they do
  not have access to

Change-Id: Ic3c748ad800f85eddd24ac6f0995a363619eed2b
Reviewed-on: https://gerrit.instructure.com/204338
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
Tested-by: Jenkins
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Mysti Lilla <mysti@instructure.com>
This commit is contained in:
Mysti Lilla 2019-08-07 18:13:59 -06:00
parent 45d23e3ab6
commit 6d9e4516b1
13 changed files with 431 additions and 50 deletions

View File

@ -78,6 +78,7 @@
# }
#
class ContentExportsApiController < ApplicationController
include ContentExportApiHelper
include Api::V1::ContentExport
before_action :require_context
@ -147,48 +148,9 @@ class ContentExportsApiController < ApplicationController
valid_types = %w(zip)
valid_types += %w(qti common_cartridge quizzes2) if @context.is_a?(Course)
return render json: { message: 'invalid export_type' }, status: :bad_request unless valid_types.include?(params[:export_type])
export = @context.content_exports.build
export.user = @current_user
export.workflow_state = 'created'
export.settings[:skip_notifications] = true if value_to_boolean(params[:skip_notifications])
# ZipExporter accepts unhashed asset strings, to avoid having to instantiate all the files and folders
if params[:select]
selected_content = ContentMigration.process_copy_params(params[:select]&.to_unsafe_h,
for_content_export: true,
return_asset_strings: params[:export_type] == ContentExport::ZIP,
global_identifiers: export.can_use_global_identifiers?)
end
case params[:export_type]
when 'qti'
export.export_type = ContentExport::QTI
export.selected_content = selected_content || { all_quizzes: true }
when 'zip'
export.export_type = ContentExport::ZIP
export.selected_content = selected_content || { all_attachments: true }
when 'quizzes2'
if params[:quiz_id].nil? || params[:quiz_id] !~ Api::ID_REGEX
return render json: { message: 'quiz_id required and must be a valid ID' },
status: :bad_request
elsif !@context.quizzes.exists?(params[:quiz_id])
return render json: { message: 'Quiz could not be found' }, status: :bad_request
else
export.export_type = ContentExport::QUIZZES2
# we pass the quiz_id of the quiz we want to clone here
export.selected_content = params[:quiz_id]
end
else
export.export_type = ContentExport::COMMON_CARTRIDGE
export.selected_content = selected_content || { everything: true }
end
# recheck, since the export type influences permissions (e.g., students can download zips of non-locked files, but not common cartridges)
return unless authorized_action(export, @current_user, :create)
opts = params.permit(:version).to_unsafe_h
export.progress = 0
if export.save
export.queue_api_job(opts)
export = create_content_export_from_api(params, @context, @current_user)
return unless export.class == ContentExport
if export.id
render json: content_export_json(export, @current_user, session)
else
render json: export.errors, status: :bad_request

View File

@ -0,0 +1,157 @@
#
# Copyright (C) 2019 - 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/>.
#
# @API Content Shares
#
# API for creating, accessing and updating Content Sharing. Content shares are used
# to share content directly between users.
#
# @model ContentShare
# {
# "id": "ContentShare",
# "description": "Content shared between two users",
# "properties": {
# "id": {
# "description": "The id of the content share for the current user",
# "example": 1,
# "type": "integer"
# },
# "name": {
# "description": "The name of the shared content",
# "example": "War of 1812 homework",
# "type": "string"
# },
# "created_at": {
# "description": "The datetime the content was shared with this user.",
# "example": "2017-05-09T10:12:00Z",
# "type": "datetime"
# },
# "updated_at": {
# "description": "The datetime the content was updated.",
# "example": "2017-05-09T10:12:00Z",
# "type": "datetime"
# },
# "user_id": {
# "description": "The id of the user who sent or received the content share.",
# "example": 1578941,
# "type": "integer"
# },
# "sender": {
# "description": "The user who shared the content. No sender information will be given for the sharing user.",
# "example": {"id": 1, "display_name": "Matilda Vargas", "avatar_image_url": "http:\/\/localhost:3000\/image_url", "html_url": "http:\/\/localhost:3000\/users\/1"},
# "type": "object"
# },
# "receivers": {
# "description": "An Array of users the content is shared with. An empty array will be returned for the receiving users.",
# "example": [{"id": 1, "display_name": "Jon Snow", "avatar_image_url": "http:\/\/localhost:3000\/image_url2", "html_url": "http:\/\/localhost:3000\/users\/2"}],
# "type": "array",
# "items": {"type": "object"}
# },
# "read_state": {
# "description": "Whether the recipient has viewed the content share.",
# "example": "read",
# "type": "string"
# }
# }
# }
#
class ContentSharesController < ApplicationController
include ContentExportApiHelper
include Api::V1::ContentShare
CONTENT_TYPES = {
assignment: Assignment,
discussion_topic: DiscussionTopic,
page: WikiPage,
quiz: Quizzes::Quiz,
module: ContextModule,
module_item: ContentTag,
content_share: ContentShare
}.freeze
before_action :require_user
before_action :require_direct_share_enabled
def require_direct_share_enabled
render json: { message: "Feature disabled" }, status: :forbidden unless @domain_root_account.feature_enabled?(:direct_share)
end
# @API Create a content share
# Share content directly between two or more users
#
# @argument receiver_ids [Array]
# IDs of users to share the content with.
#
# @argument content_type [Required, String, "assignment"|"discussion_topic"|"page"|"quiz"|"module"|"module_item"]
# Type of content you are sharing. 'content_share' allows you to re-share content that is already shared.
#
# @argument content_id [Required, Integer]
# The id of the content that you are sharing
#
#
# @example_request
#
# curl 'https://<canvas>/api/v1/content_shares \
# -d 'content_type=assignment' \
# -d 'content_id=1' \
# -H 'Authorization: Bearer <token>' \
# -X POST
#
# @returns ContentShare
#
def create
unless @current_user == api_find(User, params[:user_id])
return render(json: { message: 'Cannot create content shares for other users'}, status: :forbidden)
end
create_params = params.permit(:content_type, :content_id, receiver_ids: [])
allowed_types = ['assignment', 'discussion_topic', 'page', 'quiz', 'module', 'module_item']
receivers = User.active.where(id: create_params[:receiver_ids])
return render(json: { message: 'No valid receiving users found' }, status: :bad_request) unless receivers.any?
unless create_params[:content_type] && create_params[:content_id]
return render(json: { message: 'Content type and id required'}, status: :bad_request)
end
unless allowed_types.include?(create_params[:content_type])
return render(json: { message: "Content type not allowed. Allowed types: #{allowed_types.join(',')}" }, status: :bad_request)
end
content_type = CONTENT_TYPES[create_params[:content_type]&.to_sym]
content = content_type&.where(id: create_params[:content_id])
content = if content_type.respond_to? :not_deleted
content&.not_deleted
elsif content_type.respond_to? :active
content&.active
end
content = content&.where(tag_type: 'context_module') if content_type == ContentTag
content = content&.take
return render(json: { message: 'Requested share content not found'}, status: :bad_request) unless content
export_params = ActionController::Parameters.new(skip_notifications: true,
select: {create_params[:content_type].pluralize => [create_params[:content_id]]},
export_type: ContentExport::COMMON_CARTRIDGE)
export = create_content_export_from_api(export_params, content.context, @current_user)
return unless export.class == ContentExport
return render(json: { message: 'Unable to export content'}, status: :bad_request) unless export.id
name = Context.asset_name(content)
sender_share = @current_user.sent_content_shares.create(content_export: export, name: name, read_state: 'read')
receivers.each do |receiver|
receiver.received_content_shares.create(content_export: export, sender: @current_user, name: name, read_state: 'unread')
end
render json: content_share_json(sender_share, @current_user, session), status: :created
end
end

View File

@ -0,0 +1,66 @@
#
# Copyright (C) 2019 - 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 ContentExportApiHelper
def create_content_export_from_api(params, context, current_user)
export = context.content_exports.build
export.user = current_user
export.workflow_state = 'created'
export.settings[:skip_notifications] = true if value_to_boolean(params[:skip_notifications])
# ZipExporter accepts unhashed asset strings, to avoid having to instantiate all the files and folders
if params[:select]
selected_content = ContentMigration.process_copy_params(params[:select]&.to_unsafe_h,
for_content_export: true,
return_asset_strings: params[:export_type] == ContentExport::ZIP,
global_identifiers: export.can_use_global_identifiers?)
end
case params[:export_type]
when 'qti'
export.export_type = ContentExport::QTI
export.selected_content = selected_content || { all_quizzes: true }
when 'zip'
export.export_type = ContentExport::ZIP
export.selected_content = selected_content || { all_attachments: true }
when 'quizzes2'
if params[:quiz_id].nil? || params[:quiz_id] !~ Api::ID_REGEX
return render json: { message: 'quiz_id required and must be a valid ID' },
status: :bad_request
elsif !context.quizzes.exists?(params[:quiz_id])
return render json: { message: 'Quiz could not be found' }, status: :bad_request
else
export.export_type = ContentExport::QUIZZES2
# we pass the quiz_id of the quiz we want to clone here
export.selected_content = params[:quiz_id]
end
else
export.export_type = ContentExport::COMMON_CARTRIDGE
export.selected_content = selected_content || { everything: true }
end
# recheck, since the export type influences permissions (e.g., students can download zips of non-locked files, but not common cartridges)
return unless authorized_action(export, current_user, :create)
opts = params.permit(:version).to_unsafe_h
export.progress = 0
if export.save
export.queue_api_job(opts)
end
export
end
end

View File

@ -23,8 +23,8 @@ class ContentExport < ActiveRecord::Base
belongs_to :attachment
belongs_to :content_migration
has_many :attachments, :as => :context, :inverse_of => :context, :dependent => :destroy
has_many :content_shares
has_many :sent_content_shares, -> { where.not(sender_id: nil) }, class_name: 'ContentShare', inverse_of: :content_export
has_one :sent_content_share
has_many :received_content_shares
has_one :epub_export
has_a_broadcast_policy
serialize :settings

View File

@ -20,8 +20,5 @@ class ContentShare < ActiveRecord::Base
belongs_to :user
belongs_to :content_export
belongs_to :sender, class_name: 'User', inverse_of: :content_shares
has_many :receiver_content_shares, through: :content_export, source: :sent_content_shares
has_many :receivers, through: :receiver_content_shares, source: :user
end

View File

@ -0,0 +1,23 @@
#
# Copyright (C) 2019 - 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/>.
#
class ReceivedContentShare < ContentShare
belongs_to :sender, class_name: 'User', inverse_of: :received_content_shares
end

View File

@ -0,0 +1,24 @@
#
# Copyright (C) 2019 - 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/>.
#
class SentContentShare < ContentShare
has_many :received_content_shares, through: :content_export, source: :received_content_shares
has_many :receivers, through: :received_content_shares, source: :user
end

View File

@ -147,8 +147,8 @@ class User < ActiveRecord::Base
has_many :user_generated_media_objects, :class_name => 'MediaObject'
has_many :user_notes
has_many :content_shares, dependent: :destroy
has_many :received_content_shares, -> { where.not(content_shares: {sender: nil}) }, class_name: 'ContentShare', inverse_of: :user
has_many :sent_content_shares, -> { where(content_shares: {sender: nil}) }, class_name: 'ContentShare', inverse_of: :user
has_many :received_content_shares
has_many :sent_content_shares
has_many :account_reports, inverse_of: :user
has_many :stream_item_instances, :dependent => :delete_all
has_many :all_conversations, -> { preload(:conversation) }, class_name: 'ConversationParticipant'

View File

@ -2185,6 +2185,10 @@ CanvasRails::Application.routes.draw do
delete 'planner_notes/:id', action: :destroy
end
scope(controller: :content_shares) do
post 'users/:user_id/content_shares', action: :create
end
scope(:controller => :csp_settings) do
%w(course account).each do |context|
get "#{context.pluralize}/:#{context}_id/csp_settings", :action => :get_csp_settings

View File

@ -0,0 +1,34 @@
#
# Copyright (C) 2019 - 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/>.
#
class AddTypeToContentShares < ActiveRecord::Migration[5.2]
tag :predeploy
def up
add_column :content_shares, :type, :string, limit: 255
# there shouldn't be any ContentShares in production, so we shouldn't have to worry
# about long jobs
ContentShare.where(type: nil, sender_id: nil).update_all(type: 'SentContentShare')
ContentShare.where(type: nil).where.not(sender_id: nil).update_all(type: 'ReceivedContentShare')
change_column :content_shares, :type, :string, limit: 255, null: false
end
def down
remove_column :content_shares, :type, :string, limit: 255
end
end

View File

@ -0,0 +1,28 @@
#
# Copyright (C) 2019 - 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 Api::V1::ContentShare
include Api::V1::Json
include Api::V1::ContentExport
def content_share_json(content_share, user, session, opts = {})
json = api_json(content_share, user, session, opts.merge(only: %w(id name created_at updated_at user_id read_state)))
json['sender'] = content_share.respond_to?(:sender) ? user_display_json(content_share.sender) : nil
json['receivers'] = content_share.respond_to?(:receivers) ? content_share.receivers.map {|rec| user_display_json(rec)} : []
json
end
end

View File

@ -0,0 +1,86 @@
#
# Copyright (C) 2019 - 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/>.
#
require 'spec_helper'
describe ContentSharesController do
before :once do
course_with_teacher(active_all: true)
@course_1 = @course
@teacher_1 = @teacher
course_with_teacher(active_all: true)
@course_2 = @course
@teacher_2 = @teacher
assignment_model(course: @course_1, name: 'assignment share')
@course.root_account.enable_feature!(:direct_share)
end
describe "POST #create" do
before :each do
user_session(@teacher_1)
end
it "returns http success" do
post :create, params: {user_id: @teacher_1.id, content_type: 'assignment', content_id: @assignment.id, receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:created)
expect(SentContentShare.where(user_id: @teacher_1.id)).to exist
expect(ReceivedContentShare.where(user_id: @teacher_2.id, sender_id: @teacher_1.id)).to exist
expect(ContentExport.where(context: @assignment.context)).to exist
json = JSON.parse(response.body)
expect(json).to include({
"name" => @assignment.title,
"user_id" => @teacher_1.id,
"read_state" => 'read',
"sender" => nil,
})
expect(json['receivers'].first).to include({'id' => @teacher_2.id})
end
it "returns 400 if required parameters aren't included" do
post :create, params: {user_id: @teacher_1.id, content_type: 'assignment', content_id: @assignment.id}
expect(response).to have_http_status(:bad_request)
post :create, params: {user_id: @teacher_1.id, content_type: 'assignment', receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:bad_request)
post :create, params: {user_id: @teacher_1.id, content_id: @assignment.id, receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:bad_request)
announcement_model(context: @course_1)
post :create, params: {user_id: @teacher_1.id, content_type: 'announcement', content_id: @a.id, receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:bad_request)
end
it 'returns 400 if the associated content cannot be found' do
post :create, params: {user_id: @teacher_1.id, content_type: 'discussion_topic', content_id: @assignment.id, receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:bad_request)
end
it "returns 401 if the user doesn't have access to export the associated content" do
user_session(@teacher_2)
post :create, params: {user_id: @teacher_2.id, content_type: 'assignment', content_id: @assignment.id, receiver_ids: [@teacher_1.id]}
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 if the sharing user doesn't match current user" do
user_session(@teacher_2)
post :create, params: {user_id: @teacher_1.id, content_type: 'assignment', content_id: @assignment.id, receiver_ids: [@teacher_2.id]}
expect(response).to have_http_status(:forbidden)
end
end
end

View File

@ -176,7 +176,7 @@ describe Attachments::GarbageCollector do
export.attachment = att
export.save
Attachment.where(id: att.id).update_all(created_at: 1.year.ago)
export.content_shares.create!(name: 'content export', read_state: 'read', user: user_model)
SentContentShare.create!(name: 'content export', read_state: 'read', user: user_model, content_export: export)
gc.delete_content
expect(att.reload).not_to be_deleted