diff --git a/config/cron.sh b/config/cron.sh new file mode 100644 index 0000000..0660237 --- /dev/null +++ b/config/cron.sh @@ -0,0 +1,6 @@ +# This script adds commands to the current crontab + +# run the daily script at 1am every morning +# TODO: make sure timezone is PST +crontab -l | { cat; echo "0 1 * * * /home/csss-site/csss-site-backend/src/cron/daily.py"; } | crontab - + diff --git a/config/export_secrets.sh b/config/export_secrets.sh new file mode 100644 index 0000000..41eab07 --- /dev/null +++ b/config/export_secrets.sh @@ -0,0 +1,10 @@ +# TODO: only fill out this file in production +export GMAIL_USERNAME = "todo" +export GMAIL_PASSWORD = "todo" +export GOOGLE_DRIVE_TOKEN = "todo" +export GITHUB_TOKEN = "todo" +export DISCORD_TOKEN = "todo" + +export CSSS_GUILD_ID = "todo" +export SFU_API_TOKEN = "todo" + diff --git a/src/admin/email.py b/src/admin/email.py new file mode 100644 index 0000000..c1987b2 --- /dev/null +++ b/src/admin/email.py @@ -0,0 +1,24 @@ +import os +import smtplib + +# TODO: set this up +GMAIL_PASSWORD = os.environ['GMAIL_PASSWORD'] +GMAIL_ADDRESS = "csss-site@gmail.com" + +# TODO: look into sending emails from an sfu maillist (this might be painful) +def send_email( + recipient_address: str, + subject: str, + contents: str, +): + mail = smtplib.SMTP('smtp.gmail.com', 587) + mail.ehlo() + mail.starttls() + mail.login(GMAIL_ADDRESS, GMAIL_PASSWORD) + + header = f"To: {recipient_address}\nFrom: {GMAIL_USERNAME}\nSubject: {subject}" + content = header + content + + mail.sendmail(GMAIL_ADDRESS, recipient_address, content) + mail.quit() + diff --git a/src/cron/daily.py b/src/cron/daily.py new file mode 100644 index 0000000..f085114 --- /dev/null +++ b/src/cron/daily.py @@ -0,0 +1,71 @@ +"""This module gets called by cron every day""" + +import asyncio +import logging + +from database import _db_session +from officers.crud import officer_terms + +import github +import google + +_logger = logging.getLogger(__name__) + +async def update_google_permissions(db_session): + google_permissions = google.current_permissions() + #one_year_ago = datetime.today() - timedelta(days=365) + + # TODO: for performance, only include officers with recent end-date (1 yr) + # but measure performance first + all_officer_terms = await all_officer_terms(db_session) + for term in all_officer_terms: + if utils.is_active(term): + # TODO: if google drive permission is not active, update them + pass + else: + # TODO: if google drive permissions are active, remove them + pass + + _logger.info("updated google permissions") + +async def update_github_permissions(db_session): + github_permissions, team_id_map = github.all_permissions() + + all_officer_terms = await all_officer_terms(db_session) + for term in all_officer_terms: + new_teams = ( + # move all active officers to their respective teams + github.officer_teams(term.position) + if utils.is_active(term) + # move all inactive officers to the past_officers github organization + else ["past_officers"] + ) + if term.username not in github_permissions: + user = get_user_by_username(term.username) + github.invite_user( + user.id, + [team_id_map[team] for team in new_teams], + ) + else: + github.set_user_teams( + term.username, + github_permissions[term.username].teams, + new_teams + ) + + _logger.info("updated github permissions") + +async def update_permissions(): + db_session = _db_session() + + update google_permissions(db_session) + db_session.commit() + + update_github_permissions(db_session) + db_session.commit() + + _logger.info("all permissions updated") + +if __name__ == "__main__": + asyncio.run(update_permissions()) + diff --git a/src/github/__init__.py b/src/github/__init__.py new file mode 100644 index 0000000..fda5809 --- /dev/null +++ b/src/github/__init__.py @@ -0,0 +1,101 @@ +# TODO: does this allow importing anything from the module? + +from github.internals import list_members, list_teams +from admin.email import send_email + +from officers.constants import OfficerPosition + +# Rules: +# - all past officers will be members of the github org +# - all past officers will be put in past_officers team +# - all current officers will be put in the officers team +# - + + +# TODO: move this to github.constants.py +GITHUB_TEAMS = { + "doa": "auto", + "election_officer": "auto", + + "officers": "auto", + # TODO: create the past_officers team + "past_officers": "auto", + + "w3_committee": "manual", + "wall_e": "manual", +} +AUTO_GITHUB_TEAMS = [ + team + for (name, kind) in GITHUB_TEAMS.items() + if kind == "auto" +] + +def officer_teams(position: str) -> list[str]: + if position == OfficerPosition.DIRECTOR_OF_ARCHIVES: + return ["doa", "officers"] + elif position == OfficerPosition.ELECTIONS_OFFICER: + return ["election_officer", "officers"] + else: + return ["officers"] + +# TODO: move these functions to github.public.py + +def all_permissions() -> dict[str, GithubUserPermissions]: + """ + return a list of members in the organization (org) & their permissions + """ + + member_list = list_members() + member_name_list = { member.name for member in member_list } + + team_list = [] + for team in list_teams(): + if team.name not in GITHUB_TEAMS.keys(): + _logger.warning(f"Found unexpected github team {team.name}") + continue + elif GITHUB_TEAMS[team.name] == "manual": + continue + + team_list += [team] + + team_name_list = [team.name for team in team_list] + for team_name in AUTO_GITHUB_TEAMS: + if team_name not in team_name_list: + # TODO: send email for all errors & warnings + # send_email("csss-sysadmin@sfu.ca", "ERROR: Missing Team", "...") + _logger.error(f"Could not find 'auto' team {team_name} in organization") + + user_permissions = { + user.username: GithubUserPermissions(user.username, []) + for user in member_list + } + for team in team_list: + team_members = list_team_members(team.slug) + for member in team_members: + if member.name not in member_name_list: + _logger.warning(f"Found unexpected team_member={member.name} in team_slug={team.slug} not in the organization") + continue + user_permissions[member.username].teams += [team.slug] + + # create a mapping between team name & team id, for use in creating invitations + team_id_map = {} + for team in team_list: + team_id_map[team.slug] = team.id + + return user_permissions, team_id_map + +def set_user_teams(username: str, old_teams: list[str], new_teams: list[str]): + for team_slug in old_teams: + if team_slug not in new_teams: + remove_user_from_team(term.username, team_slug) + + for team_slug in new_teams: + if team_slug not in old_teams: + # TODO: what happens when adding a user to a team who is not part of the github org yet? + add_user_to_team(term.username, team_slug) + +def invite_user(github_username: str, teams: str): + # invite this user to the github organization + # TODO: is an invited user considered a member of the organization? + pass + diff --git a/src/github/github.py b/src/github/internals.py similarity index 58% rename from src/github/github.py rename to src/github/internals.py index 15ae943..8c7ecd6 100644 --- a/src/github/github.py +++ b/src/github/internals.py @@ -1,30 +1,18 @@ import os -from dataclasses import dataclass from json import dumps from typing import Any import requests -from constants import github_org_name +from constants import github_org_name as GITHUB_ORG_NAME from requests import Response +from github.types import GithubUser, GithubTeam -@dataclass -class GithubUser: - username: str - id: int - name: str - -@dataclass -class GithubTeam: - id: int - url: str - name: str - # slugs are the space-free special names that github likes to use - slug: str +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") async def _github_request_get( - url: str, - token: str + url: str, + token: str ) -> Response | None: result = requests.get( url, @@ -42,9 +30,9 @@ async def _github_request_get( return result async def _github_request_post( - url: str, - token: str, - post_data: Any + url: str, + token: str, + post_data: Any ) -> Response | None: result = requests.post( url, @@ -63,15 +51,16 @@ async def _github_request_post( return result async def _github_request_delete( - url: str, - token: str + url: str, + token: str ) -> Response | None: result = requests.delete( url, - headers={ + headers = { "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28"} + "X-GitHub-Api-Version": "2022-11-28" + } ) rate_limit_remaining = int(result.headers["x-ratelimit-remaining"]) if rate_limit_remaining < 50: @@ -81,9 +70,9 @@ async def _github_request_delete( return result async def _github_request_put( - url: str, - token: str, - put_data: Any + url: str, + token: str, + put_data: Any ) -> Response | None: result = requests.put( url, @@ -101,7 +90,7 @@ async def _github_request_put( return result async def get_user_by_username( - username: str + username: str ) -> GithubUser | None: """ Takes in a Github username and returns an instance of GithubUser. @@ -110,7 +99,7 @@ async def get_user_by_username( """ result = await _github_request_get( f"https://api.github.com/users/{username}", - os.environ.get("GITHUB_TOKEN"), + GITHUB_TOKEN, ) result_json = result.json() if result_json["status"] == "404": @@ -128,84 +117,117 @@ async def get_user_by_id( """ result = await _github_request_get( f"https://api.github.com/user/{uid}", - os.environ.get("GITHUB_TOKEN"), + GITHUB_TOKEN, ) + # TODO: should we check the actual status of the response? result_json = result.json() if result_json["status"] == "404": return None else: return GithubUser(result_json["login"], result_json["id"], result_json["name"]) -async def add_user_to_org( - org: str = github_org_name, - uid: str | None = None, - email: str | None = None +# TODO: if needed, add support for getting user by email + +async def invite_user( + uid: str, + team_id_list: Optional[list[str]] = None, + org: str = GITHUB_ORG_NAME, ) -> None: - """ - Takes one of either uid or email. Fails if provided both. - """ - result = None - if uid is None and email is None: - raise ValueError("uid and username cannot both be empty") - elif uid is not None and email is not None: - raise ValueError("cannot populate both uid and email") - # Arbitrarily prefer uid - elif uid is not None: - result = await _github_request_post(f"https://api.github.com/orgs/{org}/invitations", - os.environ.get("GITHUB_TOKEN"), - dumps({"invitee_id":uid, "role":"direct_member"})) - elif email is not None: - result = await _github_request_post(f"https://api.github.com/orgs/{org}/invitations", - os.environ.get("GITHUB_TOKEN"), - dumps({"email":email, "role":"direct_member"})) - result_json = result.json() + """Invites the user & gives them access to the supplied teams""" + # TODO: how long until the invite goes out of date? + if team_id_list is None: + team_id_list = [] + + result = await _github_request_post( + f"https://api.github.com/orgs/{org}/invitations", + GITHUB_TOKEN, + dumps({"invitee_id":uid, "role":"direct_member", "team_ids":team_id_list}) + ) + # Logging here potentially? if result.status_code != 201: + result_json = result.json() raise Exception( - f"Status code {result.status_code} returned when attempting to add user to org: " + f"Status code {result.status_code} returned when attempting to invite user: " f"{result_json['message']}: {[error['message'] for error in result_json['errors']]}" ) async def delete_user_from_org( - username: str, - org: str = github_org_name + username: str, + org: str = GITHUB_ORG_NAME ) -> None: if username is None: - raise Exception("Username cannot be empty") - result = await _github_request_delete(f"https://api.github.com/orgs/{org}/memberships/{username}", - os.environ.get("GITHUB_TOKEN")) + raise ValueError("Username cannot be empty") + result = await _github_request_delete( + f"https://api.github.com/orgs/{org}/memberships/{username}", GITHUB_TOKEN + ) + # Logging here potentially? if result.status_code != 204: raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from organization {org}") -async def get_teams( - org: str = github_org_name +async def list_teams( + org: str = GITHUB_ORG_NAME ) -> list[str]: - result = await _github_request_get(f"https://api.github.com/orgs/{org}/teams", os.environ.get("GITHUB_TOKEN")) - json_result = result.json() - return [GithubTeam(team["id"], team["url"], team["name"], team["slug"]) for team in json_result] + result = await _github_request_get(f"https://api.github.com/orgs/{org}/teams", GITHUB_TOKEN) + return [ + GithubTeam(team["id"], team["url"], team["name"], team["slug"]) + for team in result.json() + ] + +async def list_team_members( + team_slug: str, + org: str = GITHUB_ORG_NAME +): + result = await _github_request_get( + f"https://api.github.com/orgs/{org}/teams/{team_slug}/members", + GITHUB_TOKEN + ) + return [ + GithubUser(user["login"], user["id"], user["name"]) + for user in result.json() + ] async def add_user_to_team( - username: str, - slug: str, - org: str = github_org_name + username: str, + team_slug: str, + org: str = GITHUB_ORG_NAME ) -> None: - result = await _github_request_put(f"https://api.github.com/orgs/{org}/teams/{slug}/memberships/{username}", - os.environ.get("GITHUB_TOKEN"), - dumps({"role":"member"})) - result_json = result.json() + result = await _github_request_put( + f"https://api.github.com/orgs/{org}/teams/{team_slug}/memberships/{username}", + GITHUB_TOKEN, + dumps({"role":"member"}), + ) + # Logging here potentially? if result.status_code != 200: + result_json = result.json() raise Exception(f"Status code {result.status_code} returned when attempting to add user to team: {result_json['message']}") async def remove_user_from_team( - username: str, - slug: str, - org: str = github_org_name + username: str, + team_slug: str, + org: str = GITHUB_ORG_NAME ) -> None: result = await _github_request_delete( - f"https://api.github.com/orgs/{org}/teams/{slug}/memberships/{username}", - os.environ.get("GITHUB_TOKEN"), + f"https://api.github.com/orgs/{org}/teams/{team_slug}/memberships/{username}", + GITHUB_TOKEN, ) if result.status_code != 204: - raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from team {slug}") + raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from team {team_slug}") + +async def list_members( + org: str = GITHUB_ORG_NAME +) -> list[GithubUser]: + result = await _github_request_get( + f"https://api.github.com/orgs/{org}/members", + GITHUB_TOKEN + ) + + # TODO: check for errors + + return [ + GithubUser(user["login"], user["id"], user["name"]) + for user in result.json() + ] + diff --git a/src/github/types.py b/src/github/types.py new file mode 100644 index 0000000..9d1e09e --- /dev/null +++ b/src/github/types.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +@dataclass +class GithubUser: + username: str + id: int + name: str + +@dataclass +class GithubTeam: + id: int + url: str + name: str + # slugs are the space-free special names that github likes to use + slug: str + +@dataclass +class GithubUserPermissions: + # this class should store all the possible permissions a user might have + + # unique name used to connect the user to their officer info + username: str + + # which github teams they're in + teams: list[str] diff --git a/src/google/__init__.py b/src/google/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/officers/crud.py b/src/officers/crud.py index 9fe91df..6c22281 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -35,6 +35,7 @@ async def current_officer_position(db_session: database.DBSession, computing_id: """ Returns None if the user is not currently an officer """ + query = sqlalchemy.select(OfficerTerm) query = query.where(OfficerTerm.computing_id == computing_id) query = utils.is_active_officer(query) @@ -153,7 +154,8 @@ async def all_officer_terms( OfficerInfo.computing_id == term.computing_id ) officer_info = await db_session.scalar(officer_info_query) - + + # TODO: remove is_active from the database is_active = (term.end_date is None) or (datetime.today() <= term.end_date) officer_data_list += [OfficerData.from_data(term, officer_info, include_private, is_active)] diff --git a/src/officers/tables.py b/src/officers/tables.py index 9fe45a9..fd929e4 100644 --- a/src/officers/tables.py +++ b/src/officers/tables.py @@ -130,6 +130,7 @@ class OfficerInfo(Base): primary_key=True, ) phone_number = Column(String(24)) + # TODO: add unique constraint to this (stops users from stealing the username of someone else) github_username = Column(String(GITHUB_USERNAME_LEN)) # A comma separated list of emails