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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6192c91d6014b89e3cb78b0afc8b05cb0cd8f44352bdbeb6fe55ac99d02bf9d4
4
- data.tar.gz: f37891d8dabf058351c6dae8bc12f54b5c8752acdac352b52a1fc27add15b749
3
+ metadata.gz: 361559cb3f665d7f221b5783652cbfd95db8d217e1bb882dc6e171cb864cb7f6
4
+ data.tar.gz: b7a096a763c9ae01c23d60e80aac6d2ceb11b789314dc94f9f379db775cba039
5
5
  SHA512:
6
- metadata.gz: 25d2d665ce5a3e323977de8c2308fad23284ef1fa3fb93b56b68f0e649978214d3cd296e4a4792f23eb09a9992fee3412344097055088191cecc64f8ffc13c58
7
- data.tar.gz: e79ca2364a98b9394a21c2c213e35cfb956c75990a790baa99ae49f4441e652aa573bee3b4247aaa224f2b8684cec64a5f12b935ee72d41538cbfcef7430f9bd
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 Panel:
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
- @options[:data] = read_stdin if stdin?
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) = api.on(business_object_name, ordinal: data[:ordinal]).put(payload(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
@@ -3,6 +3,7 @@
3
3
  require_relative 'panels/base_panel'
4
4
  require_relative 'panels/vsc'
5
5
  require_relative 'panels/education'
6
+ require_relative 'panels/rank'
6
7
 
7
8
  module Usps
8
9
  module Imis
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module Imis
5
- VERSION = '0.15.3'
5
+ VERSION = '0.16.0'
6
6
  end
7
7
  end
data/lib/usps/imis.rb CHANGED
@@ -18,6 +18,7 @@ require_relative 'imis/properties'
18
18
  require_relative 'imis/blank_object'
19
19
  require_relative 'imis/models'
20
20
  require_relative 'imis/panels'
21
+ require_relative 'imis/group_membership'
21
22
  require_relative 'imis/mocks'
22
23
  require_relative 'imis/version'
23
24
 
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.15.3
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