diff --git a/app/assets/stylesheets/admin/budgets/links.scss b/app/assets/stylesheets/admin/budgets/links.scss
index f712aee3246..0188990631c 100644
--- a/app/assets/stylesheets/admin/budgets/links.scss
+++ b/app/assets/stylesheets/admin/budgets/links.scss
@@ -33,4 +33,8 @@
.results-link {
@include has-fa-icon(poll, solid);
}
+
+ .sensemaker-analyses-link {
+ @include has-fa-icon(microscope, solid);
+ }
}
diff --git a/app/assets/stylesheets/sensemaker/sensemaker.scss b/app/assets/stylesheets/sensemaker/sensemaker.scss
index 3335ab2bca9..a1fcbf8d6ef 100644
--- a/app/assets/stylesheets/sensemaker/sensemaker.scss
+++ b/app/assets/stylesheets/sensemaker/sensemaker.scss
@@ -1,3 +1,33 @@
+@mixin flex-row {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 4px;
+ justify-content: flex-start;
+}
+
+@mixin flex-between {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 4px;
+ justify-content: space-between;
+}
+
+.sensemaker-analyses-link {
+ @include has-fa-icon(microscope, solid);
+
+ &::before {
+ left: -0.25rem;
+ position: relative;
+ top: -0.1rem;
+ }
+}
+
+.sensemaker-icon {
+ @include has-fa-icon(microscope, solid);
+}
+
.admin .sensemaker {
.additional-context {
border: 1px solid $border;
@@ -178,8 +208,6 @@
list-style: none;
}
- // (moved to shallower block below)
-
li.result-group {
list-style: none;
margin-bottom: 0.5rem;
@@ -187,8 +215,6 @@
padding: 0;
}
- // (moved to shallower block below)
-
li.result-row {
opacity: 0.8;
padding: 0.1rem;
@@ -284,7 +310,6 @@
background-color: #f8f9fa;
}
- // Job links styling
.job-link {
color: $brand;
font-weight: bold;
@@ -295,7 +320,6 @@
}
}
- // Job details styling
.job-details-table {
border-collapse: collapse;
width: 100%;
@@ -335,11 +359,25 @@
background-color: $color-info;
}
}
+
+ .filter-controls {
+ @include flex-between;
+ margin-bottom: $line-height;
+
+ select {
+ height: $line-height * 1.5;
+ margin-bottom: 0;
+ }
+ label { font-weight: normal; opacity: 0.8; }
+
+ a,
+ p { margin-bottom: 0; }
+ p { opacity: 0.8; }
+ }
}
.sensemaker {
- // Responsive adjustments
@include breakpoint(small only) {
.hero-section {
h1 {
@@ -363,22 +401,13 @@
}
.flex-between {
- align-items: center;
- display: flex;
- flex-direction: row;
- gap: 2px;
- justify-content: space-between;
+ @include flex-between;
+ button,
.button { margin-bottom: 0; }
}
-
- .flex-row {
- align-items: center;
- display: flex;
- flex-direction: row;
- gap: 2px;
- justify-content: flex-start;
- }
+ .flex-row { @include flex-row; }
+ .fit-content { width: fit-content; }
.flex-grow {
flex: 1;
@@ -388,7 +417,7 @@
align-items: center;
display: flex;
flex-direction: row;
- gap: 2px;
+ gap: 4px;
justify-content: flex-end;
}
@@ -407,7 +436,6 @@
text-transform: uppercase;
}
- // Target header (quiz-header style box)
.target-header {
margin-bottom: 2.2rem;
diff --git a/app/components/admin/budgets/show_component.html.erb b/app/components/admin/budgets/show_component.html.erb
index 5f1142b5dbf..2b84c88f30c 100644
--- a/app/components/admin/budgets/show_component.html.erb
+++ b/app/components/admin/budgets/show_component.html.erb
@@ -22,3 +22,6 @@
<%= render Admin::Budgets::ActionsComponent.new(budget) %>
+
+
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(budget) %>
diff --git a/app/components/admin/sensemaker/analyses_link_component.html.erb b/app/components/admin/sensemaker/analyses_link_component.html.erb
new file mode 100644
index 00000000000..ae9bb990ee0
--- /dev/null
+++ b/app/components/admin/sensemaker/analyses_link_component.html.erb
@@ -0,0 +1 @@
+<%= link_to link_text, link_path, class: "sensemaker-analyses-link" %>
diff --git a/app/components/admin/sensemaker/analyses_link_component.rb b/app/components/admin/sensemaker/analyses_link_component.rb
new file mode 100644
index 00000000000..e394fe31156
--- /dev/null
+++ b/app/components/admin/sensemaker/analyses_link_component.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class Admin::Sensemaker::AnalysesLinkComponent < ApplicationComponent
+ attr_reader :record
+
+ def initialize(record)
+ @record = record
+ end
+
+ def render?
+ feature?(:sensemaker) && jobs_count.positive?
+ end
+
+ def jobs_count
+ @jobs_count ||= Sensemaker::Job.for_analysable(record, published_only: false).count
+ end
+
+ def link_text
+ t("admin.sensemaker.index.sensemaker_analyses_count", count: jobs_count)
+ end
+
+ def link_path
+ admin_sensemaker_jobs_path(resource_type: admin_resource_type, resource_id: record.id)
+ end
+
+ private
+
+ def admin_resource_type
+ case record
+ when Budget
+ "budgets"
+ when Debate
+ "debates"
+ when Proposal
+ "proposals"
+ when Poll
+ "polls"
+ when Legislation::Process
+ "legislation_processes"
+ else
+ raise ArgumentError, "Unsupported record type for admin sensemaker link: #{record.class}"
+ end
+ end
+end
diff --git a/app/components/admin/sensemaker/index_component.html.erb b/app/components/admin/sensemaker/index_component.html.erb
index b9344fa8538..fe99ec1bc24 100644
--- a/app/components/admin/sensemaker/index_component.html.erb
+++ b/app/components/admin/sensemaker/index_component.html.erb
@@ -48,9 +48,31 @@
<%= t("admin.sensemaker.index.past_runs") %>
- <%= link_to t("admin.sensemaker.index.new_run"), new_admin_sensemaker_job_path, class: "button" %>
+ <%= link_to t("admin.sensemaker.index.new_run"), new_admin_sensemaker_job_path, class: "button success" %>
+
+ <% if target_specified? %>
+
<%= t("admin.sensemaker.index.filtered_by_target", name: filter_description) %>
+ <% else %>
+ <%= form_tag admin_sensemaker_jobs_path, method: :get, enforce_utf8: false, class: "js-submit-on-change" do %>
+
+ <%= label_tag :resource_type, t("admin.sensemaker.index.filter_by"), class: "" %>
+ <%= select_tag :resource_type,
+ options_for_select(
+ filter_resource_type_options.map { |type| [t("admin.sensemaker.index.resource_types.#{type}"), type] },
+ filter_resource_type
+ ),
+ { prompt: t("admin.sensemaker.index.filter_all_types"), class: "fit-content" } %>
+
+ <% end %>
+ <% end %>
+
+ <% if filter_active? %>
+ <%= link_to t("admin.sensemaker.index.filter_reset"), admin_sensemaker_jobs_path, class: "" %>
+ <% end %>
+
+
diff --git a/app/components/admin/sensemaker/index_component.rb b/app/components/admin/sensemaker/index_component.rb
index 79534dce4f8..2a217dac5a8 100644
--- a/app/components/admin/sensemaker/index_component.rb
+++ b/app/components/admin/sensemaker/index_component.rb
@@ -1,11 +1,17 @@
class Admin::Sensemaker::IndexComponent < ApplicationComponent
include Header
- attr_reader :sensemaker_jobs, :running_jobs
+ attr_reader :sensemaker_jobs, :running_jobs, :filter_target,
+ :filter_resource_type, :filter_resource_id, :filter_resource_type_options
- def initialize(sensemaker_jobs, running_jobs)
+ def initialize(sensemaker_jobs, running_jobs, filter_target: nil,
+ filter_resource_type: nil, filter_resource_id: nil, filter_resource_type_options: [])
@sensemaker_jobs = sensemaker_jobs
@running_jobs = running_jobs
+ @filter_target = filter_target
+ @filter_resource_type = filter_resource_type
+ @filter_resource_id = filter_resource_id
+ @filter_resource_type_options = filter_resource_type_options || []
end
def title
@@ -15,4 +21,33 @@ def title
def enabled?
feature?(:sensemaker)
end
+
+ def target_specified?
+ filter_target.present?
+ end
+
+ def filter_active?
+ filter_target.present? || filter_resource_type.present?
+ end
+
+ def filter_description
+ return filter_target_name if filter_target.present?
+ return t("admin.sensemaker.index.resource_types.#{filter_resource_type}") if filter_resource_type.present?
+
+ nil
+ end
+
+ def filter_target_name
+ return nil unless filter_target
+
+ if filter_target.respond_to?(:title) && filter_target.title.present?
+ filter_target.title
+ elsif filter_target.respond_to?(:name) && filter_target.name.present?
+ filter_target.name
+ elsif filter_target.respond_to?(:value) && filter_target.value.present?
+ filter_target.value
+ else
+ "##{filter_target.id}"
+ end
+ end
end
diff --git a/app/components/admin/sensemaker/job_show_component.html.erb b/app/components/admin/sensemaker/job_show_component.html.erb
index c8345914ba0..38bafd3849c 100644
--- a/app/components/admin/sensemaker/job_show_component.html.erb
+++ b/app/components/admin/sensemaker/job_show_component.html.erb
@@ -71,7 +71,6 @@
<% end %>
-
| <%= t("admin.sensemaker.job_show.target_type") %> |
<%= sensemaker_job.analysable_type %> |
diff --git a/app/controllers/admin/sensemaker/jobs_controller.rb b/app/controllers/admin/sensemaker/jobs_controller.rb
index fe834e4be26..07e71ea75f1 100644
--- a/app/controllers/admin/sensemaker/jobs_controller.rb
+++ b/app/controllers/admin/sensemaker/jobs_controller.rb
@@ -1,10 +1,34 @@
class Admin::Sensemaker::JobsController < Admin::BaseController
+ include Sensemaker::ResourceTypeResolution
+
def index
@running_jobs = Sensemaker::Job.running.includes(:children).order(created_at: :desc)
- @sensemaker_jobs = Sensemaker::Job.where(parent_job_id: nil)
- .includes(:children)
- .where.not(id: @running_jobs.pluck(:id))
- .order(created_at: :desc)
+
+ @filter_resource_type, @filter_resource_id = normalized_filter_params
+ if @filter_resource_type.present? && @filter_resource_id.present?
+ @filter_target = sensemaker_find_resource(@filter_resource_type, @filter_resource_id)
+ unless @filter_target
+ redirect_to admin_sensemaker_jobs_path, alert: t("admin.sensemaker.index.target_not_found")
+ return
+ end
+ base_scope = Sensemaker::Job.for_analysable(@filter_target, published_only: false)
+ elsif @filter_resource_type.present?
+ resource_class = sensemaker_model_for_resource_type(@filter_resource_type)
+ unless resource_class
+ redirect_to admin_sensemaker_jobs_path, alert: t("admin.sensemaker.index.target_not_found")
+ return
+ end
+ base_scope = Sensemaker::Job.by_analysable_type(resource_class.to_s)
+ else
+ base_scope = Sensemaker::Job
+ end
+
+ @sensemaker_jobs = base_scope.where(parent_job_id: nil)
+ .includes(:children)
+ .where.not(id: @running_jobs.pluck(:id))
+ .order(created_at: :desc)
+
+ @sensemaker_resource_types = Sensemaker::ResourceTypeResolution::SENSEMAKER_RESOURCE_TYPES
end
def show
@@ -281,6 +305,10 @@ def help
private
+ def normalized_filter_params
+ [params[:resource_type].presence, params[:resource_id].presence]
+ end
+
def sensemaker_job_params
params.require(:sensemaker_job).permit(:analysable_type, :analysable_id, :script, :additional_context)
end
diff --git a/app/controllers/concerns/sensemaker/resource_type_resolution.rb b/app/controllers/concerns/sensemaker/resource_type_resolution.rb
new file mode 100644
index 00000000000..5bf838b9299
--- /dev/null
+++ b/app/controllers/concerns/sensemaker/resource_type_resolution.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Sensemaker
+ module ResourceTypeResolution
+ extend ActiveSupport::Concern
+
+ SENSEMAKER_RESOURCE_TYPES = %w[
+ budgets
+ debates
+ proposals
+ polls
+ poll_questions
+ legislation_processes
+ legislation_questions
+ legislation_proposals
+ legislation_question_options
+ ].freeze
+
+ def sensemaker_model_for_resource_type(resource_type)
+ case resource_type.to_s
+ when "budgets"
+ Budget
+ when "debates"
+ Debate
+ when "proposals"
+ Proposal
+ when "polls"
+ Poll
+ when "poll_questions"
+ Poll::Question
+ when "legislation_processes"
+ Legislation::Process
+ when "legislation_questions"
+ Legislation::Question
+ when "legislation_proposals"
+ Legislation::Proposal
+ when "legislation_question_options"
+ Legislation::QuestionOption
+ else
+ nil
+ end
+ end
+
+ def sensemaker_find_resource(resource_type, resource_id)
+ model = sensemaker_model_for_resource_type(resource_type)
+ return nil unless model
+
+ if model == Budget
+ model.find_by_slug_or_id(resource_id)
+ else
+ model.find(resource_id)
+ end
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+ end
+end
diff --git a/app/controllers/sensemaker/jobs_controller.rb b/app/controllers/sensemaker/jobs_controller.rb
index aa1be673159..72d53996679 100644
--- a/app/controllers/sensemaker/jobs_controller.rb
+++ b/app/controllers/sensemaker/jobs_controller.rb
@@ -1,4 +1,6 @@
class Sensemaker::JobsController < ApplicationController
+ include Sensemaker::ResourceTypeResolution
+
skip_authorization_check
def show
@@ -14,29 +16,26 @@ def show
end
def index
- if params[:resource_type].blank? || params[:resource_id].blank?
- head :not_found
- return
- end
+ resource_type, resource_id = [params[:resource_type].presence, params[:resource_id].presence]
+ raise ArgumentError, "Unknown resource type: #{resource_type}" if resource_type.blank?
- resource_type = map_resource_type_to_model(params[:resource_type])
- @resource = resource_type.find(params[:resource_id])
- @parent_resource = load_parent_resource_for(@resource)
+ @resource = sensemaker_find_resource(resource_type, resource_id)
+ raise ActiveRecord::RecordNotFound, "Resource not found" unless @resource
+ @parent_resource = load_parent_resource_for(@resource)
@sensemaker_jobs = case @resource
when Poll
Sensemaker::Job.for_poll(@resource).order(finished_at: :desc)
when Legislation::Question
Sensemaker::Job.for_legislation_question(@resource).order(finished_at: :desc)
when Legislation::Process
- Sensemaker::Job.published.for_process(@resource).order(finished_at: :desc)
+ Sensemaker::Job.for_process(@resource).order(finished_at: :desc)
else
Sensemaker::Job.published
- .where(analysable_type: resource_type.name,
- analysable_id: params[:resource_id])
+ .where(analysable: @resource)
.order(finished_at: :desc)
end
- rescue ActiveRecord::RecordNotFound
+ rescue ActiveRecord::RecordNotFound, ArgumentError
head :not_found
end
@@ -69,31 +68,6 @@ def serve_report
private
- def map_resource_type_to_model(resource_type)
- case resource_type
- when "debates"
- Debate
- when "proposals"
- Proposal
- when "polls"
- Poll
- when "topics"
- Topic
- when "poll_questions"
- Poll::Question
- when "legislation_processes"
- Legislation::Process
- when "legislation_questions"
- Legislation::Question
- when "legislation_proposals"
- Legislation::Proposal
- when "legislation_question_options"
- Legislation::QuestionOption
- else
- raise ArgumentError, "Unknown resource type: #{resource_type}"
- end
- end
-
def load_parent_resource_for(resource)
case resource
when Poll::Question
diff --git a/app/models/sensemaker/job.rb b/app/models/sensemaker/job.rb
index 2040356ce69..94b9ddf47bf 100644
--- a/app/models/sensemaker/job.rb
+++ b/app/models/sensemaker/job.rb
@@ -209,41 +209,120 @@ def publishable?
PUBLISHABLE_SCRIPTS.include?(script) && finished? && !errored? && has_outputs?
end
- def self.for_budget(budget)
+ def self.budget_related
+ where(analysable_type: "Budget").or(
+ where(analysable_type: "Budget::Group")
+ )
+ end
+
+ def self.for_budget_any_status(budget)
group_subquery = budget.groups.select(:id)
- published.where(analysable_type: "Budget", analysable_id: budget.id).or(
- published.where(analysable_type: "Budget::Group", analysable_id: group_subquery)
+ where(analysable_type: "Budget", analysable_id: budget.id).or(
+ where(analysable_type: "Budget::Group", analysable_id: group_subquery)
)
end
- def self.for_process(process)
+ def self.for_budget(budget)
+ published.merge(for_budget_any_status(budget))
+ end
+
+ def self.process_related
+ where(analysable_type: "Legislation::Process").or(
+ where(analysable_type: "Legislation::Proposal").or(
+ where(analysable_type: "Legislation::Question").or(
+ where(analysable_type: "Legislation::QuestionOption")
+ )
+ )
+ )
+ end
+
+ def self.for_process_any_status(process)
proposals_subquery = process.proposals.select(:id)
questions_subquery = process.questions.select(:id)
question_options_subquery = Legislation::QuestionOption
.where(legislation_question_id: questions_subquery)
.select(:id)
- published
- .where(analysable_type: "Legislation::Proposal", analysable_id: proposals_subquery)
- .or(published.where(analysable_type: "Legislation::Question", analysable_id: questions_subquery))
- .or(published.where(analysable_type: "Legislation::QuestionOption",
- analysable_id: question_options_subquery))
+ where(analysable_type: "Legislation::Proposal", analysable_id: proposals_subquery)
+ .or(where(analysable_type: "Legislation::Question", analysable_id: questions_subquery))
+ .or(where(analysable_type: "Legislation::QuestionOption",
+ analysable_id: question_options_subquery))
end
- def self.for_poll(poll)
+ def self.for_process(process)
+ published.merge(for_process_any_status(process))
+ end
+
+ def self.poll_related
+ where(analysable_type: "Poll").or(
+ where(analysable_type: "Poll::Question")
+ )
+ end
+
+ def self.for_poll_any_status(poll)
questions_subquery = poll.questions.select(:id)
- published.where(analysable_type: "Poll", analysable_id: poll.id).or(
- published.where(analysable_type: "Poll::Question", analysable_id: questions_subquery)
+ where(analysable_type: "Poll", analysable_id: poll.id).or(
+ where(analysable_type: "Poll::Question", analysable_id: questions_subquery)
)
end
- def self.for_legislation_question(question)
+ def self.for_poll(poll)
+ published.merge(for_poll_any_status(poll))
+ end
+
+ def self.legislation_question_related
+ where(analysable_type: "Legislation::Question").or(
+ where(analysable_type: "Legislation::QuestionOption")
+ )
+ end
+
+ def self.for_legislation_question_any_status(question)
options_subquery = question.question_options.select(:id)
- published.where(analysable_type: "Legislation::Question", analysable_id: question.id).or(
- published.where(analysable_type: "Legislation::QuestionOption", analysable_id: options_subquery)
+ where(analysable_type: "Legislation::Question", analysable_id: question.id).or(
+ where(analysable_type: "Legislation::QuestionOption", analysable_id: options_subquery)
)
end
+ def self.for_legislation_question(question)
+ published.merge(for_legislation_question_any_status(question))
+ end
+
+ def self.for_analysable(record, published_only: true)
+ if record == Proposal
+ base = where(analysable_type: "Proposal", analysable_id: nil)
+ return published_only ? base.merge(published) : base
+ end
+
+ case record
+ when Budget
+ published_only ? for_budget(record) : for_budget_any_status(record)
+ when Legislation::Process
+ published_only ? for_process(record) : for_process_any_status(record)
+ when Poll
+ published_only ? for_poll(record) : for_poll_any_status(record)
+ when Legislation::Question
+ published_only ? for_legislation_question(record) : for_legislation_question_any_status(record)
+ else
+ base = where(analysable: record)
+ published_only ? base.merge(published) : base
+ end
+ end
+
+ def self.by_analysable_type(type)
+ case type
+ when "Budget"
+ budget_related
+ when "Legislation::Process"
+ process_related
+ when "Poll"
+ poll_related
+ when "Legislation::Question"
+ legislation_question_related
+ else
+ where(analysable_type: type)
+ end
+ end
+
private
def publishing_is_allowed
diff --git a/app/views/admin/debates/show.html.erb b/app/views/admin/debates/show.html.erb
index b544094257f..81a71bb9d9c 100644
--- a/app/views/admin/debates/show.html.erb
+++ b/app/views/admin/debates/show.html.erb
@@ -19,3 +19,6 @@
<%= render "shared/tags", taggable: @debate %>
+
+
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@debate) %>
diff --git a/app/views/admin/legislation/processes/edit.html.erb b/app/views/admin/legislation/processes/edit.html.erb
index 2d2f148f086..927f0cb20e6 100644
--- a/app/views/admin/legislation/processes/edit.html.erb
+++ b/app/views/admin/legislation/processes/edit.html.erb
@@ -11,3 +11,6 @@
<%= render Admin::Legislation::Processes::FormComponent.new(@process) %>
+
+
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@process) %>
diff --git a/app/views/admin/poll/polls/show.html.erb b/app/views/admin/poll/polls/show.html.erb
index be45eeb319a..ef98c480470 100644
--- a/app/views/admin/poll/polls/show.html.erb
+++ b/app/views/admin/poll/polls/show.html.erb
@@ -5,3 +5,6 @@
<%= render "questions" %>
+
+
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@poll) %>
diff --git a/app/views/admin/proposals/show.html.erb b/app/views/admin/proposals/show.html.erb
index afb2379e36e..a98d562d6e5 100644
--- a/app/views/admin/proposals/show.html.erb
+++ b/app/views/admin/proposals/show.html.erb
@@ -33,3 +33,6 @@
<%= render "admin/milestones/milestones", milestoneable: @proposal %>
+
+
+<%= render Admin::Sensemaker::AnalysesLinkComponent.new(@proposal) %>
diff --git a/app/views/admin/sensemaker/jobs/index.html.erb b/app/views/admin/sensemaker/jobs/index.html.erb
index a3701fef857..eb2452b4019 100644
--- a/app/views/admin/sensemaker/jobs/index.html.erb
+++ b/app/views/admin/sensemaker/jobs/index.html.erb
@@ -1 +1,8 @@
-<%= render Admin::Sensemaker::IndexComponent.new(@sensemaker_jobs, @running_jobs) %>
+<%= render Admin::Sensemaker::IndexComponent.new(
+ @sensemaker_jobs,
+ @running_jobs,
+ filter_target: @filter_target,
+ filter_resource_type: @filter_resource_type,
+ filter_resource_id: @filter_resource_id,
+ filter_resource_type_options: @sensemaker_resource_types
+) %>
diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml
index 4debe1eddf1..2b899d813b5 100644
--- a/config/locales/en/admin.yml
+++ b/config/locales/en/admin.yml
@@ -1779,6 +1779,24 @@ en:
table_actions: "Actions"
cancel_all: "Cancel all"
new_run: "New Run"
+ target_not_found: "Target not found."
+ filtered_by_target: "Showing jobs for %{name}"
+ filter_by: "Filter by"
+ filter_all_types: "-"
+ filter_reset: "× Clear filters"
+ sensemaker_analyses_count:
+ one: "1 sensemaker analysis"
+ other: "%{count} sensemaker analyses"
+ resource_types:
+ budgets: "Budgets"
+ debates: "Debates"
+ proposals: "Proposals"
+ polls: "Polls"
+ poll_questions: "Poll questions"
+ legislation_processes: "Legislation processes"
+ legislation_questions: "Legislation questions"
+ legislation_proposals: "Legislation proposals"
+ legislation_question_options: "Legislation question options"
new:
title: New Sensemaker analysis
select_target: "Select a target from the results"
diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml
index c0e4fd70296..a785b9fe336 100644
--- a/config/locales/es/admin.yml
+++ b/config/locales/es/admin.yml
@@ -1827,6 +1827,24 @@ es:
table_actions: "Acciones"
cancel_all: "Cancelar todo"
new_run: "Nueva ejecución"
+ target_not_found: "Objetivo no encontrado."
+ filtered_by_target: "Mostrando trabajos de %{name}"
+ filter_by: "Filtrar por"
+ filter_all_types: "-"
+ filter_reset: "× Limpiar filtros"
+ sensemaker_analyses_count:
+ one: "1 análisis Sensemaker"
+ other: "%{count} análisis Sensemaker"
+ resource_types:
+ budgets: "Presupuestos"
+ debates: "Debates"
+ proposals: "Propuestas"
+ polls: "Encuestas"
+ poll_questions: "Preguntas de encuesta"
+ legislation_processes: "Procesos legislativos"
+ legislation_questions: "Preguntas legislativas"
+ legislation_proposals: "Propuestas legislativas"
+ legislation_question_options: "Opciones de pregunta legislativa"
new:
title: Nuevo análisis Sensemaker
select_target: "Selecciona un objetivo de los resultados"
diff --git a/spec/components/admin/sensemaker/analyses_link_component_spec.rb b/spec/components/admin/sensemaker/analyses_link_component_spec.rb
new file mode 100644
index 00000000000..ef600815400
--- /dev/null
+++ b/spec/components/admin/sensemaker/analyses_link_component_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe Admin::Sensemaker::AnalysesLinkComponent do
+ include Rails.application.routes.url_helpers
+
+ let(:debate) { create(:debate) }
+ let(:component) { Admin::Sensemaker::AnalysesLinkComponent.new(debate) }
+
+ before do
+ Setting["feature.sensemaker"] = true
+ end
+
+ describe "#render?" do
+ context "when sensemaker feature is enabled and at least one job exists (any status)" do
+ before do
+ create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false)
+ end
+
+ it "returns true" do
+ expect(component.render?).to be true
+ end
+ end
+
+ context "when sensemaker feature is enabled but no jobs exist" do
+ it "returns false" do
+ expect(component.render?).to be false
+ end
+ end
+
+ context "when sensemaker feature is disabled" do
+ before do
+ Setting["feature.sensemaker"] = nil
+ create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id)
+ end
+
+ it "returns false" do
+ expect(component.render?).to be_falsy
+ end
+ end
+ end
+
+ describe "rendering" do
+ context "when jobs are available" do
+ before do
+ create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id)
+ end
+
+ it "renders a link to the admin sensemaker jobs index filtered by the resource" do
+ render_inline component
+
+ expect(page).to have_link(I18n.t("admin.sensemaker.index.sensemaker_analyses_count", count: 1),
+ href: admin_sensemaker_jobs_path(resource_type: "debates",
+ resource_id: debate.id))
+ end
+ end
+
+ context "when no jobs exist" do
+ it "does not render the link" do
+ render_inline component
+
+ expect(page).not_to have_link(I18n.t("admin.sensemaker.index.sensemaker_analyses_count", count: 1))
+ end
+ end
+ end
+end
diff --git a/spec/controllers/admin/sensemaker/jobs_controller_spec.rb b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb
index 34b4937fa64..aa3d905e1bc 100644
--- a/spec/controllers/admin/sensemaker/jobs_controller_spec.rb
+++ b/spec/controllers/admin/sensemaker/jobs_controller_spec.rb
@@ -12,10 +12,60 @@
before { sign_in(admin) }
describe "GET #index" do
- it "returns successful response" do
+ it "returns successful response and sets no filter_target when no filter params" do
get :index
expect(response).to have_http_status(:ok)
+ expect(controller.instance_variable_get(:@filter_target)).to be(nil)
+ end
+
+ context "when filtering by resource_type and resource_id" do
+ let!(:debate_job) do
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: debate.id,
+ parent_job_id: nil,
+ started_at: nil,
+ finished_at: 1.day.ago)
+ end
+ let!(:other_job) do
+ create(:sensemaker_job,
+ user: admin,
+ analysable_type: "Debate",
+ analysable_id: create(:debate).id,
+ parent_job_id: nil,
+ started_at: nil,
+ finished_at: 1.day.ago)
+ end
+
+ it "sets filter_target and scopes jobs to that resource" do
+ get :index, params: { resource_type: "debates", resource_id: debate.id }
+
+ expect(response).to have_http_status(:ok)
+ expect(controller.instance_variable_get(:@filter_target)).to eq(debate)
+ jobs = controller.instance_variable_get(:@sensemaker_jobs)
+ expect(jobs).to include(debate_job)
+ expect(jobs).not_to include(other_job)
+ end
+ end
+
+ context "when target is not found" do
+ it "redirects to index with alert" do
+ get :index, params: { resource_type: "debates", resource_id: 99999 }
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context "when resource_type is unknown" do
+ it "redirects to index with alert" do
+ get :index, params: { resource_type: "unknown_type", resource_id: 1 }
+
+ expect(response).to redirect_to(admin_sensemaker_jobs_path)
+ expect(flash[:alert]).to be_present
+ end
end
end
diff --git a/spec/controllers/sensemaker/jobs_controller_spec.rb b/spec/controllers/sensemaker/jobs_controller_spec.rb
index 8040d81ffcc..ef7550c910d 100644
--- a/spec/controllers/sensemaker/jobs_controller_spec.rb
+++ b/spec/controllers/sensemaker/jobs_controller_spec.rb
@@ -201,6 +201,7 @@ def create_publishable_job_with_output(attributes = {})
it "returns 404" do
get :index, params: { resource_type: "debates", resource_id: 99999 }
+ expect(Debate.find_by(id: 99999)).to be(nil)
expect(response).to have_http_status(:not_found)
end
end
diff --git a/spec/models/sensemaker/job_spec.rb b/spec/models/sensemaker/job_spec.rb
index 64cc7dd44a9..7efca0d6958 100644
--- a/spec/models/sensemaker/job_spec.rb
+++ b/spec/models/sensemaker/job_spec.rb
@@ -109,6 +109,117 @@
end
end
+ describe ".for_analysable" do
+ context "when record is a Budget" do
+ let(:budget) { create(:budget) }
+ let!(:published_budget_job) do
+ j = create(:sensemaker_job, analysable_type: "Budget", analysable_id: budget.id, published: false)
+ j.update_column(:published, true)
+ j
+ end
+ let!(:unpublished_budget_job) do
+ create(:sensemaker_job, analysable_type: "Budget", analysable_id: budget.id, published: false)
+ end
+ let!(:other_budget_job) do
+ j = create(:sensemaker_job,
+ analysable_type: "Budget",
+ analysable_id: create(:budget).id,
+ published: false)
+ j.update_column(:published, true)
+ j
+ end
+
+ it "with published_only: true returns only published jobs for that budget" do
+ scope = Sensemaker::Job.for_analysable(budget, published_only: true)
+ expect(scope).to include(published_budget_job)
+ expect(scope).not_to include(unpublished_budget_job)
+ expect(scope).not_to include(other_budget_job)
+ end
+
+ it "with published_only: false returns all jobs for that budget" do
+ scope = Sensemaker::Job.for_analysable(budget, published_only: false)
+ expect(scope).to include(published_budget_job)
+ expect(scope).to include(unpublished_budget_job)
+ expect(scope).not_to include(other_budget_job)
+ end
+ end
+
+ context "when record is a Debate (else branch)" do
+ let(:debate) { create(:debate) }
+ let!(:published_debate_job) do
+ j = create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false)
+ j.update_column(:published, true)
+ j
+ end
+ let!(:unpublished_debate_job) do
+ create(:sensemaker_job, analysable_type: "Debate", analysable_id: debate.id, published: false)
+ end
+
+ it "with published_only: true returns only published jobs for that record" do
+ scope = Sensemaker::Job.for_analysable(debate, published_only: true)
+ expect(scope).to include(published_debate_job)
+ expect(scope).not_to include(unpublished_debate_job)
+ end
+
+ it "with published_only: false returns all jobs for that record" do
+ scope = Sensemaker::Job.for_analysable(debate, published_only: false)
+ expect(scope).to include(published_debate_job)
+ expect(scope).to include(unpublished_debate_job)
+ end
+ end
+
+ context "when record is Proposal (all proposals)" do
+ let!(:all_proposals_job) do
+ j = create(:sensemaker_job, analysable_type: "Proposal", analysable_id: nil, published: false)
+ j.update_column(:published, true)
+ j
+ end
+ let!(:specific_proposal_job) do
+ j = create(:sensemaker_job,
+ analysable_type: "Proposal",
+ analysable_id: create(:proposal).id,
+ published: false)
+ j.update_column(:published, true)
+ j
+ end
+
+ it "with published_only: true returns only published jobs with nil analysable_id" do
+ scope = Sensemaker::Job.for_analysable(Proposal, published_only: true)
+ expect(scope).to include(all_proposals_job)
+ expect(scope).not_to include(specific_proposal_job)
+ end
+
+ it "with published_only: false returns all jobs with nil analysable_id" do
+ unpublished = create(:sensemaker_job,
+ analysable_type: "Proposal",
+ analysable_id: nil,
+ published: false)
+ scope = Sensemaker::Job.for_analysable(Proposal, published_only: false)
+ expect(scope).to include(all_proposals_job)
+ expect(scope).to include(unpublished)
+ expect(scope).not_to include(specific_proposal_job)
+ end
+ end
+
+ context "when record is a Poll" do
+ let(:poll) { create(:poll) }
+ let!(:published_poll_job) do
+ j = create(:sensemaker_job, analysable_type: "Poll", analysable_id: poll.id, published: false)
+ j.update_column(:published, true)
+ j
+ end
+ let!(:unpublished_poll_job) do
+ create(:sensemaker_job, analysable_type: "Poll", analysable_id: poll.id, published: false)
+ end
+
+ it "with published_only: false returns all jobs for that poll" do
+ scope = Sensemaker::Job.for_analysable(poll, published_only: false)
+ expect(scope).to include(published_poll_job)
+ expect(scope).to include(unpublished_poll_job)
+ end
+ end
+ end
+
describe "instance methods" do
describe "#has_multiple_outputs?" do
it "returns true for advanced_runner.ts and runner.ts" do