From bc391abd6961e9865ea847939b654bbd58ed5232 Mon Sep 17 00:00:00 2001 From: levi makwei Date: Sun, 21 Apr 2024 22:32:35 -0500 Subject: [PATCH] This commit introduces a new feature that checks if participants' income over the past four weeks meets or exceeds their stated weekly income goals. If the condition is met and the participant hasn't been notified recently, a notification is sent. Changes include: - Updated SQL schema to add 'weekly_income_goal' to the 'participants' table and create an 'income_notifications' table for tracking sent notifications. - Added methods in the model: - to perform the check against the set income goal. - to calculate the total received income within a specific date range. - to determine if a notification was recently sent. - to handle the sending of notifications and record them in the database. - Implemented tests in a new file : - Test for the scenario where the income goal is met and a notification is sent. - Test for the scenario where the income goal is not met. - Test to ensure no notification is sent if one was sent recently. These additions enhance user engagement by actively notifying users about their income status in relation to their goals, improving the platform's support for financial tracking and user motivation. --- liberapay/constants.py | 1 + liberapay/main.py | 1 + liberapay/models/participant.py | 72 +++++++++++++++++++++++++++++++++ sql/schema.sql | 13 ++++++ tests/py/test_incomegoals.py | 67 ++++++++++++++++++++++++++++++ tests/py/test_participant.py | 2 + 6 files changed, 156 insertions(+) create mode 100644 tests/py/test_incomegoals.py diff --git a/liberapay/constants.py b/liberapay/constants.py index 22d5d1055..8d84d7755 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -128,6 +128,7 @@ def generate_value(self, currency): Event('upcoming_debit', 2**14, _("When an automatic donation renewal payment is upcoming")), Event('missing_route', 2**15, _("When I no longer have any valid payment instrument")), Event('renewal_aborted', 2**16, _("When a donation renewal payment has been aborted")), + Event('income_has_passed_goal', 2**16, _("When income has surpassed goal")), ] check_bits([e.bit for e in EVENTS]) EVENTS = {e.name: e for e in EVENTS} diff --git a/liberapay/main.py b/liberapay/main.py index b1c37d127..d9f145e67 100644 --- a/liberapay/main.py +++ b/liberapay/main.py @@ -203,6 +203,7 @@ def default_body_parser(body_bytes, headers): cron(intervals.get('execute_reviewed_payins', 3600), execute_reviewed_payins, True) cron('irregular', website.cryptograph.rotate_stored_data, True) + cron(Weekly(weekday=3, hour=1), Participant.check_income_goals, True) # Website Algorithm diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index a12913d56..1569b5539 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -3111,6 +3111,78 @@ def find_partial_match(new_sp, current_schedule_map): ) return new_schedule + + + # Check Income Goals + def check_income_goals(self): + """Check if the participant's income over the past four weeks meets or exceeds their weekly income goal.""" + four_weeks_ago = utcnow() - FOUR_WEEKS + received_income = self.get_received_income(self.db, four_weeks_ago, utcnow()) + + if received_income >= self.weekly_income_goal * 4: # Assume the goal is weekly + if not self.has_recent_notification(self.db): + self.send_income_goal_met_notification(self.db) + + def get_received_income(self, start_date, end_date, save = True): + with self.db.get_cursor() as cursor: + # Prevent race conditions + if save: + cursor.run("SELECT * FROM participants WHERE id = %s FOR UPDATE", + (self.id,)) + """Retrieve the total income received by this participant between two dates.""" + query = cursor.all(""" + SELECT COALESCE(SUM(amount), 0) FROM transfers + WHERE recipient = {user_id} + AND timestamp BETWEEN {start_date} AND {end_date} + """).format( + user_id=self.id, + start_date=start_date, + end_date=end_date + ) + return self.db.one(query) + + def has_recent_notification(self): + """Check if a notification has been sent to this participant in the past week.""" + query = self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = {user_id} + AND notified_date > CURRENT_DATE - INTERVAL '1 week' + ) + """).format(user_id=self.id) + return self.db.one(query) + + def send_income_goal_met_notification(self, save = True): + """Send a notification and record it in the database.""" + notify = False + if notify: + sp_to_dict = lambda sp: { + 'amount': sp.amount, + 'execution_date': sp.execution_date, + } + self.notify( + '"Your income has met your set goal!"', + force_email=True, + added_payments=[sp_to_dict(new_sp) for new_sp in insertions], + cancelled_payments=[sp_to_dict(old_sp) for old_sp in deletions], + modified_payments=[t for t in ( + (sp_to_dict(old_sp), sp_to_dict(new_sp)) + for old_sp, new_sp in updates + if old_sp.notifs_count > 0 + ) if t[0] != t[1]], + new_schedule=new_schedule, + ) + + + # Record the notification being sent in the database + query = self.db(""" + INSERT INTO income_notifications (user_id, notified_date) + VALUES ({user_id}, CURRENT_TIMESTAMP) + """).format(user_id=self.id) + self.db.run(query) + + + def get_tip_to(self, tippee, currency=None): diff --git a/sql/schema.sql b/sql/schema.sql index c2171dc71..e66f34828 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -168,6 +168,19 @@ CREATE TRIGGER update_profile_visibility BEFORE INSERT OR UPDATE ON participants FOR EACH ROW EXECUTE PROCEDURE update_profile_visibility(); +-- adding a new column for the income goal +ALTER TABLE participants ADD COLUMN weekly_income_goal numeric(10, 2); + +-- Create a new table to store income notifications +CREATE TABLE income_notifications ( + id serial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + notified_date timestamp with time zone NOT NULL DEFAULT current_timestamp, + UNIQUE (user_id, notified_date) -- Ensure no duplicate notifications for the same date +); + +-- Index for quick lookup by user_id +CREATE INDEX idx_income_notifications_user_id ON income_notifications(user_id); -- settings specific to users who want to receive donations diff --git a/tests/py/test_incomegoals.py b/tests/py/test_incomegoals.py new file mode 100644 index 000000000..40a447a31 --- /dev/null +++ b/tests/py/test_incomegoals.py @@ -0,0 +1,67 @@ +import pytest +from liberapay.testing import Harness +from liberapay.models.participant import Participant +from datetime import timedelta +from liberapay.i18n.currencies import Money + +class TestIncomeGoalChecks(Harness): + + def setUp(self): + super(TestIncomeGoalChecks, self).setUp() + self.alice = self.make_participant('alice', weekly_income_goal=Money('100.00', 'EUR')) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=1))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=2))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=3))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=4))) + + def test_income_goal_met_and_notification_sent(self): + # Test income goal met and notification sent correctly + self.alice.check_income_goals() + assert self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = %s + ) + """, (self.alice.id,)) is True + + def test_income_goal_not_met(self): + # Adjust one payment to simulate failing to meet the goal + self.db.run(""" + UPDATE transfers SET amount = %s WHERE timestamp = %s + """, (Money('15.00', 'EUR'), self.utcnow() - timedelta(weeks=1))) + self.alice.check_income_goals() + assert self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = %s + ) + """, (self.alice.id,)) is False + + def test_notification_not_sent_if_recently_notified(self): + # Simulate a recent notification + self.db.run(""" + INSERT INTO income_notifications (user_id, notified_date) + VALUES (%s, CURRENT_TIMESTAMP) + """, (self.alice.id,)) + self.alice.check_income_goals() + notifications = self.db.all(""" + SELECT * FROM income_notifications WHERE user_id = %s + """, (self.alice.id,)) + assert len(notifications) == 1 # No new notification should be added + +@pytest.fixture(autouse=True) +def setup(db): + db.run("CREATE TEMPORARY TABLE transfers (recipient int, amount money, timestamp timestamp)") + db.run("CREATE TEMPORARY TABLE income_notifications (user_id int, notified_date timestamp)") diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index 444dd4a99..2a59ca045 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -517,3 +517,5 @@ def test_rs_returns_openstreetmap_url_for_stub_from_openstreetmap(self): stub = Participant.from_username(unclaimed.participant.username) actual = stub.resolve_stub() assert actual == "/on/openstreetmap/alice/" + +