implement renewal reminders (#61)
continuous-integration/drone/push Build is passing Details

Closes #55.

Once this is merged and deployed, a cron job will be used to automatically run `ceo members remindexpire` at the beginning of every term.

Reviewed-on: #61
This commit is contained in:
Max Erenberg 2022-06-30 20:02:06 -04:00
parent dbb5bf1c8d
commit dc412ef5cb
14 changed files with 288 additions and 25 deletions

View File

@ -202,3 +202,23 @@ def expire(dry_run):
click.echo("The following members has been marked as expired:") click.echo("The following members has been marked as expired:")
for username in result: for username in result:
click.echo(username) click.echo(username)
@members.command(short_help="Send renewal reminder emails to expiring members")
@click.option('--dry-run', is_flag=True, default=False)
def remindexpire(dry_run):
url = '/api/members/remindexpire'
if dry_run:
url += '?dry_run=true'
resp = http_post(url)
result = handle_sync_response(resp)
if len(result) > 0:
if dry_run:
click.echo("The following members will be sent membership renewal reminders:")
else:
click.echo("The following members were sent membership renewal reminders:")
for username in result:
click.echo(username)
else:
click.echo("No members are pending expiration.")

View File

@ -88,8 +88,15 @@ class ILDAPService(Interface):
described above. described above.
""" """
def get_expiring_users(self) -> List[IUser]: def get_nonflagged_expired_users(self) -> List[IUser]:
""" """
Retrieves members whose term or nonMemberTerm does not contain the Retrieves members whose term or nonMemberTerm does not contain the
current or the last term. current or the last term.
""" """
def get_expiring_users(self) -> List[IUser]:
"""
Retrieves members whose membership will expire in less than a month.
This is used to send membership renewal reminders at the beginning
of a term, during the one-month grace period.
"""

View File

@ -23,3 +23,21 @@ class IMailService(Interface):
`operations` is a list of the operations which were performed `operations` is a list of the operations which were performed
during the transaction. during the transaction.
""" """
def send_membership_renewal_reminder(self, user: IUser):
"""
Send a reminder to the user that their membership will expire
soon.
"""
def send_cloud_account_will_be_deleted_message(self, user: IUser):
"""
Send a warning message to the user that their cloud resources
will be deleted if they do not renew their membership.
"""
def send_cloud_account_has_been_deleted_message(self, user: IUser):
"""
Send a message to the user that their cloud resources have
been deleted.
"""

View File

@ -1,11 +1,13 @@
from flask import Blueprint, g, json, request from flask import Blueprint, g, request
from flask.json import jsonify
from zope import component from zope import component
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
user_is_in_group, requires_authentication_no_realm, \ user_is_in_group, requires_authentication_no_realm, \
create_streaming_response, development_only, is_truthy create_streaming_response, development_only, is_truthy
from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError
from ceo_common.interfaces import ILDAPService, IConfig from ceo_common.interfaces import ILDAPService, IConfig, IMailService
from ceo_common.logger_factory import logger_factory
from ceod.transactions.members import ( from ceod.transactions.members import (
AddMemberTransaction, AddMemberTransaction,
ModifyMemberTransaction, ModifyMemberTransaction,
@ -14,6 +16,7 @@ from ceod.transactions.members import (
import ceod.utils as utils import ceod.utils as utils
bp = Blueprint('members', __name__) bp = Blueprint('members', __name__)
logger = logger_factory(__name__)
@bp.route('/', methods=['POST'], strict_slashes=False) @bp.route('/', methods=['POST'], strict_slashes=False)
@ -141,7 +144,7 @@ def expire_users():
ldap_srv = component.getUtility(ILDAPService) ldap_srv = component.getUtility(ILDAPService)
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
members = ldap_srv.get_expiring_users() members = ldap_srv.get_nonflagged_expired_users()
member_list = cfg.get('mailman3_new_member_list') member_list = cfg.get('mailman3_new_member_list')
if not dry_run: if not dry_run:
@ -152,4 +155,20 @@ def expire_users():
except UserNotSubscribedError: except UserNotSubscribedError:
pass pass
return json.jsonify([member.uid for member in members]) return jsonify([member.uid for member in members])
@bp.route('/remindexpire', methods=['POST'])
@authz_restrict_to_syscom
def remind_users_of_expiration():
dry_run = is_truthy(request.args.get('dry_run', 'false'))
ldap_srv = component.getUtility(ILDAPService)
members = ldap_srv.get_expiring_users()
if not dry_run:
mail_srv = component.getUtility(IMailService)
for member in members:
logger.info(f'Sending renewal reminder to {member.uid}')
mail_srv.send_membership_renewal_reminder(member)
return jsonify([member.uid for member in members])

View File

@ -245,7 +245,7 @@ class LDAPService:
except ldap3.core.exceptions.LDAPEntryAlreadyExistsResult: except ldap3.core.exceptions.LDAPEntryAlreadyExistsResult:
raise GroupAlreadyExistsError() raise GroupAlreadyExistsError()
def get_expiring_users(self) -> List[IUser]: def get_nonflagged_expired_users(self) -> List[IUser]:
syscom_members = self.get_group('syscom').members syscom_members = self.get_group('syscom').members
clauses = [] clauses = []
@ -275,6 +275,26 @@ class LDAPService:
if entry.uid.value not in syscom_members if entry.uid.value not in syscom_members
] ]
def get_expiring_users(self) -> List[IUser]:
term = Term.current()
dt = ceo_common_utils.get_current_datetime()
if dt.month != term.start_month():
# We only send membership renewal reminders at the
# start of a term
return []
last_term = term - 1
query = f'(&(term={last_term})(!(term={term})))'
conn = self._get_ldap_conn()
conn.search(
self.ldap_users_base,
query,
attributes=ldap3.ALL_ATTRIBUTES,
search_scope=ldap3.LEVEL)
return [
User.deserialize_from_ldap(entry)
for entry in conn.entries
]
@contextlib.contextmanager @contextlib.contextmanager
def entry_ctx_for_group(self, group: IGroup): def entry_ctx_for_group(self, group: IGroup):
entry = self._get_writable_entry_for_group(group) entry = self._get_writable_entry_for_group(group)

View File

@ -97,6 +97,19 @@ class MailService:
body, body,
) )
def send_membership_renewal_reminder(self, user: IUser):
template = self.jinja_env.get_template('membership_renewal_reminder.j2')
body = template.render(user=user)
self.send(
f'Computer Science Club <ceo+memberships@{self.base_domain}>',
f'{user.cn} <{user.uid}@{self.base_domain}>',
{
'Subject': 'Computer Science Club membership renewal reminder',
'Reply-To': f'syscom@{self.base_domain}',
},
body,
)
def send_cloud_account_will_be_deleted_message(self, user: IUser): def send_cloud_account_will_be_deleted_message(self, user: IUser):
template = self.jinja_env.get_template('cloud_account_will_be_deleted.j2') template = self.jinja_env.get_template('cloud_account_will_be_deleted.j2')
body = template.render(user=user) body = template.render(user=user)

