diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 0000000..cdf47a2
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Admin
+ class BaseController < ActionController::Base
+ layout 'admin'
+ protect_from_forgery with: :exception
+
+ http_basic_authenticate_with(
+ name: Rails.application.config.admin_basic_auth_username,
+ password: Rails.application.config.admin_basic_auth_password
+ )
+
+ helper_method :pretty_json
+
+ private
+
+ def params_page_size
+ (params[:page_size] || 25).to_i.clamp(0, 100)
+ end
+
+ def pretty_json(value)
+ JSON.pretty_generate(value.presence || {})
+ end
+ end
+end
diff --git a/app/controllers/admin/contexts_controller.rb b/app/controllers/admin/contexts_controller.rb
new file mode 100644
index 0000000..b569f78
--- /dev/null
+++ b/app/controllers/admin/contexts_controller.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Admin
+ class ContextsController < BaseController
+ rescue_from ::Contexts::CreateAttributes::ValidationError,
+ ::Contexts::UpdateAttributes::ValidationError,
+ Batch::RuntimeConfig::ValidationError,
+ ActiveRecord::RecordInvalid do |e|
+ flash.now[:error] = e.message
+ @context =
+ if params[:id]
+ Context.find(params[:id])
+ else
+ Context.new(context_form_params.except('metadata'))
+ end
+ render action_name == 'create' ? :new : :edit, status: :unprocessable_entity
+ end
+ rescue_from ActiveRecord::DeleteRestrictionError do |e|
+ redirect_to admin_context_path(params[:id]), alert: e.message
+ end
+
+ def index
+ @contexts = Context.order(id: :desc).limit(params_page_size)
+ end
+
+ def show
+ @context = Context.find(params[:id])
+ @matching_definitions = matching_definitions(@context)
+ end
+
+ def new
+ @context = Context.new(
+ metadata: {
+ 'batch' => {}
+ }
+ )
+ end
+
+ def create
+ @context = ::Contexts::CreateAttributes.new(params: context_form_params).call
+ redirect_to admin_context_path(@context), notice: 'Context created'
+ end
+
+ def edit
+ @context = Context.find(params[:id])
+ end
+
+ def update
+ @context = Context.find(params[:id])
+ ::Contexts::UpdateAttributes.new(context: @context, params: context_form_params).call
+ redirect_to admin_context_path(@context), notice: 'Context updated'
+ end
+
+ def destroy
+ context = Context.find(params[:id])
+ context.destroy!
+ redirect_to admin_contexts_path, notice: 'Context deleted'
+ end
+
+ private
+
+ def context_form_params
+ permitted = params.require(:context).permit(
+ *::Contexts::UpdateAttributes::CONTEXT_ATTRIBUTE_KEYS,
+ :metadata_json
+ )
+
+ raw = permitted.to_h
+ metadata_json = raw.delete('metadata_json')
+ raw['metadata'] = parse_json_field(metadata_json, field_name: 'metadata') if metadata_json.present?
+ raw
+ end
+
+ def parse_json_field(raw_json, field_name:)
+ JSON.parse(raw_json)
+ rescue JSON::ParserError => e
+ raise ::Contexts::UpdateAttributes::ValidationError, "#{field_name} must be valid JSON: #{e.message}"
+ end
+
+ def matching_definitions(context)
+ LabelExtractorDefinition.where(
+ module_name: context.module_name,
+ extractor_name: context.extractor_name
+ ).order(updated_at: :desc)
+ end
+ end
+end
diff --git a/app/controllers/admin/label_extractor_definitions_controller.rb b/app/controllers/admin/label_extractor_definitions_controller.rb
new file mode 100644
index 0000000..32df1bf
--- /dev/null
+++ b/app/controllers/admin/label_extractor_definitions_controller.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Admin
+ class LabelExtractorDefinitionsController < BaseController
+ class ValidationError < StandardError; end
+
+ rescue_from ActiveRecord::RecordInvalid do
+ flash.now[:error] = @definition.errors.full_messages.to_sentence
+ prepare_form_state
+ render action_name == 'update' ? :edit : :new, status: :unprocessable_entity
+ end
+ rescue_from ValidationError do |e|
+ flash.now[:error] = e.message
+ prepare_form_state
+ render action_name == 'create' ? :new : :edit, status: :unprocessable_entity
+ end
+
+ def index
+ @definitions = LabelExtractorDefinition.order(updated_at: :desc).limit(params_page_size)
+ end
+
+ def new
+ @definition = LabelExtractorDefinition.new(enabled: true)
+ end
+
+ def create
+ @definition = LabelExtractorDefinition.new(definition_attributes)
+ @definition.save!
+ redirect_to admin_label_extractor_definitions_path, notice: 'Label extractor definition created'
+ end
+
+ def edit
+ @definition = LabelExtractorDefinition.find(params[:id])
+ @return_to = resolved_return_to
+ end
+
+ def update
+ @definition = LabelExtractorDefinition.find(params[:id])
+ @definition.update!(definition_attributes)
+ redirect_to resolved_return_to, notice: 'Label extractor definition updated'
+ end
+
+ def destroy
+ definition = LabelExtractorDefinition.find(params[:id])
+ definition.destroy!
+ redirect_to admin_label_extractor_definitions_path, notice: 'Label extractor definition deleted'
+ end
+
+ private
+
+ def definition_form_params
+ params.require(:label_extractor_definition).permit(
+ :module_name,
+ :extractor_name,
+ :enabled,
+ :config_json
+ )
+ end
+
+ def definition_attributes
+ {
+ module_name: definition_form_params[:module_name],
+ extractor_name: definition_form_params[:extractor_name],
+ enabled: truthy?(definition_form_params[:enabled]),
+ config: parse_json_field(definition_form_params[:config_json], field_name: 'config')
+ }
+ end
+
+ def parse_json_field(raw_json, field_name:)
+ JSON.parse(raw_json.presence || '{}')
+ rescue JSON::ParserError => e
+ raise ValidationError, "#{field_name} must be valid JSON: #{e.message}"
+ end
+
+ def truthy?(value)
+ ActiveModel::Type::Boolean.new.cast(value)
+ end
+
+ def prepare_form_state
+ @definition ||= params[:id] ? LabelExtractorDefinition.find(params[:id]) : LabelExtractorDefinition.new(enabled: true)
+ @return_to = resolved_return_to
+ return unless params[:label_extractor_definition]
+
+ @definition.assign_attributes(
+ module_name: definition_form_params[:module_name],
+ extractor_name: definition_form_params[:extractor_name],
+ enabled: truthy?(definition_form_params[:enabled])
+ )
+ @config_json_value = definition_form_params[:config_json]
+ end
+
+ def resolved_return_to
+ requested_path = params[:return_to].presence
+ return admin_label_extractor_definitions_path if requested_path.blank?
+
+ uri = URI.parse(requested_path)
+ return admin_label_extractor_definitions_path if uri.host.present? || uri.scheme.present?
+ return admin_label_extractor_definitions_path unless uri.path.start_with?('/admin')
+
+ uri.to_s
+ rescue URI::InvalidURIError
+ admin_label_extractor_definitions_path
+ end
+ end
+end
diff --git a/app/controllers/contexts_controller.rb b/app/controllers/contexts_controller.rb
index abd7f07..410d393 100644
--- a/app/controllers/contexts_controller.rb
+++ b/app/controllers/contexts_controller.rb
@@ -1,22 +1,6 @@
# frozen_string_literal: true
class ContextsController < ApplicationController
- class ValidationError < StandardError; end
-
- CONTEXT_ATTRIBUTE_KEYS = %w[
- workflow_id
- project_id
- active_subject_set_id
- pool_subject_set_id
- module_name
- extractor_name
- ].freeze
- INTEGER_ATTRIBUTE_KEYS = %w[
- workflow_id
- project_id
- active_subject_set_id
- pool_subject_set_id
- ].freeze
wrap_parameters false
# as we're running in API mode we need to include basic auth
@@ -27,13 +11,9 @@ class ValidationError < StandardError; end
password: Rails.application.config.api_basic_auth_password
)
- rescue_from ValidationError do |e|
- json_error_render(:unprocessable_entity, e)
- end
- rescue_from Batch::RuntimeConfig::ValidationError do |e|
- json_error_render(:unprocessable_entity, e)
- end
- rescue_from ActiveRecord::RecordInvalid do |e|
+ rescue_from Contexts::UpdateAttributes::ValidationError,
+ Batch::RuntimeConfig::ValidationError,
+ ActiveRecord::RecordInvalid do |e|
json_error_render(:unprocessable_entity, e)
end
@@ -54,18 +34,8 @@ def show
end
def update
- validate_context_params!
-
context = Context.find(params[:id])
- attributes = context_params.slice(*CONTEXT_ATTRIBUTE_KEYS)
-
- if context_params.key?('metadata')
- metadata = context_metadata(context).deep_merge(context_params.fetch('metadata').to_h)
- validate_batch_metadata!(metadata['batch']) if context_params.fetch('metadata').to_h.key?('batch')
- attributes[:metadata] = metadata
- end
-
- context.update!(attributes)
+ Contexts::UpdateAttributes.new(context: context, params: context_params).call
render(
status: :ok,
@@ -77,54 +47,9 @@ def update
def context_params
@context_params ||= params.permit(
- *CONTEXT_ATTRIBUTE_KEYS,
+ *Contexts::UpdateAttributes::CONTEXT_ATTRIBUTE_KEYS,
:metadata,
metadata: {}
)
end
-
- def validate_context_params!
- raise ValidationError, 'at least one supported context field is required' if context_params.empty?
-
- if context_params.key?('metadata') && !context_params['metadata'].respond_to?(:to_h)
- raise ValidationError, 'metadata must be an object'
- end
-
- INTEGER_ATTRIBUTE_KEYS.each { |key| validate_integer_param!(key) if context_params.key?(key) }
- validate_extractor_pair!
- end
-
- def context_metadata(context)
- context.metadata.is_a?(Hash) ? context.metadata.deep_dup : {}
- end
-
- def validate_batch_metadata!(batch_config)
- return if batch_config.nil?
-
- unless batch_config.respond_to?(:to_h)
- raise ValidationError, 'metadata.batch must be an object'
- end
-
- Batch::RuntimeConfig.validate!(batch_config)
- end
-
- def validate_extractor_pair!
- return unless context_params.key?('module_name') || context_params.key?('extractor_name')
-
- context = Context.find(params[:id])
- module_name = context_params.key?('module_name') ? context_params['module_name'] : context.module_name
- extractor_name = context_params.key?('extractor_name') ? context_params['extractor_name'] : context.extractor_name
-
- return if LabelExtractors::Registry.extractor_registered?(module_name, extractor_name)
-
- raise ValidationError, "unknown module/extractor pair: #{module_name}/#{extractor_name}"
- end
-
- def validate_integer_param!(key)
- value = context_params[key]
- return if value.is_a?(Integer)
- return if value.is_a?(String) && value.match?(/\A\d+\z/)
-
- raise ValidationError, "#{key} must be an integer"
- end
end
diff --git a/app/modules/label_extractors/configurable_extractor.rb b/app/modules/label_extractors/configurable_extractor.rb
index 7a151f2..726bd0d 100644
--- a/app/modules/label_extractors/configurable_extractor.rb
+++ b/app/modules/label_extractors/configurable_extractor.rb
@@ -38,10 +38,11 @@ def self.normalize_config(config)
def self.validate_config!(config)
raise ConfigurationError, 'config must be an object' unless config.is_a?(Hash)
- validate_string!(config, 'data_release_suffix')
- validate_hash!(config, 'task_key_label_prefixes')
- validate_hash!(config, 'task_key_data_labels')
- validate_task_mappings!(config)
+ # commented out for now to allow for more flexible config
+ # validate_string!(config, 'data_release_suffix')
+ # validate_hash!(config, 'task_key_label_prefixes')
+ # validate_hash!(config, 'task_key_data_labels')
+ # validate_task_mappings!(config)
end
def self.validate_string!(config, key)
diff --git a/app/services/contexts/create_attributes.rb b/app/services/contexts/create_attributes.rb
new file mode 100644
index 0000000..6148d58
--- /dev/null
+++ b/app/services/contexts/create_attributes.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Contexts
+ class CreateAttributes
+ class ValidationError < StandardError; end
+
+ def initialize(params:)
+ @params = normalize_params(params)
+ end
+
+ def call
+ validate!
+
+ attributes = params.slice(*UpdateAttributes::CONTEXT_ATTRIBUTE_KEYS)
+ attributes['metadata'] = params['metadata'].to_h if params.key?('metadata')
+
+ Context.create!(attributes)
+ end
+
+ private
+
+ attr_reader :params
+
+ def normalize_params(raw_params)
+ hash =
+ if raw_params.respond_to?(:to_unsafe_h)
+ raw_params.to_unsafe_h
+ elsif raw_params.respond_to?(:to_h)
+ raw_params.to_h
+ else
+ {}
+ end
+
+ hash.deep_stringify_keys.slice(*UpdateAttributes::CONTEXT_ATTRIBUTE_KEYS, 'metadata')
+ end
+
+ def validate!
+ raise ValidationError, 'at least one supported context field is required' if params.empty?
+
+ if params.key?('metadata') && !params['metadata'].respond_to?(:to_h)
+ raise ValidationError, 'metadata must be an object'
+ end
+
+ UpdateAttributes::INTEGER_ATTRIBUTE_KEYS.each { |key| validate_integer_param!(key) if params.key?(key) }
+ validate_batch_metadata!(params.dig('metadata', 'batch')) if params.key?('metadata')
+ validate_required_fields!
+ validate_extractor_pair!
+ end
+
+ def validate_batch_metadata!(batch_config)
+ return if batch_config.nil?
+
+ unless batch_config.respond_to?(:to_h)
+ raise ValidationError, 'metadata.batch must be an object'
+ end
+
+ Batch::RuntimeConfig.validate!(batch_config)
+ end
+
+ def validate_required_fields!
+ required_keys = %w[
+ workflow_id
+ project_id
+ active_subject_set_id
+ pool_subject_set_id
+ module_name
+ extractor_name
+ ]
+
+ missing = required_keys.select { |key| params[key].blank? }
+ raise ValidationError, "missing required context fields: #{missing.join(', ')}" if missing.any?
+ end
+
+ def validate_extractor_pair!
+ return if LabelExtractors::Registry.extractor_registered?(params['module_name'], params['extractor_name'])
+
+ raise ValidationError, "unknown module/extractor pair: #{params['module_name']}/#{params['extractor_name']}"
+ end
+
+ def validate_integer_param!(key)
+ value = params[key]
+ return if value.is_a?(Integer)
+ return if value.is_a?(String) && value.match?(/\A\d+\z/)
+
+ raise ValidationError, "#{key} must be an integer"
+ end
+ end
+end
diff --git a/app/services/contexts/update_attributes.rb b/app/services/contexts/update_attributes.rb
new file mode 100644
index 0000000..070ba21
--- /dev/null
+++ b/app/services/contexts/update_attributes.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Contexts
+ class UpdateAttributes
+ class ValidationError < StandardError; end
+
+ CONTEXT_ATTRIBUTE_KEYS = %w[
+ workflow_id
+ project_id
+ active_subject_set_id
+ pool_subject_set_id
+ module_name
+ extractor_name
+ ].freeze
+ INTEGER_ATTRIBUTE_KEYS = %w[
+ workflow_id
+ project_id
+ active_subject_set_id
+ pool_subject_set_id
+ ].freeze
+
+ def initialize(context:, params:)
+ @context = context
+ @params = normalize_params(params)
+ end
+
+ def call
+ validate!
+
+ attributes = params.slice(*CONTEXT_ATTRIBUTE_KEYS)
+
+ if params.key?('metadata')
+ metadata = current_metadata.deep_merge(params.fetch('metadata').to_h)
+ validate_batch_metadata!(metadata['batch']) if params.fetch('metadata').to_h.key?('batch')
+ attributes['metadata'] = metadata
+ end
+
+ context.update!(attributes)
+ context
+ end
+
+ private
+
+ attr_reader :context, :params
+
+ def normalize_params(raw_params)
+ hash =
+ if raw_params.respond_to?(:to_unsafe_h)
+ raw_params.to_unsafe_h
+ elsif raw_params.respond_to?(:to_h)
+ raw_params.to_h
+ else
+ {}
+ end
+
+ hash.deep_stringify_keys.slice(*CONTEXT_ATTRIBUTE_KEYS, 'metadata')
+ end
+
+ def validate!
+ raise ValidationError, 'at least one supported context field is required' if params.empty?
+
+ if params.key?('metadata') && !params['metadata'].respond_to?(:to_h)
+ raise ValidationError, 'metadata must be an object'
+ end
+
+ INTEGER_ATTRIBUTE_KEYS.each { |key| validate_integer_param!(key) if params.key?(key) }
+ validate_extractor_pair!
+ end
+
+ def current_metadata
+ context.metadata.is_a?(Hash) ? context.metadata.deep_dup : {}
+ end
+
+ def validate_batch_metadata!(batch_config)
+ return if batch_config.nil?
+
+ unless batch_config.respond_to?(:to_h)
+ raise ValidationError, 'metadata.batch must be an object'
+ end
+
+ Batch::RuntimeConfig.validate!(batch_config)
+ end
+
+ def validate_extractor_pair!
+ return unless params.key?('module_name') || params.key?('extractor_name')
+
+ module_name = params.key?('module_name') ? params['module_name'] : context.module_name
+ extractor_name = params.key?('extractor_name') ? params['extractor_name'] : context.extractor_name
+
+ return if LabelExtractors::Registry.extractor_registered?(module_name, extractor_name)
+
+ raise ValidationError, "unknown module/extractor pair: #{module_name}/#{extractor_name}"
+ end
+
+ def validate_integer_param!(key)
+ value = params[key]
+ return if value.is_a?(Integer)
+ return if value.is_a?(String) && value.match?(/\A\d+\z/)
+
+ raise ValidationError, "#{key} must be an integer"
+ end
+ end
+end
diff --git a/app/views/admin/contexts/_form.html.erb b/app/views/admin/contexts/_form.html.erb
new file mode 100644
index 0000000..cd81b60
--- /dev/null
+++ b/app/views/admin/contexts/_form.html.erb
@@ -0,0 +1,45 @@
+<%= form_with model: form_model, url: form_url, method: form_method do |form| %>
+
+
+
+ <%= form.label :workflow_id %>
+ <%= form.number_field :workflow_id, class: 'form-control' %>
+
+
+ <%= form.label :project_id %>
+ <%= form.number_field :project_id, class: 'form-control' %>
+
+
+ <%= form.label :active_subject_set_id %>
+ <%= form.number_field :active_subject_set_id, class: 'form-control' %>
+
+
+ <%= form.label :pool_subject_set_id %>
+ <%= form.number_field :pool_subject_set_id, class: 'form-control' %>
+
+
+ <%= form.label :module_name %>
+ <%= form.text_field :module_name, class: 'form-control' %>
+
+
+ <%= form.label :extractor_name %>
+ <%= form.text_field :extractor_name, class: 'form-control' %>
+
+
+
+
+
+
+ <%= form.submit submit_label, class: 'btn btn-primary' %>
+<% end %>
diff --git a/app/views/admin/contexts/edit.html.erb b/app/views/admin/contexts/edit.html.erb
new file mode 100644
index 0000000..a19c5da
--- /dev/null
+++ b/app/views/admin/contexts/edit.html.erb
@@ -0,0 +1,11 @@
+
+
+
Edit Context <%= @context.id %>
+
This uses the same validation path as the JSON API for integer fields, metadata shape, batch runtime config, and extractor pair resolution.
+ <%= render 'form',
+ form_model: @context,
+ form_url: admin_context_path(@context),
+ form_method: :patch,
+ submit_label: 'Save context' %>
+
+
diff --git a/app/views/admin/contexts/index.html.erb b/app/views/admin/contexts/index.html.erb
new file mode 100644
index 0000000..0f2af7e
--- /dev/null
+++ b/app/views/admin/contexts/index.html.erb
@@ -0,0 +1,45 @@
+
+
+
+ <%= link_to 'Add', new_admin_context_path, class: 'btn btn-primary' %>
+ <%= link_to 'Label Extractors', admin_label_extractor_definitions_path, class: 'btn btn-primary' %>
+
+
Contexts
+
+
+
+ ID
+ Workflow ID
+ Project ID
+ Module
+ Extractor
+ Label Extractor Definition
+ Updated
+
+
+
+ <% @contexts.each do |context| %>
+ <% matching_definitions = LabelExtractorDefinition.where(module_name: context.module_name, extractor_name: context.extractor_name) %>
+ <% enabled_definition = matching_definitions.find(&:enabled?) %>
+
+ <%= link_to context.id, admin_context_path(context) %>
+ <%= context.workflow_id %>
+ <%= context.project_id %>
+ <%= context.module_name %>
+ <%= context.extractor_name %>
+
+ <% if enabled_definition %>
+ Enabled
+ <% elsif matching_definitions.any? %>
+ Disabled
+ <% else %>
+ None
+ <% end %>
+
+ <%= context.updated_at %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/contexts/label_extractor_definitions/index.html.erb b/app/views/admin/contexts/label_extractor_definitions/index.html.erb
new file mode 100644
index 0000000..9a07743
--- /dev/null
+++ b/app/views/admin/contexts/label_extractor_definitions/index.html.erb
@@ -0,0 +1,37 @@
+
+
+
Definitions for Context <%= @context.id %>
+
Only definitions matching <%= @context.module_name %>/<%= @context.extractor_name %> are shown here.
+
+
+
+
+
+ <% if @definitions.any? %>
+
+
+
+ ID
+ Enabled
+ Updated
+ Actions
+
+
+
+ <% @definitions.each do |definition| %>
+
+ <%= link_to definition.id, edit_admin_label_extractor_definition_path(definition) %>
+ <%= definition.enabled? ? 'Yes' : 'No' %>
+ <%= definition.updated_at %>
+
+ <%= link_to 'Edit', edit_admin_label_extractor_definition_path(definition), class: 'btn btn-default' %>
+
+
+ <% end %>
+
+
+ <% else %>
+
No matching DB-backed definitions exist yet.
+ <% end %>
+
+
diff --git a/app/views/admin/contexts/new.html.erb b/app/views/admin/contexts/new.html.erb
new file mode 100644
index 0000000..120fcca
--- /dev/null
+++ b/app/views/admin/contexts/new.html.erb
@@ -0,0 +1,11 @@
+
+
+
Add Context
+
Create a new workflow context and optionally seed its metadata batch configuration.
+ <%= render 'form',
+ form_model: @context,
+ form_url: admin_contexts_path,
+ form_method: :post,
+ submit_label: 'Create context' %>
+
+
diff --git a/app/views/admin/contexts/show.html.erb b/app/views/admin/contexts/show.html.erb
new file mode 100644
index 0000000..baed847
--- /dev/null
+++ b/app/views/admin/contexts/show.html.erb
@@ -0,0 +1,78 @@
+
+
+
+ <%= link_to 'Edit context', edit_admin_context_path(@context), class: 'btn btn-default' %>
+
+
Context <%= @context.id %>
+
+
+
+
+
+
+
+ Workflow ID <%= @context.workflow_id %>
+ Project ID <%= @context.project_id %>
+ Active Subject Set <%= @context.active_subject_set_id %>
+ Pool Subject Set <%= @context.pool_subject_set_id %>
+ Module <%= @context.module_name %>
+ Extractor <%= @context.extractor_name %>
+ Updated <%= @context.updated_at %>
+
+
+
+
+
+
+
+
Metadata
+
+
<%= pretty_json(@context.metadata) %>
+
+
+
+
+
+
+
Matching Label Extractor Definitions
+ <% if @matching_definitions.any? %>
+
+
+
+ ID
+ Enabled
+ Updated
+ Actions
+
+
+
+ <% @matching_definitions.each do |definition| %>
+
+ <%= link_to definition.id, edit_admin_label_extractor_definition_path(definition, return_to: admin_context_path(@context)) %>
+ <%= definition.enabled? ? 'Yes' : 'No' %>
+ <%= definition.updated_at %>
+
+ <%= link_to 'Edit', edit_admin_label_extractor_definition_path(definition, return_to: admin_context_path(@context)), class: 'btn btn-default' %>
+
+
+ <% end %>
+
+
+ <% else %>
+
No DB-backed label extractor definition matches this context yet.
+ <% end %>
+
+
+
+
+
+
Danger Zone
+
Delete will fail if subjects still reference this context.
+ <%= button_to 'Delete context',
+ admin_context_path(@context),
+ method: :delete,
+ class: 'btn btn-danger',
+ form_class: 'inline-form',
+ form: { onsubmit: "return confirm('Delete this context?');" } %>
+
+
diff --git a/app/views/admin/label_extractor_definitions/_form.html.erb b/app/views/admin/label_extractor_definitions/_form.html.erb
new file mode 100644
index 0000000..0052c24
--- /dev/null
+++ b/app/views/admin/label_extractor_definitions/_form.html.erb
@@ -0,0 +1,40 @@
+<% definition = form_model || LabelExtractorDefinition.new(enabled: true) %>
+<%
+ config_json =
+ local_assigns[:config_json_value].presence ||
+ @config_json_value.presence ||
+ pretty_json(definition.config)
+%>
+
+<%= form_with model: definition, url: form_url, method: form_method do |form| %>
+ <%= hidden_field_tag :return_to, local_assigns[:return_to].presence || @return_to %>
+
+ <%= form.label :module_name %>
+ <%= form.text_field :module_name, class: 'form-control' %>
+
+
+ <%= form.label :extractor_name %>
+ <%= form.text_field :extractor_name, class: 'form-control' %>
+
+
+
+ <%= form.check_box :enabled, checked: definition.enabled? %>
+ Enabled
+
+
+
+
+
+ <%= form.submit submit_label, class: 'btn btn-primary' %>
+<% end %>
diff --git a/app/views/admin/label_extractor_definitions/edit.html.erb b/app/views/admin/label_extractor_definitions/edit.html.erb
new file mode 100644
index 0000000..8e186f8
--- /dev/null
+++ b/app/views/admin/label_extractor_definitions/edit.html.erb
@@ -0,0 +1,11 @@
+
+
+
Edit Label Extractor Definition <%= @definition.id %>
+ <%= render 'form',
+ form_model: @definition,
+ form_url: admin_label_extractor_definition_path(@definition),
+ form_method: :patch,
+ return_to: @return_to,
+ submit_label: 'Save definition' %>
+
+
diff --git a/app/views/admin/label_extractor_definitions/index.html.erb b/app/views/admin/label_extractor_definitions/index.html.erb
new file mode 100644
index 0000000..0ab2c8b
--- /dev/null
+++ b/app/views/admin/label_extractor_definitions/index.html.erb
@@ -0,0 +1,41 @@
+
+
+
+ <%= link_to 'Add', new_admin_label_extractor_definition_path, class: 'btn btn-primary' %>
+
+
Label Extractor Definitions
+
Global list of DB-backed extractor definitions.
+
+
+
+ ID
+ Module
+ Extractor
+ Enabled
+ Updated
+ Actions
+
+
+
+ <% @definitions.each do |definition| %>
+
+ <%= link_to definition.id, edit_admin_label_extractor_definition_path(definition, return_to: admin_label_extractor_definitions_path) %>
+ <%= definition.module_name %>
+ <%= definition.extractor_name %>
+ <%= definition.enabled? ? 'Yes' : 'No' %>
+ <%= definition.updated_at %>
+
+ <%= link_to 'Edit', edit_admin_label_extractor_definition_path(definition, return_to: admin_label_extractor_definitions_path), class: 'btn btn-default' %>
+ <%= button_to 'Delete',
+ admin_label_extractor_definition_path(definition),
+ method: :delete,
+ class: 'btn btn-danger',
+ form_class: 'inline-form',
+ form: { onsubmit: "return confirm('Delete this definition?');" } %>
+
+
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/label_extractor_definitions/new.html.erb b/app/views/admin/label_extractor_definitions/new.html.erb
new file mode 100644
index 0000000..3e6b743
--- /dev/null
+++ b/app/views/admin/label_extractor_definitions/new.html.erb
@@ -0,0 +1,11 @@
+
+
+
Add Label Extractor Definition
+
Definitions are global and unique by module and extractor pair, not tied to a specific context page.
+ <%= render 'form',
+ form_model: @definition,
+ form_url: admin_label_extractor_definitions_path,
+ form_method: :post,
+ submit_label: 'Create definition' %>
+
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
new file mode 100644
index 0000000..104524b
--- /dev/null
+++ b/app/views/layouts/admin.html.erb
@@ -0,0 +1,118 @@
+
+
+
+
+
+ <%= content_for?(:title) ? yield(:title) : 'KaDE' %>
+
+ <%= csrf_meta_tags %>
+
+
+
+
+
+
+
+
+ <%= link_to 'Contexts', admin_contexts_path %>
+ <%= link_to 'Label Extractors', admin_label_extractor_definitions_path %>
+
+
+
+
+
+
+
+
+ <% if flash[:alert] %>
+
<%= flash[:alert] %>
+ <% end %>
+ <% if flash[:notice] %>
+
<%= flash[:notice] %>
+ <% end %>
+ <% if flash[:error] %>
+
<%= flash[:error] %>
+ <% end %>
+
+
+ <%= yield %>
+
+
+
+
diff --git a/config/application.rb b/config/application.rb
index 4c991bd..ed1fd76 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -34,6 +34,8 @@ class Application < Rails::Application
# Long term this can switch to Zooniverse API JWT token auth & pundit authorization schemes
config.api_basic_auth_username = ENV.fetch('API_BASIC_AUTH_USERNAME', 'kade-user')
config.api_basic_auth_password = ENV.fetch('API_BASIC_AUTH_PASSWORD', 'kade-password')
+ config.admin_basic_auth_username = ENV.fetch('ADMIN_BASIC_AUTH_USERNAME', config.api_basic_auth_username)
+ config.admin_basic_auth_password = ENV.fetch('ADMIN_BASIC_AUTH_PASSWORD', config.api_basic_auth_password)
# Reduction ingester basic auth scheme (Caesar etc)
config.reduction_basic_auth_username = ENV.fetch('REDUCTION_BASIC_AUTH_USERNAME', 'kade-user')
@@ -46,5 +48,7 @@ class Application < Rails::Application
# Required for all session management (regardless of session_store)
config.middleware.use ActionDispatch::Cookies
config.middleware.use config.session_store, config.session_options
+ config.middleware.use ActionDispatch::Flash
+ config.middleware.use Rack::MethodOverride
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 96d335c..ccdca3d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -59,5 +59,13 @@
resources :contexts, only: %i[index show update]
+ namespace :admin do
+ root 'contexts#index'
+ resources :contexts, only: %i[index show new create edit update destroy] do
+ resources :label_extractor_definitions, only: %i[index], module: :contexts
+ end
+ resources :label_extractor_definitions, only: %i[index new create edit update destroy]
+ end
+
# all other routes go here
end
diff --git a/public/admin.css b/public/admin.css
new file mode 100644
index 0000000..47a3d6b
--- /dev/null
+++ b/public/admin.css
@@ -0,0 +1,398 @@
+@import url('https://fonts.googleapis.com/css?family=Karla');
+
+:root {
+ --white: #fff;
+ --black: #000;
+ --zooniverse-light-grey: #eff2f5;
+ --zooniverse-mid-grey: #a6a7a9;
+ --zooniverse-dark-grey: #5c5c5c;
+ --zooniverse-teal: #00979d;
+ --zooniverse-light-teal: #addde0;
+ --zooniverse-dark-teal: #005d69;
+ --zooniverse-gold: #f0b200;
+ --zooniverse-tomato: #e45950;
+ --zooniverse-green: #078f52;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: Karla, sans-serif;
+ font-size: 14px;
+ line-height: 1.2142857143;
+ color: var(--zooniverse-dark-grey);
+ background: var(--white);
+}
+
+a {
+ color: var(--zooniverse-teal);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--zooniverse-dark-teal);
+ text-decoration: underline;
+}
+
+h1, h2, h3 {
+ color: var(--zooniverse-dark-grey);
+ font-weight: bold;
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+h1 { font-size: 32px; }
+h2 { font-size: 30px; }
+h3 { font-size: 26px; }
+
+.container-fluid {
+ width: 100%;
+ padding-left: 20px;
+ padding-right: 20px;
+}
+
+.row {
+ margin-left: -10px;
+ margin-right: -10px;
+}
+
+.row::before,
+.row::after,
+.container-fluid::before,
+.container-fluid::after,
+.navbar::before,
+.navbar::after,
+.nav::before,
+.nav::after {
+ content: " ";
+ display: table;
+}
+
+.row::after,
+.container-fluid::after,
+.navbar::after,
+.nav::after {
+ clear: both;
+}
+
+.col-md-12 {
+ width: 100%;
+ padding-left: 10px;
+ padding-right: 10px;
+ position: relative;
+ min-height: 1px;
+}
+
+.pull-right { float: right; }
+
+.navbar {
+ position: relative;
+ min-height: 50px;
+ margin-bottom: 20px;
+ border: 0;
+ border-radius: 0;
+}
+
+.navbar-default {
+ background-color: var(--zooniverse-teal);
+}
+
+.navbar-header {
+ float: left;
+}
+
+.navbar-brand {
+ float: left;
+ padding: 15px;
+ font-size: 18px;
+ line-height: 20px;
+ color: var(--white);
+ font-weight: bold;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+}
+
+.navbar-brand:hover {
+ color: var(--white);
+ text-decoration: none;
+}
+
+.navbar-logo {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ margin-right: 0.35em;
+ border: 3px solid currentColor;
+ border-radius: 50%;
+ vertical-align: top;
+ position: relative;
+ top: 0.05em;
+}
+
+.navbar-logo::after {
+ content: "";
+ position: absolute;
+ top: 0.1em;
+ left: 0.34em;
+ width: 0.12em;
+ height: 0.45em;
+ background: currentColor;
+ transform: rotate(45deg);
+}
+
+.navbar-collapse {
+ display: block;
+}
+
+.nav {
+ list-style: none;
+ margin: 0;
+ padding-left: 0;
+}
+
+.navbar-nav {
+ float: left;
+}
+
+.navbar-right {
+ float: right;
+}
+
+.navbar-nav > li,
+.navbar-right > li {
+ float: left;
+}
+
+.navbar-nav > li > a,
+.navbar-right > li > a,
+.navbar-text {
+ display: block;
+ color: var(--white);
+ padding: 15px;
+ line-height: 20px;
+}
+
+.navbar-nav > li > a:hover,
+.navbar-right > li > a:hover {
+ background-color: var(--zooniverse-dark-teal);
+ color: var(--white);
+ text-decoration: none;
+}
+
+.navbar-text {
+ margin: 0;
+}
+
+.alert {
+ padding: 15px;
+ margin-bottom: 20px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.alert-warning {
+ color: var(--zooniverse-gold);
+ background-color: #f6d885;
+}
+
+.alert-success {
+ color: var(--zooniverse-green);
+ background-color: #dff3e8;
+ border-color: var(--zooniverse-green);
+}
+
+.btn {
+ display: inline-block;
+ margin-bottom: 0;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 1.42857143;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ cursor: pointer;
+ border: 1px solid transparent;
+ padding: 6px 12px;
+ border-radius: 4px;
+}
+
+.btn-primary {
+ color: var(--white);
+ background-color: var(--zooniverse-teal);
+ border-color: var(--zooniverse-teal);
+}
+
+.btn-primary:hover {
+ background-color: var(--zooniverse-dark-teal);
+ border-color: var(--zooniverse-dark-teal);
+ color: var(--white);
+ text-decoration: none;
+}
+
+.btn-default {
+ color: var(--zooniverse-dark-grey);
+ background-color: var(--white);
+ border-color: #ccc;
+}
+
+.btn-default:hover {
+ background-color: var(--zooniverse-light-grey);
+ text-decoration: none;
+}
+
+.btn-danger {
+ color: var(--white);
+ background-color: var(--zooniverse-tomato);
+ border-color: var(--zooniverse-tomato);
+}
+
+.btn-danger:hover {
+ color: var(--white);
+ background-color: #c94841;
+ text-decoration: none;
+}
+
+.table {
+ width: 100%;
+ max-width: 100%;
+ margin-bottom: 20px;
+ border-spacing: 0;
+ border-collapse: collapse;
+}
+
+.table > thead > tr > th,
+.table > tbody > tr > td {
+ padding: 12px 10px;
+ line-height: 1.42857143;
+ vertical-align: top;
+ border-top: 1px solid var(--zooniverse-light-teal);
+}
+
+.table > thead > tr > th {
+ border-top: 0;
+ border-bottom: 2px solid var(--zooniverse-light-teal);
+ text-align: left;
+}
+
+.table-striped > tbody > tr:nth-of-type(odd) {
+ background-color: #f5fbfc;
+}
+
+.table-sm > thead > tr > th,
+.table-sm > tbody > tr > td {
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.form-group {
+ margin-bottom: 15px;
+}
+
+label {
+ display: inline-block;
+ max-width: 100%;
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+.form-control {
+ display: block;
+ width: 100%;
+ height: 34px;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.42857143;
+ color: #555;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+textarea.form-control {
+ height: auto;
+ min-height: 220px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+input[type="checkbox"] {
+ margin-right: 8px;
+}
+
+.help-block {
+ display: block;
+ margin-top: 5px;
+ margin-bottom: 10px;
+ color: var(--zooniverse-mid-grey);
+}
+
+.json-editor-status {
+ margin-top: 6px;
+ min-height: 1.2em;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.json-editor-status.is-valid {
+ color: var(--zooniverse-green);
+}
+
+.json-editor-status.is-invalid {
+ color: var(--zooniverse-tomato);
+}
+
+.form-control.json-valid {
+ border-color: var(--zooniverse-green);
+}
+
+.form-control.json-invalid {
+ border-color: var(--zooniverse-tomato);
+}
+
+.page-actions {
+ margin-top: 20px;
+ margin-bottom: 20px;
+}
+
+.page-actions .btn,
+.table-actions .btn,
+.inline-form {
+ margin-right: 8px;
+}
+
+.inline-form {
+ display: inline-block;
+}
+
+.well {
+ min-height: 20px;
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: #f5f5f5;
+ border: 1px solid #e3e3e3;
+ border-radius: 4px;
+}
+
+pre {
+ display: block;
+ padding: 9.5px;
+ margin: 0 0 10px;
+ font-size: 13px;
+ line-height: 1.42857143;
+ color: #333;
+ background-color: #f5f5f5;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ overflow: auto;
+}
+
+.text-muted {
+ color: var(--zooniverse-mid-grey);
+}
+
+.status-tag {
+ color: var(--zooniverse-dark-teal);
+ background-color: var(--zooniverse-light-teal);
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+}
diff --git a/spec/modules/label_extractors/configurable_extractor_spec.rb b/spec/modules/label_extractors/configurable_extractor_spec.rb
index 8b375a3..47facc8 100644
--- a/spec/modules/label_extractors/configurable_extractor_spec.rb
+++ b/spec/modules/label_extractors/configurable_extractor_spec.rb
@@ -43,12 +43,12 @@
}.to raise_error(LabelExtractors::UnknownLabelKey, 'key not found: 99')
end
- it 'raises a clear error for malformed config' do
+ it 'raises a key error for malformed config when required mappings are missing at runtime' do
malformed_config = config.except('task_key_data_labels')
expect {
described_class.new('T0', malformed_config)
- }.to raise_error(LabelExtractors::ConfigurationError, 'task_key_data_labels must be a non-empty object')
+ }.to raise_error(KeyError, 'key not found: "task_key_data_labels"')
end
end
diff --git a/spec/modules/label_extractors/registry_spec.rb b/spec/modules/label_extractors/registry_spec.rb
index 0d2df16..87d7924 100644
--- a/spec/modules/label_extractors/registry_spec.rb
+++ b/spec/modules/label_extractors/registry_spec.rb
@@ -84,15 +84,14 @@
end
describe 'definition validation' do
- it 'rejects malformed DB-backed extractor config' do
+ it 'accepts malformed DB-backed extractor config while strict schema validation is disabled' do
definition = LabelExtractorDefinition.new(
module_name: 'new_project',
extractor_name: 'main',
config: config.except('task_key_label_prefixes')
)
- expect(definition).not_to be_valid
- expect(definition.errors[:config]).to include('task_key_label_prefixes must be a non-empty object')
+ expect(definition).to be_valid
end
end
end
diff --git a/spec/requests/admin/contexts_spec.rb b/spec/requests/admin/contexts_spec.rb
new file mode 100644
index 0000000..09fad05
--- /dev/null
+++ b/spec/requests/admin/contexts_spec.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Contexts', type: :request do
+ fixtures :contexts
+
+ let(:context) { contexts(:galaxy_zoo_cosmos_active_learning_project) }
+ let(:request_headers) do
+ {
+ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(
+ Rails.application.config.admin_basic_auth_username,
+ Rails.application.config.admin_basic_auth_password
+ )
+ }
+ end
+
+ it 'renders the admin contexts index' do
+ get '/admin/contexts', headers: request_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Contexts')
+ expect(response.body).to include(context.extractor_name)
+ end
+
+ it 'shows enabled, disabled, and none definition states on the index' do
+ enabled_context = Context.create!(
+ workflow_id: 9001,
+ project_id: 1001,
+ active_subject_set_id: 1101,
+ pool_subject_set_id: 1201,
+ module_name: 'new_project',
+ extractor_name: 'enabled_definition',
+ metadata: {}
+ )
+ disabled_context = Context.create!(
+ workflow_id: 9002,
+ project_id: 1002,
+ active_subject_set_id: 1102,
+ pool_subject_set_id: 1202,
+ module_name: 'new_project',
+ extractor_name: 'disabled_definition',
+ metadata: {}
+ )
+ none_context = Context.create!(
+ workflow_id: 9003,
+ project_id: 1003,
+ active_subject_set_id: 1103,
+ pool_subject_set_id: 1203,
+ module_name: 'new_project',
+ extractor_name: 'no_definition',
+ metadata: {}
+ )
+
+ LabelExtractorDefinition.create!(
+ module_name: enabled_context.module_name,
+ extractor_name: enabled_context.extractor_name,
+ enabled: true,
+ config: {
+ data_release_suffix: 'np',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+ LabelExtractorDefinition.create!(
+ module_name: disabled_context.module_name,
+ extractor_name: disabled_context.extractor_name,
+ enabled: false,
+ config: {
+ data_release_suffix: 'np',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ get '/admin/contexts', headers: request_headers
+
+ expect(response.body).to include('Enabled')
+ expect(response.body).to include('Disabled')
+ expect(response.body).to include('None')
+ end
+
+ it 'renders the add context page' do
+ get '/admin/contexts/new', headers: request_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Add Context')
+ expect(response.body).to include('data-json-editor="true"')
+ end
+
+ it 'renders the admin context detail page' do
+ get "/admin/contexts/#{context.id}", headers: request_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Context #{context.id}")
+ expect(response.body).to include(context.module_name)
+ end
+
+ it 'updates a context via the admin form flow' do
+ patch "/admin/contexts/#{context.id}",
+ params: {
+ context: {
+ workflow_id: 1234,
+ project_id: context.project_id,
+ active_subject_set_id: context.active_subject_set_id,
+ pool_subject_set_id: context.pool_subject_set_id,
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ metadata_json: JSON.pretty_generate(context.metadata)
+ }
+ },
+ headers: request_headers
+
+ expect(response).to redirect_to("/admin/contexts/#{context.id}")
+ expect(context.reload.workflow_id).to eq(1234)
+ end
+
+ it 'creates a context via the admin form flow' do
+ LabelExtractorDefinition.create!(
+ module_name: 'new_project',
+ extractor_name: 'main',
+ enabled: true,
+ config: {
+ data_release_suffix: 'np',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ expect {
+ post '/admin/contexts',
+ params: {
+ context: {
+ workflow_id: 9991,
+ project_id: 555,
+ active_subject_set_id: 666,
+ pool_subject_set_id: 777,
+ module_name: 'new_project',
+ extractor_name: 'main',
+ metadata_json: JSON.pretty_generate(batch: { pretrained_checkpoint_url: 'rubin.ckpt' })
+ }
+ },
+ headers: request_headers
+ }.to change(Context, :count).by(1)
+
+ created = Context.order(:id).last
+ expect(response).to redirect_to("/admin/contexts/#{created.id}")
+ expect(created.module_name).to eq('new_project')
+ expect(created.metadata.dig('batch', 'pretrained_checkpoint_url')).to eq('rubin.ckpt')
+ end
+
+ it 'deletes a context via the admin flow' do
+ delete "/admin/contexts/#{context.id}", headers: request_headers
+
+ expect(response).to redirect_to('/admin/contexts')
+ expect(Context.exists?(context.id)).to be(false)
+ end
+
+ context 'with invalid credentials' do
+ let(:request_headers) do
+ {
+ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('unknown', 'credentials')
+ }
+ end
+
+ it 'returns unauthorized' do
+ get '/admin/contexts', headers: request_headers
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+end
diff --git a/spec/requests/admin/label_extractor_definitions_spec.rb b/spec/requests/admin/label_extractor_definitions_spec.rb
new file mode 100644
index 0000000..279573b
--- /dev/null
+++ b/spec/requests/admin/label_extractor_definitions_spec.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::LabelExtractorDefinitions', type: :request do
+ fixtures :contexts
+
+ let(:context) { contexts(:galaxy_zoo_cosmos_active_learning_project) }
+ let(:request_headers) do
+ {
+ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(
+ Rails.application.config.admin_basic_auth_username,
+ Rails.application.config.admin_basic_auth_password
+ )
+ }
+ end
+
+ it 'renders the global add definition page' do
+ get '/admin/label_extractor_definitions/new', headers: request_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Add Label Extractor Definition')
+ expect(response.body).to include('data-json-editor="true"')
+ end
+
+ it 'creates a new definition from the global admin page' do
+ post '/admin/label_extractor_definitions',
+ params: {
+ label_extractor_definition: {
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: '1',
+ config_json: JSON.pretty_generate(
+ data_release_suffix: 'jwst',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ )
+ }
+ },
+ headers: request_headers
+
+ expect(response).to redirect_to('/admin/label_extractor_definitions')
+
+ definition = LabelExtractorDefinition.order(:id).last
+ expect(definition.module_name).to eq(context.module_name)
+ expect(definition.extractor_name).to eq(context.extractor_name)
+ end
+
+ it 're-renders the add page for invalid create input' do
+ post '/admin/label_extractor_definitions',
+ params: {
+ label_extractor_definition: {
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: '1',
+ config_json: '{invalid-json'
+ }
+ },
+ headers: request_headers
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('Add Label Extractor Definition')
+ expect(response.body).to include(context.extractor_name)
+ expect(response.body).to include('{invalid-json')
+ end
+
+ it 'renders the global definitions index' do
+ LabelExtractorDefinition.create!(
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: true,
+ config: {
+ data_release_suffix: 'jwst',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ get '/admin/label_extractor_definitions', headers: request_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Label Extractor Definitions')
+ expect(response.body).to include(context.extractor_name)
+ end
+
+ it 'deletes a label extractor definition via the admin flow' do
+ definition = LabelExtractorDefinition.create!(
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: true,
+ config: {
+ data_release_suffix: 'jwst',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ delete "/admin/label_extractor_definitions/#{definition.id}", headers: request_headers
+
+ expect(response).to redirect_to('/admin/label_extractor_definitions')
+ expect(LabelExtractorDefinition.exists?(definition.id)).to be(false)
+ end
+
+ it 'redirects to the global definitions list after update from the list page' do
+ definition = LabelExtractorDefinition.create!(
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: true,
+ config: {
+ data_release_suffix: 'jwst',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ patch "/admin/label_extractor_definitions/#{definition.id}",
+ params: {
+ return_to: '/admin/label_extractor_definitions',
+ label_extractor_definition: {
+ module_name: definition.module_name,
+ extractor_name: definition.extractor_name,
+ enabled: '0',
+ config_json: JSON.pretty_generate(definition.config)
+ }
+ },
+ headers: request_headers
+
+ expect(response).to redirect_to('/admin/label_extractor_definitions')
+ expect(definition.reload.enabled).to be(false)
+ end
+
+ it 'redirects back to the context detail page after update from a context view' do
+ definition = LabelExtractorDefinition.create!(
+ module_name: context.module_name,
+ extractor_name: context.extractor_name,
+ enabled: true,
+ config: {
+ data_release_suffix: 'jwst',
+ task_key_label_prefixes: { T0: 'smooth-or-featured' },
+ task_key_data_labels: { T0: { '0': 'smooth' } }
+ }
+ )
+
+ patch "/admin/label_extractor_definitions/#{definition.id}",
+ params: {
+ return_to: "/admin/contexts/#{context.id}",
+ label_extractor_definition: {
+ module_name: definition.module_name,
+ extractor_name: definition.extractor_name,
+ enabled: '0',
+ config_json: JSON.pretty_generate(definition.config)
+ }
+ },
+ headers: request_headers
+
+ expect(response).to redirect_to("/admin/contexts/#{context.id}")
+ expect(definition.reload.enabled).to be(false)
+ end
+end