From f3105fc1df84185ce5db9b6733a8c0f59c7b8e09 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Wed, 14 Sep 2022 03:10:15 +0200 Subject: [PATCH 1/4] introduces a UserQuery model for admin/users --- app/controllers/context_menus_controller.rb | 10 ++ app/controllers/queries_controller.rb | 4 + app/controllers/users_controller.rb | 66 ++++----- app/helpers/user_queries_helper.rb | 44 ++++++ app/models/query.rb | 3 +- app/models/user_query.rb | 146 ++++++++++++++++++++ app/views/context_menus/users.html.erb | 24 ++++ app/views/users/_list.html.erb | 66 +++++++++ app/views/users/index.html.erb | 126 ++++++----------- config/locales/de.yml | 1 + config/locales/en.yml | 1 + config/routes.rb | 1 + test/functional/users_controller_test.rb | 101 ++++++++++---- 13 files changed, 448 insertions(+), 145 deletions(-) create mode 100644 app/helpers/user_queries_helper.rb create mode 100644 app/models/user_query.rb create mode 100644 app/views/context_menus/users.html.erb create mode 100644 app/views/users/_list.html.erb diff --git a/app/controllers/context_menus_controller.rb b/app/controllers/context_menus_controller.rb index f4ecb9547f..a117866ae7 100644 --- a/app/controllers/context_menus_controller.rb +++ b/app/controllers/context_menus_controller.rb @@ -108,4 +108,14 @@ class ContextMenusController < ApplicationController end render layout: false end + + def users + @users = User.where(id: params[:ids]).to_a + + (render_404; return) unless @users.present? + if (@users.size == 1) + @user = @users.first + end + render layout: false + end end diff --git a/app/controllers/queries_controller.rb b/app/controllers/queries_controller.rb index d2fc5efb95..6b34913bc6 100644 --- a/app/controllers/queries_controller.rb +++ b/app/controllers/queries_controller.rb @@ -177,6 +177,10 @@ class QueriesController < ApplicationController end end + def redirect_to_user_query(options) + redirect_to users_path(options) + end + # Returns the Query subclass, IssueQuery by default # for compatibility with previous behaviour def query_class diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5664237041..27d6eb0d3f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -34,49 +34,49 @@ class UsersController < ApplicationController helper :principal_memberships helper :activities include ActivitiesHelper + helper :queries + include QueriesHelper + helper :user_queries + include UserQueriesHelper require_sudo_mode :create, :update, :destroy def index - sort_init 'login', 'asc' - sort_update %w(login firstname lastname admin created_on last_login_on) + use_session = !request.format.csv? + retrieve_query(UserQuery, use_session) - case params[:format] - when 'xml', 'json' - @offset, @limit = api_offset_and_limit - else - @limit = per_page_option - end - - @status = params[:status] || 1 - - scope = User.logged.status(@status).preload(:email_address) - scope = scope.like(params[:name]) if params[:name].present? - scope = scope.in_group(params[:group_id]) if params[:group_id].present? + if @query.valid? + scope = @query.results_scope - if params[:twofa].present? - case params[:twofa].to_i - when 1 - scope = scope.where.not(twofa_scheme: nil) - when 0 - scope = scope.where(twofa_scheme: nil) - end - end +# sort_init 'login', 'asc' +# sort_update %w(login firstname lastname admin created_on last_login_on) - @user_count = scope.count - @user_pages = Paginator.new @user_count, @limit, params['page'] - @offset ||= @user_pages.offset - @users = scope.order(sort_clause).limit(@limit).offset(@offset).to_a + @user_count = scope.count - respond_to do |format| - format.html do - @groups = Group.givable.sort - render :layout => !request.xhr? + respond_to do |format| + format.html do + @limit = per_page_option + @user_pages = Paginator.new @user_count, @limit, params['page'] + @offset ||= @user_pages.offset + @users = scope.limit(@limit).offset(@offset).to_a + render :layout => !request.xhr? + end + format.csv do + # Export all entries + @entries = scope.to_a + send_data(query_to_csv(@entries, @query, params), :type => 'text/csv; header=present', :filename => 'users.csv') + end + format.api do + @offset, @limit = api_offset_and_limit + @users = scope.limit(@limit).offset(@offset).to_a + end end - format.csv do - send_data(users_to_csv(scope.order(sort_clause)), :type => 'text/csv; header=present', :filename => 'users.csv') + else + respond_to do |format| + format.html {render :layout => !request.xhr?} + format.csv {head 422} + format.api {render_validation_errors(@query)} end - format.api end end diff --git a/app/helpers/user_queries_helper.rb b/app/helpers/user_queries_helper.rb new file mode 100644 index 0000000000..1651092148 --- /dev/null +++ b/app/helpers/user_queries_helper.rb @@ -0,0 +1,44 @@ +module UserQueriesHelper + + def column_value(column, object, value) + if object.is_a?(User) && column.name == :status + user_status_label(column.value_object(object)) + else + super + end + end + + def csv_value(column, object, value) + if object.is_a?(User) + case column.name + when :status + user_status_label(column.value_object(object)) + when :twofa_scheme + twofa_scheme_label value + else + super + end + else + super + end + end + + def user_status_label(value) + case value.to_i + when User::STATUS_ACTIVE + l(:status_active) + when User::STATUS_REGISTERED + l(:status_registered) + when User::STATUS_LOCKED + l(:status_locked) + end + end + + def twofa_scheme_label(value) + if value + ::I18n.t :"twofa__#{value}__name" + else + ::I18n.t :label_disabled + end + end +end diff --git a/app/models/query.rb b/app/models/query.rb index 1a614f1753..196568379c 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -150,7 +150,8 @@ class QueryCustomFieldColumn < QueryColumn end def value_object(object) - if custom_field.visible_by?(object.project, User.current) + project = object.project if object.respond_to?(:project) + if custom_field.visible_by?(project, User.current) cv = object.custom_values.select {|v| v.custom_field_id == @cf.id} cv.size > 1 ? cv.sort_by {|e| e.value.to_s} : cv.first else diff --git a/app/models/user_query.rb b/app/models/user_query.rb new file mode 100644 index 0000000000..1ba45d55e8 --- /dev/null +++ b/app/models/user_query.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2022 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software + +class UserQuery < Query + self.queried_class = Principal # must be Principal (not User) for custom field filters to work + + self.available_columns = [ + QueryColumn.new(:login, sortable: "#{User.table_name}.login"), + QueryColumn.new(:firstname, sortable: "#{User.table_name}.firstname"), + QueryColumn.new(:lastname, sortable: "#{User.table_name}.lastname"), + QueryColumn.new(:mail, sortable: "#{EmailAddress.table_name}.address"), + QueryColumn.new(:admin, sortable: "#{User.table_name}.admin"), + QueryColumn.new(:created_on, :sortable => "#{User.table_name}.created_on"), + QueryColumn.new(:updated_on, :sortable => "#{User.table_name}.updated_on"), + QueryColumn.new(:last_login_on, :sortable => "#{User.table_name}.last_login_on"), + QueryColumn.new(:passwd_changed_on, :sortable => "#{User.table_name}.passwd_changed_on"), + QueryColumn.new(:status, sortable: "#{User.table_name}.status") + ] + + def initialize(attributes=nil, *args) + super attributes + self.filters ||= { 'status' => {operator: "=", values: [User::STATUS_ACTIVE]} } + end + + def initialize_available_filters + add_available_filter "status", + type: :list, values: ->{ user_statuses_values } + add_available_filter "is_member_of_group", + type: :list_optional, + values: ->{ Group.givable.visible.map {|g| [g.name, g.id.to_s] } } + if Setting.twofa? + add_available_filter "twofa_scheme", + type: :list_optional, + values: ->{ Redmine::Twofa.available_schemes.map {|s| [I18n.t("twofa__#{s}__name"), s] } } + end + add_available_filter "name", type: :text + add_available_filter "created_on", type: :date_past + add_available_filter "last_login_on", type: :date_past + add_available_filter "admin", + type: :list, + values: [ [l(:general_text_yes), '1'], [l(:general_text_no), '0'] ] + add_custom_fields_filters(user_custom_fields) + end + + def user_statuses_values + [ + [l(:status_active), User::STATUS_ACTIVE.to_s], + [l(:status_registered), User::STATUS_REGISTERED.to_s], + [l(:status_locked), User::STATUS_LOCKED.to_s] + ] + end + + def available_columns + return @available_columns if @available_columns + + @available_columns = self.class.available_columns.dup + if Setting.twofa? + @available_columns << QueryColumn.new(:twofa_scheme, sortable: "#{User.table_name}.twofa_scheme") + end + @available_columns += user_custom_fields.visible. + map {|cf| QueryCustomFieldColumn.new(cf)} + + @available_columns + end + + # Returns a scope of user custom fields that are available as columns or filters + def user_custom_fields + UserCustomField.sorted + end + + + def default_columns_names + @default_columns_names ||= %i[ login firstname lastname mail admin created_on last_login_on ] + end + + def default_sort_criteria + [['login', 'asc']] + end + + def base_scope + User.logged.where(statement).includes(:email_address) + end + + def results_scope(options={}) + order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?) + + base_scope. + order(order_option). + joins(joins_for_order_statement(order_option.join(','))) + end + + def sql_for_admin_field(field, operator, value) + return unless value = value.first + true_value = operator == '=' ? '1' : '0' + val = (value.to_s == true_value) ? + self.class.connection.quoted_true : + self.class.connection.quoted_false + "(#{User.table_name}.admin = #{val})" + end + + def sql_for_is_member_of_group_field(field, operator, value) + if ["*", "!*"].include? operator + value = Group.givable.map(&:id) + end + + e = operator.start_with?("!") ? "NOT EXISTS" : "EXISTS" + + "(#{e} (SELECT 1 FROM groups_users WHERE #{User.table_name}.id = groups_users.user_id AND #{sql_for_field(field, "=", value, "groups_users", "group_id")}))" + end + + def sql_for_name_field(field, operator, value) + match = operator == '~' + name_sql = sql_contains("CONCAT(login, ' ', firstname, ' ', lastname)", + value.first, match: match) + + emails = EmailAddress.table_name + email_sql = <<-SQL + #{match ? "EXISTS" : "NOT EXISTS"} + (SELECT 1 FROM #{emails} WHERE + #{emails}.user_id = #{User.table_name}.id AND + #{sql_contains("#{emails}.address", value.first, match: true)}) + SQL + + if match + "(#{name_sql}) OR (#{email_sql})" + else + "(#{name_sql}) AND (#{email_sql})" + end + end + +end diff --git a/app/views/context_menus/users.html.erb b/app/views/context_menus/users.html.erb new file mode 100644 index 0000000000..2a0c12be1f --- /dev/null +++ b/app/views/context_menus/users.html.erb @@ -0,0 +1,24 @@ + diff --git a/app/views/users/_list.html.erb b/app/views/users/_list.html.erb new file mode 100644 index 0000000000..4c99f360a1 --- /dev/null +++ b/app/views/users/_list.html.erb @@ -0,0 +1,66 @@ +<%= form_tag({}, data: {cm_url: users_context_menu_path}) do -%> +<%= hidden_field_tag 'back_url', url_for(params: request.query_parameters), id: nil %> +
+ + + + + <% @query.inline_columns.each do |column| %> + <%= column_header(@query, column) %> + <% end %> + + + + +<% grouped_query_results(users, @query) do |user, group_name, group_count, group_totals| -%> + <% if group_name %> + <% reset_cycle %> + + + + <% end %> + hascontextmenu"> + + <% @query.inline_columns.each do |column| %> + <% if column.name == :login %> + <%= content_tag('td', link_to(user.login, edit_user_path(user)), class: column.css_classes) %> + <% else %> + <%= content_tag('td', column_content(column, user), class: column.css_classes) %> + <% end %> + <% end %> + + + <% @query.block_columns.each do |column| + if (text = column_content(column, issue)) && text.present? -%> + + + + <% end -%> + <% end -%> +<% end -%> + +
+ <%= check_box_tag 'check_all', '', false, :class => 'toggle-selection', + :title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %> +
+   + <%= group_name %> + <% if group_count %> + <%= group_count %> + <% end %> + <%= group_totals %> + <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}", + "toggleAllRowGroups(this)", :class => 'toggle-all') %> +
<%= check_box_tag("ids[]", user.id, false, id: nil) %> + <%= link_to_context_menu %> +
+ <% if query.block_columns.count > 1 %> + <%= column.caption %> + <% end %> + <%= text %> +
+
+<% end -%> + +<%= context_menu %> + diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 101b00efd7..f5c8a1cd1c 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -7,94 +7,54 @@ <% end %> -