View File

@ -0,0 +1,29 @@
Hello {{ user.given_name }},
This is an automated message from ceo, the CSC Electronic Office.
You are receiving this message because your CSC membership will expire
at the end of this month. If you do not wish to renew your membership,
you may safely ignore this message.
When your CSC membership expires, the following will happen:
* You will lose access to most CSC resources, including the
general-use machines
* Your CSC email address will be unsubscribed from the csc-general
mailing list
* If you have a CSC cloud account, all of your cloud resources will be
permanently deleted
Note that even if you are not a member, you are still welcome to
participate in CSC events and interact with CSC on social media,
including Facebook, Instagram, Twitch, Discord and IRC.
If you wish to renew your membership, please follow the instructions
here: https://csclub.uwaterloo.ca/get-involved/
If you have any questions or concerns, please contact the Systems
Committee: syscom@csclub.uwaterloo.ca
Best regards,
ceo

View File

@ -5,10 +5,11 @@ gssapi==1.6.14
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
ldap3==2.9.1 ldap3==2.9.1
requests==2.26.0
requests-gssapi==1.2.3
zope.component==5.0.1
zope.interface==5.4.0
mysql-connector-python==8.0.26 mysql-connector-python==8.0.26
psycopg2==2.9.1 psycopg2==2.9.1
requests==2.26.0
requests-gssapi==1.2.3
urwid==2.1.2 urwid==2.1.2
werkzeug==2.1.2
zope.component==5.0.1
zope.interface==5.4.0

View File

@ -5,5 +5,7 @@ ignore =
# unable to detect undefined names # unable to detect undefined names
F403, F403,
# name may be undefined or or defined from star imports # name may be undefined or or defined from star imports
F405 F405,
# line break before binary operator
W503
exclude = .git,.vscode,venv,__pycache__,__init__.py,build,dist,one_time_scripts exclude = .git,.vscode,venv,__pycache__,__init__.py,build,dist,one_time_scripts

View File

