diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 0c4c4b10fb6..0282b378d88 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -15,6 +15,8 @@ # Anonymous users will never return any `owned` groups. They will return all # public groups instead, even if `all_available` is set to false. class GroupsFinder < UnionFinder + include CustomAttributesFilter + def initialize(current_user = nil, params = {}) @current_user = current_user @params = params @@ -22,8 +24,12 @@ class GroupsFinder < UnionFinder def execute items = all_groups.map do |item| - by_parent(item) + item = by_parent(item) + item = by_custom_attributes(item) + + item end + find_union(items, Group).with_route.order_id_desc end diff --git a/app/models/group.rb b/app/models/group.rb index c660de7fcb6..8cf632fb566 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -26,6 +26,7 @@ class Group < Namespace has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :labels, class_name: 'GroupLabel' has_many :variables, class_name: 'Ci::GroupVariable' + has_many :custom_attributes, class_name: 'GroupCustomAttribute' validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :visibility_level_allowed_by_projects diff --git a/app/models/group_custom_attribute.rb b/app/models/group_custom_attribute.rb new file mode 100644 index 00000000000..8157d602d67 --- /dev/null +++ b/app/models/group_custom_attribute.rb @@ -0,0 +1,6 @@ +class GroupCustomAttribute < ActiveRecord::Base + belongs_to :group + + validates :group, :key, :value, presence: true + validates :key, uniqueness: { scope: [:group_id] } +end diff --git a/changelogs/unreleased/feature-custom-attributes-on-projects-and-groups.yml b/changelogs/unreleased/feature-custom-attributes-on-projects-and-groups.yml new file mode 100644 index 00000000000..9eae989a270 --- /dev/null +++ b/changelogs/unreleased/feature-custom-attributes-on-projects-and-groups.yml @@ -0,0 +1,5 @@ +--- +title: Support custom attributes on groups and projects +merge_request: 14593 +author: Markus Koller +type: changed diff --git a/db/migrate/20170918140927_create_group_custom_attributes.rb b/db/migrate/20170918140927_create_group_custom_attributes.rb new file mode 100644 index 00000000000..3879ea15eb6 --- /dev/null +++ b/db/migrate/20170918140927_create_group_custom_attributes.rb @@ -0,0 +1,19 @@ +class CreateGroupCustomAttributes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :group_custom_attributes do |t| + t.timestamps_with_timezone null: false + t.references :group, null: false + t.string :key, null: false + t.string :value, null: false + + t.index [:group_id, :key], unique: true + t.index [:key, :value] + end + + add_foreign_key :group_custom_attributes, :namespaces, column: :group_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey + end +end diff --git a/db/schema.rb b/db/schema.rb index 76fc92e2282..54127087b8b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -693,6 +693,17 @@ ActiveRecord::Schema.define(version: 20171026082505) do add_index "gpg_signatures", ["gpg_key_subkey_id"], name: "index_gpg_signatures_on_gpg_key_subkey_id", using: :btree add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree + create_table "group_custom_attributes", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "group_id", null: false + t.string "key", null: false + t.string "value", null: false + end + + add_index "group_custom_attributes", ["group_id", "key"], name: "index_group_custom_attributes_on_group_id_and_key", unique: true, using: :btree + add_index "group_custom_attributes", ["key", "value"], name: "index_group_custom_attributes_on_key_and_value", using: :btree + create_table "identities", force: :cascade do |t| t.string "extern_uid" t.string "provider" @@ -1840,6 +1851,7 @@ ActiveRecord::Schema.define(version: 20171026082505) do add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "projects", on_delete: :cascade + add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade diff --git a/doc/api/custom_attributes.md b/doc/api/custom_attributes.md index 705f1d401cf..91d1b0e1520 100644 --- a/doc/api/custom_attributes.md +++ b/doc/api/custom_attributes.md @@ -1,8 +1,9 @@ # Custom Attributes API Every API call to custom attributes must be authenticated as administrator. -Custom attributes are currently available on users and projects, which will -be referred to as "resource" in this documentation. + +Custom attributes are currently available on users, groups, and projects, +which will be referred to as "resource" in this documentation. ## List custom attributes @@ -10,6 +11,7 @@ Get all custom attributes on a resource. ``` GET /users/:id/custom_attributes +GET /groups/:id/custom_attributes GET /projects/:id/custom_attributes ``` @@ -42,6 +44,7 @@ Get a single custom attribute on a resource. ``` GET /users/:id/custom_attributes/:key +GET /groups/:id/custom_attributes/:key GET /projects/:id/custom_attributes/:key ``` @@ -70,6 +73,7 @@ or newly created otherwise. ``` PUT /users/:id/custom_attributes/:key +PUT /groups/:id/custom_attributes/:key PUT /projects/:id/custom_attributes/:key ``` @@ -98,6 +102,7 @@ Delete a custom attribute on a resource. ``` DELETE /users/:id/custom_attributes/:key +DELETE /groups/:id/custom_attributes/:key DELETE /projects/:id/custom_attributes/:key ``` diff --git a/doc/api/groups.md b/doc/api/groups.md index 99d200c9c93..16db9c2f259 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -74,6 +74,12 @@ GET /groups?statistics=true You can search for groups by name or path, see below. +You can filter by [custom attributes](custom_attributes.md) with: + +``` +GET /groups?custom_attributes[key]=value&custom_attributes[other_key]=other_value +``` + ## List a group's projects Get a list of projects in this group. When accessed without authentication, only diff --git a/lib/api/groups.rb b/lib/api/groups.rb index e817dcbbc4b..340a7cecf09 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -37,6 +37,8 @@ module API end resource :groups do + include CustomAttributesEndpoints + desc 'Get a groups list' do success Entities::Group end @@ -51,7 +53,12 @@ module API use :pagination end get do - find_params = { all_available: params[:all_available], owned: params[:owned] } + find_params = { + all_available: params[:all_available], + owned: params[:owned], + custom_attributes: params[:custom_attributes] + } + groups = GroupsFinder.new(current_user, find_params).execute groups = groups.search(params[:search]) if params[:search].present? groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? diff --git a/spec/factories/group_custom_attributes.rb b/spec/factories/group_custom_attributes.rb new file mode 100644 index 00000000000..7ff5f376e8b --- /dev/null +++ b/spec/factories/group_custom_attributes.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :group_custom_attribute do + group + sequence(:key) { |n| "key#{n}" } + sequence(:value) { |n| "value#{n}" } + end +end diff --git a/spec/models/group_custom_attribute_spec.rb b/spec/models/group_custom_attribute_spec.rb new file mode 100644 index 00000000000..7ecb2022567 --- /dev/null +++ b/spec/models/group_custom_attribute_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe GroupCustomAttribute do + describe 'assocations' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + subject { build :group_custom_attribute } + + it { is_expected.to validate_presence_of(:group) } + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_presence_of(:value) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id) } + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 0e1a7fdce0b..c8caa11b8b0 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -17,6 +17,7 @@ describe Group do it { is_expected.to have_many(:variables).class_name('Ci::GroupVariable') } it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_one(:chat_team) } + it { is_expected.to have_many(:custom_attributes).class_name('GroupCustomAttribute') } describe '#members & #requesters' do let(:requester) { create(:user) } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 8ce9fcc80bf..780dbce6488 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -618,4 +618,14 @@ describe API::Groups do end end end + + it_behaves_like 'custom attributes endpoints', 'groups' do + let(:attributable) { group1 } + let(:other_attributable) { group2 } + let(:user) { user1 } + + before do + group2.add_owner(user1) + end + end end