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.label :metadata_json, 'Metadata JSON' %> + <%= form.text_area :metadata_json, + value: pretty_json(form_model.metadata), + class: 'form-control json-editor', + data: { + json_editor: true, + json_field_name: 'Metadata JSON' + } %> +

The admin UI edits metadata as raw JSON. Existing keys are preserved unless overwritten. Valid JSON is formatted when the field loses focus or the form is submitted.

+

+
+ + <%= 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

+ + + + + + + + + + + + + + <% @contexts.each do |context| %> + <% matching_definitions = LabelExtractorDefinition.where(module_name: context.module_name, extractor_name: context.extractor_name) %> + <% enabled_definition = matching_definitions.find(&:enabled?) %> + + + + + + + + + + <% end %> + +
IDWorkflow IDProject IDModuleExtractorLabel Extractor DefinitionUpdated
<%= 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 %>
+
+
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? %> + + + + + + + + + + + <% @definitions.each do |definition| %> + + + + + + + <% end %> + +
IDEnabledUpdatedActions
<%= 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' %> +
+ <% 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? %> + + + + + + + + + + + <% @matching_definitions.each do |definition| %> + + + + + + + <% end %> + +
IDEnabledUpdatedActions
<%= 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' %> +
+ <% 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.label :config_json, 'Config JSON' %> + <%= form.text_area :config_json, + value: config_json, + class: 'form-control json-editor', + data: { + json_editor: true, + json_field_name: 'Config JSON' + } %> +

Structured JSON only. Arbitrary executable logic is not supported. Valid JSON is formatted when the field loses focus or the form is submitted.

+

+
+ + <%= 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.

+ + + + + + + + + + + + + <% @definitions.each do |definition| %> + + + + + + + + + <% end %> + +
IDModuleExtractorEnabledUpdatedActions
<%= 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?');" } %> +
+
+
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 %> + + +
+ +
+
+
+
+ <% 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