1109 lines
46 KiB
Ruby
1109 lines
46 KiB
Ruby
#
|
|
# Copyright (C) 2011 - 2014 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 'atom'
|
|
|
|
# @API Conversations
|
|
#
|
|
# API for creating, accessing and updating user conversations.
|
|
#
|
|
# @model Conversation
|
|
# {
|
|
# "id": "Conversation",
|
|
# "description": "",
|
|
# "properties": {
|
|
# "id": {
|
|
# "description": "the unique identifier for the conversation.",
|
|
# "example": 2,
|
|
# "type": "integer",
|
|
# "format": "int64"
|
|
# },
|
|
# "subject": {
|
|
# "description": "the subject of the conversation.",
|
|
# "example": 2,
|
|
# "type": "string"
|
|
# },
|
|
# "workflow_state": {
|
|
# "description": "The current state of the conversation (read, unread or archived).",
|
|
# "example": "unread",
|
|
# "type": "string"
|
|
# },
|
|
# "last_message": {
|
|
# "description": "A <=100 character preview from the most recent message.",
|
|
# "example": "sure thing, here's the file",
|
|
# "type": "string"
|
|
# },
|
|
# "start_at": {
|
|
# "description": "the date and time at which the last message was sent.",
|
|
# "example": "2011-09-02T12:00:00Z",
|
|
# "type": "datetime"
|
|
# },
|
|
# "message_count": {
|
|
# "description": "the number of messages in the conversation.",
|
|
# "example": 2,
|
|
# "type": "integer"
|
|
# },
|
|
# "subscribed": {
|
|
# "description": "whether the current user is subscribed to the conversation.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "private": {
|
|
# "description": "whether the conversation is private.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "starred": {
|
|
# "description": "whether the conversation is starred.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "properties": {
|
|
# "description": "Additional conversation flags (last_author, attachments, media_objects). Each listed property means the flag is set to true (i.e. the current user is the most recent author, there are attachments, or there are media objects)",
|
|
# "type": "array",
|
|
# "items": {
|
|
# "type": "string"
|
|
# }
|
|
# },
|
|
# "audience": {
|
|
# "description": "Array of user ids who are involved in the conversation, ordered by participation level, then alphabetical. Excludes current user, unless this is a monologue.",
|
|
# "type": "array",
|
|
# "items": {
|
|
# "type": "integer"
|
|
# }
|
|
# },
|
|
# "audience_contexts": {
|
|
# "description": "Most relevant shared contexts (courses and groups) between current user and other participants. If there is only one participant, it will also include that user's enrollment(s)/ membership type(s) in each course/group.",
|
|
# "type": "array",
|
|
# "items": {
|
|
# "type": "string"
|
|
# }
|
|
# },
|
|
# "avatar_url": {
|
|
# "description": "URL to appropriate icon for this conversation (custom, individual or group avatar, depending on audience).",
|
|
# "example": "https://canvas.instructure.com/images/messages/avatar-group-50.png",
|
|
# "type": "string"
|
|
# },
|
|
# "participants": {
|
|
# "description": "Array of users (id, name) participating in the conversation. Includes current user.",
|
|
# "type": "array",
|
|
# "items": {
|
|
# "type": "string"
|
|
# }
|
|
# },
|
|
# "visible": {
|
|
# "description": "indicates whether the conversation is visible under the current scope and filter. This attribute is always true in the index API response, and is primarily useful in create/update responses so that you can know if the record should be displayed in the UI. The default scope is assumed, unless a scope or filter is passed to the create/update API call.",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "context_name": {
|
|
# "description": "Name of the course or group in which the conversation is occurring.",
|
|
# "example": "Canvas 101",
|
|
# "type": "string"
|
|
# }
|
|
# }
|
|
# }
|
|
class ConversationsController < ApplicationController
|
|
include ConversationsHelper
|
|
include SearchHelper
|
|
include KalturaHelper
|
|
include Api::V1::Conversation
|
|
include Api::V1::Progress
|
|
|
|
before_filter :require_user, :except => [:public_feed]
|
|
before_filter :reject_student_view_student
|
|
before_filter :get_conversation, :only => [:show, :update, :destroy, :add_recipients, :remove_messages]
|
|
before_filter :infer_scope, :only => [:index, :show, :create, :update, :add_recipients, :add_message, :remove_messages]
|
|
before_filter :normalize_recipients, :only => [:create, :add_recipients]
|
|
before_filter :infer_tags, :only => [:create, :add_message, :add_recipients]
|
|
|
|
# whether it's a bulk private message, or a big group conversation,
|
|
# batch up all delayed jobs to make this more responsive to the user
|
|
batch_jobs_in_actions :only => :create
|
|
|
|
API_ALLOWED_FIELDS = %w{workflow_state subscribed starred scope filter}.freeze
|
|
|
|
# @API List conversations
|
|
# Returns the list of conversations for the current user, most recent ones first.
|
|
#
|
|
# @argument scope [String, "unread"|"starred"|"archived"]
|
|
# When set, only return conversations of the specified type. For example,
|
|
# set to "unread" to return only conversations that haven't been read.
|
|
# The default behavior is to return all non-archived conversations (i.e.
|
|
# read and unread).
|
|
#
|
|
# @argument filter[] [String, course_id|group_id|user_id]
|
|
# When set, only return conversations for the specified courses, groups
|
|
# or users. The id should be prefixed with its type, e.g. "user_123" or
|
|
# "course_456". Can be an array (by setting "filter[]") or single value
|
|
# (by setting "filter")
|
|
#
|
|
# @argument filter_mode ["and"|"or", default "or"]
|
|
# When filter[] contains multiple filters, combine them with this mode,
|
|
# filtering conversations that at have at least all of the contexts ("and")
|
|
# or at least one of the contexts ("or")
|
|
#
|
|
# @argument interleave_submissions [Boolean] (Obsolete) Submissions are no
|
|
# longer linked to conversations. This parameter is ignored.
|
|
#
|
|
# @argument include_all_conversation_ids [Boolean] Default is false. If true,
|
|
# the top-level element of the response will be an object rather than
|
|
# an array, and will have the keys "conversations" which will contain the
|
|
# paged conversation data, and "conversation_ids" which will contain the
|
|
# ids of all conversations under this scope/filter in the same order.
|
|
#
|
|
# @response_field id The unique identifier for the conversation.
|
|
# @response_field subject The subject of the conversation.
|
|
# @response_field workflow_state The current state of the conversation
|
|
# (read, unread or archived)
|
|
# @response_field last_message A <=100 character preview from the most
|
|
# recent message
|
|
# @response_field last_message_at The timestamp of the latest message
|
|
# @response_field message_count The number of messages in this conversation
|
|
# @response_field subscribed Indicates whether the user is actively
|
|
# subscribed to the conversation
|
|
# @response_field private Indicates whether this is a private conversation
|
|
# (i.e. audience of one)
|
|
# @response_field starred Whether the conversation is starred
|
|
# @response_field properties Additional conversation flags (last_author,
|
|
# attachments, media_objects). Each listed property means the flag is
|
|
# set to true (i.e. the current user is the most recent author, there
|
|
# are attachments, or there are media objects)
|
|
# @response_field audience Array of user ids who are involved in the
|
|
# conversation, ordered by participation level, then alphabetical.
|
|
# Excludes current user, unless this is a monologue.
|
|
# @response_field audience_contexts Most relevant shared contexts (courses
|
|
# and groups) between current user and other participants. If there is
|
|
# only one participant, it will also include that user's enrollment(s)/
|
|
# membership type(s) in each course/group
|
|
# @response_field avatar_url URL to appropriate icon for this conversation
|
|
# (custom, individual or group avatar, depending on audience)
|
|
# @response_field participants Array of users (id, name) participating in
|
|
# the conversation. Includes current user.
|
|
# @response_field visible Boolean, indicates whether the conversation is
|
|
# visible under the current scope and filter. This attribute is always
|
|
# true in the index API response, and is primarily useful in create/update
|
|
# responses so that you can know if the record should be displayed in
|
|
# the UI. The default scope is assumed, unless a scope or filter is passed
|
|
# to the create/update API call.
|
|
#
|
|
# @example_response
|
|
# [
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "unread",
|
|
# "last_message": "sure thing, here's the file",
|
|
# "last_message_at": "2011-09-02T12:00:00Z",
|
|
# "message_count": 2,
|
|
# "subscribed": true,
|
|
# "private": true,
|
|
# "starred": false,
|
|
# "properties": ["attachments"],
|
|
# "audience": [2],
|
|
# "audience_contexts": {"courses": {"1": ["StudentEnrollment"]}, "groups": {}},
|
|
# "avatar_url": "https://canvas.instructure.com/images/messages/avatar-group-50.png",
|
|
# "participants": [{"id": 1, "name": "Joe TA"}, {"id": 2, "name": "Jane Teacher"}],
|
|
# "visible": true,
|
|
# "context_name": "Canvas 101"
|
|
# }
|
|
# ]
|
|
# @returns [Conversation]
|
|
#
|
|
def index
|
|
if request.format == :json
|
|
@conversations_scope = @conversations_scope.where('message_count > 0')
|
|
conversations = Api.paginate(@conversations_scope, self, api_v1_conversations_url)
|
|
# optimize loading the most recent messages for each conversation into a single query
|
|
ConversationParticipant.preload_latest_messages(conversations, @current_user)
|
|
@conversations_json = conversations_json(conversations, @current_user,
|
|
session, include_participant_avatars: false,
|
|
include_participant_contexts: false, visible: true,
|
|
include_context_name: true, include_beta: params[:include_beta])
|
|
|
|
if params[:include_all_conversation_ids]
|
|
@conversations_json = {:conversations => @conversations_json, :conversation_ids => @conversations_scope.conversation_ids}
|
|
end
|
|
render :json => @conversations_json
|
|
else
|
|
return redirect_to conversations_path(:scope => params[:redirect_scope]) if params[:redirect_scope]
|
|
@current_user.reset_unread_conversations_counter
|
|
@current_user.reload
|
|
|
|
hash = {
|
|
:ATTACHMENTS_FOLDER_ID => @current_user.conversation_attachments_folder.id,
|
|
:ACCOUNT_CONTEXT_CODE => "account_#{@domain_root_account.id}",
|
|
:MAX_GROUP_CONVERSATION_SIZE => Conversation.max_group_conversation_size
|
|
}
|
|
|
|
notes_enabled_accounts = @current_user.associated_accounts.where(enable_user_notes: true)
|
|
|
|
hash[:NOTES_ENABLED] = notes_enabled_accounts.any?
|
|
hash[:CAN_ADD_NOTES_FOR_ACCOUNT] = notes_enabled_accounts.any? {|a| a.grants_right?(@current_user, :manage_students) }
|
|
|
|
if hash[:NOTES_ENABLED] && !hash[:CAN_ADD_NOTES_FOR_ACCOUNT]
|
|
course_note_permissions = {}
|
|
@current_user.enrollments.active.of_instructor_type.includes(:course).each do |enrollment|
|
|
course_note_permissions[enrollment.course_id] = true if enrollment.has_permission_to?(:manage_user_notes)
|
|
end
|
|
hash[:CAN_ADD_NOTES_FOR_COURSES] = course_note_permissions
|
|
end
|
|
js_env(CONVERSATIONS: hash)
|
|
return render :index_new
|
|
end
|
|
end
|
|
|
|
def toggle_new_conversations
|
|
redirect_to action: 'index'
|
|
end
|
|
|
|
# @API Create a conversation
|
|
# Create a new conversation with one or more recipients. If there is already
|
|
# an existing private conversation with the given recipients, it will be
|
|
# reused.
|
|
#
|
|
# @argument recipients[] [Required, String]
|
|
# An array of recipient ids. These may be user ids or course/group ids
|
|
# prefixed with "course_" or "group_" respectively, e.g.
|
|
# recipients[]=1&recipients[]=2&recipients[]=course_3
|
|
#
|
|
# @argument subject [String]
|
|
# The subject of the conversation. This is ignored when reusing a
|
|
# conversation. Maximum length is 255 characters.
|
|
#
|
|
# @argument body [Required, String]
|
|
# The message to be sent
|
|
#
|
|
# @argument group_conversation [Boolean]
|
|
# Defaults to false. If true, this will be a group conversation (i.e. all
|
|
# recipients may see all messages and replies). If false, individual private
|
|
# conversations will be started with each recipient. Must be set false if the
|
|
# number of recipients is over the set maximum (default is 100).
|
|
#
|
|
# @argument attachment_ids[] [String]
|
|
# An array of attachments ids. These must be files that have been previously
|
|
# uploaded to the sender's "conversation attachments" folder.
|
|
#
|
|
# @argument media_comment_id [String]
|
|
# Media comment id of an audio of video file to be associated with this
|
|
# message.
|
|
#
|
|
# @argument media_comment_type [String, "audio"|"video"]
|
|
# Type of the associated media file
|
|
#
|
|
# @argument user_note [Boolean]
|
|
# Will add a faculty journal entry for each recipient as long as the user
|
|
# making the api call has permission, the recipient is a student and
|
|
# faculty journals are enabled in the account.
|
|
#
|
|
# @argument mode [String, "sync"|"async"]
|
|
# Determines whether the messages will be created/sent synchronously or
|
|
# asynchronously. Defaults to sync, and this option is ignored if this is a
|
|
# group conversation or there is just one recipient (i.e. it must be a bulk
|
|
# private message). When sent async, the response will be an empty array
|
|
# (batch status can be queried via the {api:ConversationsController#batches batches API})
|
|
#
|
|
# @argument scope [String, "unread"|"starred"|"archived"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter[] [String, course_id|group_id|user_id]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter_mode ["and"|"or", default "or"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
#
|
|
# @argument context_code [String]
|
|
# The course or group that is the context for this conversation. Same format
|
|
# as courses or groups in the recipients argument.
|
|
def create
|
|
return render_error('recipients', 'blank') if params[:recipients].blank?
|
|
return render_error('recipients', 'invalid') if @recipients.blank?
|
|
return render_error('body', 'blank') if params[:body].blank?
|
|
context_type = nil
|
|
context_id = nil
|
|
if params[:context_code].present?
|
|
context = Context.find_by_asset_string(params[:context_code])
|
|
return render_error('context_code', 'invalid') unless valid_context?(context)
|
|
|
|
context_type = context.class.name
|
|
context_id = context.id
|
|
end
|
|
|
|
params[:recipients].each do |recipient|
|
|
if recipient =~ /\Acourse_\d+\Z/ &&
|
|
!Context.find_by_asset_string(recipient).try(:grants_right?, @current_user, session, :send_messages_all)
|
|
return render_error('recipients', 'restricted by role')
|
|
end
|
|
end
|
|
|
|
group_conversation = value_to_boolean(params[:group_conversation])
|
|
batch_private_messages = !group_conversation && @recipients.size > 1
|
|
batch_group_messages = group_conversation && value_to_boolean(params[:bulk_message])
|
|
message = build_message
|
|
|
|
if !batch_group_messages && @recipients.size > Conversation.max_group_conversation_size
|
|
return render_error('recipients', 'too many for group conversation')
|
|
end
|
|
|
|
if batch_private_messages || batch_group_messages
|
|
mode = params[:mode] == 'async' ? :async : :sync
|
|
batch = ConversationBatch.generate(message, @recipients, mode,
|
|
subject: params[:subject], context_type: context_type,
|
|
context_id: context_id, tags: @tags, group: batch_group_messages)
|
|
|
|
if mode == :async
|
|
headers['X-Conversation-Batch-Id'] = batch.id.to_s
|
|
return render :json => [], :status => :accepted
|
|
end
|
|
|
|
# reload and preload stuff
|
|
conversations = ConversationParticipant.where(:id => batch.conversations).includes(:conversation).order("visible_last_authored_at DESC, last_message_at DESC, id DESC")
|
|
Conversation.preload_participants(conversations.map(&:conversation))
|
|
ConversationParticipant.preload_latest_messages(conversations, @current_user)
|
|
visibility_map = infer_visibility(conversations)
|
|
render :json => conversations.map{ |c| conversation_json(c, @current_user, session, :include_participant_avatars => false, :include_participant_contexts => false, :visible => visibility_map[c.conversation_id]) }, :status => :created
|
|
else
|
|
@conversation = @current_user.initiate_conversation(@recipients, !group_conversation, :subject => params[:subject], :context_type => context_type, :context_id => context_id)
|
|
@conversation.add_message(message, :tags => @tags, :update_for_sender => false, :cc_author => true)
|
|
render :json => [conversation_json(@conversation.reload, @current_user, session, :include_indirect_participants => true, :messages => [message])], :status => :created
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => err
|
|
render :json => err.record.errors, :status => :bad_request
|
|
end
|
|
|
|
# @API Get running batches
|
|
# Returns any currently running conversation batches for the current user.
|
|
# Conversation batches are created when a bulk private message is sent
|
|
# asynchronously (see the mode argument to the {api:ConversationsController#create create API action}).
|
|
#
|
|
# @example_response
|
|
# [
|
|
# {
|
|
# "id": 1,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "created",
|
|
# "completion": 0.1234,
|
|
# "tags": [],
|
|
# "message":
|
|
# {
|
|
# "id": 1,
|
|
# "created_at": "2011-09-02T10:00:00Z",
|
|
# "body": "quick reminder, no class tomorrow",
|
|
# "author_id": 1,
|
|
# "generated": false,
|
|
# "media_comment": null,
|
|
# "forwarded_messages": [],
|
|
# "attachments": []
|
|
# }
|
|
# }
|
|
# ]
|
|
def batches
|
|
batches = Api.paginate(@current_user.conversation_batches.in_progress.order(:id),
|
|
self,
|
|
api_v1_conversations_batches_url)
|
|
render :json => batches.map{ |m| conversation_batch_json(m, @current_user, session) }
|
|
end
|
|
|
|
# @API Get a single conversation
|
|
# Returns information for a single conversation. Response includes all
|
|
# fields that are present in the list/index action as well as messages
|
|
# and extended participant information.
|
|
#
|
|
# @argument interleave_submissions [Boolean] (Obsolete) Submissions are no
|
|
# longer linked to conversations. This parameter is ignored.
|
|
#
|
|
# @argument scope [String, "unread"|"starred"|"archived"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter[] [String, course_id|group_id|user_id]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter_mode ["and"|"or", default "or"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
#
|
|
# @argument auto_mark_as_read [Boolean] Default true. If true, unread
|
|
# conversations will be automatically marked as read. This will default
|
|
# to false in a future API release, so clients should explicitly send
|
|
# true if that is the desired behavior.
|
|
#
|
|
# @response_field participants Array of relevant users. Includes current
|
|
# user. If there are forwarded messages in this conversation, the authors
|
|
# of those messages will also be included, even if they are not
|
|
# participating in this conversation. Fields include:
|
|
# @response_field messages Array of messages, newest first. Fields include:
|
|
# id:: The unique identifier for the message
|
|
# created_at:: The timestamp of the message
|
|
# body:: The actual message body
|
|
# author_id:: The id of the user who sent the message (see audience, participants)
|
|
# generated:: If true, indicates this is a system-generated message (e.g. "Bob added Alice to the conversation")
|
|
# media_comment:: Audio/video comment data for this message (if applicable). Fields include: display_name, content-type, media_id, media_type, url
|
|
# forwarded_messages:: If this message contains forwarded messages, they will be included here (same format as this list). Note that those messages may have forwarded messages of their own, etc.
|
|
# attachments:: Array of attachments for this message. Fields include: display_name, content-type, filename, url
|
|
# @response_field submissions (Obsolete) Array of assignment submissions having
|
|
# comments relevant to this conversation. Submissions are no longer linked to conversations.
|
|
# This field will always be nil or empty.
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "unread",
|
|
# "last_message": "sure thing, here's the file",
|
|
# "last_message_at": "2011-09-02T12:00:00-06:00",
|
|
# "message_count": 2,
|
|
# "subscribed": true,
|
|
# "private": true,
|
|
# "starred": false,
|
|
# "properties": ["attachments"],
|
|
# "audience": [2],
|
|
# "audience_contexts": {"courses": {"1": []}, "groups": {}},
|
|
# "avatar_url": "https://canvas.instructure.com/images/messages/avatar-50.png",
|
|
# "participants": [{"id": 1, "name": "Joe TA"}, {"id": 2, "name": "Jane Teacher"}, {"id": 3, "name": "Bob Student"}],
|
|
# "messages":
|
|
# [
|
|
# {
|
|
# "id": 3,
|
|
# "created_at": "2011-09-02T12:00:00Z",
|
|
# "body": "sure thing, here's the file",
|
|
# "author_id": 2,
|
|
# "generated": false,
|
|
# "media_comment": null,
|
|
# "forwarded_messages": [],
|
|
# "attachments": [{"id": 1, "display_name": "notes.doc", "uuid": "abcdefabcdefabcdefabcdefabcdef"}]
|
|
# },
|
|
# {
|
|
# "id": 2,
|
|
# "created_at": "2011-09-02T11:00:00Z",
|
|
# "body": "hey, bob didn't get the notes. do you have a copy i can give him?",
|
|
# "author_id": 2,
|
|
# "generated": false,
|
|
# "media_comment": null,
|
|
# "forwarded_messages":
|
|
# [
|
|
# {
|
|
# "id": 1,
|
|
# "created_at": "2011-09-02T10:00:00Z",
|
|
# "body": "can i get a copy of the notes? i was out",
|
|
# "author_id": 3,
|
|
# "generated": false,
|
|
# "media_comment": null,
|
|
# "forwarded_messages": [],
|
|
# "attachments": []
|
|
# }
|
|
# ],
|
|
# "attachments": []
|
|
# }
|
|
# ],
|
|
# "submissions": []
|
|
# }
|
|
def show
|
|
unless request.xhr? || params[:format] == 'json'
|
|
scope = if @conversation.archived?
|
|
'archived'
|
|
elsif @conversation.visible_last_authored_at && !@conversation.last_message_at
|
|
'sent'
|
|
else
|
|
'default'
|
|
end
|
|
return redirect_to conversations_path(:scope => scope, :id => @conversation.conversation_id, :message => params[:message])
|
|
end
|
|
|
|
@conversation.update_attribute(:workflow_state, "read") if @conversation.unread? && auto_mark_as_read?
|
|
messages = nil
|
|
Shackles.activate(:slave) do
|
|
messages = @conversation.messages
|
|
ActiveRecord::Associations::Preloader.new(messages, :asset).run
|
|
end
|
|
|
|
render :json => conversation_json(@conversation,
|
|
@current_user,
|
|
session,
|
|
include_participant_contexts: value_to_boolean(params.fetch(:include_participant_contexts, true)),
|
|
include_indirect_participants: true,
|
|
messages: messages,
|
|
submissions: [],
|
|
include_beta: params[:include_beta],
|
|
include_context_name: true)
|
|
end
|
|
|
|
# @API Edit a conversation
|
|
# Updates attributes for a single conversation.
|
|
#
|
|
# @argument conversation[subject] [String]
|
|
# Change the subject of this conversation
|
|
#
|
|
# @argument conversation[workflow_state] [String, "read"|"unread"|"archived"]
|
|
# Change the state of this conversation
|
|
#
|
|
# @argument conversation[subscribed] [Boolean]
|
|
# Toggle the current user's subscription to the conversation (only valid for
|
|
# group conversations). If unsubscribed, the user will still have access to
|
|
# the latest messages, but the conversation won't be automatically flagged
|
|
# as unread, nor will it jump to the top of the inbox.
|
|
#
|
|
# @argument conversation[starred] [Boolean]
|
|
# Toggle the starred state of the current user's view of the conversation.
|
|
#
|
|
# @argument scope [String, "unread"|"starred"|"archived"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter[] [String, course_id|group_id|user_id]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
# @argument filter_mode ["and"|"or", default "or"]
|
|
# Used when generating "visible" in the API response. See the explanation
|
|
# under the {api:ConversationsController#index index API action}
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "read",
|
|
# "last_message": "sure thing, here's the file",
|
|
# "last_message_at": "2011-09-02T12:00:00-06:00",
|
|
# "message_count": 2,
|
|
# "subscribed": true,
|
|
# "private": true,
|
|
# "starred": false,
|
|
# "properties": ["attachments"],
|
|
# "audience": [2],
|
|
# "audience_contexts": {"courses": {"1": []}, "groups": {}},
|
|
# "avatar_url": "https://canvas.instructure.com/images/messages/avatar-50.png",
|
|
# "participants": [{"id": 1, "name": "Joe TA"}]
|
|
# }
|
|
def update
|
|
if @conversation.update_attributes(params[:conversation].slice(*API_ALLOWED_FIELDS))
|
|
render :json => conversation_json(@conversation, @current_user, session)
|
|
else
|
|
render :json => @conversation.errors, :status => :bad_request
|
|
end
|
|
end
|
|
|
|
# @API Mark all as read
|
|
# Mark all conversations as read.
|
|
def mark_all_as_read
|
|
@current_user.mark_all_conversations_as_read!
|
|
render :json => {}
|
|
end
|
|
|
|
# @API Delete a conversation
|
|
# Delete this conversation and its messages. Note that this only deletes
|
|
# this user's view of the conversation.
|
|
#
|
|
# Response includes same fields as UPDATE action
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "read",
|
|
# "last_message": null,
|
|
# "last_message_at": null,
|
|
# "message_count": 0,
|
|
# "subscribed": true,
|
|
# "private": true,
|
|
# "starred": false,
|
|
# "properties": []
|
|
# }
|
|
def destroy
|
|
@conversation.remove_messages(:all)
|
|
render :json => conversation_json(@conversation, @current_user, session, :visible => false)
|
|
end
|
|
|
|
# internal api
|
|
# @example_request
|
|
# curl https://<canvas>/api/v1/conversations/:id/delete_for_all \
|
|
# -X DELETE \
|
|
# -H 'Authorization: Bearer <token>'
|
|
def delete_for_all
|
|
return unless authorized_action(Account.site_admin, @current_user, :become_user)
|
|
|
|
Conversation.find(params[:id]).delete_for_all
|
|
|
|
render :json => {}
|
|
end
|
|
|
|
# @API Add recipients
|
|
# Add recipients to an existing group conversation. Response is similar to
|
|
# the GET/show action, except that only includes the
|
|
# latest message (e.g. "joe was added to the conversation by bob")
|
|
#
|
|
# @argument recipients[] [Required, String]
|
|
# An array of recipient ids. These may be user ids or course/group ids
|
|
# prefixed with "course_" or "group_" respectively, e.g.
|
|
# recipients[]=1&recipients[]=2&recipients[]=course_3
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "read",
|
|
# "last_message": "let's talk this over with jim",
|
|
# "last_message_at": "2011-09-02T12:00:00-06:00",
|
|
# "message_count": 2,
|
|
# "subscribed": true,
|
|
# "private": false,
|
|
# "starred": null,
|
|
# "properties": [],
|
|
# "audience": [2, 3, 4],
|
|
# "audience_contexts": {"courses": {"1": []}, "groups": {}},
|
|
# "avatar_url": "https://canvas.instructure.com/images/messages/avatar-group-50.png",
|
|
# "participants": [{"id": 1, "name": "Joe TA"}, {"id": 2, "name": "Jane Teacher"}, {"id": 3, "name": "Bob Student"}, {"id": 4, "name": "Jim Admin"}],
|
|
# "messages":
|
|
# [
|
|
# {
|
|
# "id": 4,
|
|
# "created_at": "2011-09-02T12:10:00Z",
|
|
# "body": "Jim was added to the conversation by Joe TA",
|
|
# "author_id": 1,
|
|
# "generated": true,
|
|
# "media_comment": null,
|
|
# "forwarded_messages": [],
|
|
# "attachments": []
|
|
# }
|
|
# ]
|
|
# }
|
|
#
|
|
def add_recipients
|
|
if @recipients.present?
|
|
@conversation.add_participants(@recipients, :tags => @tags, :root_account_id => @domain_root_account.id)
|
|
render :json => conversation_json(@conversation.reload, @current_user, session, :messages => [@conversation.messages.first])
|
|
else
|
|
render :json => {}, :status => :bad_request
|
|
end
|
|
end
|
|
|
|
# @API Add a message
|
|
# Add a message to an existing conversation. Response is similar to the
|
|
# GET/show action, except that only includes the
|
|
# latest message (i.e. what we just sent)
|
|
#
|
|
# @argument body [Required, String]
|
|
# The message to be sent.
|
|
#
|
|
# @argument attachment_ids[] [String]
|
|
# An array of attachments ids. These must be files that have been previously
|
|
# uploaded to the sender's "conversation attachments" folder.
|
|
#
|
|
# @argument media_comment_id [String]
|
|
# Media comment id of an audio of video file to be associated with this
|
|
# message.
|
|
#
|
|
# @argument media_comment_type [String, "audio"|"video"]
|
|
# Type of the associated media file.
|
|
#
|
|
# @argument recipients[] [String]
|
|
# An array of user ids. Defaults to all of the current conversation
|
|
# recipients. To explicitly send a message to no other recipients,
|
|
# this array should consist of the logged-in user id.
|
|
#
|
|
# @argument included_messages[] [String]
|
|
# An array of message ids from this conversation to send to recipients
|
|
# of the new message. Recipients who already had a copy of included
|
|
# messages will not be affected.
|
|
#
|
|
# @argument user_note [Boolean]
|
|
# Will add a faculty journal entry for each recipient as long as the user
|
|
# making the api call has permission, the recipient is a student and
|
|
# faculty journals are enabled in the account.
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "unread",
|
|
# "last_message": "let's talk this over with jim",
|
|
# "last_message_at": "2011-09-02T12:00:00-06:00",
|
|
# "message_count": 2,
|
|
# "subscribed": true,
|
|
# "private": false,
|
|
# "starred": null,
|
|
# "properties": [],
|
|
# "audience": [2, 3],
|
|
# "audience_contexts": {"courses": {"1": []}, "groups": {}},
|
|
# "avatar_url": "https://canvas.instructure.com/images/messages/avatar-group-50.png",
|
|
# "participants": [{"id": 1, "name": "Joe TA"}, {"id": 2, "name": "Jane Teacher"}, {"id": 3, "name": "Bob Student"}],
|
|
# "messages":
|
|
# [
|
|
# {
|
|
# "id": 3,
|
|
# "created_at": "2011-09-02T12:00:00Z",
|
|
# "body": "let's talk this over with jim",
|
|
# "author_id": 2,
|
|
# "generated": false,
|
|
# "media_comment": null,
|
|
# "forwarded_messages": [],
|
|
# "attachments": []
|
|
# }
|
|
# ]
|
|
# }
|
|
#
|
|
def add_message
|
|
get_conversation(true)
|
|
if params[:body].present?
|
|
# allow responses to be sent to anyone who is already a conversation participant.
|
|
params[:from_conversation_id] = @conversation.conversation_id
|
|
# not a before_filter because we need to set the above parameter.
|
|
normalize_recipients
|
|
# find included_messages
|
|
message_ids = params[:included_messages]
|
|
message_ids = message_ids.split(/,/) if message_ids.is_a?(String)
|
|
|
|
# these checks could be folded into the initial ConversationMessage lookup for better efficiency
|
|
if message_ids
|
|
|
|
# sanity check: are the messages part of this conversation?
|
|
db_ids = ConversationMessage.where(:id => message_ids, :conversation_id => @conversation.conversation_id).pluck(:id)
|
|
unless db_ids.count == message_ids.count
|
|
return render_error('included_messages', 'not for this conversation')
|
|
end
|
|
message_ids = db_ids
|
|
|
|
# sanity check: can the user see the included messages?
|
|
unless ConversationMessageParticipant.where(:conversation_message_id => message_ids,
|
|
:user_id => @current_user.id).count == message_ids.count
|
|
return render_error('included_messages', 'not a participant')
|
|
end
|
|
end
|
|
|
|
message_args = build_message_args
|
|
if @conversation.should_process_immediately?
|
|
message = @conversation.process_new_message(message_args, @recipients, message_ids, @tags)
|
|
render :json => conversation_json(@conversation.reload, @current_user, session, :messages => [message])
|
|
else
|
|
@conversation.send_later_enqueue_args(:process_new_message,
|
|
{:strand => "add_message_#{@conversation.global_conversation_id}", :max_attempts => 1},
|
|
message_args, @recipients, message_ids, @tags)
|
|
return render :json => [], :status => :accepted
|
|
end
|
|
else
|
|
render :json => {}, :status => :bad_reqquest
|
|
end
|
|
end
|
|
|
|
# @API Delete a message
|
|
# Delete messages from this conversation. Note that this only affects this
|
|
# user's view of the conversation. If all messages are deleted, the
|
|
# conversation will be as well (equivalent to DELETE)
|
|
#
|
|
# @argument remove[] [Required, String]
|
|
# Array of message ids to be deleted
|
|
#
|
|
# @example_response
|
|
# {
|
|
# "id": 2,
|
|
# "subject": "conversations api example",
|
|
# "workflow_state": "read",
|
|
# "last_message": "sure thing, here's the file",
|
|
# "last_message_at": "2011-09-02T12:00:00-06:00",
|
|
# "message_count": 1,
|
|
# "subscribed": true,
|
|
# "private": true,
|
|
# "starred": null,
|
|
# "properties": ["attachments"]
|
|
# }
|
|
def remove_messages
|
|
if params[:remove]
|
|
@conversation.remove_messages(*@conversation.messages.where(id: params[:remove]).to_a)
|
|
if @conversation.conversation_message_participants.where('workflow_state <> ?', 'deleted').length == 0
|
|
@conversation.update_attribute(:last_message_at, nil)
|
|
end
|
|
render :json => conversation_json(@conversation, @current_user, session)
|
|
end
|
|
end
|
|
|
|
# @API Batch update conversations
|
|
# Perform a change on a set of conversations. Operates asynchronously; use the {api:ProgressController#show progress endpoint}
|
|
# to query the status of an operation.
|
|
#
|
|
# @argument conversation_ids[] [required, String]
|
|
# List of conversations to update. Limited to 500 conversations.
|
|
#
|
|
# @argument event [Required, String, "mark_as_read"|"mark_as_unread"|"star"|"unstar"|"archive"|"destroy"]
|
|
# The action to take on each conversation.
|
|
#
|
|
# @example_request
|
|
# curl https://<canvas>/api/v1/conversations \
|
|
# -X PUT \
|
|
# -H 'Authorization: Bearer <token>' \
|
|
# -d 'event=mark_as_read' \
|
|
# -d 'conversation_ids[]=1' \
|
|
# -d 'conversation_ids[]=2'
|
|
#
|
|
# @returns Progress
|
|
def batch_update
|
|
conversation_ids = params[:conversation_ids]
|
|
update_params = params.slice(:event).with_indifferent_access
|
|
|
|
allowed_events = %w(mark_as_read mark_as_unread star unstar archive destroy)
|
|
return render(:json => {:message => 'conversation_ids not specified'}, :status => :bad_request) unless params[:conversation_ids].is_a?(Array)
|
|
return render(:json => {:message => 'conversation batch size limit (500) exceeded'}, :status => :bad_request) unless params[:conversation_ids].size <= 500
|
|
return render(:json => {:message => 'event not specified'}, :status => :bad_request) unless update_params[:event]
|
|
return render(:json => {:message => 'invalid event'}, :status => :bad_request) unless allowed_events.include? update_params[:event]
|
|
|
|
progress = ConversationParticipant.batch_update(@current_user, conversation_ids, update_params)
|
|
render :json => progress_json(progress, @current_user, session)
|
|
end
|
|
|
|
|
|
# @API Find recipients
|
|
#
|
|
# Deprecated, see the {api:SearchController#recipients Find recipients endpoint} in the Search API
|
|
def find_recipients; end
|
|
|
|
# @API Unread count
|
|
# Get the number of unread conversations for the current user
|
|
#
|
|
# @example_response
|
|
# {'unread_count': '7'}
|
|
def unread_count
|
|
# the reasons for this being a string instead of an integer are historical,
|
|
# but for backwards API compatibility we need to leave it a string.
|
|
render :json => {'unread_count' => @current_user.unread_conversations_count.to_s}
|
|
end
|
|
|
|
def public_feed
|
|
return unless get_feed_context(:only => [:user])
|
|
@current_user = @context
|
|
load_all_contexts
|
|
feed = Atom::Feed.new do |f|
|
|
f.title = t('titles.rss_feed', "Conversations Feed")
|
|
f.links << Atom::Link.new(:href => conversations_url, :rel => 'self')
|
|
f.updated = Time.now
|
|
f.id = conversations_url
|
|
end
|
|
Shackles.activate(:slave) do
|
|
@entries = []
|
|
@conversation_contexts = {}
|
|
@current_user.conversations.each do |conversation|
|
|
@entries.concat(conversation.messages.human)
|
|
if @conversation_contexts[conversation.conversation.id].blank?
|
|
@conversation_contexts[conversation.conversation.id] = feed_context_content(conversation)
|
|
end
|
|
end
|
|
@entries = @entries.sort_by{|e| [e.created_at, e.id] }.reverse
|
|
@entries.each do |entry|
|
|
feed.entries << entry.to_atom(:additional_content => @conversation_contexts[entry.conversation.id])
|
|
end
|
|
end
|
|
respond_to do |format|
|
|
format.atom { render :text => feed.to_xml }
|
|
end
|
|
end
|
|
|
|
def feed_context_content(conversation)
|
|
content = ""
|
|
audience = conversation.other_participants
|
|
audience_names = audience.map(&:name)
|
|
audience_contexts = contexts_for(audience, conversation.local_context_tags) # will be 0, 1, or 2 contexts
|
|
audience_context_names = [:courses, :groups].inject([]) { |ary, context_key|
|
|
ary + audience_contexts[context_key].keys.map { |k| @contexts[context_key][k] && @contexts[context_key][k][:name] }
|
|
}.reject(&:blank?)
|
|
|
|
content += "<hr />"
|
|
content += "<div>#{ERB::Util.h(t('conversation_context', "From a conversation with"))} "
|
|
participant_list_cutoff = 2
|
|
if audience_names.length <= participant_list_cutoff
|
|
content += "#{ERB::Util.h(audience_names.to_sentence)}"
|
|
else
|
|
others_string = t('other_recipients', {
|
|
:one => "and 1 other",
|
|
:other => "and %{count} others"
|
|
},
|
|
:count => audience_names.length - participant_list_cutoff)
|
|
content += "#{ERB::Util.h(audience_names[0...participant_list_cutoff].join(", "))} #{ERB::Util.h(others_string)}"
|
|
end
|
|
|
|
if !audience_context_names.empty?
|
|
content += " (#{ERB::Util.h(audience_context_names.to_sentence)})"
|
|
end
|
|
content += "</div>"
|
|
content
|
|
end
|
|
|
|
def watched_intro
|
|
unless @current_user.watched_conversations_intro?
|
|
@current_user.watched_conversations_intro
|
|
@current_user.save
|
|
end
|
|
render :json => {}
|
|
end
|
|
|
|
private
|
|
|
|
def render_error(attribute, message)
|
|
render :json => [{
|
|
:attribute => attribute,
|
|
:message => message,
|
|
}],
|
|
:status => :bad_request
|
|
end
|
|
|
|
def infer_scope
|
|
filter_mode = (params[:filter_mode].respond_to?(:to_sym) && params[:filter_mode].to_sym) || :or
|
|
return render_error('filter_mode', 'invalid') if ![:or, :and].include?(filter_mode)
|
|
|
|
@conversations_scope = case params[:scope]
|
|
when 'unread'
|
|
@current_user.conversations.unread
|
|
when 'starred'
|
|
@current_user.conversations.starred
|
|
when 'sent'
|
|
@current_user.all_conversations.sent
|
|
when 'archived'
|
|
@current_user.conversations.archived
|
|
else
|
|
params[:scope] = 'inbox'
|
|
@current_user.conversations.default
|
|
end
|
|
|
|
filters = param_array(:filter)
|
|
@conversations_scope = @conversations_scope.for_masquerading_user(@real_current_user) if @real_current_user
|
|
@conversations_scope = @conversations_scope.tagged(*filters, :mode => filter_mode) if filters.present?
|
|
@set_visibility = true
|
|
end
|
|
|
|
def infer_visibility(conversations)
|
|
multiple = conversations.is_a?(Enumerable) || conversations.is_a?(ActiveRecord::Relation)
|
|
conversations = [conversations] unless multiple
|
|
result = Hash.new(false)
|
|
visible_conversations = @current_user.shard.activate do
|
|
@conversations_scope.select(:conversation_id).where(:conversation_id => conversations.map(&:conversation_id)).all
|
|
end
|
|
visible_conversations.each { |c| result[c.conversation_id] = true }
|
|
if !multiple
|
|
result[conversations.first.conversation_id]
|
|
else
|
|
result
|
|
end
|
|
end
|
|
|
|
def normalize_recipients
|
|
if params[:recipients]
|
|
recipient_ids = params[:recipients]
|
|
if recipient_ids.is_a?(String)
|
|
params[:recipients] = recipient_ids = recipient_ids.split(/,/)
|
|
end
|
|
@recipients = @current_user.load_messageable_users(MessageableUser.individual_recipients(recipient_ids), :conversation_id => params[:from_conversation_id])
|
|
MessageableUser.context_recipients(recipient_ids).map do |context|
|
|
@recipients.concat @current_user.messageable_users_in_context(context)
|
|
end
|
|
@recipients = @recipients.uniq(&:id)
|
|
end
|
|
end
|
|
|
|
def infer_tags
|
|
tags = param_array(:tags).concat(param_array(:recipients)).concat([params[:context_code]])
|
|
tags = SimpleTags.normalize_tags(tags)
|
|
tags += tags.grep(/\Agroup_(\d+)\z/){ g = Group.where(id: $1.to_i).first and g.context.asset_string }.compact
|
|
@tags = tags.uniq
|
|
end
|
|
|
|
def get_conversation(allow_deleted = false)
|
|
scope = @current_user.all_conversations
|
|
scope = scope.where('message_count>0') unless allow_deleted
|
|
@conversation = scope.where(conversation_id: params[:id] || params[:conversation_id] || 0).first
|
|
raise ActiveRecord::RecordNotFound unless @conversation
|
|
end
|
|
|
|
def build_message
|
|
Conversation.build_message(*build_message_args)
|
|
end
|
|
|
|
def build_message_args
|
|
[
|
|
@current_user,
|
|
params[:body],
|
|
{
|
|
:attachment_ids => params[:attachment_ids],
|
|
:forwarded_message_ids => params[:forwarded_message_ids],
|
|
:root_account_id => @domain_root_account.id,
|
|
:media_comment => infer_media_comment,
|
|
:generate_user_note => value_to_boolean(params[:user_note])
|
|
}
|
|
]
|
|
end
|
|
|
|
def infer_media_comment
|
|
media_id = params[:media_comment_id]
|
|
media_type = params[:media_comment_type]
|
|
if media_id.present? && media_type.present?
|
|
media_comment = MediaObject.by_media_id(media_id).by_media_type(media_type).first
|
|
unless media_comment
|
|
media_comment ||= MediaObject.new
|
|
media_comment.media_type = media_type
|
|
media_comment.media_id = media_id
|
|
media_comment.root_account_id = @domain_root_account.id
|
|
media_comment.user = @current_user
|
|
end
|
|
media_comment.context = @current_user
|
|
media_comment.save
|
|
media_comment
|
|
end
|
|
end
|
|
|
|
# Obsolete. Forced to false until we go through and clean it up thoroughly
|
|
def interleave_submissions
|
|
false
|
|
end
|
|
|
|
def include_private_conversation_enrollments
|
|
if params.has_key? :include_private_conversation_enrollments
|
|
value_to_boolean(params[:include_private_conversation_enrollments])
|
|
else
|
|
api_request?
|
|
end
|
|
end
|
|
|
|
# TODO API v2: default to false, like we do in the UI
|
|
def auto_mark_as_read?
|
|
params[:auto_mark_as_read] ||= api_request?
|
|
value_to_boolean(params[:auto_mark_as_read])
|
|
end
|
|
|
|
# look up the param and cast it to an array. treat empty string same as empty
|
|
def param_array(key)
|
|
Array(params[key].presence || []).compact
|
|
end
|
|
|
|
def valid_context?(context)
|
|
case context
|
|
when nil then false
|
|
when Account then valid_account_context?(context)
|
|
when Course, Group then context.membership_for_user(@current_user) || context.grants_right?(@current_user, session, :send_messages)
|
|
else false
|
|
end
|
|
end
|
|
|
|
def valid_account_context?(account)
|
|
return false unless account.root_account?
|
|
return true if account.grants_right?(@current_user, session, :read_roster)
|
|
account.shard.activate do
|
|
user_sub_accounts = @current_user.associated_accounts.where(root_account_id: account).to_a
|
|
if user_sub_accounts.any? { |a| a.grants_right?(@current_user, session, :read_roster) }
|
|
return true
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
end
|