From b3167f4640e68167d21a6f622d786b9acc52ddbe Mon Sep 17 00:00:00 2001 From: Bracken Mosbacker Date: Thu, 21 Jul 2011 10:35:46 -0600 Subject: [PATCH] start discussion topic api refs #4752 Change-Id: I54712c27ef1c496c2c92f7805240ca91405b2858 Reviewed-on: https://gerrit.instructure.com/4797 Tested-by: Hudson Reviewed-by: Brian Palmer --- .../discussion_topics_controller.rb | 67 ++++++++- app/controllers/submissions_api_controller.rb | 23 +-- config/routes.rb | 8 ++ doc/templates/rest/basics.md | 18 +++ lib/api.rb | 41 ++++++ lib/api/v1/discussion_topics.rb | 57 ++++++++ spec/apis/v1/discussion_topics_api_spec.rb | 136 ++++++++++++++++++ 7 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 lib/api/v1/discussion_topics.rb create mode 100644 spec/apis/v1/discussion_topics_api_spec.rb diff --git a/app/controllers/discussion_topics_controller.rb b/app/controllers/discussion_topics_controller.rb index 0f348bbbb8b..b88e4355e84 100644 --- a/app/controllers/discussion_topics_controller.rb +++ b/app/controllers/discussion_topics_controller.rb @@ -16,24 +16,85 @@ # with this program. If not, see . # +# @API Discussion Topics +# +# API for accessing and participating in discussion topics in groups and courses. class DiscussionTopicsController < ApplicationController before_filter :require_context, :except => :public_feed add_crumb(lambda { t('#crumbs.discussions', "Discussions")}, :except => [:public_feed]) { |c| c.send :named_context_url, c.instance_variable_get("@context"), :context_discussion_topics_url } before_filter { |c| c.active_tab = "discussions" } - + + include Api::V1::DiscussionTopics + + # @API + # + # Returns the list of discussion topics for this course. + # + # @response_field assignment_id The unique identifier of the assignment if the topic is for grading, otherwise null + # @response_field attachments Array of attachments + # @response_field delayed_post_at The datetime to post the topic (if not right away) + # @response_field discussion_subentry_count The count of entries in the topic + # @response_field id The unique identifier for the discussion topic. + # @response_field last_reply_at The datetime for when the last reply was in the topic + # @response_field message The HTML content of the topic + # @response_field permissions A hash of permissions for the current user. If a key is not present then the permission is false. + # Example: + # {"delete"=>true,"reply"=>true,"read"=>true,"attach"=>true,"create"=>true,"update"=>true} + # @response_field podcast_url If the topic is a podcast topic this is the feed url for the current user + # @response_field posted_at The datetime the topic was posted. If it is null it hasn't been posted yet. (see delayed_post_at) + # @response_field require_initial_post If true then a user may not respond to other replies until that user has made an initial reply + # @response_field root_topic_id If the topic is for grading and a group assignment this will point to the original topic in the course + # @response_field title The title of the topic + # @response_field topic_children An array of topic_ids for the group discussions the user is a part of + # @response_field user_name The username of the creator + # + # @example_response + # [ + # { + # "id":1, + # "title":"Topic 1", + # "message":"

content here

