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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ # Controller that allows managing admin stratified sortitions.
7
+ #
8
+ class StratifiedSortitionsController < Decidim::StratifiedSortitions::Admin::ApplicationController
9
+ include Decidim::ApplicationHelper
10
+ include StrataChartsData
11
+
12
+ helper StratifiedSortitions::ApplicationHelper
13
+ helper Decidim::PaginateHelper
14
+
15
+ helper_method :stratified_sortitions, :stratified_sortition, :form_presenter, :blank_stratum
16
+
17
+ def index
18
+ enforce_permission_to :read, :stratified_sortitions
19
+ @stratified_sortitions = stratified_sortitions
20
+ end
21
+
22
+ def new
23
+ enforce_permission_to :create, :stratified_sortition
24
+ @form = form(Decidim::StratifiedSortitions::Admin::StratifiedSortitionsForm).instance
25
+ end
26
+
27
+ def edit
28
+ enforce_permission_to(:edit, :stratified_sortition, stratified_sortition:)
29
+ @form = form(Decidim::StratifiedSortitions::Admin::StratifiedSortitionsForm).from_model(stratified_sortition)
30
+ end
31
+
32
+ def create
33
+ enforce_permission_to :create, :stratified_sortition
34
+ @form = form(Decidim::StratifiedSortitions::Admin::StratifiedSortitionsForm).from_params(params)
35
+
36
+ Decidim::StratifiedSortitions::Admin::CreateStratifiedSortition.call(@form) do
37
+ on(:ok) do |created_sortition|
38
+ flash[:notice] = I18n.t("stratified_sortitions.create.success", scope: "decidim.stratified_sortitions.admin")
39
+ redirect_to edit_stratified_sortition_path(created_sortition)
40
+ end
41
+
42
+ on(:invalid) do
43
+ flash.now[:alert] = I18n.t("stratified_sortitions.create.error", scope: "decidim.stratified_sortitions.admin")
44
+ render action: "new"
45
+ end
46
+ end
47
+ end
48
+
49
+ def update
50
+ enforce_permission_to(:edit, :stratified_sortition, stratified_sortition:)
51
+ @form = form(Decidim::StratifiedSortitions::Admin::StratifiedSortitionsForm).from_params(params)
52
+
53
+ Decidim::StratifiedSortitions::Admin::UpdateStratifiedSortition.call(@form, stratified_sortition) do
54
+ on(:ok) do |_stratified_sortition|
55
+ flash[:notice] = t("stratified_sortitions.update.success", scope: "decidim.stratified_sortitions.admin")
56
+ redirect_to edit_stratified_sortition_path(stratified_sortition)
57
+ end
58
+
59
+ on(:invalid) do
60
+ flash.now[:alert] = t("stratified_sortitions.update.error", scope: "decidim.stratified_sortitions.admin")
61
+ render :edit
62
+ end
63
+ end
64
+ end
65
+
66
+ def duplicate
67
+ enforce_permission_to(:duplicate, :stratified_sortition, stratified_sortition:)
68
+
69
+ Decidim::StratifiedSortitions::Admin::DuplicateStratifiedSortition.call(stratified_sortition, current_user) do
70
+ on(:ok) do |_new_stratified_sortition|
71
+ flash[:notice] = I18n.t("stratified_sortitions.duplicate.success", scope: "decidim.stratified_sortitions.admin")
72
+ redirect_to stratified_sortitions_path(assembly_slug: -1, component_id: -1)
73
+ end
74
+
75
+ on(:invalid) do
76
+ flash[:alert] = I18n.t("stratified_sortitions.duplicate.error", scope: "decidim.stratified_sortitions.admin")
77
+ redirect_to stratified_sortitions_path(assembly_slug: -1, component_id: -1)
78
+ end
79
+ end
80
+ end
81
+
82
+ def destroy
83
+ enforce_permission_to(:destroy, :stratified_sortition, stratified_sortition:)
84
+
85
+ Decidim::StratifiedSortitions::Admin::DestroyStratifiedSortition.call(stratified_sortition, current_user) do
86
+ on(:ok) do
87
+ flash[:notice] = I18n.t("stratified_sortitions.destroy.success", scope: "decidim.stratified_sortitions.admin")
88
+ redirect_to stratified_sortitions_path(assembly_slug: -1, component_id: -1)
89
+ end
90
+
91
+ on(:has_problems) do
92
+ redirect_to stratified_sortitions_path, flash: { error: t("stratified_sortitions.destroy.has_problems", scope: "decidim.stratified_sortitions.admin") }
93
+ end
94
+
95
+ on(:invalid) do
96
+ redirect_to stratified_sortitions_path, flash: { error: t("stratified_sortitions.destroy.error", scope: "decidim.stratified_sortitions.admin") }
97
+ end
98
+ end
99
+ end
100
+
101
+ def upload_sample
102
+ enforce_permission_to :upload_sample, :stratified_sortition
103
+ if stratified_sortition.strata_and_substrata_configured?
104
+ @stratified_sortition = stratified_sortition
105
+ @form = SampleUploadForm.new
106
+ @sample_participants_count = @stratified_sortition.sample_participants.count
107
+ @last_sample = SampleImport.where(stratified_sortition: @stratified_sortition).order(created_at: :desc).first
108
+ @samples = SampleImport.where(stratified_sortition: @stratified_sortition).order(created_at: :asc)
109
+ @strata_data = strata_data(@stratified_sortition)
110
+ @candidates_data = candidates_data(@stratified_sortition)
111
+ else
112
+ redirect_to edit_stratified_sortition_path(stratified_sortition),
113
+ flash: { warning: t("stratified_sortitions.upload_sample.strata_not_configured", scope: "decidim.stratified_sortitions.admin") }
114
+ end
115
+ end
116
+
117
+ def execute
118
+ unless stratified_sortition.can_execute? || stratified_sortition.executing? || stratified_sortition.executed? || stratified_sortition.failed?
119
+ redirect_to edit_stratified_sortition_path(stratified_sortition),
120
+ flash: { warning: t("stratified_sortitions.execute.empty_sample_participants", scope: "decidim.stratified_sortitions.admin") }
121
+ return
122
+ end
123
+
124
+ @stratified_sortition = stratified_sortition
125
+ @portfolio = @stratified_sortition.panel_portfolio
126
+ @strata = @stratified_sortition.strata.order(:position)
127
+ @selected_participants = if @portfolio&.sampled?
128
+ SampleParticipant
129
+ .where(id: @portfolio.selected_panel)
130
+ .includes(sample_participant_strata: [:decidim_stratified_sortitions_stratum, :decidim_stratified_sortitions_substratum])
131
+ .order(:id)
132
+ .to_a
133
+ else
134
+ []
135
+ end
136
+ @strata_data = strata_data(@stratified_sortition)
137
+ @candidates_data = candidates_data(@stratified_sortition)
138
+ @results_data = results_data(@stratified_sortition)
139
+ end
140
+
141
+ def execute_stratified_sortition
142
+ unless stratified_sortition.can_execute?
143
+ redirect_to execute_stratified_sortition_path(stratified_sortition)
144
+ return
145
+ end
146
+
147
+ stratified_sortition.update!(status: "executing")
148
+ ExecuteSortitionJob.perform_later(stratified_sortition, current_user)
149
+ flash[:notice] = I18n.t("stratified_sortitions.execute.executing", scope: "decidim.stratified_sortitions.admin")
150
+ redirect_to execute_stratified_sortition_path(stratified_sortition)
151
+ end
152
+
153
+ def export_charts_pdf
154
+ portfolio = stratified_sortition.panel_portfolio
155
+
156
+ unless portfolio&.sampled?
157
+ flash[:error] = I18n.t("stratified_sortitions.export_results.no_results", scope: "decidim.stratified_sortitions.admin")
158
+ redirect_to execute_stratified_sortition_path(stratified_sortition)
159
+ return
160
+ end
161
+
162
+ generator = ChartsPdfGenerator.new(
163
+ stratified_sortition,
164
+ strata_data(stratified_sortition),
165
+ candidates_data(stratified_sortition),
166
+ results_data(stratified_sortition),
167
+ locale: I18n.locale
168
+ )
169
+
170
+ filename = "sortition_charts_#{stratified_sortition.id}_#{Time.current.strftime("%Y%m%d_%H%M%S")}.pdf"
171
+ send_data generator.generate,
172
+ filename:,
173
+ type: "application/pdf",
174
+ disposition: "attachment"
175
+ end
176
+
177
+ def export_results
178
+ format = params[:format]&.downcase || "csv"
179
+ portfolio = stratified_sortition.panel_portfolio
180
+
181
+ unless portfolio&.sampled?
182
+ flash[:error] = I18n.t("stratified_sortitions.export_results.no_results", scope: "decidim.stratified_sortitions.admin")
183
+ redirect_to execute_stratified_sortition_path(stratified_sortition)
184
+ return
185
+ end
186
+
187
+ SortitionResultsExportJob.perform_later(current_user, stratified_sortition, format)
188
+
189
+ Decidim.traceability.perform_action!("export_results", stratified_sortition, current_user, visibility: "all")
190
+ flash[:notice] = I18n.t("decidim.admin.exports.notice")
191
+ redirect_to execute_stratified_sortition_path(stratified_sortition)
192
+ end
193
+
194
+ def log_view_participants
195
+ portfolio = stratified_sortition.panel_portfolio
196
+
197
+ unless portfolio&.sampled?
198
+ head :unprocessable_entity
199
+ return
200
+ end
201
+
202
+ Decidim.traceability.perform_action!("view_participants", stratified_sortition, current_user, visibility: "all")
203
+
204
+ head :ok
205
+ end
206
+
207
+ private
208
+
209
+ def collection
210
+ @collection ||= StratifiedSortition.where(component: current_component)
211
+ end
212
+
213
+ def stratified_sortitions
214
+ @stratified_sortitions ||= collection.page(params[:page]).per(10)
215
+ end
216
+
217
+ def stratified_sortition
218
+ @stratified_sortition ||= collection.find_by(id: params[:id])
219
+ end
220
+
221
+ def form_presenter
222
+ @form_presenter ||= present(@form, presenter_class: Decidim::StratifiedSortitions::StratifiedSortitionPresenter)
223
+ end
224
+
225
+ def blank_stratum
226
+ @blank_stratum ||= Decidim::StratifiedSortitions::Admin::StratumForm.new
227
+ end
228
+
229
+ def blank_substratum(stratum_form)
230
+ Decidim::StratifiedSortitions::Admin::SubstratumForm.new(stratum: stratum_form.model)
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # This controller is the abstract class from which all other controllers of
6
+ # this engine inherit.
7
+ #
8
+ # Note that it inherits from `Decidim::Components::BaseController`, which
9
+ # override its layout and provide all kinds of useful methods.
10
+ class ApplicationController < Decidim::Components::BaseController
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # A dummy controller used to render PDF views outside the request cycle.
6
+ # Following the same pattern as Decidim core (decidim-forms).
7
+ # rubocop:disable Rails/ApplicationController
8
+ class ChartsPdfControllerHelper < ActionController::Base
9
+ # rubocop:enable Rails/ApplicationController
10
+ helper Decidim::TranslationsHelper
11
+ helper Decidim::StratifiedSortitions::ChartsPdfHelper
12
+
13
+ # Ensure the engine's views (templates + layouts) are found.
14
+ append_view_path Decidim::StratifiedSortitions::Engine.root.join("app", "views")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ # Controller that allows browsing stratified sortitions.
6
+ #
7
+ class StratifiedSortitionsController < Decidim::StratifiedSortitions::ApplicationController
8
+ include Decidim::ApplicationHelper
9
+ include FilterResource
10
+ include Paginable
11
+ include OrderableStratifiedSortitions
12
+ include StrataChartsData
13
+
14
+ helper Decidim::CheckBoxesTreeHelper
15
+ helper Decidim::PaginateHelper
16
+
17
+ helper_method :stratified_sortitions, :stratified_sortition
18
+
19
+ def index
20
+ @stratified_sortitions = search.result
21
+ @stratified_sortitions = reorder(@stratified_sortitions)
22
+ @stratified_sortitions = paginate(@stratified_sortitions)
23
+ end
24
+
25
+ def show
26
+ raise ActionController::RoutingError, "Not Found" unless stratified_sortition
27
+
28
+ if current_component.settings.publish_sortitions
29
+ @strata_data = strata_data(stratified_sortition) if stratified_sortition.strata_and_substrata_configured?
30
+ @candidates_data = candidates_data(stratified_sortition) if stratified_sortition.can_execute?
31
+ @results_data = results_data(stratified_sortition) if stratified_sortition.panel_portfolio&.sampled?
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def stratified_sortition
38
+ @stratified_sortition ||= search_collection.find_by(id: params[:id])
39
+ end
40
+
41
+ def stratified_sortition_scope
42
+ @stratified_sortition_scope ||= current_organization.scopes.find_by(id: stratified_sortition&.decidim_scope_id)
43
+ end
44
+
45
+ def stratified_sortitions
46
+ @stratified_sortitions ||= reorder(paginate(search.result))
47
+ end
48
+
49
+ def search_collection
50
+ ::Decidim::StratifiedSortitions::StratifiedSortition.where(component: current_component)
51
+ end
52
+
53
+ def default_filter_params
54
+ {
55
+ search_text_cont: "",
56
+ with_any_state: "all",
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ class SampleUploadForm < Decidim::Form
7
+ include Decidim::HasUploadValidations
8
+
9
+ attribute :file, Decidim::Attributes::Blob
10
+
11
+ validates :file, presence: true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ # A form object used to create stratified sortitions from the admin dashboard.
7
+ #
8
+ class StratifiedSortitionsForm < Decidim::Form
9
+ include TranslatableAttributes
10
+ include TranslationsHelper
11
+ include Decidim::HasUploadValidations
12
+ include ApplicationHelper
13
+
14
+ mimic :stratified_sortition
15
+
16
+ translatable_attribute :title, String do |field, _locale|
17
+ validates field, length: { in: 5..150 }, if: proc { |resource| resource.send(field).present? }
18
+ end
19
+ translatable_attribute :description, String
20
+ translatable_attribute :selection_criteria, String
21
+ translatable_attribute :selected_profiles_description, String
22
+
23
+ attribute :id, Integer
24
+ attribute :decidim_component_id, Integer
25
+ attribute :num_candidates, Integer
26
+ attribute :strata, [StratumForm]
27
+
28
+ validates :title, :description, translatable_presence: true
29
+ validates :num_candidates,
30
+ presence: true,
31
+ numericality: {
32
+ only_integer: true,
33
+ greater_than_or_equal_to: 1,
34
+ }
35
+
36
+ alias organization current_organization
37
+
38
+ def map_model(model)
39
+ super
40
+ self.strata = model.strata.order(:position).map do |stratum|
41
+ Decidim::StratifiedSortitions::Admin::StratumForm.from_model(stratum)
42
+ end
43
+ end
44
+
45
+ def strata_to_persist
46
+ strata.reject(&:deleted)
47
+ end
48
+
49
+ validate :strata_immutable_when_sample_participants_exist
50
+ validate :num_candidates_immutable_when_executed
51
+
52
+ private
53
+
54
+ def num_candidates_immutable_when_executed
55
+ return unless stratified_sortition&.executed?
56
+ return if stratified_sortition.num_candidates == num_candidates
57
+
58
+ errors.add(:num_candidates, :cannot_change_num_candidates_when_executed)
59
+ end
60
+
61
+ def strata_immutable_when_sample_participants_exist
62
+ return unless stratified_sortition&.sample_participants&.any?
63
+ return if stratified_sortition.executed?
64
+
65
+ validate_strata_count
66
+ validate_strata_attributes
67
+ validate_substrata_changes
68
+ end
69
+
70
+ def validate_strata_count
71
+ existing_strata_ids = stratified_sortition.strata.pluck(:id)
72
+ form_strata_ids = strata_to_persist.map { |s| s.id.to_i }.reject(&:zero?)
73
+
74
+ new_strata = strata_to_persist.select { |s| s.id.blank? }
75
+ errors.add(:strata, :cannot_add_strata_with_sample_participants) if new_strata.any?
76
+
77
+ errors.add(:strata, :cannot_delete_strata_with_sample_participants) if (existing_strata_ids - form_strata_ids).any?
78
+ end
79
+
80
+ def validate_strata_attributes
81
+ strata_to_persist.each do |stratum_form|
82
+ next if stratum_form.id.blank?
83
+
84
+ existing_stratum = stratified_sortition.strata.find_by(id: stratum_form.id)
85
+ next unless existing_stratum
86
+
87
+ errors.add(:strata, :cannot_change_stratum_name_with_sample_participants) unless same_translated_attribute?(existing_stratum.name, stratum_form.name)
88
+
89
+ errors.add(:strata, :cannot_change_stratum_kind_with_sample_participants) if existing_stratum.kind != stratum_form.kind
90
+ end
91
+ end
92
+
93
+ def validate_substrata_changes
94
+ strata_to_persist.each do |stratum_form|
95
+ next if stratum_form.id.blank?
96
+
97
+ existing_stratum = stratified_sortition.strata.find_by(id: stratum_form.id)
98
+ next unless existing_stratum
99
+
100
+ existing_substrata_ids = existing_stratum.substrata.pluck(:id)
101
+ form_substrata_ids = stratum_form.substrata_to_persist.map { |s| s.id.to_i }.reject(&:zero?)
102
+
103
+ new_substrata = stratum_form.substrata_to_persist.select { |s| s.id.blank? }
104
+ errors.add(:strata, :cannot_add_substrata_with_sample_participants) if new_substrata.any?
105
+
106
+ errors.add(:strata, :cannot_delete_substrata_with_sample_participants) if (existing_substrata_ids - form_substrata_ids).any?
107
+
108
+ validate_substrata_attributes(existing_stratum, stratum_form)
109
+ end
110
+ end
111
+
112
+ def validate_substrata_attributes(existing_stratum, stratum_form)
113
+ stratum_form.substrata_to_persist.each do |substratum_form|
114
+ next if substratum_form.id.blank?
115
+
116
+ existing_substratum = existing_stratum.substrata.find_by(id: substratum_form.id)
117
+ next unless existing_substratum
118
+
119
+ errors.add(:strata, :cannot_change_substratum_name_with_sample_participants) unless same_translated_attribute?(existing_substratum.name, substratum_form.name)
120
+
121
+ unless normalize_blank(existing_substratum.value) == normalize_blank(substratum_form.value)
122
+ errors.add(:strata, :cannot_change_substratum_value_with_sample_participants)
123
+ end
124
+
125
+ unless normalize_blank(existing_substratum.range) == normalize_blank(substratum_form.range)
126
+ errors.add(:strata, :cannot_change_substratum_range_with_sample_participants)
127
+ end
128
+ end
129
+ end
130
+
131
+ def normalize_blank(value)
132
+ value.presence
133
+ end
134
+
135
+ def same_translated_attribute?(existing_value, form_value)
136
+ existing_hash = normalize_translated_hash(existing_value)
137
+ form_hash = normalize_translated_hash(form_value)
138
+
139
+ locales = (existing_hash.keys + form_hash.keys).uniq
140
+ locales.all? { |locale| existing_hash[locale] == form_hash[locale] }
141
+ end
142
+
143
+ def normalize_translated_hash(value)
144
+ hash = value.is_a?(Hash) ? value.stringify_keys : {}
145
+ hash = hash.except("machine_translations")
146
+ hash.transform_values { |v| v.to_s.strip.presence }.compact
147
+ end
148
+
149
+ def stratified_sortition
150
+ @stratified_sortition ||= Decidim::StratifiedSortitions::StratifiedSortition.find_by(id:)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/PerceivedComplexity
4
+ # rubocop:disable Lint/ReturnInVoidContext
5
+ module Decidim
6
+ module StratifiedSortitions
7
+ module Admin
8
+ class StratumForm < Decidim::Form
9
+ include TranslatableAttributes
10
+
11
+ translatable_attribute :name, String
12
+ attribute :kind, String
13
+ attribute :deleted, Boolean, default: false
14
+ attribute :substrata, [SubstratumForm]
15
+ attribute :position, Integer
16
+
17
+ validates :name, translatable_presence: true, unless: :deleted
18
+ validates :position, numericality: { greater_than_or_equal_to: 0 }
19
+
20
+ def substrata
21
+ @substrata ||= []
22
+ end
23
+
24
+ def substrata=(value)
25
+ return @substrata = [] if value.blank?
26
+
27
+ if value.is_a?(Hash)
28
+ value = if value.values.first.is_a?(String)
29
+ [value]
30
+ else
31
+ value.values
32
+ end
33
+ end
34
+
35
+ @substrata = value.map do |substratum_data|
36
+ if substratum_data.is_a?(SubstratumForm)
37
+ substratum_data
38
+ elsif substratum_data.is_a?(Hash)
39
+ SubstratumForm.from_params(substratum_data)
40
+ elsif substratum_data.is_a?(Array) && substratum_data[1].is_a?(Hash)
41
+ SubstratumForm.from_params(substratum_data[1])
42
+ else
43
+ next
44
+ end
45
+ end.compact
46
+ end
47
+
48
+ def map_model(model)
49
+ super
50
+ self.substrata = model.substrata.order(:position).map do |substratum|
51
+ Decidim::StratifiedSortitions::Admin::SubstratumForm.from_model(substratum)
52
+ end
53
+ end
54
+
55
+ def substrata_to_persist
56
+ substrata.select { |s| s.is_a?(SubstratumForm) && !s.deleted }
57
+ end
58
+
59
+ def to_param
60
+ return id if id.present?
61
+
62
+ "stratified-sortition-stratum-id"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ # rubocop:enable Metrics/PerceivedComplexity
69
+ # rubocop:enable Lint/ReturnInVoidContext
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ class SubstratumForm < Decidim::Form
7
+ include TranslatableAttributes
8
+
9
+ translatable_attribute :name, String
10
+ attribute :value, String
11
+ attribute :range, String
12
+ attribute :max_quota_percentage, String
13
+ attribute :deleted, Boolean, default: false
14
+ attribute :position, Integer
15
+
16
+ validates :position, numericality: { greater_than_or_equal_to: 0 }
17
+
18
+ def to_param
19
+ return id if id.present?
20
+
21
+ "stratified-sortition-strata-substratum-id"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module StratifiedSortitions
5
+ module Admin
6
+ module StratifiedSortitionsHelper
7
+ def stratified_sortition_state_css_class(stratified_sortition)
8
+ case stratified_sortition.status
9
+ when "executed" then "success"
10
+ when "executing", "failed" then "alert"
11
+ when "pending" then "warning"
12
+ else "info"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end