diff --git a/back/app/controllers/web_api/v1/ideas_controller.rb b/back/app/controllers/web_api/v1/ideas_controller.rb index 52a8d84b1806..9abbb4d7ba73 100644 --- a/back/app/controllers/web_api/v1/ideas_controller.rb +++ b/back/app/controllers/web_api/v1/ideas_controller.rb @@ -179,7 +179,7 @@ def create input.phase_ids = [phase.id] if phase_ids.empty? # NOTE: Needs refactor allow_anonymous_participation? so anonymous_participation can be allow or force - if phase.native_survey? && phase.allow_anonymous_participation? + if phase.pmethod.supports_survey_form? && phase.allow_anonymous_participation? input.anonymous = true end input.author ||= current_user diff --git a/back/app/controllers/web_api/v1/phases_controller.rb b/back/app/controllers/web_api/v1/phases_controller.rb index c0b4a4f9912b..1771f0c55a74 100644 --- a/back/app/controllers/web_api/v1/phases_controller.rb +++ b/back/app/controllers/web_api/v1/phases_controller.rb @@ -74,8 +74,8 @@ def survey_results end def submission_count - count = if @phase.native_survey? - @phase.ideas.native_survey.published.count + count = if @phase.pmethod.supports_survey_form? + @phase.ideas.supports_survey.published.count else @phase.ideas.transitive.published.count end diff --git a/back/app/controllers/web_api/v1/projects_controller.rb b/back/app/controllers/web_api/v1/projects_controller.rb index 9c78350c00b4..e1437cf35b64 100644 --- a/back/app/controllers/web_api/v1/projects_controller.rb +++ b/back/app/controllers/web_api/v1/projects_controller.rb @@ -127,7 +127,7 @@ def index_for_areas # Returns all non-draft projects that are visible to user, for the selected topics. # Ordered by created_at, newest first. def index_for_topics - projects = policy_scope(Project) + projects = policy_scope(Project).not_hidden projects = projects .not_draft .with_some_topics(params[:topics]) @@ -317,6 +317,22 @@ def destroy_participation_data ).serializable_hash, status: :ok end + def community_monitor + settings = AppConfiguration.instance.settings + settings.dig('community_monitor', 'enabled') || raise(ActiveRecord::RecordNotFound) + + # Find the community monitor project from config or create it + project_id = settings.dig('community_monitor', 'project_id') + project = project_id.present? ? Project.find(project_id) : create_community_monitor_project(settings) + + authorize project + render json: WebApi::V1::ProjectSerializer.new( + project, + params: jsonapi_serializer_params, + include: %i[current_phase] + ).serializable_hash + end + private def sidefx @@ -375,6 +391,34 @@ def base_render_mini_index include: %i[project_images current_phase] ) end + + def create_community_monitor_project(settings) + multiloc_service = MultilocService.new + project = Project.create!( + hidden: true, + title_multiloc: multiloc_service.i18n_to_multiloc('phases.community_monitor_title'), + internal_role: 'community_monitor' + ) + sidefx.after_create(project, current_user) if project + + Phase.create!( + title_multiloc: multiloc_service.i18n_to_multiloc('phases.community_monitor_title'), + project: project, + participation_method: 'community_monitor_survey', + commenting_enabled: false, # TODO: JS - set in the participation method defaults + reacting_enabled: false, + start_at: Time.now, + campaigns_settings: { project_phase_started: true }, # TODO: JS - Is this correct? + native_survey_title_multiloc: multiloc_service.i18n_to_multiloc('phases.community_monitor_title'), + native_survey_button_multiloc: multiloc_service.i18n_to_multiloc('phases.native_survey_button') + ) + + # Set the ID in the settings + settings['community_monitor']['project_id'] = project.id + AppConfiguration.instance.update!(settings: settings) + + project + end end WebApi::V1::ProjectsController.include(AggressiveCaching::Patches::WebApi::V1::ProjectsController) diff --git a/back/app/models/idea.rb b/back/app/models/idea.rb index 894b5778c10f..ea50d86ff7a1 100644 --- a/back/app/models/idea.rb +++ b/back/app/models/idea.rb @@ -235,6 +235,9 @@ class Idea < ApplicationRecord native_survey.where(publication_status: 'draft') } + # Equivalent to pmethod.supports_survey_form? + scope :supports_survey, -> { where(creation_phase: Phase.where(participation_method: %w[native_survey community_monitor_survey])) } + # Filters out all the ideas for which the ParticipationMethod responds truety # to the given block. The block receives the ParticipationMethod object as an # argument diff --git a/back/app/models/notifications/project_phase_started.rb b/back/app/models/notifications/project_phase_started.rb index 6db1d9dc5512..1a0449becdf0 100644 --- a/back/app/models/notifications/project_phase_started.rb +++ b/back/app/models/notifications/project_phase_started.rb @@ -72,6 +72,7 @@ class ProjectPhaseStarted < Notification def self.make_notifications_on(activity) phase = activity.item + return [] unless phase.project.published? ProjectPolicy::InverseScope.new(phase.project, User.from_follows(phase.project.followers)).resolve.map do |recipient| new(recipient: recipient, phase: phase, project: phase.project) diff --git a/back/app/models/notifications/project_phase_upcoming.rb b/back/app/models/notifications/project_phase_upcoming.rb index fe4bd75e330f..9d1d266eb47a 100644 --- a/back/app/models/notifications/project_phase_upcoming.rb +++ b/back/app/models/notifications/project_phase_upcoming.rb @@ -72,18 +72,15 @@ class ProjectPhaseUpcoming < Notification def self.make_notifications_on(activity) phase = activity.item + return [] unless phase.project.published? - if phase.project - recipients = UserRoleService.new.moderators_for phase - recipients.ids.map do |recipient_id| - new( - recipient_id: recipient_id, - phase: phase, - project: phase.project - ) - end - else - [] + recipients = UserRoleService.new.moderators_for phase + recipients.ids.map do |recipient_id| + new( + recipient_id: recipient_id, + phase: phase, + project: phase.project + ) end end end diff --git a/back/app/models/permission.rb b/back/app/models/permission.rb index dd7992aba298..43a7f597ea79 100644 --- a/back/app/models/permission.rb +++ b/back/app/models/permission.rb @@ -29,6 +29,7 @@ class Permission < ApplicationRecord 'ideation' => %w[posting_idea commenting_idea reacting_idea attending_event], 'proposals' => %w[posting_idea commenting_idea reacting_idea attending_event], 'native_survey' => %w[posting_idea attending_event], + 'community_monitor_survey' => %w[posting_idea attending_event], 'survey' => %w[taking_survey attending_event], 'poll' => %w[taking_poll attending_event], 'voting' => %w[voting commenting_idea attending_event], diff --git a/back/app/models/phase.rb b/back/app/models/phase.rb index b0d0af7c4660..c825e1348a67 100644 --- a/back/app/models/phase.rb +++ b/back/app/models/phase.rb @@ -160,7 +160,6 @@ class Phase < ApplicationRecord # voting? with_options if: :voting? do validates :voting_method, presence: true, inclusion: { in: VOTING_METHODS } - validate :validate_voting validates :voting_term_singular_multiloc, multiloc: { presence: false } validates :voting_term_plural_multiloc, multiloc: { presence: false } validates :autoshare_results_enabled, inclusion: { in: [true, false] } @@ -182,12 +181,14 @@ class Phase < ApplicationRecord where(start_at: date) } - # native_survey? - with_options if: :native_survey? do + # any type of native_survey phase + with_options if: ->(phase) { phase.pmethod.supports_survey_form? } do validates :native_survey_title_multiloc, presence: true, multiloc: { presence: true } validates :native_survey_button_multiloc, presence: true, multiloc: { presence: true } end + validate :validate_phase_participation_method + scope :published, lambda { joined = includes(project: { admin_publication: :parent }) joined.where( @@ -256,11 +257,6 @@ def voting? participation_method == 'voting' end - # Used for validations (which are hard to delegate through the participation method) - def native_survey? - participation_method == 'native_survey' - end - def pmethod reload_participation_method if !@pmethod @pmethod @@ -371,6 +367,8 @@ def reload_participation_method ParticipationMethod::Proposals.new(self) when 'native_survey' ParticipationMethod::NativeSurvey.new(self) + when 'community_monitor_survey' + ParticipationMethod::CommunityMonitorSurvey.new(self) when 'document_annotation' ParticipationMethod::DocumentAnnotation.new(self) when 'survey' @@ -390,8 +388,9 @@ def set_presentation_mode self.presentation_mode ||= 'card' end - def validate_voting - Factory.instance.voting_method_for(self).validate_phase + # Delegate any rules specific to a method to the participation method itself + def validate_phase_participation_method + pmethod.validate_phase end end diff --git a/back/app/models/project.rb b/back/app/models/project.rb index d52b0070c1b4..325067df223d 100644 --- a/back/app/models/project.rb +++ b/back/app/models/project.rb @@ -23,6 +23,7 @@ # followers_count :integer default(0), not null # preview_token :string not null # header_bg_alt_text_multiloc :jsonb +# hidden :boolean default(FALSE), not null # # Indexes # @@ -88,7 +89,7 @@ class Project < ApplicationRecord after_save :reassign_moderators, if: :folder_changed? after_commit :clear_folder_changes, if: :folder_changed? - INTERNAL_ROLES = %w[open_idea_box].freeze + INTERNAL_ROLES = %w[open_idea_box community_monitor].freeze validates :title_multiloc, presence: true, multiloc: { presence: true } validates :description_multiloc, multiloc: { presence: false, html: true } @@ -97,6 +98,8 @@ class Project < ApplicationRecord validates :internal_role, inclusion: { in: INTERNAL_ROLES, allow_nil: true } validate :admin_publication_must_exist, unless: proc { Current.loading_tenant_template } # TODO: This should always be validated! + scope :not_hidden, -> { where(hidden: false) } + pg_search_scope :search_by_all, against: %i[title_multiloc description_multiloc description_preview_multiloc slug], using: { tsearch: { prefix: true } } @@ -142,7 +145,7 @@ class Project < ApplicationRecord alias project_id id - delegate :ever_published?, :never_published?, to: :admin_publication, allow_nil: true + delegate :published?, :ever_published?, :never_published?, to: :admin_publication, allow_nil: true class << self def search_ids_by_all_including_patches(term) @@ -223,6 +226,10 @@ def refresh_preview_token self.preview_token = self.class.generate_preview_token end + def hidden? + hidden + end + private def admin_publication_must_exist diff --git a/back/app/policies/admin_publication_policy.rb b/back/app/policies/admin_publication_policy.rb index 988b8b386317..60803f9bda8b 100644 --- a/back/app/policies/admin_publication_policy.rb +++ b/back/app/policies/admin_publication_policy.rb @@ -5,7 +5,7 @@ class Scope < ApplicationPolicy::Scope def resolve AdminPublication .publication_types - .map { |klass| scope.where(publication: scope_for(klass)) } # scope per publication type + .map { |klass| scope.where(publication: klass == Project ? scope_for(klass).not_hidden : scope_for(klass)) } # scope per publication type .reduce(&:or) # joining partial scopes end end diff --git a/back/app/policies/project_policy.rb b/back/app/policies/project_policy.rb index c9e43fa10d5f..72f6ac2aa92d 100644 --- a/back/app/policies/project_policy.rb +++ b/back/app/policies/project_policy.rb @@ -185,6 +185,10 @@ def active_moderator? UserRoleService.new.can_moderate_project? record, user end + def community_monitor? + active_moderator? + end + private def update_status? diff --git a/back/app/serializers/web_api/v1/phase_serializer.rb b/back/app/serializers/web_api/v1/phase_serializer.rb index 3ec35518504c..1fc5acbee808 100644 --- a/back/app/serializers/web_api/v1/phase_serializer.rb +++ b/back/app/serializers/web_api/v1/phase_serializer.rb @@ -69,6 +69,10 @@ class WebApi::V1::PhaseSerializer < WebApi::V1::BaseSerializer object.custom_form_persisted? end + attribute :supports_survey_form do |phase| + phase.pmethod.supports_survey_form? + end + belongs_to :project has_one :user_basket, if: proc { |object, params| diff --git a/back/app/services/idea_custom_fields_service.rb b/back/app/services/idea_custom_fields_service.rb index 5e8732bcdcec..c1739ceaa8a0 100644 --- a/back/app/services/idea_custom_fields_service.rb +++ b/back/app/services/idea_custom_fields_service.rb @@ -104,12 +104,19 @@ def remove_ignored_update_params(field_params) def check_form_structure(fields, errors) return if fields.empty? + # Check the first field is of the correct type first_field_type = @participation_method.supports_pages_in_form? ? 'page' : 'section' cannot_have_type = @participation_method.supports_pages_in_form? ? 'section' : 'page' if fields[0][:input_type] != first_field_type error = { error: "First field must be of type '#{first_field_type}'" } errors['0'] = { structure: [error] } end + + # Check the last field is a page + if @participation_method.supports_pages_in_form? && fields.last[:input_type] != 'page' + errors[(fields.length - 1).to_s] = { structure: [{ error: "Last field must be of type 'page'" }] } + end + fields.each_with_index do |field, index| next unless field[:input_type] == cannot_have_type diff --git a/back/app/services/project_copy_service.rb b/back/app/services/project_copy_service.rb index d9b360a70ef9..68a264672d87 100644 --- a/back/app/services/project_copy_service.rb +++ b/back/app/services/project_copy_service.rb @@ -242,7 +242,8 @@ def yml_projects(shift_timestamps: 0, new_slug: nil, new_title_multiloc: nil, ne 'updated_at' => ti.updated_at.to_s } end, - 'include_all_areas' => @project.include_all_areas + 'include_all_areas' => @project.include_all_areas, + 'hidden' => @project.hidden } yml_project['slug'] = new_slug if new_slug.present? store_ref yml_project, @project.id, :project @@ -335,7 +336,7 @@ def yml_phases(shift_timestamps: 0, timeline_start_at: nil) yml_phase['document_annotation_embed_url'] = phase.document_annotation_embed_url end - if yml_phase['participation_method'] == 'native_survey' + if phase.pmethod.supports_survey_form? yml_phase['native_survey_title_multiloc'] = phase.native_survey_title_multiloc yml_phase['native_survey_button_multiloc'] = phase.native_survey_button_multiloc end diff --git a/back/app/services/projects_finder_service.rb b/back/app/services/projects_finder_service.rb index 146a69ee50d1..ec4fa3f97005 100644 --- a/back/app/services/projects_finder_service.rb +++ b/back/app/services/projects_finder_service.rb @@ -1,6 +1,6 @@ class ProjectsFinderService def initialize(projects, user = nil, params = {}) - @projects = projects + @projects = projects.not_hidden @user = user @page_size = (params.dig(:page, :size) || 500).to_i @page_number = (params.dig(:page, :number) || 1).to_i @@ -22,7 +22,7 @@ def participation_possible subquery = projects_with_active_phase(subquery) .joins('INNER JOIN phases AS active_phases ON active_phases.project_id = projects.id') .where.not(phases: { participation_method: 'information' }) - .select('projects.*, projects.created_at AS projects_created_at, projects.id AS projects_id') + .select('projects.created_at AS projects_created_at, projects.id AS projects_id') # Perform the SELECT DISTINCT on the outer query and order first by the end date of the active phase, # second by project created_at, and third by project ID. diff --git a/back/app/services/side_fx_idea_service.rb b/back/app/services/side_fx_idea_service.rb index 9eabff6e9e05..2d5a5ba0b7fc 100644 --- a/back/app/services/side_fx_idea_service.rb +++ b/back/app/services/side_fx_idea_service.rb @@ -166,13 +166,19 @@ def scrape_facebook(idea) end def create_followers(idea, user) - Follower.find_or_create_by(followable: idea, user: user) + return if idea.project.hidden? + + Follower.find_or_create_by(followable: idea, user: user) if create_idea_follower?(idea) Follower.find_or_create_by(followable: idea.project, user: user) return if !idea.project.in_folder? Follower.find_or_create_by(followable: idea.project.folder, user: user) end + def create_idea_follower?(idea) + idea.creation_phase ? idea.creation_phase.pmethod.follow_idea_on_idea_submission? : true # Defaults for true for ideas without a creation_phase + end + def serialize_idea(frozen_idea) serialized_idea = clean_time_attributes(frozen_idea.attributes) serialized_idea['location_point'] = serialized_idea['location_point'].to_s diff --git a/back/app/services/survey_results_generator_service.rb b/back/app/services/survey_results_generator_service.rb index fb2c3d42bc87..b5ae16229405 100644 --- a/back/app/services/survey_results_generator_service.rb +++ b/back/app/services/survey_results_generator_service.rb @@ -7,7 +7,7 @@ def initialize(phase, group_mode: nil, group_field_id: nil) @group_field_id = group_field_id form = phase.custom_form || CustomForm.new(participation_context: phase) @fields = IdeaCustomFieldsService.new(form).enabled_fields - @inputs = phase.ideas.native_survey.published + @inputs = phase.ideas.supports_survey.published @locales = AppConfiguration.instance.settings('core', 'locales') end diff --git a/back/config/locales/en.yml b/back/config/locales/en.yml index a9dccf5f1cbc..6a8c530e53d2 100644 --- a/back/config/locales/en.yml +++ b/back/config/locales/en.yml @@ -126,6 +126,7 @@ en: open_idea_phase_title: Current phase native_survey_title: Survey native_survey_button: Take the survey + community_monitor_title: Community monitor events: council_meeting_title: Council Meeting council_meeting_description: > diff --git a/back/config/routes.rb b/back/config/routes.rb index f1f82d7290c0..5034f531a0c3 100644 --- a/back/config/routes.rb +++ b/back/config/routes.rb @@ -192,6 +192,7 @@ get 'finished_or_archived', action: 'index_finished_or_archived' get 'for_followed_item', action: 'index_for_followed_item' get 'with_active_participatory_phase', action: 'index_with_active_participatory_phase' + get 'community_monitor', action: 'community_monitor' end resource :review, controller: 'project_reviews' diff --git a/back/config/schemas/settings.schema.json.erb b/back/config/schemas/settings.schema.json.erb index 32ea403ff95a..71454adc6134 100644 --- a/back/config/schemas/settings.schema.json.erb +++ b/back/config/schemas/settings.schema.json.erb @@ -1372,6 +1372,32 @@ } } } + }, + + "community_monitor": { + "type": "object", + "title": "Community Monitor", + "description": "Long running public survey.", + "additionalProperties": false, + "required": [ + "allowed", + "enabled" + ], + "properties": { + "allowed": { + "type": "boolean", + "default": false + }, + "enabled": { + "type": "boolean", + "default": false + }, + "project_id": { + "title": "The ID of the required hidden project", + "description": "Do not edit unless you know what you're doing. This is usually set by the system", + "type": "string" + } + } } }, "dependencies": { diff --git a/back/db/migrate/20250219104523_add_hidden_to_projects.rb b/back/db/migrate/20250219104523_add_hidden_to_projects.rb new file mode 100644 index 000000000000..50d8392beeee --- /dev/null +++ b/back/db/migrate/20250219104523_add_hidden_to_projects.rb @@ -0,0 +1,5 @@ +class AddHiddenToProjects < ActiveRecord::Migration[7.0] + def change + add_column :projects, :hidden, :boolean, default: false, null: false + end +end diff --git a/back/db/structure.sql b/back/db/structure.sql index 3e1e850c9e00..31de48406c1c 100644 --- a/back/db/structure.sql +++ b/back/db/structure.sql @@ -1184,7 +1184,8 @@ CREATE TABLE public.projects ( votes_count integer DEFAULT 0 NOT NULL, followers_count integer DEFAULT 0 NOT NULL, preview_token character varying NOT NULL, - header_bg_alt_text_multiloc jsonb DEFAULT '{}'::jsonb + header_bg_alt_text_multiloc jsonb DEFAULT '{}'::jsonb, + hidden boolean DEFAULT false NOT NULL ); @@ -6827,6 +6828,7 @@ SET search_path TO public,shared_extensions; INSERT INTO "schema_migrations" (version) VALUES ('20250224150953'), +('20250219104523'), ('20250204143605'), ('20250120125531'), ('20250117121004'), diff --git a/back/engines/commercial/idea_assignment/app/services/idea_assignment/idea_assignment_service.rb b/back/engines/commercial/idea_assignment/app/services/idea_assignment/idea_assignment_service.rb index ce7e4da14497..f4ae6817ceac 100644 --- a/back/engines/commercial/idea_assignment/app/services/idea_assignment/idea_assignment_service.rb +++ b/back/engines/commercial/idea_assignment/app/services/idea_assignment/idea_assignment_service.rb @@ -25,7 +25,7 @@ def clean_assignees_for_project!(project) end def automatically_assigned_idea_assignee(idea) - return if idea.participation_method_on_creation.instance_of?(ParticipationMethod::NativeSurvey) + return unless idea.participation_method_on_creation.automatically_assign_idea? idea&.project&.default_assignee end diff --git a/back/engines/commercial/idea_assignment/app/services/idea_assignment/patches/side_fx_idea_service.rb b/back/engines/commercial/idea_assignment/app/services/idea_assignment/patches/side_fx_idea_service.rb index a3b8847aedfc..8c72cce9e731 100644 --- a/back/engines/commercial/idea_assignment/app/services/idea_assignment/patches/side_fx_idea_service.rb +++ b/back/engines/commercial/idea_assignment/app/services/idea_assignment/patches/side_fx_idea_service.rb @@ -33,7 +33,7 @@ def before_publish_or_submit(idea, user) # If a survey is opened in multiple tabs then different draft responses can be created for the same user. # We need to remove any duplicates when the survey is submitted. def remove_duplicate_survey_responses_on_publish(idea) - return unless idea.creation_phase&.native_survey? && idea.publication_status_previously_changed?(from: 'draft', to: 'published') + return unless idea.participation_method_on_creation&.supports_survey_form? && idea.publication_status_previously_changed?(from: 'draft', to: 'published') Idea.where( creation_phase_id: idea.creation_phase_id, diff --git a/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_community_monitor_survey_spec.rb b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_community_monitor_survey_spec.rb new file mode 100644 index 000000000000..d493fce812d0 --- /dev/null +++ b/back/engines/commercial/idea_custom_fields/spec/acceptance/idea_custom_fields/phase_context/update_all_community_monitor_survey_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'rspec_api_documentation/dsl' + +resource 'Idea Custom Fields' do + explanation 'Fields in idea forms which are customized by the city, scoped on the project level.' + before { header 'Content-Type', 'application/json' } + + patch 'web_api/v1/admin/phases/:phase_id/custom_fields/update_all' do + parameter :custom_fields, type: :array + with_options scope: 'custom_fields[]' do + parameter :id, 'The ID of an existing custom field to update. When the ID is not provided, a new field is created.', required: false + parameter :input_type, 'The type of the input. Required when creating a new field.', required: false + parameter :required, 'Whether filling out the field is mandatory', required: false + parameter :enabled, 'Whether the field is active or not', required: false + parameter :title_multiloc, 'A title of the field, as shown to users, in multiple locales', required: false + parameter :description_multiloc, 'An optional description of the field, as shown to users, in multiple locales', required: false + parameter :options, type: :array + end + with_options scope: 'options[]' do + parameter :id, 'The ID of an existing custom field option to update. When the ID is not provided, a new option is created.', required: false + parameter :title_multiloc, 'A title of the option, as shown to users, in multiple locales', required: false + parameter :image_id, 'If the option has an image, the ID of the image', required: false + end + + let(:first_page) do + { + id: '1234', + key: 'page1', + title_multiloc: { 'en' => 'First page' }, + input_type: 'page', + page_layout: 'default' + } + end + + let(:last_page) do + { + id: '1234', + key: 'survey_end', + title_multiloc: { 'en' => 'Final page' }, + description_multiloc: { 'en' => 'Thank you for participating!' }, + input_type: 'page', + page_layout: 'default' + } + end + + let(:context) { create(:community_monitor_survey_phase) } + let!(:custom_form) { create(:custom_form, participation_context: context) } + let(:phase_id) { context.id } + + context 'when admin' do + before { admin_header_token } + + example 'Insert one field, update one field, and destroy one field' do + field_to_update = create(:custom_field_rating, resource: custom_form, title_multiloc: { 'en' => 'Rating field' }) + create(:custom_field, resource: custom_form) # field to destroy + request = { + custom_fields: [ + first_page, + # Updated field + { + id: field_to_update.id, + title_multiloc: { 'en' => 'Updated rating field' }, + description_multiloc: { 'en' => 'Updated description' }, + required: true, + enabled: true, + maximum: 7 + }, + # Inserted field first to test reordering of fields. + { + input_type: 'text', + title_multiloc: { 'en' => 'Inserted field' }, + required: false, + enabled: false + }, + last_page + ] + } + do_request request + + assert_status 200 + expect(response_data.size).to eq 4 + expect(response_data[1]).to match({ + attributes: { + code: nil, + created_at: an_instance_of(String), + description_multiloc: { en: 'Updated description' }, + enabled: true, + input_type: 'rating', + key: field_to_update.key, + ordering: 1, + required: true, + title_multiloc: { en: 'Updated rating field' }, + updated_at: an_instance_of(String), + logic: {}, + constraints: {}, + random_option_ordering: false, + maximum: 7 + }, + id: an_instance_of(String), + type: 'custom_field', + relationships: { options: { data: [] }, resource: { data: { id: custom_form.id, type: 'custom_form' } } } + }) + expect(response_data[2]).to match({ + attributes: { + code: nil, + created_at: an_instance_of(String), + description_multiloc: {}, + enabled: false, + input_type: 'text', + key: Regexp.new('inserted_field'), + ordering: 2, + required: false, + title_multiloc: { en: 'Inserted field' }, + updated_at: an_instance_of(String), + logic: {}, + constraints: {}, + random_option_ordering: false + }, + id: an_instance_of(String), + type: 'custom_field', + relationships: { options: { data: [] }, resource: { data: { id: custom_form.id, type: 'custom_form' } } } + }) + end + + context 'Errors' do + example 'Unsupported field types' do + request = { + custom_fields: [ + first_page, + { input_type: 'multiselect_image', title_multiloc: { en: 'Not allowed' } }, + { input_type: 'html_multiloc', title_multiloc: { en: 'Not allowed' } }, + last_page + ] + } + do_request request + assert_status 422 + expect(json_response_body.dig(:errors, :'1')).to eq( + { input_type: [{ error: 'inclusion', value: 'multiselect_image' }] } + ) + expect(json_response_body.dig(:errors, :'2')).to eq( + { input_type: [{ error: 'inclusion', value: 'html_multiloc' }] } + ) + end + + example 'First page & last page must be present' do + request = { + custom_fields: [ + { input_type: 'text', title_multiloc: { en: 'Text field' } }, + { input_type: 'text', title_multiloc: { en: 'Text field' } } + ] + } + do_request request + assert_status 422 + expect(json_response_body.dig(:errors, :'0')).to eq( + { structure: [{ error: "First field must be of type 'page'" }] } + ) + expect(json_response_body.dig(:errors, :'1')).to eq( + { structure: [{ error: "Last field must be of type 'page'" }] } + ) + end + end + end + end +end diff --git a/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/phase.rb b/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/phase.rb index da928ca24592..2c31734a6136 100644 --- a/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/phase.rb +++ b/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/phase.rb @@ -43,8 +43,8 @@ class Phase < Base attribute(:survey_embed_url, if: :survey?) attribute(:survey_service, if: :survey?) attribute(:document_annotation_embed_url, if: :document_annotation?) - attribute(:native_survey_title_multiloc, if: :native_survey?) - attribute(:native_survey_button_multiloc, if: :native_survey?) + attribute(:native_survey_title_multiloc, if: proc { |phase| phase.pmethod.supports_survey_form? }) + attribute(:native_survey_button_multiloc, if: proc { |phase| phase.pmethod.supports_survey_form? }) end end end diff --git a/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/project.rb b/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/project.rb index 1ace81ba1fcb..9c5ed481ccaa 100644 --- a/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/project.rb +++ b/back/engines/commercial/multi_tenancy/app/services/multi_tenancy/templates/serializers/project.rb @@ -13,6 +13,7 @@ class Project < Base internal_role title_multiloc visible_to + hidden ] end end diff --git a/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb b/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb index 7b3db94e9b2a..691a97153f97 100644 --- a/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb +++ b/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb @@ -479,6 +479,11 @@ def create_localhost_tenant project_library: { enabled: false, allowed: false + }, + community_monitor: { + enabled: true, + allowed: true, + project_id: '' } }) ) diff --git a/back/engines/commercial/public_api/app/serializers/public_api/v2/phase_serializer.rb b/back/engines/commercial/public_api/app/serializers/public_api/v2/phase_serializer.rb index 3db3927232d7..3f5d3f7fee24 100644 --- a/back/engines/commercial/public_api/app/serializers/public_api/v2/phase_serializer.rb +++ b/back/engines/commercial/public_api/app/serializers/public_api/v2/phase_serializer.rb @@ -23,6 +23,7 @@ class PublicApi::V2::PhaseSerializer < PublicApi::V2::BaseSerializer :reacting_dislike_enabled, :reacting_dislike_method, :reacting_dislike_limited_max, + :voting_method, :voting_max_total, :voting_min_total diff --git a/back/lib/participation_method/base.rb b/back/lib/participation_method/base.rb index 28d5e8d0fe09..6129641daf62 100644 --- a/back/lib/participation_method/base.rb +++ b/back/lib/participation_method/base.rb @@ -3,7 +3,7 @@ module ParticipationMethod class Base def self.all_methods - [DocumentAnnotation, Ideation, Information, NativeSurvey, Poll, Proposals, Survey, Volunteering, Voting] + [DocumentAnnotation, Ideation, Information, NativeSurvey, CommunityMonitorSurvey, Poll, Proposals, Survey, Volunteering, Voting] end def initialize(phase) @@ -51,6 +51,10 @@ def custom_form context.custom_form || CustomForm.new(participation_context: context) end + def form_logic_enabled? + false + end + def default_fields(_custom_form) [] end @@ -162,6 +166,18 @@ def use_reactions_as_votes? false end + def follow_idea_on_idea_submission? + false + end + + def validate_phase + # Default is to do nothing. + end + + def automatically_assign_idea? + false + end + private attr_reader :phase diff --git a/back/lib/participation_method/community_monitor_survey.rb b/back/lib/participation_method/community_monitor_survey.rb new file mode 100644 index 000000000000..4d73e47881ca --- /dev/null +++ b/back/lib/participation_method/community_monitor_survey.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module ParticipationMethod + class CommunityMonitorSurvey < NativeSurvey + def self.method_str + 'community_monitor_survey' + end + + def allowed_extra_field_input_types + %w[page text linear_scale rating select multiselect] + end + + # TODO: Fields are currently placeholders + def default_fields(custom_form) + return [] if custom_form.persisted? + + multiloc_service = MultilocService.new + [ + start_page_field(custom_form), + CustomField.new( + id: SecureRandom.uuid, + key: 'cm_living_in_city', + resource: custom_form, + input_type: 'rating', + maximum: 5, + title_multiloc: { 'en' => 'How do you rate living in our city?' } + ), + CustomField.new( + id: SecureRandom.uuid, + key: 'cm_council_services', + resource: custom_form, + input_type: 'rating', + maximum: 5, + title_multiloc: { 'en' => 'How do you rate the quality of council services?' } + ), + end_page_field(custom_form, multiloc_service) + ] + end + + def constraints + {} # TODO: Any constraints to be added once we know what the fields are + end + + def form_logic_enabled? + false + end + + def validate_phase + if phase.project.phases.count > 1 + phase.errors.add(:base, :too_many_phases, message: 'community_monitor project can only have one phase') + end + + unless phase.project.hidden? + phase.errors.add(:base, :project_not_hidden, message: 'community_monitor projects must be hidden') + end + + if phase.end_at.present? + phase.errors.add(:base, :has_end_at, message: 'community_monitor projects cannot have an end date') + end + end + end +end diff --git a/back/lib/participation_method/ideation.rb b/back/lib/participation_method/ideation.rb index cea83a0403d8..7957f795bbed 100644 --- a/back/lib/participation_method/ideation.rb +++ b/back/lib/participation_method/ideation.rb @@ -396,6 +396,14 @@ def transitive? true end + def follow_idea_on_idea_submission? + true + end + + def automatically_assign_idea? + true + end + private def proposed_budget_in_form? diff --git a/back/lib/participation_method/native_survey.rb b/back/lib/participation_method/native_survey.rb index 099a7b4e0e85..f04b9ba8194a 100644 --- a/back/lib/participation_method/native_survey.rb +++ b/back/lib/participation_method/native_survey.rb @@ -41,13 +41,7 @@ def default_fields(custom_form) multiloc_service = MultilocService.new [ - CustomField.new( - id: SecureRandom.uuid, - key: 'page1', - resource: custom_form, - input_type: 'page', - page_layout: 'default' - ), + start_page_field(custom_form), CustomField.new( id: SecureRandom.uuid, key: CustomFieldService.new.generate_key( @@ -69,18 +63,14 @@ def default_fields(custom_form) ) ] ), - CustomField.new( - id: SecureRandom.uuid, - key: 'survey_end', - resource: custom_form, - input_type: 'page', - page_layout: 'default', - title_multiloc: multiloc_service.i18n_to_multiloc('form_builder.form_end_page.title_text_3'), - description_multiloc: multiloc_service.i18n_to_multiloc('form_builder.form_end_page.description_text_3') - ) + end_page_field(custom_form, multiloc_service) ] end + def form_logic_enabled? + true + end + # Survey responses do not have a fixed field that can be used # to generate a slug, so use the id as the basis for the slug. def generate_slug(input) @@ -132,5 +122,29 @@ def supports_survey_form? def supports_toxicity_detection? false end + + private + + def start_page_field(custom_form) + CustomField.new( + id: SecureRandom.uuid, + key: 'page1', + resource: custom_form, + input_type: 'page', + page_layout: 'default' + ) + end + + def end_page_field(custom_form, multiloc_service) + CustomField.new( + id: SecureRandom.uuid, + key: 'survey_end', + resource: custom_form, + input_type: 'page', + page_layout: 'default', + title_multiloc: multiloc_service.i18n_to_multiloc('form_builder.form_end_page.title_text_3'), + description_multiloc: multiloc_service.i18n_to_multiloc('form_builder.form_end_page.description_text_3') + ) + end end end diff --git a/back/lib/participation_method/voting.rb b/back/lib/participation_method/voting.rb index a29cafb03fbe..6b9ee5b31159 100644 --- a/back/lib/participation_method/voting.rb +++ b/back/lib/participation_method/voting.rb @@ -2,7 +2,7 @@ module ParticipationMethod class Voting < Ideation - delegate :additional_export_columns, :supports_serializing?, to: :voting_method + delegate :additional_export_columns, :supports_serializing?, :validate_phase, to: :voting_method def self.method_str 'voting' diff --git a/back/spec/acceptance/admin_publications_spec.rb b/back/spec/acceptance/admin_publications_spec.rb index fa2fd1d271bf..b1384d0db50e 100644 --- a/back/spec/acceptance/admin_publications_spec.rb +++ b/back/spec/acceptance/admin_publications_spec.rb @@ -49,51 +49,47 @@ parameter :review_state, 'Filter by project review status (pending, approved)', required: false example_request 'List all admin publications' do + hidden_project = create(:community_monitor_project) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 10 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 8 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 2 + expect(response_data.size).to eq 10 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 8 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 2 + expect(response_data.pluck(:id)).not_to include(hidden_project.admin_publication.id) end example 'List all top-level admin publications' do do_request(depth: 0) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 7 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 5 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 2 + expect(response_data.size).to eq 7 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 5 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 2 end example 'List all admin publications in a folder' do do_request(folder: custom_folder.id) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 3 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 0 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 3 + expect(response_data.size).to eq 3 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 0 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 3 end example 'List all draft or archived admin publications' do do_request(publication_statuses: %w[draft archived]) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 5 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :id) }).to match_array [empty_draft_folder.id, projects[2].id, projects[3].id, projects[5].id, projects[6].id] - expect(json_response[:data].find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 0 + expect(response_data.size).to eq 5 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :id) }).to match_array [empty_draft_folder.id, projects[2].id, projects[3].id, projects[5].id, projects[6].id] + expect(response_data.find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 0 end example_request 'List projects only' do do_request(only_projects: 'true') expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 8 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 8 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 0 + expect(response_data.size).to eq 8 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 8 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 0 end example 'List publications admin can moderate', document: false do do_request filter_can_moderate: true - json_response = json_parse(response_body) assert_status 200 - expect(json_response[:data].size).to eq 10 + expect(response_data.size).to eq 10 end example 'List publications a specific user can moderate', document: false do @@ -261,16 +257,16 @@ example 'List all root-level admin publications is ordered correctly', document: false do do_request(depth: 0) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].map { |d| d.dig(:attributes, :publication_title_multiloc, :en) }) + + expect(response_data.map { |d| d.dig(:attributes, :publication_title_multiloc, :en) }) .to eq(%w[P1 F1 P2 F2 P6 P7 P8]) end example 'List only project publications maintains a flattened nested ordering', document: false do do_request(only_projects: 'true') expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].map { |d| d.dig(:attributes, :publication_title_multiloc, :en) }) + + expect(response_data.map { |d| d.dig(:attributes, :publication_title_multiloc, :en) }) .to eq(%w[P1 P2 P3-f2 P4-f2 P5-f2 P6 P7 P8]) end end @@ -296,9 +292,8 @@ ]) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].pluck(:id)) + expect(response_data.pluck(:id)) .to eq [non_draft_ids[3], non_draft_ids[0], non_draft_ids[1], non_draft_ids[4]] end @@ -316,9 +311,7 @@ ) expect(status).to eq(200) - json_response = json_parse(response_body) - - expect(json_response[:data].pluck(:id)).to eq [non_draft_ids[4], non_draft_ids[2]] + expect(response_data.pluck(:id)).to eq [non_draft_ids[4], non_draft_ids[2]] end example 'Does not include draft admin_publications', document: false do @@ -332,9 +325,7 @@ ]) expect(status).to eq(200) - json_response = json_parse(response_body) - - expect(json_response[:data].pluck(:id)) + expect(response_data.pluck(:id)) .to eq [non_draft_ids[3], non_draft_ids[0], non_draft_ids[1], non_draft_ids[4]] end @@ -342,9 +333,7 @@ do_request(ids: ['not_an_admin_publication_id']) expect(status).to eq(200) - json_response = json_parse(response_body) - - expect(json_response[:data]).to be_empty + expect(response_data).to be_empty end end @@ -370,9 +359,8 @@ old_second_publication = AdminPublication.find_by(ordering: ordering) do_request expect(response_status).to eq 200 - json_response = json_parse(response_body) - expect(json_response.dig(:data, :attributes, :ordering)).to eq ordering - expect(json_response.dig(:data, :id)).to eq id + expect(response_data.dig(:attributes, :ordering)).to eq ordering + expect(response_data[:id]).to eq id expect(AdminPublication.find(id).ordering).to eq(ordering) expect(old_second_publication.reload.ordering).to eq 2 # previous second is now third @@ -384,12 +372,10 @@ example_request 'Get one admin publication by id' do expect(status).to eq 200 - json_response = json_parse(response_body) - - expect(json_response.dig(:data, :id)).to eq projects.first.admin_publication.id - expect(json_response.dig(:data, :relationships, :publication, :data, :type)).to eq 'project' - expect(json_response.dig(:data, :relationships, :publication, :data, :id)).to eq projects.first.id - expect(json_response.dig(:data, :attributes, :publication_slug)).to eq projects.first.slug + expect(response_data[:id]).to eq projects.first.admin_publication.id + expect(response_data.dig(:relationships, :publication, :data, :type)).to eq 'project' + expect(response_data.dig(:relationships, :publication, :data, :id)).to eq projects.first.id + expect(response_data.dig(:attributes, :publication_slug)).to eq projects.first.slug end end @@ -397,13 +383,9 @@ example 'Get publication_status counts for top-level admin publications' do do_request(depth: 0) expect(status).to eq 200 - - json_response = json_parse(response_body) - - expect(json_response[:data][:attributes][:status_counts][:draft]).to eq 2 - expect(json_response[:data][:attributes][:status_counts][:archived]).to eq 2 - - expect(json_response[:data][:attributes][:status_counts][:published]).to eq 3 + expect(response_data[:attributes][:status_counts][:draft]).to eq 2 + expect(response_data[:attributes][:status_counts][:archived]).to eq 2 + expect(response_data[:attributes][:status_counts][:published]).to eq 3 end end end @@ -431,27 +413,25 @@ example 'Listed admin publications have correct visible children count', document: false do do_request(folder: nil, remove_not_allowed_parents: true) expect(status).to eq(200) - json_response = json_parse(response_body) # Only 3 of initial 6 projects are not in folder - expect(json_response[:data].size).to eq 3 + expect(response_data.size).to eq 3 # Only 1 folder expected - Draft folder created at top of file is not visible to resident, # nor should a folder with only a draft project in it - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 1 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 1 # 3 projects are inside folder, 3 top-level projects remain, of which 1 is not visible (draft) - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 2 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 2 # Only the two non-draft projects are visible to resident - expect(json_response[:data].find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 2 + expect(response_data.find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 2 end example 'Visible children count should take account of applied filters', document: false do projects.first.admin_publication.update! publication_status: 'archived' do_request(folder: nil, publication_statuses: ['published'], remove_not_allowed_parents: true) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 2 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 1 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 1 - expect(json_response[:data].find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 1 + expect(response_data.size).to eq 2 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('folder')).to eq 1 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :type) }.count('project')).to eq 1 + expect(response_data.find { |d| d.dig(:relationships, :publication, :data, :type) == 'folder' }.dig(:attributes, :visible_children_count)).to eq 1 end context 'search param' do @@ -595,7 +575,6 @@ build(:layout, craftjs_json: { sometext: { props: { text: { en: 'othertext' } } } }) ]) do_request search: 'sometext' - expect(response_data.size).to eq 1 expect(response_ids).to contain_exactly(project.admin_publication.id) end @@ -605,8 +584,7 @@ AdminPublication.publication_types.each { |claz| claz.all.each(&:destroy!) } do_request(publication_statuses: ['published']) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].size).to eq 0 + expect(response_data.size).to eq 0 end end @@ -625,8 +603,7 @@ do_request(ids: [folder_with_children.admin_publication.id]) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data].first.dig(:attributes, :visible_children_count)).to eq 1 + expect(response_data.first.dig(:attributes, :visible_children_count)).to eq 1 end example 'Does not includes folders containing only non-visible children', document: false do @@ -637,8 +614,7 @@ do_request(ids: [folder_with_non_visible_children.admin_publication.id]) expect(status).to eq(200) - json_response = json_parse(response_body) - expect(json_response[:data]).to be_empty + expect(response_data).to be_empty end end end @@ -651,12 +627,9 @@ example 'Get publication_status counts for top-level admin publications' do do_request(depth: 0) expect(status).to eq 200 - - json_response = json_parse(response_body) - expect(json_response[:data][:attributes][:status_counts][:draft]).to be_nil - expect(json_response[:data][:attributes][:status_counts][:published]).to eq 2 - - expect(json_response[:data][:attributes][:status_counts][:archived]).to eq 1 + expect(response_data[:attributes][:status_counts][:draft]).to be_nil + expect(response_data[:attributes][:status_counts][:published]).to eq 2 + expect(response_data[:attributes][:status_counts][:archived]).to eq 1 end end end @@ -686,10 +659,10 @@ example 'List only the projects the current user is moderator of' do do_request(filter_is_moderator_of: true, only_projects: true) - json_response = json_parse(response_body) + assert_status 200 - expect(json_response[:data].size).to eq 2 - expect(json_response[:data].map { |d| d.dig(:relationships, :publication, :data, :id) }) + expect(response_data.size).to eq 2 + expect(response_data.map { |d| d.dig(:relationships, :publication, :data, :id) }) .to match_array [published_projects[0].id, published_projects[1].id] end @@ -789,7 +762,7 @@ expect(second_publication.ordering).to eq second_publication_ordering do_request - new_ordering = json_parse(response_body).dig(:data, :attributes, :ordering) + new_ordering = response_data.dig(:attributes, :ordering) expect(response_status).to eq 200 expect(new_ordering).to eq second_publication_ordering @@ -816,18 +789,17 @@ do_request include_publications: 'true' expect(status).to eq(200) - json_response = json_parse(response_body) - relationships_data = json_response[:data].map { |d| d.dig(:relationships, :publication, :data) } + relationships_data = response_data.map { |d| d.dig(:relationships, :publication, :data) } related_project_ids = relationships_data.select { |d| d[:type] == 'project' }.pluck(:id) related_folder_ids = relationships_data.select { |d| d[:type] == 'folder' }.pluck(:id) - included_projects = json_response[:included].select { |d| d[:type] == 'project' } - included_folder_ids = json_response[:included].select { |d| d[:type] == 'folder' }.pluck(:id) - included_phase_ids = json_response[:included].select { |d| d[:type] == 'phase' }.pluck(:id) - included_avatar_ids = json_response[:included].select { |d| d[:type] == 'avatar' }.pluck(:id) - included_image_ids = json_response[:included].select { |d| d[:type] == 'image' }.pluck(:id) + included_projects = json_response_body[:included].select { |d| d[:type] == 'project' } + included_folder_ids = json_response_body[:included].select { |d| d[:type] == 'folder' }.pluck(:id) + included_phase_ids = json_response_body[:included].select { |d| d[:type] == 'phase' }.pluck(:id) + included_avatar_ids = json_response_body[:included].select { |d| d[:type] == 'avatar' }.pluck(:id) + included_image_ids = json_response_body[:included].select { |d| d[:type] == 'image' }.pluck(:id) current_phase_ids = included_projects.filter_map { |d| d.dig(:relationships, :current_phase, :data, :id) } avatar_ids = included_projects.map { |d| d.dig(:relationships, :avatars, :data) }.flatten.pluck(:id) diff --git a/back/spec/acceptance/ideas_create_spec.rb b/back/spec/acceptance/ideas_create_spec.rb index d3efd5e4985d..e988d8ef9e98 100644 --- a/back/spec/acceptance/ideas_create_spec.rb +++ b/back/spec/acceptance/ideas_create_spec.rb @@ -544,6 +544,74 @@ def public_input_params(spec) end end end + + context 'in a community monitor survey phase' do + let(:project) { create(:community_monitor_project, default_assignee_id: create(:admin).id) } + let(:phase) { create(:community_monitor_survey_phase, project: project, with_permissions: true) } + + let(:extra_field_name) { 'custom_field_name1' } + let(:form) { create(:custom_form, participation_context: phase) } + let!(:text_field) { create(:custom_field_text, key: extra_field_name, required: true, resource: form) } + let(:custom_field_name1) { 'test value' } + + context "when visitor (permission is 'everyone')" do + before { phase.permissions.find_by(action: 'posting_idea').update! permitted_by: 'everyone' } + + example_request 'Create a community monitor survey response without author' do + assert_status 201 + idea_from_db = Idea.find(response_data[:id]) + expect(idea_from_db.author_id).to be_nil + expect(idea_from_db.custom_field_values.to_h).to eq({ + extra_field_name => 'test value' + }) + end + end + + context 'when resident' do + let(:resident) { create(:user) } + + before { header_token_for(resident) } + + example_request 'does not assign anyone to the created idea', document: false do + assert_status 201 + idea = Idea.find(response_data[:id]) + expect(idea.assignee_id).to be_nil + expect(idea.assigned_at).to be_nil + end + + context 'creating a draft community monitor survey response' do + let(:publication_status) { 'draft' } + + example_request 'sets the publication status to draft' do + assert_status 201 + idea = Idea.find(response_data[:id]) + expect(idea.publication_status).to eq 'draft' + end + end + + context 'Creating a community monitor survey response when posting anonymously is enabled' do + before { phase.update! allow_anonymous_participation: true } + + example_request 'Posting a survey automatically sets anonymous to true' do + assert_status 201 + expect(response_data.dig(:attributes, :anonymous)).to be true + expect(response_data.dig(:attributes, :author_name)).to be_nil + expect(response_data.dig(:relationships, :author, :data)).to be_nil + end + end + + context 'Creating a community monitor survey response when posting anonymously is not enabled' do + before { phase.update! allow_anonymous_participation: false } + + example_request 'Posting a survey does not set the survey to anonymous' do + assert_status 201 + expect(response_data.dig(:attributes, :anonymous)).to be false + expect(response_data.dig(:attributes, :author_name)).not_to be_nil + expect(response_data.dig(:relationships, :author, :data)).not_to be_nil + end + end + end + end end end diff --git a/back/spec/acceptance/phases_spec.rb b/back/spec/acceptance/phases_spec.rb index d1b5f66ab189..50d77551ae17 100644 --- a/back/spec/acceptance/phases_spec.rb +++ b/back/spec/acceptance/phases_spec.rb @@ -30,6 +30,14 @@ expect(json_response[:data].size).to eq 2 expect(json_response[:included].pluck(:type)).to include 'permission' end + + example 'List all phases of a project which is hidden (internal_role: community_monitor)' do + @project.update!(internal_role: 'community_monitor') + Permissions::PermissionsUpdateService.new.update_all_permissions + do_request + assert_status 200 + expect(json_response[:data].size).to eq 2 + end end context 'when admin' do diff --git a/back/spec/acceptance/projects_spec.rb b/back/spec/acceptance/projects_spec.rb index 36e2a8efa8c8..504acd72ed45 100644 --- a/back/spec/acceptance/projects_spec.rb +++ b/back/spec/acceptance/projects_spec.rb @@ -1681,4 +1681,74 @@ end end end + + get 'web_api/v1/projects/community_monitor' do + context 'when project admin' do + before { admin_header_token } + + context 'hidden community monitor project exists' do + let!(:project) { create(:community_monitor_project) } + + example 'Get community monitor project' do + settings = AppConfiguration.instance.settings + settings['community_monitor'] = { 'enabled' => true, 'allowed' => true, 'project_id' => project.id } + AppConfiguration.instance.update!(settings:) + + do_request + assert_status 200 + end + end + + context 'hidden community monitor project does not exist' do + example 'Create and get hidden community monitor project' do + SettingsService.new.activate_feature! 'community_monitor' + + do_request + assert_status 200 + + created_project = Project.first + created_phase = Phase.first + expect(created_project.hidden).to be true + expect(created_project.internal_role).to eq 'community_monitor' + expect(created_project.title_multiloc['en']).to eq 'Community monitor' + expect(created_phase.participation_method).to eq 'community_monitor_survey' + expect(created_phase.title_multiloc['en']).to eq 'Community monitor' + + settings = AppConfiguration.instance.settings + expect(settings['community_monitor']['project_id']).to eq created_project.id + end + + example 'Error: Hidden project does not get created without feature flag' do + do_request + assert_status 404 + end + end + + context 'stored community monitor project ID is incorrect' do + example 'Error: Hidden project does not exist' do + settings = AppConfiguration.instance.settings + settings['community_monitor'] = { 'enabled' => true, 'allowed' => true, 'project_id' => 'NON_EXISTENT' } + AppConfiguration.instance.update!(settings:) + + do_request + assert_status 404 + end + end + end + + context 'when resident' do + let!(:project) { create(:project, internal_role: 'community_monitor') } + + before { resident_header_token } + + example '[Error] Get community monitor project returns unauthorised' do + settings = AppConfiguration.instance.settings + settings['community_monitor'] = { 'enabled' => true, 'allowed' => true, 'project_id' => project.id } + AppConfiguration.instance.update!(settings:) + + do_request + assert_status 401 + end + end + end end diff --git a/back/spec/factories/phases.rb b/back/spec/factories/phases.rb index 724088142c8f..617b06e7be1b 100644 --- a/back/spec/factories/phases.rb +++ b/back/spec/factories/phases.rb @@ -114,6 +114,15 @@ end end + factory :community_monitor_survey_phase do + association :project, factory: :community_monitor_project + participation_method { 'community_monitor_survey' } + native_survey_title_multiloc { { 'en' => 'Community Monitor', 'nl-BE' => 'Gemeenschapsmonitor' } } + native_survey_button_multiloc { { 'en' => 'Take the survey', 'nl-BE' => 'De enquete invullen' } } + start_at { Time.zone.today - 7.days } + end_at { nil } + end + factory :single_voting_phase do participation_method { 'voting' } voting_method { 'single_voting' } diff --git a/back/spec/factories/projects.rb b/back/spec/factories/projects.rb index 973905aa4955..3ead94c2ca0d 100644 --- a/back/spec/factories/projects.rb +++ b/back/spec/factories/projects.rb @@ -612,6 +612,11 @@ ) end end + + factory :community_monitor_project do + internal_role { 'community_monitor' } + hidden { true } + end end end end diff --git a/back/spec/fixtures/locales/en.yml b/back/spec/fixtures/locales/en.yml index 24c9a683cf58..dadc3c758352 100644 --- a/back/spec/fixtures/locales/en.yml +++ b/back/spec/fixtures/locales/en.yml @@ -62,6 +62,11 @@ en: description: other_input_field: title: Type your answer + phases: + open_idea_phase_title: Current phase + native_survey_title: Survey + native_survey_button: Take the survey + community_monitor_title: Community monitor custom_forms: categories: main_content: diff --git a/back/spec/lib/participation_method/community_monitor_survey_spec.rb b/back/spec/lib/participation_method/community_monitor_survey_spec.rb new file mode 100644 index 000000000000..ce6a3ba89b31 --- /dev/null +++ b/back/spec/lib/participation_method/community_monitor_survey_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ParticipationMethod::CommunityMonitorSurvey do + subject(:participation_method) { described_class.new phase } + + let(:phase) { create(:community_monitor_survey_phase) } + + describe '#method_str' do + it 'returns community_monitor_survey' do + expect(described_class.method_str).to eq 'community_monitor_survey' + end + end + + describe '#assign_defaults' do + context 'when the proposed idea status is available' do + let!(:proposed) { create(:idea_status_proposed) } + let(:input) { build(:idea, publication_status: nil, idea_status: nil) } + + it 'sets the publication_status to "published" and the idea_status to "proposed"' do + participation_method.assign_defaults input + expect(input.publication_status).to eq 'published' + expect(input.idea_status).to eq proposed + end + end + + context 'when the proposed idea status is not available' do + let(:input) { build(:idea, idea_status: nil) } + + it 'raises ActiveRecord::RecordNotFound' do + expect { participation_method.assign_defaults input }.to raise_error ActiveRecord::RecordNotFound + end + end + end + + describe '#assign_defaults_for_phase' do + let(:phase) { build(:native_survey_phase) } + + it 'does not change the ideas_order' do + expect do + participation_method.assign_defaults_for_phase + end.not_to change(phase, :ideas_order) + end + end + + describe '#create_default_form!' do + it 'persists a default form with a page for the participation context' do + expect(phase.custom_form).to be_nil + + participation_method.create_default_form! + # create_default_form! does not reload associations for form/fields/options, + # so fetch the project from the database. The associations will be fetched + # when they are needed. + # Not doing this makes this test flaky, as create_default_form! creates fields + # and CustomField uses acts_as_list for ordering fields. The ordering is ok + # in the database, but not necessarily in memory. + phase_in_db = Phase.find(phase.id) + + expect(phase_in_db.custom_form.custom_fields.size).to eq 4 + expect(phase_in_db.custom_form.custom_fields.pluck(:input_type)).to match_array %w[page rating rating page] + end + end + + describe '#default_fields' do + it 'returns an empty list' do + expect( + participation_method.default_fields(create(:custom_form, participation_context: phase)).map(&:code) + ).to eq [] + end + end + + describe '#generate_slug' do + let(:input) { create(:input, slug: nil, project: phase.project, creation_phase: phase) } + + before { create(:idea_status_proposed) } + + it 'sets and persists the id as the slug of the input' do + expect(input.slug).to eq input.id + + input.update_column :slug, nil + input.reload + expect(participation_method.generate_slug(input)).to eq input.id + end + end + + describe '#author_in_form?' do + it 'returns false for a moderator when idea_author_change is activated' do + SettingsService.new.activate_feature! 'idea_author_change' + expect(participation_method.author_in_form?(create(:admin))).to be false + end + end + + describe '#budget_in_form?' do + it 'returns false for a moderator' do + expect(participation_method.budget_in_form?(create(:admin))).to be false + end + end + + describe '#custom_form' do + let(:project_form) { create(:custom_form, participation_context: phase.project) } + let(:phase) { create(:native_survey_phase) } + + it 'returns the custom form of the phase' do + expect(participation_method.custom_form.participation_context_id).to eq phase.id + end + end + + describe '#supports_serializing?' do + it 'returns true for native survey attributes' do + %i[native_survey_title_multiloc native_survey_button_multiloc].each do |attribute| + expect(participation_method.supports_serializing?(attribute)).to be true + end + end + + it 'returns false for the other attributes' do + %i[ + voting_method voting_max_total voting_min_total voting_max_votes_per_idea baskets_count + voting_term_singular_multiloc voting_term_plural_multiloc votes_count + ].each do |attribute| + expect(participation_method.supports_serializing?(attribute)).to be false + end + end + end + + describe '#supports_private_attributes_in_export?' do + it 'returns true if config setting is set to true' do + config = AppConfiguration.instance + config.settings['core']['private_attributes_in_export'] = true + config.save! + expect(participation_method.supports_private_attributes_in_export?).to be true + end + + it 'returns false if config setting is set to false' do + config = AppConfiguration.instance + config.settings['core']['private_attributes_in_export'] = false + config.save! + expect(participation_method.supports_private_attributes_in_export?).to be false + end + + it 'returns true if the setting is not present' do + expect(participation_method.supports_private_attributes_in_export?).to be true + end + end + + its(:additional_export_columns) { is_expected.to eq [] } + its(:allowed_ideas_orders) { is_expected.to be_empty } + its(:return_disabled_actions?) { is_expected.to be true } + its(:supports_assignment?) { is_expected.to be false } + its(:supports_built_in_fields?) { is_expected.to be false } + its(:supports_commenting?) { is_expected.to be false } + its(:supports_edits_after_publication?) { is_expected.to be false } + its(:supports_exports?) { is_expected.to be true } + its(:supports_input_term?) { is_expected.to be false } + its(:supports_inputs_without_author?) { is_expected.to be true } + its(:supports_multiple_posts?) { is_expected.to be false } + its(:supports_pages_in_form?) { is_expected.to be true } + its(:supports_permitted_by_everyone?) { is_expected.to be true } + its(:supports_public_visibility?) { is_expected.to be false } + its(:supports_reacting?) { is_expected.to be false } + its(:supports_status?) { is_expected.to be false } + its(:supports_submission?) { is_expected.to be true } + its(:supports_toxicity_detection?) { is_expected.to be false } + its(:use_reactions_as_votes?) { is_expected.to be false } + its(:transitive?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + + describe 'proposed_budget_in_form?' do # private method + it 'is expected to be false' do + expect(participation_method.send(:proposed_budget_in_form?)).to be false + end + end +end diff --git a/back/spec/lib/participation_method/ideation_spec.rb b/back/spec/lib/participation_method/ideation_spec.rb index 16d1b89b1848..21906c3cbc0f 100644 --- a/back/spec/lib/participation_method/ideation_spec.rb +++ b/back/spec/lib/participation_method/ideation_spec.rb @@ -228,6 +228,9 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be true } its(:supports_private_attributes_in_export?) { is_expected.to be true } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be true } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be true' do diff --git a/back/spec/lib/participation_method/information_spec.rb b/back/spec/lib/participation_method/information_spec.rb index 31bd6d66fa80..64fa802efba0 100644 --- a/back/spec/lib/participation_method/information_spec.rb +++ b/back/spec/lib/participation_method/information_spec.rb @@ -107,6 +107,9 @@ its(:transitive?) { is_expected.to be false } its(:use_reactions_as_votes?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/native_survey_spec.rb b/back/spec/lib/participation_method/native_survey_spec.rb index d0e32aecc625..0e1f131e981d 100644 --- a/back/spec/lib/participation_method/native_survey_spec.rb +++ b/back/spec/lib/participation_method/native_survey_spec.rb @@ -18,7 +18,7 @@ let!(:proposed) { create(:idea_status_proposed) } let(:input) { build(:idea, publication_status: nil, idea_status: nil) } - it 'sets the publication_status to "publised" and the idea_status to "proposed"' do + it 'sets the publication_status to "published" and the idea_status to "proposed"' do participation_method.assign_defaults input expect(input.publication_status).to eq 'published' expect(input.idea_status).to eq proposed @@ -188,6 +188,9 @@ its(:supports_toxicity_detection?) { is_expected.to be false } its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be true } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/none_spec.rb b/back/spec/lib/participation_method/none_spec.rb index 090d3c468ac6..de06452ac9d1 100644 --- a/back/spec/lib/participation_method/none_spec.rb +++ b/back/spec/lib/participation_method/none_spec.rb @@ -90,6 +90,9 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/poll_spec.rb b/back/spec/lib/participation_method/poll_spec.rb index 5962903317b3..031a50961a59 100644 --- a/back/spec/lib/participation_method/poll_spec.rb +++ b/back/spec/lib/participation_method/poll_spec.rb @@ -106,6 +106,9 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/proposals_spec.rb b/back/spec/lib/participation_method/proposals_spec.rb index 2863e373ec9e..64349a84ca0a 100644 --- a/back/spec/lib/participation_method/proposals_spec.rb +++ b/back/spec/lib/participation_method/proposals_spec.rb @@ -235,6 +235,9 @@ its(:use_reactions_as_votes?) { is_expected.to be true } its(:transitive?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be true } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be true } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/survey_spec.rb b/back/spec/lib/participation_method/survey_spec.rb index 920cec013975..5e49a519374a 100644 --- a/back/spec/lib/participation_method/survey_spec.rb +++ b/back/spec/lib/participation_method/survey_spec.rb @@ -107,6 +107,9 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/volunteering_spec.rb b/back/spec/lib/participation_method/volunteering_spec.rb index 6b3115c62f2f..4b277638754d 100644 --- a/back/spec/lib/participation_method/volunteering_spec.rb +++ b/back/spec/lib/participation_method/volunteering_spec.rb @@ -106,6 +106,9 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be false } its(:supports_private_attributes_in_export?) { is_expected.to be false } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be false } + its(:validate_phase) { is_expected.to be_nil } describe 'proposed_budget_in_form?' do # private method it 'is expected to be false' do diff --git a/back/spec/lib/participation_method/voting_spec.rb b/back/spec/lib/participation_method/voting_spec.rb index d6cb46b48b90..f67fb89fe384 100644 --- a/back/spec/lib/participation_method/voting_spec.rb +++ b/back/spec/lib/participation_method/voting_spec.rb @@ -184,6 +184,8 @@ its(:use_reactions_as_votes?) { is_expected.to be false } its(:transitive?) { is_expected.to be true } its(:supports_private_attributes_in_export?) { is_expected.to be true } + its(:form_logic_enabled?) { is_expected.to be false } + its(:follow_idea_on_idea_submission?) { is_expected.to be true } describe 'proposed_budget_in_form?' do # private method it 'is expected to be true' do diff --git a/back/spec/models/phase_spec.rb b/back/spec/models/phase_spec.rb index e61ccf26c09b..17ba98759126 100644 --- a/back/spec/models/phase_spec.rb +++ b/back/spec/models/phase_spec.rb @@ -310,53 +310,44 @@ end end - describe '#native_survey?' do - it 'returns true when the participation method is native_survey' do - phase = create(:native_survey_phase) - expect(phase.native_survey?).to be true - end - - it 'returns false otherwise' do - phase = create(:poll_phase) - expect(phase.native_survey?).to be false - end - end - - describe 'native_survey_title_multiloc' do - it 'must contain a survey title if a native survey phase' do - phase = build(:native_survey_phase) + describe 'native_survey_title_multiloc and native_survey_button_multiloc' do + %i[ + native_survey_phase + community_monitor_survey_phase + ].each do |factory| + context factory do + let(:phase) { build(factory) } + + it 'must contain a survey title' do + phase.native_survey_title_multiloc = { en: 'Survey' } + expect(phase).to be_valid + + phase.native_survey_title_multiloc = {} + expect(phase).not_to be_valid + + phase.native_survey_title_multiloc = nil + expect(phase).not_to be_valid + end - phase.native_survey_title_multiloc = { en: 'Survey' } - expect(phase).to be_valid + it 'must contain survey button text' do + phase.native_survey_button_multiloc = { en: 'Take the survey' } + expect(phase).to be_valid - phase.native_survey_title_multiloc = {} - expect(phase).not_to be_valid + phase.native_survey_button_multiloc = {} + expect(phase).not_to be_valid - phase.native_survey_title_multiloc = nil - expect(phase).not_to be_valid + phase.native_survey_button_multiloc = nil + expect(phase).not_to be_valid + end + end end - it 'does not need a survey title if not native survey' do + it 'does not need a survey title if not a type of native survey' do phase = build(:phase, native_survey_title_multiloc: {}) expect(phase).to be_valid end - end - - describe 'native_survey_button_multiloc' do - it 'must contain survey button text if a native survey phase' do - phase = build(:native_survey_phase) - - phase.native_survey_button_multiloc = { en: 'Take the survey' } - expect(phase).to be_valid - phase.native_survey_button_multiloc = {} - expect(phase).not_to be_valid - - phase.native_survey_button_multiloc = nil - expect(phase).not_to be_valid - end - - it 'does not need a survey title if not native survey' do + it 'does not need a survey button if not a type of native survey' do phase = build(:phase, native_survey_button_multiloc: {}) expect(phase).to be_valid end @@ -466,6 +457,44 @@ end end + describe '#validate_community_monitor_phase' do + let(:project) { create(:project) } + let(:survey_phase) { create(:native_survey_phase, project: project, start_at: Time.zone.today, end_at: nil) } + + context 'survey is not a community monitor survey' do + it 'is valid when the phase is not a community monitor native survey' do + expect(survey_phase).to be_valid + end + end + + context 'survey is a community monitor survey' do + before do + project.update! hidden: true, internal_role: 'community_monitor' + survey_phase.update! participation_method: 'community_monitor_survey' + end + + it 'is valid' do + expect(survey_phase).to be_valid + end + + it 'is not valid when the project has more than one phase' do + project.phases << create(:phase, project: project, start_at: survey_phase.start_at - 10.days, end_at: survey_phase.start_at - 5.days) + expect(survey_phase).not_to be_valid + end + + it 'is not valid when the phase has an end date' do + survey_phase.end_at = Time.zone.today + 1.day + expect(survey_phase).not_to be_valid + end + + it 'is not valid when the project is not hidden' do + project.hidden = false + # survey_phase.project.admin_publication.publication_status = 'published' + expect(survey_phase).not_to be_valid + end + end + end + describe '#disliking_enabled' do it 'defaults to false when disable_disliking feature flag is enabled (default)' do # binding.pry diff --git a/back/spec/models/project_spec.rb b/back/spec/models/project_spec.rb index 59e0e9ce05a3..1a746c5db6b0 100644 --- a/back/spec/models/project_spec.rb +++ b/back/spec/models/project_spec.rb @@ -142,4 +142,17 @@ expect(described_class.not_in_draft_folder).to match_array([project2, project3]) end end + + describe "'hidden' scopes" do + let!(:project) { create(:project) } + let!(:hidden_project) { create(:project, hidden: true) } + + it 'returns all projects that are not hidden' do + expect(described_class.all.count).to eq 2 + end + + it 'returns projects that are not hidden' do + expect(described_class.not_hidden.count).to eq 1 + end + end end diff --git a/back/spec/policies/admin_publication_policy_spec.rb b/back/spec/policies/admin_publication_policy_spec.rb index 84c618de5162..0a705315768c 100644 --- a/back/spec/policies/admin_publication_policy_spec.rb +++ b/back/spec/policies/admin_publication_policy_spec.rb @@ -18,6 +18,11 @@ it 'should index the project holder' do expect(scope.resolve.size).to eq 1 end + + it 'should not index the project holder if the project is hidden' do + admin_publication.publication.update!(hidden: true) + expect(scope.resolve.size).to eq 0 + end end context 'for a resident' do @@ -28,6 +33,11 @@ it 'should index the project holder' do expect(scope.resolve.size).to eq 1 end + + it 'should not index the project holder if hidden' do + admin_publication.publication.update!(hidden: true) + expect(scope.resolve.size).to eq 0 + end end context 'for an admin' do @@ -38,6 +48,11 @@ it 'should index the project holder' do expect(scope.resolve.size).to eq 1 end + + it 'should not index the project holder if hidden' do + admin_publication.publication.update!(hidden: true) + expect(scope.resolve.size).to eq 0 + end end context 'for a moderator of another project' do @@ -48,6 +63,11 @@ it 'indexes the project holder' do expect(scope.resolve.size).to eq 2 end + + it 'should not index any projects if the publication_status is hidden' do + admin_publication.publication.update!(hidden: true) + expect(scope.resolve.size).to eq 1 + end end end diff --git a/back/spec/policies/idea_policy_spec.rb b/back/spec/policies/idea_policy_spec.rb index 912301356076..a11a5eb37090 100644 --- a/back/spec/policies/idea_policy_spec.rb +++ b/back/spec/policies/idea_policy_spec.rb @@ -638,7 +638,7 @@ let!(:idea) do create(:idea_status_proposed) phase = project.phases.first - create(:idea, project: project, author: author, creation_phase: phase.native_survey? ? phase : nil) + create(:idea, project: project, author: author, creation_phase: phase.pmethod.supports_survey_form? ? phase : nil) end context "for a visitor with posting permissions granted to 'everyone'" do diff --git a/back/spec/serializers/web_api/v1/phase_serializer_spec.rb b/back/spec/serializers/web_api/v1/phase_serializer_spec.rb index 397c63916c17..40f713472c7e 100644 --- a/back/spec/serializers/web_api/v1/phase_serializer_spec.rb +++ b/back/spec/serializers/web_api/v1/phase_serializer_spec.rb @@ -70,4 +70,30 @@ end end end + + context 'for a native survey phase' do + let(:user) { create(:user) } + let(:phase) { create(:native_survey_phase) } + + it 'includes survey attributes' do + expect(result.dig(:data, :attributes).keys).to include( + :native_survey_title_multiloc, + :native_survey_button_multiloc + ) + expect(result.dig(:data, :attributes, :supports_survey_form)).to be true + end + end + + context 'for a community monitor phase' do + let(:user) { create(:user) } + let(:phase) { create(:community_monitor_survey_phase) } + + it 'includes survey attributes' do + expect(result.dig(:data, :attributes).keys).to include( + :native_survey_title_multiloc, + :native_survey_button_multiloc + ) + expect(result.dig(:data, :attributes, :supports_survey_form)).to be true + end + end end diff --git a/back/spec/services/side_fx_idea_service_spec.rb b/back/spec/services/side_fx_idea_service_spec.rb index 42ef3d27dc3e..e9c67cef3e8d 100644 --- a/back/spec/services/side_fx_idea_service_spec.rb +++ b/back/spec/services/side_fx_idea_service_spec.rb @@ -64,16 +64,34 @@ .with(idea, 'published', any_args) end - it 'creates a follower' do - project = create(:project) - folder = create(:project_folder, projects: [project]) - idea = create(:idea, project: project) + context 'followers' do + let!(:project) { create(:project) } + let!(:folder) { create(:project_folder, projects: [project]) } + let!(:idea) { create(:idea, project: project) } + + it 'creates idea, project and folder followers' do + expect do + service.after_create idea.reload, user + end.to change(Follower, :count).from(0).to(3) - expect do - service.after_create idea.reload, user - end.to change(Follower, :count).from(0).to(3) + expect(user.follows.pluck(:followable_id)).to contain_exactly idea.id, project.id, folder.id + end - expect(user.follows.pluck(:followable_id)).to contain_exactly idea.id, project.id, folder.id + it 'does not create followers if the project is hidden' do + project.update!(hidden: true) + expect do + service.after_create idea.reload, user + end.not_to change(Follower, :count) + end + + it 'creates only creates project and folder followers for native_survey responses' do + idea.update!(creation_phase: create(:native_survey_phase, project: project)) + expect do + service.after_create idea.reload, user + end.to change(Follower, :count).from(0).to(2) + + expect(user.follows.pluck(:followable_id)).to contain_exactly project.id, folder.id + end end it 'creates a cosponsorship' do diff --git a/back/spec/services/survey_results_generator_service_community_monitor_spec.rb b/back/spec/services/survey_results_generator_service_community_monitor_spec.rb new file mode 100644 index 000000000000..c6ca79522aeb --- /dev/null +++ b/back/spec/services/survey_results_generator_service_community_monitor_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# NOTE: These tests are basic and will be updated when additional features specific to community monitor are added +RSpec.describe SurveyResultsGeneratorService do + subject(:generator) { described_class.new survey_phase } + + let_it_be(:project) { create(:community_monitor_project) } + let_it_be(:survey_phase) { create(:community_monitor_survey_phase, project: project) } + let_it_be(:phases_of_inputs) { [survey_phase] } + + # Set-up custom form + let_it_be(:form) { create(:custom_form, participation_context: survey_phase) } + let_it_be(:page_field) { create(:custom_field_page, resource: form) } + let_it_be(:rating_field) do + create( + :custom_field_rating, + resource: form, + title_multiloc: { + 'en' => 'How satisfied are you with our service?', + 'fr-FR' => 'À quel point êtes-vous satisfait de notre service ?', + 'nl-NL' => 'Hoe tevreden ben je met onze service?' + }, + maximum: 7, + required: true + ) + end + + let_it_be(:text_field) do + create( + :custom_field, + resource: form, + title_multiloc: { 'en' => 'What is your favourite colour?' }, + description_multiloc: {} + ) + end + + # The following page for form submission should not be returned in the results + let_it_be(:last_page_field) do + create(:custom_field_page, resource: form, key: 'survey_end') + end + + let_it_be(:gender_user_custom_field) do + create(:custom_field_gender, :with_options) + end + + let_it_be(:domicile_user_custom_field) do + field = create(:custom_field_domicile) + create(:area, title_multiloc: { 'en' => 'Area 1' }) + create(:area, title_multiloc: { 'en' => 'Area 2' }) + field + end + + # Create responses + let_it_be(:responses) do + create(:idea_status_proposed) + male_user = create(:user, custom_field_values: { gender: 'male', domicile: domicile_user_custom_field.options[0].area.id }) + female_user = create(:user, custom_field_values: { gender: 'female', domicile: domicile_user_custom_field.options[1].area.id }) + no_gender_user = create(:user, custom_field_values: {}) + create( + :native_survey_response, + project: project, + phases: phases_of_inputs, + custom_field_values: { + rating_field.key => 3, + text_field.key => 'Red' + }, + author: female_user + ) + create( + :native_survey_response, + project: project, + phases: phases_of_inputs, + custom_field_values: { + rating_field.key => 4, + text_field.key => 'Blue' + }, + author: male_user + ) + create( + :native_survey_response, + project: project, + phases: phases_of_inputs, + custom_field_values: { + rating_field.key => 5, + text_field.key => 'Green' + }, + author: female_user + ) + create( + :native_survey_response, + project: project, + phases: phases_of_inputs, + custom_field_values: { + rating_field.key => 5, + text_field.key => 'Pink' + }, + author: no_gender_user + ) + end + + describe 'generate_results for community monitor surveys' do + let(:generated_results) { generator.generate_results } + + describe 'structure' do + it 'returns the correct totals' do + expect(generated_results[:totalSubmissions]).to eq 4 + end + + it 'returns the correct fields and structure' do + expect(generated_results[:results].count).to eq 3 + expect(generated_results[:results].pluck(:inputType)).to eq %w[page rating text] + end + end + + describe 'page fields' do + it 'returns correct values for a page field in full results' do + page_result = generated_results[:results][0] + expect(page_result[:inputType]).to eq 'page' + expect(page_result[:totalResponseCount]).to eq(4) + expect(page_result[:questionResponseCount]).to eq(4) + expect(page_result[:pageNumber]).to eq(1) + expect(page_result[:questionNumber]).to be_nil + end + end + + describe 'rating field' do + let(:expected_result_rating) do + { + customFieldId: rating_field.id, + inputType: 'rating', + question: { + 'en' => 'How satisfied are you with our service?', + 'fr-FR' => 'À quel point êtes-vous satisfait de notre service ?', + 'nl-NL' => 'Hoe tevreden ben je met onze service?' + }, + required: true, + grouped: false, + description: { 'en' => 'Please rate your experience from 1 (poor) to 5 (excellent).' }, + hidden: false, + logic: {}, + pageNumber: nil, + questionNumber: nil, + totalResponseCount: 4, + questionResponseCount: 4, + totalPickCount: 4, + answers: [ + { answer: 1, count: 0 }, + { answer: 2, count: 0 }, + { answer: 3, count: 1 }, + { answer: 4, count: 1 }, + { answer: 5, count: 2 }, + { answer: 6, count: 0 }, + { answer: 7, count: 0 }, + { answer: nil, count: 0 } + ], + multilocs: { + answer: { + 1 => { title_multiloc: { 'en' => '1', 'fr-FR' => '1', 'nl-NL' => '1' } }, + 2 => { title_multiloc: { 'en' => '2', 'fr-FR' => '2', 'nl-NL' => '2' } }, + 3 => { title_multiloc: { 'en' => '3', 'fr-FR' => '3', 'nl-NL' => '3' } }, + 4 => { title_multiloc: { 'en' => '4', 'fr-FR' => '4', 'nl-NL' => '4' } }, + 5 => { title_multiloc: { 'en' => '5', 'fr-FR' => '5', 'nl-NL' => '5' } }, + 6 => { title_multiloc: { 'en' => '6', 'fr-FR' => '6', 'nl-NL' => '6' } }, + 7 => { title_multiloc: { 'en' => '7', 'fr-FR' => '7', 'nl-NL' => '7' } } + } + } + } + end + + it 'returns the results for a rating field' do + expected_result_rating[:questionNumber] = 1 + expect(generated_results[:results][1]).to match expected_result_rating + end + + it 'returns a single result for a rating field' do + expect(generator.generate_results(field_id: rating_field.id)).to match expected_result_rating + end + + context 'with grouping' do + let(:grouped_rating_results) do + { + customFieldId: rating_field.id, + inputType: 'rating', + question: { + 'en' => 'How satisfied are you with our service?', + 'fr-FR' => 'À quel point êtes-vous satisfait de notre service ?', + 'nl-NL' => 'Hoe tevreden ben je met onze service?' + }, + required: true, + grouped: true, + description: { 'en' => 'Please rate your experience from 1 (poor) to 5 (excellent).' }, + hidden: false, + logic: {}, + pageNumber: nil, + questionNumber: nil, + totalResponseCount: 4, + questionResponseCount: 4, + totalPickCount: 4, + answers: [ + { + answer: 1, + count: 0, + groups: [] + }, + { + answer: 2, + count: 0, + groups: [] + }, + { + answer: 3, + count: 1, + groups: [ + { count: 1, group: 'female' } + ] + }, + { + answer: 4, + count: 1, + groups: [ + { count: 1, group: 'male' } + ] + }, + { + answer: 5, + count: 2, + groups: [ + { count: 1, group: 'female' }, + { count: 1, group: nil } + ] + }, + { + answer: 6, + count: 0, + groups: [] + }, + { + answer: 7, + count: 0, + groups: [] + }, + { + answer: nil, + count: 0, + groups: [] + } + ], + multilocs: { + answer: { + 1 => { title_multiloc: { 'en' => '1', 'fr-FR' => '1', 'nl-NL' => '1' } }, + 2 => { title_multiloc: { 'en' => '2', 'fr-FR' => '2', 'nl-NL' => '2' } }, + 3 => { title_multiloc: { 'en' => '3', 'fr-FR' => '3', 'nl-NL' => '3' } }, + 4 => { title_multiloc: { 'en' => '4', 'fr-FR' => '4', 'nl-NL' => '4' } }, + 5 => { title_multiloc: { 'en' => '5', 'fr-FR' => '5', 'nl-NL' => '5' } }, + 6 => { title_multiloc: { 'en' => '6', 'fr-FR' => '6', 'nl-NL' => '6' } }, + 7 => { title_multiloc: { 'en' => '7', 'fr-FR' => '7', 'nl-NL' => '7' } } + }, + group: { + 'female' => { title_multiloc: { 'en' => 'youth council', 'fr-FR' => 'conseil des jeunes', 'nl-NL' => 'jeugdraad' } }, + 'male' => { title_multiloc: { 'en' => 'youth council', 'fr-FR' => 'conseil des jeunes', 'nl-NL' => 'jeugdraad' } }, + 'unspecified' => { title_multiloc: { 'en' => 'youth council', 'fr-FR' => 'conseil des jeunes', 'nl-NL' => 'jeugdraad' } } + } + }, + legend: ['male', 'female', 'unspecified', nil] + } + end + + it 'returns a grouped result for a rating field' do + generator = described_class.new( + survey_phase, + group_mode: 'user_field', + group_field_id: gender_user_custom_field.id + ) + result = generator.generate_results( + field_id: rating_field.id + ) + expect(result).to match grouped_rating_results + end + end + end + end +end