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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
module AdminLog
|
|
6
|
+
# This class holds the logic to present a `Decidim::StratifiedSortitions::StratifiedSortition`
|
|
7
|
+
# for the `AdminLog` log.
|
|
8
|
+
#
|
|
9
|
+
# Usage should be automatic and you should not need to call this class
|
|
10
|
+
# directly, but here is an example:
|
|
11
|
+
#
|
|
12
|
+
# action_log = Decidim::ActionLog.last
|
|
13
|
+
# view_helpers # => this comes from the views
|
|
14
|
+
# StratifiedSortitionPresenter.new(action_log, view_helpers).present
|
|
15
|
+
class StratifiedSortitionPresenter < Decidim::Log::BasePresenter
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def action_string
|
|
19
|
+
case action
|
|
20
|
+
when "create", "update", "delete", "duplicate",
|
|
21
|
+
"execute", "export_results", "view_participants",
|
|
22
|
+
"import_sample", "remove_samples"
|
|
23
|
+
"decidim.stratified_sortitions.admin_log.stratified_sortition.#{action}"
|
|
24
|
+
else
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def i18n_labels_scope
|
|
30
|
+
"activemodel.attributes.stratified_sortition"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
# A class used to find stratified sortitions filtered by components and a date range
|
|
6
|
+
class FilteredStratifiedSortitions < Decidim::Query
|
|
7
|
+
# Syntactic sugar to initialize the class and return the queried objects.
|
|
8
|
+
#
|
|
9
|
+
# components - An array of Decidim::Component
|
|
10
|
+
# start_at - A date to filter resources created after it
|
|
11
|
+
# end_at - A date to filter resources created before it.
|
|
12
|
+
def self.for(components, start_at = nil, end_at = nil)
|
|
13
|
+
new(components, start_at, end_at).query
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Initializes the class.
|
|
17
|
+
#
|
|
18
|
+
# components - An array of Decidim::Component
|
|
19
|
+
# start_at - A date to filter resources created after it
|
|
20
|
+
# end_at - A date to filter resources created before it.
|
|
21
|
+
# rubocop:disable Lint/MissingSuper
|
|
22
|
+
def initialize(components, start_at = nil, end_at = nil)
|
|
23
|
+
@components = components
|
|
24
|
+
@start_at = start_at
|
|
25
|
+
@end_at = end_at
|
|
26
|
+
end
|
|
27
|
+
# rubocop:enable Lint/MissingSuper
|
|
28
|
+
|
|
29
|
+
# Finds the StratifiedSortitions scoped to an array of components and filtered
|
|
30
|
+
# by a range of dates.
|
|
31
|
+
def query
|
|
32
|
+
stratified_sortitions = Decidim::StratifiedSortitions::StratifiedSortition.where(component: @components)
|
|
33
|
+
stratified_sortitions = stratified_sortitions.where(created_at: @start_at..) if @start_at.present?
|
|
34
|
+
stratified_sortitions = stratified_sortitions.where(created_at: ..@end_at) if @end_at.present?
|
|
35
|
+
stratified_sortitions
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
# Serializer for sortition results export.
|
|
6
|
+
# Includes sortition metadata only in the first row and participant data in each row.
|
|
7
|
+
class SortitionResultSerializer < Decidim::Exporters::Serializer
|
|
8
|
+
# Reset the metadata flag before each export run.
|
|
9
|
+
def self.reset!
|
|
10
|
+
@metadata_serialized = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.metadata_serialized?
|
|
14
|
+
@metadata_serialized == true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.metadata_serialized!
|
|
18
|
+
@metadata_serialized = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash] serialized data for one participant with sortition metadata (only first row)
|
|
22
|
+
def serialize
|
|
23
|
+
participant = resource
|
|
24
|
+
portfolio = participant.decidim_stratified_sortition.panel_portfolio
|
|
25
|
+
audit_log = portfolio.audit_log
|
|
26
|
+
|
|
27
|
+
data = if self.class.metadata_serialized?
|
|
28
|
+
metadata_blank(audit_log)
|
|
29
|
+
else
|
|
30
|
+
self.class.metadata_serialized!
|
|
31
|
+
metadata_filled(audit_log)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
data.merge!(participant_data(participant))
|
|
35
|
+
data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def metadata_filled(audit_log)
|
|
41
|
+
{
|
|
42
|
+
algorithm: audit_log[:algorithm],
|
|
43
|
+
version: audit_log[:version],
|
|
44
|
+
stratified_sortition_id: audit_log[:stratified_sortition_id],
|
|
45
|
+
generated_at: audit_log[:generated_at],
|
|
46
|
+
generation_time_seconds: audit_log[:generation_time_seconds],
|
|
47
|
+
num_panels: audit_log[:num_panels],
|
|
48
|
+
num_iterations: audit_log[:num_iterations],
|
|
49
|
+
convergence_achieved: audit_log[:convergence_achieved],
|
|
50
|
+
selected_at: audit_log[:selected_at],
|
|
51
|
+
selected_panel_index: audit_log[:selected_panel_index],
|
|
52
|
+
verification_seed: audit_log[:verification_seed],
|
|
53
|
+
random_value_used: audit_log[:random_value_used],
|
|
54
|
+
selected_panel_probability: audit_log[:selected_panel_probability],
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def metadata_blank(_audit_log)
|
|
59
|
+
{
|
|
60
|
+
algorithm: nil,
|
|
61
|
+
version: nil,
|
|
62
|
+
stratified_sortition_id: nil,
|
|
63
|
+
generated_at: nil,
|
|
64
|
+
generation_time_seconds: nil,
|
|
65
|
+
num_panels: nil,
|
|
66
|
+
num_iterations: nil,
|
|
67
|
+
convergence_achieved: nil,
|
|
68
|
+
selected_at: nil,
|
|
69
|
+
selected_panel_index: nil,
|
|
70
|
+
verification_seed: nil,
|
|
71
|
+
random_value_used: nil,
|
|
72
|
+
selected_panel_probability: nil,
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def participant_data(participant)
|
|
77
|
+
data = personal_data_fields(participant)
|
|
78
|
+
add_strata_columns(data, participant)
|
|
79
|
+
add_fairness_metrics(data, participant)
|
|
80
|
+
data
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def personal_data_fields(participant)
|
|
84
|
+
{
|
|
85
|
+
personal_data_1: participant.personal_data_1,
|
|
86
|
+
personal_data_2: participant.personal_data_2,
|
|
87
|
+
personal_data_3: participant.personal_data_3,
|
|
88
|
+
personal_data_4: participant.personal_data_4,
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def add_strata_columns(data, participant)
|
|
93
|
+
strata = participant.decidim_stratified_sortition.strata.order(:position)
|
|
94
|
+
strata.each do |stratum|
|
|
95
|
+
ps = participant.sample_participant_strata.find { |s| s.decidim_stratified_sortitions_stratum_id == stratum.id }
|
|
96
|
+
data[:"stratum_#{stratum_key(stratum)}"] = substratum_name_for(ps)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stratum_key(stratum)
|
|
101
|
+
stratum.name.values.compact.first || stratum.id.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def substratum_name_for(participant_stratum)
|
|
105
|
+
participant_stratum&.decidim_stratified_sortitions_substratum&.name&.values&.compact&.first || "-"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_fairness_metrics(data, participant)
|
|
109
|
+
metrics = participant.decidim_stratified_sortition.panel_portfolio.audit_log[:fairness_metrics]
|
|
110
|
+
return if metrics.blank?
|
|
111
|
+
|
|
112
|
+
metrics.each { |key, value| data[:"fairness_#{key}"] = value }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "wicked_pdf"
|
|
4
|
+
|
|
5
|
+
module Decidim
|
|
6
|
+
module StratifiedSortitions
|
|
7
|
+
# Generates a PDF with comparative pie charts for a stratified sortition.
|
|
8
|
+
# Uses WickedPdf (wkhtmltopdf) to convert an HTML template to PDF
|
|
9
|
+
class ChartsPdfGenerator
|
|
10
|
+
def initialize(stratified_sortition, strata_data, candidates_data, results_data, locale: I18n.locale)
|
|
11
|
+
@stratified_sortition = stratified_sortition
|
|
12
|
+
@strata_data = strata_data
|
|
13
|
+
@candidates_data = candidates_data
|
|
14
|
+
@results_data = results_data
|
|
15
|
+
@locale = locale
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate
|
|
19
|
+
I18n.with_locale(@locale) do
|
|
20
|
+
html = controller.render_to_string(
|
|
21
|
+
template: "decidim/stratified_sortitions/admin/stratified_sortitions/export_charts_pdf",
|
|
22
|
+
layout: "decidim/stratified_sortitions/charts_pdf",
|
|
23
|
+
assigns: template_assigns
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
WickedPdf.new.pdf_from_string(html, orientation: "Portrait", page_size: "A4")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def template_assigns
|
|
33
|
+
{
|
|
34
|
+
title: t("pdf_title", name: translated_name(@stratified_sortition.title)),
|
|
35
|
+
subtitle: t("pdf_subtitle"),
|
|
36
|
+
belongs_to: t("pdf_belongs_to", space_name: participatory_space_name),
|
|
37
|
+
algorithm: t("pdf_algorithm", algorithm: algorithm_info),
|
|
38
|
+
executed_at: t("pdf_executed_at", date: execution_date),
|
|
39
|
+
no_data_text: t("pdf_no_data"),
|
|
40
|
+
col_target: t("target"),
|
|
41
|
+
col_candidates: t("candidates"),
|
|
42
|
+
col_results: t("results"),
|
|
43
|
+
strata_chart_data: build_strata_chart_data,
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_strata_chart_data
|
|
48
|
+
@strata_data.each_with_index.map do |sd, idx|
|
|
49
|
+
{
|
|
50
|
+
name: translated_name(sd[:stratum].name),
|
|
51
|
+
target: sd[:chart_data],
|
|
52
|
+
candidates: @candidates_data[idx][:chart_data],
|
|
53
|
+
results: @results_data[idx][:chart_data],
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def translated_name(hash)
|
|
59
|
+
return hash if hash.is_a?(String)
|
|
60
|
+
|
|
61
|
+
hash[@locale.to_s] || hash[hash.keys.first] || ""
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def participatory_space_name
|
|
65
|
+
space = @stratified_sortition.participatory_space
|
|
66
|
+
translated_name(space.title)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
"-"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def algorithm_info
|
|
72
|
+
portfolio = @stratified_sortition.panel_portfolio
|
|
73
|
+
return "-" unless portfolio&.sampled?
|
|
74
|
+
|
|
75
|
+
"#{portfolio.audit_log[:algorithm]} v#{portfolio.audit_log[:version]}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def execution_date
|
|
79
|
+
portfolio = @stratified_sortition.panel_portfolio
|
|
80
|
+
return "-" unless portfolio&.sampled?
|
|
81
|
+
|
|
82
|
+
I18n.l(portfolio.selected_at, format: :decidim_short)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
portfolio.selected_at.to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def t(key, **opts)
|
|
88
|
+
I18n.t(key, scope: "decidim.stratified_sortitions.admin.stratified_sortitions.execute", **opts)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def controller
|
|
92
|
+
@controller ||= ChartsPdfControllerHelper.new
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module StratifiedSortitions
|
|
5
|
+
# Complete Fair Sortition Service
|
|
6
|
+
#
|
|
7
|
+
# Orchestrates the full LEXIMIN-based fair sortition process:
|
|
8
|
+
# 1. Runs LEXIMIN algorithm to find optimal panel distribution
|
|
9
|
+
# 2. Samples a panel according to the fair probability distribution
|
|
10
|
+
# 3. Returns the selected participants
|
|
11
|
+
#
|
|
12
|
+
# Supports two modes:
|
|
13
|
+
# - Single-phase: Generate and sample in one call
|
|
14
|
+
# - Two-phase: Generate portfolio first, sample later (for public ceremonies)
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage (single-phase)
|
|
17
|
+
# result = FairSortitionService.new(stratified_sortition).call
|
|
18
|
+
# if result.success?
|
|
19
|
+
# result.selected_participants # Array of SampleParticipant records
|
|
20
|
+
# result.selection_log # Audit information
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Two-phase usage (for public/auditable draws)
|
|
24
|
+
# service = FairSortitionService.new(stratified_sortition)
|
|
25
|
+
#
|
|
26
|
+
# # Phase 1: Generate portfolio (can be done in background)
|
|
27
|
+
# portfolio_result = service.generate_portfolio
|
|
28
|
+
# # Publish portfolio_result.portfolio.panels for transparency
|
|
29
|
+
#
|
|
30
|
+
# # Phase 2: Sample from portfolio (can be done publicly)
|
|
31
|
+
# final_result = service.sample_from_portfolio(verification_seed: SecureRandom.hex(64))
|
|
32
|
+
#
|
|
33
|
+
# @example With verification seed (for auditable draws)
|
|
34
|
+
# result = FairSortitionService.new(stratified_sortition, verification_seed: "public_seed_123").call
|
|
35
|
+
#
|
|
36
|
+
class FairSortitionService
|
|
37
|
+
# Result object containing all sortition outputs
|
|
38
|
+
Result = Struct.new(
|
|
39
|
+
:selected_participants,
|
|
40
|
+
:selected_participant_ids,
|
|
41
|
+
:selection_probabilities,
|
|
42
|
+
:portfolio,
|
|
43
|
+
:sampling_result,
|
|
44
|
+
:selection_log,
|
|
45
|
+
:success,
|
|
46
|
+
:error,
|
|
47
|
+
keyword_init: true
|
|
48
|
+
) do
|
|
49
|
+
def success?
|
|
50
|
+
success
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Result for portfolio generation
|
|
55
|
+
PortfolioResult = Struct.new(:portfolio, :success, :error, keyword_init: true) do
|
|
56
|
+
def success?
|
|
57
|
+
success
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(stratified_sortition, verification_seed: nil)
|
|
62
|
+
@stratified_sortition = stratified_sortition
|
|
63
|
+
@verification_seed = verification_seed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Execute the complete fair sortition process (single-phase)
|
|
67
|
+
#
|
|
68
|
+
# This generates the portfolio and samples in one call.
|
|
69
|
+
# For two-phase process, use generate_portfolio and sample_from_portfolio.
|
|
70
|
+
#
|
|
71
|
+
# @return [Result] containing selected participants and audit log
|
|
72
|
+
def call
|
|
73
|
+
# Check if already sampled
|
|
74
|
+
return error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.already_performed")) if existing_portfolio&.sampled?
|
|
75
|
+
|
|
76
|
+
# Generate or retrieve portfolio
|
|
77
|
+
portfolio = find_or_generate_portfolio
|
|
78
|
+
return error_result(error: portfolio.error) unless portfolio.is_a?(PanelPortfolio)
|
|
79
|
+
|
|
80
|
+
# Sample from portfolio
|
|
81
|
+
sample_from_portfolio(verification_seed: @verification_seed)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Generate portfolio without sampling (Phase 1 of two-phase process)
|
|
85
|
+
#
|
|
86
|
+
# Creates a PanelPortfolio with all candidate panels and their probabilities.
|
|
87
|
+
# The portfolio is persisted to the database for later sampling.
|
|
88
|
+
#
|
|
89
|
+
# @return [PortfolioResult] containing the generated portfolio
|
|
90
|
+
def generate_portfolio
|
|
91
|
+
if existing_portfolio.present?
|
|
92
|
+
return PortfolioResult.new(
|
|
93
|
+
portfolio: existing_portfolio,
|
|
94
|
+
success: true,
|
|
95
|
+
error: nil
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
start_time = Time.current
|
|
100
|
+
leximin_result = LeximinSelector.new(@stratified_sortition).call
|
|
101
|
+
|
|
102
|
+
unless leximin_result.success?
|
|
103
|
+
return PortfolioResult.new(
|
|
104
|
+
portfolio: nil,
|
|
105
|
+
success: false,
|
|
106
|
+
error: leximin_result.error
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@existing_portfolio = portfolio = PanelPortfolio.create!(
|
|
111
|
+
stratified_sortition: @stratified_sortition,
|
|
112
|
+
panels: leximin_result.panels,
|
|
113
|
+
probabilities: leximin_result.probabilities,
|
|
114
|
+
selection_probabilities: leximin_result.selection_probabilities,
|
|
115
|
+
generated_at: Time.current,
|
|
116
|
+
generation_time_seconds: Time.current - start_time,
|
|
117
|
+
num_iterations: leximin_result.panels.size, # Approximation
|
|
118
|
+
convergence_achieved: true
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
PortfolioResult.new(
|
|
122
|
+
portfolio:,
|
|
123
|
+
success: true,
|
|
124
|
+
error: nil
|
|
125
|
+
)
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
PortfolioResult.new(
|
|
128
|
+
portfolio: nil,
|
|
129
|
+
success: false,
|
|
130
|
+
error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.portfolio_generation_failed", error: e.message)
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Sample from existing portfolio (Phase 2 of two-phase process)
|
|
135
|
+
#
|
|
136
|
+
# @param verification_seed [String, nil] Optional seed for reproducible sampling
|
|
137
|
+
# @return [Result] containing selected participants and audit log
|
|
138
|
+
def sample_from_portfolio(verification_seed: nil)
|
|
139
|
+
portfolio = existing_portfolio
|
|
140
|
+
|
|
141
|
+
return error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.no_portfolio")) unless portfolio
|
|
142
|
+
|
|
143
|
+
if portfolio.sampled?
|
|
144
|
+
# Return existing result
|
|
145
|
+
return build_success_result(portfolio)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sampling_result = portfolio.sample!(verification_seed:)
|
|
149
|
+
|
|
150
|
+
return error_result(error: sampling_result.error) unless sampling_result.success?
|
|
151
|
+
|
|
152
|
+
build_success_result(portfolio.reload)
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
Rails.logger.error("Error while sampling portfolio: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
155
|
+
error_result(error: I18n.t("decidim.stratified_sortitions.errors.fair_sortition.sampling_failed", error: e.message))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Verify a previous sortition result
|
|
159
|
+
#
|
|
160
|
+
# Given the same seed and data, should produce the same result
|
|
161
|
+
#
|
|
162
|
+
# @param expected_ids [Array<Integer>] expected selected participant IDs
|
|
163
|
+
# @param verification_seed [String] the seed used in the original draw
|
|
164
|
+
# @return [Boolean] true if verification passes
|
|
165
|
+
def verify(expected_ids, verification_seed:)
|
|
166
|
+
portfolio = existing_portfolio
|
|
167
|
+
return false unless portfolio&.sampled?
|
|
168
|
+
|
|
169
|
+
# Re-sample with the same seed (doesn't persist)
|
|
170
|
+
random_seed = Decidim::StratifiedSortitions.derive_random_seed(verification_seed)
|
|
171
|
+
sampler = Leximin::PanelSampler.new(
|
|
172
|
+
portfolio.panels,
|
|
173
|
+
portfolio.probabilities,
|
|
174
|
+
random_seed:
|
|
175
|
+
)
|
|
176
|
+
result = sampler.sample
|
|
177
|
+
return false unless result.success?
|
|
178
|
+
|
|
179
|
+
Set.new(result.selected_panel) == Set.new(expected_ids)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Check if this sortition has already been performed
|
|
183
|
+
#
|
|
184
|
+
# @return [Boolean]
|
|
185
|
+
def already_performed?
|
|
186
|
+
existing_portfolio&.sampled? || false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Get the existing portfolio if any
|
|
190
|
+
#
|
|
191
|
+
# @return [PanelPortfolio, nil]
|
|
192
|
+
def existing_portfolio
|
|
193
|
+
@existing_portfolio ||= @stratified_sortition.panel_portfolio
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def find_or_generate_portfolio
|
|
199
|
+
return existing_portfolio if existing_portfolio.present?
|
|
200
|
+
|
|
201
|
+
result = generate_portfolio
|
|
202
|
+
result.success? ? result.portfolio : result
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def load_participants(participant_ids)
|
|
206
|
+
return [] if participant_ids.empty?
|
|
207
|
+
|
|
208
|
+
SampleParticipant
|
|
209
|
+
.where(id: participant_ids)
|
|
210
|
+
.order(:id)
|
|
211
|
+
.to_a
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def build_success_result(portfolio)
|
|
215
|
+
Result.new(
|
|
216
|
+
selected_participants: portfolio.selected_participants,
|
|
217
|
+
selected_participant_ids: portfolio.selected_panel,
|
|
218
|
+
selection_probabilities: portfolio.selection_probabilities,
|
|
219
|
+
portfolio:,
|
|
220
|
+
sampling_result: Leximin::PanelSampler::Result.new(
|
|
221
|
+
selected_panel: portfolio.selected_panel,
|
|
222
|
+
selected_index: portfolio.selected_panel_index,
|
|
223
|
+
random_value: portfolio.random_value_used,
|
|
224
|
+
success: true,
|
|
225
|
+
error: nil
|
|
226
|
+
),
|
|
227
|
+
selection_log: portfolio.audit_log,
|
|
228
|
+
success: true,
|
|
229
|
+
error: nil
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def error_result(error:)
|
|
234
|
+
Result.new(
|
|
235
|
+
selected_participants: [],
|
|
236
|
+
selected_participant_ids: [],
|
|
237
|
+
selection_probabilities: {},
|
|
238
|
+
portfolio: existing_portfolio,
|
|
239
|
+
sampling_result: nil,
|
|
240
|
+
selection_log: { error: },
|
|
241
|
+
success: false,
|
|
242
|
+
error:
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|