Disable inactive club sites (#68)
Closes #51. An API argument called `remove_inactive_club_reps` was added so that we can dynamically control whether we want to remove inactive club reps or not. The default action is only to disable club websites without changing group membership. Reviewed-on: #68 Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca> Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>pull/72/head
parent
32b2dbb307
commit
cfb5f77711
@ -0,0 +1,37 @@ |
||||
import click |
||||
|
||||
from ..utils import http_post |
||||
from .utils import handle_sync_response |
||||
|
||||
|
||||
@click.group(short_help='Manage websites hosted by the main CSC web server') |
||||
def webhosting(): |
||||
pass |
||||
|
||||
|
||||
@webhosting.command(short_help='Disable club sites with no active club reps') |
||||
@click.option('--dry-run', is_flag=True, default=False) |
||||
@click.option('--remove-inactive-club-reps', is_flag=True, default=False) |
||||
def disableclubsites(dry_run, remove_inactive_club_reps): |
||||
params = {} |
||||
if dry_run: |
||||
params['dry_run'] = 'true' |
||||
if remove_inactive_club_reps: |
||||
params['remove_inactive_club_reps'] = 'true' |
||||
if not dry_run: |
||||
click.confirm('Are you sure you want to disable the websites of clubs with no active club reps?', abort=True) |
||||
|
||||
resp = http_post('/api/webhosting/disableclubsites', params=params) |
||||
disabled_club_names = handle_sync_response(resp) |
||||
if len(disabled_club_names) == 0: |
||||
if dry_run: |
||||
click.echo('No websites would have been disabled.') |
||||
else: |
||||
click.echo('No websites were disabled.') |
||||
else: |
||||
if dry_run: |
||||
click.echo('The following club websites would have been disabled:') |
||||
else: |
||||
click.echo('The following club websites were disabled:') |
||||
for club_name in disabled_club_names: |
||||
click.echo(club_name) |
@ -0,0 +1,45 @@ |
||||
from typing import List |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
|
||||
class IClubWebHostingService(Interface): |
||||
""" |
||||
Performs operations on the configuration files of club websites hosted |
||||
by the CSC. |
||||
""" |
||||
|
||||
def begin_transaction(): |
||||
""" |
||||
Performs any steps necessary to query the website config files. |
||||
This must be called BEFORE any of the methods below, using a context |
||||
expression like so: |
||||
|
||||
with club_site_mgr.begin_transaction(): |
||||
club_site_mgr.disable_club_site('club1') |
||||
... |
||||
club_site_mgr.commit() |
||||
""" |
||||
|
||||
def commit(): |
||||
""" |
||||
Writes the config file changes to disk. This must be called at the |
||||
end of the context expression. |
||||
""" |
||||
|
||||
def disable_club_site(club_name: str): |
||||
""" |
||||
Disables the site for the club. Note that commit() must still be |
||||
called to commit this change. |
||||
""" |
||||
|
||||
def disable_sites_for_inactive_clubs( |
||||
dry_run: bool = False, |
||||
remove_inactive_club_reps: bool = False, |
||||
) -> List[str]: |
||||
""" |
||||
Disables sites for inactive clubs. If remove_inactive_club_reps is set |
||||
to True, then inactive club reps will be removed from club groups. |
||||
The list of clubs whose sites were disabled (or would have been |
||||
disabled, if dry_run is True) is returned. |
||||
""" |
@ -0,0 +1,22 @@ |
||||
from flask import Blueprint, request |
||||
from flask.json import jsonify |
||||
from zope import component |
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy |
||||
from ceo_common.interfaces import IClubWebHostingService |
||||
|
||||
|
||||
bp = Blueprint('webhosting', __name__) |
||||
|
||||
|
||||
@bp.route('/disableclubsites', methods=['POST']) |
||||
@authz_restrict_to_syscom |
||||
def expire_club_sites(): |
||||
dry_run = is_truthy(request.args.get('dry_run', 'false')) |
||||
remove_inactive_club_reps = is_truthy(request.args.get('remove_inactive_club_reps', 'false')) |
||||
webhosting_srv = component.getUtility(IClubWebHostingService) |
||||
disabled_sites = webhosting_srv.disable_sites_for_inactive_clubs( |
||||
dry_run=dry_run, |
||||
remove_inactive_club_reps=remove_inactive_club_reps, |
||||
) |
||||
return jsonify(disabled_sites) |
@ -0,0 +1,250 @@ |
||||
from collections import defaultdict |
||||
import contextlib |
||||
import glob |
||||
import os |
||||
import re |
||||
import subprocess |
||||
from threading import Lock |
||||
import traceback |
||||
from typing import List |
||||
|
||||
from augeas import Augeas |
||||
from zope import component |
||||
from zope.interface import implementer |
||||
|
||||
from ceo_common.interfaces import ILDAPService, IMailService, \ |
||||
IClubWebHostingService, IConfig |
||||
from ceo_common.logger_factory import logger_factory |
||||
from ceo_common.model import Term |
||||
|
||||
# NOTE: We are assuming that the name of a club is the same as its home |
||||
# directory (under /users). While this rule is not enforced, it has been true |
||||
# up until now. |
||||
# FIXME: don't assume this |
||||
APACHE_USERDIR_RE = re.compile(r'^/users/(?P<club_name>[0-9a-z-]+)/www/?$') |
||||
|
||||
# This is where all the <Directory> directives for disabled clubs are stored. |
||||
APACHE_DISABLED_CLUBS_FILE = 'conf-available/disable-club.conf' |
||||
# This is the file which contains the snippet to disable a single club. |
||||
APACHE_DISABLING_SNIPPET_FILE = 'snippets/disable-club.conf' |
||||
# This is the maximum number of consecutive terms for which a club is allowed |
||||
# to have no active club reps. After this many terms have passed, the club's |
||||
# website may be disabled. |
||||
MAX_TERMS_WITH_NO_ACTIVE_CLUB_REPS = 3 |
||||
|
||||
logger = logger_factory(__name__) |
||||
|
||||
|
||||
def is_a_disabling_snippet(snippet_path: str) -> bool: |
||||
return snippet_path.startswith('snippets/disable-') |
||||
|
||||
|
||||
@implementer(IClubWebHostingService) |
||||
class ClubWebHostingService: |
||||
def __init__(self, augeas_root='/'): |
||||
cfg = component.getUtility(IConfig) |
||||
self.aug_root = augeas_root |
||||
self.apache_dir = os.path.join(augeas_root, 'etc/apache2') |
||||
self.sites_available_dir = os.path.join(self.apache_dir, 'sites-available') |
||||
self.conf_available_dir = os.path.join(self.apache_dir, 'conf-available') |
||||
self.clubs_home = cfg.get('clubs_home') |
||||
self.aug = None |
||||
self.clubs = None |
||||
self.made_at_least_one_change = False |
||||
self.lock = Lock() |
||||
|
||||
@contextlib.contextmanager |
||||
def _hold_lock(self): |
||||
try: |
||||
self.lock.acquire() |
||||
yield |
||||
finally: |
||||
self.lock.release() |
||||
|
||||
@contextlib.contextmanager |
||||
def begin_transaction(self): |
||||
with self._hold_lock(): |
||||
try: |
||||
self.aug = Augeas(self.aug_root) |
||||
self.clubs = defaultdict(lambda: {'disabled': False, 'email': None}) |
||||
self._get_club_emails() |
||||
self._get_disabled_sites() |
||||
yield |
||||
finally: |
||||
if self.aug is not None: |
||||
self.aug.close() |
||||
self.aug = None |
||||
self.clubs = None |
||||
self.made_at_least_one_change = False |
||||
|
||||
def _run(self, args: List[str], **kwargs): |
||||
subprocess.run(args, check=True, **kwargs) |
||||
|
||||
def _reload_web_server(self): |
||||
logger.debug('Reloading Apache') |
||||
self._run(['systemctl', 'reload', 'apache2']) |
||||
|
||||
# This requires the APACHE_CONFIG_CRON environment variable to be |
||||
# set to 1 (e.g. in a systemd drop-in) |
||||
# See /etc/apache2/.git/hooks/pre-commit on caffeine |
||||
def _git_commit(self): |
||||
if not os.path.isdir(os.path.join(self.apache_dir, '.git')): |
||||
logger.debug('No git folder found in Apache directory') |
||||
return |
||||
logger.debug('Committing changes to git repository') |
||||
self._run(['git', 'add', APACHE_DISABLED_CLUBS_FILE], cwd=self.apache_dir) |
||||
self._run(['git', 'commit', '-m', '[ceo] disable club websites'], cwd=self.apache_dir) |
||||
|
||||
def commit(self): |
||||
if not self.made_at_least_one_change: |
||||
return |
||||
logger.debug('Saving Augeas changes') |
||||
self.aug.save() |
||||
self._reload_web_server() |
||||
self._git_commit() |
||||
|
||||
def _get_club_emails(self): |
||||
config_file_paths = glob.glob(self.sites_available_dir + '/club-*.conf') |
||||
for config_file_path in config_file_paths: |
||||
club_name = None |
||||
club_email = None |
||||
filename = os.path.basename(config_file_path) |
||||
directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive') |
||||
for directive_path in directive_paths: |
||||
directive = self.aug.get(directive_path) |
||||
directive_value = self.aug.get(directive_path + '/arg') |
||||
if directive == 'DocumentRoot': |
||||
match = APACHE_USERDIR_RE.match(directive_value) |
||||
if match is not None: |
||||
club_name = match.group('club_name') |
||||
elif directive == 'ServerAdmin': |
||||
club_email = directive_value |
||||
if club_name is not None: |
||||
self.clubs[club_name]['email'] = club_email |
||||
|
||||
def _get_disabled_sites(self): |
||||
config_file_paths = glob.glob(self.conf_available_dir + '/disable-*.conf') |
||||
for config_file_path in config_file_paths: |
||||
filename = os.path.basename(config_file_path) |
||||
directory_paths = self.aug.match(f'/files/etc/apache2/conf-available/{filename}/Directory') |
||||
for directory_path in directory_paths: |
||||
directory = self.aug.get(directory_path + '/arg') |
||||
match = APACHE_USERDIR_RE.match(directory) |
||||
if match is None: |
||||
continue |
||||
club_name = match.group('club_name') |
||||
directive_paths = self.aug.match(directory_path + '/directive') |
||||
for directive_path in directive_paths: |
||||
if self.aug.get(directive_path) != 'Include': |
||||
continue |
||||
included_file = self.aug.get(directive_path + '/arg') |
||||
if is_a_disabling_snippet(included_file): |
||||
self.clubs[club_name]['disabled'] = True |
||||
|
||||
def disable_club_site(self, club_name: str): |
||||
logger.info(f'Disabling website for {club_name}') |
||||
directory_path = f'/files/etc/apache2/{APACHE_DISABLED_CLUBS_FILE}/Directory' |
||||
num_directories = len(self.aug.match(directory_path)) |
||||
# Create a new <Directory> section |
||||
directory_path += '[%d]' % (num_directories + 1) |
||||
# FIXME: use self.clubs_home here instead (need to update unit tests) |
||||
self.aug.set(directory_path + '/arg', f'/users/{club_name}/www') |
||||
self.aug.set(directory_path + '/directive', 'Include') |
||||
self.aug.set(directory_path + '/directive/arg', APACHE_DISABLING_SNIPPET_FILE) |
||||
|
||||
self.clubs[club_name]['disabled'] = True |
||||
self.made_at_least_one_change = True |
||||
|
||||
def _site_uses_php(self, club_name: str) -> bool: |
||||
www = f'{self.clubs_home}/{club_name}/www' |
||||
if os.path.isdir(www): |
||||
# We're just going to look one level deep; that should be good enough. |
||||
for filename in os.listdir(www): |
||||
filepath = os.path.join(www, filename) |
||||
if os.path.isfile(filepath) and filename.endswith('.php'): |
||||
return True |
||||
return False |
||||
|
||||
# This method needs to be called from within a transaction (uses self.clubs) |
||||
def _need_to_disable_inactive_club_site(self, club_name: str) -> bool: |
||||
if self.clubs[club_name]['disabled']: |
||||
# already disabled - nothing to do |
||||
return False |
||||
if not self._site_uses_php(club_name): |
||||
# These are the only sites which are actually a security concern |
||||
# to us - it's OK for a purely static website to be unmaintained. |
||||
return False |
||||
return True |
||||
|
||||
def disable_sites_for_inactive_clubs( |
||||
self, |
||||
dry_run: bool = False, |
||||
remove_inactive_club_reps: bool = False, |
||||
) -> List[str]: |
||||
ldap_srv = component.getUtility(ILDAPService) |
||||
mail_srv = component.getUtility(IMailService) |
||||
all_clubs = ldap_srv.get_clubs() |
||||
club_rep_uids = list({ |
||||
member |
||||
for club in all_clubs |
||||
for member in club.members |
||||
}) |
||||
club_reps = ldap_srv.get_club_reps_non_member_terms(club_rep_uids) |
||||
# If a club rep's last non-member term is before cutoff_term, then |
||||
# they are considered inactive |
||||
cutoff_term = Term.current() - MAX_TERMS_WITH_NO_ACTIVE_CLUB_REPS |
||||
active_club_reps = { |
||||
club_rep |
||||
for club_rep, non_member_terms in club_reps.items() |
||||
if len(non_member_terms) > 0 and max(non_member_terms) >= cutoff_term |
||||
} |
||||
# a club is inactive if it does not have at least one active club rep |
||||
inactive_clubs = [ |
||||
club |
||||
for club in all_clubs |
||||
if not any(map(lambda member: member in active_club_reps, club.members)) |
||||
] |
||||
|
||||
# STEP 1: update the Apache configs |
||||
with self.begin_transaction(): |
||||
clubs_to_disable = [ |
||||
club.cn |
||||
for club in inactive_clubs |
||||
if self._need_to_disable_inactive_club_site(club.cn) |
||||
] |
||||
if dry_run: |
||||
return clubs_to_disable |
||||
|
||||
for club_name in clubs_to_disable: |
||||
self.disable_club_site(club_name) |
||||
self.commit() |
||||
# self.clubs is set to None once the transaction closes, so make a copy now |
||||
clubs_info = self.clubs.copy() |
||||
|
||||
# STEP 2: send emails to clubs whose websites were disabled |
||||
clubs_who_were_not_notified = set() |
||||
for club_name in clubs_to_disable: |
||||
address = clubs_info['email'] |
||||
if address is None: |
||||
clubs_who_were_not_notified.add(club_name) |
||||
continue |
||||
try: |
||||
mail_srv.send_club_website_has_been_disabled_message(club_name, address) |
||||
except Exception: |
||||
trace = traceback.format_exc() |
||||
logger.error(f'Failed to send email to {address}:\n{trace}') |
||||
clubs_who_were_not_notified.add(club_name) |
||||
|
||||
# STEP 3: remove inactive club reps from Unix groups |
||||
if remove_inactive_club_reps: |
||||
for club in all_clubs: |
||||
if club.cn in clubs_who_were_not_notified: |
||||
continue |
||||
# club.members gets modified after calling club.remove_member(), |
||||
# so we need to make a copy |
||||
members = club.members.copy() |
||||
for member in members: |
||||
if member not in active_club_reps: |
||||
logger.info(f'Removing {member} from {club.cn}') |
||||
club.remove_member(member) |
||||
return clubs_to_disable |
@ -0,0 +1,20 @@ |
||||
Hello {{ club }} representatives, |
||||
|
||||
This is an automated message from ceo, the CSC Electronic Office. |
||||
|
||||
Since your club has not had any active CSC club reps for 3 or more consecutive |
||||
terms, your club's website has been disabled. Websites require active |
||||
maintenance and software upgrades, and an unmaintained website poses a |
||||
security risk to CSC and to the University of Waterloo. |
||||
|
||||
If you wish for your site to be re-enabled, please register at least |
||||
one club rep with CSC and, if necessary, upgrade the software running |
||||
your website. A club rep membership is free. You can find instructions |
||||
on our website for how to obtain a membership: |
||||
|
||||
https://csclub.uwaterloo.ca/get-involved/ |
||||
|
||||
If you have any questions or concerns, please contact syscom@csclub.uwaterloo.ca. |
||||
|
||||
Sincerely, |
||||
ceo |
@ -0,0 +1,62 @@ |
||||
from click.testing import CliRunner |
||||
|
||||
from ceo.cli import cli |
||||
from ceo_common.model import Term |
||||
from tests.utils import ( |
||||
create_php_file_for_club, |
||||
reset_disable_club_conf, |
||||
set_datetime_in_app_process, |
||||
restore_datetime_in_app_process, |
||||
) |
||||
|
||||
|
||||
def test_disable_club_sites( |
||||
cfg, cli_setup, app_process, webhosting_srv, webhosting_srv_resources, |
||||
new_club_gen, new_user_gen, g_admin_ctx, ldap_srv_session, |
||||
): |
||||
runner = CliRunner() |
||||
term = Term.current() |
||||
clubs_home = cfg.get('clubs_home') |
||||
with new_club_gen() as group, new_user_gen() as user: |
||||
create_php_file_for_club(clubs_home, group.cn) |
||||
user.add_non_member_terms([str(Term.current())]) |
||||
group.add_member(user.uid) |
||||
|
||||
set_datetime_in_app_process(app_process, (term + 4).to_datetime()) |
||||
|
||||
result = runner.invoke(cli, ['webhosting', 'disableclubsites', '--dry-run']) |
||||
assert result.exit_code == 0 |
||||
assert result.output.endswith( |
||||
'The following club websites would have been disabled:\n' |
||||
f'{group.cn}\n' |
||||
) |
||||
|
||||
result = runner.invoke(cli, ['webhosting', 'disableclubsites'], input='y\n') |
||||
assert result.exit_code == 0 |
||||
assert result.output.endswith( |
||||
'The following club websites were disabled:\n' |
||||
f'{group.cn}\n' |
||||
) |
||||
|
||||
with g_admin_ctx(): |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [user.uid] |
||||
|
||||
reset_disable_club_conf(webhosting_srv) |
||||
|
||||
result = runner.invoke( |
||||
cli, |
||||
['webhosting', 'disableclubsites', '--remove-inactive-club-reps'], |
||||
input='y\n' |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output.endswith( |
||||
'The following club websites were disabled:\n' |
||||
f'{group.cn}\n' |
||||
) |
||||
|
||||
with g_admin_ctx(): |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [] |
||||
|
||||
restore_datetime_in_app_process(app_process) |
@ -0,0 +1,43 @@ |
||||
import datetime |
||||
from unittest.mock import patch |
||||
|
||||
from ceo_common.model import Term |
||||
import ceo_common.utils |
||||
from tests.utils import create_php_file_for_club, reset_disable_club_conf |
||||
|
||||
|
||||
def test_disable_club_sites( |
||||
cfg, client, webhosting_srv, webhosting_srv_resources, new_club_gen, |
||||
new_user_gen, g_admin_ctx, ldap_srv_session, |
||||
): |
||||
term = Term.current() |
||||
clubs_home = cfg.get('clubs_home') |
||||
with patch.object(ceo_common.utils, 'get_current_datetime') as now_mock: |
||||
now_mock.return_value = datetime.datetime.now() |
||||
with new_club_gen() as group, new_user_gen() as user: |
||||
create_php_file_for_club(clubs_home, group.cn) |
||||
user.add_non_member_terms([str(Term.current())]) |
||||
group.add_member(user.uid) |
||||
|
||||
now_mock.return_value = (term + 4).to_datetime() |
||||
status, data = client.post('/api/webhosting/disableclubsites?dry_run=true') |
||||
assert status == 200 |
||||
assert data == [group.cn] |
||||
|
||||
status, data = client.post('/api/webhosting/disableclubsites') |
||||
assert status == 200 |
||||
assert data == [group.cn] |
||||
|
||||
with g_admin_ctx(): |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [user.uid] |
||||
|
||||
reset_disable_club_conf(webhosting_srv) |
||||
|
||||
status, data = client.post('/api/webhosting/disableclubsites?remove_inactive_club_reps=true') |
||||
assert status == 200 |
||||
assert data == [group.cn] |
||||
|
||||
with g_admin_ctx(): |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [] |
@ -0,0 +1,163 @@ |
||||
import datetime |
||||
import os |
||||
import re |
||||
import subprocess |
||||
from unittest.mock import patch |
||||
|
||||
from ceo_common.model import Term |
||||
import ceo_common.utils |
||||
from tests.utils import create_php_file_for_club |
||||
|
||||
|
||||
def create_website_config_for_club(sites_available_dir, cn, filename=None): |
||||
if filename is None: |
||||
filename = f'club-{cn}.conf' |
||||
filepath = os.path.join(sites_available_dir, filename) |
||||
with open(filepath, 'w') as fo: |
||||
fo.write(f""" |
||||
<VirtualHost *:80> |
||||
ServerName {cn}.uwaterloo.internal |
||||
ServerAdmin {cn}@{cn}.uwaterloo.internal |
||||
DocumentRoot /users/{cn}/www/ |
||||
</VirtualHost> |
||||
""") |
||||
|
||||
|
||||
def get_enabled_sites(webhosting_srv): |
||||
return [ |
||||
club_name |
||||
for club_name, club_info in webhosting_srv.clubs.items() |
||||
if not club_info['disabled'] |
||||
] |
||||
|
||||
|
||||
def test_disable_club_sites(webhosting_srv, webhosting_srv_resources, new_club_gen): |
||||
sites_available_dir = webhosting_srv.sites_available_dir |
||||
with new_club_gen() as group1, new_club_gen() as group2: |
||||
create_website_config_for_club(sites_available_dir, group1.cn) |
||||
create_website_config_for_club(sites_available_dir, group2.cn, 'club-somerandomname.conf') |
||||
with webhosting_srv.begin_transaction(): |
||||
enabled_clubs = get_enabled_sites(webhosting_srv) |
||||
assert sorted(enabled_clubs) == [group1.cn, group2.cn] |
||||
webhosting_srv.disable_club_site(group1.cn) |
||||
# Make sure that if we don't call commit(), nothing gets saved |
||||
with webhosting_srv.begin_transaction(): |
||||
enabled_clubs = get_enabled_sites(webhosting_srv) |
||||
assert sorted(enabled_clubs) == [group1.cn, group2.cn] |
||||
webhosting_srv.disable_club_site(group1.cn) |
||||
webhosting_srv.commit() |
||||
# Now that we committed the changes, they should be persistent |
||||
with webhosting_srv.begin_transaction(): |
||||
enabled_clubs = get_enabled_sites(webhosting_srv) |
||||
assert enabled_clubs == [group2.cn] |
||||
|
||||
|
||||
def test_disable_inactive_club_sites( |
||||
cfg, webhosting_srv, webhosting_srv_resources, g_admin_ctx, new_club_gen, |
||||
new_user_gen, mock_mail_server, |
||||
): |
||||
sites_available_dir = webhosting_srv.sites_available_dir |
||||
term = Term.current() |
||||
clubs_home = cfg.get('clubs_home') |
||||
with patch.object(ceo_common.utils, 'get_current_datetime') as now_mock: |
||||
now_mock.return_value = datetime.datetime.now() |
||||
with new_club_gen() as group1, \ |
||||
new_club_gen() as group2, \ |
||||
new_user_gen() as user1, \ |
||||
new_user_gen() as user2: |
||||
create_website_config_for_club(sites_available_dir, group1.cn) |
||||
create_website_config_for_club(sites_available_dir, group2.cn) |
||||
create_php_file_for_club(clubs_home, group1.cn) |
||||
with g_admin_ctx(): |
||||
# group1 has no club reps so it should be disabled |
||||
# group2 has no club reps but it doesn't use PHP |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [group1.cn] |
||||
|
||||
user1.add_non_member_terms([str(Term.current())]) |
||||
group1.add_member(user1.uid) |
||||
with g_admin_ctx(): |
||||
# group1 has an active club rep, so it shouldn't be disabled anymore |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [] |
||||
|
||||
now_mock.return_value = (term + 3).to_datetime() |
||||
with g_admin_ctx(): |
||||
# club reps are allowed to be inactive for up to 3 terms |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [] |
||||
|
||||
now_mock.return_value = (term + 4).to_datetime() |
||||
with g_admin_ctx(): |
||||
# club site should be disabled now that club rep is inactive |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [group1.cn] |
||||
|
||||
user2.add_non_member_terms([str(Term.current())]) |
||||
group1.add_member(user2.uid) |
||||
with g_admin_ctx(): |
||||
# group1 has a new club rep, so it shouldn't be disabled anymore |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [] |
||||
|
||||
create_php_file_for_club(clubs_home, group2.cn) |
||||
group2.add_member(user2.uid) |
||||
with g_admin_ctx(): |
||||
# user2 is an active club rep for both group1 and group2 |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs(dry_run=True) == [] |
||||
|
||||
now_mock.return_value = (term + 8).to_datetime() |
||||
mock_mail_server.messages.clear() |
||||
subprocess.run(['git', 'init'], cwd=webhosting_srv.apache_dir, check=True) |
||||
with g_admin_ctx(): |
||||
disabled_sites = webhosting_srv.disable_sites_for_inactive_clubs() |
||||
# user2 expired and both sites use PHP, so they should both be disabled |
||||
assert sorted(disabled_sites) == [group1.cn, group2.cn] |
||||
# since each club had a ServerAdmin directive, they should both have received |
||||
# notification emails |
||||
assert len(mock_mail_server.messages) == 2 |
||||
with open(webhosting_srv.conf_available_dir + '/disable-club.conf') as fi: |
||||
disable_club_conf_content = fi.read() |
||||
print(disable_club_conf_content) |
||||
for group in [group1, group2]: |
||||
pat = re.compile( |
||||
( |
||||
rf'^<Directory "?/users/{group.cn}/www"?>\n' |
||||
r'\s*Include snippets/disable-club\.conf\n' |
||||
r'</Directory>$' |
||||
), |
||||
re.MULTILINE |
||||
) |
||||
assert pat.search(disable_club_conf_content) is not None |
||||
|
||||
with g_admin_ctx(): |
||||
# Club sites should only be disabled once |
||||
assert webhosting_srv.disable_sites_for_inactive_clubs() == [] |
||||
|
||||
mock_mail_server.messages.clear() |
||||
|
||||
|
||||
def test_remove_inactive_club_reps( |
||||
cfg, webhosting_srv, webhosting_srv_resources, g_admin_ctx, new_club_gen, |
||||
new_user_gen, ldap_srv_session, |
||||
): |
||||
term = Term.current() |
||||
clubs_home = cfg.get('clubs_home') |
||||
with patch.object(ceo_common.utils, 'get_current_datetime') as now_mock: |
||||
now_mock.return_value = datetime.datetime.now() |
||||
with new_club_gen() as group, \ |
||||
new_user_gen() as user1, \ |
||||
new_user_gen() as user2: |
||||
create_php_file_for_club(clubs_home, group.cn) |
||||
|
||||
for user in [user1, user2]: |
||||
user.add_non_member_terms([str(Term.current())]) |
||||
group.add_member(user.uid) |
||||
now_mock.return_value = (term + 4).to_datetime() |
||||
with g_admin_ctx(): |
||||
webhosting_srv.disable_sites_for_inactive_clubs(remove_inactive_club_reps=True) |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [] |
||||
|
||||
# Make sure that inactive club reps get removed even if the site |
||||
# has already been disabled |
||||
group.add_member(user1.uid) |
||||
with g_admin_ctx(): |
||||
webhosting_srv.disable_sites_for_inactive_clubs(remove_inactive_club_reps=True) |
||||
group = ldap_srv_session.get_group(group.cn) |
||||
assert group.members == [] |
@ -0,0 +1,8 @@ |
||||
# This file would normally be in /etc/apache2/conf-available/disable-club.conf |
||||
# on caffeine. |
||||
|
||||
Alias /~sysadmin/disabled /users/sysadmin/www/disabled |
||||
|
||||
<Directory "/users/somerandomclub/www"> |
||||
Include snippets/disable-club.conf |
||||
</Directory> |