Commit 9f616e0b9baf3bad75d3308531b824591c396aa9

Authored by Arthur Del Esposte
Committed by Fabio Teixeira
1 parent c9eccb09

access_level: Custom permission for community users to access an article

- Added a field on article new/edit to specify which users can access the article
- Added a relationship has_and_belongs_to_many between Article and Person
- Created a new table article_privacy_exceptions for has_and_belongs_to_many
- Added cucumber tests
- Added a new cucumber step to add a user to the exception users list

(ActionItem2852)

Signed-off-by: Alex de Souza <campelo.al1@gmail.com>
Signed-off-by: Athos Ribeiro <athoscribeiro@gmail.com>
Signed-off-by: Andre Bedran <bedran.fleck@gmail.com>
Signed-off-by: Arthur Del Esposte <arthurmde@gmail.com>
Signed-off-by: Carlos Andre <carlos.andre.souza@msn.com>
Signed-off-by: Fabio Teixeira <fabio1079@gmail.com>
Signed-off-by: Gabriela Navarro <navarro1703@gmail.com>
Signed-off-by: Gustavo Jaruga <darksshades@hotmail.com>
Signed-off-by: Matheus Faria <matheus.sousa.faria@gmail.com>
Signed-off-by: Tales Martins <tales.martins@gmail.com>
app/controllers/my_profile/cms_controller.rb
... ... @@ -2,6 +2,8 @@ class CmsController &lt; MyProfileController
2 2  
3 3 protect 'edit_profile', :profile, :only => [:set_home_page]
4 4  
  5 + include ArticleHelper
  6 +
5 7 def self.protect_if(*args)
6 8 before_filter(*args) do |c|
7 9 user, profile = c.send(:user), c.send(:profile)
... ... @@ -70,6 +72,14 @@ class CmsController &lt; MyProfileController
70 72 translations if @article.translatable?
71 73 continue = params[:continue]
72 74  
  75 + @article.article_privacy_exceptions = params[:q].split(/,/).map{|n| environment.people.find n.to_i} unless params[:q].nil?
  76 +
  77 + @tokenized_children = prepare_to_token_input(
  78 + profile.members.map{|m|
  79 + m if @article.article_privacy_exceptions.include?(m)
  80 + }.compact
  81 + )
  82 +
73 83 refuse_blocks
74 84 record_coming
75 85 if request.post?
... ... @@ -130,6 +140,8 @@ class CmsController &lt; MyProfileController
130 140  
131 141 continue = params[:continue]
132 142 if request.post?
  143 + @article.article_privacy_exceptions = params[:q].split(/,/).map{|n| environment.people.find n.to_i} unless params[:q].nil?
  144 +
133 145 if @article.save
134 146 if continue
135 147 redirect_to :action => 'edit', :id => @article
... ... @@ -290,6 +302,12 @@ class CmsController &lt; MyProfileController
290 302 render :text => article_list_to_json(results), :content_type => 'application/json'
291 303 end
292 304  
  305 + def search_article_privacy_exceptions
  306 + arg = params[:q].downcase
  307 + result = profile.members.find(:all, :conditions => ['LOWER(name) LIKE ?', "%#{arg}%"])
  308 + render :text => prepare_to_token_input(result).to_json
  309 + end
  310 +
293 311 def media_upload
294 312 files_uploaded = []
295 313 parent = check_parent(params[:parent_id])
... ...
app/helpers/application_helper.rb
... ... @@ -40,6 +40,8 @@ module ApplicationHelper
40 40  
41 41 include Noosfero::Gravatar
42 42  
  43 + include TokenHelper
  44 +
