Skip to content
31 changes: 13 additions & 18 deletions back/app/models/phase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ class Phase < ApplicationRecord
validates :participation_method, inclusion: { in: PARTICIPATION_METHODS }
validates :placement_type, inclusion: { in: PLACEMENT_TYPES }
validates :prescreening_mode, inclusion: { in: PRESCREENING_MODES }, allow_nil: true
validate :validate_prescreening_mode, if: :prescreening_mode_changed?

with_options if: ->(phase) { phase.pmethod.supports_public_visibility? } do
validates :presentation_mode, inclusion: { in: PRESENTATION_MODES }
Expand Down Expand Up @@ -342,30 +341,26 @@ def feed_enabled?
available_views&.include?('feed')
end

def prescreening_enabled? = prescreening_mode.present?
def prescreening_flagged_only? = prescreening_mode == 'flagged_only'
def prescreening_all? = prescreening_mode == 'all'
def prescreening_enabled? = effective_prescreening_mode.present?
def prescreening_flagged_only? = effective_prescreening_mode == 'flagged_only'
def prescreening_all? = effective_prescreening_mode == 'all'

private

def validate_prescreening_mode
# The configured `prescreening_mode`, reduced to what this platform's features permit.
# The column is configuration data and is stored verbatim: tenant templates and project
# copies carry it between platforms whose features differ, so it can be set on a
# platform where prescreening is not available. It only takes effect where it is.
def effective_prescreening_mode
return if prescreening_mode.nil?

prescreening_flag = participation_method == 'proposals' ? 'prescreening' : 'prescreening_ideation'
return unless AppConfiguration.instance.feature_activated?(prescreening_flag)
return if prescreening_mode == 'flagged_only' && !AppConfiguration.instance.feature_activated?('flag_inappropriate_content')

# Any prescreening mode requires the prescreening feature to be enabled.
unless AppConfiguration.instance.feature_activated?(prescreening_flag)
errors.add(:prescreening_mode, "requires the #{prescreening_flag} feature to be enabled")
return
end

# 'flagged_only' mode requires the flag_inappropriate_content feature to be enabled.
return unless prescreening_mode == 'flagged_only'
return if AppConfiguration.instance.feature_activated?('flag_inappropriate_content')

errors.add(:prescreening_mode, 'requires the flag_inappropriate_content feature to be enabled')
prescreening_mode
end

private

def sanitize_description_multiloc
self.description_multiloc = sanitize_html_multiloc(description_multiloc)
end
Expand Down
2 changes: 1 addition & 1 deletion back/app/serializers/web_api/v1/phase_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class WebApi::V1::PhaseSerializer < WebApi::V1::BaseSerializer
:reacting_enabled, :reacting_like_method, :reacting_like_limited_max,
:reacting_dislike_enabled, :reacting_dislike_method, :reacting_dislike_limited_max,
:presentation_mode, :available_views, :ideas_order, :input_term, :vote_term,
:prescreening_mode, :manual_voters_amount, :manual_votes_count,
:prescreening_mode, :effective_prescreening_mode, :manual_voters_amount, :manual_votes_count,
:similarity_enabled, :similarity_threshold_title, :similarity_threshold_body,
:survey_popup_frequency

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,26 @@ def stub_toxicity_detection!(service)
end
end

# A phase can carry a prescreening_mode on a platform without the prescreening feature:
# tenant templates and project copies bring the value across from platforms that have it.
describe 'prescreening_mode: flagged_only, but the prescreening feature is disabled' do
before { SettingsService.new.deactivate_feature!('prescreening_ideation') }

it 'creates flag but does not move the idea to prescreening' do
phase = create(:phase, prescreening_mode: 'flagged_only')
idea = create(
:idea, phases: [phase], title_multiloc: { 'en' => 'offensive idea' },
idea_status: idea_proposed_status, publication_status: 'published'
)

expect { described_class.perform_now(idea, attributes: [:title_multiloc]) }
.to not_change(idea, :idea_status)
.and not_change(idea, :publication_status)