<%=l(:label_user_plural)%>

+

<%= @query.new_record? ? l(:label_user_plural) : @query.name %>

-<%= form_tag(users_path, { :method => :get, :id => 'users_form' }) do %> -
<%= l(:label_filter_plural) %> - -<%= select_tag 'status', users_status_options_for_select(@status), :class => "small", :onchange => "this.form.submit(); return false;" %> - -<% if @groups.present? %> - -<%= select_tag 'group_id', content_tag('option') + options_from_collection_for_select(@groups, :id, :name, params[:group_id].to_i), :onchange => "this.form.submit(); return false;" %> -<% end %> - -<% if Setting.twofa_required? || Setting.twofa_optional? %> - - <%= select_tag 'twofa', options_for_select([[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], params[:twofa]), :onchange => "this.form.submit(); return false;", :include_blank => true %> +<%= form_tag(users_path, method: :get, id: 'query_form') do %> + <%= render partial: 'queries/query_form' %> <% end %> - -<%= text_field_tag 'name', params[:name], :size => 30 %> -<%= submit_tag l(:button_apply), :class => "small", :name => nil %> -<%= link_to l(:button_clear), users_path, :class => 'icon icon-reload' %> -
-<%= hidden_field_tag 'encoding', l(:general_csv_encoding) unless l(:general_csv_encoding).casecmp('UTF-8') == 0 %> -<% end %> -  - -<% if @users.any? %> -
- - - <%= sort_header_tag('login', :caption => l(:field_login)) %> - <%= sort_header_tag('firstname', :caption => l(:field_firstname)) %> - <%= sort_header_tag('lastname', :caption => l(:field_lastname)) %> - - <%= sort_header_tag('admin', :caption => l(:field_admin), :default_order => 'desc') %> - <% if Setting.twofa_required? || Setting.twofa_optional? %> - +<% if @query.valid? %> + <% if @users.empty? %> +

<%= l(:label_no_data) %>

+ <% else %> + <%= render_query_totals(@query) %> + <%= render partial: 'list', :locals => { :users => @users }%> + <%= pagination_links_full @user_pages, @user_count %> <% end %> - <%= sort_header_tag('created_on', :caption => l(:field_created_on), :default_order => 'desc') %> - <%= sort_header_tag('last_login_on', :caption => l(:field_last_login_on), :default_order => 'desc') %> - - - -<% for user in @users -%> - - - - - - - <% if Setting.twofa_required? || Setting.twofa_optional? %> - + <% other_formats_links do |f| %> + <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '350px'); return false;" %> <% end %> - - - - -<% end -%> - -
<%= l(:field_mail) %><%= l(:setting_twofa) %>
<%= avatar(user, :size => "14") %><%= link_to user.login, edit_user_path(user) %><%= user.firstname %><%= user.lastname %><%= checked_image user.admin? %><%= checked_image user.twofa_active? %><%= format_time(user.created_on) %> - <%= change_status_link(user) %> - <%= delete_link user_path(user, :back_url => request.original_fullpath), :data => {} unless User.current == user %> -
-
-<%= pagination_links_full @user_pages, @user_count %> -<% other_formats_links do |f| %> - <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %> -<% end %> - -<%= javascript_tag do %> -$(document).ready(function(){ - $('input#csv-export-button').click(function(){ - $('form input#encoding').val($('select#encoding option:selected').val()); - $('form#users_form').attr('action', "<%= users_path(:format => 'csv') %>").submit(); - $('form#users_form').attr('action', '<%= users_path %>'); - hideModal(this); - }); -}); -<% end %> -<% else %> -

