From e3eb990b9666b1ed161a865650ce55d5f34138ec Mon Sep 17 00:00:00 2001 From: Andreas Maierhofer Date: Mon, 6 Nov 2023 14:58:15 +0100 Subject: [PATCH] Add groups to json:api refs hitobito#2243 --- .pryrc | 17 ++++ CHANGELOG.md | 10 ++- app/controllers/json_api/groups_controller.rb | 26 ++++++ app/controllers/json_api/people_controller.rb | 2 + app/resources/group_resource.rb | 21 +++++ config/routes.rb | 1 + doc/development/05_json_api.md | 4 +- spec/requests/json_api/group_schema.rb | 36 ++++++++ spec/requests/json_api/groups_spec.rb | 72 +++++++++++++++ spec/requests/json_api/person_schema.rb | 7 ++ spec/resources/group/reads_spec.rb | 65 +++++++++++--- spec/resources/person/reads_spec.rb | 7 +- spec/resources/role/reads_spec.rb | 7 +- spec/spec_helper.rb | 1 + spec/support/resource_spec_helper.rb | 28 ++++++ swagger/swagger.yaml | 88 +++++++++++++------ 16 files changed, 340 insertions(+), 52 deletions(-) create mode 100644 .pryrc create mode 100644 app/controllers/json_api/groups_controller.rb create mode 100644 spec/requests/json_api/group_schema.rb create mode 100644 spec/requests/json_api/groups_spec.rb create mode 100644 spec/support/resource_spec_helper.rb diff --git a/.pryrc b/.pryrc new file mode 100644 index 0000000000..7b3870479e --- /dev/null +++ b/.pryrc @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sac_cas. + +def gr(resource, scope: nil, params: {}, as: Role.first.person) + raise "#{resource} is not a resource class" unless resource <= ApplicationResource + + context = OpenStruct.new(current_ability: Ability.new(as)) + Graphiti.with_context(context, params: params) do + action_controller_params = ActionController::Parameters.new(params) + resources = resource.all(action_controller_params, scope || resource.new.base_scope) + JSON.parse(resources.to_jsonapi).deep_symbolize_keys + end +end diff --git a/CHANGELOG.md b/CHANGELOG.md index c68a4f0259..47ab997862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Hitobito Changelog +## Unreleased + +* Eigener JSON:API Endpoint für Gruppen (hitobito#2243) + ## Version 1.30 * Der Buchungsbeleg berücksichtigt neu keine Rechnungen mit dem Status "Storniert" (hitobito_sww#136) @@ -8,7 +12,7 @@ * Der Gruppen-Tab "Einstellungen" wurde entfernt und die Optionen sind neu in der Bearbeitungsansicht der Gruppe unter dem Tab "Abos" (#2165) * Einführung von Gruppen-Attributen sowie Migration der Gruppen-Einstellungen (#2165) * Sammelrechnungen können neu gelöscht werden (#1387) -* Neu gibt es für Gruppen mit aktivierter Selbstregistrierung eine Seite, über welche sich eingeloggte Personen +* Neu gibt es für Gruppen mit aktivierter Selbstregistrierung eine Seite, über welche sich eingeloggte Personen in der Gruppe einschreiben können (#2180) * Logo kann auf Rechnungen angezeigt werden (#hitobito_sww#144) - konfigurierbar pro Layer @@ -16,7 +20,7 @@ ## Version 1.30 -* Die JSON:API liefert für Personen neu auch die Sprache (#2104) +* Die JSON:API liefert für Personen neu auch die Sprache (#2104) * Der Sicherheits-Tab einer Person kann neu die Gruppen und Rollen, welche `:show_details` Zugriff auf einem haben, auflisten. Merci @cdn64! (hitobito_pbs#257) * Auf der Personen-Listenansicht können neu via Multiselekt Personen als Abonnenten einem Abo hinzugefügt werden (#2110) @@ -63,7 +67,7 @@ * Personen mit layer_full oder layer_and_below_full können neu Personen, welche in ihren Ebenen unter "Ohne Rollen" erscheinen, per globale Suchfunktion finden und anzeigen. (hitobito_sww#80) * Anbindung an Nextcloud möglich (#1854) * Rechnungen werden neu in einem Hintergrundprozess gedruckt (#2014) -* Auf dem Buchungsbeleg sind die einzelnen Positionen nun verlinkt und führen auf eine Auflistung aller Rechnungen, welche die jeweilige Position beinhalten (hitobito_sww#69) +* Auf dem Buchungsbeleg sind die einzelnen Positionen nun verlinkt und führen auf eine Auflistung aller Rechnungen, welche die jeweilige Position beinhalten (hitobito_sww#69) ## Version 1.27 diff --git a/app/controllers/json_api/groups_controller.rb b/app/controllers/json_api/groups_controller.rb new file mode 100644 index 0000000000..443c94d4f4 --- /dev/null +++ b/app/controllers/json_api/groups_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sac_cas. + +class JsonApi::GroupsController < JsonApiController + def index + authorize!(:index, Group) + resources = resource_class.all(params, without_archived) + render(jsonapi: resources) + end + + def show + group = without_archived.find(params[:id]) + authorize!(:show, group) + super + end + + private + + def without_archived + Group.without_archived + end +end diff --git a/app/controllers/json_api/people_controller.rb b/app/controllers/json_api/people_controller.rb index 76cb076d70..7c103a5212 100644 --- a/app/controllers/json_api/people_controller.rb +++ b/app/controllers/json_api/people_controller.rb @@ -21,6 +21,8 @@ def update super end + private + def entry @entry ||= Person.find(params[:id]) end diff --git a/app/resources/group_resource.rb b/app/resources/group_resource.rb index 62dc19099f..01f5602d64 100644 --- a/app/resources/group_resource.rb +++ b/app/resources/group_resource.rb @@ -6,10 +6,13 @@ # https://github.com/hitobito/hitobito. class GroupResource < ApplicationResource + primary_endpoint 'groups', [:index, :show] + with_options writable: false do attribute :name, :string attribute :short_name, :string attribute(:display_name, :string) { @object.display_name } + attribute :description, :string attribute(:layer, :boolean) { @object.layer? } attribute :type, :string attribute :email, :string @@ -17,11 +20,29 @@ class GroupResource < ApplicationResource attribute :zip_code, :integer attribute :town, :string attribute :country, :string + + attribute :require_person_add_requests, :boolean + attribute(:self_registration_url, :string) do + context.group_self_registration_url(group_id: @object.id) + end + + attribute :archived_at, :datetime attribute :created_at, :datetime attribute :updated_at, :datetime attribute :deleted_at, :datetime + + extra_attribute :logo, :string do + next unless @object.logo.attached? + + context.rails_storage_proxy_url(@object.logo.blob) + end end + belongs_to :contact, resource: PersonResource, writable: false, foreign_key: :contact_id + belongs_to :creator, resource: PersonResource, writable: false, foreign_key: :creator_id + belongs_to :updater, resource: PersonResource, writable: false, foreign_key: :updater_id + belongs_to :deleter, resource: PersonResource, writable: false, foreign_key: :deleter_id + belongs_to :parent, resource: GroupResource, writable: false, foreign_key: :parent_id belongs_to :layer_group, resource: GroupResource, writable: false, foreign_key: :layer_group_id do assign do |_groups, _layer_groups| diff --git a/config/routes.rb b/config/routes.rb index 485c96c96e..bac5213aa6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -391,6 +391,7 @@ scope path: ApplicationResource.endpoint_namespace, module: :json_api, constraints: { format: 'jsonapi' }, defaults: { format: 'jsonapi' } do resources :people, only: [:index, :show, :update] + resources :groups, only: [:index, :show] end # The priority is based upon order of creation: diff --git a/doc/development/05_json_api.md b/doc/development/05_json_api.md index 37512c159a..62806ab57f 100644 --- a/doc/development/05_json_api.md +++ b/doc/development/05_json_api.md @@ -13,6 +13,8 @@ Currently the following endpoints are provided: | GET | /api/people/ | List all accessible people | | GET | /api/people/:id | Fetch a single person entry, replace :id with the person's primary key | | PATCH | /api/people/:id | Update a person entry, replace :id with the person's primary key | +| GET | /api/groups/ | List all accessible groups | +| GET | /api/groups/:id | Fetch a single group entry, replace :id with the groups's primary key | Visit your hitobito's swagger UI [/api-docs](/api-docs) for detailed documentation and a sandbox for testing/developing requests. @@ -280,6 +282,6 @@ Checklist for creating/extending JSON:API endpoints: #### Permissions Permissions are primarly checked in graphiti resources `app/resources`, not in controllers like -in non JSON:API controllers. For this there's specific abilities in `app/abilities/json_api`. +in non JSON:API controllers. For this there's specific abilities in `app/abilities/json_api`. We're also authorizing inside the JSON:API controllers to make sure the right HTTP status code is returned. (e.g. 403 instead of 404 if access denied) diff --git a/spec/requests/json_api/group_schema.rb b/spec/requests/json_api/group_schema.rb new file mode 100644 index 0000000000..7a24076b90 --- /dev/null +++ b/spec/requests/json_api/group_schema.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sac_cas. + +class JsonApi::GroupSchema + + def self.read + self.new.data + end + + def data + { type: :object, + properties: { + data: { + type: :object, + properties: { + id: { type: :string, description: 'ID'}, + type: { type: :string, enum: ['groups'], default: 'groups'}, + } + }, + } + } + end + + def attributes + { type: :object, + properties: { + name: { type: :string }, + description: { type: :string } + }, + description: 'Group attributes' } + end +end diff --git a/spec/requests/json_api/groups_spec.rb b/spec/requests/json_api/groups_spec.rb new file mode 100644 index 0000000000..f76a77b3c4 --- /dev/null +++ b/spec/requests/json_api/groups_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sac_cas. + +require 'swagger_helper' +# require_relative 'group_schema' + +RSpec.describe 'json_api/groups', type: :request do + let(:'X-TOKEN') { service_tokens(:permitted_top_layer_token).token } + let(:token) { service_tokens(:permitted_top_layer_token).token } + let(:include) { [] } + + path '/api/groups' do + + # add pagination + # add filter for updated_at + + get('list groups') do + parameter( + name: 'include', + in: :query, + required: false, + explode: false, + schema: { + type: :array, + enum: %w(contact creator updater deleter parent layer_group), + nullable: true + } + ) + + parameter(name: 'extra_fields', in: :query, required: false, schema: { type: :string, enum: %w(logo) }) + parameter(name: 'filter[type][eq]', in: :query, required: false, schema: { type: :string, enum: Group.all_types }) + + response(200, 'successful') do + after do |example| + example.metadata[:response][:content] = { + 'application/vnd.json+api' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + run_test! + end + + response(200, 'successful') do + let(:include) { %w(contact creator updater deleter parent layer_group) } + run_test! + end + end + end + + path '/api/groups/{id}' do + let(:id) { groups(:top_group).id } + parameter name: :id, in: :path, type: :string + + get('fetch group') do + response(200, 'successful') do + after do |example| + example.metadata[:response][:content] = { + 'application/vnd.json+api' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + run_test! + end + end + end +end diff --git a/spec/requests/json_api/person_schema.rb b/spec/requests/json_api/person_schema.rb index f1892f7e9d..b99904a580 100644 --- a/spec/requests/json_api/person_schema.rb +++ b/spec/requests/json_api/person_schema.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sac_cas. + class JsonApi::PersonSchema def self.read diff --git a/spec/resources/group/reads_spec.rb b/spec/resources/group/reads_spec.rb index 80e5a87d94..ab2113c8ee 100644 --- a/spec/resources/group/reads_spec.rb +++ b/spec/resources/group/reads_spec.rb @@ -7,24 +7,16 @@ require 'spec_helper' -RSpec.describe GroupResource, type: :resource do - let(:user) { user_role.person } - let!(:user_role) { Fabricate(Group::BottomGroup::Leader.name, person: Fabricate(:person), group: group) } - - around do |example| - RSpec::Mocks.with_temporary_scope do - Graphiti.with_context(double({ current_ability: Ability.new(user) })) { example.run } - end - end +describe GroupResource, type: :resource do describe 'serialization' do let!(:group) { groups(:bottom_group_two_one) } - def serialized_attrs [ :name, :short_name, :display_name, + :description, :type, :layer, :email, @@ -32,6 +24,9 @@ def serialized_attrs :zip_code, :town, :country, + :require_person_add_requests, + :self_registration_url, + :archived_at, :created_at, :updated_at, :deleted_at @@ -40,16 +35,24 @@ def serialized_attrs def date_time_attrs [ + :archived_at, :created_at, :updated_at, :deleted_at ] end + before do params[:filter] = { id: { eq: group.id } } end + def read_attr(attr) + return 'http://example.com/groups/944618784/self_registration' if attr =~ /self_registration_url/ + + group.public_send(attr) + end + it 'works' do render @@ -61,13 +64,40 @@ def date_time_attrs expect(data.jsonapi_type).to eq('groups') (serialized_attrs - date_time_attrs).each do |attr| - expect(data.public_send(attr)).to eq(group.public_send(attr)) + expect(data.public_send(attr)).to eq(read_attr(attr)) end date_time_attrs.each do |attr| expect(data.public_send(attr)&.to_time).to eq(group.public_send(attr)) end end + + describe 'optional logo attributes' do + before { params[:extra_fields] = { groups: 'logo' } } + + it 'includes active_storage path to logo' do + group.logo.attach( + io: File.open('spec/fixtures/person/test_picture.jpg'), + filename: 'test_picture.jpg' + ) + allow(context).to receive(:rails_storage_proxy_url).and_return('/active_storage') + render + expect(jsonapi_data[0]['logo']).to eq('/active_storage') + end + + it 'is blank if no logo is set' do + render + expect(jsonapi_data[0]['logo']).to be_blank + end + end + end + + describe 'filtering' do + it 'can filter by type' do + params[:filter] = { type: 'Group::TopGroup' } + render + expect(d).to have(1).item + end end describe 'sideloading' do @@ -103,5 +133,18 @@ def date_time_attrs expect(layer_group_data.jsonapi_type).to eq('groups') end end + + [:creator, :contact, :updater, :deleter].each do |assoc| + it "includes #{assoc} if asked to do so" do + group.update_columns("#{assoc}_id" => person.id) + expect(group.send(assoc)).to be_present + params[:include] = assoc + + render + + person_attrs = d[0].sideload(assoc) + expect(person_attrs).to be_present + end + end end end diff --git a/spec/resources/person/reads_spec.rb b/spec/resources/person/reads_spec.rb index 942257ab89..d5f0d82752 100644 --- a/spec/resources/person/reads_spec.rb +++ b/spec/resources/person/reads_spec.rb @@ -9,12 +9,7 @@ describe PersonResource, type: :resource do let(:user) { user_role.person } - - around do |example| - RSpec::Mocks.with_temporary_scope do - Graphiti.with_context(double({ current_ability: Ability.new(user) })) { example.run } - end - end + let(:ability) { Ability.new(user) } describe 'serialization' do let!(:person) { Fabricate(:person, birthday: Date.today, gender: 'm') } diff --git a/spec/resources/role/reads_spec.rb b/spec/resources/role/reads_spec.rb index c07d147a90..2637f7cb74 100644 --- a/spec/resources/role/reads_spec.rb +++ b/spec/resources/role/reads_spec.rb @@ -10,12 +10,7 @@ describe RoleResource, type: :resource do let!(:role) { roles(:bottom_member) } let(:user) { user_role.person } - - around do |example| - RSpec::Mocks.with_temporary_scope do - Graphiti.with_context(double({ current_ability: Ability.new(user) })) { example.run } - end - end + let(:ability) { Ability.new(user) } before do allow(Graphiti.context[:object]).to receive(:can?).and_return(true) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 432ec64573..bf93fd5d5b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -176,6 +176,7 @@ config.include GraphitiSpecHelpers::RSpec config.include GraphitiSpecHelpers::Sugar config.include Graphiti::Rails::TestHelpers + config.include ResourceSpecHelper, type: :resource config.before :each do handle_request_exceptions(false) diff --git a/spec/support/resource_spec_helper.rb b/spec/support/resource_spec_helper.rb new file mode 100644 index 0000000000..8330a7d2a2 --- /dev/null +++ b/spec/support/resource_spec_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright (c) 2023, Schweizer Wanderwege. This file is part of +# hitobito_sww and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_sww. +# +module ResourceSpecHelper + extend ActiveSupport::Concern + + included do + let(:ability) { Ability.new(person) } + let(:person) { people(:top_leader) } + let(:url_options) { { host: 'example.com' } } + + let(:context) do + double(current_ability: ability, url_options: url_options).tap do |context| + context.extend(Rails.application.routes.url_helpers) + end + end + + around do |example| + RSpec::Mocks.with_temporary_scope do + Graphiti.with_context(context) { example.run } + end + end + end +end diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index dacc4603a9..d17c209868 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -4,6 +4,58 @@ info: title: JSON:API version: v1 paths: + "/api/groups": + get: + summary: list groups + parameters: + - name: include + in: query + required: false + explode: false + schema: + type: array + enum: + - contact + - creator + - updater + - deleter + - parent + - layer_group + nullable: true + - name: extra_fields + in: query + required: false + schema: + type: string + enum: + - logo + - name: filter[type][eq] + in: query + required: false + schema: + type: string + enum: + - Group::TopLayer + - Group::TopGroup + - Group::BottomLayer + - Group::BottomGroup + - Group::MountedAttrsGroup + - Group::GlobalGroup + responses: + '200': + description: successful + "/api/groups/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + summary: fetch group + responses: + '200': + description: successful "/api/people": get: summary: list people @@ -97,29 +149,10 @@ paths: last_name: type: string description: Person attributes - relationships: - type: object - properties: - phone_numbers: - type: object - properties: - data: - type: array - items: - type: object - properties: - type: - type: string - enum: - - phone_numbers - default: phone_numbers - id: - type: string - method: - type: string - enum: - - update - default: update + extra_fields: + type: array + items: + type: string included: type: array items: @@ -128,8 +161,13 @@ paths: type: type: string enum: - - phone_numbers - default: phone_numbers + - contact + - creator + - updater + - deleter + - parent + - layer_group + default: contact id: type: string attributes: