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.
- checksums.yaml +7 -0
- data/LICENSE-AGPLv3.txt +661 -0
- data/README.md +91 -0
- data/Rakefile +42 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_cell.rb +21 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_l_cell.rb +22 -0
- data/app/cells/decidim/stratified_sortitions/stratified_sortition_metadata_cell.rb +63 -0
- data/app/commands/decidim/stratified_sortitions/admin/create_stratified_sortition.rb +84 -0
- data/app/commands/decidim/stratified_sortitions/admin/destroy_stratified_sortition.rb +49 -0
- data/app/commands/decidim/stratified_sortitions/admin/duplicate_stratified_sortition.rb +74 -0
- data/app/commands/decidim/stratified_sortitions/admin/import_sample.rb +40 -0
- data/app/commands/decidim/stratified_sortitions/admin/remove_uploaded_samples.rb +53 -0
- data/app/commands/decidim/stratified_sortitions/admin/update_stratified_sortition.rb +174 -0
- data/app/controllers/concerns/decidim/stratified_sortitions/orderable_stratified_sortitions.rb +37 -0
- data/app/controllers/concerns/decidim/stratified_sortitions/strata_charts_data.rb +91 -0
- data/app/controllers/decidim/stratified_sortitions/admin/application_controller.rb +19 -0
- data/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb +143 -0
- data/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb +235 -0
- data/app/controllers/decidim/stratified_sortitions/application_controller.rb +13 -0
- data/app/controllers/decidim/stratified_sortitions/charts_pdf_controller_helper.rb +17 -0
- data/app/controllers/decidim/stratified_sortitions/stratified_sortitions_controller.rb +61 -0
- data/app/forms/decidim/stratified_sortitions/admin/sample_upload_form.rb +15 -0
- data/app/forms/decidim/stratified_sortitions/admin/stratified_sortitions_form.rb +155 -0
- data/app/forms/decidim/stratified_sortitions/admin/stratum_form.rb +69 -0
- data/app/forms/decidim/stratified_sortitions/admin/substratum_form.rb +26 -0
- data/app/helpers/decidim/stratified_sortitions/admin/stratified_sortitions_helper.rb +18 -0
- data/app/helpers/decidim/stratified_sortitions/application_helper.rb +85 -0
- data/app/helpers/decidim/stratified_sortitions/charts_pdf_helper.rb +73 -0
- data/app/helpers/decidim/stratified_sortitions/stratified_sortition_cells_helper.rb +56 -0
- data/app/javascript/channels/sample_import_progress.js +12 -0
- data/app/jobs/decidim/stratified_sortitions/admin/execute_sortition_job.rb +21 -0
- data/app/jobs/decidim/stratified_sortitions/admin/import_sample_job.rb +170 -0
- data/app/jobs/decidim/stratified_sortitions/admin/remove_samples_job.rb +19 -0
- data/app/jobs/decidim/stratified_sortitions/admin/sortition_results_export_job.rb +28 -0
- data/app/mailers/decidim/stratified_sortitions/admin/import_mailer.rb +26 -0
- data/app/models/decidim/stratified_sortitions/application_record.rb +9 -0
- data/app/models/decidim/stratified_sortitions/panel_portfolio.rb +165 -0
- data/app/models/decidim/stratified_sortitions/sample_import.rb +14 -0
- data/app/models/decidim/stratified_sortitions/sample_participant.rb +12 -0
- data/app/models/decidim/stratified_sortitions/sample_participant_stratum.rb +11 -0
- data/app/models/decidim/stratified_sortitions/stratified_sortition.rb +74 -0
- data/app/models/decidim/stratified_sortitions/stratum.rb +22 -0
- data/app/models/decidim/stratified_sortitions/substratum.rb +19 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions.js +10 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions_admin.js +9 -0
- data/app/packs/entrypoints/decidim_stratified_sortitions_admin.scss +1 -0
- data/app/packs/images/decidim/stratified_sortitions/icon.svg +1 -0
- data/app/packs/src/decidim/stratified_sortitions/application.js +3 -0
- data/app/packs/src/decidim/stratified_sortitions/results_tabs.js +59 -0
- data/app/packs/src/decidim/stratified_sortitions/stratum_fields.js +302 -0
- data/app/packs/src/decidim/stratified_sortitions/substratum_fields.js +165 -0
- data/app/packs/src/decidim/stratified_sortitions/upload_sample.js +16 -0
- data/app/packs/stylesheets/decidim/stratified_sortitions/admin/stratified_sortitions.scss +210 -0
- data/app/packs/stylesheets/decidim/stratified_sortitions/charts_pdf.scss +148 -0
- data/app/packs/stylesheets/stratified_sortitions.scss +56 -0
- data/app/permissions/decidim/stratified_sortitions/admin/permissions.rb +74 -0
- data/app/permissions/decidim/stratified_sortitions/permissions.rb +23 -0
- data/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb +35 -0
- data/app/queries/decidim/stratified_sortitions/filtered_stratified_sortitions.rb +39 -0
- data/app/serializers/decidim/stratified_sortitions/sortition_result_serializer.rb +116 -0
- data/app/services/decidim/stratified_sortitions/charts_pdf_generator.rb +96 -0
- data/app/services/decidim/stratified_sortitions/fair_sortition_service.rb +247 -0
- data/app/services/decidim/stratified_sortitions/leximin/constraint_builder.rb +180 -0
- data/app/services/decidim/stratified_sortitions/leximin/distribution_solver.rb +217 -0
- data/app/services/decidim/stratified_sortitions/leximin/feasibility_checker.rb +222 -0
- data/app/services/decidim/stratified_sortitions/leximin/panel_generator.rb +149 -0
- data/app/services/decidim/stratified_sortitions/leximin/panel_sampler.rb +125 -0
- data/app/services/decidim/stratified_sortitions/leximin_selector.rb +136 -0
- data/app/services/decidim/stratified_sortitions/sortition_results_exporter.rb +161 -0
- data/app/views/decidim/admin/components/index.html.erb +54 -0
- data/app/views/decidim/stratified_sortitions/admin/import_mailer/import.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_execute_modal.html.erb +19 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb +20 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_form.html.erb +55 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_navigation_menu.html.erb +10 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_strata.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_stratum.html.erb +75 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substrata.html.erb +34 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_substratum.html.erb +54 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/edit.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/execute.html.erb +155 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf.html.erb +38 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/index.html.erb +75 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/new.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/upload_sample.html.erb +117 -0
- data/app/views/decidim/stratified_sortitions/shared/_strata_charts.html.erb +21 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_results_count.html.erb +15 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortition.html.erb +1 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_stratified_sortitions.html.erb +13 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/_tags.html.erb +9 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.html.erb +22 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/index.js.erb +5 -0
- data/app/views/decidim/stratified_sortitions/stratified_sortitions/show.html.erb +142 -0
- data/app/views/layouts/decidim/admin/_sidebar_menu.html.erb +45 -0
- data/app/views/layouts/decidim/stratified_sortitions/charts_pdf.html.erb +14 -0
- data/config/assets.rb +24 -0
- data/config/locales/ca.yml +361 -0
- data/config/locales/en.yml +360 -0
- data/config/locales/es.yml +360 -0
- data/config/locales/oc.yml +2 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20251023103900_create_decidim_stratified_sortitions_stratified_sortitions.rb +18 -0
- data/db/migrate/20251104120000_create_stratified_sortitions_strata_and_substrata.decidim_stratified_sortitions.rb +27 -0
- data/db/migrate/20251219120000_create_sample_imports.rb +17 -0
- data/db/migrate/20251219120100_create_sample_participants.rb +19 -0
- data/db/migrate/20251219120200_create_sample_participant_strata.rb +16 -0
- data/db/migrate/20251230130000_change_value_to_text_in_substrata.rb +11 -0
- data/db/migrate/20260102114744_add_status_to_stratified_sortitions.rb +11 -0
- data/db/migrate/20260109105643_allow_null_value_in_substrata.rb +10 -0
- data/db/migrate/20260113121726_add_position_to_strata_and_substrata.rb +8 -0
- data/db/migrate/20260127100000_rename_weighing_to_max_quota_percentage.rb +12 -0
- data/db/migrate/20260127120000_create_panel_portfolios.rb +46 -0
- data/db/migrate/20260518000000_add_foreign_key_to_stratified_sortitions_component.rb +10 -0
- data/db/migrate/20260521000000_add_cascade_to_stratified_sortitions_foreign_keys.rb +64 -0
- data/db/migrate/20260616000000_add_execution_error_to_stratified_sortitions.rb +11 -0
- data/lib/decidim/stratified_sortitions/admin.rb +10 -0
- data/lib/decidim/stratified_sortitions/admin_engine.rb +49 -0
- data/lib/decidim/stratified_sortitions/component.rb +41 -0
- data/lib/decidim/stratified_sortitions/engine.rb +34 -0
- data/lib/decidim/stratified_sortitions/seeds.rb +24 -0
- data/lib/decidim/stratified_sortitions/test/factories.rb +80 -0
- data/lib/decidim/stratified_sortitions/version.rb +13 -0
- data/lib/decidim/stratified_sortitions.rb +29 -0
- 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,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
|