", + # "posted_at":"2037-07-21T13:29:31Z", + # "last_reply_at":"2037-07-28T19:38:31Z", + # "require_initial_post":null, + # "discussion_subentry_count":0, + # "assignment_id":null, + # "delayed_post_at":null, + # "user_name":"User Name", + # "permissions":{"reply":true,"read":true}, + # "topic_children":[], + # "root_topic_id":null, + # "podcast_url":"/feeds/topics/1/enrollment_1XAcepje4u228rt4mi7Z1oFbRpn3RAkTzuXIGOPe.rss", + # "attachments":[ + # { + # "content-type":"unknown/unknown", + # "url":"http://www.example.com/courses/1/files/1/download", + # "filename":"content.txt", + # "display_name":"content.txt" + # } + # ] + # } + # ] def index @context.assert_assignment_group rescue nil @all_topics = @context.discussion_topics.active @all_topics = @all_topics.only_discussion_topics if params[:include_announcements] != "1" - @topics = @all_topics.paginate(:page => params[:page]).reject{|a| a.locked_for?(@current_user, :check_policies => true) } + @topics = @all_topics.paginate(:page => params[:page], :per_page => params[:per_page] || 10).reject{|a| a.locked_for?(@current_user, :check_policies => true) } if authorized_action(@context.discussion_topics.new, @current_user, :read) return child_topic if params[:root_discussion_topic_id] && @context.respond_to?(:context) && @context.context && @context.context.discussion_topics.find(params[:root_discussion_topic_id]) log_asset_access("topics:#{@context.asset_string}", "topics", 'other') respond_to do |format| format.html format.xml { render :xml => @topics.to_xml } - format.json { render :json => @topics.to_json(:methods => [:user_name, :discussion_subentry_count], :permissions => {:user => @current_user, :session => session }) } + format.json do + if api_request? + api_pagination_headers(@topics) + render :json => discussion_topic_api_json(@topics) + else + render :json => @topics.to_json(:methods => [:user_name, :discussion_subentry_count], :permissions => {:user => @current_user, :session => session }) + end + end end end end diff --git a/app/controllers/submissions_api_controller.rb b/app/controllers/submissions_api_controller.rb index 1b06e48b049..9faf5485db8 100644 --- a/app/controllers/submissions_api_controller.rb +++ b/app/controllers/submissions_api_controller.rb @@ -22,6 +22,7 @@ # id in these URLs is the id of the student in the course, there is no separate # submission id exposed in these APIs. class SubmissionsApiController < ApplicationController + include Api before_filter :require_context # @API @@ -249,7 +250,7 @@ class SubmissionsApiController < ApplicationController sc.media_comment_type) end sc_hash['attachments'] = sc.attachments.map do |a| - attachment_json(a, assignment, {:comment_id => sc.id, :id => submission.user_id}) + attachment_json(a, :assignment => assignment, :url_params => {:comment_id => sc.id, :id => submission.user_id}) end unless sc.attachments.blank? sc_hash end @@ -287,7 +288,7 @@ class SubmissionsApiController < ApplicationController attachments = attempt.versioned_attachments.dup attachments << attempt.attachment if attempt.attachment && attempt.attachment.context_type == 'Submission' && attempt.attachment.context_id == attempt.id hash['attachments'] = attachments.map do |attachment| - attachment_json(attachment, assignment, :id => attempt.user_id) + attachment_json(attachment, :assignment => assignment, :url_params => {:id => attempt.user_id}) end unless attachments.blank? # include the discussion topic entries @@ -308,7 +309,7 @@ class SubmissionsApiController < ApplicationController ) attachments = (entry.attachments.dup + [entry.attachment]).compact ehash['attachments'] = attachments.map do |attachment| - attachment_json(attachment, assignment, :id => attempt.user_id) + attachment_json(attachment, :assignment => assignment, :url_params => {:id => attempt.user_id}) end unless attachments.blank? ehash end @@ -317,22 +318,6 @@ class SubmissionsApiController < ApplicationController hash end - def attachment_json(attachment, assignment, url_params = {}) - url = case attachment.context_type - when "Course" - course_file_download_url(url_params.merge(:file_id => attachment.id, :id => nil)) - else - course_assignment_submission_url(@context, assignment, - url_params.merge(:download => attachment.id)) - end - { - 'content-type' => attachment.content_type, - 'display_name' => attachment.display_name, - 'filename' => attachment.filename, - 'url' => url, - } - end - # a media comment looks just like an attachment to the API def media_comment_json(media_comment_id, media_comment_type) { diff --git a/config/routes.rb b/config/routes.rb index 3b254350e96..bef16b541b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -664,6 +664,14 @@ ActionController::Routing::Routes.draw do |map| course.student_submissions 'students/submissions.:format', :controller => 'submissions_api', :action => 'for_students', :conditions => { :method => :get } + course.resources :discussion_topics, + :only => %w(index) + end + api.resources :groups, + :name_prefix => "api_v1_", + :only => %w() do |group| + group.resources :discussion_topics, + :only => %w(index) end api.resources :accounts, :only => %{} do |account| account.resources :sis_imports, diff --git a/doc/templates/rest/basics.md b/doc/templates/rest/basics.md index 9c2852547c0..0defcc8453d 100644 --- a/doc/templates/rest/basics.md +++ b/doc/templates/rest/basics.md @@ -146,3 +146,21 @@ the sis field, like "sis\_course\_id:". For instance, to retrieve the list of assignments for a course with sis id of 'A1234': /api/v1/courses/sis_course_id:A1234/assignments.json + +Pagination +---------- + +Requests that return multiple items will be paginated to 10 items by default. Further pages +can be requested with the `?page` query parameter. You can set a custom per-page amount +with the `?per_page` parameter. + +Pagination information is provided in the [link Header](http://www.w3.org/Protocols/9707-link-header.html): + + Link: ; rel="next",; rel="first",; rel="last" + +The possible `rel` values are: + +* next - link to the next page of results. None is sent if there is no next page. +* prev - link to the previous page of results. None is sent if there is no previous page. +* first - link to the first page of results. None is sent if there are no pages. +* last - link to the last page of results. None is sent if there are no pages. diff --git a/lib/api.rb b/lib/api.rb index bd76e65b999..b85f549136e 100644 --- a/lib/api.rb +++ b/lib/api.rb @@ -67,6 +67,47 @@ module Api raise ArgumentError, "need to add support for table name: #{collection.table_name}" end end + + # Add [link HTTP Headers](http://www.w3.org/Protocols/9707-link-header.html) for pagination + # The collection needs to be a will_paginate collection + def self.set_pagination_headers!(collection, response, base_url) + return unless collection.respond_to?(:next_page) + links = [] + template = "<#{base_url}.json?page=%s&per_page=#{collection.per_page}>; rel=\"%s\"" + if collection.next_page + links << template % [collection.next_page, "next"] + end + if collection.previous_page + links << template % [collection.previous_page, "prev"] + end + if collection.total_pages > 1 + links << template % [1, "first"] + links << template % [collection.total_pages, "last"] + end + response.headers["Link"] = links.join(',') if links.length > 0 + end + + def attachment_json(attachment, opts={}) + url_params = opts[:url_params] || {} + + url = case attachment.context_type + when "Course" + course_file_download_url(url_params.merge(:file_id => attachment.id, :id => nil)) + when "Group" + group_file_download_url(url_params.merge(:file_id => attachment.id, :id => nil)) + when /Submission|User/ + return nil unless opts[:assignment] + course_assignment_submission_url(@context, opts[:assignment], url_params.merge(:download => attachment.id)) + else + return nil + end + { + 'content-type' => attachment.content_type, + 'display_name' => attachment.display_name, + 'filename' => attachment.filename, + 'url' => url, + } + end # See User.submissions_for_given_assignments and SubmissionsApiController#for_students mattr_accessor :assignment_ids_for_students_api diff --git a/lib/api/v1/discussion_topics.rb b/lib/api/v1/discussion_topics.rb new file mode 100644 index 00000000000..7ad6819d999 --- /dev/null +++ b/lib/api/v1/discussion_topics.rb @@ -0,0 +1,57 @@ +# +# Copyright (C) 2011 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 . +# + +module Api::V1::DiscussionTopics + include Api + + def discussion_topic_api_json(topics) + topics.map do |topic| + + attachments = [] + if topic.attachment + attachments << attachment_json(topic.attachment, :context => @context) + end + + url = nil + if topic.podcast_enabled + code = @context_enrollment ? @context_enrollment.feed_code : @context.feed_code + url = feeds_topic_format_path(topic.id, code, :rss) + end + + children = topic.child_topics.scoped(:select => 'id').map(&:id) + + topic.as_json(:include_root => false, + :only => %w(id title assignment_id delayed_post_at last_reply_at message posted_at require_initial_post root_topic_id), + :methods => [:user_name, :discussion_subentry_count], + :permissions => {:user => @current_user, :session => session} + ).tap do |json| + json.merge! :podcast_url => url, + :topic_children => children, + :attachments => attachments + end + end + end + + def api_pagination_headers(topics) + if @context.is_a? Course + Api.set_pagination_headers!(topics, response, api_v1_course_discussion_topics_path(@context)) + else + Api.set_pagination_headers!(topics, response, api_v1_group_discussion_topics_path(@context)) + end + end +end \ No newline at end of file diff --git a/spec/apis/v1/discussion_topics_api_spec.rb b/spec/apis/v1/discussion_topics_api_spec.rb new file mode 100644 index 00000000000..2fc18f91c46 --- /dev/null +++ b/spec/apis/v1/discussion_topics_api_spec.rb @@ -0,0 +1,136 @@ +# +# Copyright (C) 2011 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 . +# + +require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper') + +describe DiscussionTopicsController, :type => :integration do + before(:each) do + course_with_teacher(:active_all => true, :user => user_with_pseudonym) + end + + it "should return discussion topic list" do + @topic = @course.discussion_topics.create!(:title => "Topic 1", :message => "