@ -1,14 +1,13 @@
import os import os
import re import re
import shutil import shutil
from datetime import datetime from datetime import datetime, timedelta
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch
from ceo.cli import cli from ceo.cli import cli
from ceo_common.model import Term from ceo_common.model import Term
import ceo_common.utils from tests.utils import set_datetime_in_app_process, restore_datetime_in_app_process
def test_members_get(cli_setup, ldap_user): def test_members_get(cli_setup, ldap_user):
@ -147,12 +146,13 @@ def test_members_pwreset(cli_setup, ldap_user, krb_user):
assert expected_pat.match(result.output) is not None assert expected_pat.match(result.output) is not None
def test_members_expire(cli_setup, ldap_user, syscom_group): def test_members_expire(cli_setup, app_process, ldap_user, syscom_group):
runner = CliRunner() runner = CliRunner()
with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: try:
# use a time that we know for sure will expire # use a time that we know for sure will expire
datetime_mock.return_value = datetime(4000, 4, 1) test_date = datetime(4000, 4, 1)
set_datetime_in_app_process(app_process, test_date)
result = runner.invoke(cli, ['members', 'expire', '--dry-run']) result = runner.invoke(cli, ['members', 'expire', '--dry-run'])
assert result.exit_code == 0 assert result.exit_code == 0
@ -168,3 +168,35 @@ def test_members_expire(cli_setup, ldap_user, syscom_group):
result = runner.invoke(cli, ['members', 'expire', '--dry-run']) result = runner.invoke(cli, ['members', 'expire', '--dry-run'])
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == '' assert result.output == ''
finally:
restore_datetime_in_app_process(app_process)
def test_members_remindexpire(cli_setup, app_process, ldap_user):
runner = CliRunner()
term = Term(ldap_user.terms[0])
try:
test_date = (term + 1).to_datetime()
set_datetime_in_app_process(app_process, test_date)
result = runner.invoke(cli, ['members', 'remindexpire', '--dry-run'])
assert result.exit_code == 0
assert result.output == (
"The following members will be sent membership renewal reminders:\n"
f"{ldap_user.uid}\n"
)
result = runner.invoke(cli, ['members', 'remindexpire'])
assert result.exit_code == 0
assert result.output == (
"The following members were sent membership renewal reminders:\n"
f"{ldap_user.uid}\n"
)
test_date = (term + 1).to_datetime() + timedelta(days=40)
set_datetime_in_app_process(app_process, test_date)
result = runner.invoke(cli, ['members', 'remindexpire'])
assert result.exit_code == 0
assert result.output == "No members are pending expiration.\n"
finally:
restore_datetime_in_app_process(app_process)

View File

@ -365,3 +365,71 @@ def test_office_member(cfg, client):
status, _ = client.delete('/api/members/test3') status, _ = client.delete('/api/members/test3')
assert status == 200 assert status == 200
def test_membership_renewal_reminder(client, mock_mail_server):
uids = ['test3', 'test4']
# fast-forward by one term so that we don't clash with the other users
# created by other tests
term = Term.current() + 1
with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock:
datetime_mock.return_value = term.to_datetime()
for uid in uids:
body = {
'uid': uid,
'cn': 'John Doe',
'given_name': 'John',
'sn': 'Doe',
'program': 'Math',
'terms': [str(term)],
'forwarding_addresses': [uid + '@uwaterloo.internal'],
}
status, data = client.post('/api/members', json=body)
assert status == 200 and data[-1]['status'] == 'completed'
mock_mail_server.messages.clear()
# Members were freshly created - nobody should be expirable
status, data = client.post('/api/members/remindexpire')
assert status == 200
assert data == []
next_term = term + 1
datetime_mock.return_value = next_term.to_datetime() + datetime.timedelta(days=7)
status, data = client.post('/api/members/remindexpire?dry_run=true')
assert status == 200
assert sorted(uids) == sorted(data)
# dry run - no messages should have been sent
assert len(mock_mail_server.messages) == 0
status, data = client.post('/api/members/remindexpire')
assert status == 200
assert len(mock_mail_server.messages) == len(uids)
assert (
[uid + '@csclub.internal' for uid in sorted(uids)]
== sorted([msg['to'] for msg in mock_mail_server.messages])
)
# Renew only one of the expiring users
status, _ = client.post(f'/api/members/{uids[0]}/renew', json={'terms': [str(next_term)]})
assert status == 200
status, data = client.post('/api/members/remindexpire?dry_run=true')
assert status == 200
assert len(data) == len(uids) - 1
datetime_mock.return_value = next_term.to_datetime() + datetime.timedelta(days=40)
status, data = client.post('/api/members/remindexpire')
assert status == 200
# one-month grace period has passed - no messages should be sent out
assert data == []
next_next_term = next_term + 1
datetime_mock.return_value = next_next_term.to_datetime()
status, data = client.post('/api/members/remindexpire?dry_run=true')
assert status == 200
# only the user who renewed last term should get a reminder
assert data == [uids[0]]
for uid in uids:
status, _ = client.delete(f'/api/members/{uid}')
assert status == 200
mock_mail_server.messages.clear()

