From 5897567921e496a510344eadfc1dd9564e89962a Mon Sep 17 00:00:00 2001 From: Daniel Illi Date: Wed, 20 Nov 2024 18:22:41 +0100 Subject: [PATCH] Implement mailing list subscriber filter for invoice receiver, fixes #1272 --- app/domain/person/filter/invoice_receiver.rb | 51 ++++ .../_filter_form_invoice_receiver.html.haml | 14 + .../_filter_show_invoice_receiver.html.haml | 12 + config/locales/wagon.de.yml | 8 + lib/hitobito_sac_cas/wagon.rb | 2 + spec/domain/mailing_lists/subscribers_spec.rb | 159 ++++++++++ .../person/filter/invoice_receiver_spec.rb | 272 ++++++++++++++++++ spec/features/mailing_lists_spec.rb | 22 ++ 8 files changed, 540 insertions(+) create mode 100644 app/domain/person/filter/invoice_receiver.rb create mode 100644 app/views/subscriber/filter/_filter_form_invoice_receiver.html.haml create mode 100644 app/views/subscriber/filter/_filter_show_invoice_receiver.html.haml create mode 100644 spec/domain/mailing_lists/subscribers_spec.rb create mode 100644 spec/domain/person/filter/invoice_receiver_spec.rb diff --git a/app/domain/person/filter/invoice_receiver.rb b/app/domain/person/filter/invoice_receiver.rb new file mode 100644 index 000000000..4872198e1 --- /dev/null +++ b/app/domain/person/filter/invoice_receiver.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas 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_sac_cas + +class Person::Filter::InvoiceReceiver < Person::Filter::Base + VISIBLE_ATTRS = [:stammsektion, :zusatzsektion].freeze + self.permitted_args = [*VISIBLE_ATTRS, :group_id] + + def apply(scope) + return scope if blank? + + scope.where(id: invoice_receiver_scope) + end + + def blank? = !(stammsektion || zusatzsektion) || group_id.blank? + + private + + def stammsektion = ActiveModel::Type::Boolean.new.cast(args[:stammsektion]) + + def zusatzsektion = ActiveModel::Type::Boolean.new.cast(args[:zusatzsektion]) + + def group_id = args[:group_id].presence + + def group + raise "Group ID is required" unless args[:group_id].present? + Group.find(group_id) + end + + def role_types + [].tap do |role_types| + role_types << Group::SektionsMitglieder::Mitglied.sti_name if stammsektion + role_types << Group::SektionsMitglieder::MitgliedZusatzsektion.sti_name if zusatzsektion + end + end + + def base_scope + Person.joins(:roles).where(roles: {type: role_types}).then do |scope| + group.layer_group.is_a?(Group::SacCas) ? scope : scope.in_layer(group) + end + end + + def invoice_receiver_scope + base_scope + .where.not(roles: {beitragskategorie: SacCas::Beitragskategorie::Calculator::CATEGORY_FAMILY}) + .or(base_scope.where(sac_family_main_person: true)) + end +end diff --git a/app/views/subscriber/filter/_filter_form_invoice_receiver.html.haml b/app/views/subscriber/filter/_filter_form_invoice_receiver.html.haml new file mode 100644 index 000000000..ec05b5f9d --- /dev/null +++ b/app/views/subscriber/filter/_filter_form_invoice_receiver.html.haml @@ -0,0 +1,14 @@ +-# Copyright (c) 2024, Schweizer Alpen-Club SAC. 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_sac_cas + +- caption = t("people_filters.invoice_receiver.title", group_name: assigns["group"].layer_group.name) += render(layout: 'people_filters/filter', locals: { entry: @mailing_list, type: :invoice_receiver, caption: }) do + - filter_args = entry.filter_chain[:invoice_receiver]&.args + = hidden_field_tag('filters[invoice_receiver][group_id]', assigns["group"].id) + - Person::Filter::InvoiceReceiver::VISIBLE_ATTRS.each do |attr| + - id = "filters_invoice_receiver_#{attr}" + = label_tag(nil, class: 'checkbox ', for: id) do + = check_box_tag("filters[invoice_receiver][#{attr}]", true, filter_args&.dig(attr), id: id) + = t(".invoice_receiver.#{attr}") diff --git a/app/views/subscriber/filter/_filter_show_invoice_receiver.html.haml b/app/views/subscriber/filter/_filter_show_invoice_receiver.html.haml new file mode 100644 index 000000000..e50ffe241 --- /dev/null +++ b/app/views/subscriber/filter/_filter_show_invoice_receiver.html.haml @@ -0,0 +1,12 @@ +-# Copyright (c) 2024, Schweizer Alpen-Club SAC. 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_sac_cas + +- if @mailing_list.filter_chain[:invoice_receiver].present? + - filter_args = @mailing_list.filter_chain[:invoice_receiver].args + + - Person::Filter::InvoiceReceiver::VISIBLE_ATTRS.each do |attr| + - if filter_args&.dig(attr) + %li + = t("people_filters.filter.invoice_receiver.#{attr}") diff --git a/config/locales/wagon.de.yml b/config/locales/wagon.de.yml index 5c02dc8be..18614c03b 100644 --- a/config/locales/wagon.de.yml +++ b/config/locales/wagon.de.yml @@ -1574,6 +1574,14 @@ de: one: Anmeldung wurde abgelehnt other: "%{count} Anmeldungen wurden abgelehnt" + people_filters: + invoice_receiver: + title: Rechnungsempfänger in %{group_name} + filter: + invoice_receiver: + stammsektion: Rechnungsempfänger SAC Mitgliedschaft + zusatzsektion: Rechnungsempfänger Zusatzsektion + roles: beitragskategorie: adult: Einzel diff --git a/lib/hitobito_sac_cas/wagon.rb b/lib/hitobito_sac_cas/wagon.rb index bafbeef43..058bcee16 100644 --- a/lib/hitobito_sac_cas/wagon.rb +++ b/lib/hitobito_sac_cas/wagon.rb @@ -43,6 +43,8 @@ class Wagon < Rails::Engine ] HitobitoLogEntry.categories += %w[neuanmeldungen rechnungen stapelverarbeitung] + MailingLists::Filter::Chain::TYPES << Person::Filter::InvoiceReceiver + # extend application classes here CustomContent.prepend SacCas::CustomContent Event.prepend SacCas::Event diff --git a/spec/domain/mailing_lists/subscribers_spec.rb b/spec/domain/mailing_lists/subscribers_spec.rb new file mode 100644 index 000000000..943e60819 --- /dev/null +++ b/spec/domain/mailing_lists/subscribers_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas 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_sac_cas + +require "spec_helper" + +describe MailingLists::Subscribers do + include Subscriptions::SpecHelper + + let!(:list) { + Fabricate(:mailing_list, + group:, + subscribable_for: "configured", + subscribable_mode: "opt_out") + } + let!(:subscription) do + Fabricate(:subscription, + mailing_list: list, + subscriber: group, + role_types: [Mitglied, MitgliedZusatzsektion, Schreibrecht]) + end + + let(:test_person) { create_person(30) } + + subject(:entries) { MailingLists::Subscribers.new(list).people } + + def group_id = group.id + + def set_filter(filter) + list.update!(filter_chain: filter) + end + + def create_person(age = 30, **attrs) = Fabricate(:person, birthday: age.years.ago, **attrs) + + def create_role(type, group, person = test_person, **attrs) + Fabricate(type.sti_name, group: groups(group), person: person, **attrs) + end + + Mitglied = Group::SektionsMitglieder::Mitglied + MitgliedZusatzsektion = Group::SektionsMitglieder::MitgliedZusatzsektion + Schreibrecht = Group::SektionsMitglieder::Schreibrecht + + context "invoice_receiver filter" do + context "with list in top layer" do + let(:group) { groups(:root) } + + context "with invoice_receiver stammsektion filter" do + before { set_filter(invoice_receiver: {stammsektion: true, group_id:}) } + + it "includes member in a sektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes person with non-member role" do + create_role(Schreibrecht, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + end + + context "with invoice_receiver zusatzsektion filter" do + before { set_filter(invoice_receiver: {zusatzsektion: true, group_id:}) } + + it "includes member in a zusatzsektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + create_role(MitgliedZusatzsektion, :matterhorn_mitglieder) + end + + it "excludes member without zusatzsektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + end + + context "with both invoice_receiver filters stammsektion and zusatzsektion" do + before { set_filter(invoice_receiver: {stammsektion: true, zusatzsektion: true, group_id:}) } + + it "includes member in a sektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "includes member in a zusatzsektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + create_role(MitgliedZusatzsektion, :matterhorn_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes person with non-member role" do + create_role(Schreibrecht, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + end + end + + context "with list in sublayer" do + let(:group) { groups(:bluemlisalp) } + + context "with invoice_receiver stammsektion filter" do + before { set_filter(invoice_receiver: {stammsektion: true, group_id:}) } + + it "includes member in same layer" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes member in a lower layer" do + create_role(Mitglied, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes member in a different layer" do + create_role(Mitglied, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + end + + context "with invoice_receiver zusatzsektion filter" do + before { set_filter(invoice_receiver: {zusatzsektion: true, group_id:}) } + + it "includes zusatzsektion member in same layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes zusatzsektion member in a lower layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes zusatzsektion member in a different layer" do + create_role(Mitglied, :bluemlisalp_mitglieder) + create_role(MitgliedZusatzsektion, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + end + + context "with both invoice_receiver filters stammsektion and zusatzsektion" do + before { set_filter(invoice_receiver: {stammsektion: true, zusatzsektion: true, group_id:}) } + + it "includes member in same layer" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "includes zusatzsektion member in same layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + end + end + end +end diff --git a/spec/domain/person/filter/invoice_receiver_spec.rb b/spec/domain/person/filter/invoice_receiver_spec.rb new file mode 100644 index 000000000..caa64796c --- /dev/null +++ b/spec/domain/person/filter/invoice_receiver_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of +# hitobito_sac_cas 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_sac_cas + +require "spec_helper" + +describe Person::Filter::InvoiceReceiver do + let(:user) { people(:admin) } + let(:filter) { described_class.new("attr", filter_args) } + + subject(:entries) { filter.apply(Person.select(:id)) } + + let(:test_person) { create_person(30) } + + def create_person(age = 30, **attrs) = Fabricate(:person, birthday: age.years.ago, **attrs) + + def create_role(type, group, person = test_person, **attrs) + Fabricate(type.sti_name, group: groups(group), person: person, **attrs) + end + + Mitglied = Group::SektionsMitglieder::Mitglied + MitgliedZusatzsektion = Group::SektionsMitglieder::MitgliedZusatzsektion + + context "all filters false" do + let(:filter_args) { {stammsektion: false, zusatzsektion: false} } + + it "does not filter" do + expect(entries.size).to eq(Person.count) + end + end + + context "with group_id in root layer" do + let(:group_id) { groups(:abo_die_alpen).id } + + context "arg only stammsektion" do + let(:filter_args) { {stammsektion: true, zusatzsektion: false, group_id:} } + + it "includes member in a sektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "includes member in a ortsgruppe" do + create_role(Mitglied, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes person with non-member role" do + create_role(Group::SektionsMitglieder::Schreibrecht, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + + it "includes adult member" do + person = create_person(30) + role = create_role(Mitglied, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "adult" + expect(entries).to include(person) + end + + it "includes youth member" do + person = create_person(15) + role = create_role(Mitglied, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "youth" + expect(entries).to include(person) + end + + it "includes main person family member" do + person = create_person(30, sac_family_main_person: true) + role = create_role(Mitglied, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + expect(role.beitragskategorie).to eq "family" + expect(entries).to include(person) + end + + it "excludes non-main person family member" do + person = create_person(30, sac_family_main_person: true) # create as main person first + role = create_role(Mitglied, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + person.update!(sac_family_main_person: false) # change to non-main person + expect(role.beitragskategorie).to eq "family" + expect(entries).not_to include(person) + end + end + + context "arg only zusatzsektion" do + let(:filter_args) { {stammsektion: false, zusatzsektion: true, group_id:} } + + it "includes zusatzsektion member" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes member without zusatzsektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + end + + context "arg both stammsektion and zusatzsektion" do + let(:filter_args) { {stammsektion: true, zusatzsektion: true, group_id:} } + + it "includes member without zusatzsektion" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "includes zusatzsektion member" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + end + end + + context "with group_id in sublayer" do + let(:group_id) { groups(:bluemlisalp).id } + + context "arg only stammsektion" do + let(:filter_args) { {stammsektion: true, zusatzsektion: false, group_id:} } + + it "includes member of layer" do + create_role(Group::SektionsMitglieder::Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes member of another layer" do + create_role(Mitglied, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes member of sublayer" do + create_role(Mitglied, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes zusatzsektion member of layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + + it "includes adult member of layer" do + person = create_person(30) + role = create_role(Mitglied, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "adult" + expect(entries).to include(person) + end + + it "includes youth member of layer" do + person = create_person(15) + role = create_role(Mitglied, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "youth" + expect(entries).to include(person) + end + + it "includes main person family member of layer" do + person = create_person(30, sac_family_main_person: true) + role = create_role(Mitglied, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + expect(role.beitragskategorie).to eq "family" + expect(entries).to include(person) + end + + it "excludes non-main person family member of layer" do + person = create_person(30, sac_family_main_person: true) # create as main person first + role = create_role(Mitglied, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + person.update!(sac_family_main_person: false) # change to non-main person + expect(role.beitragskategorie).to eq "family" + expect(entries).not_to include(person) + end + end + + context "with only zusatzsektion" do + let(:filter_args) { {stammsektion: false, zusatzsektion: true, group_id:} } + + it "includes zusatzsektion member of layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes zusatsektion member of other layer" do + create_role(Mitglied, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + create_role(MitgliedZusatzsektion, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes zusatzsektion member of sublayer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes member of layer" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).not_to include(test_person) + end + + it "includes adult zusatzsektion member of layer" do + person = create_person(30) + create_role(Mitglied, :matterhorn_mitglieder, person: person) + role = create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "adult" + expect(entries).to include(person) + end + + it "includes youth zusatzsektion member of layer" do + person = create_person(15) + create_role(Mitglied, :matterhorn_mitglieder, person: person) + role = create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder, person: person) + expect(role.beitragskategorie).to eq "youth" + expect(entries).to include(person) + end + + it "includes main person family zusatzsektion member of layer" do + person = create_person(30, sac_family_main_person: true) + create_role(Mitglied, :matterhorn_mitglieder, person: person) + role = create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + expect(role.beitragskategorie).to eq "family" + expect(entries).to include(person) + end + + it "excludes non-main person family zusatzsektion member of layer" do + person = create_person(30, sac_family_main_person: true) # create as main person first + create_role(Mitglied, :matterhorn_mitglieder, person: person) + role = create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder, + person: person, + beitragskategorie: "family") + person.update!(sac_family_main_person: false) # change to non-main person + expect(role.beitragskategorie).to eq "family" + expect(entries).not_to include(person) + end + end + + context "with both filters" do + let(:filter_args) { {stammsektion: true, zusatzsektion: true, group_id:} } + + it "includes member of layer" do + create_role(Mitglied, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "includes zusatzsektion member of layer" do + create_role(Mitglied, :matterhorn_mitglieder) + create_role(MitgliedZusatzsektion, :bluemlisalp_mitglieder) + expect(entries).to include(test_person) + end + + it "excludes member of another layer" do + create_role(Mitglied, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + + it "excludes zusatzsektion member of another layer" do + create_role(Mitglied, :bluemlisalp_ortsgruppe_ausserberg_mitglieder) + create_role(MitgliedZusatzsektion, :matterhorn_mitglieder) + expect(entries).not_to include(test_person) + end + end + end +end diff --git a/spec/features/mailing_lists_spec.rb b/spec/features/mailing_lists_spec.rb index 8d6aad221..488091d62 100644 --- a/spec/features/mailing_lists_spec.rb +++ b/spec/features/mailing_lists_spec.rb @@ -23,6 +23,28 @@ def edit_mailing_list(mailing_list) visit edit_group_mailing_list_path(group_id: mailing_list.group_id, id: mailing_list.id) end + context "configuring subscription" do + it "filters has global invoice receiver option" do + sign_in(people(:admin)) + edit_mailing_list(mailing_list) + binding.pry + click_link "Abonnenten" + + find("h2", text: "Globale Bedingungen").click_link + click_link "Rechnungsempfänger" + check "Rechnungsempfänger SAC Mitgliedschaft" + click_button "Speichern" + expect(page).to have_text "Rechnungsempfänger SAC Mitgliedschaft" + + find("h2", text: "Globale Bedingungen").click_link + uncheck "Rechnungsempfänger SAC Mitgliedschaft" + check "Rechnungsempfänger Zusatzsektion" + click_button "Speichern" + expect(page).to have_text "Rechnungsempfänger Zusatzsektion" + expect(page).to have_no_text "Rechnungsempfänger SAC Mitgliedschaft" + end + end + [:root, :admin].each do |person_key| context "as #{person_key}" do let(:user) { people(person_key) }