def create_offer(self, captcha_response, name, country, amount, min_amount, charity, email, expiration): errors = util.Template('errors-and-warnings.json') if not self.automation_mode and not self._captcha.is_legit(self._ip_address, captcha_response): raise DonationException(errors.json('bad captcha')) name, country, amount, min_amount, charity, email, expires_ts = self._validate_offer(name, country, amount, min_amount, charity, email, expiration) secret = create_secret() # Do NOT return this secret to the client via this method. # Only put it in the email, so that having the link acts as email address verification. with self._database.connect() as db: offer = entities.Offer.create(db, secret, name, email, country.id, amount, min_amount, charity.id, expires_ts) eventlog.created_offer(db, offer) if self.automation_mode: return offer replacements = { '{%NAME%}': offer.name, '{%SECRET%}': offer.secret, '{%CHARITY%}': offer.charity.name, '{%CURRENCY%}': offer.country.currency.iso, '{%AMOUNT%}': offer.amount, '{%MIN_AMOUNT%}': offer.min_amount, } self._mail.send( util.Template('email-subjects.json').json('new-post-email'), util.Template('new-post-email.txt').replace(replacements).content, html=util.Template('new-post-email.html').replace(replacements).content, to=email ) return None
def _get_gift_aid_insert(offer, to_charity_amount, charity_receiving): if offer.country.gift_aid_multiplier <= 1: return "", "" # hardcoding might be bad practice, # but if we have to do more work due to more gift aid, it's not such a bad thing, # I'm also happy to move this to the database eventually gift_aid_name = "UK government Gift Aid" if offer.country.iso_name == "IE": gift_aid_name = "Irish government contribution" replacements = { '{%GIFT_AID_NAME%}': gift_aid_name, '{%GIFT_AID_AMOUNT%}': offer.country.gift_aid, '{%TO_CHARITY%}': to_charity_amount, '{%CURRENCY%}': offer.country.currency.iso, '{%CHARITY_NAME%}': charity_receiving, } txt = util.Template('gift-aid-insert.txt').replace( replacements).content html = util.Template('gift-aid-insert.html').replace( replacements).content return txt, html
def _send_mail_about_expired_offer(self, offer): newExpirey = offer.expires_ts + (offer.expires_ts - offer.created_ts) replacements = { '{%NAME%}': offer.name, '{%AMOUNT%}': offer.amount, '{%MIN_AMOUNT%}': offer.min_amount, '{%CURRENCY%}': offer.country.currency.iso, '{%CHARITY%}': offer.charity.name, '{%ARGS%}': '#%s' % urllib.parse.quote(json.dumps({ 'name': offer.name, 'country': offer.country_id, 'amount': offer.amount, 'min_amount': offer.min_amount, 'charity': offer.charity_id, 'email': offer.email, 'expires': { 'day': newExpirey.day, 'month': newExpirey.month, 'year': newExpirey.year, } })) } self._mail.send( util.Template('email-subjects.json').json('offer-expired-email'), util.Template('offer-expired-email.txt').replace(replacements).content, html=util.Template('offer-expired-email.html').replace(replacements).content, to=offer.email )
def _send_mail_about_expired_offer(self, offer): replacements = { '{%NAME%}': offer.name, '{%AMOUNT%}': offer.amount, '{%MIN_AMOUNT%}': offer.min_amount, '{%CURRENCY%}': offer.country.currency.iso, '{%CHARITY%}': offer.charity.name, '{%ARGS%}': '#%s' % urllib.parse.quote( json.dumps({ 'country': offer.country_id, 'amount': offer.amount, 'charity': offer.charity_id, 'email': offer.email, })) } self._mail.send( util.Template('email-subjects.json').json('offer-expired-email'), util.Template('offer-expired-email.txt').replace( replacements).content, html=util.Template('offer-expired-email.html').replace( replacements).content, to=offer.email)
def send_contact_message(self, captcha_response, message, name=None, email=None): if not self.automation_mode and not self._captcha.is_legit(self._ip_address, captcha_response): raise DonationException( util.Template('errors-and-warnings.json').json('bad captcha') ) tmp = util.Template('contact-email.txt') tmp.replace({ '{%IP_ADDRESS%}': self._ip_address, '{%COUNTRY%}': self._geoip.lookup(self._ip_address), '{%NAME%}': name or 'n/a', '{%EMAIL%}': email or 'n/a', '{%MESSAGE%}': message.strip(), }) send_to = self._config.contact_message_receivers.get('to', []) send_cc = self._config.contact_message_receivers.get('cc', []) send_bcc = self._config.contact_message_receivers.get('bcc', []) with self._database.connect() as db: eventlog.sent_contact_message(db, tmp.content, send_to, send_cc, send_bcc) self._mail.send( 'Message for donationswap.eahub.org', tmp.content, to=send_to, cc=send_cc, bcc=send_bcc )
def _send_mail_about_match(self, my_offer, their_offer, match_secret): my_actual_amount, _ = self._get_actual_amounts(my_offer, their_offer) replacements = { '{%YOUR_NAME%}': my_offer.name, '{%YOUR_CHARITY%}': my_offer.charity.name, '{%YOUR_AMOUNT%}': my_offer.amount, '{%YOUR_MIN_AMOUNT%}': my_offer.min_amount, '{%YOUR_ACTUAL_AMOUNT%}': my_actual_amount, '{%YOUR_CURRENCY%}': my_offer.country.currency.iso, '{%THEIR_CHARITY%}': their_offer.charity.name, '{%SECRET%}': '%s%s' % (my_offer.secret, match_secret), # Do NOT put their email address here. # Wait until both parties approved the match. } logging.info('Sending match email to %s.', my_offer.email) self._mail.send( util.Template('email-subjects.json').json('match-suggested-email'), util.Template('match-suggested-email.txt').replace( replacements).content, html=util.Template('match-suggested-email.html').replace( replacements).content, to=my_offer.email)
def test_new_post_email(self): placeholders = [ '{%NAME%}', '{%SECRET%}', '{%CHARITY%}', '{%CURRENCY%}', '{%AMOUNT%}', '{%MIN_AMOUNT%}', ] txt = util.Template('new-post-email.txt').content self._check_expected_placeholders(txt, placeholders) txt = util.Template('new-post-email.html').content self._check_expected_placeholders(txt, placeholders)
def test_match_suggested_email(self): placeholders = [ '{%YOUR_NAME%}', '{%YOUR_CHARITY%}', '{%YOUR_AMOUNT%}', '{%YOUR_MIN_AMOUNT%}', '{%YOUR_ACTUAL_AMOUNT%}', '{%YOUR_CURRENCY%}', '{%THEIR_CHARITY%}', '{%SECRET%}', ] txt = util.Template('match-suggested-email.txt').content self._check_expected_placeholders(txt, placeholders) txt = util.Template('match-suggested-email.html').content self._check_expected_placeholders(txt, placeholders)
def test_match_appoved_email(self): placeholders_txt = [ '{%NAME_A%}', '{%COUNTRY_A%}', '{%CHARITY_A%}', '{%ACTUAL_AMOUNT_A%}', '{%CURRENCY_A%}', '{%EMAIL_A%}', '{%INSTRUCTIONS_A%}', '{%NAME_B%}', '{%COUNTRY_B%}', '{%CHARITY_B%}', '{%ACTUAL_AMOUNT_B%}', '{%CURRENCY_B%}', '{%EMAIL_B%}', '{%INSTRUCTIONS_B%}', '{%ONE_CURRENCY_A_AS_B%}', '{%TO_CHARITY_A%}', '{%TO_CHARITY_B%}', '{%GIFT_AID_INSERT_A_TXT%}', '{%GIFT_AID_INSERT_B_TXT%}', ] placeholders_html = [ '{%NAME_A%}', '{%COUNTRY_A%}', '{%CHARITY_A%}', '{%ACTUAL_AMOUNT_A%}', '{%CURRENCY_A%}', '{%EMAIL_A%}', '{%INSTRUCTIONS_A%}', '{%NAME_B%}', '{%COUNTRY_B%}', '{%CHARITY_B%}', '{%ACTUAL_AMOUNT_B%}', '{%CURRENCY_B%}', '{%EMAIL_B%}', '{%INSTRUCTIONS_B%}', '{%ONE_CURRENCY_A_AS_B%}', '{%TO_CHARITY_A%}', '{%TO_CHARITY_B%}', '{%GIFT_AID_INSERT_A_HTML%}', '{%GIFT_AID_INSERT_B_HTML%}', ] txt = util.Template('match-approved-email.txt').content self._check_expected_placeholders(txt, placeholders_txt) txt = util.Template('match-approved-email.html').content self._check_expected_placeholders(txt, placeholders_html)
def confirm_offer(self, secret): offer = entities.Offer.by_secret(secret) if offer is None: return None # caller knows the secret (which we emailed) # => caller received email # => email address is valid # caller clicked on link we emailed # => offer is confirmed # => mark it as confirmed in db, and try to find a match for it. was_confirmed = offer.confirmed if not was_confirmed: with self._database.connect() as db: offer.confirm(db) eventlog.confirmed_offer(db, offer) replacements = { '{%CHARITY%}': offer.charity.name, '{%CURRENCY%}': offer.country.currency.iso, '{%AMOUNT%}': offer.amount, '{%MIN_AMOUNT%}': offer.min_amount, '{%COUNTRY%}': offer.country.name } self._mail.send( util.Template('email-subjects.json').json('post-confirmed-email'), util.Template('post-confirmed-email.txt').replace(replacements).content, html=util.Template('post-confirmed-email.html').replace(replacements).content, to=self._config.contact_message_receivers['to'] ) return { 'was_confirmed': was_confirmed, 'name': offer.name, 'currency': offer.country.currency.iso, 'amount': offer.amount, 'min_amount': offer.min_amount, 'charity': offer.charity.name, 'created_ts': offer.created_ts.isoformat(' '), 'expires_ts': offer.expires_ts.isoformat(' '), }
def test_contact_email(self): placeholders = [ '{%IP_ADDRESS%}', '{%COUNTRY%}', '{%NAME%}', '{%EMAIL%}', '{%MESSAGE%}', ] txt = util.Template('contact-email.txt').content self._check_expected_placeholders(txt, placeholders)
def test_errors_and_warnings(self): data = util.Template('errors-and-warnings.json').json() self.assertTrue('bad amount' in data) self.assertTrue('bad captcha' in data) self.assertTrue('bad email address' in data) self.assertTrue('bad expiration date' in data) self.assertTrue('bad min_amount' in data) self.assertTrue('charity not found' in data) self.assertTrue('country not found' in data) self.assertTrue('match not found' in data) self.assertTrue('no name provided' in data)
def get_page(self, name): content = util.Template(name).content # This acts as a cache breaker -- just increment # self.STATIC_VERSION whenever a static file has changed, # so the client will know to re-request it from the server. # The only exception are files referenced in style.css, # which must be handled manually. content = re.sub('src="/static/(.*?)"', lambda m: 'src="/static/%s?v=%s"' % (m.group(1), self.STATIC_VERSION), content) content = re.sub('href="/static/(.*?)"', lambda m: 'href="/static/%s?v=%s"' % (m.group(1), self.STATIC_VERSION), content) return content
def _validate_offer(self, name, country, amount, min_amount, charity, email, expiration): errors = util.Template('errors-and-warnings.json') name = name.strip() if not name: raise DonationException(errors.json('no name provided')) country = entities.Country.by_id(country) if country is None: raise DonationException(errors.json('country not found')) amount = self._int(amount, errors.json('bad amount')) if amount < 0: raise DonationException(errors.json('bad amount')) min_amount = self._int(min_amount, errors.json('bad min_amount')) if min_amount < 0: raise DonationException(errors.json('bad min_amount')) if min_amount > amount: raise DonationException(errors.json('min_amount_larger')) min_allowed_amount = self._currency.convert( country.min_donation_amount, country.min_donation_currency, country.currency) if min_amount < min_allowed_amount: raise DonationException( errors.json('min_amount_too_small') % (country.min_donation_amount, country.min_donation_currency.iso)) charity = entities.Charity.by_id(charity) if charity is None: raise DonationException(errors.json('charity not found')) email = email.strip() if not re.fullmatch(r'.+?@.+\..+', email): raise DonationException(errors.json('bad email address')) expires_ts = '%04i-%02i-%02i' % ( self._int(expiration['year'], errors.json('bad expiration date')), self._int(expiration['month'], errors.json('bad expiration date')), self._int(expiration['day'], errors.json('bad expiration date')), ) try: expires_ts = datetime.datetime.strptime(expires_ts, '%Y-%m-%d') except ValueError: raise DonationException(errors.json('bad expiration date')) return name, country, amount, min_amount, charity, email, expires_ts
def decline_match(self, secret, feedback): match, old_offer, new_offer, my_offer, other_offer = self._get_match_and_offers( secret) if match is None: # this error is shown directly to the user, don't put any sensitive details in it! raise DonationException( util.Template('errors-and-warnings.json').json( 'match not found')) with self._database.connect() as db: query = ''' do $$ begin IF NOT EXISTS (SELECT * FROM declined_matches WHERE new_offer_id=%(id_old)s AND old_offer_id=%(id_new)s) THEN INSERT INTO declined_matches (new_offer_id, old_offer_id) VALUES (%(id_old)s, %(id_new)s); END IF; end $$; ''' db.write(query, id_old=old_offer.id, id_new=new_offer.id) match.delete(db) my_offer.suspend(db) eventlog.declined_match(db, match, my_offer, feedback) replacements = { '{%NAME%}': my_offer.name, '{%OFFER_SECRET%}': my_offer.secret, } self._mail.send( util.Template('email-subjects.json').json( 'match-decliner-email'), util.Template('match-decliner-email.txt').replace( replacements).content, html=util.Template('match-decliner-email.html').replace( replacements).content, to=my_offer.email) email_subject = 'match-declined-email' if other_offer == old_offer and match.old_agrees: email_subject = 'match-approved-declined-email' elif other_offer == new_offer and match.new_agrees: email_subject = 'match-approved-declined-email' replacements = { '{%NAME%}': other_offer.name, '{%OFFER_SECRET%}': other_offer.secret, } self._mail.send( util.Template('email-subjects.json').json(email_subject), util.Template('match-declined-email.txt').replace( replacements).content, html=util.Template('match-declined-email.html').replace( replacements).content, to=other_offer.email)
def approve_match(self, secret): match, old_offer, new_offer, my_offer, _ = self._get_match_and_offers(secret) if match is None: # this error is shown directly to the user, don't put any sensitive details in it! raise DonationException( util.Template('errors-and-warnings.json').json('match not found') ) with self._database.connect() as db: if my_offer == old_offer: match.agree_old(db) eventlog.approved_match(db, match, my_offer) elif my_offer == new_offer: match.agree_new(db) eventlog.approved_match(db, match, my_offer) if match.old_agrees and match.new_agrees: self._send_mail_about_approved_match(match, old_offer, new_offer, db)
def _send_feedback_email(self, match, db): new_offer = entities.Offer.by_id(match.new_offer_id) old_offer = entities.Offer.by_id(match.old_offer_id) new_actual_amount, old_actual_amount = self._get_actual_amounts(match, new_offer, old_offer, db) new_replacements = { '{%NAME%}': new_offer.name, '{%NAME_OTHER%}': old_offer.name, '{%AMOUNT%}': new_actual_amount, '{%CURRENCY%}': new_offer.country.currency.iso, '{%CHARITY%}': new_offer.charity.name, '{%AMOUNT_OTHER%}': old_actual_amount, '{%CURRENCY_OTHER%}': old_offer.country.currency.iso, '{%CHARITY_OTHER%}': old_offer.charity.name, '{%OFFER_SECRET%}': urllib.parse.quote(new_offer.secret) } old_replacements = { '{%NAME%}': old_offer.name, '{%NAME_OTHER%}': new_offer.name, '{%AMOUNT%}':old_actual_amount, '{%CURRENCY%}': old_offer.country.currency.iso, '{%CHARITY%}': old_offer.charity.name, '{%AMOUNT_OTHER%}': new_actual_amount, '{%CURRENCY_OTHER%}': new_offer.country.currency.iso, '{%CHARITY_OTHER%}': new_offer.charity.name, '{%OFFER_SECRET%}': urllib.parse.quote(old_offer.secret) } self._mail.send( util.Template('email-subjects.json').json('feedback-email'), util.Template('feedback-email.txt').replace(new_replacements).content, html=util.Template('feedback-email.html').replace(new_replacements).content, to=new_offer.email) self._mail.send( util.Template('email-subjects.json').json('feedback-email'), util.Template('feedback-email.txt').replace(old_replacements).content, html=util.Template('feedback-email.html').replace(old_replacements).content, to=old_offer.email)
def _send_mail_about_approved_match(self, match, offer_a, offer_b, db): actual_amount_a, actual_amount_b = self._get_actual_amounts(match, offer_a, offer_b, db) to_charity_a = actual_amount_a * offer_a.country.gift_aid_multiplier to_charity_b = actual_amount_b * offer_b.country.gift_aid_multiplier tmp = entities.CharityInCountry.by_charity_and_country_id( offer_b.charity.id, offer_a.country.id) if tmp is not None: instructions_a = tmp.instructions else: instructions_a = 'Sorry, there are no instructions available (yet).' tmp = entities.CharityInCountry.by_charity_and_country_id( offer_a.charity.id, offer_b.country.id) if tmp is not None: instructions_b = tmp.instructions else: instructions_b = 'Sorry, there are no instructions available (yet).' gift_aid_insert_a_txt, gift_aid_insert_a_html = self._get_gift_aid_insert(offer_a, to_charity_a, offer_b.charity.name) gift_aid_insert_b_txt, gift_aid_insert_b_html = self._get_gift_aid_insert(offer_b, to_charity_b, offer_a.charity.name) currency_a_as_b = self._currency.convert( 1000, offer_a.country.currency.iso, offer_b.country.currency.iso) / 1000.0 replacements = { '{%NAME_A%}': offer_a.name, '{%COUNTRY_A%}': offer_a.country.name, '{%CHARITY_A%}': offer_a.charity.name, '{%ACTUAL_AMOUNT_A%}': actual_amount_a, # the amount A donates '{%CURRENCY_A%}': offer_a.country.currency.iso, '{%EMAIL_A%}': offer_a.email, '{%INSTRUCTIONS_A%}': instructions_a, '{%TO_CHARITY_A%}': to_charity_a, # the amount received from A's donation '{%GIFT_AID_INSERT_A_TXT%}': gift_aid_insert_a_txt, '{%GIFT_AID_INSERT_A_HTML%}': gift_aid_insert_a_html, '{%ONE_CURRENCY_A_AS_B%}': currency_a_as_b, '{%NAME_B%}': offer_b.name, '{%COUNTRY_B%}': offer_b.country.name, '{%CHARITY_B%}': offer_b.charity.name, '{%ACTUAL_AMOUNT_B%}': actual_amount_b, '{%CURRENCY_B%}': offer_b.country.currency.iso, '{%EMAIL_B%}': offer_b.email, '{%INSTRUCTIONS_B%}': instructions_b, '{%TO_CHARITY_B%}': to_charity_b, '{%GIFT_AID_INSERT_B_TXT%}': gift_aid_insert_b_txt, '{%GIFT_AID_INSERT_B_HTML%}': gift_aid_insert_b_html, } logging.info('Sending deal email to %s and %s.', offer_a.email, offer_b.email) self._mail.send( util.Template('email-subjects.json').json('match-approved-email'), util.Template('match-approved-email.txt').replace(replacements).content, html=util.Template('match-approved-email.html').replace(replacements).content, to=[offer_a.email, offer_b.email] )
def test_email_subjects(self): data = util.Template('email-subjects.json').json() self.assertTrue('match-declined-email' in data) self.assertTrue('match-approved-declined-email' in data) self.assertTrue('match-decliner-email' in data) self.assertTrue('new-post-email' in data)
def _send_mail_about_unconfirmed_matches(self, match): new_offer = entities.Offer.by_id(match.new_offer_id) old_offer = entities.Offer.by_id(match.old_offer_id) new_replacements = { '{%NAME%}': new_offer.name, '{%OFFER_SECRET%}': new_offer.secret, '{%ARGS%}': '#%s' % urllib.parse.quote(json.dumps({ 'name': new_offer.name, 'country': new_offer.country_id, 'amount': new_offer.amount, 'min_amount': new_offer.min_amount, 'charity': new_offer.charity_id, 'email': new_offer.email, 'expires': { 'day': new_offer.expires_ts.day, 'month': new_offer.expires_ts.month, 'year': new_offer.expires_ts.year, } })) } old_replacements = { '{%NAME%}': old_offer.name, '{%OFFER_SECRET%}': old_offer.secret, '{%ARGS%}': '#%s' % urllib.parse.quote(json.dumps({ 'name': old_offer.name, 'country': old_offer.country_id, 'amount': old_offer.amount, 'min_amount': old_offer.min_amount, 'charity': old_offer.charity_id, 'email': old_offer.email, 'expires': { 'day': old_offer.expires_ts.day, 'month': old_offer.expires_ts.month, 'year': old_offer.expires_ts.year, } })) } #TODO: needs args applied to a new offer rather than reconfirming old offer if (match.new_agrees == True): self._mail.send( util.Template('email-subjects.json').json('match-unconfirmed-email'), util.Template('match-unconfirmed-email.txt').replace(new_replacements).content, html=util.Template('match-unconfirmed-email.html').replace(new_replacements).content, to=new_offer.email) else: self._mail.send( util.Template('email-subjects.json').json('match-unconfirmer-email'), util.Template('match-unconfirmer-email.txt').replace(new_replacements).content, html=util.Template('match-unconfirmer-email.html').replace(new_replacements).content, to=new_offer.email) if (match.old_agrees == True): self._mail.send( util.Template('email-subjects.json').json('match-unconfirmed-email'), util.Template('match-unconfirmed-email.txt').replace(old_replacements).content, html=util.Template('match-unconfirmed-email.html').replace(old_replacements).content, to=old_offer.email) else: self._mail.send( util.Template('email-subjects.json').json('match-unconfirmer-email'), util.Template('match-unconfirmer-email.txt').replace(old_replacements).content, html=util.Template('match-unconfirmer-email.html').replace(old_replacements).content, to=old_offer.email)