Skip to content

Commit df54d31

Browse files
Feat/create execution view (#21)
* Add public views * Fix sample_participants condition * Fix lints * Fix rubocop ci * Update locale for publish_sortitions * Remove unused setting for component * Execution view and data export * Changes in execution view and exports * Remove test secrets * Update gitignore * Fix rubocop lints * Fix rubocop lints * Fix tests * Fix tests * Fixes from review * Fix job tests * Modify Sortition workflow and form (#22) * Add navigation menu to form new * Refactor in navigation menu * Update test * Fix lint * Fix specs
1 parent b383e2f commit df54d31

File tree

39 files changed

+2165
-212
lines changed

39 files changed

+2165
-212
lines changed

Gemfile.lock

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -370,14 +370,7 @@ GEM
370370
logger
371371
faraday-net_http (3.4.1)
372372
net-http (>= 0.5.0)
373-
ffi (1.17.2-aarch64-linux-gnu)
374-
ffi (1.17.2-aarch64-linux-musl)
375-
ffi (1.17.2-arm-linux-gnu)
376-
ffi (1.17.2-arm-linux-musl)
377-
ffi (1.17.2-arm64-darwin)
378-
ffi (1.17.2-x86_64-darwin)
379373
ffi (1.17.2-x86_64-linux-gnu)
380-
ffi (1.17.2-x86_64-linux-musl)
381374
file_validators (3.0.0)
382375
activemodel (>= 3.2)
383376
mime-types (>= 1.0)
@@ -507,22 +500,8 @@ GEM
507500
net-smtp (0.3.4)
508501
net-protocol
509502
nio4r (2.7.4)
510-
nokogiri (1.18.10-aarch64-linux-gnu)
511-
racc (~> 1.4)
512-
nokogiri (1.18.10-aarch64-linux-musl)
513-
racc (~> 1.4)
514-
nokogiri (1.18.10-arm-linux-gnu)
515-
racc (~> 1.4)
516-
nokogiri (1.18.10-arm-linux-musl)
517-
racc (~> 1.4)
518-
nokogiri (1.18.10-arm64-darwin)
519-
racc (~> 1.4)
520-
nokogiri (1.18.10-x86_64-darwin)
521-
racc (~> 1.4)
522503
nokogiri (1.18.10-x86_64-linux-gnu)
523504
racc (~> 1.4)
524-
nokogiri (1.18.10-x86_64-linux-musl)
525-
racc (~> 1.4)
526505
oauth (1.1.2)
527506
oauth-tty (~> 1.0, >= 1.0.6)
528507
snaky_hash (~> 2.0)
@@ -821,14 +800,7 @@ GEM
821800
zeitwerk (2.7.3)
822801

823802
PLATFORMS
824-
aarch64-linux-gnu
825-
aarch64-linux-musl
826-
arm-linux-gnu
827-
arm-linux-musl
828-
arm64-darwin
829-
x86_64-darwin
830803
x86_64-linux-gnu
831-
x86_64-linux-musl
832804

833805
DEPENDENCIES
834806
bootsnap
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/concern"
4+
5+
module Decidim
6+
module StratifiedSortitions
7+
# Shared logic for computing strata and candidates chart data.
8+
# Used by both admin and public controllers to avoid duplication.
9+
module StrataChartsData
10+
extend ActiveSupport::Concern
11+
12+
private
13+
14+
def strata_data(stratified_sortition)
15+
stratified_sortition.strata.map do |stratum|
16+
chart_data = stratum.substrata.map do |substratum|
17+
quota_value = substratum.max_quota_percentage.present? ? substratum.max_quota_percentage.to_f : 0.0
18+
label_with_percentage = "#{translated_attribute(substratum.name)} (#{quota_value}%)"
19+
[label_with_percentage, quota_value]
20+
end
21+
chart_data = chart_data.reject { |_name, value| value.zero? }
22+
{
23+
stratum:,
24+
chart_data:,
25+
}
26+
end
27+
end
28+
29+
def candidates_data(stratified_sortition)
30+
participant_ids = stratified_sortition.sample_participants.pluck(:id)
31+
participants_distribution_data(stratified_sortition, participant_ids)
32+
end
33+
34+
def results_data(stratified_sortition)
35+
return stratified_sortition.strata.map { |stratum| { stratum:, chart_data: [] } } unless stratified_sortition.panel_portfolio&.sampled?
36+
37+
selected_ids = stratified_sortition.panel_portfolio.selected_panel
38+
participants_distribution_data(stratified_sortition, selected_ids)
39+
end
40+
41+
def participants_distribution_data(stratified_sortition, participant_ids)
42+
sample_candidates_stratum = fetch_sample_candidates_stratum(participant_ids)
43+
by_stratum = group_by_stratum(sample_candidates_stratum)
44+
by_stratum_and_substratum = group_by_stratum_and_substratum(sample_candidates_stratum)
45+
46+
stratified_sortition.strata.map do |stratum|
47+
build_stratum_chart(stratum, by_stratum, by_stratum_and_substratum)
48+
end
49+
end
50+
51+
def fetch_sample_candidates_stratum(sample_candidates_ids)
52+
Decidim::StratifiedSortitions::SampleParticipantStratum
53+
.where(decidim_stratified_sortitions_sample_participant_id: sample_candidates_ids)
54+
.select(:decidim_stratified_sortitions_sample_participant_id,
55+
:decidim_stratified_sortitions_stratum_id,
56+
:decidim_stratified_sortitions_substratum_id)
57+
.distinct
58+
.to_a
59+
end
60+
61+
def group_by_stratum(sample_candidates_stratum)
62+
sample_candidates_stratum.group_by(&:decidim_stratified_sortitions_stratum_id)
63+
end
64+
65+
def group_by_stratum_and_substratum(sample_candidates_stratum)
66+
sample_candidates_stratum.group_by do |s|
67+
[s.decidim_stratified_sortitions_stratum_id, s.decidim_stratified_sortitions_substratum_id]
68+
end
69+
end
70+
71+
def build_stratum_chart(stratum, by_stratum, by_stratum_and_substratum)
72+
substrata = stratum.substrata
73+
total = by_stratum[stratum.id]&.map(&:decidim_stratified_sortitions_sample_participant_id)&.uniq&.count || 0
74+
chart_data = substrata.map do |substratum|
75+
build_substratum_chart_row(stratum, substratum, by_stratum_and_substratum, total)
76+
end
77+
chart_data = chart_data.reject { |_name, value| value.zero? }
78+
{ stratum:, chart_data: }
79+
end
80+
81+
def build_substratum_chart_row(stratum, substratum, by_stratum_and_substratum, total)
82+
ids = (by_stratum_and_substratum[[stratum.id, substratum.id]] || [])
83+
.map(&:decidim_stratified_sortitions_sample_participant_id).uniq
84+
count = ids.count
85+
percentage = total.positive? ? ((count.to_f / total) * 100).round(1) : 0.0
86+
label = "#{translated_attribute(substratum.name)} (#{percentage}%)"
87+
[label, count]
88+
end
89+
end
90+
end
91+
end

app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb

Lines changed: 62 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module Admin
77
#
88
class StratifiedSortitionsController < Decidim::StratifiedSortitions::Admin::ApplicationController
99
include Decidim::ApplicationHelper
10+
include StrataChartsData
1011

1112
helper StratifiedSortitions::ApplicationHelper
1213
helper Decidim::PaginateHelper
@@ -33,9 +34,9 @@ def create
3334
@form = form(Decidim::StratifiedSortitions::Admin::StratifiedSortitionsForm).from_params(params)
3435

3536
Decidim::StratifiedSortitions::Admin::CreateStratifiedSortition.call(@form) do
36-
on(:ok) do
37+
on(:ok) do |created_sortition|
3738
flash[:notice] = I18n.t("stratified_sortitions.create.success", scope: "decidim.stratified_sortitions.admin")
38-
redirect_to stratified_sortitions_path(assembly_slug: -1, component_id: -1)
39+
redirect_to edit_stratified_sortition_path(created_sortition)
3940
end
4041

4142
on(:invalid) do
@@ -116,22 +117,75 @@ def execute
116117
unless stratified_sortition.can_execute?
117118
redirect_to edit_stratified_sortition_path(stratified_sortition),
118119
flash: { warning: t("stratified_sortitions.execute.empty_sample_participants", scope: "decidim.stratified_sortitions.admin") }
120+
return
119121
end
122+
123+
@stratified_sortition = stratified_sortition
124+
@portfolio = @stratified_sortition.panel_portfolio
125+
@strata = @stratified_sortition.strata.order(:position)
126+
@selected_participants = if @portfolio&.sampled?
127+
SampleParticipant
128+
.where(id: @portfolio.selected_panel)
129+
.includes(sample_participant_strata: [:decidim_stratified_sortitions_stratum, :decidim_stratified_sortitions_substratum])
130+
.order(:id)
131+
.to_a
132+
else
133+
[]
134+
end
135+
@strata_data = strata_data(@stratified_sortition)
136+
@candidates_data = candidates_data(@stratified_sortition)
137+
@results_data = results_data(@stratified_sortition)
120138
end
121139

122140
def execute_stratified_sortition
123141
@result = FairSortitionService.new(stratified_sortition).call
124142
if @result.success?
125143
stratified_sortition.update!(status: "executed")
126-
csv_data = generate_sortition_csv(@result)
127-
send_data csv_data,
128-
filename: "sortition_results_#{stratified_sortition.id}_#{Time.current.strftime("%Y%m%d_%H%M%S")}.csv",
129-
type: "text/csv",
130-
disposition: "attachment"
144+
flash[:notice] = I18n.t("stratified_sortitions.execute.success", scope: "decidim.stratified_sortitions.admin")
131145
else
132146
flash[:error] = @result.error
147+
end
148+
redirect_to execute_stratified_sortition_path(stratified_sortition)
149+
end
150+
151+
def export_charts_pdf
152+
portfolio = stratified_sortition.panel_portfolio
153+
154+
unless portfolio&.sampled?
155+
flash[:error] = I18n.t("stratified_sortitions.export_results.no_results", scope: "decidim.stratified_sortitions.admin")
133156
redirect_to execute_stratified_sortition_path(stratified_sortition)
157+
return
134158
end
159+
160+
generator = ChartsPdfGenerator.new(
161+
stratified_sortition,
162+
strata_data(stratified_sortition),
163+
candidates_data(stratified_sortition),
164+
results_data(stratified_sortition),
165+
locale: I18n.locale
166+
)
167+
168+
filename = "sortition_charts_#{stratified_sortition.id}_#{Time.current.strftime("%Y%m%d_%H%M%S")}.pdf"
169+
send_data generator.generate,
170+
filename:,
171+
type: "application/pdf",
172+
disposition: "attachment"
173+
end
174+
175+
def export_results
176+
format = params[:format]&.downcase || "csv"
177+
portfolio = stratified_sortition.panel_portfolio
178+
179+
unless portfolio&.sampled?
180+
flash[:error] = I18n.t("stratified_sortitions.export_results.no_results", scope: "decidim.stratified_sortitions.admin")
181+
redirect_to execute_stratified_sortition_path(stratified_sortition)
182+
return
183+
end
184+
185+
SortitionResultsExportJob.perform_later(current_user, stratified_sortition, format)
186+
187+
flash[:notice] = I18n.t("decidim.admin.exports.notice")
188+
redirect_to execute_stratified_sortition_path(stratified_sortition)
135189
end
136190

137191
private
@@ -145,7 +199,7 @@ def stratified_sortitions
145199
end
146200

147201
def stratified_sortition
148-
@stratified_sortition ||= collection.find(params[:id])
202+
@stratified_sortition ||= collection.find_by(id: params[:id])
149203
end
150204

151205
def form_presenter
@@ -159,115 +213,6 @@ def blank_stratum
159213
def blank_substratum(stratum_form)
160214
Decidim::StratifiedSortitions::Admin::SubstratumForm.new(stratum: stratum_form.model)
161215
end
162-
163-
def strata_data(stratified_sortition)
164-
stratified_sortition.strata.map do |stratum|
165-
chart_data = stratum.substrata.map do |substratum|
166-
quota_value = substratum.max_quota_percentage.present? ? substratum.max_quota_percentage.to_f : 0.0
167-
label_with_percentage = "#{translated_attribute(substratum.name)} (#{quota_value}%)"
168-
[label_with_percentage, quota_value]
169-
end
170-
chart_data = chart_data.reject { |_name, value| value.zero? }
171-
{
172-
stratum:,
173-
chart_data:,
174-
}
175-
end
176-
end
177-
178-
def candidates_data(stratified_sortition)
179-
sample_candidates_ids = stratified_sortition.sample_participants.pluck(:id)
180-
sample_candidates_stratum = fetch_sample_candidates_stratum(sample_candidates_ids)
181-
by_stratum = group_by_stratum(sample_candidates_stratum)
182-
by_stratum_and_substratum = group_by_stratum_and_substratum(sample_candidates_stratum)
183-
184-
stratified_sortition.strata.map do |stratum|
185-
build_stratum_chart(stratum, by_stratum, by_stratum_and_substratum)
186-
end
187-
end
188-
189-
def fetch_sample_candidates_stratum(sample_candidates_ids)
190-
Decidim::StratifiedSortitions::SampleParticipantStratum
191-
.where(decidim_stratified_sortitions_sample_participant_id: sample_candidates_ids)
192-
.select(:decidim_stratified_sortitions_sample_participant_id,
193-
:decidim_stratified_sortitions_stratum_id,
194-
:decidim_stratified_sortitions_substratum_id)
195-
.distinct
196-
.to_a
197-
end
198-
199-
def group_by_stratum(sample_candidates_stratum)
200-
sample_candidates_stratum.group_by(&:decidim_stratified_sortitions_stratum_id)
201-
end
202-
203-
def group_by_stratum_and_substratum(sample_candidates_stratum)
204-
sample_candidates_stratum.group_by do |s|
205-
[s.decidim_stratified_sortitions_stratum_id, s.decidim_stratified_sortitions_substratum_id]
206-
end
207-
end
208-
209-
def build_stratum_chart(stratum, by_stratum, by_stratum_and_substratum)
210-
substrata = stratum.substrata
211-
total = by_stratum[stratum.id]&.map(&:decidim_stratified_sortitions_sample_participant_id)&.uniq&.count || 0
212-
chart_data = substrata.map do |substratum|
213-
build_substratum_chart_row(stratum, substratum, by_stratum_and_substratum, total)
214-
end
215-
chart_data = chart_data.reject { |_name, value| value.zero? }
216-
{ stratum:, chart_data: }
217-
end
218-
219-
def build_substratum_chart_row(stratum, substratum, by_stratum_and_substratum, total)
220-
ids = (by_stratum_and_substratum[[stratum.id, substratum.id]] || [])
221-
.map(&:decidim_stratified_sortitions_sample_participant_id).uniq
222-
count = ids.count
223-
percentage = total.positive? ? ((count.to_f / total) * 100).round(1) : 0.0
224-
label = "#{translated_attribute(substratum.name)} (#{percentage}%)"
225-
[label, count]
226-
end
227-
228-
# NOTE: This is a temporary export for see sortition results
229-
def generate_sortition_csv(result)
230-
require "csv"
231-
232-
CSV.generate(headers: true, col_sep: ";") do |csv|
233-
csv << ["# INFORMACIÓ DEL SORTEIG"]
234-
csv << ["Algorisme", result.selection_log[:algorithm]]
235-
csv << ["Versió", result.selection_log[:version]]
236-
csv << ["ID Sorteig", result.selection_log[:stratified_sortition_id]]
237-
csv << ["Generat el", result.selection_log[:generated_at]]
238-
csv << ["Temps de generació (s)", result.selection_log[:generation_time_seconds]]
239-
csv << ["Nombre de panels", result.selection_log[:num_panels]]
240-
csv << ["Iteracions", result.selection_log[:num_iterations]]
241-
csv << ["Convergència", result.selection_log[:convergence_achieved]]
242-
csv << ["Seleccionat el", result.selection_log[:selected_at]]
243-
csv << ["Índex panel seleccionat", result.selection_log[:selected_panel_index]]
244-
csv << ["Seed de verificació", result.selection_log[:verification_seed]]
245-
csv << ["Valor aleatori", result.selection_log[:random_value_used]]
246-
csv << ["Probabilitat panel seleccionat", result.selection_log[:selected_panel_probability]]
247-
248-
if result.selection_log[:fairness_metrics].present?
249-
csv << [""]
250-
csv << ["# MÈTRIQUES D'EQUITAT"]
251-
result.selection_log[:fairness_metrics].each do |key, value|
252-
csv << [key.to_s.humanize, value]
253-
end
254-
end
255-
256-
csv << [""]
257-
csv << ["# PARTICIPANTS SELECCIONATS"]
258-
csv << %w[ID Dada_Personal_1(Identificador únic) Dada_Personal_2 Dada_Personal_3 Dada_Personal_4]
259-
260-
result.selected_participants.each do |participant|
261-
csv << [
262-
participant.id,
263-
participant.personal_data_1,
264-
participant.personal_data_2,
265-
participant.personal_data_3,
266-
participant.personal_data_4,
267-
]
268-
end
269-
end
270-
end
271216
end
272217
end
273218
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Decidim
4+
module StratifiedSortitions
5+
# A dummy controller used to render PDF views outside the request cycle.
6+
# Following the same pattern as Decidim core (decidim-forms).
7+
# rubocop:disable Rails/ApplicationController
8+
class ChartsPdfControllerHelper < ActionController::Base
9+
# rubocop:enable Rails/ApplicationController
10+
helper Decidim::TranslationsHelper
11+
helper Decidim::StratifiedSortitions::ChartsPdfHelper
12+
13+
# Ensure the engine's views (templates + layouts) are found.
14+
append_view_path Decidim::StratifiedSortitions::Engine.root.join("app", "views")
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)