content here

", :podcast_enabled => true) + att = Attachment.create!(:filename => 'content.txt', :display_name => "content.txt", :uploaded_data => StringIO.new('attachment content'), :folder => Folder.unfiled_folder(@course), :context => @course) + @topic.attachment = att + @topic.save + + sub = @course.discussion_topics.create!(:title => "Sub topic", :message => "

i'm subversive

") + sub.root_topic_id = @topic.id + sub.save + + json = api_call(:get, "/api/v1/courses/#{@course.id}/discussion_topics.json", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :course_id => @course.id.to_s}) + + # get rid of random characters in podcast url + json.last["podcast_url"].gsub!(/_[^.]*/, '_randomness') + json.last == {"podcast_url"=>"/feeds/topics/1/enrollment_randomness.rss", + "require_initial_post"=>nil, + "title"=>"Topic 1", + "discussion_subentry_count"=>0, + "assignment_id"=>nil, + "delayed_post_at"=>nil, + "id"=>@topic.id, + "user_name"=>"User Name", + "last_reply_at"=>@topic.last_reply_at.as_json, + "permissions"=>{"delete"=>true, + "reply"=>true, + "read"=>true, + "attach"=>true, + "create"=>true, + "update"=>true}, + "message"=>"

content here

", + "posted_at"=>@topic.posted_at.as_json, + "root_topic_id"=>nil, + "attachments"=>[{"content-type"=>"unknown/unknown", + "url"=>"http://www.example.com/courses/#{@course.id}/files/#{att.id}/download", + "filename"=>"content.txt", + "display_name"=>"content.txt"}], + "topic_children"=>[sub.id]} + end + + it "should paginate and return proper pagination headers for courses" do + 7.times { |i| @course.discussion_topics.create!(:title => i.to_s, :message => i.to_s) } + @course.discussion_topics.count.should == 7 + json = api_call(:get, "/api/v1/courses/#{@course.id}/discussion_topics.json?per_page=3", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :course_id => @course.id.to_s, :per_page => '3'}) + + json.length.should == 3 + response.headers['Link'].should eql(%{; rel="next",; rel="first",; rel="last"}) + + # get the last page + json = api_call(:get, "/api/v1/courses/#{@course.id}/discussion_topics.json?page=3&per_page=3", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :course_id => @course.id.to_s, :page => '3', :per_page => '3'}) + json.length.should == 1 + response.headers['Link'].should eql(%{; rel="prev",; rel="first",; rel="last"}) + end + + it "should work with groups" do + group = Group.create!(:name=>"group1", :category=>"watup", :context => @course) + gtopic = group.discussion_topics.create!(:title => "Group Topic 1", :message => "

