forest_admin_datasource_pylon 1.41.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 +7 -0
- data/.rspec +3 -0
- data/README.md +179 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_pylon.gemspec +36 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
- data/lib/forest_admin_datasource_pylon/client.rb +436 -0
- data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
- data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
- data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
- data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
- data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
- data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
- data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
- data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
- data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
- data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
- data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
- data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
- data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
- data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
- data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
- data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
- data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
- data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
- data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
- data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
- data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
- data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
- data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
- data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
- data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
- data/lib/forest_admin_datasource_pylon/version.rb +3 -0
- data/lib/forest_admin_datasource_pylon.rb +70 -0
- metadata +152 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Issue < BaseCollection
|
|
4
|
+
# The conversation of an issue, embedded as a structured array column the
|
|
5
|
+
# way the Zendesk datasource embeds a ticket's comments.
|
|
6
|
+
#
|
|
7
|
+
# Pylon has no way to read the threads of several issues at once, so the
|
|
8
|
+
# thread costs one request per row — against an endpoint allowing 20 per
|
|
9
|
+
# minute. The fan-out is therefore bounded like the primary-key lookups of
|
|
10
|
+
# this collection: truncated with a warning rather than turned into a rate
|
|
11
|
+
# limit error halfway through the page. A relation the projection does not
|
|
12
|
+
# ask for costs no request at all.
|
|
13
|
+
module MessagesEmbedder
|
|
14
|
+
include RecordSerialization
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# The thread is read only when the projection names it. A nil projection
|
|
19
|
+
# — what a count or an export goes through — asks for the record as
|
|
20
|
+
# Pylon returns it, and embeds nothing, exactly like RelationEmbedder:
|
|
21
|
+
# spending one request per row on a path that never asked for the
|
|
22
|
+
# conversation is the very fan-out MAX_MESSAGE_EMBEDS exists to bound.
|
|
23
|
+
def want_messages?(projection)
|
|
24
|
+
Array(projection).map(&:to_s).any? { |p| p == 'messages' || p.start_with?('messages:') }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A row past the cap, and a row whose thread failed to be read, are left
|
|
28
|
+
# at nil: "unknown", never the empty list, which would read as "this
|
|
29
|
+
# issue has no message" — the kind of answer that looks complete without
|
|
30
|
+
# being it.
|
|
31
|
+
def embed_messages(records, rows)
|
|
32
|
+
embedded = rows.first(MAX_MESSAGE_EMBEDS)
|
|
33
|
+
warn_truncated_threads(rows.size) if rows.size > embedded.size
|
|
34
|
+
|
|
35
|
+
embedded.each_with_index do |row, index|
|
|
36
|
+
messages = datasource.client.fetch_issue_messages(records[index]['id'])
|
|
37
|
+
row['messages'] = messages&.map { |message| serialize_message(message) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialize_message(message)
|
|
42
|
+
attrs = message.is_a?(Hash) ? message : {}
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
'id' => attrs['id'],
|
|
46
|
+
'body_html' => attrs['message_html'],
|
|
47
|
+
'is_private' => attrs['is_private'],
|
|
48
|
+
'source' => attrs['source'],
|
|
49
|
+
'thread_id' => attrs['thread_id'],
|
|
50
|
+
'file_urls' => attrs['file_urls'],
|
|
51
|
+
'created_at' => attrs['timestamp']
|
|
52
|
+
}.merge(flatten_author(attrs['author']))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Pylon nests the author's contact and user sides side by side, both
|
|
56
|
+
# optional and with nothing telling them apart: a message written by an
|
|
57
|
+
# agent carries `user`, one written by a customer carries `contact`. Both
|
|
58
|
+
# ids are kept, so a message stays traceable to the PylonContact or
|
|
59
|
+
# PylonUser record it came from, and the email is taken from whichever
|
|
60
|
+
# side is there.
|
|
61
|
+
def flatten_author(author)
|
|
62
|
+
attrs = author.is_a?(Hash) ? author : {}
|
|
63
|
+
contact = attrs['contact']
|
|
64
|
+
user = attrs['user']
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
'author_name' => attrs['name'],
|
|
68
|
+
'author_avatar_url' => attrs['avatar_url'],
|
|
69
|
+
'author_email' => nested_email(contact) || nested_email(user),
|
|
70
|
+
'author_contact_id' => nested_id(contact),
|
|
71
|
+
'author_user_id' => nested_id(user)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def nested_email(value)
|
|
76
|
+
value['email'] if value.is_a?(Hash)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def warn_truncated_threads(asked)
|
|
80
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
81
|
+
"[forest_admin_datasource_pylon] Asked for the message thread of #{asked} issues, reading the first " \
|
|
82
|
+
"#{MAX_MESSAGE_EMBEDS}: one request per issue, each carrying a whole conversation. " \
|
|
83
|
+
'Narrow the selection, or take the thread out of the projection, to reach the records past this point.'
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Issue < BaseCollection
|
|
4
|
+
# A column is writable when `POST /issues` or `PATCH /issues/{id}` accepts
|
|
5
|
+
# it, the two directions being told apart by `Issue::CREATE_ONLY` and
|
|
6
|
+
# `Issue::UPDATE_ONLY`; everything Pylon computes stays read-only. No
|
|
7
|
+
# column is sortable, `/issues/search` exposing no sort parameter at all:
|
|
8
|
+
# results always come back ordered by `created_at` descending, so
|
|
9
|
+
# advertising a sortable column would let the UI ask for an order the API
|
|
10
|
+
# cannot honour.
|
|
11
|
+
#
|
|
12
|
+
# Filter operators are not chosen here: they come from
|
|
13
|
+
# `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
|
|
14
|
+
# column missing from that table gets no operator, so the UI offers no
|
|
15
|
+
# filter of this collection's own that Pylon would refuse — the absence
|
|
16
|
+
# family the agent derives above the datasource being the exception
|
|
17
|
+
# `Query::OperatorMaps::Table` describes.
|
|
18
|
+
module SchemaDefinition
|
|
19
|
+
ColumnSchema = BaseCollection::ColumnSchema
|
|
20
|
+
ManyToOneSchema = BaseCollection::ManyToOneSchema
|
|
21
|
+
Operators = BaseCollection::Operators
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def define_schema
|
|
26
|
+
define_identity_fields
|
|
27
|
+
define_content_fields
|
|
28
|
+
define_party_fields
|
|
29
|
+
define_time_fields
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The four parties of an issue, each pointing at the collection owning its
|
|
33
|
+
# shape: the flattened `*_id` columns stay, as the keys the relation is
|
|
34
|
+
# read through and as the columns the search endpoint filters.
|
|
35
|
+
#
|
|
36
|
+
# RelationEmbedder resolves them at read time, in bulk. The reverse sides
|
|
37
|
+
# are declared by the collections they belong to; `/issues/search`
|
|
38
|
+
# filters every one of these keys, so they are answered server-side.
|
|
39
|
+
def define_relations
|
|
40
|
+
add_field('account', ManyToOneSchema.new(foreign_collection: 'PylonAccount',
|
|
41
|
+
foreign_key: 'account_id', foreign_key_target: 'id'))
|
|
42
|
+
add_field('requester', ManyToOneSchema.new(foreign_collection: 'PylonContact',
|
|
43
|
+
foreign_key: 'requester_id', foreign_key_target: 'id'))
|
|
44
|
+
add_field('assignee', ManyToOneSchema.new(foreign_collection: 'PylonUser',
|
|
45
|
+
foreign_key: 'assignee_id', foreign_key_target: 'id'))
|
|
46
|
+
add_field('team', ManyToOneSchema.new(foreign_collection: 'PylonTeam',
|
|
47
|
+
foreign_key: 'team_id', foreign_key_target: 'id'))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define_identity_fields
|
|
51
|
+
# Only equal/in: these are the two the primary-key short-circuit can
|
|
52
|
+
# actually serve through GET /issues/{id}. `id` is not part of the
|
|
53
|
+
# search allow-list, so it never reaches the translator.
|
|
54
|
+
add_field('id', ColumnSchema.new(column_type: 'String',
|
|
55
|
+
filter_operators: [Operators::EQUAL, Operators::IN],
|
|
56
|
+
is_primary_key: true, is_groupable: false, is_read_only: true))
|
|
57
|
+
add_column('number', 'Number')
|
|
58
|
+
add_column('link', 'String')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def define_content_fields
|
|
62
|
+
add_column('title', 'String', writable: true)
|
|
63
|
+
# Writable on creation only: it is the first message of the thread,
|
|
64
|
+
# which `PATCH /issues/{id}` does not carry.
|
|
65
|
+
add_column('body_html', 'String', writable: true)
|
|
66
|
+
# Left as String rather than Enum: Pylon ships five built-in states
|
|
67
|
+
# but organisations define their own on top of them. Writable on an
|
|
68
|
+
# update only — every issue is created `new`.
|
|
69
|
+
add_column('state', 'String', writable: true)
|
|
70
|
+
add_column('type', 'String', writable: true)
|
|
71
|
+
# Where the issue came from: Pylon sets it, no endpoint takes it.
|
|
72
|
+
add_column('source', 'String')
|
|
73
|
+
add_column('tags', 'Json', writable: true)
|
|
74
|
+
add_column('customer_portal_visible', 'Boolean', writable: true)
|
|
75
|
+
add_column('author_unverified', 'Boolean', writable: true)
|
|
76
|
+
add_column('number_of_touches', 'Number')
|
|
77
|
+
define_thread_field
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The conversation, embedded at read time by MessagesEmbedder. Declared
|
|
81
|
+
# by hand rather than through `add_column`: its type is the shape of one
|
|
82
|
+
# message, not a primitive.
|
|
83
|
+
#
|
|
84
|
+
# Neither filterable nor sortable — `POST /issues/search` covers no
|
|
85
|
+
# message field, and the thread is not even part of the payload the
|
|
86
|
+
# search endpoint returns — and not groupable either: `ColumnSchema`
|
|
87
|
+
# defaults that flag to true, where the `add_column` of the base passes
|
|
88
|
+
# false for every Pylon column, a thread being both an array and a value
|
|
89
|
+
# the pages of a cursor walk do not carry.
|
|
90
|
+
def define_thread_field
|
|
91
|
+
add_field('messages', ColumnSchema.new(column_type: [Issue::MESSAGE_THREAD_SCHEMA],
|
|
92
|
+
filter_operators: [], is_groupable: false,
|
|
93
|
+
is_read_only: true))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Flattened from the nested `{id: …}` objects Pylon returns, and kept as
|
|
97
|
+
# columns next to the relations they are the keys of: they are what the
|
|
98
|
+
# search endpoint filters, on this side and on the reverse one.
|
|
99
|
+
#
|
|
100
|
+
# Writable, although `GeneratorField` forces a foreign key read-only in
|
|
101
|
+
# the emitted schema whatever the datasource says, so the detail view has
|
|
102
|
+
# one editor per key rather than two. What the flag opens is that editor:
|
|
103
|
+
# the `BelongsTo` reads its own read-only state off the key column, and
|
|
104
|
+
# the front sends the choice back as the very column named here.
|
|
105
|
+
def define_party_fields
|
|
106
|
+
%w[account_id requester_id assignee_id team_id].each do |field|
|
|
107
|
+
add_column(field, 'String', writable: true)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def define_time_fields
|
|
112
|
+
%w[first_response_time resolution_time latest_message_time created_at updated_at].each do |field|
|
|
113
|
+
add_column(field, 'Date')
|
|
114
|
+
end
|
|
115
|
+
%w[time_in_status_seconds business_hours_time_in_status_seconds].each do |field|
|
|
116
|
+
add_column(field, 'Json')
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Issue < BaseCollection
|
|
4
|
+
module Serializer
|
|
5
|
+
PARTY_FIELDS = { 'account_id' => 'account', 'requester_id' => 'requester',
|
|
6
|
+
'assignee_id' => 'assignee', 'team_id' => 'team' }.freeze
|
|
7
|
+
|
|
8
|
+
NATIVE_FIELDS = %w[id number link title body_html state type source tags
|
|
9
|
+
customer_portal_visible author_unverified number_of_touches
|
|
10
|
+
first_response_time resolution_time latest_message_time
|
|
11
|
+
created_at updated_at time_in_status_seconds
|
|
12
|
+
business_hours_time_in_status_seconds].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def serialize(issue)
|
|
17
|
+
attrs = issue.is_a?(Hash) ? issue : {}
|
|
18
|
+
record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
|
|
19
|
+
PARTY_FIELDS.each { |column, source| record[column] = nested_id(attrs[source]) }
|
|
20
|
+
add_custom_field_values(record, attrs['custom_fields'])
|
|
21
|
+
record
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Issue < BaseCollection
|
|
4
|
+
include SchemaDefinition
|
|
5
|
+
include RecordSerialization
|
|
6
|
+
include Serializer
|
|
7
|
+
include RelationEmbedder
|
|
8
|
+
include MessagesEmbedder
|
|
9
|
+
include IdLookupReader
|
|
10
|
+
|
|
11
|
+
# `/issues/search` exposes no sort parameter, so the allow-list is empty and
|
|
12
|
+
# every requested order is reported instead of being silently swallowed.
|
|
13
|
+
# The mechanism stays in place for the collections whose endpoint sorts.
|
|
14
|
+
PYLON_SORTABLE = {}.freeze
|
|
15
|
+
|
|
16
|
+
# A primary-key lookup spends one `GET /issues/{id}` per id, sequentially.
|
|
17
|
+
# The fan-out is bounded like the cursor walk, and for the same reason:
|
|
18
|
+
# `RateLimiter` keeps the requests inside the 300 a minute that endpoint
|
|
19
|
+
# grants, but nothing makes two hundred round-trips fast.
|
|
20
|
+
#
|
|
21
|
+
# What it bounds is one page, not the selection behind it: the window is
|
|
22
|
+
# taken off the ids first, so a selection wider than this is read a page
|
|
23
|
+
# at a time rather than truncated at its first #{MAX_ID_LOOKUPS}. See
|
|
24
|
+
# `IdLookupReader`, and the one case that cannot be paged.
|
|
25
|
+
MAX_ID_LOOKUPS = 20
|
|
26
|
+
|
|
27
|
+
# The shape of one message inside the `messages` column. Field names follow
|
|
28
|
+
# the columns of this collection rather than the payload: Pylon spells them
|
|
29
|
+
# `message_html` and `timestamp`, which would put two conventions in the
|
|
30
|
+
# same schema for the operator to reconcile.
|
|
31
|
+
MESSAGE_THREAD_SCHEMA = {
|
|
32
|
+
'id' => 'String',
|
|
33
|
+
'body_html' => 'String',
|
|
34
|
+
'is_private' => 'Boolean',
|
|
35
|
+
'source' => 'String',
|
|
36
|
+
'thread_id' => 'String',
|
|
37
|
+
'file_urls' => 'Json',
|
|
38
|
+
'created_at' => 'Date',
|
|
39
|
+
'author_name' => 'String',
|
|
40
|
+
'author_email' => 'String',
|
|
41
|
+
'author_avatar_url' => 'String',
|
|
42
|
+
'author_contact_id' => 'String',
|
|
43
|
+
'author_user_id' => 'String'
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# One thread is one `GET /issues/{id}/messages`, and a thread is the whole
|
|
47
|
+
# conversation rather than a page of it. A list view asking for more of
|
|
48
|
+
# them than this reads the first ones and reports the rest, for the same
|
|
49
|
+
# reason MAX_ID_LOOKUPS bounds the primary-key fan-out — sequential
|
|
50
|
+
# round-trips, each carrying an unbounded payload. Lower than
|
|
51
|
+
# MAX_ID_LOOKUPS because of that payload, not because of the quota: the
|
|
52
|
+
# endpoint grants 120 requests a minute.
|
|
53
|
+
MAX_MESSAGE_EMBEDS = 10
|
|
54
|
+
|
|
55
|
+
# `body_html` is the first message of the thread, which `POST /issues`
|
|
56
|
+
# requires and `PATCH /issues/{id}` does not carry; `author_unverified`
|
|
57
|
+
# qualifies that message and travels with it.
|
|
58
|
+
CREATE_ONLY = %w[body_html author_unverified].freeze
|
|
59
|
+
|
|
60
|
+
# Pylon creates every issue as `new`, of the type it decides, and takes
|
|
61
|
+
# both on an update only.
|
|
62
|
+
UPDATE_ONLY = %w[state type].freeze
|
|
63
|
+
|
|
64
|
+
def initialize(datasource, custom_fields: [])
|
|
65
|
+
super(datasource, 'PylonIssue', custom_fields: custom_fields, searchable: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list(caller, filter, projection)
|
|
69
|
+
records = fetch_records(caller, filter)
|
|
70
|
+
rows = records.map { |record| project(record, projection) }
|
|
71
|
+
embed_relations(records, rows, projection)
|
|
72
|
+
embed_messages(records, rows) if want_messages?(projection)
|
|
73
|
+
rows
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
protected
|
|
77
|
+
|
|
78
|
+
def filter_table = ApiFilters
|
|
79
|
+
|
|
80
|
+
def create_record(payload) = datasource.client.create_issue(payload)
|
|
81
|
+
def update_record(id, payload) = datasource.client.update_issue(id, payload)
|
|
82
|
+
def delete_record(id) = datasource.client.delete_issue(id)
|
|
83
|
+
|
|
84
|
+
def create_only_fields = CREATE_ONLY
|
|
85
|
+
def update_only_fields = UPDATE_ONLY
|
|
86
|
+
|
|
87
|
+
# An issue is read through `GET /issues/{id}`, one request per record: a
|
|
88
|
+
# selection resolved or compared that way spends the write budget twice
|
|
89
|
+
# over, so it divides the records one pass reaches rather than fitting
|
|
90
|
+
# beside them.
|
|
91
|
+
def requests_per_record_read = 1
|
|
92
|
+
|
|
93
|
+
# Never past the primary-key fan-out either: a write resolving named ids
|
|
94
|
+
# through `list` reads them one request apiece, and a resolution the page
|
|
95
|
+
# cap trimmed would write to a subset of the selection while reporting the
|
|
96
|
+
# whole of it. The budget is the tighter of the two at today's numbers;
|
|
97
|
+
# the clamp keeps that true if either moves.
|
|
98
|
+
def max_resolvable_ids(reads: 0) = [super, MAX_ID_LOOKUPS].min
|
|
99
|
+
|
|
100
|
+
def sortable_fields
|
|
101
|
+
PYLON_SORTABLE
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unsortable_warning
|
|
105
|
+
'[forest_admin_datasource_pylon] PylonIssue cannot honour the requested order; ' \
|
|
106
|
+
'POST /issues/search always returns issues from the most recent to the oldest.'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def search_page(limit:, cursor:, filter:, search_text:)
|
|
110
|
+
datasource.client.search_issues(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def fetch_records(caller, filter)
|
|
116
|
+
warn_unsortable(filter&.sort)
|
|
117
|
+
|
|
118
|
+
with_resolved_relations(caller, filter) do |query|
|
|
119
|
+
lookup = extract_id_lookup(query&.condition_tree)
|
|
120
|
+
next search_records(caller, query) unless lookup
|
|
121
|
+
|
|
122
|
+
ensure_searchless_lookup!(query)
|
|
123
|
+
records_by_id(caller, lookup, query)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
# The two parts of a Pylon payload every collection reads the same way: the
|
|
4
|
+
# nested `{ id: ... }` objects it flattens into foreign-key columns, and the
|
|
5
|
+
# custom fields the organization defined, which are columns of their own.
|
|
6
|
+
module RecordSerialization
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def nested_id(value)
|
|
10
|
+
value['id'] if value.is_a?(Hash)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add_custom_field_values(record, values)
|
|
14
|
+
custom_fields.each do |cf|
|
|
15
|
+
entry = values.is_a?(Hash) ? values[cf[:column_name]] : nil
|
|
16
|
+
record[cf[:column_name]] = coerce_custom_field(custom_field_value(entry), cf[:schema].column_type)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Pylon spells a custom field as `slug => {"slug": ..., "value": ...}`,
|
|
21
|
+
# with `"values": [...]` instead of `"value"` for multi-value fields.
|
|
22
|
+
def custom_field_value(entry)
|
|
23
|
+
return entry unless entry.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
entry.key?('value') ? entry['value'] : entry['values']
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The API reference documents what a custom field *is*, never the form its
|
|
29
|
+
# value is read back in, so a Number answering `"42"` is not ruled out. A
|
|
30
|
+
# value that is not already of its column's type is therefore converted --
|
|
31
|
+
# the in-memory pass of the primary-key short-circuit would otherwise drop
|
|
32
|
+
# a row on `"42" == 42.0`, comparing a read string with the float
|
|
33
|
+
# `ConditionTreeParser.cast_to_type` casts the filter to.
|
|
34
|
+
#
|
|
35
|
+
# A date stays the string it is: the filter carries an ISO8601 string too,
|
|
36
|
+
# and comparing two of those is the ordering itself.
|
|
37
|
+
def coerce_custom_field(value, column_type)
|
|
38
|
+
return nil if value.nil?
|
|
39
|
+
|
|
40
|
+
case column_type
|
|
41
|
+
when 'Number' then coerce_number(value)
|
|
42
|
+
when 'Boolean' then coerce_boolean(value)
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A value already numeric is handed back untouched: `ConditionTreeLeaf#match`
|
|
48
|
+
# compares with `==` and `Array#include?`, both of which hold across Integer
|
|
49
|
+
# and Float, so nothing needs widening -- and widening would make an integer
|
|
50
|
+
# field display the `12.0` it does not hold, where `FilterValue#format_float`
|
|
51
|
+
# narrows the very same value on the way out. A string is read to the
|
|
52
|
+
# tightest form for that reason, the two halves agreeing on what an integer
|
|
53
|
+
# looks like.
|
|
54
|
+
#
|
|
55
|
+
# A number that cannot be read reads as absent rather than as zero.
|
|
56
|
+
#
|
|
57
|
+
# Integers are parsed as integers rather than reached through a Float: past
|
|
58
|
+
# 2**53 the intermediate loses the last digits, and `"9007199254740993"`
|
|
59
|
+
# would be both displayed and compared as ...992. Base 10 is passed
|
|
60
|
+
# explicitly -- `Integer("012")` is 10, Ruby reading a leading zero as
|
|
61
|
+
# octal, which would silently renumber every zero-padded value Pylon holds.
|
|
62
|
+
def coerce_number(value)
|
|
63
|
+
return value if value.is_a?(Numeric)
|
|
64
|
+
|
|
65
|
+
integer = Integer(value, 10, exception: false)
|
|
66
|
+
return integer unless integer.nil?
|
|
67
|
+
|
|
68
|
+
float = Float(value, exception: false)
|
|
69
|
+
return nil if float.nil?
|
|
70
|
+
|
|
71
|
+
float == float.to_i ? float.to_i : float
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# An empty boolean reads as absent as well: `false` is an answer of its own,
|
|
75
|
+
# and Pylon gave none.
|
|
76
|
+
def coerce_boolean(value)
|
|
77
|
+
return value if [true, false].include?(value)
|
|
78
|
+
return nil if value.to_s.strip.empty?
|
|
79
|
+
|
|
80
|
+
!%w[false 0 no].include?(value.to_s.strip.downcase)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
# Forest asks for a ManyToOne relation as `relation:field` entries in the
|
|
4
|
+
# projection and expects the related record nested under the relation name on
|
|
5
|
+
# every row. Pylon has no join and no include parameter, so the records are
|
|
6
|
+
# read from the foreign collection — in bulk, from the foreign keys the
|
|
7
|
+
# serialized records already carry, never one request per row.
|
|
8
|
+
#
|
|
9
|
+
# What to embed comes from the schema rather than from a list kept here: a
|
|
10
|
+
# collection embeds whatever ManyToOne relations it declares, and a relation
|
|
11
|
+
# the projection does not ask for costs no request at all.
|
|
12
|
+
module RelationEmbedder
|
|
13
|
+
ManyToOneSchema = BaseCollection::ManyToOneSchema
|
|
14
|
+
Projection = BaseCollection::Projection
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# `records` are the serialized records, carrying the foreign keys `project`
|
|
19
|
+
# strips off the rows; `rows` are the projected rows, in the same order.
|
|
20
|
+
def embed_relations(records, rows, projection)
|
|
21
|
+
# Rebuilt rather than read off the argument: `list` is also called with
|
|
22
|
+
# a plain array of field names, inside the agent and in the specs, and a
|
|
23
|
+
# relation read from one of those would go unprojected -- which is the
|
|
24
|
+
# one shape this must not have.
|
|
25
|
+
sub_projections = Projection.new(Array(projection).map(&:to_s)).relations
|
|
26
|
+
|
|
27
|
+
projected_relations(projection).group_by { |_name, relation| relation.foreign_collection }
|
|
28
|
+
.each do |foreign, group|
|
|
29
|
+
embed_foreign(foreign, group, records, rows, sub_projections)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Grouped by foreign collection, so two relations pointing at the same one
|
|
34
|
+
# are answered by a single read and their ids are deduped together.
|
|
35
|
+
#
|
|
36
|
+
# A relation the projection asked for is written on every row, whether or
|
|
37
|
+
# not it resolved: a null foreign key, and a record the operator can no
|
|
38
|
+
# longer reach, both read as "no related record" rather than as a row
|
|
39
|
+
# missing the field.
|
|
40
|
+
#
|
|
41
|
+
# The nested record is cut down to the fields the projection named, the
|
|
42
|
+
# way `project` cuts the row it sits on. The foreign collection hands back
|
|
43
|
+
# everything it holds -- Pylon has no join, so a related record is read
|
|
44
|
+
# through its own endpoint, which takes no field list -- and nesting that
|
|
45
|
+
# whole record would answer `account:name` with every column of the
|
|
46
|
+
# account, the ones the caller's permissions had the agent take out of the
|
|
47
|
+
# projection included.
|
|
48
|
+
#
|
|
49
|
+
# The primary key is added back rather than assumed: the route's
|
|
50
|
+
# projection carries it, but the nested record is what the serializer
|
|
51
|
+
# reads an `included` resource's id off, and a caller inside the agent may
|
|
52
|
+
# well have projected without it.
|
|
53
|
+
def embed_foreign(foreign_collection, relations, records, rows, sub_projections)
|
|
54
|
+
ids = foreign_ids(records, relations)
|
|
55
|
+
foreign = datasource.get_collection(foreign_collection)
|
|
56
|
+
indexed = ids.empty? ? {} : foreign.records_indexed_by_id(ids)
|
|
57
|
+
relations.each do |name, relation|
|
|
58
|
+
wanted = projected_fields(sub_projections[name], foreign)
|
|
59
|
+
rows.each_with_index do |row, index|
|
|
60
|
+
related = indexed[records[index][relation.foreign_key]]
|
|
61
|
+
row[name] = wanted ? wanted.re_project(related) : related
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The fields of the relation, plus the primary key of the collection it
|
|
67
|
+
# points at. The key is unioned in by hand rather than through
|
|
68
|
+
# `Projection#with_pks`, which also walks the relations of the projection
|
|
69
|
+
# it is given and dereferences their schema without a nil guard: a
|
|
70
|
+
# projection reaching through a relation this collection does not declare
|
|
71
|
+
# would raise there, where it used to be ignored. Only the immediate
|
|
72
|
+
# foreign key is wanted here, so only that is added.
|
|
73
|
+
def projected_fields(sub_projection, foreign)
|
|
74
|
+
return nil if sub_projection.nil?
|
|
75
|
+
|
|
76
|
+
Projection.new(sub_projection | ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(foreign))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A foreign key Pylon left empty asks for nothing — a blank one no more
|
|
80
|
+
# than a null one, and it would reach the `in` filter of the read below,
|
|
81
|
+
# which refuses a blank inside a list and would fail the whole page over
|
|
82
|
+
# one malformed key. The same id is asked for once however many rows point
|
|
83
|
+
# at it.
|
|
84
|
+
def foreign_ids(records, relations)
|
|
85
|
+
keys = relations.map { |_name, relation| relation.foreign_key }
|
|
86
|
+
records.flat_map { |record| keys.map { |key| record[key] } }
|
|
87
|
+
.reject { |id| id.nil? || id.to_s.empty? }
|
|
88
|
+
.uniq
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `account:name` asks for the `account` relation; a projected column, and a
|
|
92
|
+
# relation that is not a ManyToOne, name no field to embed.
|
|
93
|
+
def projected_relations(projection)
|
|
94
|
+
Array(projection).map { |field| field.to_s.split(':').first }.uniq.filter_map do |name|
|
|
95
|
+
relation = schema[:fields][name]
|
|
96
|
+
[name, relation] if relation.is_a?(ManyToOneSchema)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class Team < FetchAllCollection
|
|
4
|
+
def initialize(datasource)
|
|
5
|
+
super(datasource, 'PylonTeam')
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
# Pylon exposes no DELETE on a team, so that hook is left to refuse.
|
|
11
|
+
def create_record(payload) = datasource.client.create_team(payload)
|
|
12
|
+
def update_record(id, payload) = datasource.client.update_team(id, payload)
|
|
13
|
+
|
|
14
|
+
def fetch_all
|
|
15
|
+
datasource.client.fetch_teams
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# `users` is flattened to the ids of the members: the emails Pylon nests
|
|
19
|
+
# there belong to PylonUser, which is where they stay up to date.
|
|
20
|
+
def serialize(team)
|
|
21
|
+
attrs = team.is_a?(Hash) ? team : {}
|
|
22
|
+
|
|
23
|
+
{ 'id' => attrs['id'], 'name' => attrs['name'],
|
|
24
|
+
'user_ids' => Array(attrs['users']).filter_map { |user| user['id'] if user.is_a?(Hash) } }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# `/issues/search` filters `team_id` server-side, so the issues assigned to
|
|
30
|
+
# a team are listed by one request. `user_ids` gets no relation: Pylon
|
|
31
|
+
# nests the members here rather than pointing at the team from a user, so
|
|
32
|
+
# the membership is a ManyToMany with no join collection to declare it on.
|
|
33
|
+
def define_relations
|
|
34
|
+
add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
|
|
35
|
+
origin_key: 'team_id', origin_key_target: 'id'))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def define_schema
|
|
39
|
+
add_column('id', 'String', is_primary_key: true)
|
|
40
|
+
add_column('name', 'String', writable: true)
|
|
41
|
+
# A list, so neither filterable nor sortable, and no relation either:
|
|
42
|
+
# see `define_relations` above. `POST /teams` and `PATCH /teams/{id}`
|
|
43
|
+
# take the members as this very list, and the one sent replaces the
|
|
44
|
+
# membership whole.
|
|
45
|
+
add_column('user_ids', 'Json', writable: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Collections
|
|
3
|
+
class User < FetchAllCollection
|
|
4
|
+
NATIVE_FIELDS = %w[id name email emails avatar_url status role_id is_deactivated].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(datasource)
|
|
7
|
+
super(datasource, 'PylonUser')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
# Pylon exposes no POST and no DELETE on a user: an agent is invited and
|
|
13
|
+
# deactivated from Pylon itself, so the other two hooks are left to refuse.
|
|
14
|
+
def update_record(id, payload) = datasource.client.update_user(id, payload)
|
|
15
|
+
|
|
16
|
+
# `include_deactivated` is left at the client default of true on purpose:
|
|
17
|
+
# a deactivated agent stays the assignee and the author of the issues they
|
|
18
|
+
# handled, and a record the rest of the panel points at has to stay
|
|
19
|
+
# readable. `is_deactivated` is exposed as a column so an operator can
|
|
20
|
+
# filter them out when they want to.
|
|
21
|
+
def fetch_all
|
|
22
|
+
datasource.client.fetch_users
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `role` is flattened to its name only: its id is already carried by
|
|
26
|
+
# `role_id`, and the name is what an operator recognises. Its slug is left
|
|
27
|
+
# out — Pylon derives it from the name.
|
|
28
|
+
def serialize(user)
|
|
29
|
+
attrs = user.is_a?(Hash) ? user : {}
|
|
30
|
+
role = attrs['role']
|
|
31
|
+
|
|
32
|
+
NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
|
|
33
|
+
.merge('role_name' => role.is_a?(Hash) ? role['name'] : nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# `/issues/search` filters `assignee_id` server-side, so the issues of an
|
|
39
|
+
# agent are listed by one request.
|
|
40
|
+
#
|
|
41
|
+
# The teams of a user are left out: Pylon nests the members inside a team
|
|
42
|
+
# and exposes no team id on a user, so that side is a ManyToMany with no
|
|
43
|
+
# key column to build it on.
|
|
44
|
+
def define_relations
|
|
45
|
+
add_field('assigned_issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
|
|
46
|
+
origin_key: 'assignee_id', origin_key_target: 'id'))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `PATCH /users/{id}` takes the name, the avatar, the role and the status,
|
|
50
|
+
# and nothing else.
|
|
51
|
+
def define_schema
|
|
52
|
+
add_column('id', 'String', is_primary_key: true)
|
|
53
|
+
add_column('name', 'String', writable: true)
|
|
54
|
+
add_column('email', 'String')
|
|
55
|
+
# The other addresses of the same agent: a list, so it is neither
|
|
56
|
+
# filterable nor sortable. `email` carries the primary one.
|
|
57
|
+
add_column('emails', 'Json')
|
|
58
|
+
add_column('avatar_url', 'String', writable: true)
|
|
59
|
+
# Left as String rather than Enum: Pylon documents active / away /
|
|
60
|
+
# out_of_office on the update endpoint, but does not promise the read
|
|
61
|
+
# side is limited to them.
|
|
62
|
+
add_column('status', 'String', writable: true)
|
|
63
|
+
add_column('role_id', 'String', writable: true)
|
|
64
|
+
add_column('role_name', 'String')
|
|
65
|
+
add_column('is_deactivated', 'Boolean')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|