From 2934189afd8209c18fb1dc5420b4786c93b9125d Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Wed, 21 Aug 2024 13:53:39 +0200 Subject: [PATCH 1/6] feat: add default ahv question for #58 --- app/models/youth/person.rb | 25 +------------------------ db/seeds/event_questions.rb | 13 +++++++++++++ spec/fixtures/event/questions.yml | 12 ++++++++++++ 3 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 db/seeds/event_questions.rb diff --git a/app/models/youth/person.rb b/app/models/youth/person.rb index bfe30856..d58f9af6 100644 --- a/app/models/youth/person.rb +++ b/app/models/youth/person.rb @@ -8,9 +8,6 @@ module Youth::Person extend ActiveSupport::Concern - require_dependency "social_security_number" - include ::SocialSecurityNumber - NATIONALITIES_J_S = %w[CH FL ANDERE].freeze included do @@ -27,25 +24,9 @@ module Youth::Person has_many :manageds, through: :people_manageds validates :nationality_j_s, inclusion: {in: NATIONALITIES_J_S, allow_blank: true} + validates :ahv_number, ahv_number: true # AhvNumberValidator in core validate :assert_either_only_managers_or_manageds - validate :validate_ahv_number - end - - AHV_NUMBER_REGEX = /\A\d{3}\.\d{4}\.\d{4}\.\d{2}\z/ - - def validate_ahv_number - # Allow changing the password, even if there is an invalid AHV number in the database - return if will_save_change_to_encrypted_password? && !will_save_change_to_ahv_number? - return if ahv_number.blank? - - if !AHV_NUMBER_REGEX.match?(ahv_number) - errors.add(:ahv_number, :must_be_social_security_number_with_correct_format) - return - end - unless checksum_validate(ahv_number).valid? - errors.add(:ahv_number, :must_be_social_security_number_with_correct_checksum) - end end def assert_either_only_managers_or_manageds # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity @@ -67,10 +48,6 @@ def and_manageds [self, manageds].flatten end - def checksum_validate(ahv_number) - SocialSecurityNumber::Validator.new(number: ahv_number.to_s, country_code: "ch") - end - def valid_email?(email = self.email) if FeatureGate.enabled?("people.people_managers") super || Person.mailing_emails_for(self).any? { |mail| super(mail) } diff --git a/db/seeds/event_questions.rb b/db/seeds/event_questions.rb new file mode 100644 index 00000000..b48f465d --- /dev/null +++ b/db/seeds/event_questions.rb @@ -0,0 +1,13 @@ +# encoding: utf-8 + +# Copyright (c) 2012-2024, Jungwacht Blauring Schweiz. This file is part of +# hitobito_jubla and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito_jubla. + +Event::Question.seed_global([ + { question: 'AHV-Nummer', + disclosure: nil, # Has to be chosen for every event + event_type: nil, # Is derived for every event + type: Event::Question::AhvNumber.sti_name }, +]) diff --git a/spec/fixtures/event/questions.yml b/spec/fixtures/event/questions.yml index 8ec2d0f5..25ffca10 100644 --- a/spec/fixtures/event/questions.yml +++ b/spec/fixtures/event/questions.yml @@ -5,19 +5,31 @@ top_ov: event: top_course + disclosure: optional + type: Event::Question::Default top_vegi: event: top_course + disclosure: optional + type: Event::Question::Default top_more: event: top_course + disclosure: optional + type: Event::Question::Default # global questions (not assigned to event) ga: event: {} + disclosure: optional + type: Event::Question::Default vegi: event: {} + disclosure: optional + type: Event::Question::Default schub: event: {} + disclosure: optional + type: Event::Question::Default From fb94fe07cf5699e6a024b00013353e5a6d60493b Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Wed, 4 Sep 2024 17:46:29 +0200 Subject: [PATCH 2/6] feat: specs for ahv_number on events (#58) --- app/domain/ahv_number_validator.rb | 29 +++++ .../tabular/people/participation_nds_row.rb | 6 + app/models/event/question/ahv_number.rb | 30 +++++ app/models/youth/person.rb | 15 ++- spec/features/question_spec.rb | 105 ++++++++++++++++++ spec/models/event/question/ahv_number_spec.rb | 46 ++++++++ spec/models/person_spec.rb | 35 ++++++ 7 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/domain/ahv_number_validator.rb create mode 100644 app/models/event/question/ahv_number.rb create mode 100644 spec/features/question_spec.rb create mode 100644 spec/models/event/question/ahv_number_spec.rb diff --git a/app/domain/ahv_number_validator.rb b/app/domain/ahv_number_validator.rb new file mode 100644 index 00000000..f6269a73 --- /dev/null +++ b/app/domain/ahv_number_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Copyright (c) 2012-2021, Jungwacht Blauring Schweiz. This file is part of +# hitobito and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito. + +class AhvNumberValidator < ActiveModel::EachValidator + require_dependency "social_security_number" + include ::SocialSecurityNumber + + AHV_NUMBER_REGEX = /\A\d{3}\.\d{4}\.\d{4}\.\d{2}\z/ + + def validate_each(record, attribute, value) + return if value.blank? + + if !AHV_NUMBER_REGEX.match?(value) + record.errors.add(attribute, :must_be_social_security_number_with_correct_format) + return + end + unless checksum_validate(value).valid? + record.errors.add(attribute, :must_be_social_security_number_with_correct_checksum) + end + end + + def checksum_validate(ahv_number, country_code: "ch") + SocialSecurityNumber::Validator.new(number: ahv_number.to_s, country_code:) + end +end diff --git a/app/domain/export/tabular/people/participation_nds_row.rb b/app/domain/export/tabular/people/participation_nds_row.rb index 23bdb2c4..a5c622e2 100644 --- a/app/domain/export/tabular/people/participation_nds_row.rb +++ b/app/domain/export/tabular/people/participation_nds_row.rb @@ -29,6 +29,12 @@ def canton entry.canton.to_s.upcase end + def ahv_number + entry.answers.joins(:question).where(event_questions: { type: Event::Question::AhvNumber.sti_name }) + .where.not(answer: [nil, ""]) + .first&.answer.presence + end + def country { "CH" => "CH", diff --git a/app/models/event/question/ahv_number.rb b/app/models/event/question/ahv_number.rb new file mode 100644 index 00000000..32e15c71 --- /dev/null +++ b/app/models/event/question/ahv_number.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright (c) 2012-2021, Jungwacht Blauring Schweiz. This file is part of +# hitobito and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito. + +# == Schema Information +# +# Table name: event_questions +# +# id :integer not null, primary key +# admin :boolean default(FALSE), not null +# choices :string(255) +# multiple_choices :boolean default(FALSE), not null +# question :text(65535) +# required :boolean default(FALSE), not null +# event_id :integer +# +# Indexes +# +# index_event_questions_on_event_id (event_id) +# + +class Event::Question::AhvNumber < Event::Question + def validate_answer(answer) + validator = AhvNumberValidator.new(attributes: :answer) + validator.validate(answer) + end +end diff --git a/app/models/youth/person.rb b/app/models/youth/person.rb index d58f9af6..86bc1630 100644 --- a/app/models/youth/person.rb +++ b/app/models/youth/person.rb @@ -24,11 +24,15 @@ module Youth::Person has_many :manageds, through: :people_manageds validates :nationality_j_s, inclusion: {in: NATIONALITIES_J_S, allow_blank: true} - validates :ahv_number, ahv_number: true # AhvNumberValidator in core + validates :ahv_number, ahv_number: true, unless: :skip_ahv_number_validation? validate :assert_either_only_managers_or_manageds end + def skip_ahv_number_validation? + will_save_change_to_encrypted_password? && !will_save_change_to_ahv_number? + end + def assert_either_only_managers_or_manageds # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity existent_managers = people_managers.reject { |pm| pm.marked_for_destruction? } existent_manageds = people_manageds.reject { |pm| pm.marked_for_destruction? } @@ -55,4 +59,13 @@ def valid_email?(email = self.email) super end end + + def last_known_ahv_number + @last_known_ahv_number ||= Event::Answer.joins(:question, :participation) + .where(participation: event_participation_ids) + .where(event_questions: {type: Event::Question::AhvNumber.sti_name}) + .where.not(answer: [nil, ""]) + .order(Event::Participation.arel_table[:updated_at].desc) + .last&.answer.presence || try(:ahv_number) + end end diff --git a/spec/features/question_spec.rb b/spec/features/question_spec.rb new file mode 100644 index 00000000..d19c450d --- /dev/null +++ b/spec/features/question_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Copyright (c) 2012-2024, Jungwacht Blauring Schweiz. This file is part of +# hitobito and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito. + +require "spec_helper" + +describe EventsController, js: true do + let(:event) do + Fabricate(:event, groups: [groups(:top_group)]).tap do |event| + event.dates.create!(start_at: 10.days.ago, finish_at: 5.days.ago) + end + end + let(:global_questions) do + { + ahv_number: Event::Question::AhvNumber.create!(question: "AHV-Number?", event_type: nil, disclosure: nil), + } + end + + def click_save + all("form .btn-group").first.click_button "Speichern" + end + + def click_next + all(".bottom .btn-group").first.click_button "Weiter" + end + + def click_signup + all(".bottom .btn-group").first.click_button "Anmelden" + end + + def find_question_field(question) + page.all(".fields").find { |question_element| question_element.text.start_with?(question.question) } + end + + describe "global Event::Question::AhvNumber" do + subject(:question_fields_element) do + click_link I18n.t("event.participations.application_answers") + page.find("#application_questions_fields") + end + + before do + Event::Question.delete_all + global_questions + sign_in + visit edit_group_event_path(event.group_ids.first, event.id) + end + + it "includes global questions with matching event type" do + is_expected.to have_text(global_questions[:ahv_number].question) + + is_expected.not_to have_text('Mögliche Antworten') + is_expected.not_to have_text('Mehrfachauswahl') + is_expected.not_to have_text('Entfernen') + end + end + + describe "answers for global questions" do + let(:user) { people(:bottom_member) } + let(:event_with_questions) do + event.init_questions + event.application_questions.map { |question| question.update!(disclosure: question.disclosure || :optional) } + event.save! + event + end + + subject { page } + + before do + Event::Question.delete_all + global_questions + event_with_questions + sign_in(user) + visit contact_data_group_event_participations_path(event.group_ids.first, event.id, event_role: {type: Event::Role::Participant}) + click_next + end + + it "fails with empty required question" do + sleep 0.5 # avoid wizard race condition + + within find_question_field(global_questions[:ahv_number]) do + answer_element = find('input[type="text"]') + answer_element.fill_in(with: "Not An AHV-Number") + end + click_signup + is_expected.to have_content "Antwort muss im gültigen Format sein (756.1234.5678.97)" + + within find_question_field(global_questions[:ahv_number]) do + answer_element = find('input[type="text"]') + answer_element.fill_in(with: "756.1234.5678.90") + end + click_signup + is_expected.to have_content "Antwort muss eine gültige Prüfziffer haben." + + within find_question_field(global_questions[:ahv_number]) do + answer_element = find('input[type="text"]') + answer_element.fill_in(with: "756.1234.5678.97") + end + click_signup + is_expected.to have_content "Teilnahme von Bottom Member in Eventus wurde erfolgreich erstellt." + end + end +end diff --git a/spec/models/event/question/ahv_number_spec.rb b/spec/models/event/question/ahv_number_spec.rb new file mode 100644 index 00000000..ecb20d7a --- /dev/null +++ b/spec/models/event/question/ahv_number_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Copyright (c) 2012-2020, Jungwacht Blauring Schweiz. This file is part of +# hitobito and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/hitobito/hitobito. + +# == Schema Information +# +# Table name: event_questions +# +# id :integer not null, primary key +# event_id :integer +# question :string +# choices :string +# multiple_choices :boolean default(FALSE) +# required :boolean +# + +require "spec_helper" + +describe Event::Question::AhvNumber do + let(:event) { events(:top_course) } + let(:participation) { event.participations.build } + subject(:question) { described_class.new(question: "AHV-Number", disclosure: :optional, event: event) } + + describe "Event::Answer" do + subject(:answer) { question.answers.build(participation: participation) } + + it "accepts empty answer when optional" do + answer.answer = "" + is_expected.to be_valid + end + + it "accepts only real ahv-numbers" do + answer.answer = "test" + is_expected.not_to be_valid + + answer.answer = "756.1234.5678.91" # invalid checksum + is_expected.not_to be_valid + + answer.answer = "756.1234.5678.97" + is_expected.to be_valid + end + end +end diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 167791e5..1216ad58 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -29,6 +29,41 @@ end end + describe '#last_known_ahv_number' do + let(:valid_ahv_number) { '756.1234.5678.97' } + let(:person) { top_leader } + subject(:last_known_ahv_number) { person.last_known_ahv_number } + + context "without any answers" do + it 'falls back on the legacy ahv number' do + person.ahv_number = valid_ahv_number + is_expected.to eq(person.ahv_number) + end + end + + context "with answered questions of type Event::Question::AhvNumber" do + let(:ahv_numbers) do + %w[756.3720.9797.95 756.7774.5627.12 756.5137.2138.68] + end + let(:ahv_number_answers) do + ahv_numbers.map.with_index do |ahv_number, i| + participation = Fabricate(:event_participation, person: person) + event = participation.event + question = Event::Question::AhvNumber.create(disclosure: :required, question: "AHV?", event: event) + answer = Event::Answer.find_by(question: question, participation: participation) + answer.update!(answer: ahv_number) + participation.touch(time: (i + 1).months.ago) + answer + end + end + + it "uses the last updated answer" do + ahv_number_answers + is_expected.to eq(ahv_numbers.last) + end + end + end + describe '#ahv_number' do it 'fails for malformatted ahv number' do person = Person.new From ac5767e872335505c8581fa81b00061323299626 Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Thu, 5 Sep 2024 14:05:23 +0200 Subject: [PATCH 3/6] feat: add more specs to nds export for ahv_number --- .../tabular/people/participation_nds_row.rb | 4 +-- app/models/youth/person.rb | 6 ++--- .../people/participation_nds_row_spec.rb | 27 +++++++++++++++++-- spec/models/person_spec.rb | 5 ++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/app/domain/export/tabular/people/participation_nds_row.rb b/app/domain/export/tabular/people/participation_nds_row.rb index a5c622e2..fd21255b 100644 --- a/app/domain/export/tabular/people/participation_nds_row.rb +++ b/app/domain/export/tabular/people/participation_nds_row.rb @@ -30,9 +30,7 @@ def canton end def ahv_number - entry.answers.joins(:question).where(event_questions: { type: Event::Question::AhvNumber.sti_name }) - .where.not(answer: [nil, ""]) - .first&.answer.presence + entry.last_known_ahv_number(participation.id) end def country diff --git a/app/models/youth/person.rb b/app/models/youth/person.rb index 86bc1630..afa017d1 100644 --- a/app/models/youth/person.rb +++ b/app/models/youth/person.rb @@ -60,9 +60,9 @@ def valid_email?(email = self.email) end end - def last_known_ahv_number - @last_known_ahv_number ||= Event::Answer.joins(:question, :participation) - .where(participation: event_participation_ids) + def last_known_ahv_number(participation_ids = event_participation_ids) + Event::Answer.joins(:question, :participation) + .where(participation: participation_ids) .where(event_questions: {type: Event::Question::AhvNumber.sti_name}) .where.not(answer: [nil, ""]) .order(Event::Participation.arel_table[:updated_at].desc) diff --git a/spec/domain/export/tabular/people/participation_nds_row_spec.rb b/spec/domain/export/tabular/people/participation_nds_row_spec.rb index 7d3ec76f..57171e65 100644 --- a/spec/domain/export/tabular/people/participation_nds_row_spec.rb +++ b/spec/domain/export/tabular/people/participation_nds_row_spec.rb @@ -22,7 +22,6 @@ it { expect(row.fetch(:first_name)).to eq 'Peter' } it { expect(row.fetch(:birthday)).to eq '11.06.1980' } it { expect(row.fetch(:gender)).to eq 'm' } - it { expect(row.fetch(:ahv_number)).to eq '756.1234.5678.97' } it { expect(row.fetch(:peid)).to be_nil } it { expect(row.fetch(:nationality_j_s)).to eq 'FL' } it { expect(row.fetch(:first_language)).to eq 'DE' } @@ -69,6 +68,31 @@ end end + describe 'ahv_number' do + subject(:ahv_number) { row.fetch(:ahv_number) } + let(:valid_ahv_number) { '756.1234.5678.97' } + + before do + expect(person).to receive(:last_known_ahv_number).and_call_original + end + + context "with ahv_number fallback on person" do + it "calls #last_known_ahv_number and returns #ahv_number" do + person.ahv_number = valid_ahv_number + is_expected.to eq(person.ahv_number) + end + end + + context "with ahv_number on participation" do + it "calls #last_known_ahv_number and returns participation answer" do + event = participation.event + question = Event::Question::AhvNumber.create(disclosure: :required, question: "AHV?", event: event) + answer = Event::Answer.find_by(question: question, participation: participation) + answer.update!(answer: valid_ahv_number) + is_expected.to eq(valid_ahv_number) + end + end + end private @@ -80,7 +104,6 @@ def nds_person birthday: '11.06.1980', gender: 'm', j_s_number: '1695579', - ahv_number: '756.1234.5678.97', street: 'Hauptstrasse', housenumber: '33', zip_code: '4000', diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 1216ad58..dfb3dea6 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -61,6 +61,11 @@ ahv_number_answers is_expected.to eq(ahv_numbers.last) end + + it "uses only specified participation_id" do + expected_answer = ahv_number_answers.first + expect(person.last_known_ahv_number(expected_answer.participation_id)).to eq(expected_answer.answer) + end end end From 13884a7526a59389432ed31ffd767464fe61fccb Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Thu, 5 Sep 2024 14:09:06 +0200 Subject: [PATCH 4/6] fix: flaky spec in youth wagon --- spec/domain/synchronize/mailchimp/subscriber_spec.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/domain/synchronize/mailchimp/subscriber_spec.rb b/spec/domain/synchronize/mailchimp/subscriber_spec.rb index a7af11d5..5220410f 100644 --- a/spec/domain/synchronize/mailchimp/subscriber_spec.rb +++ b/spec/domain/synchronize/mailchimp/subscriber_spec.rb @@ -21,21 +21,23 @@ AdditionalEmail.new(label: 'vater', email: 'vater@example.com', mailings: true) end - subject { described_class.mailing_list_subscribers(mailing_list) } + subject(:subscribers) { described_class.mailing_list_subscribers(mailing_list) } context 'default strategy' do it 'returns all people and their manager' do manager = Fabricate(:person) person.managers = [manager] - subscribers = subject expect(subscribers.count).to eq(2) - managed_subscriber = subscribers.first - manager_subscriber = subscribers.last + managed_subscriber = subscribers.find { _1.person == person } + manager_subscriber = subscribers.find { _1.person == manager } + expect(managed_subscriber).to be_present expect(managed_subscriber.email).to eq(person.email) expect(managed_subscriber.person).to eq(person) + + expect(manager_subscriber).to be_present expect(manager_subscriber.email).to eq(manager.email) expect(manager_subscriber.person).to eq(manager) end From cca76ecb4de0872411536682976590daf90e9ad2 Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Tue, 10 Sep 2024 09:42:04 +0200 Subject: [PATCH 5/6] fix: use correct method from core --- app/models/event/question/ahv_number.rb | 5 +++++ db/seeds/event_questions.rb | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/models/event/question/ahv_number.rb b/app/models/event/question/ahv_number.rb index 32e15c71..7d708626 100644 --- a/app/models/event/question/ahv_number.rb +++ b/app/models/event/question/ahv_number.rb @@ -27,4 +27,9 @@ def validate_answer(answer) validator = AhvNumberValidator.new(attributes: :answer) validator.validate(answer) end + + def translation_class + # ensures globalize works with STI + Event::Question.globalize_translation_class + end end diff --git a/db/seeds/event_questions.rb b/db/seeds/event_questions.rb index b48f465d..358c6fbc 100644 --- a/db/seeds/event_questions.rb +++ b/db/seeds/event_questions.rb @@ -5,9 +5,16 @@ # or later. See the COPYING file at the top-level directory or at # https://github.com/hitobito/hitobito_jubla. -Event::Question.seed_global([ - { question: 'AHV-Nummer', +Event::Question.create_with_translations([ + { disclosure: nil, # Has to be chosen for every event event_type: nil, # Is derived for every event - type: Event::Question::AhvNumber.sti_name }, + type: Event::Question::AhvNumber.sti_name, + translation_attributes: [ + { locale: 'de', question: 'AHV-Nummer?' }, + { locale: 'fr', question: 'Numéro AVS ?' }, + { locale: 'it', question: 'Numero AVS?' }, + { locale: 'en', question: 'AVS number?' } + ] + }, ]) From c47031066a2b28addaf14f1a746a4e5e939c6949 Mon Sep 17 00:00:00 2001 From: Diego Steiner Date: Tue, 10 Sep 2024 10:41:00 +0200 Subject: [PATCH 6/6] feat: add changelog entry (hitobit_youth#58) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d17998..69ea17e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Hitobito Changelog +## Unreleased + +* AHV-Nummern wurde als globale Frage für alle Anlässe hinzugefügt. Es muss für jeden neuen Anlass ausgewählt werden, ob die Antwort auf diese Frage obligatorisch, optional oder versteckt sein soll. Diese Antworten dafür werden im NDS-Export des jeweiligen Anlasses berücksichtigt. (hitobito_youth#58) + ## Version 1.30 * Es können neu die eigenen Kinder direkt am Anlass angemeldet werden. Kinder werden entweder auf der Person hinterlegt oder können bei der Anlass-Anmeldung direkt neu angelegt werden. (#1969)