usps-imis-api 0.15.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/usps/imis/api.rb +16 -0
- data/lib/usps/imis/command_line/formatters.rb +11 -0
- data/lib/usps/imis/command_line/options.yml +16 -5
- data/lib/usps/imis/command_line/options_parser.rb +8 -3
- data/lib/usps/imis/command_line/performers.rb +25 -0
- data/lib/usps/imis/errors/ambiguous_detail_error.rb +26 -0
- data/lib/usps/imis/errors/detail_not_found_error.rb +21 -0
- data/lib/usps/imis/errors/unknown_role_error.rb +21 -0
- data/lib/usps/imis/group_membership/detail.rb +103 -0
- data/lib/usps/imis/group_membership.rb +288 -0
- data/lib/usps/imis/panels/base_panel.rb +7 -3
- data/lib/usps/imis/panels/rank.rb +50 -0
- data/lib/usps/imis/panels.rb +1 -0
- data/lib/usps/imis/version.rb +1 -1
- data/lib/usps/imis.rb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 361559cb3f665d7f221b5783652cbfd95db8d217e1bb882dc6e171cb864cb7f6
|
|
4
|
+
data.tar.gz: b7a096a763c9ae01c23d60e80aac6d2ceb11b789314dc94f9f379db775cba039
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8370f2fc2e3b9d416fc79ea96f29a03f6cc904101fda60c8bae222c134094f439c3f12b211d844290fbdf50bccfd5ced67b2a5a6daccabaedd71b2fb6e96a984
|
|
7
|
+
data.tar.gz: 33f55ebc156713e025a93ad1db4d8b1d5846af59fb56dabacd1d8730857066309e0882b6a602efb7b0ef9e3e6e30328e31350c02ec477bcbadc5800f657206d4
|
data/lib/usps/imis/api.rb
CHANGED
|
@@ -255,6 +255,22 @@ module Usps
|
|
|
255
255
|
@panels ||= Panels.all(self)
|
|
256
256
|
end
|
|
257
257
|
|
|
258
|
+
# Build a +GroupMembership+ wrapper for a group, using this instance as its parent +Api+
|
|
259
|
+
#
|
|
260
|
+
# @param group_id [String] Full group identifier, e.g. +COMMITTEE/SQUADRON525+
|
|
261
|
+
#
|
|
262
|
+
# @return [Usps::Imis::GroupMembership]
|
|
263
|
+
#
|
|
264
|
+
def group_membership(group_id) = GroupMembership.new(self, group_id:)
|
|
265
|
+
|
|
266
|
+
# Convenience for committee groups — pass the bare committee code
|
|
267
|
+
#
|
|
268
|
+
# @param code [String, Symbol] Committee code, e.g. +SQUADRON525+ or +NAV+
|
|
269
|
+
#
|
|
270
|
+
# @return [Usps::Imis::GroupMembership]
|
|
271
|
+
#
|
|
272
|
+
def committee(code) = group_membership("COMMITTEE/#{code}")
|
|
273
|
+
|
|
258
274
|
# Used by the CLI wrappers to reduce authentication requests
|
|
259
275
|
#
|
|
260
276
|
def auth_token
|
|
@@ -17,6 +17,8 @@ module Usps
|
|
|
17
17
|
|
|
18
18
|
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
19
19
|
def simplify(data)
|
|
20
|
+
return simplify_details(data) if details?(data)
|
|
21
|
+
|
|
20
22
|
data = data.to_a if data.is_a?(Query)
|
|
21
23
|
data = data.map { it.is_a?(Models::Base) ? it.data : it } if data.is_a?(Array)
|
|
22
24
|
data = data.data if data.is_a?(Models::Base)
|
|
@@ -48,6 +50,15 @@ module Usps
|
|
|
48
50
|
data
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
def details?(data)
|
|
54
|
+
data.is_a?(GroupMembership::Detail) || (data.is_a?(Array) && data.any? && data.all?(GroupMembership::Detail))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def simplify_details(data)
|
|
58
|
+
format = force_raw_output? ? :raw : :properties
|
|
59
|
+
data.is_a?(Array) ? data.map(&format) : data.public_send(format)
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def force_raw_output? = options[:raw] || RAW_HASH_RESPONSE_OPTIONS.any? { options[it] }
|
|
52
63
|
|
|
53
64
|
# :nocov:
|
|
@@ -12,6 +12,12 @@ Subcommands:
|
|
|
12
12
|
option:
|
|
13
13
|
type: string
|
|
14
14
|
short: P
|
|
15
|
+
group:
|
|
16
|
+
description: Interact with a member's positions in a Group (e.g. `%{green:COMMITTEE/NOM}`)
|
|
17
|
+
value: id
|
|
18
|
+
option:
|
|
19
|
+
type: string
|
|
20
|
+
short: G
|
|
15
21
|
query:
|
|
16
22
|
description: Run an IQA or Business Object Query
|
|
17
23
|
value: name
|
|
@@ -56,23 +62,28 @@ Options:
|
|
|
56
62
|
- type: string
|
|
57
63
|
short: u
|
|
58
64
|
|
|
59
|
-
Business Object or
|
|
65
|
+
Business Object, Panel, or Group:
|
|
60
66
|
create:
|
|
61
|
-
- Send a %{cyan:POST} request
|
|
67
|
+
- Send a %{cyan:POST} request (for a Group, add a position)
|
|
62
68
|
- short: C
|
|
63
69
|
delete:
|
|
64
|
-
- Send a %{cyan:DELETE} request
|
|
70
|
+
- Send a %{cyan:DELETE} request (for a Group, remove a position)
|
|
65
71
|
- short: D
|
|
66
72
|
all:
|
|
67
73
|
- Send a %{cyan:GET} request for all records for a member
|
|
68
74
|
- short: A
|
|
69
75
|
|
|
70
|
-
Panel:
|
|
76
|
+
Panel or Group:
|
|
71
77
|
ordinal:
|
|
72
|
-
- Ordinal ID within a Panel
|
|
78
|
+
- Ordinal ID within a Panel, or position sequence number within a Group
|
|
73
79
|
- type: integer
|
|
74
80
|
short: r
|
|
75
81
|
|
|
82
|
+
Group:
|
|
83
|
+
roles:
|
|
84
|
+
- List the Group's role (position) catalog
|
|
85
|
+
- short: K
|
|
86
|
+
|
|
76
87
|
Query:
|
|
77
88
|
page:
|
|
78
89
|
- Specific page number from a query to return
|
|
@@ -14,7 +14,7 @@ module Usps
|
|
|
14
14
|
CONFLICTING_OPTION_GROUPS = [
|
|
15
15
|
%i[certificate id uuid],
|
|
16
16
|
%i[certificate record_id uuid],
|
|
17
|
-
%i[object panel query mapper map] + SOLO_FLAGS,
|
|
17
|
+
%i[object panel group query mapper map] + SOLO_FLAGS,
|
|
18
18
|
%i[field fields map query],
|
|
19
19
|
%i[raw include_ids],
|
|
20
20
|
%i[raw jsonl csv],
|
|
@@ -29,7 +29,9 @@ module Usps
|
|
|
29
29
|
*(SOLO_FLAGS + QUERY_FLAGS + %i[mapper map field fields certificate]).map { [:create, it] },
|
|
30
30
|
*(SOLO_FLAGS + QUERY_FLAGS + %i[mapper map field fields certificate data raw]).map { [:delete, it] },
|
|
31
31
|
*(SOLO_FLAGS + QUERY_FLAGS + %i[mapper map object]).map { [:ordinal, it] },
|
|
32
|
-
*(SOLO_FLAGS + %i[certificate]).map { [:data, it] }
|
|
32
|
+
*(SOLO_FLAGS + %i[certificate]).map { [:data, it] },
|
|
33
|
+
*(SOLO_FLAGS + QUERY_FLAGS + %i[object panel mapper map create delete all ordinal data field fields])
|
|
34
|
+
.map { [:roles, it] }
|
|
33
35
|
].freeze
|
|
34
36
|
|
|
35
37
|
attr_reader :options
|
|
@@ -111,8 +113,11 @@ module Usps
|
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def pre_process_options!
|
|
116
|
+
# Empty STDIN (e.g. /dev/null under a web server or cron) is not data
|
|
117
|
+
#
|
|
114
118
|
# :nocov:
|
|
115
|
-
|
|
119
|
+
stdin_data = read_stdin if stdin?
|
|
120
|
+
@options[:data] = stdin_data unless stdin_data.to_s.strip.empty?
|
|
116
121
|
# :nocov:
|
|
117
122
|
|
|
118
123
|
@options[:data] = JSON.parse(@options[:data]) if @options[:data]
|
|
@@ -15,6 +15,7 @@ module Usps
|
|
|
15
15
|
in mapper:, **extra then perform_mapper(mapper, **extra)
|
|
16
16
|
in object:, **extra then perform_object(object, **extra)
|
|
17
17
|
in panel:, **extra then perform_panel(panel, **extra)
|
|
18
|
+
in group:, **extra then perform_group(group, **extra)
|
|
18
19
|
in query:, **extra then perform_query(query, **extra)
|
|
19
20
|
in business_objects: true then api.business_objects
|
|
20
21
|
in auth_token: true then api.auth_token
|
|
@@ -34,6 +35,7 @@ module Usps
|
|
|
34
35
|
in mapper: true then converted[:mapper] = api.mapper
|
|
35
36
|
in object: then converted[:object] = api.on(object)
|
|
36
37
|
in panel: then converted[:panel] = api.panels.public_send(panel)
|
|
38
|
+
in group: then converted[:group] = api.group_membership(group)
|
|
37
39
|
else # Nothing to convert
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -77,6 +79,29 @@ module Usps
|
|
|
77
79
|
end
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# Group positions are addressed by their sequence number, passed as +ordinal+
|
|
83
|
+
#
|
|
84
|
+
def perform_group(group, **options)
|
|
85
|
+
case options
|
|
86
|
+
in roles: true then group.roles.map(&:to_h)
|
|
87
|
+
in delete: true, ordinal: then group.remove(ordinal)
|
|
88
|
+
in create: true, data: then group.add(group_data(data, 'role'), **group_dates(data))
|
|
89
|
+
in ordinal:, data: then group.expire(ordinal, on: group_data(data, 'expires_on'))
|
|
90
|
+
in ordinal: then group.find_detail(ordinal)
|
|
91
|
+
else group.details
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def group_data(data, key)
|
|
96
|
+
data.transform_keys(&:to_s).fetch(key) do
|
|
97
|
+
raise Errors::CommandLineError, "Group data must include #{key.inspect}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def group_dates(data)
|
|
102
|
+
data.transform_keys(&:to_s).slice('effective_on', 'expires_on').transform_keys(&:to_sym)
|
|
103
|
+
end
|
|
104
|
+
|
|
80
105
|
def perform_query(query, **options)
|
|
81
106
|
kwargs = options.slice(:page_size, :offset)
|
|
82
107
|
kwargs.merge!(options[:data]) if options[:data]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Imis
|
|
5
|
+
module Errors
|
|
6
|
+
# Exception raised when multiple membership details match the given
|
|
7
|
+
# reference — retry with the detail's +sequence_number+ or full +id+
|
|
8
|
+
#
|
|
9
|
+
class AmbiguousDetailError < Error
|
|
10
|
+
attr_reader :group_id, :ref, :matches
|
|
11
|
+
|
|
12
|
+
def initialize(group_id, ref, matches)
|
|
13
|
+
@group_id = group_id
|
|
14
|
+
@ref = ref
|
|
15
|
+
@matches = matches
|
|
16
|
+
super(message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def message
|
|
20
|
+
"Multiple membership details match #{ref.inspect} for group #{group_id}: " \
|
|
21
|
+
"#{matches.map(&:id).join(', ')}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Imis
|
|
5
|
+
module Errors
|
|
6
|
+
# Exception raised when no membership detail matches the given reference
|
|
7
|
+
#
|
|
8
|
+
class DetailNotFoundError < Error
|
|
9
|
+
attr_reader :group_id, :ref
|
|
10
|
+
|
|
11
|
+
def initialize(group_id, ref)
|
|
12
|
+
@group_id = group_id
|
|
13
|
+
@ref = ref
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def message = "No membership detail matches #{ref.inspect} for group #{group_id}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Imis
|
|
5
|
+
module Errors
|
|
6
|
+
# Exception raised when assigning a role that is not in the group's role catalog
|
|
7
|
+
#
|
|
8
|
+
class UnknownRoleError < Error
|
|
9
|
+
attr_reader :group_id, :role_id
|
|
10
|
+
|
|
11
|
+
def initialize(group_id, role_id)
|
|
12
|
+
@group_id = group_id
|
|
13
|
+
@role_id = role_id
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def message = "Role #{role_id} is not in the role catalog for group #{group_id}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Imis
|
|
5
|
+
class GroupMembership
|
|
6
|
+
# Read-only view of one +MembershipDetails+ entry — a single held
|
|
7
|
+
# position (job) on a group — wrapping the raw API hash.
|
|
8
|
+
#
|
|
9
|
+
class Detail
|
|
10
|
+
# Placeholder used by iMIS for unset dates
|
|
11
|
+
#
|
|
12
|
+
UNSET_DATE = '0001-01-01T00:00:00'
|
|
13
|
+
|
|
14
|
+
# The raw API hash for this detail
|
|
15
|
+
#
|
|
16
|
+
attr_reader :raw
|
|
17
|
+
|
|
18
|
+
def initialize(raw)
|
|
19
|
+
@raw = raw
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Composite identifier, e.g. +COMMITTEE/SQUADRON525:31092:372094+
|
|
23
|
+
#
|
|
24
|
+
def id = raw['GroupMemberDetailId']
|
|
25
|
+
|
|
26
|
+
# Server-assigned integer key (the trailing element of +id+); matches
|
|
27
|
+
# the legacy +CsCommittee.SequenceNumber+
|
|
28
|
+
#
|
|
29
|
+
def sequence_number
|
|
30
|
+
return if id.nil?
|
|
31
|
+
|
|
32
|
+
id.split(':').last.to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Namespaced role identifier, e.g. +COMMITTEE:SCDR+
|
|
36
|
+
#
|
|
37
|
+
def role_id = raw.dig('Role', 'RoleId')
|
|
38
|
+
|
|
39
|
+
# Bare position code, e.g. +SCDR+
|
|
40
|
+
#
|
|
41
|
+
def role_code = role_id&.split(':')&.last
|
|
42
|
+
|
|
43
|
+
# Display title, e.g. +Squadron Commander+
|
|
44
|
+
#
|
|
45
|
+
def role_name = raw.dig('Role', 'Name')
|
|
46
|
+
|
|
47
|
+
# Term start; +nil+ when unset
|
|
48
|
+
#
|
|
49
|
+
def effective_date = parse_date(raw['EffectiveDate'])
|
|
50
|
+
|
|
51
|
+
# Term end; +nil+ while the term is open
|
|
52
|
+
#
|
|
53
|
+
def expiration_date = parse_date(raw['ExpirationDate'])
|
|
54
|
+
|
|
55
|
+
# Whether iMIS considers the position currently held (the server
|
|
56
|
+
# recalculates this from the term dates on every save)
|
|
57
|
+
#
|
|
58
|
+
def active? = !!raw['IsActive']
|
|
59
|
+
|
|
60
|
+
# Whether the term has already ended by +on+ -- unlike +active?+, a future-dated term has not
|
|
61
|
+
#
|
|
62
|
+
# @param on [Date] Date to check against (defaults to today)
|
|
63
|
+
#
|
|
64
|
+
def ended?(on = Date.today) = !expiration_date.nil? && expiration_date < on
|
|
65
|
+
|
|
66
|
+
# Whether +ref+ identifies this detail: an +Integer+ matches
|
|
67
|
+
# +sequence_number+; a +String+ / +Symbol+ matches +id+, +role_id+,
|
|
68
|
+
# or +role_code+ exactly.
|
|
69
|
+
#
|
|
70
|
+
def matches?(ref)
|
|
71
|
+
return sequence_number == ref if ref.is_a?(Integer)
|
|
72
|
+
|
|
73
|
+
[id, role_id, role_code].include?(ref.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h = raw
|
|
77
|
+
|
|
78
|
+
# Simplified data, as returned by the CLI
|
|
79
|
+
#
|
|
80
|
+
def properties
|
|
81
|
+
{
|
|
82
|
+
'id' => id,
|
|
83
|
+
'sequence_number' => sequence_number,
|
|
84
|
+
'role_id' => role_id,
|
|
85
|
+
'role_code' => role_code,
|
|
86
|
+
'role_name' => role_name,
|
|
87
|
+
'effective_date' => effective_date&.iso8601,
|
|
88
|
+
'expiration_date' => expiration_date&.iso8601,
|
|
89
|
+
'active' => active?
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def parse_date(value)
|
|
96
|
+
return if value.nil? || value.to_s.empty? || value == UNSET_DATE
|
|
97
|
+
|
|
98
|
+
Date.parse(value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'group_membership/detail'
|
|
4
|
+
|
|
5
|
+
module Usps
|
|
6
|
+
module Imis
|
|
7
|
+
# Wrapper for the +GroupMember+ SOA entity — the write path for committee
|
|
8
|
+
# membership and job (position) assignments.
|
|
9
|
+
#
|
|
10
|
+
# iMIS stores one +GroupMember+ document per (group, member) pair, holding
|
|
11
|
+
# the member's positions as a nested +MembershipDetails+ collection.
|
|
12
|
+
# Saving is a full-document POST with *replace* semantics: any detail
|
|
13
|
+
# omitted from the posted document is deleted by the server. Every
|
|
14
|
+
# mutation here therefore re-fetches the current document, modifies the
|
|
15
|
+
# complete detail list, and posts it back — never a partial list.
|
|
16
|
+
#
|
|
17
|
+
# Single-resource URLs (+api/GroupMember/COMMITTEE%2FSQUADRON525:31092+)
|
|
18
|
+
# are unusable for committee records — the colon in the composite ID trips
|
|
19
|
+
# IIS request filtering — so all requests go through the collection URL.
|
|
20
|
+
#
|
|
21
|
+
# @example Assign, expire, and remove a squadron job
|
|
22
|
+
# membership = api.with(31092).committee('SQUADRON525')
|
|
23
|
+
# membership.add('SHIST', effective_on: Date.new(2026, 7, 3))
|
|
24
|
+
# membership.expire('SHIST', on: Date.new(2027, 1, 31))
|
|
25
|
+
# membership.remove('SHIST')
|
|
26
|
+
#
|
|
27
|
+
class GroupMembership
|
|
28
|
+
include Requests
|
|
29
|
+
|
|
30
|
+
# Endpoint for GroupMember documents
|
|
31
|
+
#
|
|
32
|
+
API_PATH = 'api/GroupMember'
|
|
33
|
+
|
|
34
|
+
# Endpoint for Group definitions (role catalog, stages)
|
|
35
|
+
#
|
|
36
|
+
GROUP_API_PATH = 'api/Group'
|
|
37
|
+
|
|
38
|
+
# Type discriminators required by the iMIS JSON deserializer when
|
|
39
|
+
# constructing payloads from scratch
|
|
40
|
+
#
|
|
41
|
+
TYPES = {
|
|
42
|
+
document: 'Asi.Soa.Membership.DataContracts.GroupMemberData, Asi.Contracts',
|
|
43
|
+
collection: 'Asi.Soa.Membership.DataContracts.GroupMemberDetailDataCollection, Asi.Contracts',
|
|
44
|
+
detail: 'Asi.Soa.Membership.DataContracts.GroupMemberDetailData, Asi.Contracts',
|
|
45
|
+
stage: 'Asi.Soa.Membership.DataContracts.GroupStageData, Asi.Contracts',
|
|
46
|
+
role: 'Asi.Soa.Membership.DataContracts.GroupRoleData, Asi.Contracts',
|
|
47
|
+
group: 'Asi.Soa.Membership.DataContracts.GroupSummaryData, Asi.Contracts',
|
|
48
|
+
party: 'Asi.Soa.Membership.DataContracts.PartySummaryData, Asi.Contracts'
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# One entry from the group's role (position) catalog
|
|
52
|
+
#
|
|
53
|
+
Role = ::Data.define(:role_id, :name)
|
|
54
|
+
|
|
55
|
+
# The parent +Api+ object
|
|
56
|
+
#
|
|
57
|
+
attr_reader :api
|
|
58
|
+
|
|
59
|
+
# Full group identifier, e.g. +COMMITTEE/SQUADRON525+
|
|
60
|
+
#
|
|
61
|
+
attr_reader :group_id
|
|
62
|
+
|
|
63
|
+
# Tagged logger
|
|
64
|
+
#
|
|
65
|
+
attr_reader :logger
|
|
66
|
+
|
|
67
|
+
# @param api [Api] Parent to use for making requests
|
|
68
|
+
# @param group_id [String] Full group identifier, e.g. +COMMITTEE/SQUADRON525+
|
|
69
|
+
# @param imis_id [Integer, String] iMIS ID to select immediately
|
|
70
|
+
#
|
|
71
|
+
def initialize(api = nil, group_id:, imis_id: nil)
|
|
72
|
+
@api = api || Api.new
|
|
73
|
+
@group_id = group_id.to_s
|
|
74
|
+
@api.imis_id = imis_id if imis_id
|
|
75
|
+
@logger = Imis.logger('GroupMembership', @group_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The current member's raw +GroupMember+ document, always fetched fresh
|
|
79
|
+
#
|
|
80
|
+
# @return [Hash, nil] +nil+ when the member has no membership in the group
|
|
81
|
+
#
|
|
82
|
+
def document = get_json(document_uri).dig('Items', '$values', 0)
|
|
83
|
+
|
|
84
|
+
# The current member's held positions in the group
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Detail>]
|
|
87
|
+
#
|
|
88
|
+
def details = wrap_details(document)
|
|
89
|
+
|
|
90
|
+
# Whether the current member actively holds a matching position
|
|
91
|
+
#
|
|
92
|
+
# @param ref [Integer, String, Symbol] See +Detail#matches?+
|
|
93
|
+
#
|
|
94
|
+
def held?(ref) = details.any? { it.matches?(ref) && it.active? }
|
|
95
|
+
|
|
96
|
+
# The group's role (position) catalog
|
|
97
|
+
#
|
|
98
|
+
# @return [Array<Role>]
|
|
99
|
+
#
|
|
100
|
+
def roles
|
|
101
|
+
(group_document.dig('Roles', '$values') || []).map do |entry|
|
|
102
|
+
Role.new(role_id: entry['RoleId'], name: entry['Name'])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Assign a position to the current member
|
|
107
|
+
#
|
|
108
|
+
# The role must exist in the group's role catalog. Creates the
|
|
109
|
+
# +GroupMember+ document if the member has no membership in the group yet.
|
|
110
|
+
#
|
|
111
|
+
# Safe to retry: if the member already has a term in that role starting on the
|
|
112
|
+
# same date that hasn't ended, that detail is returned and nothing is saved.
|
|
113
|
+
#
|
|
114
|
+
# @param role [String, Symbol] Position code (+SHIST+) or full role ID (+COMMITTEE:SHIST+)
|
|
115
|
+
# @param effective_on [Date, String] Term start (defaults to today)
|
|
116
|
+
# @param expires_on [Date, String] Term end (defaults to an open term)
|
|
117
|
+
#
|
|
118
|
+
# @return [Detail] The created (or matching existing) detail, with its server-assigned key
|
|
119
|
+
#
|
|
120
|
+
def add(role, effective_on: nil, expires_on: nil)
|
|
121
|
+
entry = catalog_role(role)
|
|
122
|
+
doc = document || blank_document
|
|
123
|
+
effective_on = parse_date(effective_on || Date.today)
|
|
124
|
+
|
|
125
|
+
if (duplicate = find_duplicate(doc, entry, effective_on))
|
|
126
|
+
logger.info "#{duplicate.role_id} starting #{effective_on} already exists (#{duplicate.id})"
|
|
127
|
+
return duplicate
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
existing = detail_ids(doc)
|
|
131
|
+
doc['MembershipDetails']['$values'] << build_detail(entry, effective_on, expires_on)
|
|
132
|
+
wrap_details(save(doc)).find { existing.exclude?(it.id) }
|
|
133
|
+
end
|
|
134
|
+
alias assign add
|
|
135
|
+
|
|
136
|
+
# End the current member's term in a position
|
|
137
|
+
#
|
|
138
|
+
# @param ref [Integer, String, Symbol] See +Detail#matches?+
|
|
139
|
+
# @param on [Date, String] Term end date (defaults to today)
|
|
140
|
+
#
|
|
141
|
+
# @return [Detail] The updated detail
|
|
142
|
+
#
|
|
143
|
+
def expire(ref, on: Date.today)
|
|
144
|
+
doc = document
|
|
145
|
+
target = find_detail(ref, wrap_details(doc))
|
|
146
|
+
target.raw['ExpirationDate'] = format_date(on)
|
|
147
|
+
find_detail(target.id, wrap_details(save(doc)))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Remove a position from the current member entirely
|
|
151
|
+
#
|
|
152
|
+
# Prefer +expire+ for normal end-of-term flows — +remove+ erases the
|
|
153
|
+
# record (including from the legacy +CsCommittee+ table).
|
|
154
|
+
#
|
|
155
|
+
# @param ref [Integer, String, Symbol] See +Detail#matches?+
|
|
156
|
+
#
|
|
157
|
+
# @return [true] Only on success response
|
|
158
|
+
#
|
|
159
|
+
def remove(ref) # rubocop:disable Naming/PredicateMethod
|
|
160
|
+
doc = document
|
|
161
|
+
target = find_detail(ref, wrap_details(doc))
|
|
162
|
+
doc['MembershipDetails']['$values'].reject! { it['GroupMemberDetailId'] == target.id }
|
|
163
|
+
save(doc)
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Find exactly one detail matching +ref+
|
|
168
|
+
#
|
|
169
|
+
# When several details match (e.g. a role code the member has held before),
|
|
170
|
+
# the single one whose term hasn't ended is chosen.
|
|
171
|
+
#
|
|
172
|
+
# @param ref [Integer, String, Symbol] See +Detail#matches?+
|
|
173
|
+
# @param details [Array<Detail>] Details to search (fetched fresh by default)
|
|
174
|
+
#
|
|
175
|
+
# @return [Detail]
|
|
176
|
+
#
|
|
177
|
+
def find_detail(ref, details = self.details)
|
|
178
|
+
matches = details.select { it.matches?(ref) }
|
|
179
|
+
raise Errors::DetailNotFoundError.new(group_id, ref) if matches.empty?
|
|
180
|
+
return matches.first if matches.one?
|
|
181
|
+
|
|
182
|
+
current = matches.reject(&:ended?)
|
|
183
|
+
raise Errors::AmbiguousDetailError.new(group_id, ref, matches) unless current.one?
|
|
184
|
+
|
|
185
|
+
current.first
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Ruby 3.5 instance variable filter
|
|
189
|
+
#
|
|
190
|
+
def instance_variables_to_inspect = instance_variables - %i[@api @logger @group_document @current_uri]
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def party_id
|
|
195
|
+
raise Errors::MissingIdError if api.imis_id.nil?
|
|
196
|
+
|
|
197
|
+
api.imis_id
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The group's own definition document (role catalog, stages, class)
|
|
201
|
+
#
|
|
202
|
+
def group_document
|
|
203
|
+
@group_document ||= get_json(group_uri)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def role_prefix = group_document.dig('GroupClass', 'GroupClassId')
|
|
207
|
+
|
|
208
|
+
def role_id_for(role)
|
|
209
|
+
role = role.to_s
|
|
210
|
+
role.include?(':') ? role : "#{role_prefix}:#{role}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def catalog_role(role)
|
|
214
|
+
role_id = role_id_for(role)
|
|
215
|
+
entry = (group_document.dig('Roles', '$values') || []).find { it['RoleId'] == role_id }
|
|
216
|
+
entry || raise(Errors::UnknownRoleError.new(group_id, role_id))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def default_stage_id
|
|
220
|
+
stages = group_document.dig('AvailableStages', '$values') || []
|
|
221
|
+
stages.find { it['IsDefault'] }&.dig('Stage', 'GroupStageId') || role_prefix
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def wrap_details(doc)
|
|
225
|
+
(doc&.dig('MembershipDetails', '$values') || []).map { Detail.new(it) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def detail_ids(doc) = doc['MembershipDetails']['$values'].map { it['GroupMemberDetailId'] }
|
|
229
|
+
|
|
230
|
+
def find_duplicate(doc, role_entry, effective_on)
|
|
231
|
+
wrap_details(doc).find do |detail|
|
|
232
|
+
detail.role_id == role_entry['RoleId'] && detail.effective_date == effective_on.to_date && !detail.ended?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_detail(role_entry, effective_on, expires_on)
|
|
237
|
+
{
|
|
238
|
+
'$type' => TYPES[:detail],
|
|
239
|
+
'Stage' => { '$type' => TYPES[:stage], 'GroupStageId' => default_stage_id },
|
|
240
|
+
'EffectiveDate' => format_date(effective_on),
|
|
241
|
+
'IsActive' => true,
|
|
242
|
+
'Role' => { '$type' => TYPES[:role], 'RoleId' => role_entry['RoleId'], 'Name' => role_entry['Name'] }
|
|
243
|
+
}.tap { it['ExpirationDate'] = format_date(expires_on) if expires_on }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def blank_document
|
|
247
|
+
{
|
|
248
|
+
'$type' => TYPES[:document],
|
|
249
|
+
'GroupMemberId' => "#{group_id}:#{party_id}",
|
|
250
|
+
'Group' => { '$type' => TYPES[:group], 'GroupId' => group_id, 'Name' => group_document['Name'] },
|
|
251
|
+
'Party' => { '$type' => TYPES[:party], 'PartyId' => party_id.to_s, 'Id' => party_id.to_s },
|
|
252
|
+
'IsActive' => true,
|
|
253
|
+
'MembershipDetails' => { '$type' => TYPES[:collection], '$values' => [] }
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Accepts date strings (e.g. +2026-07-03+ from the CLI) as well as date objects
|
|
258
|
+
#
|
|
259
|
+
def parse_date(value) = value.is_a?(String) ? Date.parse(value) : value
|
|
260
|
+
|
|
261
|
+
def format_date(value) = parse_date(value).strftime('%Y-%m-%dT%H:%M:%S')
|
|
262
|
+
|
|
263
|
+
def save(doc)
|
|
264
|
+
@current_uri = URI(File.join(Imis.configuration.hostname, API_PATH))
|
|
265
|
+
request = Net::HTTP::Post.new(@current_uri)
|
|
266
|
+
request['Content-Type'] = 'application/json'
|
|
267
|
+
request.body = JSON.dump(doc)
|
|
268
|
+
JSON.parse(submit(authorize(request)).body)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def get_json(target_uri)
|
|
272
|
+
@current_uri = target_uri
|
|
273
|
+
JSON.parse(submit(authorize(Net::HTTP::Get.new(target_uri))).body)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def document_uri
|
|
277
|
+
params = { GroupId: group_id, PartyId: party_id, Limit: 1 }.to_query
|
|
278
|
+
URI(File.join(Imis.configuration.hostname, "#{API_PATH}?#{params}"))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def group_uri = URI(File.join(Imis.configuration.hostname, GROUP_API_PATH, CGI.escape(group_id)))
|
|
282
|
+
|
|
283
|
+
# Requests#submit logs the current request URI via this reader
|
|
284
|
+
#
|
|
285
|
+
def uri(...) = @current_uri
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -94,16 +94,20 @@ module Usps
|
|
|
94
94
|
#
|
|
95
95
|
# @return [Usps::Imis::Data] Response data from the API
|
|
96
96
|
#
|
|
97
|
-
def put(data)
|
|
97
|
+
def put(data)
|
|
98
|
+
data = data.transform_keys(&:to_sym)
|
|
99
|
+
api.on(business_object_name, ordinal: data[:ordinal]).put(payload(data))
|
|
100
|
+
end
|
|
98
101
|
alias update put
|
|
99
102
|
|
|
100
103
|
# Create a new object in the Panel
|
|
101
104
|
#
|
|
102
|
-
# @param data [Hash] The record data for the desired object
|
|
105
|
+
# @param data [Hash] The record data for the desired object (String keys, as parsed from
|
|
106
|
+
# JSON by the CLI, are accepted)
|
|
103
107
|
#
|
|
104
108
|
# @return [Usps::Imis::Data] Response data from the API
|
|
105
109
|
#
|
|
106
|
-
def post(data) = api.on(business_object_name).post(payload(data))
|
|
110
|
+
def post(data) = api.on(business_object_name).post(payload(data.transform_keys(&:to_sym)))
|
|
107
111
|
alias create post
|
|
108
112
|
|
|
109
113
|
# Remove a specific object from the Panel
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Imis
|
|
5
|
+
module Panels
|
|
6
|
+
# Panel for accessing the rank and job history business object
|
|
7
|
+
#
|
|
8
|
+
class Rank < BasePanel
|
|
9
|
+
# Payload keys, and the properties they set
|
|
10
|
+
#
|
|
11
|
+
PROPERTIES = {
|
|
12
|
+
job_year: 'JOB_YEAR',
|
|
13
|
+
job_code: 'JOB_CODE',
|
|
14
|
+
job_description: 'JOB_DESCRIPTION',
|
|
15
|
+
job_rank: 'JOB_RANK',
|
|
16
|
+
past_job_rank: 'PAST_JOB_RANK',
|
|
17
|
+
rank: 'Rank',
|
|
18
|
+
rank_status: 'Rank_Status',
|
|
19
|
+
rank_begin_date: 'RankBeginDate',
|
|
20
|
+
rank_end_date: 'RankEndDate'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Payload keys holding dates
|
|
24
|
+
#
|
|
25
|
+
DATES = %i[rank_begin_date rank_end_date].freeze
|
|
26
|
+
|
|
27
|
+
def business_object_name = 'ABC_ASC_RANK'
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Only the provided keys are set, since every property is optional
|
|
32
|
+
#
|
|
33
|
+
def payload(data)
|
|
34
|
+
build_payload(data) do |props|
|
|
35
|
+
PROPERTIES.each do |key, name|
|
|
36
|
+
props.add(name, property_value(key, data[key])) unless data[key].nil?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def property_value(key, value)
|
|
42
|
+
return Integer(value) if key == :job_year
|
|
43
|
+
return Date.parse(value.to_s).strftime('%Y-%m-%dT00:00:00') if DATES.include?(key)
|
|
44
|
+
|
|
45
|
+
value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/usps/imis/panels.rb
CHANGED
data/lib/usps/imis/version.rb
CHANGED
data/lib/usps/imis.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usps-imis-api
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julian Fiander
|
|
@@ -120,9 +120,11 @@ files:
|
|
|
120
120
|
- lib/usps/imis/dependencies.rb
|
|
121
121
|
- lib/usps/imis/error.rb
|
|
122
122
|
- lib/usps/imis/errors/ambiguous_certificate_error.rb
|
|
123
|
+
- lib/usps/imis/errors/ambiguous_detail_error.rb
|
|
123
124
|
- lib/usps/imis/errors/api_error.rb
|
|
124
125
|
- lib/usps/imis/errors/command_line_error.rb
|
|
125
126
|
- lib/usps/imis/errors/config_error.rb
|
|
127
|
+
- lib/usps/imis/errors/detail_not_found_error.rb
|
|
126
128
|
- lib/usps/imis/errors/embedded_model_find_error.rb
|
|
127
129
|
- lib/usps/imis/errors/empty_payload_error.rb
|
|
128
130
|
- lib/usps/imis/errors/locked_id_error.rb
|
|
@@ -134,6 +136,9 @@ files:
|
|
|
134
136
|
- lib/usps/imis/errors/pattern_matching_error.rb
|
|
135
137
|
- lib/usps/imis/errors/response_error.rb
|
|
136
138
|
- lib/usps/imis/errors/unexpected_property_type_error.rb
|
|
139
|
+
- lib/usps/imis/errors/unknown_role_error.rb
|
|
140
|
+
- lib/usps/imis/group_membership.rb
|
|
141
|
+
- lib/usps/imis/group_membership/detail.rb
|
|
137
142
|
- lib/usps/imis/helpers.rb
|
|
138
143
|
- lib/usps/imis/logger.rb
|
|
139
144
|
- lib/usps/imis/logger_formatter.rb
|
|
@@ -601,6 +606,7 @@ files:
|
|
|
601
606
|
- lib/usps/imis/panels.rb
|
|
602
607
|
- lib/usps/imis/panels/base_panel.rb
|
|
603
608
|
- lib/usps/imis/panels/education.rb
|
|
609
|
+
- lib/usps/imis/panels/rank.rb
|
|
604
610
|
- lib/usps/imis/panels/vsc.rb
|
|
605
611
|
- lib/usps/imis/party_data.rb
|
|
606
612
|
- lib/usps/imis/properties.rb
|