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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Custom helpers, scoped to the stratified_sortitions engine.
6
+ #
7
+ module ApplicationHelper
8
+ def component_name
9
+ i18n_key = "decidim.components.stratified_sortitions.name"
10
+ (defined?(current_component) && translated_attribute(current_component&.name).presence) || t(i18n_key)
11
+ end
12
+
13
+ def tabs_id_for_stratum(stratum)
14
+ "stratified-sortition-stratum-#{stratum.to_param}"
15
+ end
16
+
17
+ def tabs_id_for_substratum(substratum)
18
+ "stratified-sortition-stratum-substratum-#{substratum.to_param}"
19
+ end
20
+
21
+ # Renders the errors for a given object field in an HTML block.
22
+ # If there are no errors renders nothing.
23
+ def field_errors_block(form, field)
24
+ return if form.object.errors[field].blank?
25
+
26
+ html = <<~EOHTML
27
+ <div class="row column errors">#{form.error_for(field)}</div>
28
+ EOHTML
29
+ html.html_safe
30
+ end
31
+
32
+ def dynamic_title(title, **options)
33
+ data = {
34
+ "max-length" => options[:max_length],
35
+ "omission" => options[:omission],
36
+ "placeholder" => options[:placeholder],
37
+ "locale" => I18n.locale,
38
+ }
39
+ tag.span(class: options[:class], data:) do
40
+ truncate translated_attribute(title), length: options[:max_length], omission: options[:omission]
41
+ end
42
+ end
43
+
44
+ def navigation_menu_items(sortition)
45
+ has_sortition = sortition.present?
46
+
47
+ [
48
+ {
49
+ path: has_sortition ? edit_stratified_sortition_path(sortition) : new_stratified_sortition_path,
50
+ icon: "settings-4-line",
51
+ label: t("actions.configure", scope: "decidim.stratified_sortitions.admin"),
52
+ active: %w(new edit).include?(action_name),
53
+ },
54
+ {
55
+ path: has_sortition ? upload_sample_stratified_sortition_path(sortition) : "#",
56
+ icon: "group-line",
57
+ label: t("actions.census_management", scope: "decidim.stratified_sortitions.admin"),
58
+ active: action_name == "upload_sample",
59
+ disabled: has_sortition ? !sortition.strata_and_substrata_configured? : true,
60
+ },
61
+ {
62
+ path: has_sortition ? execute_stratified_sortition_path(sortition) : "#",
63
+ icon: "play-fill",
64
+ label: t("actions.execute", scope: "decidim.stratified_sortitions.admin"),
65
+ active: action_name == "execute",
66
+ disabled: has_sortition ? !sortition.can_execute? : true,
67
+ },
68
+ ]
69
+ end
70
+
71
+ def filter_sections_stratified_sortitions
72
+ sections = [{ method: :with_any_state, collection: filter_state_values, label_scope: "decidim.stratified_sortitions.stratified_sortitions.filters", id: "state" }]
73
+ sections.reject { |item| item[:collection].blank? }
74
+ end
75
+
76
+ def filter_state_values
77
+ [
78
+ ["all", t("all", scope: "decidim.stratified_sortitions.stratified_sortitions.filters")],
79
+ ["pending", t("pending", scope: "decidim.stratified_sortitions.stratified_sortitions.filters")],
80
+ ["executed", t("executed", scope: "decidim.stratified_sortitions.stratified_sortitions.filters")],
81
+ ]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # View helpers used in the charts PDF template.
6
+ # Generates inline SVG pie charts and HTML legends.
7
+ module ChartsPdfHelper
8
+ CHART_COLORS = %w(
9
+ #3366CC #DC3912 #FF9900 #109618 #990099
10
+ #0099C6 #DD4477 #66AA00 #B82E2E #316395
11
+ #994499 #22AA99 #AAAA11 #6633CC #E67300
12
+ #8B0707 #651067 #329262 #5574A6 #3B3EAC
13
+ ).freeze
14
+
15
+ # Generates an inline SVG pie chart from a data array of [label, value] pairs.
16
+ def svg_pie_chart(data, size: 155)
17
+ return "" if data.blank?
18
+
19
+ total = data.sum { |_label, value| value.to_f }
20
+ return "" if total.zero?
21
+
22
+ cx = size / 2
23
+ cy = size / 2
24
+ r = (size / 2) - 4
25
+ paths = []
26
+ cumulative = 0.0
27
+
28
+ data.each_with_index do |(_label, value), idx|
29
+ slice = (value.to_f / total) * 360.0
30
+ start_angle = cumulative
31
+ end_angle = cumulative + slice
32
+ cumulative = end_angle
33
+
34
+ start_rad = (start_angle - 90) * Math::PI / 180.0
35
+ end_rad = (end_angle - 90) * Math::PI / 180.0
36
+
37
+ x_1 = cx + (r * Math.cos(start_rad))
38
+ y_1 = cy + (r * Math.sin(start_rad))
39
+ x_2 = cx + (r * Math.cos(end_rad))
40
+ y_2 = cy + (r * Math.sin(end_rad))
41
+
42
+ color = CHART_COLORS[idx % CHART_COLORS.size]
43
+ large_arc = slice > 180 ? 1 : 0
44
+
45
+ paths << if slice >= 359.99
46
+ %(<circle cx="#{cx}" cy="#{cy}" r="#{r}" fill="#{color}" stroke="white" stroke-width="1"/>)
47
+ else
48
+ path_d = "M#{cx},#{cy} L#{x_1.round(2)},#{y_1.round(2)} " \
49
+ "A#{r},#{r} 0 #{large_arc},1 #{x_2.round(2)},#{y_2.round(2)} Z"
50
+ %(<path d="#{path_d}" fill="#{color}" stroke="white" stroke-width="1"/>)
51
+ end
52
+ end
53
+
54
+ %(<svg width="#{size}" height="#{size}" viewBox="0 0 #{size} #{size}" xmlns="http://www.w3.org/2000/svg">#{paths.join}</svg>).html_safe
55
+ end
56
+
57
+ # Generates an HTML legend block for the given chart data.
58
+ def legend_html(data)
59
+ return "" if data.blank?
60
+
61
+ total = data.sum { |_label, value| value.to_f }
62
+
63
+ items = data.each_with_index.map do |(label, value), idx|
64
+ pct = total.positive? ? ((value.to_f / total) * 100).round(1) : 0.0
65
+ color = CHART_COLORS[idx % CHART_COLORS.size]
66
+ %(<div class="legend-item"><span class="legend-color" style="background:#{color}"></span>#{h(label)} (#{pct}%)</div>)
67
+ end
68
+
69
+ %(<div class="legend">#{items.join}</div>).html_safe
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Custom helpers, scoped to the stratified sortitions engine.
6
+ #
7
+ module StratifiedSortitionCellsHelper
8
+ include Decidim::StratifiedSortitions::ApplicationHelper
9
+ include Decidim::StratifiedSortitions::Engine.routes.url_helpers
10
+ include Decidim::LayoutHelper
11
+ include Decidim::ApplicationHelper
12
+ include Decidim::TranslationsHelper
13
+ include Decidim::ResourceReferenceHelper
14
+ include Decidim::TranslatableAttributes
15
+ include Decidim::CardHelper
16
+
17
+ delegate :title, to: :model
18
+
19
+ def has_actions?
20
+ return context[:has_actions] if context[:has_actions].present?
21
+
22
+ stratified_sortitions_controller? && index_action? && current_settings.votes_enabled? && !model.draft?
23
+ end
24
+
25
+ def has_footer?
26
+ return context[:has_footer] if context[:has_footer].present?
27
+
28
+ stratified_sortitions_controller? && index_action? && current_settings.votes_enabled? && !model.draft?
29
+ end
30
+
31
+ def stratified_sortitions_controller?
32
+ context[:controller].instance_of?(::Decidim::StratifiedSortitions::StratifiedSortitionsController)
33
+ end
34
+
35
+ def index_action?
36
+ context[:controller].action_name == "index"
37
+ end
38
+
39
+ def current_settings
40
+ model.component.current_settings
41
+ end
42
+
43
+ def component_settings
44
+ model.component.settings
45
+ end
46
+
47
+ def current_component
48
+ model.component
49
+ end
50
+
51
+ def from_context
52
+ options[:from]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,12 @@
1
+ import consumer from "./consumer"
2
+
3
+ export default function subscribeSampleImportProgress(sampleImportId, onProgress) {
4
+ return consumer.subscriptions.create(
5
+ { channel: "SampleImportProgressChannel", sample_import_id: sampleImportId },
6
+ {
7
+ received(data) {
8
+ if (onProgress) onProgress(data)
9
+ }
10
+ }
11
+ )
12
+ }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ class ExecuteSortitionJob < ApplicationJob
7
+ queue_as :stratified_sortitions
8
+
9
+ def perform(stratified_sortition, user)
10
+ result = FairSortitionService.new(stratified_sortition).call
11
+ if result.success?
12
+ stratified_sortition.update!(status: "executed", execution_error: nil)
13
+ Decidim.traceability.perform_action!("execute", stratified_sortition, user, visibility: "all")
14
+ else
15
+ stratified_sortition.update!(status: "failed", execution_error: result.error)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "rubyXL"
5
+
6
+ module Decidim
7
+ module StratifiedSortitions
8
+ module Admin
9
+ class ImportSampleJob < ApplicationJob
10
+ queue_as :stratified_sortitions
11
+
12
+ def perform(file_content, filename, stratified_sortition, user)
13
+ sample_import = Decidim::StratifiedSortitions::SampleImport.create!(
14
+ stratified_sortition:,
15
+ filename:,
16
+ status: :processing
17
+ )
18
+ processing_errors = []
19
+ total_rows = 0
20
+
21
+ each_import_row(file_content, filename) do |headers, row|
22
+ total_rows += 1
23
+
24
+ errors = process_row(row, headers, stratified_sortition, sample_import)
25
+
26
+ processing_errors << errors if errors.present?
27
+ end
28
+
29
+ status = processing_errors.flatten.empty? ? :completed : :failed
30
+ sample_import.update(
31
+ status:,
32
+ total_rows:,
33
+ imported_rows: total_rows - processing_errors.flatten.size,
34
+ failed_rows: processing_errors.flatten.size,
35
+ import_errors: processing_errors.flatten
36
+ )
37
+
38
+ Decidim::StratifiedSortitions::Admin::ImportMailer.import(user, sample_import).deliver_now
39
+ end
40
+
41
+ private
42
+
43
+ def each_import_row(file_content, filename, &)
44
+ if File.extname(filename.to_s).casecmp(".xlsx").zero?
45
+ parse_xlsx_rows(file_content, &)
46
+ else
47
+ parse_csv_rows(file_content, &)
48
+ end
49
+ end
50
+
51
+ def parse_csv_rows(file_content)
52
+ CSV.parse(file_content, headers: true, col_sep: ",") do |row|
53
+ headers = row.headers
54
+ values = row.fields.map { |value| normalize_value(value) }
55
+ next if values.all?(&:blank?)
56
+
57
+ yield(headers, values)
58
+ end
59
+ end
60
+
61
+ def parse_xlsx_rows(file_content)
62
+ workbook = RubyXL::Parser.parse_buffer(file_content)
63
+ worksheet = workbook[0]
64
+ rows = worksheet&.sheet_data&.rows || []
65
+ return if rows.empty?
66
+
67
+ headers = extract_xlsx_row_values(rows.first)
68
+
69
+ rows.drop(1).each do |xlsx_row|
70
+ values = extract_xlsx_row_values(xlsx_row)
71
+ next if values.all?(&:blank?)
72
+
73
+ yield(headers, values)
74
+ end
75
+ end
76
+
77
+ def extract_xlsx_row_values(xlsx_row)
78
+ return [] unless xlsx_row&.cells
79
+
80
+ last_index = xlsx_row.cells.rindex { |cell| cell&.value.present? }
81
+ return [] unless last_index
82
+
83
+ (0..last_index).map do |index|
84
+ normalize_value(xlsx_row.cells[index]&.value)
85
+ end
86
+ end
87
+
88
+ def normalize_value(value)
89
+ return nil if value.nil?
90
+
91
+ normalized = value.is_a?(String) ? value.strip : value.to_s.strip
92
+ normalized.presence
93
+ end
94
+
95
+ def process_row(row, headers, stratified_sortition, sample_import)
96
+ ActiveRecord::Base.transaction do
97
+ participant = Decidim::StratifiedSortitions::SampleParticipant.find_or_create_by(
98
+ personal_data_1: row[0]
99
+ )
100
+
101
+ participant.update!(
102
+ decidim_stratified_sortition: stratified_sortition,
103
+ decidim_stratified_sortitions_sample_import: sample_import,
104
+ personal_data_2: row[1],
105
+ personal_data_3: row[2],
106
+ personal_data_4: row[3],
107
+ )
108
+
109
+ # Strata is saved in the order of the strata creation
110
+ strata = Decidim::StratifiedSortitions::Stratum.where(stratified_sortition:).order(position: :asc)
111
+
112
+ strata_index = 4
113
+ strata.each do |stratum|
114
+ row_value = row[strata_index]
115
+
116
+ substratum = find_substratum(stratum, row_value)
117
+
118
+ raise "No valid substratum found for value '#{row_value}' in stratum '#{stratum.name}'" if substratum.nil?
119
+
120
+ Decidim::StratifiedSortitions::SampleParticipantStratum.create!(
121
+ decidim_stratified_sortitions_sample_participant: participant,
122
+ decidim_stratified_sortitions_stratum: stratum,
123
+ decidim_stratified_sortitions_substratum: substratum
124
+ )
125
+
126
+ strata_index += 1
127
+ end
128
+ end
129
+
130
+ nil
131
+ rescue StandardError => e
132
+ {
133
+ row: build_error_row(headers, row),
134
+ error: e.message,
135
+ backtrace: e.backtrace.first(3),
136
+ }
137
+ end
138
+
139
+ def build_error_row(headers, row)
140
+ return row if headers.blank?
141
+
142
+ headers.each_with_index.to_h do |header, index|
143
+ [header, row[index]]
144
+ end
145
+ end
146
+
147
+ def find_substratum(stratum, value)
148
+ if stratum.kind == "value"
149
+ normalized_value = normalize_value(value)
150
+ Decidim::StratifiedSortitions::Substratum.find_by(
151
+ decidim_stratified_sortitions_stratum_id: stratum.id,
152
+ value: normalized_value
153
+ )
154
+ elsif stratum.kind == "numeric_range"
155
+ numeric_value = normalize_value(value).to_f
156
+ stratum.substrata.find do |substratum|
157
+ next if substratum.range.blank?
158
+
159
+ range_parts = substratum.range.split("-")
160
+ min_value = range_parts[0].to_f
161
+ max_value = range_parts[1].to_f
162
+
163
+ numeric_value >= min_value && numeric_value <= max_value
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ class RemoveSamplesJob < ApplicationJob
7
+ queue_as :stratified_sortitions
8
+
9
+ def perform(stratified_sortition, user)
10
+ RemoveUploadedSamples.call(stratified_sortition) do
11
+ on(:ok) do
12
+ Decidim.traceability.perform_action!("remove_samples", stratified_sortition, user, visibility: "all")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ # Background job that generates a sortition results export and sends it
7
+ # to the requesting user as a zipped email attachment.
8
+ # Follows the same pattern as Decidim::ExportJob in decidim-core.
9
+ class SortitionResultsExportJob < ApplicationJob
10
+ queue_as :stratified_sortitions
11
+
12
+ def perform(user, stratified_sortition, format)
13
+ exporter = SortitionResultsExporter.new(stratified_sortition)
14
+
15
+ export_data = case format
16
+ when "excel" then exporter.export_excel
17
+ when "json" then exporter.export_json
18
+ else exporter.export_csv
19
+ end
20
+
21
+ export_name = "sortition_results_#{stratified_sortition.id}"
22
+
23
+ Decidim::ExportMailer.export(user, export_name, export_data).deliver_now
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ # This mailer sends a notification email with the results of the import process.
7
+ class ImportMailer < ApplicationMailer
8
+ # Public: Sends a notification email with the result of an import process.
9
+ #
10
+ # user - The user to be notified.
11
+ # sample_import - The sample import record containing the import results.
12
+ #
13
+ # Returns nothing.
14
+ def import(user, sample_import)
15
+ @user = user
16
+ @organization = user.organization
17
+ @sample_import = sample_import
18
+
19
+ with_user(user) do
20
+ mail(to: "#{user.name} <#{user.email}>", subject: I18n.t("decidim.stratified_sortitions.admin.import_mailer.subject"))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Stores the portfolio of panels generated by the LEXIMIN algorithm.
6
+ #
7
+ # This model enables a two-phase sortition process:
8
+ # 1. Generate portfolio (expensive computation, stores panels and probabilities)
9
+ # 2. Sample from portfolio (fast, selects one panel according to distribution)
10
+ #
11
+ # The separation allows for:
12
+ # - Background generation of portfolios
13
+ # - Public/auditable sampling ceremonies
14
+ # - Verification of results using the stored data
15
+ #
16
+ # @example Generate and sample
17
+ # portfolio = PanelPortfolio.create!(
18
+ # stratified_sortition: sortition,
19
+ # panels: [[1, 2, 3], [4, 5, 6]],
20
+ # probabilities: [0.6, 0.4],
21
+ # generated_at: Time.current
22
+ # )
23
+ #
24
+ # # Later, sample from the portfolio
25
+ # portfolio.sample!(verification_seed: "public_seed")
26
+ # portfolio.selected_panel # => [1, 2, 3] or [4, 5, 6]
27
+ #
28
+ class PanelPortfolio < ApplicationRecord
29
+ self.table_name = "decidim_stratified_sortitions_panel_portfolios"
30
+
31
+ belongs_to :stratified_sortition,
32
+ class_name: "Decidim::StratifiedSortitions::StratifiedSortition",
33
+ foreign_key: "decidim_stratified_sortitions_stratified_sortition_id"
34
+
35
+ validates :panels, presence: true
36
+ validates :probabilities, presence: true
37
+ validates :generated_at, presence: true
38
+ validate :panels_and_probabilities_match
39
+ validate :probabilities_sum_to_one
40
+
41
+ # Check if the portfolio has been sampled
42
+ #
43
+ # @return [Boolean]
44
+ def sampled?
45
+ selected_panel_index.present?
46
+ end
47
+
48
+ # Get the selected panel (after sampling)
49
+ #
50
+ # @return [Array<Integer>, nil] Array of participant IDs or nil if not sampled
51
+ def selected_panel
52
+ return nil unless sampled?
53
+
54
+ panels[selected_panel_index]
55
+ end
56
+
57
+ # Get the probability of the selected panel
58
+ #
59
+ # @return [Float, nil]
60
+ def selected_panel_probability
61
+ return nil unless sampled?
62
+
63
+ probabilities[selected_panel_index]
64
+ end
65
+
66
+ # Sample a panel from this portfolio
67
+ #
68
+ # @param verification_seed [String, nil] Optional seed for reproducible sampling
69
+ # @return [Leximin::PanelSampler::Result]
70
+ # @raise [RuntimeError] if already sampled
71
+ def sample!(verification_seed: nil)
72
+ raise I18n.t("decidim.stratified_sortitions.errors.panel_portfolio.already_sampled") if sampled?
73
+
74
+ random_seed = Decidim::StratifiedSortitions.derive_random_seed(verification_seed)
75
+ sampler = Leximin::PanelSampler.new(panels, probabilities, random_seed:)
76
+ result = sampler.sample
77
+
78
+ if result.success?
79
+ update!(
80
+ selected_panel_index: result.selected_index,
81
+ selected_at: Time.current,
82
+ verification_seed:,
83
+ random_value_used: result.random_value
84
+ )
85
+ end
86
+
87
+ result
88
+ end
89
+
90
+ # Load selected participants from database
91
+ #
92
+ # @return [Array<SampleParticipant>]
93
+ def selected_participants
94
+ return [] unless sampled?
95
+
96
+ SampleParticipant.where(id: selected_panel).order(:id).to_a
97
+ end
98
+
99
+ # Number of panels in the portfolio
100
+ #
101
+ # @return [Integer]
102
+ def num_panels
103
+ panels&.size || 0
104
+ end
105
+
106
+ # Fairness metrics for this portfolio
107
+ #
108
+ # @return [Hash]
109
+ def fairness_metrics
110
+ probs = selection_probabilities.values
111
+ return {} if probs.empty?
112
+
113
+ {
114
+ min_probability: probs.min,
115
+ max_probability: probs.max,
116
+ mean_probability: probs.sum / probs.size,
117
+ probability_range: probs.max - probs.min,
118
+ }
119
+ end
120
+
121
+ # Build audit log for the portfolio
122
+ #
123
+ # @return [Hash]
124
+ def audit_log
125
+ {
126
+ algorithm: "LEXIMIN",
127
+ version: "1.0",
128
+ stratified_sortition_id: stratified_sortition.id,
129
+ generated_at: generated_at&.iso8601,
130
+ generation_time_seconds:,
131
+ num_panels:,
132
+ num_iterations:,
133
+ convergence_achieved:,
134
+ sampled: sampled?,
135
+ selected_at: selected_at&.iso8601,
136
+ selected_panel_index:,
137
+ verification_seed:,
138
+ random_value_used:,
139
+ selected_panel_probability:,
140
+ fairness_metrics:,
141
+ }
142
+ end
143
+
144
+ private
145
+
146
+ def panels_and_probabilities_match
147
+ return if panels.blank? || probabilities.blank?
148
+ return if panels.size == probabilities.size
149
+
150
+ errors.add(:probabilities, I18n.t("decidim.stratified_sortitions.errors.panel_portfolio.probabilities_size_mismatch",
151
+ panels_count: panels.size,
152
+ probabilities_count: probabilities.size))
153
+ end
154
+
155
+ def probabilities_sum_to_one
156
+ return if probabilities.blank?
157
+
158
+ total = probabilities.sum
159
+ return if (total - 1.0).abs < 0.001
160
+
161
+ errors.add(:probabilities, I18n.t("decidim.stratified_sortitions.errors.panel_portfolio.probabilities_sum_invalid", total:))
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ class SampleImport < ApplicationRecord
6
+ self.table_name = "decidim_stratified_sortitions_sample_imports"
7
+
8
+ belongs_to :stratified_sortition
9
+ has_many :sample_participants, dependent: :destroy, foreign_key: :decidim_stratified_sortitions_sample_import_id
10
+
11
+ enum :status, { pending: "pending", processing: "processing", completed: "completed", failed: "failed" }
12
+ end
13
+ end
14
+ end