From: Lunar Date: Sun, 2 Jun 2013 17:00:35 +0000 (+0200) Subject: Implement send-membership-reminders X-Git-Url: https://nos-oignons.net/gitweb/gestion-adh.git/commitdiff_plain/9d982cb5d7f9abe598c8df3eefabe10501056110 Implement send-membership-reminders --- diff --git a/.gitignore b/.gitignore index ab0f804..2ece9d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.bundle /vendor/bundle + +/var/reminders.yaml diff --git a/Gemfile.lock b/Gemfile.lock index f41add5..2728fde 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: nos_oignons (0.0.1.dev) + mail safe_yaml GEM @@ -24,11 +25,19 @@ GEM gherkin (2.12.0) multi_json (~> 1.3) json (1.7.7) + mail (2.5.4) + mime-types (~> 1.16) + treetop (~> 1.4.8) + mime-types (1.23) multi_json (1.7.4) + polyglot (0.3.3) rspec-expectations (2.13.0) diff-lcs (>= 1.1.3, < 2.0) safe_yaml (0.9.2) timecop (0.6.1) + treetop (1.4.12) + polyglot + polyglot (>= 0.3.1) PLATFORMS ruby diff --git a/README b/README index 573ec80..0aafd0a 100644 --- a/README +++ b/README @@ -100,6 +100,15 @@ Met à jour la liste des emails inscrites à la liste ag@ par rapport aux membre lancer les commandes `list_members`, `add_members` et `remove_members` via `sudo` sur le compte `list`. +`send-membership-reminders` +--------------------------- + +Préviens les membres qu'il faut renouveller leur cotisation. + +Le fichier utiliser pour garder la liste des envois déjà effectués est soit +celui indiqué par la variable d'environnement `NOS_OIGNONS_REMINDER_DB`, ou +le fichier si elle est vide `var/reminders.yaml`. + Développement ============= diff --git a/bin/send-membership-reminders b/bin/send-membership-reminders new file mode 100755 index 0000000..b089585 --- /dev/null +++ b/bin/send-membership-reminders @@ -0,0 +1,15 @@ +#!/usr/bin/ruby1.9.1 +#-*- coding: utf-8 -*- + +require 'rubygems' +require 'bundler' +Bundler.setup + +require 'mail' +require 'nos_oignons' + +Mail.defaults do + delivery_method :sendmail +end + +NosOignons.send_membership_reminders! diff --git a/features/send-membership-reminders.feature b/features/send-membership-reminders.feature new file mode 100644 index 0000000..cc8507b --- /dev/null +++ b/features/send-membership-reminders.feature @@ -0,0 +1,62 @@ +# language: fr + +Fonctionnalité: prévenir les membres qu'il faut renouveller leur cotisation + En tant que membre de Nos oignons, j'aimerais être prévenu lorsqu'il est + tant de renouvelle ma cotisation, afin de pouvoir rester membre de + l'association. + + Scénario: Pas de messages + Soit une base avec Jane, à jour de cotisation + Lorsque j'exécute send-membership-reminders + Alors aucun email ne doit avoir été envoyé + + Scénario: Pas de rappel après qu'il soit trop tard + Soit une base avec Pierre qui n'a pas payé sa cotisation cette année + Lorsque j'exécute send-membership-reminders + Alors aucun email ne doit avoir été envoyé + + Scénario: Envoi de l'appel + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 30 jours + Lorsque j'exécute send-membership-reminders + Alors un appel pour la cotisation doit avoir été envoyé + + Scénario: Vérification de l'adresse postale + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 30 jours + Lorsque j'exécute send-membership-reminders + Alors l'appel envoyé doit demander de vérifier l'adresse postale + + Scénario: Pas de messages pour une toute nouvelle adhésion + Soit une nouvelle adhésion de Jane + Lorsque j'exécute send-membership-reminders + Alors aucun email ne doit avoir été envoyé + + Scénario: Un seul envoi par membre + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 30 jours + Lorsque j'exécute send-membership-reminders + Et que j'exécute send-membership-reminders + Alors 1 email doit avoir été envoyé + + Scénario: Un an après + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 30 jours + Et elle avait déjà reçu des appels l'année précédente + Lorsque j'exécute send-membership-reminders + Alors un appel pour la cotisation doit avoir été envoyé + + Scénario: Premier rappel + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 10 jours + Lorsque j'exécute send-membership-reminders + Alors un premier rappel pour la cotisation doit avoir été envoyé + + Scénario: Dernier rappel + Soit une base avec Jane qui doit renouveller sa cotisation d'ici 2 jours + Lorsque j'exécute send-membership-reminders + Alors un dernier rappel pour la cotisation doit avoir été envoyé + + Scénario: Plusieurs messages + Soit une base avec Pierre, à jour de cotisation + Et avec Jane qui doit renouveller sa cotisation d'ici 10 jours + Et avec Fatima qui doit renouveller sa cotisation d'ici 2 jours + Et avec Fred qui doit renouveller sa cotisation d'ici 10 jours + Et avec Moly qui doit renouveller sa cotisation d'ici 30 jours + Lorsque j'exécute send-membership-reminders + Alors 4 emails doivent avoir été envoyés diff --git a/features/step_definitions/commands.rb b/features/step_definitions/commands.rb index 26886e6..221523a 100644 --- a/features/step_definitions/commands.rb +++ b/features/step_definitions/commands.rb @@ -21,6 +21,10 @@ When /^j'exécute update-ag-subscribers$/ do run_simple 'update-ag-subscribers' end +When /^(?:que )?j'exécute send-membership-reminders$/ do + NosOignons.send_membership_reminders! +end + Then /^je ne dois pas avoir eu d'erreur$/ do assert_exit_status(0) end diff --git a/features/step_definitions/emails.rb b/features/step_definitions/emails.rb new file mode 100644 index 0000000..9ed3f98 --- /dev/null +++ b/features/step_definitions/emails.rb @@ -0,0 +1,29 @@ +#-*- coding: utf-8 -*- + +Then /^aucun email ne doit avoir été envoyé$/ do + Mail::TestMailer.deliveries.should be_empty +end + +Then /^un appel pour la cotisation doit avoir été envoyé$/ do + Mail::TestMailer.deliveries.should have(1).email + mail = Mail::TestMailer.deliveries.first + expect(mail.from).to eql([NosOignons::BOARD_EMAIL]) + expect(mail.to).to eql([@last_member['email']]) + expect(mail.subject).to include('Renouvellement de votre cotisation') + expect(mail.body).to include(@last_member['name']) + expect(mail.body).to include('30 jours') +end + +Then /^l'appel envoyé doit demander de vérifier l'adresse postale$/ do + mail = Mail::TestMailer.deliveries.first + expect(mail.body.to_s.gsub(/^ */, '')).to include(@last_member['address']) +end + +Then /^un (premier|dernier) rappel pour la cotisation doit avoir été envoyé$/ do |kind| + mail = Mail::TestMailer.deliveries.first + expect(mail.subject).to include("(#{kind == 'premier' ? '' : 'dernier '}rappel)") +end + +Then /^(\d+) emails? (?:doit|doivent) avoir été envoyés?$/ do |count| + Mail::TestMailer.deliveries.should have(count).email +end diff --git a/features/step_definitions/members.rb b/features/step_definitions/members.rb index 5bcc39c..6e84484 100644 --- a/features/step_definitions/members.rb +++ b/features/step_definitions/members.rb @@ -7,15 +7,15 @@ def init_db end def create_new_member(name, joined_on, paid_on) - data = { 'name' => name, - 'address' => "At #{name}", - 'email' => "#{name.downcase}@example.org", - 'joined_on' => joined_on, - 'membership_fee_paid_on' => paid_on + @last_member = { 'name' => name, + 'address' => "At #{name}", + 'email' => "#{name.downcase}@example.org", + 'joined_on' => joined_on, + 'membership_fee_paid_on' => paid_on } init_db unless @member_db_path file = member_filename_for_id(new_id) - write_file file, render_member_file(data) + write_file file, render_member_file(@last_member) end Given /une base de membres vide$/ do @@ -39,11 +39,23 @@ Given /^une base avec (\w+) qui a adhéré le ([0-9-]+) et payé sa dernière co create_new_member(name, joined_on, paid_on) end +Given /^(?:une base )?avec (\w+) qui doit renouveller sa cotisation d'ici (\d+) jours$/ do |name, days_before_anniversary| + paid_on = Time.now.to_date - days_before_anniversary.to_i + joined_on = paid_on + create_new_member(name, joined_on, paid_on) +end + Given /^une nouvelle adhésion de (\w+)$/ do |name| joined_on = Time.now.strftime('%Y-%m-%d') create_new_member(name, joined_on, joined_on) end +Given /^elle avait déjà reçu des appels l'année précédente$/ do + Timecop.travel(Time.now.to_date - 375) do + NosOignons::ReminderDb.instance.record(OpenStruct.new(@last_member)) + end +end + When /^j'ajoute une fiche correcte pour une nouvelle adhésion$/ do @file = member_filename_for_id(new_id) write_file @file, render_member_file(EXTRA_MEMBER) diff --git a/features/support/env.rb b/features/support/env.rb index 154fc30..210137a 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -6,6 +6,7 @@ require 'timecop' require 'tmpdir' require 'aruba/cucumber' require 'safe_yaml' +require 'mail' SafeYAML::OPTIONS[:default_mode] = :safe @@ -16,11 +17,19 @@ Before do ENV['GIT_COMMITTER_EMAIL'] = ENV['GIT_AUTHOR_EMAIL'] = 'test@example.org' @tmpdir = Dir.mktmpdir('gestion-adh') @dirs = [@tmpdir] - @aruba_io_wait_seconds = 1 + @aruba_io_wait_seconds = 0.1 @orig_wiki_path = ENV['NOS_OIGNONS_BOARD_WIKI_PATH'] + @orig_reminder_db_path = ENV['NOS_OIGNONS_REMINDER_DB'] + ENV['NOS_OIGNONS_REMINDER_DB'] = File.join(current_dir, 'reminders.yaml') + NosOignons::ReminderDb.instance.reload! + Mail.defaults do + delivery_method :test + end + Mail::TestMailer.deliveries.clear end After do + ENV['NOS_OIGNONS_REMINDER_DB'] = @orig_reminder_db_path ENV['NOS_OIGNONS_BOARD_WIKI_PATH'] = @orig_wiki_path FileUtils.remove_entry_secure @tmpdir end diff --git a/lib/nos_oignons.rb b/lib/nos_oignons.rb index 028cac9..26ffdc6 100644 --- a/lib/nos_oignons.rb +++ b/lib/nos_oignons.rb @@ -3,8 +3,13 @@ require 'nos_oignons/git' require 'nos_oignons/mailman' require 'nos_oignons/member' +require 'nos_oignons/reminder' +require 'nos_oignons/reminder_db' module NosOignons + BOARD_EMAIL = 'ca@nos-oignons.net' + MEMBER_MAILING_LIST = 'ag' + # The following class methods are all meant to be called as command-line scripts class << self def list_emails! @@ -15,13 +20,32 @@ module NosOignons end end - MEMBER_MAILING_LIST = 'ag' def update_ag_subscribers! - current_emails = NosOignons::Mailman.list_members(MEMBER_MAILING_LIST) + list = NosOignons::MEMBER_MAILING_LIST + + current_emails = NosOignons::Mailman.list_members(list) uptodate_emails = NosOignons::Member.all.select(&:up_to_date?).collect(&:email) - NosOignons::Mailman.add_members(MEMBER_MAILING_LIST, uptodate_emails - current_emails) - NosOignons::Mailman.remove_members(MEMBER_MAILING_LIST, current_emails - uptodate_emails) + NosOignons::Mailman.add_members(list, uptodate_emails - current_emails) + NosOignons::Mailman.remove_members(list, current_emails - uptodate_emails) + end + + def send_membership_reminders! + today = Time.now.to_date + NosOignons::Member.all.select(&:up_to_date?).each do |member| + NosOignons::Reminder.all.sort_by(&:days).reverse.each do |reminder| + anniversary = Time.new(today.year, member.joined_on.month, + member.joined_on.day).to_date + + next if member.membership_fee_paid_on > anniversary + next if member.membership_fee_paid_on > today - reminder.days + next if anniversary > today - reminder.days + next if member.reminded_on && member.reminded_on >= today + + member.remind(reminder) + break + end + end end def pre_commit_hook! diff --git a/lib/nos_oignons/member.rb b/lib/nos_oignons/member.rb index 7e6254e..46fc41b 100644 --- a/lib/nos_oignons/member.rb +++ b/lib/nos_oignons/member.rb @@ -1,6 +1,8 @@ require 'safe_yaml' SafeYAML::OPTIONS[:default_mode] = :safe +require 'nos_oignons/reminder_db' + module NosOignons MEMBER_FIELDS = [:name, :address, :email, :joined_on, :membership_fee_paid_on] MEMBER_MANDATORY_FIELDS = [:name, :email] @@ -78,9 +80,18 @@ module NosOignons def up_to_date? return false if !joined_on || !membership_fee_paid_on - now = Time.now.to_date + today = Time.now.to_date expire_on = Time.new(membership_fee_paid_on.year + 1, joined_on.month, joined_on.day).to_date - now <= expire_on + today <= expire_on + end + + def remind(reminder) + reminder.send(self) + ReminderDb.instance.record(self) + end + + def reminded_on + ReminderDb.instance.last_reminder(self) end end end diff --git a/lib/nos_oignons/reminder.rb b/lib/nos_oignons/reminder.rb new file mode 100644 index 0000000..068a667 --- /dev/null +++ b/lib/nos_oignons/reminder.rb @@ -0,0 +1,87 @@ +#-*- coding: utf-8 -*- + +module NosOignons + class Reminder < Struct.new(:days, :subject, :template) + class << self + def all + return @all if @all + + @all = [] + @all << Reminder.new( + 30, + '[Nos oignons] Renouvellement de votre cotisation', + <<-END_OF_ERB.gsub(/^ /, '')) + Salut <%= member.name %>, + + La date anniversaire de votre adhésion à Nos oignons est dans <%= days %> jours. + Il est donc temps de renouveller votre cotisation si vous souhaitez rester + membre de l'association. Si vous avez besoin de plus d'informations sur + comment faire, écrivez au conseil d'administration. Répondre à cet email + devrait faire l'affaire. + + Au passage, est-ce que vous pourriez vérifier que l'adresse postale est + toujours bonne ? Voici celle que nous avons retenu : + + <%= member.address.gsub(/^/, ' ') %> + + Si ce n'est plus le cas, c'est chouette de le signaler. + + Au plaisir de continuer l'aventure de Nos oignons avec vous, + -- + Le robot du conseil d'administration + END_OF_ERB + @all << Reminder.new( + 10, + '[Nos oignons] Renouvellement de votre cotisation (rappel)', + <<-END_OF_ERB.gsub(/^ /, '')) + Salut <%= member.name %>, + + La date anniversaire de ton adhésion à Nos oignons est dans <%= days %> jours. + Si vous ne renouvellez pas votre cotisation d'ici là, vous perdrez la qualité + de membre de Nos oignons. + + Pour plus d'informations sur comment faire pour renouveller sa cotisation, + c'est possible d'écrire au conseil d'administration. Répondre à ce mail + devrait fonctionner. + + À bientôt ? + -- + Le robot du conseil d'administration + END_OF_ERB + @all << Reminder.new( + 2, + '[Nos oignons] Renouvellement de votre cotisation (dernier rappel)', + <<-END_OF_ERB.gsub(/^ /, '')) + Salut <%= member.name %>, + + Dans <%= days %> jours, c'est la date anniversaire de ton adhésion à Nos oignons. + Vu que vous n'avez pas renouvellé votre cotisation, cela signifie que vous + allez bientôt quitter l'association… + + Mais il n'est pas encore trop tard pour le faire ! + + Des questions ? Mieux vaut les poser au conseil d'administration. En répondant à + cet email par exemple. + + Et sinon… bonne route ! :) + + Bien à vous, + -- + Le robot du conseil d'administration + END_OF_ERB + @all + end + end + + def send(member) + locals = { :member => member, :days => days } + body = ERB.new(template).result(OpenStruct.new(locals).instance_eval { binding }) + mail = Mail.new :charset => 'utf-8', + :from => NosOignons::BOARD_EMAIL, + :to => member.email, + :subject => subject, + :body => body + mail.deliver + end + end +end diff --git a/lib/nos_oignons/reminder_db.rb b/lib/nos_oignons/reminder_db.rb new file mode 100644 index 0000000..6052875 --- /dev/null +++ b/lib/nos_oignons/reminder_db.rb @@ -0,0 +1,52 @@ +#-*- coding: utf-8 -*- + +require 'singleton' +require 'tempfile' + +module NosOignons + class ReminderDb + DEFAULT_PATH = File.expand_path('../../../var/reminders.yaml', __FILE__) + + include Singleton + + def db_path + ENV['NOS_OIGNONS_REMINDER_DB'] || DEFAULT_PATH + end + + def initialize + reload! + end + + def reload! + # hash of email => last_reminder_date + begin + @reminders = YAML.load_file(db_path) + rescue Errno::ENOENT + @reminders = {} + end + end + + def save + # save using atomic rename + file = Tempfile.new('reminder_db', File.dirname(db_path)) + begin + file.write(YAML.dump(@reminders)) + file.close + File.rename file.path, db_path + rescue + file.unlink + raise + end + end + + def last_reminder(member) + @reminders[member.email] + end + + def record(member) + @reminders[member.email] = Time.now.to_date + # Not efficient, but let's put people's mailbox as first priority + save + end + end +end diff --git a/nos_oignons.gemspec b/nos_oignons.gemspec index a858422..83b7ea2 100644 --- a/nos_oignons.gemspec +++ b/nos_oignons.gemspec @@ -8,4 +8,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'json', '~> 1.7.7' s.add_development_dependency 'timecop' s.add_runtime_dependency 'safe_yaml' + s.add_runtime_dependency 'mail' end diff --git a/var/.placeholder b/var/.placeholder new file mode 100644 index 0000000..e69de29