rails_ai_admin 0.1.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/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Outbox
|
|
5
|
+
class DeliveryContext
|
|
6
|
+
attr_reader :record, :generation
|
|
7
|
+
|
|
8
|
+
def initialize(item, context, resource, record, authorization:)
|
|
9
|
+
@item, @context, @resource, @record, @authorization = item, context, resource, record, authorization
|
|
10
|
+
@generation, @used, @completed = item.generation, false, false
|
|
11
|
+
@effect_mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
%i[actor tenant actor_ref tenant_ref security_version time_zone].each do |method|
|
|
15
|
+
define_method(method) { @context.public_send(method) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delivery_key = "raa:work:#{@item.id}"
|
|
19
|
+
def effect_completed? = @completed
|
|
20
|
+
|
|
21
|
+
def assert_current!
|
|
22
|
+
current = WorkItem.where(id: @item.id, status: "running", lease_owner: @item.lease_owner, generation: generation)
|
|
23
|
+
.where("lease_expires_at > ?", Time.current).exists?
|
|
24
|
+
raise Dispatcher::LeaseLost unless current
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def effect(request:)
|
|
29
|
+
@effect_mutex.synchronize do
|
|
30
|
+
raise ConfigurationError, "Each Work item permits exactly one effect" if @used
|
|
31
|
+
@used = true
|
|
32
|
+
end
|
|
33
|
+
raise ConfigurationError, "External effects cannot run in a database transaction" if WorkItem.connection.transaction_open?
|
|
34
|
+
digest = RequestDigest.call(request)
|
|
35
|
+
@context, @resource, @record = @authorization.call
|
|
36
|
+
owned = WorkItem.where(id: @item.id, status: "running", lease_owner: @item.lease_owner, generation: generation)
|
|
37
|
+
.where("lease_expires_at > ?", Time.current)
|
|
38
|
+
previous = owned.pick(:effect_digest)
|
|
39
|
+
raise Dispatcher::Blocked, "REQUEST_CHANGED" if previous && previous != digest
|
|
40
|
+
changed = ActiveRecord::Base.while_preventing_writes(false) do
|
|
41
|
+
owned.where("(effect_generation IS NULL OR effect_generation < ?) AND (effect_digest IS NULL OR effect_digest = ?)", generation, digest)
|
|
42
|
+
.update_all(effect_started_at: Time.current, effect_digest: digest, effect_generation: generation, updated_at: Time.current)
|
|
43
|
+
end
|
|
44
|
+
raise Dispatcher::LeaseLost if changed.zero?
|
|
45
|
+
result = yield self
|
|
46
|
+
@completed = true
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Outbox
|
|
5
|
+
# Fencing protects database receipts. Replaying external requests still needs
|
|
6
|
+
# the downstream system to honor the stable delivery key.
|
|
7
|
+
class Dispatcher
|
|
8
|
+
Result = Data.define(:work_ref, :status, :code) do
|
|
9
|
+
def success? = status == "succeeded"
|
|
10
|
+
end
|
|
11
|
+
class Blocked < StandardError
|
|
12
|
+
attr_reader :code
|
|
13
|
+
def initialize(code)
|
|
14
|
+
@code = code
|
|
15
|
+
super(code)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
class LeaseLost < StandardError; end
|
|
19
|
+
|
|
20
|
+
def run(work_ref)
|
|
21
|
+
raise ConfigurationError, "Outbox delivery cannot run inside a database transaction" if WorkItem.connection.transaction_open?
|
|
22
|
+
item = claim(work_ref)
|
|
23
|
+
return item if item.is_a?(Result)
|
|
24
|
+
return Result.new(work_ref, "missing", "NOT_FOUND") unless item
|
|
25
|
+
definition = RailsAiAdmin.work_registry.fetch(item.kind)
|
|
26
|
+
authorization = -> { authorize(item) }
|
|
27
|
+
context, resource, record = ActiveRecord::Base.while_preventing_writes { authorization.call }
|
|
28
|
+
values = InputSchema.new(definition.handler_class.input).coerce(item.payload, time_zone: context.time_zone)
|
|
29
|
+
raise Blocked, "PAYLOAD_INVALID" unless values.valid? && InputSchema.digest(values.values) == item.payload_digest
|
|
30
|
+
delivery = DeliveryContext.new(item, context, resource, record, authorization: authorization)
|
|
31
|
+
ActiveRecord::Base.while_preventing_writes { definition.handler_class.new.perform(delivery, values.values) }
|
|
32
|
+
raise ConfigurationError, "Work must complete exactly one ctx.effect" unless delivery.effect_completed?
|
|
33
|
+
finish(item, "succeeded", "OK")
|
|
34
|
+
rescue AccessDenied
|
|
35
|
+
delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, "AUTHORIZATION_REVOKED", denied: true)
|
|
36
|
+
rescue Blocked => error
|
|
37
|
+
delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, error.code, reconcile: true)
|
|
38
|
+
rescue LeaseLost
|
|
39
|
+
fence_lost(item)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
raise unless item.is_a?(WorkItem)
|
|
42
|
+
delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, "DELIVERY_FAILED")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scan_pending!(limit: 100, inline: false)
|
|
46
|
+
raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
|
|
47
|
+
now = Time.current
|
|
48
|
+
due = WorkItem.where(status: "pending").where("next_attempt_at IS NULL OR next_attempt_at <= ? OR deadline_at <= ?", now, now)
|
|
49
|
+
stale = WorkItem.where(status: "running").where("lease_expires_at IS NULL OR lease_expires_at <= ?", now)
|
|
50
|
+
ids = due.or(stale).order(:created_at, :id).limit(limit).pluck(:id)
|
|
51
|
+
ids.count do |id|
|
|
52
|
+
if inline
|
|
53
|
+
run(id)
|
|
54
|
+
true
|
|
55
|
+
else
|
|
56
|
+
!!DeliveryJob.perform_later(id)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def claim(work_ref)
|
|
64
|
+
WorkItem.transaction do
|
|
65
|
+
item = WorkItem.lock.find_by(id: work_ref)
|
|
66
|
+
next unless item
|
|
67
|
+
now = Time.current
|
|
68
|
+
unless %w[pending running].include?(item.status)
|
|
69
|
+
next Result.new(item.id, item.status, item.last_code || "TERMINAL")
|
|
70
|
+
end
|
|
71
|
+
if item.status == "running" && item.lease_expires_at && item.lease_expires_at > now
|
|
72
|
+
next Result.new(item.id, item.status, "IN_PROGRESS")
|
|
73
|
+
end
|
|
74
|
+
if item.deadline_at <= now
|
|
75
|
+
terminal = item.effect_started_at ? "reconciliation_required" : "failed"
|
|
76
|
+
item.update!(status: terminal, last_code: "WORK_EXPIRED", completed_at: now)
|
|
77
|
+
next Result.new(item.id, terminal, "WORK_EXPIRED")
|
|
78
|
+
end
|
|
79
|
+
if item.status == "pending" && item.next_attempt_at && item.next_attempt_at > now
|
|
80
|
+
next Result.new(item.id, item.status, "BACKOFF")
|
|
81
|
+
end
|
|
82
|
+
begin
|
|
83
|
+
definition = RailsAiAdmin.work_registry.fetch(item.kind)
|
|
84
|
+
valid = item.handler_version == definition.fingerprint
|
|
85
|
+
rescue ConfigurationError, NameError
|
|
86
|
+
valid = false
|
|
87
|
+
end
|
|
88
|
+
unless valid
|
|
89
|
+
item.update!(status: "reconciliation_required", last_code: "HANDLER_CHANGED", completed_at: now)
|
|
90
|
+
next Result.new(item.id, item.status, item.last_code)
|
|
91
|
+
end
|
|
92
|
+
if item.effect_started_at && !definition.handler_class.idempotent
|
|
93
|
+
item.update!(status: "reconciliation_required", last_code: "OUTCOME_UNKNOWN", completed_at: now)
|
|
94
|
+
next Result.new(item.id, item.status, item.last_code)
|
|
95
|
+
end
|
|
96
|
+
if item.attempts >= definition.handler_class.max_attempts
|
|
97
|
+
item.update!(status: item.effect_started_at ? "reconciliation_required" : "failed", last_code: "ATTEMPTS_EXHAUSTED", completed_at: now)
|
|
98
|
+
next Result.new(item.id, item.status, item.last_code)
|
|
99
|
+
end
|
|
100
|
+
item.update!(status: "running", lease_owner: SecureRandom.uuid, generation: item.generation + 1,
|
|
101
|
+
attempts: item.attempts + 1, lease_expires_at: now + definition.handler_class.lease_duration,
|
|
102
|
+
next_attempt_at: nil, last_code: nil)
|
|
103
|
+
item
|
|
104
|
+
end
|
|
105
|
+
rescue ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout
|
|
106
|
+
Result.new(work_ref, "pending", "CONFLICT")
|
|
107
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
108
|
+
raise unless defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
|
|
109
|
+
Result.new(work_ref, "pending", "CONFLICT")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def authorize(item)
|
|
113
|
+
begin
|
|
114
|
+
work = RailsAiAdmin.work_registry.fetch(item.kind)
|
|
115
|
+
valid = work.fingerprint == item.handler_version
|
|
116
|
+
rescue ConfigurationError, NameError
|
|
117
|
+
valid = false
|
|
118
|
+
end
|
|
119
|
+
raise Blocked, "HANDLER_CHANGED" unless valid
|
|
120
|
+
run = OperationRun.find(item.operation_run_id)
|
|
121
|
+
raise Blocked, "OPERATION_NOT_COMMITTED" unless run.status == "succeeded"
|
|
122
|
+
resolver = RailsAiAdmin.configuration.context_adapter&.constantize&.new
|
|
123
|
+
raise Blocked, "JOB_CONTEXT_NOT_CONFIGURED" unless resolver&.respond_to?(:resolve_job)
|
|
124
|
+
context = resolver.resolve_job(actor_ref: run.actor_ref, tenant_ref: run.tenant_ref, security_version: run.security_version)
|
|
125
|
+
unless context.is_a?(Context) && context.actor_ref == run.actor_ref && context.tenant_ref == run.tenant_ref && context.security_version == run.security_version
|
|
126
|
+
raise AccessDenied
|
|
127
|
+
end
|
|
128
|
+
resource = RailsAiAdmin.registry.fetch(run.resource_key)
|
|
129
|
+
unless resource.model_class.connection_pool.equal?(WorkItem.connection_pool) && OperationRun.connection_pool.equal?(WorkItem.connection_pool)
|
|
130
|
+
raise Blocked, "CONNECTION_CHANGED"
|
|
131
|
+
end
|
|
132
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
|
|
133
|
+
raise Blocked, "OPERATION_CHANGED" unless run.operation_version == Operations::RunnerFingerprint.call(resource, definition)
|
|
134
|
+
access = Authorization::Access.new(context, resource)
|
|
135
|
+
record = access.relation.find_by(resource.model_class.primary_key => run.record_id)
|
|
136
|
+
raise AccessDenied unless record
|
|
137
|
+
access.authorize_delivery!(run.operation, item.kind, record)
|
|
138
|
+
policy = RailsAiAdmin.work_registry.fetch(item.kind).handler_class.record_version_policy
|
|
139
|
+
raise Blocked, "RECORD_CHANGED" if policy == :exact && item.expected_version != RecordVersion.of(record)
|
|
140
|
+
# The admission TTL has been consumed. Delivery has its own deadline.
|
|
141
|
+
raise Blocked, "WORK_EXPIRED" unless item.deadline_at > Time.current
|
|
142
|
+
[ context, resource, record ]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def owned(item)
|
|
146
|
+
WorkItem.where(id: item.id, status: "running", lease_owner: item.lease_owner, generation: item.generation)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def finish(item, status, code)
|
|
150
|
+
changed = owned(item).where("lease_expires_at > ?", Time.current).update_all(status: status, last_code: code,
|
|
151
|
+
lease_owner: nil, lease_expires_at: nil, completed_at: Time.current, updated_at: Time.current)
|
|
152
|
+
changed.zero? ? fence_lost(item) : Result.new(item.id, status, code)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def fail_delivery(item, code, denied: false, reconcile: false)
|
|
156
|
+
return Result.new(nil, "missing", "NOT_FOUND") unless item.is_a?(WorkItem)
|
|
157
|
+
current = WorkItem.find(item.id)
|
|
158
|
+
unknown = current.effect_started_at.present?
|
|
159
|
+
definition = RailsAiAdmin.work_registry.fetch(item.kind)
|
|
160
|
+
terminal = if reconcile || (unknown && (denied || !definition.handler_class.idempotent))
|
|
161
|
+
"reconciliation_required"
|
|
162
|
+
elsif denied
|
|
163
|
+
"cancelled"
|
|
164
|
+
elsif current.attempts >= definition.handler_class.max_attempts
|
|
165
|
+
unknown ? "reconciliation_required" : "failed"
|
|
166
|
+
else
|
|
167
|
+
"pending"
|
|
168
|
+
end
|
|
169
|
+
if terminal == "pending"
|
|
170
|
+
changed = owned(item).where("lease_expires_at > ?", Time.current).update_all(status: terminal, last_code: code,
|
|
171
|
+
next_attempt_at: Time.current + [ 2**current.attempts, 300 ].min, lease_owner: nil, lease_expires_at: nil, updated_at: Time.current)
|
|
172
|
+
changed.zero? ? fence_lost(item) : Result.new(item.id, terminal, code)
|
|
173
|
+
else
|
|
174
|
+
finish(item, terminal, code)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def fence_lost(item)
|
|
179
|
+
return Result.new(nil, "missing", "NOT_FOUND") unless item.is_a?(WorkItem)
|
|
180
|
+
# Only this expired generation may become uncertain. Never overwrite a
|
|
181
|
+
# newer worker's pending/success state with a stale response.
|
|
182
|
+
owned(item).where("lease_expires_at <= ?", Time.current).where.not(effect_started_at: nil).update_all(
|
|
183
|
+
status: "reconciliation_required", last_code: "OUTCOME_UNKNOWN", completed_at: Time.current, updated_at: Time.current)
|
|
184
|
+
current = WorkItem.find_by(id: item.id)
|
|
185
|
+
Result.new(item.id, current&.status || "missing", "FENCED")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Outbox
|
|
5
|
+
class Registry
|
|
6
|
+
Definition = Data.define(:kind, :handler_name) do
|
|
7
|
+
def handler_class
|
|
8
|
+
klass = handler_name.constantize
|
|
9
|
+
raise ConfigurationError, "Work handler must inherit RailsAiAdmin::Work" unless klass.is_a?(Class) && klass < Work
|
|
10
|
+
klass
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fingerprint
|
|
14
|
+
InputSchema.digest(handler: handler_name, version: handler_class.version, fields: handler_class.input.map(&:contract),
|
|
15
|
+
idempotent: handler_class.idempotent, lease: handler_class.lease_duration, attempts: handler_class.max_attempts,
|
|
16
|
+
max_age: handler_class.max_age, record_version: handler_class.record_version_policy)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(handlers)
|
|
21
|
+
@entries = handlers.to_h do |kind, name|
|
|
22
|
+
definition = Definition.new(kind.to_s, name)
|
|
23
|
+
definition.handler_class.validate!
|
|
24
|
+
[ kind.to_s, definition ]
|
|
25
|
+
end.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch(kind)
|
|
30
|
+
@entries.fetch(kind.to_s) { raise ConfigurationError, "Unregistered outbox work kind" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def definitions = @entries.values
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Outbox
|
|
5
|
+
# Unlike typed input digests, request fingerprints have no type-marker domain.
|
|
6
|
+
# Accept only plain JSON data and reject keys that collide after normalization.
|
|
7
|
+
module RequestDigest
|
|
8
|
+
def self.call(request)
|
|
9
|
+
raise ConfigurationError, "Effect request must be an object" unless request.is_a?(Hash)
|
|
10
|
+
body = JSON.generate(normalize(request))
|
|
11
|
+
raise ConfigurationError, "Effect request exceeds 16384 bytes" if body.bytesize > 16_384
|
|
12
|
+
Digest::SHA256.hexdigest(body)
|
|
13
|
+
rescue JSON::GeneratorError
|
|
14
|
+
raise ConfigurationError, "Effect request must contain valid JSON data", cause: nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.normalize(value, depth = 0)
|
|
18
|
+
raise ConfigurationError, "Effect request nesting is too deep" if depth > 8
|
|
19
|
+
case value
|
|
20
|
+
when Hash
|
|
21
|
+
normalized = {}
|
|
22
|
+
value.each do |key, entry|
|
|
23
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
24
|
+
raise ConfigurationError, "Effect request keys must be strings or symbols"
|
|
25
|
+
end
|
|
26
|
+
name = key.to_s
|
|
27
|
+
raise ConfigurationError, "Effect request has duplicate normalized keys" if normalized.key?(name)
|
|
28
|
+
normalized[name] = normalize(entry, depth + 1)
|
|
29
|
+
end
|
|
30
|
+
normalized.sort.to_h
|
|
31
|
+
when Array then value.map { |entry| normalize(entry, depth + 1) }
|
|
32
|
+
when String, Integer, TrueClass, FalseClass, NilClass then value
|
|
33
|
+
when Float
|
|
34
|
+
raise ConfigurationError, "Effect request numbers must be finite" unless value.finite?
|
|
35
|
+
value
|
|
36
|
+
else
|
|
37
|
+
raise ConfigurationError, "Serialize typed values to plain JSON scalars before effect admission"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Outbox
|
|
5
|
+
# Persists intent in Runner's transaction; never contacts a queue/provider.
|
|
6
|
+
class Writer
|
|
7
|
+
def initialize(context, run, resource)
|
|
8
|
+
@context, @run, @resource = context, run, resource
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def enqueue(kind, key: kind, input: nil, **attributes)
|
|
12
|
+
unless OperationRun.connection.transaction_open? && @run.status == "running" && @run.actor_ref == @context.actor_ref && @run.tenant_ref == @context.tenant_ref
|
|
13
|
+
raise ConfigurationError, "Outbox enqueue requires the active operation transaction"
|
|
14
|
+
end
|
|
15
|
+
raise ConfigurationError, "Work key must be a bounded string" unless key.is_a?(String) && key.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,99}\z/)
|
|
16
|
+
raise ConfigurationError, "Use input or keyword attributes, not both" if input && attributes.any?
|
|
17
|
+
definition = RailsAiAdmin.work_registry.fetch(kind)
|
|
18
|
+
result = InputSchema.new(definition.handler_class.input).coerce(input || attributes, time_zone: @context.time_zone)
|
|
19
|
+
raise ConfigurationError, "Invalid outbox payload" unless result.valid?
|
|
20
|
+
payload = result.values.transform_values do |value|
|
|
21
|
+
case value
|
|
22
|
+
when BigDecimal then value.to_s("F")
|
|
23
|
+
when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
|
|
24
|
+
when Date then value.iso8601
|
|
25
|
+
else value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
raise ConfigurationError, "Outbox payload exceeds 4096 bytes" if JSON.generate(payload).bytesize > 4096
|
|
29
|
+
digest = InputSchema.digest(result.values)
|
|
30
|
+
existing = WorkItem.find_by(operation_run_id: @run.id, work_key: key)
|
|
31
|
+
if existing
|
|
32
|
+
unless existing.kind == kind.to_s && existing.payload_digest == digest && existing.handler_version == definition.fingerprint
|
|
33
|
+
raise AccessDenied, "IDEMPOTENCY_CONFLICT"
|
|
34
|
+
end
|
|
35
|
+
return existing.id
|
|
36
|
+
end
|
|
37
|
+
WorkItem.create!(id: SecureRandom.uuid, operation_run_id: @run.id, work_key: key,
|
|
38
|
+
kind: kind.to_s, payload: payload, payload_digest: digest, handler_version: definition.fingerprint,
|
|
39
|
+
deadline_at: Time.current + definition.handler_class.max_age).id
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Presentation
|
|
5
|
+
# Builds readonly has_many panels for one parent record. Children come only
|
|
6
|
+
# from the child resource's current policy relation, narrowed by the child's
|
|
7
|
+
# own belongs_to column; nothing here writes or widens a grant.
|
|
8
|
+
module AssociationPanel
|
|
9
|
+
MAX_PANELS = 10
|
|
10
|
+
MAX_LIMIT = 50
|
|
11
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
12
|
+
Row = Data.define(:id, :label, :linked)
|
|
13
|
+
# list_filter is the canonical list filter for "view all", present only
|
|
14
|
+
# while the child's reference field carries a current filter grant.
|
|
15
|
+
# list_params is the complete list state for that link: like the panel,
|
|
16
|
+
# it shows every child, not the child's default list tab (RAA-006).
|
|
17
|
+
# unscoped_list is true when the child has a granted default list tab.
|
|
18
|
+
# new_prefill is the prefill for the child's new form when the actor may
|
|
19
|
+
# create a child with a writable reference back to this record.
|
|
20
|
+
Panel = Data.define(:name, :label, :resource_key, :rows, :more, :list_filter, :unscoped_list, :single, :new_prefill) do
|
|
21
|
+
def list_params
|
|
22
|
+
return unless list_filter
|
|
23
|
+
unscoped_list ? { filter: list_filter, scope: Queries::NamedScope::NONE } : { filter: list_filter }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Definition = Data.define(:name, :resource, :explicit_via, :label, :limit, :single) do
|
|
28
|
+
def self.build(name, resource:, via:, label:, limit:, single: false)
|
|
29
|
+
names = [ name, resource ] + (via.nil? ? [] : [ via ])
|
|
30
|
+
unless names.all? { |value| (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.match?(NAME) && value.to_s.length <= 100 } &&
|
|
31
|
+
(label.nil? || label.is_a?(String) && label.valid_encoding? && label.length.between?(1, 80)) &&
|
|
32
|
+
limit.is_a?(Integer) && limit.between?(1, MAX_LIMIT)
|
|
33
|
+
raise ConfigurationError, "has_many needs stable names, an optional short plain label and a limit of 1..#{MAX_LIMIT}"
|
|
34
|
+
end
|
|
35
|
+
new(name.to_sym, resource.to_sym, via&.to_sym, label&.dup&.freeze, single ? 1 : limit, single)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Defaults to the parent model's underscored name, as Rails does.
|
|
39
|
+
def via(parent) = explicit_via || parent.model.to_s.demodulize.underscore.to_sym
|
|
40
|
+
def caption = label || name.to_s.humanize
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def build(context, parent, record)
|
|
45
|
+
parent.has_many_declarations.filter_map { |definition| panel(context, parent, record, definition) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def panel(context, parent, record, definition)
|
|
51
|
+
child = RailsAiAdmin.registry.fetch(definition.resource)
|
|
52
|
+
access = Authorization::Access.new(context, child)
|
|
53
|
+
access.authorize!(:index)
|
|
54
|
+
field = child.fields.find { |candidate| candidate.name == definition.via(parent) }
|
|
55
|
+
target = Fields::Association.validate!(child, field)
|
|
56
|
+
unless target.model_class == parent.model_class
|
|
57
|
+
raise ConfigurationError, "has_many #{definition.name} must point back to the #{parent.key} model"
|
|
58
|
+
end
|
|
59
|
+
# Listing children by parent reveals each child's reference value.
|
|
60
|
+
return unless access.fields(:read, view: :index).include?(field)
|
|
61
|
+
model = child.model_class
|
|
62
|
+
records = access.relation.where(field.column => record.id)
|
|
63
|
+
.reorder(model.arel_table[model.primary_key].asc).limit(definition.limit + 1).to_a
|
|
64
|
+
rows = records.first(definition.limit).map do |child_record|
|
|
65
|
+
linked = begin
|
|
66
|
+
access.authorize!(:show, child_record)
|
|
67
|
+
rescue AccessDenied
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
Row.new(child_record.id.to_s.freeze, access.record_label(child_record, view: :index).freeze, linked)
|
|
71
|
+
end
|
|
72
|
+
filter = access.fields(:filter).include?(field) ? { field.name.to_s => { "eq" => record.id.to_s } }.freeze : nil
|
|
73
|
+
Panel.new(definition.name, definition.caption, child.key, rows.freeze, records.size > definition.limit, filter,
|
|
74
|
+
access.named_scopes.any?(&:default?), definition.single, creatable(access, field, record))
|
|
75
|
+
rescue AccessDenied
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def creatable(access, field, record)
|
|
80
|
+
child = access.relation.new
|
|
81
|
+
access.authorize!(:create, child)
|
|
82
|
+
return unless access.fields(:write, child, operation: :create).include?(field)
|
|
83
|
+
{ field.name.to_s => record.id.to_s }.freeze
|
|
84
|
+
rescue AccessDenied
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Presentation
|
|
5
|
+
# Recent audit events across the resources whose view_audit_log? grant
|
|
6
|
+
# this actor holds, for the current tenant only. Field names follow each
|
|
7
|
+
# resource's current read and write grants; the rest are counted.
|
|
8
|
+
class AuditLog
|
|
9
|
+
PAGE_SIZE = 50
|
|
10
|
+
STATUSES = %w[succeeded rejected].freeze
|
|
11
|
+
Entry = Data.define(:id, :at, :resource, :record_id, :operation, :status, :code, :origin, :actor_ref, :fields, :hidden_fields)
|
|
12
|
+
Page = Data.define(:entries, :older)
|
|
13
|
+
CURSOR = /\A[1-9][0-9]{0,18}\z/
|
|
14
|
+
|
|
15
|
+
attr_reader :resources
|
|
16
|
+
|
|
17
|
+
def initialize(context)
|
|
18
|
+
@context = context
|
|
19
|
+
@resources = RailsAiAdmin.registry.resources.select do |resource|
|
|
20
|
+
Authorization::Access.new(context, resource).authorize_audit_log!
|
|
21
|
+
rescue AccessDenied
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
raise AccessDenied if @resources.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Filters are exact values; unknown ones are an invalid query.
|
|
28
|
+
def page(resource: nil, operation: nil, status: nil, actor: nil, before: nil)
|
|
29
|
+
keys = @resources.map { |entry| entry.key.to_s }
|
|
30
|
+
valid = (resource.nil? || keys.include?(resource)) && (status.nil? || STATUSES.include?(status)) &&
|
|
31
|
+
(operation.nil? || operation.is_a?(String) && operation.match?(/\A[a-z][a-z0-9_]{0,63}\z/)) &&
|
|
32
|
+
(actor.nil? || actor.is_a?(String) && actor.bytesize <= 150 && !actor.match?(/[[:cntrl:]]/)) &&
|
|
33
|
+
(before.nil? || before.is_a?(String) && before.match?(CURSOR))
|
|
34
|
+
raise AccessDenied, "INVALID_QUERY" unless valid
|
|
35
|
+
scope = AuditEvent.where(tenant_ref: @context.tenant_ref, resource_key: resource || keys)
|
|
36
|
+
scope = scope.where(operation: operation) if operation
|
|
37
|
+
scope = scope.where(status: status) if status
|
|
38
|
+
scope = scope.where(actor_ref: actor) if actor
|
|
39
|
+
scope = scope.where(AuditEvent.arel_table[:id].lt(Integer(before, 10))) if before
|
|
40
|
+
events = scope.order(id: :desc).limit(PAGE_SIZE + 1).to_a
|
|
41
|
+
by_key = @resources.index_by { |entry| entry.key.to_s }
|
|
42
|
+
labels = Hash.new { |hash, key| hash[key] = visible_labels(by_key.fetch(key)) }
|
|
43
|
+
entries = events.first(PAGE_SIZE).map do |event|
|
|
44
|
+
names = Array(event.changed_fields).grep(String)
|
|
45
|
+
shown = names.filter_map { |name| labels[event.resource_key][name] }
|
|
46
|
+
Entry.new(event.id, event.created_at, by_key.fetch(event.resource_key), event.record_id, event.operation, event.status,
|
|
47
|
+
event.code, event.origin, event.actor_ref, shown.freeze, names.size - shown.size)
|
|
48
|
+
end
|
|
49
|
+
Page.new(entries.freeze, events.size > PAGE_SIZE ? entries.last.id : nil)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def visible_labels(resource)
|
|
55
|
+
access = Authorization::Access.new(@context, resource)
|
|
56
|
+
fields = access.fields(:read) | access.fields(:write, operation: :update)
|
|
57
|
+
fields.to_h { |field| [ field.name.to_s, field.label ] }
|
|
58
|
+
rescue AccessDenied
|
|
59
|
+
{}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Presentation
|
|
5
|
+
# The current list (search, list tab, filters and sort) as CSV, through
|
|
6
|
+
# the same grants as the pages: rows from the index relation and each
|
|
7
|
+
# record's show grant, columns and values from its read grant.
|
|
8
|
+
class CsvExport
|
|
9
|
+
BATCH = 500
|
|
10
|
+
# A spreadsheet runs a cell that starts with these as a formula
|
|
11
|
+
# (CSV injection); such cells get a leading apostrophe.
|
|
12
|
+
FORMULA = /\A[=+\-@\t\r]/
|
|
13
|
+
# Number and time text is produced by coercion and cannot be a formula.
|
|
14
|
+
NUMERIC = %i[integer decimal date datetime time].freeze
|
|
15
|
+
Result = Data.define(:body, :rows, :fields, :too_many)
|
|
16
|
+
|
|
17
|
+
def initialize(context, resource, limit: RailsAiAdmin.configuration.export_limit)
|
|
18
|
+
@context, @resource, @limit = context, resource, limit
|
|
19
|
+
@access = Authorization::Access.new(context, resource)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(search: nil, sort: nil, direction: "asc", scope: nil, filters: nil)
|
|
23
|
+
@access.authorize_export!
|
|
24
|
+
relation = Queries::List.new(@context, @resource).relation(search: search, sort: sort, direction: direction, scope: scope, filters: filters)
|
|
25
|
+
return Result.new(nil, 0, [], true) if relation.except(:order).limit(@limit + 1).count > @limit
|
|
26
|
+
fields = @access.fields(:read, nil, view: :show)
|
|
27
|
+
references = Fields::Association.new(@context, @resource)
|
|
28
|
+
lines = [ line(fields.map(&:label)) ]
|
|
29
|
+
# One query per list field and batch instead of one per row.
|
|
30
|
+
lists = fields.select { |field| field.scalar_type == :references }.map(&:name)
|
|
31
|
+
relation = relation.preload(*lists) if lists.any?
|
|
32
|
+
rows = 0
|
|
33
|
+
offset = 0
|
|
34
|
+
loop do
|
|
35
|
+
records = relation.offset(offset).limit(BATCH).to_a
|
|
36
|
+
break if records.empty?
|
|
37
|
+
offset += records.size
|
|
38
|
+
fields.select { |field| field.scalar_type == :belongs_to }.each do |field|
|
|
39
|
+
references.prime(field, records.filter_map { |record| record.has_attribute?(field.column) ? record.read_attribute(field.column)&.to_s : nil })
|
|
40
|
+
end
|
|
41
|
+
records.each do |record|
|
|
42
|
+
values = @access.project(record, view: :show)
|
|
43
|
+
lines << line(fields.map { |field| values.key?(field.name.to_s) ? cell(field, values[field.name.to_s], references) : "" })
|
|
44
|
+
rows += 1
|
|
45
|
+
rescue AccessDenied
|
|
46
|
+
# Left out like an unavailable row on the list page (RAA-001).
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
break if records.size < BATCH || offset >= @limit
|
|
50
|
+
end
|
|
51
|
+
# The byte order mark lets spreadsheet programs read UTF-8.
|
|
52
|
+
Result.new("#{lines.join}", rows, fields.map { |field| field.name.to_s }, false)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def cell(field, value, references)
|
|
58
|
+
return "" if value.nil?
|
|
59
|
+
text = if field.plugin?
|
|
60
|
+
field.format(value, time_zone: @context.time_zone)
|
|
61
|
+
else
|
|
62
|
+
case field.scalar_type
|
|
63
|
+
when :date then value.iso8601
|
|
64
|
+
when :datetime then value.in_time_zone(@context.time_zone).iso8601
|
|
65
|
+
when :decimal then field.format_decimal(value)
|
|
66
|
+
when :json then JSON.generate(value)
|
|
67
|
+
when :file then value.filename
|
|
68
|
+
when :files then Array(value).map(&:filename).join("; ")
|
|
69
|
+
when :references
|
|
70
|
+
references.prime(field, Array(value))
|
|
71
|
+
Array(value).map { |id| references.reference(field, id).label.to_s }.join("; ")
|
|
72
|
+
when :belongs_to then references.reference(field, value.to_s).label.to_s
|
|
73
|
+
else field.format(value, time_zone: @context.time_zone)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
text = text.to_s
|
|
77
|
+
!NUMERIC.include?(field.scalar_type) && text.match?(FORMULA) ? "'#{text}" : text
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def line(cells)
|
|
81
|
+
"#{cells.map { |cell| %("#{cell.to_s.gsub('"', '""')}") }.join(',')}\r\n"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|