decidim-stratified_sortitions 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE-AGPLv3.txt +661 -0
  3. data/README.md +91 -0
  4. data/Rakefile +42 -0
  5. data/app/cells/decidim/stratified_sortitions/stratified_sortition_cell.rb +21 -0
  6. data/app/cells/decidim/stratified_sortitions/stratified_sortition_l_cell.rb +22 -0
  7. data/app/cells/decidim/stratified_sortitions/stratified_sortition_metadata_cell.rb +63 -0
  8. data/app/commands/decidim/stratified_sortitions/admin/create_stratified_sortition.rb +84 -0
  9. data/app/commands/decidim/stratified_sortitions/admin/destroy_stratified_sortition.rb +49 -0
  10. data/app/commands/decidim/stratified_sortitions/admin/duplicate_stratified_sortition.rb +74 -0
  11. data/app/commands/decidim/stratified_sortitions/admin/import_sample.rb +40 -0
  12. data/app/commands/decidim/stratified_sortitions/admin/remove_uploaded_samples.rb +53 -0
  13. data/app/commands/decidim/stratified_sortitions/admin/update_stratified_sortition.rb +174 -0
  14. data/app/controllers/concerns/decidim/stratified_sortitions/orderable_stratified_sortitions.rb +37 -0
  15. data/app/controllers/concerns/decidim/stratified_sortitions/strata_charts_data.rb +91 -0
  16. data/app/controllers/decidim/stratified_sortitions/admin/application_controller.rb +19 -0
  17. data/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb +143 -0
  18. data/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb +235 -0
  19. data/app/controllers/decidim/stratified_sortitions/application_controller.rb +13 -0
  20. data/app/controllers/decidim/stratified_sortitions/charts_pdf_controller_helper.rb +17 -0
  21. data/app/controllers/decidim/stratified_sortitions/stratified_sortitions_controller.rb +61 -0
  22. data/app/forms/decidim/stratified_sortitions/admin/sample_upload_form.rb +15 -0
  23. data/app/forms/decidim/stratified_sortitions/admin/stratified_sortitions_form.rb +155 -0
  24. data/app/forms/decidim/stratified_sortitions/admin/stratum_form.rb +69 -0
  25. data/app/forms/decidim/stratified_sortitions/admin/substratum_form.rb +26 -0
  26. data/app/helpers/decidim/stratified_sortitions/admin/stratified_sortitions_helper.rb +18 -0
  27. data/app/helpers/decidim/stratified_sortitions/application_helper.rb +85 -0
  28. data/app/helpers/decidim/stratified_sortitions/charts_pdf_helper.rb +73 -0
  29. data/app/helpers/decidim/stratified_sortitions/stratified_sortition_cells_helper.rb +56 -0
  30. data/app/javascript/channels/sample_import_progress.js +12 -0
  31. data/app/jobs/decidim/stratified_sortitions/admin/execute_sortition_job.rb +21 -0
  32. data/app/jobs/decidim/stratified_sortitions/admin/import_sample_job.rb +170 -0
  33. data/app/jobs/decidim/stratified_sortitions/admin/remove_samples_job.rb +19 -0
  34. data/app/jobs/decidim/stratified_sortitions/admin/sortition_results_export_job.rb +28 -0
  35. data/app/mailers/decidim/stratified_sortitions/admin/import_mailer.rb +26 -0
  36. data/app/models/decidim/stratified_sortitions/application_record.rb +9 -0
  37. data/app/models/decidim/stratified_sortitions/panel_portfolio.rb +165 -0
  38. data/app/models/decidim/stratified_sortitions/sample_import.rb +14 -0
  39. data/app/models/decidim/stratified_sortitions/sample_participant.rb +12 -0
  40. data/app/models/decidim/stratified_sortitions/sample_participant_stratum.rb +11 -0
  41. data/app/models/decidim/stratified_sortitions/stratified_sortition.rb +74 -0
  42. data/app/models/decidim/stratified_sortitions/stratum.rb +22 -0
  43. data/app/models/decidim/stratified_sortitions/substratum.rb +19 -0
  44. data/app/packs/entrypoints/decidim_stratified_sortitions.js +10 -0
  45. data/app/packs/entrypoints/decidim_stratified_sortitions_admin.js +9 -0
  46. data/app/packs/entrypoints/decidim_stratified_sortitions_admin.scss +1 -0
  47. data/app/packs/images/decidim/stratified_sortitions/icon.svg +1 -0
  48. data/app/packs/src/decidim/stratified_sortitions/application.js +3 -0
  49. data/app/packs/src/decidim/stratified_sortitions/results_tabs.js +59 -0
  50. data/app/packs/src/decidim/stratified_sortitions/stratum_fields.js +302 -0
  51. data/app/packs/src/decidim/stratified_sortitions/substratum_fields.js +165 -0
  52. data/app/packs/src/decidim/stratified_sortitions/upload_sample.js +16 -0
  53. data/app/packs/stylesheets/decidim/stratified_sortitions/admin/stratified_sortitions.scss +210 -0
  54. data/app/packs/stylesheets/decidim/stratified_sortitions/charts_pdf.scss +148 -0
  55. data/app/packs/stylesheets/stratified_sortitions.scss +56 -0
  56. data/app/permissions/decidim/stratified_sortitions/admin/permissions.rb +74 -0
  57. data/app/permissions/decidim/stratified_sortitions/permissions.rb +23 -0
  58. data/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb +35 -0
  59. data/app/queries/decidim/stratified_sortitions/filtered_stratified_sortitions.rb +39 -0
  60. data/app/serializers/decidim/stratified_sortitions/sortition_result_serializer.rb +116 -0
  61. data/app/services/decidim/stratified_sortitions/charts_pdf_generator.rb +96 -0
  62. data/app/services/decidim/stratified_sortitions/fair_sortition_service.rb +247 -0
  63. data/app/services/decidim/stratified_sortitions/leximin/constraint_builder.rb +180 -0
  64. data/app/services/decidim/stratified_sortitions/leximin/distribution_solver.rb +217 -0
  65. data/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +222 -0
  66. data/app/services/decidim/stratified_sortitions/leximin/panel_generator.rb +149 -0
  67. data/app/services/decidim/stratified_sortitions/leximin/panel_sampler.rb +125 -0
  68. data/app/services/decidim/stratified_sortitions/leximin_selector.rb +136 -0
  69. data/app/services/decidim/stratified_sortitions/sortition_results_exporter.rb +161 -0
  70. data/app/views/decidim/admin/components/index.html.erb +54 -0
  71. data/app/views/decidim/stratified_sortitions/admin/import_mailer/import.html.erb +22 -0
  72. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_execute_modal.html.erb +19 -0
  73. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb +20 -0
  74. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_form.html.erb +55 -0
  75. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_navigation_menu.html.erb +10 -0
  76. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_strata.html.erb +22 -0
  77. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_stratum.html.erb +75 -0
  78. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substrata.html.erb +34 -0
  79. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substratum.html.erb +54 -0
  80. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/edit.html.erb +21 -0
  81. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/execute.html.erb +155 -0
  82. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf.html.erb +38 -0
  83. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/index.html.erb +75 -0
  84. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/new.html.erb +21 -0
  85. data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/upload_sample.html.erb +117 -0
  86. data/app/views/decidim/stratified_sortitions/shared/_strata_charts.html.erb +21 -0
  87. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_results_count.html.erb +15 -0
  88. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortition.html.erb +1 -0
  89. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortitions.html.erb +13 -0
  90. data/app/views/decidim/stratified_sortitions/stratified_sortitions/_tags.html.erb +9 -0
  91. data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.html.erb +22 -0
  92. data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.js.erb +5 -0
  93. data/app/views/decidim/stratified_sortitions/stratified_sortitions/show.html.erb +142 -0
  94. data/app/views/layouts/decidim/admin/_sidebar_menu.html.erb +45 -0
  95. data/app/views/layouts/decidim/stratified_sortitions/charts_pdf.html.erb +14 -0
  96. data/config/assets.rb +24 -0
  97. data/config/locales/ca.yml +361 -0
  98. data/config/locales/en.yml +360 -0
  99. data/config/locales/es.yml +360 -0
  100. data/config/locales/oc.yml +2 -0
  101. data/config/routes.rb +1 -0
  102. data/db/migrate/20251023103900_create_decidim_stratified_sortitions_stratified_sortitions.rb +18 -0
  103. data/db/migrate/20251104120000_create_stratified_sortitions_strata_and_substrata.decidim_stratified_sortitions.rb +27 -0
  104. data/db/migrate/20251219120000_create_sample_imports.rb +17 -0
  105. data/db/migrate/20251219120100_create_sample_participants.rb +19 -0
  106. data/db/migrate/20251219120200_create_sample_participant_strata.rb +16 -0
  107. data/db/migrate/20251230130000_change_value_to_text_in_substrata.rb +11 -0
  108. data/db/migrate/20260102114744_add_status_to_stratified_sortitions.rb +11 -0
  109. data/db/migrate/20260109105643_allow_null_value_in_substrata.rb +10 -0
  110. data/db/migrate/20260113121726_add_position_to_strata_and_substrata.rb +8 -0
  111. data/db/migrate/20260127100000_rename_weighing_to_max_quota_percentage.rb +12 -0
  112. data/db/migrate/20260127120000_create_panel_portfolios.rb +46 -0
  113. data/db/migrate/20260518000000_add_foreign_key_to_stratified_sortitions_component.rb +10 -0
  114. data/db/migrate/20260521000000_add_cascade_to_stratified_sortitions_foreign_keys.rb +64 -0
  115. data/db/migrate/20260616000000_add_execution_error_to_stratified_sortitions.rb +11 -0
  116. data/lib/decidim/stratified_sortitions/admin.rb +10 -0
  117. data/lib/decidim/stratified_sortitions/admin_engine.rb +49 -0
  118. data/lib/decidim/stratified_sortitions/component.rb +41 -0
  119. data/lib/decidim/stratified_sortitions/engine.rb +34 -0
  120. data/lib/decidim/stratified_sortitions/seeds.rb +24 -0
  121. data/lib/decidim/stratified_sortitions/test/factories.rb +80 -0
  122. data/lib/decidim/stratified_sortitions/version.rb +13 -0
  123. data/lib/decidim/stratified_sortitions.rb +29 -0
  124. metadata +222 -0
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Leximin
6
+ # Generates feasible panels using Integer Linear Programming (ILP).
7
+ #
8
+ # Uses the COIN-OR CBC solver to find panels that satisfy:
9
+ # - Exactly k volunteers selected (panel size)
10
+ # - Quota constraints for each category (min/max)
11
+ #
12
+ # For column generation, it can also find panels that improve the
13
+ # current LEXIMIN objective using dual prices.
14
+ #
15
+ class PanelGenerator
16
+ class CbcNotAvailableError < StandardError; end
17
+
18
+ def initialize(constraint_builder)
19
+ @cb = constraint_builder
20
+ require_cbc!
21
+ end
22
+
23
+ private
24
+
25
+ def require_cbc!
26
+ require "ruby-cbc"
27
+ rescue LoadError
28
+ raise CbcNotAvailableError,
29
+ "The CBC solver is required for LEXIMIN selection. " \
30
+ "Install the gem with: bundle add ruby-cbc\n" \
31
+ "And system libraries: sudo apt install coinor-cbc coinor-libcbc-dev"
32
+ end
33
+
34
+ public
35
+
36
+ # Find any feasible panel satisfying all constraints
37
+ #
38
+ # @return [Array<Integer>, nil] Array of volunteer IDs or nil if infeasible
39
+ def find_feasible_panel
40
+ solve_panel_ilp(nil)
41
+ end
42
+
43
+ # Find a panel that improves the LEXIMIN objective
44
+ #
45
+ # Uses dual prices from the master problem to find a panel with
46
+ # positive reduced cost (improving the objective).
47
+ #
48
+ # @param dual_prices [Hash{Integer => Float}] dual prices for each volunteer
49
+ # @return [Array<Integer>, nil] Array of volunteer IDs or nil if no improving panel
50
+ def find_improving_panel(dual_prices)
51
+ return find_feasible_panel if dual_prices.blank?
52
+
53
+ solve_panel_ilp(dual_prices)
54
+ end
55
+
56
+ private
57
+
58
+ # Solve the panel selection ILP
59
+ #
60
+ # Variables: x_i ∈ {0,1} for each volunteer i
61
+ #
62
+ # Constraints:
63
+ # ∑_i x_i = k (panel size)
64
+ # ℓ_j ≤ ∑_{i ∈ C_j} x_i ≤ u_j for each category j
65
+ #
66
+ # Objective:
67
+ # - If dual_prices nil: any feasible solution (maximize constant)
68
+ # - If dual_prices given: maximize ∑_i π_i * x_i (reduced cost)
69
+ #
70
+ # @param dual_prices [Hash, nil]
71
+ # @return [Array<Integer>, nil]
72
+ def solve_panel_ilp(dual_prices) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
73
+ return nil if @cb.num_volunteers.zero?
74
+
75
+ model = Cbc::Model.new
76
+
77
+ # Create binary variables for each volunteer
78
+ x = @cb.volunteer_ids.map do |vid|
79
+ model.bin_var(name: "x_#{vid}")
80
+ end
81
+
82
+ # Constraint: exactly k volunteers selected
83
+ model.enforce(sum_vars(x) == @cb.panel_size)
84
+
85
+ # Quota constraints for each category
86
+ @cb.category_ids.each do |cat_id|
87
+ quota = @cb.quotas[cat_id]
88
+ volunteers_in_cat = @cb.category_volunteers[cat_id]
89
+
90
+ next if volunteers_in_cat.empty?
91
+
92
+ # Sum of x_i for volunteers in this category
93
+ cat_vars = volunteers_in_cat.map do |vid|
94
+ idx = @cb.volunteer_index(vid)
95
+ x[idx]
96
+ end.compact
97
+
98
+ next if cat_vars.empty?
99
+
100
+ cat_sum = sum_vars(cat_vars)
101
+
102
+ # min <= sum <= max
103
+ model.enforce(cat_sum >= quota[:min]) if (quota[:min]).positive?
104
+ model.enforce(cat_sum <= quota[:max]) if quota[:max] < @cb.panel_size
105
+ end
106
+
107
+ # Objective function
108
+ if dual_prices.blank?
109
+ # Just find any feasible solution - maximize sum (constant effect)
110
+ model.maximize(sum_vars(x))
111
+ else
112
+ # Maximize reduced cost: ∑_i π_i * x_i
113
+ objective_terms = @cb.volunteer_ids.map.with_index do |vid, idx|
114
+ price = dual_prices[vid] || 0.0
115
+ x[idx] * price
116
+ end
117
+ model.maximize(sum_vars(objective_terms))
118
+ end
119
+
120
+ # Solve
121
+ problem = model.to_problem
122
+ problem.solve
123
+
124
+ return nil unless problem.proven_optimal?
125
+
126
+ # Check if reduced cost is positive (improving)
127
+ if dual_prices.present?
128
+ obj_value = problem.objective_value
129
+ return nil if obj_value <= LeximinSelector::CONVERGENCE_THRESHOLD
130
+ end
131
+
132
+ # Extract selected volunteer IDs
133
+ selected = []
134
+ @cb.volunteer_ids.each_with_index do |vid, idx|
135
+ selected << vid if problem.value_of(x[idx]) > 0.5
136
+ end
137
+
138
+ selected.empty? ? nil : selected
139
+ end
140
+
141
+ def sum_vars(vars)
142
+ return 0 if vars.empty?
143
+
144
+ vars.reduce(:+)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Leximin
6
+ # Samples a single panel from the LEXIMIN distribution.
7
+ #
8
+ # Given a set of panels and their probabilities (from LeximinSelector),
9
+ # this service selects one panel according to the probability distribution.
10
+ #
11
+ # The sampling is cryptographically secure using SecureRandom.
12
+ #
13
+ # @example
14
+ # leximin_result = LeximinSelector.new(stratified_sortition).call
15
+ # sampler = PanelSampler.new(leximin_result.panels, leximin_result.probabilities)
16
+ # selected_panel = sampler.sample
17
+ # # => [participant_id_1, participant_id_2, ...]
18
+ #
19
+ class PanelSampler
20
+ # Result object for sampling operation
21
+ Result = Struct.new(:selected_panel, :selected_index, :random_value, :success, :error, keyword_init: true) do
22
+ def success?
23
+ success
24
+ end
25
+ end
26
+
27
+ def initialize(panels, probabilities, random_seed: nil)
28
+ @panels = panels
29
+ @probabilities = normalize_probabilities(probabilities)
30
+ @random_seed = random_seed
31
+ end
32
+
33
+ # Sample a panel according to the probability distribution
34
+ #
35
+ # @return [Result] containing the selected panel and metadata
36
+ def sample
37
+ return error_result("No hi ha panels disponibles per mostrejar") if @panels.empty?
38
+ return error_result("Les probabilitats no coincideixen amb el nombre de panels") if @panels.size != @probabilities.size
39
+
40
+ # Generate random value in [0, 1)
41
+ random_value = generate_random_value
42
+
43
+ # Select panel using cumulative distribution
44
+ selected_index = select_panel_index(random_value)
45
+
46
+ Result.new(
47
+ selected_panel: @panels[selected_index],
48
+ selected_index:,
49
+ random_value:,
50
+ success: true,
51
+ error: nil
52
+ )
53
+ rescue StandardError => e
54
+ error_result("Error en el mostreig: #{e.message}")
55
+ end
56
+
57
+ # Sample multiple panels (with replacement)
58
+ #
59
+ # @param count [Integer] number of panels to sample
60
+ # @return [Array<Result>] array of sampling results
61
+ def sample_multiple(count)
62
+ Array.new(count) { sample }
63
+ end
64
+
65
+ # Get the cumulative distribution function
66
+ #
67
+ # @return [Array<Float>] cumulative probabilities
68
+ def cumulative_distribution
69
+ cumsum = 0.0
70
+ @probabilities.map { |p| cumsum += p }
71
+ end
72
+
73
+ private
74
+
75
+ def normalize_probabilities(probs)
76
+ return [] if probs.blank?
77
+
78
+ total = probs.sum.to_f
79
+ return probs if (total - 1.0).abs < 1e-9
80
+
81
+ # Normalize to sum to 1
82
+ if total.positive?
83
+ probs.map { |p| p / total }
84
+ else
85
+ # Uniform distribution if all zeros
86
+ uniform = 1.0 / probs.size
87
+ Array.new(probs.size, uniform)
88
+ end
89
+ end
90
+
91
+ def generate_random_value
92
+ if @random_seed
93
+ # Deterministic for testing
94
+ Random.new(@random_seed).rand
95
+ else
96
+ # Cryptographically secure random
97
+ SecureRandom.random_number
98
+ end
99
+ end
100
+
101
+ def select_panel_index(random_value)
102
+ cumsum = 0.0
103
+
104
+ @probabilities.each_with_index do |prob, idx|
105
+ cumsum += prob
106
+ return idx if random_value < cumsum
107
+ end
108
+
109
+ # Edge case: return last panel (handles floating point errors)
110
+ @panels.size - 1
111
+ end
112
+
113
+ def error_result(message)
114
+ Result.new(
115
+ selected_panel: [],
116
+ selected_index: nil,
117
+ random_value: nil,
118
+ success: false,
119
+ error: message
120
+ )
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # LEXIMIN Selector Service
6
+ #
7
+ # Implements the LEXIMIN fair selection algorithm using column generation.
8
+ # Based on "Fair algorithms for selecting citizens' assemblies" paper.
9
+ #
10
+ # The algorithm finds an optimal portfolio of panels (feasible selections)
11
+ # and computes a probability distribution over them that maximizes fairness
12
+ # in selection probabilities using the leximin criterion.
13
+ #
14
+ # @example
15
+ # result = LeximinSelector.new(stratified_sortition).call
16
+ # if result.success?
17
+ # result.panels # Array of panels (each panel is array of participant IDs)
18
+ # result.probabilities # Probability for each panel
19
+ # else
20
+ # result.error # Error message
21
+ # end
22
+ #
23
+ class LeximinSelector
24
+ # Maximum number of column generation iterations
25
+ MAX_ITERATIONS = 1000
26
+
27
+ # Convergence threshold for reduced cost
28
+ CONVERGENCE_THRESHOLD = 1e-6
29
+
30
+ # Result object returned by the algorithm
31
+ Result = Struct.new(:panels, :probabilities, :selection_probabilities, :iterations, :converged, :success, :error, keyword_init: true) do
32
+ def success?
33
+ success
34
+ end
35
+ end
36
+
37
+ def initialize(stratified_sortition)
38
+ @stratified_sortition = stratified_sortition
39
+ @constraint_builder = Leximin::ConstraintBuilder.new(stratified_sortition)
40
+ end
41
+
42
+ # Execute the LEXIMIN algorithm
43
+ #
44
+ # @return [Result] containing panels, probabilities, and success status
45
+ def call
46
+ # Step 1: Check feasibility
47
+ feasibility = Leximin::FeasibilityChecker.new(@constraint_builder).check
48
+ unless feasibility[:feasible]
49
+ return Result.new(
50
+ panels: [],
51
+ probabilities: [],
52
+ selection_probabilities: {},
53
+ success: false,
54
+ error: feasibility[:errors].join("; ")
55
+ )
56
+ end
57
+
58
+ # Step 2: Initialize with one feasible panel
59
+ panel_generator = Leximin::PanelGenerator.new(@constraint_builder)
60
+ initial_panel = panel_generator.find_feasible_panel
61
+ return infeasible_result(I18n.t("decidim.stratified_sortitions.errors.leximin.no_feasible_panel")) if initial_panel.nil?
62
+
63
+ panels = [initial_panel]
64
+
65
+ # Step 3: Column generation loop
66
+ distribution_solver = Leximin::DistributionSolver.new(@constraint_builder)
67
+
68
+ MAX_ITERATIONS.times do
69
+ # Compute optimal distribution over current panels
70
+ distribution = distribution_solver.compute(panels)
71
+
72
+ # Try to find a new panel that improves the objective
73
+ new_panel = panel_generator.find_improving_panel(distribution[:dual_prices])
74
+
75
+ # Check convergence: no improving panel found
76
+ break if new_panel.nil? || panels_include?(panels, new_panel)
77
+
78
+ panels << new_panel
79
+ end
80
+
81
+ # Step 4: Compute final distribution
82
+ final_distribution = distribution_solver.compute(panels)
83
+
84
+ # Step 5: Compute selection probabilities for each volunteer
85
+ selection_probs = compute_selection_probabilities(panels, final_distribution[:probabilities])
86
+
87
+ Result.new(
88
+ panels:,
89
+ probabilities: final_distribution[:probabilities],
90
+ selection_probabilities: selection_probs,
91
+ success: true,
92
+ error: nil
93
+ )
94
+ rescue StandardError => e
95
+ Rails.logger.debug { "LEXIMIN Selector error: #{e.message}\n#{e.backtrace.join("\n")}" }
96
+ Result.new(
97
+ panels: [],
98
+ probabilities: [],
99
+ selection_probabilities: {},
100
+ success: false,
101
+ error: I18n.t("decidim.stratified_sortitions.errors.leximin.internal_error", error: e.message)
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def infeasible_result(message)
108
+ Result.new(
109
+ panels: [],
110
+ probabilities: [],
111
+ selection_probabilities: {},
112
+ success: false,
113
+ error: message
114
+ )
115
+ end
116
+
117
+ def panels_include?(panels, new_panel)
118
+ new_panel_set = Set.new(new_panel)
119
+ panels.any? { |p| Set.new(p) == new_panel_set }
120
+ end
121
+
122
+ def compute_selection_probabilities(panels, probabilities)
123
+ selection_probs = Hash.new(0.0)
124
+
125
+ panels.each_with_index do |panel, idx|
126
+ prob = probabilities[idx] || 0.0
127
+ panel.each do |volunteer_id|
128
+ selection_probs[volunteer_id] += prob
129
+ end
130
+ end
131
+
132
+ selection_probs
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "rubyXL"
5
+ require "rubyXL/convenience_methods/cell"
6
+ require "rubyXL/convenience_methods/font"
7
+ require "rubyXL/convenience_methods/workbook"
8
+ require "rubyXL/convenience_methods/worksheet"
9
+
10
+ module Decidim
11
+ module StratifiedSortitions
12
+ # Custom exporter for sortition results that produces a layout with:
13
+ # Row 1: Metadata headers
14
+ # Row 2: Metadata values
15
+ # Row 3: Blank
16
+ # Row 4: Participant headers
17
+ # Row 5+: Participant data
18
+ class SortitionResultsExporter
19
+ def initialize(stratified_sortition)
20
+ @stratified_sortition = stratified_sortition
21
+ @portfolio = stratified_sortition.panel_portfolio
22
+ @audit_log = @portfolio.audit_log
23
+ @strata = stratified_sortition.strata.order(:position)
24
+ @participants = SampleParticipant
25
+ .where(id: @portfolio.selected_panel)
26
+ .includes(sample_participant_strata: [:decidim_stratified_sortitions_stratum, :decidim_stratified_sortitions_substratum])
27
+ .order(:id)
28
+ @total_participants = stratified_sortition.sample_participants.count
29
+ end
30
+
31
+ def export_csv(col_sep = Decidim.default_csv_col_sep)
32
+ data = ::CSV.generate(col_sep:) do |csv|
33
+ csv << metadata_headers
34
+ csv << metadata_values
35
+ csv << [] # blank row
36
+ csv << participant_headers
37
+ @participants.each { |p| csv << participant_row(p) }
38
+ end
39
+ Decidim::Exporters::ExportData.new(data, "csv")
40
+ end
41
+
42
+ def export_excel
43
+ workbook = RubyXL::Workbook.new
44
+ worksheet = workbook[0]
45
+
46
+ # Row 0: metadata headers
47
+ metadata_headers.each_with_index do |header, col|
48
+ worksheet.add_cell(0, col, header)
49
+ worksheet.change_column_width(col, 20)
50
+ end
51
+ style_header_row(worksheet, 0, metadata_headers.size)
52
+
53
+ # Row 1: metadata values
54
+ metadata_values.each_with_index { |val, col| worksheet.add_cell(1, col, val) }
55
+
56
+ # Row 2: blank
57
+ # Row 3: participant headers
58
+ participant_headers.each_with_index do |header, col|
59
+ worksheet.add_cell(3, col, header)
60
+ current_width = worksheet.get_column_width(col)
61
+ worksheet.change_column_width(col, 20) if current_width.nil? || current_width < 20
62
+ end
63
+ style_header_row(worksheet, 3, participant_headers.size)
64
+
65
+ # Row 4+: participant data
66
+ @participants.each_with_index do |participant, idx|
67
+ participant_row(participant).each_with_index do |val, col|
68
+ worksheet.add_cell(4 + idx, col, val)
69
+ end
70
+ end
71
+
72
+ Decidim::Exporters::ExportData.new(workbook.stream.string, "xlsx")
73
+ end
74
+
75
+ def export_json
76
+ data = {
77
+ metadata: metadata_headers.zip(metadata_values).to_h,
78
+ participants: @participants.map do |p|
79
+ participant_headers.zip(participant_row(p)).to_h
80
+ end,
81
+ }
82
+ Decidim::Exporters::ExportData.new(JSON.pretty_generate(data), "json")
83
+ end
84
+
85
+ private
86
+
87
+ def i18n_scope
88
+ "decidim.stratified_sortitions.admin.stratified_sortitions.execute"
89
+ end
90
+
91
+ def metadata_headers
92
+ @metadata_headers ||= [
93
+ I18n.t("algorithm", scope: i18n_scope),
94
+ I18n.t("total_participants", scope: i18n_scope),
95
+ I18n.t("generated_at", scope: i18n_scope),
96
+ I18n.t("generation_time", scope: i18n_scope),
97
+ I18n.t("num_panels", scope: i18n_scope),
98
+ I18n.t("selected_at", scope: i18n_scope),
99
+ I18n.t("random_value_used", scope: i18n_scope),
100
+ I18n.t("selected_panel_probability", scope: i18n_scope),
101
+ ]
102
+ end
103
+
104
+ def metadata_values
105
+ @metadata_values ||= [
106
+ "#{@audit_log[:algorithm]} v#{@audit_log[:version]}",
107
+ @total_participants,
108
+ @portfolio.generated_at,
109
+ @portfolio.generation_time_seconds,
110
+ @portfolio.num_panels,
111
+ @portfolio.selected_at,
112
+ @portfolio.random_value_used,
113
+ format_percentage(@portfolio.selected_panel_probability),
114
+ ]
115
+ end
116
+
117
+ def participant_headers
118
+ @participant_headers ||= begin
119
+ template_scope = "decidim.stratified_sortitions.admin.samples.template"
120
+ headers = [
121
+ I18n.t("personal_data_1", scope: template_scope),
122
+ I18n.t("personal_data_2", scope: template_scope),
123
+ I18n.t("personal_data_3", scope: template_scope),
124
+ I18n.t("personal_data_4", scope: template_scope),
125
+ ]
126
+ @strata.each do |stratum|
127
+ stratum_name = stratum.name.values.compact.first || stratum.id.to_s
128
+ headers << stratum_name
129
+ end
130
+ headers
131
+ end
132
+ end
133
+
134
+ def participant_row(participant)
135
+ row = [
136
+ participant.personal_data_1,
137
+ participant.personal_data_2,
138
+ participant.personal_data_3,
139
+ participant.personal_data_4,
140
+ ]
141
+ @strata.each do |stratum|
142
+ ps = participant.sample_participant_strata.find { |s| s.decidim_stratified_sortitions_stratum_id == stratum.id }
143
+ substratum_name = ps&.decidim_stratified_sortitions_substratum&.name&.values&.compact&.first
144
+ row << (substratum_name || "-")
145
+ end
146
+ row
147
+ end
148
+
149
+ def style_header_row(worksheet, row_index, _col_count)
150
+ worksheet.change_row_fill(row_index, "c0c0c0")
151
+ worksheet.change_row_bold(row_index, true)
152
+ worksheet.change_row_horizontal_alignment(row_index, "center")
153
+ end
154
+
155
+ def format_percentage(value)
156
+ pct = value.to_f * 100
157
+ "#{format("%.2f", pct)}%"
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,54 @@
1
+ <% add_decidim_page_title(t("components.title", scope: "decidim.admin")) %>
2
+ <%
3
+ allowed_emails = ENV.fetch("STRATIFIED_SORTITIONS_ALLOWED_EMAILS", "").split(",").map { |email| email.strip.downcase }.reject(&:blank?)
4
+ can_see_stratified_sortitions = allowed_emails.include?(current_user&.email.to_s.strip.downcase)
5
+ @components = if can_see_stratified_sortitions
6
+ @components
7
+ else
8
+ @components.reject { |component| component.manifest_name.to_s == "stratified_sortitions" }
9
+ end
10
+ %>
11
+
12
+ <div class="card">
13
+ <div class="item_show__header">
14
+ <h1 class="item_show__header-title">
15
+ <%= t("components.title", scope: "decidim.admin") %>
16
+
17
+ <% if allowed_to?(:create, :component) %>
18
+ <div class="relative">
19
+ <button class="button button__sm button__secondary button--title" data-toggle="add-component-dropdown">
20
+ <%= t "components.index.add", scope: "decidim.admin" %>
21
+ <%= icon "arrow-down-s-line", class: "dropdown-filter-icon" %>
22
+ </button>
23
+ <div class="dropdown-pane" id="add-component-dropdown" data-dropdown data-auto-focus="true" data-close-on-click="true">
24
+ <ul class="vertical menu add-components font-normal">
25
+ <% @manifests.each do |manifest| %>
26
+ <% next if manifest.name.to_s == "stratified_sortitions" && !can_see_stratified_sortitions %>
27
+ <li>
28
+ <%= link_to new_component_path(type: manifest.name), class: manifest.name do %>
29
+ <%= t("#{manifest.name}.name", scope: "decidim.components") %>
30
+ <% end %>
31
+ </li>
32
+ <% end %>
33
+ </ul>
34
+ </div>
35
+ </div>
36
+ <% end %>
37
+ </h1>
38
+ </div>
39
+
40
+ <div class="table-scroll">
41
+ <table class="table-list">
42
+ <thead>
43
+ <th><%= t(".headers.name") %></th>
44
+ <th><%= t(".headers.type") %></th>
45
+ <th><%= t(".headers.scope") %></th>
46
+ <th><%= t(".headers.actions") %></th>
47
+ </thead>
48
+
49
+ <tbody>
50
+ <%= render partial: "component", collection: @components %>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ </div>
@@ -0,0 +1,22 @@
1
+ <p class="email-instructions">
2
+ <%= t(".ready") %>
3
+ </p>
4
+ <p class="email-instructions">
5
+ <ul>
6
+ <li><strong><%= t(".total_rows") %>:</strong> <%= @sample_import.total_rows %></li>
7
+ <li><strong><%= t(".imported_rows") %>:</strong> <%= @sample_import.imported_rows %></li>
8
+ <li><strong><%= t(".failed_rows") %>:</strong> <%= @sample_import.failed_rows %></li>
9
+ </ul>
10
+
11
+ <% if @sample_import.failed_rows.positive? %>
12
+ <br>
13
+ <strong><%= t(".see_details") %></strong>
14
+ <ul>
15
+ <% @sample_import.import_errors.each do |error| %>
16
+ <li>
17
+ <strong><%= t(".participant_id") %> <%= error["row"].values.first %></strong>
18
+ </li>
19
+ <% end %>
20
+ </ul>
21
+ <% end %>
22
+ </p>
@@ -0,0 +1,19 @@
1
+ <%= decidim_modal id: "confirm-execute-modal" do %>
2
+ <div data-dialog-container>
3
+ <h2 id="dialog-title-confirm-execute-modal" tabindex="-1" data-dialog-title>
4
+ <%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_title") %>
5
+ </h2>
6
+ <div>
7
+ <p id="dialog-desc-confirm-execute-modal"><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_body") %></p>
8
+ </div>
9
+ </div>
10
+ <div data-dialog-actions class="flex flex-col-reverse md:flex-row">
11
+ <button class="button button__lg button__transparent-secondary w-full md:w-auto" data-dialog-close="confirm-execute-modal">
12
+ <span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_cancel") %></span>
13
+ </button>
14
+ <%= button_to t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_execute_ok"),
15
+ execute_stratified_sortition_stratified_sortition_path(stratified_sortition),
16
+ method: :post,
17
+ class: "button button__lg button__secondary w-full md:w-auto" %>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,20 @@
1
+ <%= decidim_modal id: "confirm-participants-modal" do %>
2
+ <div data-dialog-container>
3
+ <h2 id="dialog-title-confirm-participants-modal" tabindex="-1" data-dialog-title>
4
+ <%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_title") %>
5
+ </h2>
6
+ <div>
7
+ <p id="dialog-desc-confirm-participants-modal"><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_body") %></p>
8
+ </div>
9
+ </div>
10
+ <div data-dialog-actions class="flex flex-col-reverse md:flex-row">
11
+ <button class="button button__lg button__transparent-secondary w-full md:w-auto" data-dialog-close="confirm-participants-modal">
12
+ <span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_cancel") %></span>
13
+ </button>
14
+ <button type="button" class="button button__lg button__secondary w-full md:w-auto" id="confirm-show-participants"
15
+ data-log-url="<%= log_view_participants_stratified_sortition_path(stratified_sortition) %>"
16
+ data-authenticity-token="<%= form_authenticity_token %>">
17
+ <span><%= t("decidim.stratified_sortitions.admin.stratified_sortitions.execute.confirm_participants_ok") %></span>
18
+ </button>
19
+ </div>
20
+ <% end %>