Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

This commit introduces a new feature that checks if participants' inc… #2346

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions liberapay/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions liberapay/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 13 additions & 0 deletions sql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions tests/py/test_incomegoals.py
Original file line number Diff line number Diff line change
@@ -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)")
2 changes: 2 additions & 0 deletions tests/py/test_participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"