diff --git a/back/app/jobs/automated_transition_job.rb b/back/app/jobs/automated_transition_job.rb index 1d194c583a41..a6d33cc88cc4 100644 --- a/back/app/jobs/automated_transition_job.rb +++ b/back/app/jobs/automated_transition_job.rb @@ -4,6 +4,8 @@ class AutomatedTransitionJob < ApplicationJob queue_as :default def run + InputStatusService.auto_transition_hourly! + return unless AppConfiguration.instance.feature_activated? 'initiatives' InitiativeStatusService.new.automated_transitions! diff --git a/back/app/models/idea.rb b/back/app/models/idea.rb index 280896f88877..052edeedfe55 100644 --- a/back/app/models/idea.rb +++ b/back/app/models/idea.rb @@ -183,9 +183,12 @@ def will_be_published? publication_status_change == %w[draft published] || publication_status_change == [nil, 'published'] end + def consultation_context + creation_phase || project + end + def custom_form - participation_context = creation_phase || project - participation_context.custom_form || CustomForm.new(participation_context: participation_context) + consultation_context.custom_form || CustomForm.new(participation_context: consultation_context) end def input_term @@ -197,7 +200,7 @@ def input_term end def participation_method_on_creation - (creation_phase || project).pmethod + consultation_context.pmethod end private diff --git a/back/app/services/input_status_service.rb b/back/app/services/input_status_service.rb index cb66e698303e..d19db4b3aec8 100644 --- a/back/app/services/input_status_service.rb +++ b/back/app/services/input_status_service.rb @@ -1,12 +1,47 @@ # frozen_string_literal: true class InputStatusService + AUTOMATED_TRANSITIONS = { + 'proposed' => %w[threshold_reached expired] + } + attr_reader :input_status def initialize(input_status) @input_status = input_status end + def self.auto_transition_input!(input) + AUTOMATED_TRANSITIONS[input.idea_status.code]&.each do |code_to| + can_transition = case code_to + when 'threshold_reached' + threshold_reached_condition?(input) + else + false + end + + apply_transition!(input, code_to) if can_transition + end + end + + def self.auto_transition_hourly! + AUTOMATED_TRANSITIONS&.each do |code_from, codes_to| + inputs = Idea.includes(:idea_status).where(idea_status: { code: code_from }) + codes_to.each do |code_to| + inputs_to_transition = case code_to + when 'expired' + expired_scope(inputs) + else + inputs.none + end + + inputs_to_transition.each do |input| + apply_transition!(input, code_to) + end + end + end + end + def can_transition_manually? case input_status.participation_method when 'ideation' @@ -21,4 +56,33 @@ def can_transition_manually? def can_reorder? !input_status.automatic? end + + private + + private_class_method def self.apply_transition!(input, code_to) + code_from = input.idea_status.code + status_to = IdeaStatus.find_by(code: code_to, participation_method: input.participation_method_on_creation.class.method_str) + input.update!(idea_status: status_to) + LogActivityJob.perform_later( + input, + 'changed_input_status', + nil, + Time.zone.now.to_i, + payload: { input_status_from_code: code_from, input_status_to_code: code_to } + ) + end + + private_class_method def self.threshold_reached_condition?(input) + threshold = input.consultation_context.try(:reacting_threshold) + threshold && input.likes_count >= threshold + end + + private_class_method def self.expired_scope(inputs, now = Time.zone.now) + # This code assumes that the consultation context corresponds to + # the creation phase, which is not yet the case for ideation. + inputs + .includes(:creation_phase) + .where.not(creation_phase: { expire_days_limit: nil }) + .where("published_at + creation_phase.expire_days_limit * interval '1 day' < ?", now) + end end diff --git a/back/app/services/side_fx_reaction_service.rb b/back/app/services/side_fx_reaction_service.rb index f5693c388de1..6a3b4c916435 100644 --- a/back/app/services/side_fx_reaction_service.rb +++ b/back/app/services/side_fx_reaction_service.rb @@ -4,6 +4,8 @@ class SideFxReactionService include SideFxHelper def after_create(reaction, current_user) + InputStatusService.auto_transition_input!(reaction.reactable.reload) if reaction.reactable_type == 'Idea' + if reaction.reactable_type == 'Initiative' AutomatedTransitionJob.perform_now diff --git a/back/spec/acceptance/idea_reactions_spec.rb b/back/spec/acceptance/idea_reactions_spec.rb index 1ee6b8e7712b..c305c812963d 100644 --- a/back/spec/acceptance/idea_reactions_spec.rb +++ b/back/spec/acceptance/idea_reactions_spec.rb @@ -76,6 +76,21 @@ assert_status 422 end end + + describe do + let!(:status_threshold_reached) { create(:proposals_status, code: 'threshold_reached') } + let(:phase) { create(:proposals_phase, reacting_threshold: 2) } + let(:proposal) { create(:proposal, idea_status: create(:proposals_status, code: 'proposed'), creation_phase: phase, project: phase.project, phases: [phase]) } + let(:idea_id) { proposal.id } + + example 'Reaching the voting threshold immediately triggers status change', document: false do + create_list(:reaction, 2, reactable: proposal, mode: 'up') + + do_request + assert_status 201 + expect(proposal.reload.idea_status).to eq status_threshold_reached + end + end end post 'web_api/v1/ideas/:idea_id/reactions/up' do diff --git a/back/spec/acceptance/initiative_reactions_spec.rb b/back/spec/acceptance/initiative_reactions_spec.rb index 931d43cefd03..22a9efb62ebe 100644 --- a/back/spec/acceptance/initiative_reactions_spec.rb +++ b/back/spec/acceptance/initiative_reactions_spec.rb @@ -68,6 +68,7 @@ expect(@initiative.reload.likes_count).to eq 3 end + # TODO: cleanup-after-proposals-migration example 'Reaching the voting threshold immediately triggers status change', document: false do settings = AppConfiguration.instance.settings settings['initiatives']['reacting_threshold'] = 3 diff --git a/back/spec/acceptance/initiatives_spec.rb b/back/spec/acceptance/initiatives_spec.rb index 53e9aa7b2fed..a10de82653b4 100644 --- a/back/spec/acceptance/initiatives_spec.rb +++ b/back/spec/acceptance/initiatives_spec.rb @@ -759,7 +759,7 @@ end end - # TODO: move-old-proposals-test + # TODO: cleanup-after-proposals-migration get 'web_api/v1/initiatives/:id/allowed_transitions' do before do admin_header_token diff --git a/back/spec/factories/phases.rb b/back/spec/factories/phases.rb index 20d07366d052..c767cde3a2bf 100644 --- a/back/spec/factories/phases.rb +++ b/back/spec/factories/phases.rb @@ -65,6 +65,8 @@ factory :proposals_phase do participation_method { 'proposals' } + start_at { Time.zone.today - 7.days } + end_at { Time.zone.today + 7.days } end factory :poll_phase do diff --git a/back/spec/services/initiative_status_service_spec.rb b/back/spec/services/initiative_status_service_spec.rb index a05837e9c89a..bfc0b4c74519 100644 --- a/back/spec/services/initiative_status_service_spec.rb +++ b/back/spec/services/initiative_status_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -# TODO: move-old-proposals-test +# TODO: cleanup-after-proposals-migration describe InitiativeStatusService do let(:service) { described_class.new } diff --git a/back/spec/services/input_status_service_spec.rb b/back/spec/services/input_status_service_spec.rb new file mode 100644 index 000000000000..cd772c8f8779 --- /dev/null +++ b/back/spec/services/input_status_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe InputStatusService do + describe 'automated transitions' do + before do + %w[proposed threshold_reached expired].each do |status_code| + create(:proposals_status, code: status_code) + end + end + + let(:phase) { create(:proposals_phase, reacting_threshold: 2, expire_days_limit: 20) } + let!(:proposal) { create(:proposal, idea_status: IdeaStatus.find_by(code: 'proposed'), creation_phase: phase, project: phase.project, phases: [phase], published_at: Time.now) } + + describe 'auto_transition_input!' do + it 'transitions when voting threshold was reached' do + create_list(:reaction, 3, reactable: proposal, mode: 'up') + + described_class.auto_transition_input!(proposal.reload) + + expect(proposal.reload.idea_status.code).to eq 'threshold_reached' + end + + it 'remains proposed if not expired nor threshold reached' do + create(:reaction, reactable: proposal, mode: 'up') + + travel_to(Time.now + 15.days) do + described_class.auto_transition_input!(proposal.reload) + expect(proposal.reload.idea_status.code).to eq 'proposed' + end + end + end + + describe 'auto_transition_hourly!' do + it 'transitions when expired' do + create(:idea) + travel_to(Time.now + 22.days) do + described_class.auto_transition_hourly! + expect(proposal.reload.idea_status.code).to eq 'expired' + end + end + + it 'remains proposed if not expired nor threshold reached' do + create(:reaction, reactable: proposal, mode: 'up') + + travel_to(Time.now + 19.days) do + described_class.auto_transition_hourly! + expect(proposal.reload.idea_status.code).to eq 'proposed' + end + end + end + end +end