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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Upload a CSV file, check it, then create one record per row.
|
|
5
|
+
class ImportsController < ApplicationController
|
|
6
|
+
before_action :load_import
|
|
7
|
+
|
|
8
|
+
def new; end
|
|
9
|
+
|
|
10
|
+
# A header row naming every importable field; no data and no audit.
|
|
11
|
+
def template
|
|
12
|
+
send_data @import.template, filename: "#{@resource.key}-import.csv", type: "text/csv; charset=utf-8", disposition: "attachment"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
file = params[:file]
|
|
17
|
+
unless file.is_a?(ActionDispatch::Http::UploadedFile)
|
|
18
|
+
@problem = t("rails_ai_admin.imports.choose_file")
|
|
19
|
+
return render(:new, status: :unprocessable_content)
|
|
20
|
+
end
|
|
21
|
+
raise AccessDenied, "INVALID_QUERY" unless params[:mode].nil? || %w[check import].include?(params[:mode])
|
|
22
|
+
if file.size > Presentation::CsvImport::MAX_BYTES
|
|
23
|
+
@problem = t("rails_ai_admin.imports.too_large")
|
|
24
|
+
return render(:new, status: :unprocessable_content)
|
|
25
|
+
end
|
|
26
|
+
@report = @import.call(file.read, write: params[:mode] == "import")
|
|
27
|
+
render :new, status: @report.failed.any? ? :unprocessable_content : :ok
|
|
28
|
+
rescue Presentation::CsvImport::Invalid => error
|
|
29
|
+
@problem = error.message
|
|
30
|
+
render :new, status: :unprocessable_content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_import
|
|
36
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
37
|
+
Authorization::Access.new(@context, @resource).authorize_import!
|
|
38
|
+
@import = Presentation::CsvImport.new(@context, @resource)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# The MCP endpoint. It reads no cookie or session: a bearer token names the
|
|
5
|
+
# owner, and the host's resolve_job turns that into a fresh Context.
|
|
6
|
+
class McpController < ActionController::API
|
|
7
|
+
before_action :prepare_headers
|
|
8
|
+
|
|
9
|
+
def handle
|
|
10
|
+
return head(:forbidden) unless same_origin?
|
|
11
|
+
context, token = authenticate
|
|
12
|
+
return unauthorized unless context
|
|
13
|
+
require "rails_ai_admin/mcp"
|
|
14
|
+
raw = request.raw_post.to_s
|
|
15
|
+
return render(json: rpc_error(nil, -32600, "Request too large"), status: :payload_too_large) if raw.bytesize > Mcp::MAX_BODY
|
|
16
|
+
message = begin
|
|
17
|
+
JSON.parse(raw)
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
return render(json: rpc_error(nil, -32700, "Parse error"), status: :bad_request)
|
|
20
|
+
end
|
|
21
|
+
return render(json: rpc_error(nil, -32600, "Batches are not supported"), status: :bad_request) if message.is_a?(Array)
|
|
22
|
+
server = Mcp::Server.new(context: context, token: token,
|
|
23
|
+
review_url: ->(surface_id) { admin_surface_url(surface_id: surface_id) })
|
|
24
|
+
status, body = if RailsAiAdmin.configuration.surfaces
|
|
25
|
+
Integrations::ToolViews.with_context(context) { server.handle(message) }
|
|
26
|
+
else
|
|
27
|
+
server.handle(message)
|
|
28
|
+
end
|
|
29
|
+
body ? render(json: body, status: status) : head(status)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# No server-initiated stream and no session to end.
|
|
33
|
+
def unsupported = head(:method_not_allowed)
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def prepare_headers
|
|
38
|
+
response.headers["Cache-Control"] = "no-store"
|
|
39
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# MCP requires rejecting cross-site browser requests (DNS rebinding).
|
|
43
|
+
def same_origin?
|
|
44
|
+
origin = request.headers["Origin"]
|
|
45
|
+
return true if origin.blank?
|
|
46
|
+
URI.parse(origin).then { |uri| uri.host == request.host && (uri.port || 80) == request.port }
|
|
47
|
+
rescue URI::InvalidURIError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def authenticate
|
|
52
|
+
raise AccessDenied unless RailsAiAdmin.configuration.mcp
|
|
53
|
+
require "rails_ai_admin/assistant"
|
|
54
|
+
require "rails_ai_admin/mcp"
|
|
55
|
+
require "rails_ai_admin/integrations/tool_views" if RailsAiAdmin.configuration.surfaces
|
|
56
|
+
header = request.headers["Authorization"].to_s
|
|
57
|
+
token = Mcp::AccessToken.authenticate(header.delete_prefix("Bearer ").strip) if header.start_with?("Bearer ")
|
|
58
|
+
return [ nil, nil ] unless token
|
|
59
|
+
adapter = RailsAiAdmin.configuration.context_adapter&.constantize&.new
|
|
60
|
+
return [ nil, nil ] unless adapter.respond_to?(:resolve_job)
|
|
61
|
+
context = ActiveRecord::Base.while_preventing_writes do
|
|
62
|
+
adapter.resolve_job(actor_ref: token.actor_ref, tenant_ref: token.tenant_ref, security_version: token.security_version)
|
|
63
|
+
end
|
|
64
|
+
unless context.is_a?(Context) && context.actor_ref == token.actor_ref && context.tenant_ref == token.tenant_ref &&
|
|
65
|
+
context.security_version == token.security_version
|
|
66
|
+
return [ nil, nil ]
|
|
67
|
+
end
|
|
68
|
+
token.update_columns(last_used_at: Time.current) if token.last_used_at.nil? || token.last_used_at < 1.minute.ago
|
|
69
|
+
[ context, token ]
|
|
70
|
+
rescue AccessDenied, ActiveRecord::ReadOnlyError
|
|
71
|
+
[ nil, nil ]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def unauthorized
|
|
75
|
+
response.headers["WWW-Authenticate"] = %(Bearer realm="rails_ai_admin")
|
|
76
|
+
render json: rpc_error(nil, -32001, "A valid admin access token is required"), status: :unauthorized
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rpc_error(id, code, message) = { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Personal access tokens for MCP clients. A new token is shown once, in the
|
|
5
|
+
# response that created it; the list shows names and dates only.
|
|
6
|
+
class McpTokensController < ApplicationController
|
|
7
|
+
before_action :require_mcp
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
load_tokens
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create
|
|
14
|
+
@record, @token = Mcp::AccessToken.issue!(@context, name: params[:name], lifetime: params[:lifetime])
|
|
15
|
+
load_tokens
|
|
16
|
+
render :index, status: :created
|
|
17
|
+
rescue AccessDenied => error
|
|
18
|
+
raise unless error.code == "INVALID_QUERY"
|
|
19
|
+
@problem = t("rails_ai_admin.mcp.invalid")
|
|
20
|
+
load_tokens
|
|
21
|
+
render :index, status: :unprocessable_content
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def revoke
|
|
25
|
+
token = Mcp::AccessToken.for_owner(@context).find_by(id: params[:id])
|
|
26
|
+
raise AccessDenied unless token
|
|
27
|
+
token.update!(revoked_at: Time.current) if token.revoked_at.nil?
|
|
28
|
+
redirect_to mcp_tokens_path, status: :see_other, notice: t("rails_ai_admin.mcp.revoked")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def require_mcp
|
|
34
|
+
raise AccessDenied unless RailsAiAdmin.configuration.mcp
|
|
35
|
+
require "rails_ai_admin/assistant"
|
|
36
|
+
require "rails_ai_admin/mcp"
|
|
37
|
+
raise AccessDenied unless Assistant.available?(@context)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_tokens
|
|
41
|
+
@tokens = Mcp::AccessToken.for_owner(@context).order(created_at: :desc).limit(50).to_a
|
|
42
|
+
@endpoint = mcp_url
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Human input -> pure preview -> input-bound confirmation -> shared Runner.
|
|
5
|
+
class OperationsController < ApplicationController
|
|
6
|
+
before_action :resolve_operation
|
|
7
|
+
|
|
8
|
+
def new
|
|
9
|
+
load_form
|
|
10
|
+
@form_values, @errors = {}, {}
|
|
11
|
+
render :form
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def preflight
|
|
15
|
+
load_form
|
|
16
|
+
input = submitted_input
|
|
17
|
+
proposal = Operations::Runner.new.preflight(context: @context, resource: @resource, operation: @definition.key,
|
|
18
|
+
target_id: @record.id, expected_version: params[:expected_version], input: input)
|
|
19
|
+
if proposal.is_a?(Operations::Result)
|
|
20
|
+
render_failure(proposal, input)
|
|
21
|
+
else
|
|
22
|
+
@proposal = proposal
|
|
23
|
+
@form_values = proposal.values
|
|
24
|
+
@wire_input = JSON.generate(proposal.values.transform_values { |value| wire_value(value) })
|
|
25
|
+
if request.format.turbo_stream?
|
|
26
|
+
render turbo_stream: turbo_stream.replace(@flow_id, template: "rails_ai_admin/operations/confirmation", layout: false)
|
|
27
|
+
else
|
|
28
|
+
render :confirmation
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute
|
|
34
|
+
input = confirmed_input
|
|
35
|
+
result = Operations::Runner.new.execute(context: @context, intent_ref: params[:intent_ref], input: input,
|
|
36
|
+
human_confirmed: true, binding: { resource_key: @resource.key, operation: @definition.key, target_id: params[:id] })
|
|
37
|
+
if result.success?
|
|
38
|
+
notice = result.work_refs.any? ? t("rails_ai_admin.operations.completed_with_work") : t("rails_ai_admin.operations.completed")
|
|
39
|
+
notice += " A host callback needs review." if result.warning
|
|
40
|
+
redirect_to resource_path(resource: @resource.key, id: result.record_id, **navigation_params), status: :see_other, notice: notice
|
|
41
|
+
elsif result.code == "NOT_FOUND"
|
|
42
|
+
raise AccessDenied
|
|
43
|
+
elsif result.code == "VALIDATION_FAILED"
|
|
44
|
+
load_form
|
|
45
|
+
render_failure(result, input)
|
|
46
|
+
else
|
|
47
|
+
@failure_code = result.code
|
|
48
|
+
render :failure, status: %w[OPERATION_FAILED TIMEOUT].include?(result.code) ? :service_unavailable : :conflict
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_operation
|
|
55
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
56
|
+
@definition = RailsAiAdmin.operation_registry.fetch(@resource, params[:operation])
|
|
57
|
+
raise AccessDenied unless @definition.custom? && @definition.scope == :member
|
|
58
|
+
@access = Authorization::Access.new(@context, @resource)
|
|
59
|
+
raise AccessDenied unless @access.canonical_id?(params[:id])
|
|
60
|
+
resolve_list_navigation
|
|
61
|
+
@flow_id = "raa_operation_#{@resource.key}_#{params[:id]}_#{@definition.key}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_form
|
|
65
|
+
@record = @access.find!(params[:id], action: @definition.key)
|
|
66
|
+
@flow_id = "raa_operation_#{@resource.key}_#{@record.id}_#{@definition.key}"
|
|
67
|
+
@form_fields = @definition.fields(@access, @record)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def submitted_input
|
|
71
|
+
input = params[:input]
|
|
72
|
+
raise AccessDenied, "INVALID_QUERY" unless input.nil? || input.is_a?(ActionController::Parameters)
|
|
73
|
+
input ? input.to_unsafe_h : {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def confirmed_input
|
|
77
|
+
value = params[:confirmed_input]
|
|
78
|
+
raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= 16_384
|
|
79
|
+
JSON.parse(value)
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wire_value(value)
|
|
85
|
+
case value
|
|
86
|
+
when BigDecimal then value.to_s("F")
|
|
87
|
+
when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
|
|
88
|
+
when Date then value.iso8601
|
|
89
|
+
else value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render_failure(result, input)
|
|
94
|
+
names = @form_fields.map { |field| field.name.to_s }
|
|
95
|
+
@form_values = input.is_a?(Hash) ? input.stringify_keys.slice(*names).reject { |_, value| value.is_a?(Array) || value.is_a?(Hash) } : {}
|
|
96
|
+
@errors, @failure_code = result.errors, result.code
|
|
97
|
+
status = case result.code
|
|
98
|
+
when "VALIDATION_FAILED" then :unprocessable_content
|
|
99
|
+
when "CONFLICT" then :conflict
|
|
100
|
+
else :service_unavailable
|
|
101
|
+
end
|
|
102
|
+
render :form, status: status
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Picker search for reference inputs whose choices exceed the bounded
|
|
5
|
+
# select. :resource is the target resource; the reply holds only what that
|
|
6
|
+
# resource's own index would show this actor for the same term.
|
|
7
|
+
class ReferencesController < ApplicationController
|
|
8
|
+
def search
|
|
9
|
+
target = RailsAiAdmin.registry.fetch(params[:resource])
|
|
10
|
+
term = params[:q]
|
|
11
|
+
raise AccessDenied, "INVALID_QUERY" unless term.nil? || term.is_a?(String)
|
|
12
|
+
results = Fields::Association.search(@context, target, term)
|
|
13
|
+
render json: { results: results.map { |label, id| { id: id, label: label } } }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Conventional pages use the same operation runner as other trusted adapters.
|
|
5
|
+
class ResourcesController < ApplicationController
|
|
6
|
+
before_action :resolve_resource
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
raw = params.to_unsafe_h.slice(*Navigation::ListState::KEYS)
|
|
10
|
+
@list_navigation = Navigation::ListState.new(@access, raw, strict: true)
|
|
11
|
+
if return_state_discarded?
|
|
12
|
+
redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.resources.index.settings_restored")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
# A native filter form submits every input; share only the canonical
|
|
16
|
+
# nonblank conditions. This changes no grant and adds no notice.
|
|
17
|
+
if raw.key?("filter") && raw["filter"] != navigation_state["filter"]
|
|
18
|
+
redirect_to list_return_path, status: :see_other
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
@page = Queries::List.new(@context, @resource).call(search: navigation_state["q"], sort: navigation_state["sort"], direction: navigation_state.fetch("direction", "asc"), page: navigation_state.fetch("page", "1"), recover_empty_page: true, scope: navigation_state["scope"], filters: navigation_state["filter"])
|
|
22
|
+
if @page.number != navigation_state.fetch("page", "1").to_i
|
|
23
|
+
@list_navigation = Navigation::ListState.new(@access, navigation_state.merge("page" => @page.number.to_s))
|
|
24
|
+
redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.resources.index.page_empty")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
@sortable_fields = @access.fields(:sort)
|
|
28
|
+
@list_scopes = @access.named_scopes
|
|
29
|
+
@filter_fields = @access.fields(:filter)
|
|
30
|
+
@current_scope = @page.scope
|
|
31
|
+
@columns = @resource.index.order(@access.fields(:read, view: :index))
|
|
32
|
+
computed = @columns.map(&:name) | [ @resource.title ]
|
|
33
|
+
# A record inside the policy scope whose own show check fails is left out
|
|
34
|
+
# of the page, like an unavailable reference, instead of failing the
|
|
35
|
+
# whole list (RAA-001). The notice says rows were left out, never which.
|
|
36
|
+
@rows = @page.records.filter_map do |record|
|
|
37
|
+
values = @access.project(record, view: :index, only: computed)
|
|
38
|
+
{ id: record.id, label: record_label(@resource, record, values: values, view: :index), values: values,
|
|
39
|
+
editable: allowed_action?(@resource, :update, record) }
|
|
40
|
+
rescue AccessDenied
|
|
41
|
+
@omitted_rows = true
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
# One target query per reference column instead of one per row.
|
|
45
|
+
@columns.select { |field| field.scalar_type == :belongs_to }.each do |field|
|
|
46
|
+
admin_associations.prime(field, @rows.filter_map { |row| row[:values][field.name.to_s] })
|
|
47
|
+
end
|
|
48
|
+
@searchable = @access.fields(:search).any?
|
|
49
|
+
@omitted_rows ||= false
|
|
50
|
+
@selectable = begin
|
|
51
|
+
@access.authorize_selection!
|
|
52
|
+
rescue AccessDenied
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
@importable = begin
|
|
56
|
+
@access.authorize_import!
|
|
57
|
+
rescue AccessDenied
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
@exportable = begin
|
|
61
|
+
@access.authorize_export!
|
|
62
|
+
rescue AccessDenied
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def show
|
|
68
|
+
@record = @access.find!(params[:id])
|
|
69
|
+
@values = @access.project(@record, view: :show)
|
|
70
|
+
@fields = @access.fields(:read, @record, view: :show)
|
|
71
|
+
@association_panels = Presentation::AssociationPanel.build(@context, @resource, @record)
|
|
72
|
+
@operations = RailsAiAdmin.operation_registry.custom(@resource).select { |definition| definition.scope == :member }.select do |definition|
|
|
73
|
+
@access.authorize!(definition.key, @record)
|
|
74
|
+
definition.fields(@access, @record)
|
|
75
|
+
true
|
|
76
|
+
rescue AccessDenied
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Who changed this record, when and which fields, newest first.
|
|
82
|
+
def history
|
|
83
|
+
@record = @access.find!(params[:id])
|
|
84
|
+
before = params[:before]
|
|
85
|
+
raise AccessDenied, "INVALID_QUERY" unless before.nil? || before.is_a?(String)
|
|
86
|
+
@history = Presentation::History.new(@access, @record).page(before: before)
|
|
87
|
+
@actor_labels = actor_labels(@history.entries.map(&:actor_ref).uniq)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def new
|
|
91
|
+
prepare_form(:create)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def edit
|
|
95
|
+
prepare_form(:update)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def recover_form
|
|
99
|
+
response.headers["Vary"] = [ response.headers["Vary"], "Cookie", "X-RailsAiAdmin-Recovery-Intent", "X-RailsAiAdmin-Return-State" ].compact.join(", ")
|
|
100
|
+
@intent_ref = request.headers["X-RailsAiAdmin-Recovery-Intent"]
|
|
101
|
+
session = runner.resume_form(context: @context, resource: @resource, intent_ref: @intent_ref)
|
|
102
|
+
@intent, @record = session.intent, session.record
|
|
103
|
+
@operation = @intent.operation.to_sym
|
|
104
|
+
build_form_plan(@operation)
|
|
105
|
+
@errors = {}
|
|
106
|
+
render @operation == :create ? :new : :edit
|
|
107
|
+
response.headers["X-RailsAiAdmin-Form-Recovery"] = "ready"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def confirm_destroy
|
|
111
|
+
prepare_form(:destroy)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create = submit(:create)
|
|
115
|
+
def update = submit(:update)
|
|
116
|
+
def destroy = submit(:destroy)
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def resolve_resource
|
|
121
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
122
|
+
@access = Authorization::Access.new(@context, @resource)
|
|
123
|
+
raise AccessDenied if params.key?(:id) && !@access.canonical_id?(params[:id])
|
|
124
|
+
resolve_list_navigation unless action_name == "index"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def runner
|
|
128
|
+
@runner ||= Operations::Runner.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def prepare_form(operation)
|
|
132
|
+
@operation = operation
|
|
133
|
+
@record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
|
|
134
|
+
@access.authorize!(operation, @record)
|
|
135
|
+
build_form_plan(operation, operation == :create ? prefill_values : nil) unless operation == :destroy
|
|
136
|
+
@errors = {}
|
|
137
|
+
# GET stays read-only: the form carries a signed reference that becomes
|
|
138
|
+
# an intent on submit (RAA-005, ADR 0025).
|
|
139
|
+
@intent_ref = runner.issue_form_intent(context: @context, resource: @resource, operation: operation,
|
|
140
|
+
target_id: operation == :create ? nil : @record.id, expected_version: operation == :create ? nil : RecordVersion.of(@record)).ref
|
|
141
|
+
render operation == :destroy ? :confirm_destroy : operation == :create ? :new : :edit
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# prefill[<belongs_to field>]=<key> starts a new record pointing at a
|
|
145
|
+
# parent, as the "Add" link of an association panel does. Only writable
|
|
146
|
+
# references to targets the actor can see are taken; anything else is
|
|
147
|
+
# ignored, and the person still submits the form.
|
|
148
|
+
def prefill_values
|
|
149
|
+
raw = params[:prefill]
|
|
150
|
+
return nil unless raw.is_a?(ActionController::Parameters)
|
|
151
|
+
raw = raw.to_unsafe_h
|
|
152
|
+
references = @access.fields(:write, @record, operation: :create).select { |field| field.scalar_type == :belongs_to }
|
|
153
|
+
resolver = Fields::Association.new(@context, @resource)
|
|
154
|
+
references.each_with_object({}) do |field, values|
|
|
155
|
+
value = raw[field.name.to_s]
|
|
156
|
+
next unless value.is_a?(String)
|
|
157
|
+
key = field.coerce(value)
|
|
158
|
+
values[field.name.to_s] = key if key && resolver.admissible?(field, key)
|
|
159
|
+
rescue ArgumentError
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def submit(operation)
|
|
165
|
+
input = operation == :destroy ? {} : submitted_input
|
|
166
|
+
@intent_ref = params[:intent_ref]
|
|
167
|
+
intent_id = runner.materialize_form_intent(context: @context, reference: @intent_ref)
|
|
168
|
+
upload_files(operation, input) unless operation == :destroy
|
|
169
|
+
result = runner.execute(context: @context, intent_ref: intent_id, input: input,
|
|
170
|
+
human_confirmed: true, origin: :ui,
|
|
171
|
+
binding: { resource_key: @resource.key, operation: operation, target_id: params[:id] })
|
|
172
|
+
discard_uploads unless result.success?
|
|
173
|
+
if result.success?
|
|
174
|
+
target = operation == :destroy ? list_return_path : resource_path(resource: @resource.key, id: result.record_id, **navigation_params)
|
|
175
|
+
redirect_to target, status: :see_other, notice: result.warning ? t("rails_ai_admin.resources.saved_with_warning", operation: intent_id) : t("rails_ai_admin.resources.saved")
|
|
176
|
+
elsif result.code == "NOT_FOUND"
|
|
177
|
+
raise AccessDenied
|
|
178
|
+
else
|
|
179
|
+
@operation = operation
|
|
180
|
+
@record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
|
|
181
|
+
@access.authorize!(operation, @record)
|
|
182
|
+
@intent = OperationRun.find_by!(id: intent_id, actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
|
|
183
|
+
build_form_plan(operation, input) unless operation == :destroy
|
|
184
|
+
names = operation == :destroy ? [] : @form_fields.map { |field| field.name.to_s }
|
|
185
|
+
@errors = result.errors.slice("base", *names)
|
|
186
|
+
@errors["base"] ||= [ t("rails_ai_admin.resources.form.inputs_unavailable") ] if (result.errors.keys - [ "base" ] - names).any?
|
|
187
|
+
@failure_code = result.code
|
|
188
|
+
# A restricted delete can succeed once the references are gone.
|
|
189
|
+
@needs_reload = !%w[VALIDATION_FAILED RESTRICTED].include?(result.code)
|
|
190
|
+
render operation == :destroy ? :confirm_destroy : operation == :create ? :new : :edit,
|
|
191
|
+
status: %w[VALIDATION_FAILED RESTRICTED].include?(result.code) ? :unprocessable_content : :conflict
|
|
192
|
+
end
|
|
193
|
+
rescue ActiveRecord::RecordNotFound
|
|
194
|
+
raise AccessDenied
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A chosen file becomes a new blob before the write, and only for a file
|
|
198
|
+
# field the actor may write now. Pages never pass blob ids: a text value in
|
|
199
|
+
# a file slot is refused. remove_files[] clears a file that is not required.
|
|
200
|
+
def upload_files(operation, input)
|
|
201
|
+
@uploaded_blobs = []
|
|
202
|
+
record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
|
|
203
|
+
writable = @access.fields(:write, record, operation: operation)
|
|
204
|
+
upload_lists(writable.select { |field| field.scalar_type == :files }, input)
|
|
205
|
+
files = writable.select { |field| field.scalar_type == :file }.index_by { |field| field.name.to_s }
|
|
206
|
+
input.each_key do |name|
|
|
207
|
+
value = input[name]
|
|
208
|
+
next if writable.any? { |field| field.scalar_type == :files && field.name.to_s == name }
|
|
209
|
+
next unless files.key?(name) || value.is_a?(ActionDispatch::Http::UploadedFile)
|
|
210
|
+
field = files[name] || raise(AccessDenied, "INVALID_QUERY")
|
|
211
|
+
if value == ""
|
|
212
|
+
input.delete(name)
|
|
213
|
+
elsif !value.is_a?(ActionDispatch::Http::UploadedFile)
|
|
214
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
215
|
+
elsif value.size > field.options[:max_size]
|
|
216
|
+
input[name] = Fields::RejectedUpload.new("too_large", field.options[:max_size])
|
|
217
|
+
else
|
|
218
|
+
input[name] = store_upload(value)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
removals = params[:remove_files]
|
|
222
|
+
return if removals.nil?
|
|
223
|
+
raise AccessDenied, "INVALID_QUERY" unless removals.is_a?(Array) && removals.size <= files.size && removals.all? { |name| name.is_a?(String) && files.key?(name) }
|
|
224
|
+
removals.each { |name| input[name] = nil unless input.key?(name) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def store_upload(value)
|
|
228
|
+
raise ConfigurationError, "file fields need Active Storage" unless defined?(::ActiveStorage::Blob)
|
|
229
|
+
blob = ::ActiveStorage::Blob.create_and_upload!(io: value, filename: value.original_filename.presence || "upload",
|
|
230
|
+
content_type: value.content_type, identify: true)
|
|
231
|
+
@uploaded_blobs << blob
|
|
232
|
+
blob.signed_id
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# files fields: record[photos][] carries new uploads and
|
|
236
|
+
# remove_attachments[photos][] the attachment ids to remove.
|
|
237
|
+
def upload_lists(fields, input)
|
|
238
|
+
removals = params[:remove_attachments]
|
|
239
|
+
removals = removals.to_unsafe_h if removals.is_a?(ActionController::Parameters)
|
|
240
|
+
raise AccessDenied, "INVALID_QUERY" unless removals.nil? || removals.is_a?(Hash)
|
|
241
|
+
removals ||= {}
|
|
242
|
+
raise AccessDenied, "INVALID_QUERY" unless (removals.keys - fields.map { |field| field.name.to_s }).empty?
|
|
243
|
+
fields.each do |field|
|
|
244
|
+
name = field.name.to_s
|
|
245
|
+
uploads = input.key?(name) ? input[name] : []
|
|
246
|
+
remove = removals.fetch(name, [])
|
|
247
|
+
raise AccessDenied, "INVALID_QUERY" unless uploads.is_a?(Array) && remove.is_a?(Array) && remove.all? { |id| id.is_a?(String) }
|
|
248
|
+
uploads = uploads.reject { |value| value == "" }
|
|
249
|
+
raise AccessDenied, "INVALID_QUERY" unless uploads.all? { |value| value.is_a?(ActionDispatch::Http::UploadedFile) }
|
|
250
|
+
added = uploads.map { |value| value.size > field.options[:max_size] ? Fields::RejectedUpload.new("too_large", field.options[:max_size]) : store_upload(value) }
|
|
251
|
+
if added.empty? && remove.empty?
|
|
252
|
+
input.delete(name)
|
|
253
|
+
else
|
|
254
|
+
input[name] = { "add" => added, "remove" => remove }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A refused write keeps no new upload; the person chooses the file again.
|
|
260
|
+
def discard_uploads
|
|
261
|
+
Array(@uploaded_blobs).each { |blob| blob.purge_later unless blob.attachments.exists? }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def submitted_input
|
|
265
|
+
value = params[:record]
|
|
266
|
+
raise AccessDenied, "INVALID_QUERY" unless value.nil? || value.is_a?(ActionController::Parameters)
|
|
267
|
+
value.is_a?(ActionController::Parameters) ? value.to_unsafe_h : {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_form_plan(operation, input = nil)
|
|
271
|
+
view = operation == :create ? :create : :edit
|
|
272
|
+
@form_fields = @access.fields(:write, @record, operation: operation)
|
|
273
|
+
readable_values = begin
|
|
274
|
+
@access.project(@record, view: view)
|
|
275
|
+
rescue AccessDenied
|
|
276
|
+
{}
|
|
277
|
+
end
|
|
278
|
+
names = @form_fields.map { |field| field.name.to_s }
|
|
279
|
+
@form_values = readable_values.slice(*names)
|
|
280
|
+
if input.is_a?(Hash)
|
|
281
|
+
# A refused file is chosen again; the form keeps showing the stored one.
|
|
282
|
+
files = @form_fields.select { |field| Field::FILE_TYPES.include?(field.scalar_type) }.map { |field| field.name.to_s }
|
|
283
|
+
@form_values.merge!(input.stringify_keys.slice(*(names - files)).reject { |_, value| value.is_a?(Hash) || value.is_a?(Array) })
|
|
284
|
+
end
|
|
285
|
+
readonly_fields = @access.fields(:read, @record, view: view).select { |field| readable_values.key?(field.name.to_s) && !names.include?(field.name.to_s) }
|
|
286
|
+
readonly_values = readable_values.slice(*readonly_fields.map { |field| field.name.to_s })
|
|
287
|
+
@form_plan = Forms::Plan.new(fields: @form_fields, values: @form_values, readonly_fields: readonly_fields, readonly_values: readonly_values, order: @resource.fields)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# One search box over every resource the actor may list and search. Each
|
|
5
|
+
# resource runs its own list query (policy relation, default list tab,
|
|
6
|
+
# granted search fields) and each row its own show grant.
|
|
7
|
+
class SearchController < ApplicationController
|
|
8
|
+
PER_RESOURCE = 5
|
|
9
|
+
# A bound on list queries for one search.
|
|
10
|
+
MAX_RESOURCES = 30
|
|
11
|
+
Section = Data.define(:resource, :rows, :more)
|
|
12
|
+
Row = Data.define(:id, :label)
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
query = params[:q]
|
|
16
|
+
raise AccessDenied, "INVALID_QUERY" unless query.nil? || query.is_a?(String)
|
|
17
|
+
@query = query.to_s.strip
|
|
18
|
+
raise AccessDenied, "INVALID_QUERY" unless @query.valid_encoding? && @query.length <= 200
|
|
19
|
+
@sections = @query.empty? ? [] : searchable_resources.first(MAX_RESOURCES).filter_map { |resource| section(resource) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def section(resource)
|
|
25
|
+
access = Authorization::Access.new(@context, resource)
|
|
26
|
+
page = Queries::List.new(@context, resource).call(search: @query, page_size: PER_RESOURCE)
|
|
27
|
+
rows = page.records.filter_map do |record|
|
|
28
|
+
access.authorize!(:show, record)
|
|
29
|
+
Row.new(record.id.to_s, access.record_label(record, view: :index))
|
|
30
|
+
rescue AccessDenied
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
return if rows.empty?
|
|
34
|
+
Section.new(resource, rows, page.has_next)
|
|
35
|
+
rescue AccessDenied
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|