<%= l(:label_no_data) %>

+ + <% end %> +<% content_for :sidebar do %> + <%= render_sidebar_queries(UserQuery, nil) %> + <%= call_hook(:view_users_sidebar_queries_bottom) %> +<% end %> <% html_title(l(:label_user_plural)) -%> diff --git a/config/locales/de.yml b/config/locales/de.yml index bda3f8dbe2..00e2c11b34 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -331,6 +331,7 @@ de: field_is_filter: Als Filter benutzen field_is_for_all: Für alle Projekte field_is_in_roadmap: In der Roadmap anzeigen + field_is_member_of_group: Mitglied in Gruppe field_is_private: Privat field_is_public: Öffentlich field_is_required: Erforderlich diff --git a/config/locales/en.yml b/config/locales/en.yml index a96c48b825..8f80d309e0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -302,6 +302,7 @@ en: field_title: Title field_project: Project field_issue: Issue + field_is_member_of_group: Member of group field_status: Status field_notes: Notes field_is_closed: Issue closed diff --git a/config/routes.rb b/config/routes.rb index 1e4b20d94f..f2106d90f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,6 +108,7 @@ Rails.application.routes.draw do match 'my/twofa/backup_codes', :controller => 'twofa_backup_codes', :action => 'show', :via => [:get] match 'users/:user_id/twofa/deactivate', :controller => 'twofa', :action => 'admin_deactivate', :via => :post + match '/users/context_menu', to: 'context_menus#users', as: :users_context_menu, via: [:get, :post] resources :users do resources :memberships, :controller => 'principal_memberships' resources :email_addresses, :only => [:index, :create, :update, :destroy] diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index e753c38730..5044b32120 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -37,33 +37,33 @@ class UsersControllerTest < Redmine::ControllerTest def test_index get :index assert_response :success + active = User.active.first + locked = User.where(status: User::STATUS_LOCKED).first assert_select 'table.users' - assert_select 'tr.user.active' - assert_select 'tr.user.locked', 0 + assert_select "tr#user-#{active.id}" + assert_select "tr#user-#{locked.id}", 0 end def test_index_with_status_filter - get :index, :params => {:status => 3} + get :index, params: { set_filter: 1, f: ['status'], op: {status: '='}, v: {status: [3]} } assert_response :success - assert_select 'tr.user.active', 0 - assert_select 'tr.user.locked' + assert_select "tr.user", User.where(status: 3).count end def test_index_with_name_filter - get :index, :params => {:name => 'john'} + get :index, params: { set_filter: 1, f: ['name'], op: {name: '~'}, v: {name: ['john']} } assert_response :success - assert_select 'tr.user td.username', :text => 'jsmith' + assert_select 'tr.user td.login', text: 'jsmith' assert_select 'tr.user', 1 end def test_index_with_group_filter - get :index, :params => {:group_id => '10'} + get :index, params: { + set_filter: 1, + f: ['is_member_of_group'], op: {is_member_of_group: '='}, v: {is_member_of_group: ['10']} + } assert_response :success - assert_select 'tr.user', Group.find(10).users.count - assert_select 'select[name=group_id]' do - assert_select 'option[value="10"][selected=selected]' - end end def test_index_should_not_show_2fa_filter_and_column_if_disabled @@ -71,8 +71,12 @@ class UsersControllerTest < Redmine::ControllerTest get :index assert_response :success - assert_select "select#twofa", 0 - assert_select 'td.twofa', 0 + assert_select "select#add_filter_select" do + assert_select "option[value=twofa_scheme]", 0 + end + assert_select "select#available_c" do + assert_select "option[value=twofa_scheme]", 0 + end end end @@ -83,13 +87,35 @@ class UsersControllerTest < Redmine::ControllerTest user.twofa_scheme = "totp" user.save - get :index, :params => {:twofa => '1'} + get :index, params: { set_filter: 1, f: ['twofa_scheme'], op: {twofa_scheme: '*'} } assert_response :success - assert_select "select#twofa", 1 - + assert_select 'tr#user-1', 1 assert_select 'tr.user', 1 - assert_select 'td.twofa.tick .icon-checked' + + assert_select "select#add_filter_select" do + assert_select "option[value=twofa_scheme]" + end + assert_select "select#available_c" do + assert_select "option[value=twofa_scheme]" + end + end + + def test_index_filter_by_twofa_scheme + get :index, params: { + set_filter: 1, + f: ['twofa_scheme'], op: {twofa_scheme: '='}, v: {twofa_scheme: ['totp']} + } + assert_response :success + + assert_select 'tr#user-1', 1 + + assert_select "select#add_filter_select" do + assert_select "option[value=twofa_scheme]" + end + assert_select "select#available_c" do + assert_select "option[value=twofa_scheme]" + end end end @@ -100,13 +126,11 @@ class UsersControllerTest < Redmine::ControllerTest user.twofa_scheme = "totp" user.save - get :index, :params => {:twofa => '0'} + get :index, params: { set_filter: 1, f: ['twofa_scheme'], op: {twofa_scheme: '!*'} } assert_response :success - assert_select "select#twofa", 1 - assert_select "td.twofa.tick" do - assert_select "span.icon-checked", 0 - end + assert_select 'tr#user-1', 0 + assert_select 'tr.user' end end @@ -114,7 +138,7 @@ class UsersControllerTest < Redmine::ControllerTest with_settings :default_language => 'en' do user = User.logged.status(1).first user.update(passwd_changed_on: Time.current.last_month, twofa_scheme: 'totp') - get :index, params: {format: 'csv'} + get :index, params: {format: 'csv', c: ['updated_on', 'status', 'passwd_changed_on', 'twofa_scheme']} assert_response :success assert_equal User.logged.status(1).count, response.body.chomp.split("\n").size - 1 @@ -142,7 +166,13 @@ class UsersControllerTest < Redmine::ControllerTest User.find(@request.session[:user_id]).update(:language => nil) with_settings :default_language => 'fr' do - get :index, :params => {:name => user.lastname, :format => 'csv'} + get :index, params: { + c: [ "cf_#{float_custom_field.id}", "cf_#{date_custom_field.id}" ], + f: ["name"], + op: { name: "~" }, + v: { name: [user.lastname] }, + format: 'csv' + } assert_response :success assert_include 'float field;date field', response.body @@ -153,7 +183,12 @@ class UsersControllerTest < Redmine::ControllerTest def test_index_csv_with_status_filter with_settings :default_language => 'en' do - get :index, :params => {:status => 3, :format => 'csv'} + get :index, :params => { + :set_filter => '1', + f: [:status], :op => { :status => '=' }, :v => { :status => [3] }, + c: [:login, :status], + :format => 'csv' + } assert_response :success assert_equal User.logged.status(3).count, response.body.chomp.split("\n").size - 1 @@ -164,7 +199,12 @@ class UsersControllerTest < Redmine::ControllerTest end def test_index_csv_with_name_filter - get :index, :params => {:name => 'John', :format => 'csv'} + get :index, :params => { + :set_filter => '1', + f: [:name], :op => { :name => '~' }, :v => { :name => ['John'] }, + c: [:login, :firstname, :status], + :format => 'csv' + } assert_response :success assert_equal User.logged.like('John').count, response.body.chomp.split("\n").size - 1 @@ -173,7 +213,12 @@ class UsersControllerTest < Redmine::ControllerTest end def test_index_csv_with_group_filter - get :index, :params => {:group_id => '10', :format => 'csv'} + get :index, :params => { + :set_filter => '1', + f: [:is_member_of_group], :op => { :is_member_of_group => '=' }, :v => { :is_member_of_group => [10] }, + c: [:login, :status], + :format => 'csv' + } assert_response :success assert_equal Group.find(10).users.count, response.body.chomp.split("\n").size - 1 -- 2.30.2