content here

") + + att = Attachment.create!(:filename => 'content.txt', :display_name => "content.txt", :uploaded_data => StringIO.new('attachment content'), :folder => Folder.unfiled_folder(group), :context => group) + gtopic.attachment = att + gtopic.save + + json = api_call(:get, "/api/v1/groups/#{group.id}/discussion_topics.json", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :group_id => group.id.to_s}) + json.first.should == {"podcast_url"=>nil, + "require_initial_post"=>nil, + "title"=>"Group Topic 1", + "discussion_subentry_count"=>0, + "assignment_id"=>nil, + "delayed_post_at"=>nil, + "id"=>gtopic.id, + "user_name"=>"User Name", + "last_reply_at"=>gtopic.last_reply_at.as_json, + "permissions"=> + {"delete"=>true, + "reply"=>true, + "read"=>true, + "attach"=>true, + "create"=>true, + "update"=>true}, + "message"=>"

content here

", + "attachments"=> + [{"content-type"=>"unknown/unknown", + "url"=>"http://www.example.com/groups/#{group.id}/files/#{att.id}/download", + "filename"=>"content.txt", + "display_name"=>"content.txt"}], + "posted_at"=>gtopic.posted_at.as_json, + "root_topic_id"=>nil, + "topic_children"=>[]} + end + + it "should paginate and return proper pagination headers for groups" do + group = Group.create!(:name=>"group1", :category=>"watup", :context => @course) + 7.times { |i| group.discussion_topics.create!(:title => i.to_s, :message => i.to_s) } + group.discussion_topics.count.should == 7 + json = api_call(:get, "/api/v1/groups/#{group.id}/discussion_topics.json?per_page=3", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :group_id => group.id.to_s, :per_page => '3'}) + + json.length.should == 3 + response.headers['Link'].should eql(%{; rel="next",; rel="first",; rel="last"}) + + # get the last page + json = api_call(:get, "/api/v1/groups/#{group.id}/discussion_topics.json?page=3&per_page=3", + {:controller => 'discussion_topics', :action => 'index', :format => 'json', :group_id => group.id.to_s, :page => '3', :per_page => '3'}) + json.length.should == 1 + response.headers['Link'].should eql(%{; rel="prev",; rel="first",; rel="last"}) + end + +end