expect(idea.reload.inappropriate_content_flag).to be_present
end
end

describe 'prescreening_mode: nil (disabled)' do
it 'creates flag but does not change status' do
phase = create(:proposals_phase, prescreening_mode: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,54 @@
expect(idea.body_multiloc.keys).to contain_exactly('nl-BE')
end
end

# Templates carry model data but no feature flags (AppConfiguration is not serialized),
# so a template derived from a platform with screening enabled records a
# `prescreening_mode` that the target platform may have no feature for. Applying it
# must succeed regardless; the mode simply has no effect where the feature is off.
describe 'a phase with a prescreening_mode' do
let(:template) do
YAML.load(<<~YAML, permitted_classes: [Time], aliases: true)
models:
project:
- &project
title_multiloc:
en: Project
admin_publication:
- publication_ref: *project
publication_status: published
phase:
- project_ref: *project
title_multiloc:
en: Ideation phase
participation_method: ideation
start_at: '2026-01-01'
end_at: '2026-12-31'
prescreening_mode: all
YAML
end

context 'when the target platform does not have the prescreening feature' do
it 'applies the template, storing the mode but leaving it without effect' do
expect { service.deserialize(template) }.not_to raise_error

phase = Phase.first
expect(phase.prescreening_mode).to eq 'all'
expect(phase.prescreening_all?).to be false
end
end

context 'when the target platform has the prescreening feature' do
before { SettingsService.new.activate_feature!('prescreening_ideation') }

it 'applies the template, and the mode takes effect' do
service.deserialize(template)

phase = Phase.first
expect(phase.prescreening_mode).to eq 'all'
expect(phase.prescreening_all?).to be true
end
end
end
end
end
18 changes: 18 additions & 0 deletions back/spec/acceptance/ideas/ideas_update_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,24 @@ def public_input_params(spec)
end
end

# A phase can carry a prescreening_mode on a platform without the feature: tenant
# templates and project copies bring the value across from platforms that have it.
# The mode must not restrict the author where screening never applied.
context 'when prescreening_mode is all but the prescreening_ideation feature is disabled' do
let!(:proposed_status) { create(:idea_status_proposed) }

let(:phase) { create(:phase, :ongoing, prescreening_mode: 'all') }
let(:input) { create(:idea, phases: [phase], idea_status: proposed_status) }
let(:title_multiloc) { { 'en' => 'Changed title' } }

example 'Author can edit a published idea', document: false do
do_request

assert_status 200
expect(response_data.dig(:attributes, :title_multiloc, :en)).to eq('Changed title')
end
end

context 'when prescreening_mode is flagged_only' do
before_all do
SettingsService.new.activate_feature!('prescreening_ideation')
Expand Down
14 changes: 14 additions & 0 deletions back/spec/lib/participation_method/ideation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@
end
end
end

# A phase can carry a prescreening_mode on a platform without the feature: tenant
# templates and project copies bring the value across from platforms that have it.
context 'with prescreening_mode but the prescreening_ideation feature disabled' do
let!(:proposed_status) { create(:idea_status_proposed) }
let(:phase) { create(:phase, prescreening_mode: 'all', with_permissions: true) }

it 'ignores the mode: sets idea_status to proposed and publication_status to published' do
input = build(:idea, idea_status: nil, publication_status: nil)
participation_method.assign_defaults input
expect(input.idea_status).to eq proposed_status
expect(input.publication_status).to eq 'published'
end
end
end

describe '#assign_defaults_for_phase' do
Expand Down
15 changes: 15 additions & 0 deletions back/spec/lib/participation_method/proposals_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@
expect(proposal.idea_status).to eq custom_status
end
end

# A phase can carry a prescreening_mode on a platform without the feature: tenant
# templates and project copies bring the value across from platforms that have it.
context 'when prescreening_mode is all but the prescreening feature is disabled' do
before { SettingsService.new.deactivate_feature!('prescreening') }

let(:phase) { create(:proposals_phase, prescreening_mode: 'all') }

it 'ignores the mode: assigns the default "proposed" status' do
proposal = build(:proposal, idea_status: nil, creation_phase: phase, project: phase.project)
participation_method.assign_defaults proposal
expect(proposal.idea_status).to eq proposed_status
expect(proposal.publication_status).to eq 'published'
end
end
end

context 'when the proposed status is not available' do
Expand Down
160 changes: 105 additions & 55 deletions back/spec/models/phase_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -667,77 +667,127 @@
describe 'prescreening_mode' do
using RSpec::Parameterized::TableSyntax

describe 'validation' do
def set_feature_flags(prescreening:, flag_inappropriate_content:)
AppConfiguration.instance.settings.tap do |settings|
settings['prescreening'] = { 'allowed' => true, 'enabled' => prescreening }
settings['prescreening_ideation'] = { 'allowed' => true, 'enabled' => prescreening }
settings['flag_inappropriate_content'] = { 'allowed' => true, 'enabled' => flag_inappropriate_content }
end

AppConfiguration.instance.save!
end

describe 'on create' do
where(:factory, :prescreening_mode, :prescreening, :flag_inappropriate_content, :valid) do
:phase | nil | false | false | true
:phase | 'all' | false | false | false
:phase | 'all' | true | false | true
:phase | 'flagged_only' | true | false | false
:phase | 'flagged_only' | true | true | true
:phase | 'invalid' | true | true | false
:proposals_phase | nil | false | false | true
:proposals_phase | 'all' | false | false | false
:proposals_phase | 'all' | true | false | true
:proposals_phase | 'flagged_only' | true | false | false
:proposals_phase | 'flagged_only' | true | true | true
:proposals_phase | 'invalid' | true | true | false
end

with_them do
before { set_feature_flags(prescreening:, flag_inappropriate_content:) }

it { expect(build(factory, prescreening_mode: prescreening_mode).valid?).to eq valid }
end
def set_feature_flags(prescreening:, flag_inappropriate_content:)
AppConfiguration.instance.settings.tap do |settings|
settings['prescreening'] = { 'allowed' => true, 'enabled' => prescreening }
settings['prescreening_ideation'] = { 'allowed' => true, 'enabled' => prescreening }
settings['flag_inappropriate_content'] = { 'allowed' => true, 'enabled' => flag_inappropriate_content }
end

describe 'on update' do
it 'remains valid when feature is disabled but prescreening_mode unchanged' do
set_feature_flags(prescreening: true, flag_inappropriate_content: false)
phase = create(:phase, prescreening_mode: 'all')
set_feature_flags(prescreening: false, flag_inappropriate_content: false)
AppConfiguration.instance.save!
end

expect(phase).to be_valid
phase.title_multiloc = { 'en' => 'Updated title' }
expect(phase).to be_valid
end
# `prescreening_mode` is configuration data, not a feature-gated invariant: tenant
# templates and project copies carry it onto platforms whose features differ, so any
# valid mode is storable regardless of the flags. It is the *effect* that is gated -
# see 'effective mode' below.
describe 'validation' do
where(:factory, :prescreening_mode, :prescreening, :flag_inappropriate_content, :valid) do
:phase | nil | false | false | true
:phase | 'all' | false | false | true
:phase | 'all' | true | false | true
:phase | 'flagged_only' | false | false | true
:phase | 'flagged_only' | true | true | true
:phase | 'invalid' | true | true | false
:proposals_phase | nil | false | false | true
:proposals_phase | 'all' | false | false | true
:proposals_phase | 'flagged_only' | false | false | true
:proposals_phase | 'invalid' | true | true | false
end

it 'becomes invalid when changing prescreening_mode to non-nil with feature disabled' do
set_feature_flags(prescreening: true, flag_inappropriate_content: true)
phase = create(:phase, prescreening_mode: 'flagged_only')
set_feature_flags(prescreening: false, flag_inappropriate_content: false)
with_them do
before { set_feature_flags(prescreening:, flag_inappropriate_content:) }

expect(phase).to be_valid
phase.prescreening_mode = 'all'
expect(phase).not_to be_valid
end
it { expect(build(factory, prescreening_mode: prescreening_mode).valid?).to eq valid }
end
end

describe 'helper methods' do
where(:mode, :enabled, :flagged_only, :all) do
nil | false | false | false
'flagged_only' | true | true | false
'all' | true | false | true
# The mode only takes effect where the platform has the corresponding feature. Note
# ideation phases are gated on `prescreening_ideation` and proposals phases on
# `prescreening`; `set_feature_flags` sets both together.
describe 'effective mode' do
where(:factory, :mode, :prescreening, :flag_inappropriate_content, :enabled, :flagged_only, :all) do
# No mode configured: inert regardless of the flags.
:phase | nil | true | true | false | false | false
:phase | nil | false | false | false | false | false

# Mode configured, prescreening feature off: inert.
:phase | 'all' | false | false | false | false | false
:phase | 'flagged_only' | false | true | false | false | false
:proposals_phase | 'all' | false | false | false | false | false

# Mode configured, prescreening feature on: in effect.
:phase | 'all' | true | false | true | false | true
:proposals_phase | 'all' | true | false | true | false | true

# 'flagged_only' additionally requires flag_inappropriate_content.
:phase | 'flagged_only' | true | false | false | false | false
:phase | 'flagged_only' | true | true | true | true | false
:proposals_phase | 'flagged_only' | true | true | true | true | false
end

with_them do
subject(:phase) { build(:phase, prescreening_mode: mode) }
subject(:phase) { build(factory, prescreening_mode: mode) }

before { set_feature_flags(prescreening:, flag_inappropriate_content:) }

it { expect(phase.prescreening_enabled?).to eq enabled }
it { expect(phase.prescreening_flagged_only?).to eq flagged_only }
it { expect(phase.prescreening_all?).to eq all }
end

it 'preserves the configured mode even where the feature is off' do
set_feature_flags(prescreening: false, flag_inappropriate_content: false)
phase = create(:phase, prescreening_mode: 'all')

expect(phase.reload.prescreening_mode).to eq 'all'
expect(phase.prescreening_all?).to be false
end

it 'takes effect once the feature is enabled, without rewriting the phase' do
set_feature_flags(prescreening: false, flag_inappropriate_content: false)
phase = create(:phase, prescreening_mode: 'all')
expect(phase.prescreening_all?).to be false

set_feature_flags(prescreening: true, flag_inappropriate_content: false)

expect(phase.prescreening_all?).to be true
end

# The two participation methods are gated on different features, so converting a
# phase moves it from one gate to the other. This matters on a default-configured
# platform, where `prescreening` (proposals) is enabled and `prescreening_ideation`
# is not.
describe 'when the participation_method is converted' do
before do
AppConfiguration.instance.settings.tap do |settings|
settings['prescreening'] = { 'allowed' => true, 'enabled' => true }
settings['prescreening_ideation'] = { 'allowed' => false, 'enabled' => false }
end

AppConfiguration.instance.save!
end

it 'stops taking effect when proposals (feature on) becomes ideation (feature off)' do
phase = create(:proposals_phase, prescreening_mode: 'all')
expect(phase.prescreening_all?).to be true

phase.update!(participation_method: 'ideation')

expect(phase.reload.prescreening_mode).to eq 'all'
expect(phase.prescreening_all?).to be false
end

it 'starts taking effect when ideation (feature off) becomes proposals (feature on)' do
phase = create(:phase, prescreening_mode: 'all')
expect(phase.prescreening_all?).to be false

phase.update!(participation_method: 'proposals')

expect(phase.reload.prescreening_mode).to eq 'all'
expect(phase.prescreening_all?).to be true
end
end
end
end
end
Loading