View File

@ -4,6 +4,7 @@ import subprocess
import pytest import pytest
from ceo_common.errors import UserNotFoundError, UserAlreadyExistsError from ceo_common.errors import UserNotFoundError, UserAlreadyExistsError
from ceo_common.model import Term
from ceod.model import User from ceod.model import User
@ -107,13 +108,14 @@ def test_user_forwarding_addresses(cfg, ldap_user):
def test_user_terms(ldap_user, ldap_srv): def test_user_terms(ldap_user, ldap_srv):
user = ldap_user user = ldap_user
term = Term(user.terms[0])
user.add_terms(['f2021']) user.add_terms([str(term + 1)])
assert user.terms == ['s2021', 'f2021'] assert user.terms == [str(term), str(term + 1)]
assert ldap_srv.get_user(user.uid).terms == user.terms assert ldap_srv.get_user(user.uid).terms == user.terms
user.add_non_member_terms(['w2022', 's2022']) user.add_non_member_terms([str(term), str(term + 1)])
assert user.non_member_terms == ['w2022', 's2022'] assert user.non_member_terms == [str(term), str(term + 1)]
assert ldap_srv.get_user(user.uid).non_member_terms == user.non_member_terms assert ldap_srv.get_user(user.uid).non_member_terms == user.non_member_terms

View File

@ -1,11 +1,13 @@
import contextlib import contextlib
import importlib.resources import importlib.resources
import multiprocessing
from multiprocessing import Process from multiprocessing import Process
import os import os
import shutil import shutil
import subprocess import subprocess
from subprocess import DEVNULL from subprocess import DEVNULL
import sys import sys
import threading
import time import time
from unittest.mock import Mock from unittest.mock import Mock
@ -28,6 +30,7 @@ from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \ IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
ICloudResourceManager, IContainerRegistryService ICloudResourceManager, IContainerRegistryService
from ceo_common.model import Config, HTTPClient, Term from ceo_common.model import Config, HTTPClient, Term
import ceo_common.utils
from ceod.api import create_app from ceod.api import create_app
from ceod.db import MySQLService, PostgreSQLService from ceod.db import MySQLService, PostgreSQLService
from ceod.model import KerberosService, LDAPService, FileService, User, \ from ceod.model import KerberosService, LDAPService, FileService, User, \
@ -365,7 +368,7 @@ def simple_user():
given_name='John', given_name='John',
sn='Doe', sn='Doe',
program='Math', program='Math',
terms=['s2021'], terms=[str(Term.current())],
) )
@ -508,13 +511,32 @@ def uwldap_user(cfg, uwldap_srv, ldap_conn):
def app_process(cfg, app, http_client): def app_process(cfg, app, http_client):
port = cfg.get('ceod_port') port = cfg.get('ceod_port')
hostname = socket.gethostname() hostname = socket.gethostname()
# The parent process may want to mock the datetime
# function in the child process, so we need IPC
mock_datetime_value = None
orig_get_datetime = ceo_common.utils.get_current_datetime
def server_start(): def mock_get_datetime():
if mock_datetime_value is not None:
return mock_datetime_value
else:
return orig_get_datetime()
def ipc_thread_start(pipe):
nonlocal mock_datetime_value
ceo_common.utils.get_current_datetime = mock_get_datetime
while True:
mock_datetime_value = pipe.recv()
pipe.send(None) # ACK
def server_start(pipe):
sys.stdout = open('/dev/null', 'w') sys.stdout = open('/dev/null', 'w')
sys.stderr = sys.stdout sys.stderr = sys.stdout
threading.Thread(target=ipc_thread_start, args=(pipe,)).start()
app.run(debug=False, host='0.0.0.0', port=port) app.run(debug=False, host='0.0.0.0', port=port)
proc = Process(target=server_start) parent_conn, child_conn = multiprocessing.Pipe()
proc = Process(target=server_start, args=(child_conn,))
proc.start() proc.start()
try: try:
@ -526,7 +548,7 @@ def app_process(cfg, app, http_client):
continue continue
break break
assert i != 5, 'Timed out' assert i != 5, 'Timed out'
yield yield parent_conn
finally: finally:
proc.terminate() proc.terminate()
proc.join() proc.join()

View File

@ -60,3 +60,13 @@ def gen_password_mock_ctx():
def mocks_for_create_user_ctx(): def mocks_for_create_user_ctx():
with gen_password_mock_ctx(): with gen_password_mock_ctx():
yield yield
def set_datetime_in_app_process(app_process, value):
pipe = app_process
pipe.send(value)
pipe.recv() # wait for ACK
def restore_datetime_in_app_process(app_process):
set_datetime_in_app_process(app_process, None)