open-loam 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 +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<h1>Dictionary: <code><%%= @dictionary.key %></code></h1>
|
|
2
|
+
|
|
3
|
+
<%%= render "form" %>
|
|
4
|
+
|
|
5
|
+
<h2>Entries</h2>
|
|
6
|
+
<p>Ordered by position; an inactive entry stops being offered but keeps existing values readable.</p>
|
|
7
|
+
|
|
8
|
+
<table>
|
|
9
|
+
<thead>
|
|
10
|
+
<tr><th>Pos</th><th>Value</th><th>Label</th><th>Color</th><th>Icon</th><th>Default</th><th>Active</th><th></th></tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
<%% @dictionary.entries.ordered.each do |entry| %>
|
|
14
|
+
<tr>
|
|
15
|
+
<%%= form_with model: entry, scope: :dictionary_entry, url: admin_dictionary_entry_path(@dictionary, entry) do |f| %>
|
|
16
|
+
<td><%%= f.number_field :position, style: "width:4rem" %></td>
|
|
17
|
+
<td><%%= f.text_field :value %></td>
|
|
18
|
+
<td><%%= f.text_field :label %></td>
|
|
19
|
+
<td><%%= f.text_field :color, placeholder: "#a00", style: "width:6rem" %></td>
|
|
20
|
+
<td><%%= f.text_field :icon, style: "width:6rem" %></td>
|
|
21
|
+
<td><%%= f.check_box :is_default %></td>
|
|
22
|
+
<td><%%= f.check_box :active %></td>
|
|
23
|
+
<td>
|
|
24
|
+
<%%= f.submit "Save" %>
|
|
25
|
+
<%%= button_to "Delete", admin_dictionary_entry_path(@dictionary, entry), method: :delete, form: { style: "display:inline" } %>
|
|
26
|
+
</td>
|
|
27
|
+
<%% end %>
|
|
28
|
+
</tr>
|
|
29
|
+
<%% end %>
|
|
30
|
+
</tbody>
|
|
31
|
+
</table>
|
|
32
|
+
|
|
33
|
+
<h3>Add entry</h3>
|
|
34
|
+
<%%= form_with model: @entry, scope: :dictionary_entry, url: admin_dictionary_entries_path(@dictionary) do |f| %>
|
|
35
|
+
<p><%%= f.label :value %> <%%= f.text_field :value %>
|
|
36
|
+
<%%= f.label :label %> <%%= f.text_field :label %>
|
|
37
|
+
<%%= f.label :position %> <%%= f.number_field :position, value: 0, style: "width:4rem" %></p>
|
|
38
|
+
<p><%%= f.label :color %> <%%= f.text_field :color, placeholder: "#a00" %>
|
|
39
|
+
<%%= f.label :icon %> <%%= f.text_field :icon %>
|
|
40
|
+
<%%= f.label :is_default %> <%%= f.check_box :is_default %>
|
|
41
|
+
<%%= f.label :active %> <%%= f.check_box :active, checked: true %></p>
|
|
42
|
+
<%%= f.submit "Add entry" %>
|
|
43
|
+
<%% end %>
|
|
44
|
+
|
|
45
|
+
<p><%%= link_to "Back to dictionaries", admin_dictionaries_path %></p>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%%# Loam::Dictionary is namespaced, so pin the URL + param scope. %>
|
|
2
|
+
<%%= form_with model: @dictionary, scope: :dictionary,
|
|
3
|
+
url: (@dictionary.persisted? ? admin_dictionary_path(@dictionary) : admin_dictionaries_path) do |f| %>
|
|
4
|
+
<%% if @dictionary.errors.any? %>
|
|
5
|
+
<ul><%% @dictionary.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
|
|
6
|
+
<%% end %>
|
|
7
|
+
<p><%%= f.label :key, "Key (code, e.g. damage_severity)" %><br><%%= f.text_field :key %></p>
|
|
8
|
+
<p><%%= f.label :name %><br><%%= f.text_field :name %></p>
|
|
9
|
+
<%%= f.submit %>
|
|
10
|
+
<%% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<h1>Dictionaries</h1>
|
|
2
|
+
|
|
3
|
+
<p>Managed lookup lists for this tenant — curate the values here, then use one as
|
|
4
|
+
a "dictionary" custom-field type. <%%= link_to "New dictionary", new_admin_dictionary_path %></p>
|
|
5
|
+
|
|
6
|
+
<table>
|
|
7
|
+
<thead><tr><th>Key</th><th>Name</th><th>Entries</th><th></th></tr></thead>
|
|
8
|
+
<tbody>
|
|
9
|
+
<%% @dictionaries.each do |dictionary| %>
|
|
10
|
+
<tr>
|
|
11
|
+
<td><code><%%= dictionary.key %></code></td>
|
|
12
|
+
<td><%%= dictionary.name %></td>
|
|
13
|
+
<td><%%= dictionary.entries.count %></td>
|
|
14
|
+
<td>
|
|
15
|
+
<%%= link_to "Edit", edit_admin_dictionary_path(dictionary) %>
|
|
16
|
+
<%%= button_to "Delete", admin_dictionary_path(dictionary), method: :delete, form: { style: "display:inline" } %>
|
|
17
|
+
</td>
|
|
18
|
+
</tr>
|
|
19
|
+
<%% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Entries of a Loam::Dictionary — manager-only, nested under a dictionary.
|
|
3
|
+
# Add / edit (value, label, color, icon, position, default, active) / delete;
|
|
4
|
+
# reordering is by editing `position`.
|
|
5
|
+
class DictionaryEntriesController < BaseController
|
|
6
|
+
before_action { require_role!(:manager) }
|
|
7
|
+
before_action :set_dictionary
|
|
8
|
+
before_action :set_entry, only: %i[update destroy]
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
@entry = @dictionary.entries.new(entry_params)
|
|
12
|
+
if @entry.save
|
|
13
|
+
redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry added."
|
|
14
|
+
else
|
|
15
|
+
@dictionary_errors = @entry.errors.full_messages
|
|
16
|
+
redirect_to edit_admin_dictionary_path(@dictionary), alert: @dictionary_errors.join(", ")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update
|
|
21
|
+
if @entry.update(entry_params)
|
|
22
|
+
redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry updated."
|
|
23
|
+
else
|
|
24
|
+
redirect_to edit_admin_dictionary_path(@dictionary), alert: @entry.errors.full_messages.join(", ")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def destroy
|
|
29
|
+
@entry.destroy!
|
|
30
|
+
redirect_to edit_admin_dictionary_path(@dictionary), notice: "Entry removed."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def set_dictionary
|
|
36
|
+
@dictionary = Loam::Dictionary.find(params[:dictionary_id])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set_entry
|
|
40
|
+
@entry = @dictionary.entries.find(params[:id])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def entry_params
|
|
44
|
+
params.require(:dictionary_entry).permit(:value, :label, :color, :icon, :position, :is_default, :active)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Durable event deliveries (Loam::EventDelivery) — the dead-letter view.
|
|
3
|
+
# Manager-only. Lists deliveries parked as `dead` (handler removed, or retries
|
|
4
|
+
# exhausted) and lets an operator requeue one after fixing the handler. Pending
|
|
5
|
+
# deliveries drain on their own; a small sample is shown for visibility.
|
|
6
|
+
class EventDeliveriesController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@dead = Loam::EventDelivery.dead.order(updated_at: :desc).limit(200)
|
|
11
|
+
@pending = Loam::EventDelivery.pending.order(Arel.sql("next_attempt_at IS NULL DESC, next_attempt_at ASC")).limit(50)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Re-arm one delivery: back to pending with a clean slate, and nudge a job.
|
|
15
|
+
# The sweep would eventually pick it up anyway; this is the "try now" button.
|
|
16
|
+
def redeliver
|
|
17
|
+
delivery = Loam::EventDelivery.find(params[:id])
|
|
18
|
+
delivery.update!(status: "pending", attempts: 0, next_attempt_at: nil, last_error: nil)
|
|
19
|
+
Loam::EventDeliveryJob.perform_later(delivery.tenant_id, delivery.id)
|
|
20
|
+
redirect_to admin_event_deliveries_path, notice: "Delivery requeued."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<h1>Event deliveries</h1>
|
|
2
|
+
|
|
3
|
+
<p>Durable subscribers (<code>Loam::DurableEvents</code>). Delivery is at-least-once
|
|
4
|
+
with retry; a delivery that exhausts its retries — or whose subscriber was removed —
|
|
5
|
+
is parked here as <strong>dead</strong> for you to fix and requeue.</p>
|
|
6
|
+
|
|
7
|
+
<h2>Dead-letter (<%%= @dead.size %>)</h2>
|
|
8
|
+
<%% if @dead.any? %>
|
|
9
|
+
<table>
|
|
10
|
+
<thead>
|
|
11
|
+
<tr><th>Subscriber</th><th>Event</th><th>Attempts</th><th>Last error</th><th>When</th><th></th></tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody>
|
|
14
|
+
<%% @dead.each do |d| %>
|
|
15
|
+
<tr>
|
|
16
|
+
<td><code><%%= d.subscriber_key %></code></td>
|
|
17
|
+
<td><code><%%= d.event_name %></code></td>
|
|
18
|
+
<td><%%= d.attempts %></td>
|
|
19
|
+
<td><small><%%= d.last_error %></small></td>
|
|
20
|
+
<td><%%= d.updated_at.strftime("%Y-%m-%d %H:%M UTC") %></td>
|
|
21
|
+
<td><%%= button_to "Requeue", redeliver_admin_event_delivery_path(d), form: { style: "display:inline" } %></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<%% end %>
|
|
24
|
+
</tbody>
|
|
25
|
+
</table>
|
|
26
|
+
<%% else %>
|
|
27
|
+
<p>No dead deliveries. 🎉</p>
|
|
28
|
+
<%% end %>
|
|
29
|
+
|
|
30
|
+
<h2>Pending (next <%%= @pending.size %>)</h2>
|
|
31
|
+
<%% if @pending.any? %>
|
|
32
|
+
<table>
|
|
33
|
+
<thead>
|
|
34
|
+
<tr><th>Subscriber</th><th>Event</th><th>Attempts</th><th>Next attempt</th></tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
<%% @pending.each do |d| %>
|
|
38
|
+
<tr>
|
|
39
|
+
<td><code><%%= d.subscriber_key %></code></td>
|
|
40
|
+
<td><code><%%= d.event_name %></code></td>
|
|
41
|
+
<td><%%= d.attempts %></td>
|
|
42
|
+
<td><%%= d.next_attempt_at ? d.next_attempt_at.strftime("%Y-%m-%d %H:%M UTC") : "now" %></td>
|
|
43
|
+
</tr>
|
|
44
|
+
<%% end %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
<%% else %>
|
|
48
|
+
<p>Nothing pending.</p>
|
|
49
|
+
<%% end %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Server-Sent Events: a per-tenant push stream (Loam::EventStream) so the admin
|
|
3
|
+
# updates live instead of polling. Authentication runs in the normal
|
|
4
|
+
# before_action chain (set_loam_context) BEFORE any stream write — so an
|
|
5
|
+
# unauthenticated request redirects to login the ordinary way, since no headers
|
|
6
|
+
# have been committed yet. Once the loop starts writing, the response is
|
|
7
|
+
# committed and there is no going back; that ordering is the whole safety of a
|
|
8
|
+
# Live action.
|
|
9
|
+
class EventsController < BaseController
|
|
10
|
+
include ActionController::Live
|
|
11
|
+
|
|
12
|
+
HEARTBEAT_SECONDS = 20
|
|
13
|
+
|
|
14
|
+
def stream
|
|
15
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
16
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
17
|
+
handle = nil # so the ensure never raises NameError if subscribe below does
|
|
18
|
+
queue = Queue.new
|
|
19
|
+
|
|
20
|
+
# Subscribe as the FIRST thing in the begin whose ensure unsubscribes: a
|
|
21
|
+
# leaked AS::Notifications subscriber would fire on every future event in
|
|
22
|
+
# the process, holding this dead stream's queue forever.
|
|
23
|
+
handle = Loam::EventStream.broadcaster.subscribe(tenant: current_tenant, actor: current_actor) do |sse|
|
|
24
|
+
queue << sse
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
# A heartbeat comment both keeps the connection alive AND detects a dead
|
|
29
|
+
# client: writing to a closed socket raises IOError → the loop exits →
|
|
30
|
+
# ensure runs and the subscription is dropped.
|
|
31
|
+
message = queue.pop(timeout: HEARTBEAT_SECONDS)
|
|
32
|
+
response.stream.write(message || ": heartbeat\n\n")
|
|
33
|
+
end
|
|
34
|
+
rescue IOError, ActionController::Live::ClientDisconnected
|
|
35
|
+
# The client went away — nothing to do but clean up in `ensure`.
|
|
36
|
+
ensure
|
|
37
|
+
Loam::EventStream.broadcaster.unsubscribe(handle)
|
|
38
|
+
response.stream.close
|
|
39
|
+
Loam::Current.reset
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Feature flags (Loam::Features). Structural and privileged, so manager-only,
|
|
3
|
+
# like Settings. Everything routes through Loam::Features; the admin toggles
|
|
4
|
+
# the CURRENT tenant's override, and Reset drops it back to the declared /
|
|
5
|
+
# global state. Global (app-wide) flips are a deploy concern, not this screen.
|
|
6
|
+
class FeaturesController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@flags = Loam::Features.declared.map do |name|
|
|
11
|
+
{
|
|
12
|
+
name: name,
|
|
13
|
+
on: Loam::Features.on?(name),
|
|
14
|
+
overridden: Loam::Features.overridden?(name),
|
|
15
|
+
description: Loam::Features.description(name)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enable
|
|
21
|
+
Loam::Features.enable(params[:name])
|
|
22
|
+
redirect_to admin_features_path, notice: "#{params[:name]} enabled for this tenant."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def disable
|
|
26
|
+
Loam::Features.disable(params[:name])
|
|
27
|
+
redirect_to admin_features_path, notice: "#{params[:name]} disabled for this tenant."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset
|
|
31
|
+
Loam::Features.reset(params[:name])
|
|
32
|
+
redirect_to admin_features_path, notice: "#{params[:name]} reset to the default."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<h1>Features</h1>
|
|
2
|
+
|
|
3
|
+
<p>Feature flags turn a capability on or off for <strong><%%= current_tenant.name %></strong>,
|
|
4
|
+
independent of who is signed in. Toggling sets an override for this tenant;
|
|
5
|
+
Reset drops it back to the default.</p>
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Flag</th>
|
|
11
|
+
<th>State</th>
|
|
12
|
+
<th>Source</th>
|
|
13
|
+
<th>Description</th>
|
|
14
|
+
<th></th>
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<%% @flags.each do |flag| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td><%%= flag[:name] %></td>
|
|
21
|
+
<td><%%= flag[:on] ? "on" : "off" %></td>
|
|
22
|
+
<td><%%= flag[:overridden] ? "override" : "default" %></td>
|
|
23
|
+
<td><%%= flag[:description] %></td>
|
|
24
|
+
<td>
|
|
25
|
+
<%% if flag[:on] %>
|
|
26
|
+
<%%= button_to "Disable", admin_disable_feature_path(name: flag[:name]), form: { style: "display:inline" } %>
|
|
27
|
+
<%% else %>
|
|
28
|
+
<%%= button_to "Enable", admin_enable_feature_path(name: flag[:name]), form: { style: "display:inline" } %>
|
|
29
|
+
<%% end %>
|
|
30
|
+
<%% if flag[:overridden] %>
|
|
31
|
+
<%%= button_to "Reset", admin_features_path(name: flag[:name]), method: :delete, form: { style: "display:inline" } %>
|
|
32
|
+
<%% end %>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<%% end %>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Manages Loam::FieldDefinition records: the "migration-free field" screen.
|
|
3
|
+
# Structural, not per-entity — gated on the manager role rather than a
|
|
4
|
+
# generated entity policy, since a field definition applies to a whole
|
|
5
|
+
# entity_type, not one record.
|
|
6
|
+
class FieldDefinitionsController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
before_action :set_record, only: %i[destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@entity_type = params[:entity_type]
|
|
12
|
+
@records = Loam::FieldDefinition.order(:entity_type, :name)
|
|
13
|
+
@records = @records.where(entity_type: @entity_type) if @entity_type.present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def new
|
|
17
|
+
@record = Loam::FieldDefinition.new(entity_type: params[:entity_type])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
@record = Loam::FieldDefinition.new(permitted_params)
|
|
22
|
+
@record.writable_roles = parse_roles(params.dig(:field_definition, :writable_roles))
|
|
23
|
+
@record.readable_roles = parse_roles(params.dig(:field_definition, :readable_roles))
|
|
24
|
+
|
|
25
|
+
if @record.save
|
|
26
|
+
redirect_to admin_field_definitions_path(entity_type: @record.entity_type)
|
|
27
|
+
else
|
|
28
|
+
render :new, status: :unprocessable_entity
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy
|
|
33
|
+
entity_type = @record.entity_type
|
|
34
|
+
@record.destroy!
|
|
35
|
+
redirect_to admin_field_definitions_path(entity_type: entity_type)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def set_record
|
|
41
|
+
@record = Loam::FieldDefinition.find(params[:id])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def permitted_params
|
|
45
|
+
params.require(:field_definition).permit(:entity_type, :name, :field_type, :dictionary_key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_roles(raw)
|
|
49
|
+
raw.to_s.split(",").map(&:strip).reject(&:blank?)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<h1>Field definitions<%%= " — #{@entity_type}" if @entity_type.present? %></h1>
|
|
2
|
+
|
|
3
|
+
<p>Add a field to an entity with no migration and no deploy.</p>
|
|
4
|
+
|
|
5
|
+
<%%= link_to "New field definition", new_admin_field_definition_path(entity_type: @entity_type) %>
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Entity type</th>
|
|
11
|
+
<th>Name</th>
|
|
12
|
+
<th>Field type</th>
|
|
13
|
+
<th>Writable by</th>
|
|
14
|
+
<th>Readable by</th>
|
|
15
|
+
<th></th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<%% @records.each do |record| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%%= record.entity_type %></td>
|
|
22
|
+
<td><%%= record.name %></td>
|
|
23
|
+
<td><%%= record.field_type %></td>
|
|
24
|
+
<td><%%= record.writable_roles.presence&.join(", ") || "any member" %></td>
|
|
25
|
+
<td><%%= record.readable_roles.presence&.join(", ") || "any member" %></td>
|
|
26
|
+
<td><%%= button_to "Delete", admin_field_definition_path(record), method: :delete, form: { style: "display:inline" }, data: { turbo_confirm: "Delete this field? Existing data under this key becomes inaccessible." } %></td>
|
|
27
|
+
</tr>
|
|
28
|
+
<%% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<h1>New field definition</h1>
|
|
2
|
+
|
|
3
|
+
<%%= form_with model: @record, url: admin_field_definitions_path do |f| %>
|
|
4
|
+
<%% if @record.errors.any? %>
|
|
5
|
+
<ul>
|
|
6
|
+
<%% @record.errors.full_messages.each do |message| %>
|
|
7
|
+
<li><%%= message %></li>
|
|
8
|
+
<%% end %>
|
|
9
|
+
</ul>
|
|
10
|
+
<%% end %>
|
|
11
|
+
|
|
12
|
+
<p>
|
|
13
|
+
<%%= f.label :entity_type %><br>
|
|
14
|
+
<%%= f.text_field :entity_type %>
|
|
15
|
+
</p>
|
|
16
|
+
<p>
|
|
17
|
+
<%%= f.label :name %><br>
|
|
18
|
+
<%%= f.text_field :name %>
|
|
19
|
+
</p>
|
|
20
|
+
<p>
|
|
21
|
+
<%%= f.label :field_type %><br>
|
|
22
|
+
<%%= f.select :field_type, Loam::FieldDefinition::FIELD_TYPES %>
|
|
23
|
+
</p>
|
|
24
|
+
<p>
|
|
25
|
+
<%%= f.label :dictionary_key, "Dictionary key (only for a 'dictionary' field type)" %><br>
|
|
26
|
+
<%%= f.select :dictionary_key, Loam::Dictionary.order(:key).pluck(:key), include_blank: true %>
|
|
27
|
+
</p>
|
|
28
|
+
<p>
|
|
29
|
+
<%%= f.label :writable_roles, "Writable by (comma-separated roles, blank = any member)" %><br>
|
|
30
|
+
<%%= f.text_field :writable_roles %>
|
|
31
|
+
</p>
|
|
32
|
+
<p>
|
|
33
|
+
<%%= f.label :readable_roles, "Readable by (comma-separated roles, blank = any member)" %><br>
|
|
34
|
+
<%%= f.text_field :readable_roles %>
|
|
35
|
+
<small>Restricts who can see, filter, and sort by this field.</small>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<%%= f.submit "Create field definition" %>
|
|
39
|
+
<%% end %>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# A record's change history (Loam::AuditRecord) with one-click Undo (L-704).
|
|
3
|
+
# Undo applies the inverse of a change and records ITSELF as an audit, so
|
|
4
|
+
# undoing an "undo" entry is redo — no separate redo action. Reading needs read
|
|
5
|
+
# on the record; undoing needs update (the same gate as editing it).
|
|
6
|
+
class HistoryController < BaseController
|
|
7
|
+
before_action :set_target
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
authorize!(policy_for(@record), :read?)
|
|
11
|
+
@audits = Loam::AuditRecord
|
|
12
|
+
.where(auditable_type: @record.class.name, auditable_id: @record.id)
|
|
13
|
+
.order(id: :desc).limit(100)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def undo
|
|
17
|
+
authorize!(policy_for(@record), :update?)
|
|
18
|
+
audit = Loam::AuditRecord.find(params[:id])
|
|
19
|
+
Loam::Undo.undo(audit, policy: policy_for(@record))
|
|
20
|
+
redirect_to admin_history_path(type: @record.class.name, record_id: @record.id), notice: "Change undone."
|
|
21
|
+
rescue Loam::Undo::NotUndoableError => error
|
|
22
|
+
redirect_to admin_history_path(type: @record.class.name, record_id: @record.id), alert: error.message
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# The target record, looked up through its OWN default scope (tenancy holds;
|
|
28
|
+
# a soft-deleted row is still reachable to restore). The type is whitelisted
|
|
29
|
+
# to a Loam::TenantRecord — never a bare constantize of a param.
|
|
30
|
+
def set_target
|
|
31
|
+
klass = params[:type].to_s.safe_constantize
|
|
32
|
+
raise Loam::NotAuthorizedError unless klass.is_a?(Class) && klass < Loam::TenantRecord
|
|
33
|
+
|
|
34
|
+
scope = klass.respond_to?(:with_deleted) ? klass.with_deleted : klass.all
|
|
35
|
+
@record = scope.find(params[:record_id])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<h1>History: <%%= @record.class.model_name.human %> #<%%= @record.id %></h1>
|
|
2
|
+
|
|
3
|
+
<p><%%= link_to "← Back", [:admin, @record] %></p>
|
|
4
|
+
|
|
5
|
+
<p>Undo applies the inverse of a change and is itself recorded — so undoing an
|
|
6
|
+
<code>undo</code> row is redo. Only the most recent field change can be undone
|
|
7
|
+
(undo walks back one step at a time); encrypted fields and the workflow state are
|
|
8
|
+
never reverted here.</p>
|
|
9
|
+
|
|
10
|
+
<table>
|
|
11
|
+
<thead>
|
|
12
|
+
<tr><th>When</th><th>Action</th><th>By</th><th>Changes</th><th></th></tr>
|
|
13
|
+
</thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
<%% @audits.each do |audit| %>
|
|
16
|
+
<tr>
|
|
17
|
+
<td><%%= audit.created_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
18
|
+
<td><code><%%= audit.action %></code></td>
|
|
19
|
+
<td><%%= audit.actor&.name || "—" %></td>
|
|
20
|
+
<td>
|
|
21
|
+
<%% (audit.changeset || {}).each do |field, values| %>
|
|
22
|
+
<div><code><%%= field %></code>:
|
|
23
|
+
<%%= values.is_a?(Array) ? "#{values.first.inspect} → #{values.last.inspect}" : values %></div>
|
|
24
|
+
<%% end %>
|
|
25
|
+
</td>
|
|
26
|
+
<td>
|
|
27
|
+
<%% if Loam::Undo.undoable?(audit) %>
|
|
28
|
+
<%%= button_to "Undo", admin_undo_history_path(id: audit.id, type: @record.class.name, record_id: @record.id),
|
|
29
|
+
form: { style: "display:inline" } %>
|
|
30
|
+
<%% end %>
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
<%% end %>
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Generic CSV importer, scoped by entity_type — manager-only (a privileged bulk
|
|
3
|
+
# mutation). Flow: new (upload) → preview (map columns) → create (dry-run
|
|
4
|
+
# summary, or a backgrounded run reporting live progress). The mapping only
|
|
5
|
+
# offers fields the manager may write (Loam::Import.allowed_targets); a crafted
|
|
6
|
+
# target is refused by the engine.
|
|
7
|
+
class ImportsController < BaseController
|
|
8
|
+
before_action { require_role!(:manager) }
|
|
9
|
+
before_action :set_model, only: %i[preview create]
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
@entity_type = params[:entity_type]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Parse the uploaded file, show the mapping form + a few sample rows.
|
|
16
|
+
def preview
|
|
17
|
+
@csv = read_upload
|
|
18
|
+
preview = Loam::Import.preview(@csv)
|
|
19
|
+
@headers = preview[:headers]
|
|
20
|
+
@sample = preview[:rows]
|
|
21
|
+
@targets = Loam::Import.allowed_targets(@model, current_actor).to_a.sort
|
|
22
|
+
rescue Loam::Error => error
|
|
23
|
+
redirect_to new_admin_import_path(entity_type: @entity_type), alert: error.message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create
|
|
27
|
+
@csv = params[:csv].to_s
|
|
28
|
+
mapping = params[:mapping].to_unsafe_h.reject { |_h, target| target.blank? }
|
|
29
|
+
match_key = params[:match_key].presence
|
|
30
|
+
|
|
31
|
+
if params[:commit] == "Dry run"
|
|
32
|
+
run_dry(mapping, match_key)
|
|
33
|
+
else
|
|
34
|
+
ImportJob.perform_later(entity_type: @entity_type, csv: @csv, mapping: mapping,
|
|
35
|
+
match_key: match_key, tenant_id: current_tenant.id, actor_id: current_actor.id)
|
|
36
|
+
redirect_to admin_progress_jobs_path, notice: "Import started — watch it progress under Tasks."
|
|
37
|
+
end
|
|
38
|
+
rescue Loam::Error => error
|
|
39
|
+
redirect_to new_admin_import_path(entity_type: @entity_type), alert: error.message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The failed rows as a fix-and-re-upload CSV.
|
|
43
|
+
def download_errors
|
|
44
|
+
@entity_type = params[:entity_type]
|
|
45
|
+
model = Loam::Import.allowed_model(@entity_type)
|
|
46
|
+
csv = params[:csv].to_s
|
|
47
|
+
mapping = params[:mapping].to_unsafe_h.reject { |_h, target| target.blank? }
|
|
48
|
+
result = Loam::Import.run(csv, model: model, mapping: mapping, actor: current_actor,
|
|
49
|
+
match_key: params[:match_key].presence, dry_run: true)
|
|
50
|
+
send_data Loam::Import.error_csv(result, csv),
|
|
51
|
+
filename: "#{@entity_type.underscore}-import-errors.csv", type: "text/csv"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def set_model
|
|
57
|
+
@entity_type = params[:entity_type]
|
|
58
|
+
@model = Loam::Import.allowed_model(@entity_type)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_dry(mapping, match_key)
|
|
62
|
+
@result = Loam::Import.run(@csv, model: @model, mapping: mapping, actor: current_actor,
|
|
63
|
+
match_key: match_key, dry_run: true)
|
|
64
|
+
@headers = Loam::Import.preview(@csv)[:headers]
|
|
65
|
+
render :summary
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_upload
|
|
69
|
+
file = params[:file]
|
|
70
|
+
raise Loam::Error, "choose a CSV file to upload" if file.blank?
|
|
71
|
+
|
|
72
|
+
file.read.force_encoding("UTF-8")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<h1>Import <%%= @entity_type %></h1>
|
|
2
|
+
|
|
3
|
+
<p>Upload a CSV, map its columns to fields, then dry-run or import. Rows that
|
|
4
|
+
fail validation are skipped and reported (with a downloadable error file).</p>
|
|
5
|
+
|
|
6
|
+
<%%= form_with url: preview_admin_imports_path, method: :post, multipart: true do %>
|
|
7
|
+
<%%= hidden_field_tag :entity_type, @entity_type %>
|
|
8
|
+
<p><%%= file_field_tag :file, accept: ".csv" %></p>
|
|
9
|
+
<%%= submit_tag "Upload & map" %>
|
|
10
|
+
<%% end %>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<h1>Map columns — <%%= @entity_type %></h1>
|
|
2
|
+
|
|
3
|
+
<%%= form_with url: admin_imports_path, method: :post do %>
|
|
4
|
+
<%%= hidden_field_tag :entity_type, @entity_type %>
|
|
5
|
+
<%%= hidden_field_tag :csv, @csv %>
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<thead><tr><th>CSV column</th><th>Maps to field</th><th>Sample</th></tr></thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<%% @headers.each_with_index do |header, i| %>
|
|
11
|
+
<tr>
|
|
12
|
+
<td><%%= header %></td>
|
|
13
|
+
<td>
|
|
14
|
+
<%%= select_tag "mapping[#{header}]",
|
|
15
|
+
options_for_select([["— skip —", ""]] + @targets.map { |t| [t, t] }, @targets.include?(header) ? header : "") %>
|
|
16
|
+
</td>
|
|
17
|
+
<td><%%= @sample.map { |r| r[i] }.compact.first %></td>
|
|
18
|
+
</tr>
|
|
19
|
+
<%% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
|
|
23
|
+
<p>
|
|
24
|
+
<%%= label_tag :match_key, "Update existing rows matching (blank = create only)" %>
|
|
25
|
+
<%%= select_tag :match_key, options_for_select([["— create only —", ""]] + @targets.map { |t| [t, t] }) %>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<p>
|
|
29
|
+
<%%= submit_tag "Dry run" %>
|
|
30
|
+
<%%= submit_tag "Import" %>
|
|
31
|
+
</p>
|
|
32
|
+
<%% end %>
|