43 45 def locale
44 46 (@page && !@page.language.blank?) ? @page.language : FastGettext.locale
45 47 end
... ... @@ -1299,10 +1301,6 @@ module ApplicationHelper
1299 1301 content_tag(:div, content_tag(:ul, titles) + raw(contents), :class => 'ui-tabs')
1300 1302 end
1301 1303  
1302   - def jquery_token_input_messages_json(hintText = _('Type in an keyword'), noResultsText = _('No results'), searchingText = _('Searching...'))
1303   - "hintText: '#{hintText}', noResultsText: '#{noResultsText}', searchingText: '#{searchingText}'"
1304   - end
1305   -
1306 1304 def delete_article_message(article)
1307 1305 if article.folder?
1308 1306 _("Are you sure that you want to remove the folder \"#{article.name}\"? Note that all the items inside it will also be removed!")
... ... @@ -1343,50 +1341,6 @@ module ApplicationHelper
1343 1341 )
1344 1342 end
1345 1343  
1346   - def token_input_field_tag(name, element_id, search_action, options = {}, text_field_options = {}, html_options = {})
1347   - options[:min_chars] ||= 3
1348   - options[:hint_text] ||= _("Type in a search term")
1349   - options[:no_results_text] ||= _("No results")
1350   - options[:searching_text] ||= _("Searching...")
1351   - options[:search_delay] ||= 1000
1352   - options[:prevent_duplicates] ||= true
1353   - options[:backspace_delete_item] ||= false
1354   - options[:focus] ||= false
1355   - options[:avoid_enter] ||= true
1356   - options[:on_result] ||= 'null'
1357   - options[:on_add] ||= 'null'
1358   - options[:on_delete] ||= 'null'
1359   - options[:on_ready] ||= 'null'
1360   -
1361   - result = text_field_tag(name, nil, text_field_options.merge(html_options.merge({:id => element_id})))
1362   - result += javascript_tag("jQuery('##{element_id}')
1363   - .tokenInput('#{url_for(search_action)}', {
1364   - minChars: #{options[:min_chars].to_json},
1365   - prePopulate: #{options[:pre_populate].to_json},
1366   - hintText: #{options[:hint_text].to_json},
1367   - noResultsText: #{options[:no_results_text].to_json},
1368   - searchingText: #{options[:searching_text].to_json},
1369   - searchDelay: #{options[:serach_delay].to_json},
1370   - preventDuplicates: #{options[:prevent_duplicates].to_json},
1371   - backspaceDeleteItem: #{options[:backspace_delete_item].to_json},
1372   - queryParam: #{name.to_json},
1373   - tokenLimit: #{options[:token_limit].to_json},
1374   - onResult: #{options[:on_result]},
1375   - onAdd: #{options[:on_add]},
1376   - onDelete: #{options[:on_delete]},
1377   - onReady: #{options[:on_ready]},
1378   - });
1379   - ")
1380   - result += javascript_tag("jQuery('##{element_id}').focus();") if options[:focus]
1381   - if options[:avoid_enter]
1382   - result += javascript_tag("jQuery('#token-input-#{element_id}')
1383   - .live('keydown', function(event){
1384   - if(event.keyCode == '13') return false;
1385   - });")
1386   - end
1387   - result
1388   - end
1389   -
1390 1344 def expirable_content_reference(content, action, text, url, options = {})
1391 1345 reason = @plugins.dispatch("content_expire_#{action.to_s}", content).first
1392 1346 options[:title] = reason
... ...
app/helpers/article_helper.rb
1 1 module ArticleHelper
2 2  
3   - def custom_options_for_article(article)
  3 + include TokenHelper
  4 +
  5 + def custom_options_for_article(article, tokenized_children)
4 6 @article = article
5   - content_tag('h4', _('Visibility')) +
6   - content_tag('div',
7   - content_tag('div',
8   - radio_button(:article, :published, true) +
9   - content_tag('label', _('Public (visible to other people)'), :for => 'article_published_true')
10   - ) +
11   - content_tag('div',
12   - radio_button(:article, :published, false) +
13   - content_tag('label', _('Private'), :for => 'article_published_false')
14   - )
15   - ) +
  7 +
  8 + visibility_options(@article, tokenized_children) +
16 9 content_tag('h4', _('Options')) +
17 10 content_tag('div',
18 11 (article.profile.has_members? ?
... ... @@ -53,6 +46,28 @@ module ArticleHelper
53 46 )
54 47 end
55 48  
  49 + def visibility_options(article, tokenized_children)
  50 + content_tag('h4', _('Visibility')) +
  51 + content_tag('div',
  52 + content_tag('div',
  53 + radio_button(:article, :published, true) +
  54 + content_tag('label', _('Public (visible to other people)'), :for => 'article_published_true')
  55 + ) +
  56 + content_tag('div',
  57 + radio_button(:article, :published, false) +
  58 + content_tag('label', _('Private'), :for => 'article_published_false', :id => "label_private")
  59 + ) +
  60 + (article.profile.class == Community ? content_tag('div',
  61 + content_tag('label', _('Fill in the search field to add the exception users to see this content'), :id => "text-input-search-exception-users") +
  62 + token_input_field_tag(:q, 'search-article-privacy-exceptions', {:action => 'search_article_privacy_exceptions'},
  63 + {:focus => false, :hint_text => _('Type in a search term for a user'), :pre_populate => tokenized_children})) :
  64 + ''))
  65 + end
  66 +
  67 + def prepare_to_token_input(array)
  68 + array.map { |object| {:id => object.id, :name => object.name} }
  69 + end
  70 +
56 71 def cms_label_for_new_children
57 72 _('New article')
58 73 end
... ...
app/helpers/blog_helper.rb
1 1 module BlogHelper
2 2  
3   - def custom_options_for_article(article)
  3 + include ArticleHelper
  4 +
  5 + def custom_options_for_article(article,tokenized_children)
4 6 @article = article
5 7 hidden_field_tag('article[published]', 1) +
6   - hidden_field_tag('article[accept_comments]', 0)
  8 + hidden_field_tag('article[accept_comments]', 0) +
  9 + visibility_options(article,tokenized_children)
7 10 end
8 11  
9 12 def cms_label_for_new_children
... ...
app/helpers/cms_helper.rb
... ... @@ -22,9 +22,9 @@ module CmsHelper
22 22  
23 23 attr_reader :environment
24 24  
25   - def options_for_article(article)
  25 + def options_for_article(article, tokenized_children=nil)
26 26 article_helper = helper_for_article(article)
27   - article_helper.custom_options_for_article(article)
  27 + article_helper.custom_options_for_article(article, tokenized_children)
28 28 end
29 29  
30 30 def link_to_article(article)
... ...
app/helpers/folder_helper.rb
1 1 module FolderHelper
2 2  
3 3 include ShortFilename
  4 + include ArticleHelper
4 5  
5 6 def list_articles(articles, recursive = false)
6 7 if !articles.blank?
... ... @@ -60,19 +61,10 @@ module FolderHelper
60 61 "icon-new icon-new%s" % klass.icon_name
61 62 end
62 63  
63   - def custom_options_for_article(article)
  64 + def custom_options_for_article(article,tokenized_children)
64 65 @article = article
65   - content_tag('h4', _('Visibility')) +
66   - content_tag('div',
67   - content_tag('div',
68   - radio_button(:article, :published, true) +
69   - content_tag('label', _('Public (visible to other people)'), :for => 'article_published_true')
70   - ) +
71   - content_tag('div',
72   - radio_button(:article, :published, false) +
73   - content_tag('label', _('Private'), :for => 'article_published_false')
74   - )
75   - ) +
  66 +
  67 + visibility_options(article,tokenized_children) +
76 68 content_tag('div',
77 69 hidden_field_tag('article[accept_comments]', 0)
78 70 )
... ...
app/helpers/token_helper.rb 0 → 100644
... ... @@ -0,0 +1,51 @@
  1 +module TokenHelper
  2 +
  3 + def jquery_token_input_messages_json(hintText = _('Type in an keyword'), noResultsText = _('No results'), searchingText = _('Searching...'))
  4 + "hintText: '#{hintText}', noResultsText: '#{noResultsText}', searchingText: '#{searchingText}'"
  5 + end
  6 +
  7 + def token_input_field_tag(name, element_id, search_action, options = {}, text_field_options = {}, html_options = {})
  8 + options[:min_chars] ||= 3
  9 + options[:hint_text] ||= _("Type in a search term")
  10 + options[:no_results_text] ||= _("No results")
  11 + options[:searching_text] ||= _("Searching...")
  12 + options[:search_delay] ||= 1000
  13 + options[:prevent_duplicates] ||= true
  14 + options[:backspace_delete_item] ||= false
  15 + options[:focus] ||= false
  16 + options[:avoid_enter] ||= true
  17 + options[:on_result] ||= 'null'
  18 + options[:on_add] ||= 'null'
  19 + options[:on_delete] ||= 'null'
  20 + options[:on_ready] ||= 'null'
  21 +
  22 + result = text_field_tag(name, nil, text_field_options.merge(html_options.merge({:id => element_id})))
  23 + result += javascript_tag("jQuery('##{element_id}')
  24 + .tokenInput('#{url_for(search_action)}', {
  25 + minChars: #{options[:min_chars].to_json},
  26 + prePopulate: #{options[:pre_populate].to_json},
  27 + hintText: #{options[:hint_text].to_json},
  28 + noResultsText: #{options[:no_results_text].to_json},
  29 + searchingText: #{options[:searching_text].to_json},
  30 + searchDelay: #{options[:serach_delay].to_json},
  31 + preventDuplicates: #{options[:prevent_duplicates].to_json},
  32 + backspaceDeleteItem: #{options[:backspace_delete_item].to_json},
  33 + queryParam: #{name.to_json},
  34 + tokenLimit: #{options[:token_limit].to_json},
  35 + onResult: #{options[:on_result]},
  36 + onAdd: #{options[:on_add]},
  37 + onDelete: #{options[:on_delete]},
  38 + onReady: #{options[:on_ready]},
  39 + });
  40 + ")
  41 + result += javascript_tag("jQuery('##{element_id}').focus();") if options[:focus]
  42 + if options[:avoid_enter]
  43 + result += javascript_tag("jQuery('#token-input-#{element_id}')
  44 + .live('keydown', function(event){
  45 + if(event.keyCode == '13') return false;
  46 + });")
  47 + end
  48 + result
  49 + end
  50 +
  51 +end
0 52 \ No newline at end of file
... ...
app/models/article.rb
... ... @@ -69,6 +69,7 @@ class Article &lt; ActiveRecord::Base
69 69 settings_items :allow_members_to_edit, :type => :boolean, :default => false
70 70 settings_items :moderate_comments, :type => :boolean, :default => false
71 71 settings_items :followers, :type => Array, :default => []
  72 + has_and_belongs_to_many :article_privacy_exceptions, :class_name => 'Person', :join_table => 'article_privacy_exceptions'
72 73  
73 74 belongs_to :reference_article, :class_name => "Article", :foreign_key => 'reference_article_id'
74 75  
... ... @@ -470,7 +471,8 @@ class Article &lt; ActiveRecord::Base
470 471  
471 472 def display_unpublished_article_to?(user)
472 473 user == author || allow_view_private_content?(user) || user == profile ||
473   - user.is_admin?(profile.environment) || user.is_admin?(profile)
  474 + user.is_admin?(profile.environment) || user.is_admin?(profile) ||
  475 + article_privacy_exceptions.include?(user)
474 476 end
475 477  
476 478 def display_to?(user = nil)
... ...
app/models/person.rb
... ... @@ -63,6 +63,8 @@ class Person &lt; Profile
63 63  
64 64 has_many :scraps_sent, :class_name => 'Scrap', :foreign_key => :sender_id, :dependent => :destroy
65 65  
  66 + has_and_belongs_to_many :articles_with_access, :class_name => 'Article', :join_table => 'article_privacy_exceptions'
  67 +
66 68 named_scope :more_popular,
67 69 :select => "#{Profile.qualified_column_names}, count(friend_id) as total",
68 70 :group => Profile.qualified_column_names,
... ...
app/views/cms/edit.rhtml
1 1 <%= error_messages_for 'article' %>
  2 +<%= javascript_include_tag "article.js" %>
2 3  
3 4 <div class='<%= (environment.enabled?('media_panel') ? 'with_media_panel' : 'no_media_panel') %>'>
4 5 <% labelled_form_for 'article', @article, :html => { :multipart => true, :class => @type } do |f| %>
... ... @@ -33,7 +34,7 @@
33 34 <%= content_tag( 'small', _('Separate tags with commas') ) %>
34 35  
35 36 <div id='edit-article-options'>
36   - <%= options_for_article(@article) %>
  37 + <%= options_for_article(@article, @tokenized_children) %>
37 38 </div>
38 39  
39 40 <% button_bar do %>
... ...
db/migrate/20140108132730_article_privacy_exceptions.rb 0 → 100644
... ... @@ -0,0 +1,12 @@
  1 +class ArticlePrivacyExceptions < ActiveRecord::Migration
  2 + def self.up
  3 + create_table :article_privacy_exceptions, :id => false do |t|
  4 + t.integer :article_id
  5 + t.integer :person_id
  6 + end
  7 + end
  8 +
  9 + def self.down
  10 + drop_table :article_privacy_exceptions
  11 + end
  12 +end
... ...
db/schema.rb
... ... @@ -9,8 +9,7 @@
9 9 #
10 10 # It's strongly recommended to check this file into your version control system.
11 11  
12   -ActiveRecord::Schema.define(:version => 20131121162641) do
13   -
  12 +ActiveRecord::Schema.define(:version => 20140108132730) do
14 13 create_table "abuse_reports", :force => true do |t|
15 14 t.integer "reporter_id"
16 15 t.integer "abuse_complaint_id"
... ... @@ -46,6 +45,11 @@ ActiveRecord::Schema.define(:version =&gt; 20131121162641) do
46 45 add_index "action_tracker_notifications", ["profile_id", "action_tracker_id"], :name => "index_action_tracker_notif_on_prof_id_act_tracker_id", :unique => true
47 46 add_index "action_tracker_notifications", ["profile_id"], :name => "index_action_tracker_notifications_on_profile_id"
48 47  
  48 + create_table "article_privacy_exceptions", :id => false, :force => true do |t|
  49 + t.integer "article_id"
  50 + t.integer "person_id"
  51 + end
  52 +
49 53 create_table "article_versions", :force => true do |t|
50 54 t.integer "article_id"
51 55 t.integer "version"
... ... @@ -460,6 +464,7 @@ ActiveRecord::Schema.define(:version =&gt; 20131121162641) do
460 464 t.boolean "is_template", :default => false
461 465 t.integer "template_id"
462 466 t.string "redirection_after_login"
  467 + t.text "settings"
463 468 end
464 469  
465 470 add_index "profiles", ["environment_id"], :name => "index_profiles_on_environment_id"
... ... @@ -566,6 +571,11 @@ ActiveRecord::Schema.define(:version =&gt; 20131121162641) do
566 571  
567 572 add_index "tasks", ["spam"], :name => "index_tasks_on_spam"
568 573  
  574 + create_table "terms_forum_people", :id => false, :force => true do |t|
  575 + t.integer "forum_id"
  576 + t.integer "person_id"
  577 + end
  578 +
569 579 create_table "thumbnails", :force => true do |t|
570 580 t.integer "size"
571 581 t.string "content_type"
... ...
features/edit_article.feature
... ... @@ -22,6 +22,70 @@ Feature: edit article
22 22 And I go to joaosilva's control panel
23 23 Then I should see "My Folder"
24 24  
  25 + @selenium
  26 + Scenario: denied access folder for a not logged user
  27 + Given the following communities
  28 + | name | identifier | owner |
  29 + | Free Software | freesoftware | joaosilva |
  30 + And the following users
  31 + | login | name |
  32 + | mario | Mario Souto |
  33 + | maria | Maria Silva |
  34 + And "Mario Souto" is a member of "Free Software"
  35 + And "Maria Silva" is a member of "Free Software"
  36 + And I am on freesoftware's control panel
  37 + And I follow "Manage Content"
  38 + And I follow "New content"
  39 + When I follow "Folder"
  40 + And I fill in "Title" with "My Folder"
  41 + And I choose "article_published_false"
  42 + And I press "Save"
  43 + And I log off
  44 + And I go to /freesoftware/my-folder
  45 + Then I should see "Access denied"
  46 +
  47 + @selenium
  48 + Scenario: show exception users field when you choose the private option
  49 + Given the following communities
  50 + | name | identifier | owner |
  51 + | Free Software | freesoftware | joaosilva |
  52 + And the following users
  53 + | login | name |
  54 + | mario | Mario Souto |
  55 + | maria | Maria Silva |
  56 + And "Mario Souto" is a member of "Free Software"
  57 + And "Maria Silva" is a member of "Free Software"
  58 + And I am on freesoftware's control panel
  59 + And I follow "Manage Content"
  60 + And I follow "New content"
  61 + When I follow "Folder"
  62 + And I fill in "Title" with "My Folder"
  63 + And I choose "article_published_false"
  64 + Then I should see "Fill in the search field to add the exception users to see this content"
  65 +
  66 + @selenium
  67 + Scenario: allowed user should see the content of a folder
  68 + Given the following communities
  69 + | name | identifier | owner |
  70 + | Free Software | freesoftware | joaosilva |
  71 + And the following users
  72 + | login | name |
  73 + | mario | Mario Souto |
  74 + | maria | Maria Silva |
  75 + And the following articles
  76 + | owner | name | body |
  77 + | freesoftware | My Folder | ... |
  78 + And "Mario Souto" is a member of "Free Software"
  79 + And "Maria Silva" is a member of "Free Software"
  80 + And I go to /freesoftware/my-folder
  81 + When I follow "Edit"
  82 + And I choose "article_published_false"
  83 + And I press "Save"
  84 + And I add to "My Folder" the following exception "Maria Silva"
  85 + And I am logged in as "maria"
  86 + And I go to /freesoftware/my-folder
  87 + Then I should see "My Folder"
  88 +
25 89 Scenario: redirect to the created folder
26 90 Given I am on joaosilva's control panel
27 91 And I follow "Manage Content"
... ... @@ -40,6 +104,7 @@ Feature: edit article
40 104 When I follow "Cancel" within ".main-block"
41 105 Then I should be on joaosilva's cms
42 106  
  107 + @selenium
43 108 Scenario: display tag list field when creating event
44 109 Given I am on joaosilva's control panel
45 110 And I follow "Manage Content"
... ...
features/step_definitions/content_steps.rb
... ... @@ -8,3 +8,15 @@ When /^I create a content of type &quot;([^\&quot;]*)&quot; with the following data$/ do |conte
8 8  
9 9 click_button "Save"
10 10 end
  11 +
  12 +And /^I add to "([^\"]*)" the following exception "([^\"]*)"$/ do |article_name, user_exception|
  13 + article = Article.find_by_name(article_name)
  14 + community = article.profile
  15 + raise "The article profile is not a community." unless community.class == Community
  16 +
  17 + my_user = community.members.find_by_name(user_exception)
  18 + raise "Could not find #{user_exception} in #{community.name} community." if my_user.nil?
  19 +
  20 + article.article_privacy_exceptions << my_user
  21 + article.save
  22 +end
11 23 \ No newline at end of file
... ...
public/javascripts/article.js
... ... @@ -172,4 +172,19 @@ jQuery(function($) {
172 172 return false;
173 173 });
174 174  
  175 + function show_hide_token_input() {
  176 + if($("#article_published_false").attr('checked'))
  177 + $("#text-input-search-exception-users").parent("div").css('display', 'block');
  178 + else
  179 + $("#text-input-search-exception-users").parent("div").css('display', 'none');
  180 + }
  181 +
  182 + if( $("#token-input-search-article-privacy-exceptions").length == 1 ) {
  183 + show_hide_token_input();
  184 +
  185 + //Hide / Show the text area
  186 + $("#article_published_false").click(show_hide_token_input);
  187 + $("#article_published_true").click(show_hide_token_input);
  188 + }
  189 +
175 190 });
... ...