From 73d8c4e68c6d2981ab22568f1e21f35b4b921aa7 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:30:43 -0700 Subject: [PATCH 1/9] add daily cron file for updating officer permissions --- config/cron.sh | 6 ++++++ src/cron/daily.py | 39 +++++++++++++++++++++++++++++++++++++++ src/officers/crud.py | 4 +++- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 config/cron.sh create mode 100644 src/cron/daily.py 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/src/cron/daily.py b/src/cron/daily.py new file mode 100644 index 0000000..9501688 --- /dev/null +++ b/src/cron/daily.py @@ -0,0 +1,39 @@ +"""This module gets called by cron every day""" + +import asyncio +import logging + +from database import _db_session +from officers.crud import officer_terms + +_logger = logging.getLogger(__name__) + +async def update_permissions(): + db_session = _db_session() + + # TODO: get current github permissions + + # TODO: get current google drive permissions + + one_year_ago = datetime.today() - timedelta(days=365) + + # TODO: for performance, only include officers with recent end-date (1 yr) + all_officer_terms = await all_officer_terms(db_session) + for term in all_officer_terms: + if utils.is_active(term): + # TODO: if google drive permissions is not active, update them + # TODO: if github permissions is not active, update them + pass + elif utils.end_date <= one_year_ago: + # ignore old executives + continue + else: + # TODO: if google drive permissions are active, remove them + # TODO: if github permissions are active, remove them + pass + + _logger.info("Complete permissions update") + +if __name__ == "__main__": + asyncio.run(update_permissions()) + 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)] From 29083cbe0d2443ed30f8bf881cc30370008336a0 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 21 Aug 2024 18:01:27 -0700 Subject: [PATCH 2/9] update daily --- src/cron/daily.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cron/daily.py b/src/cron/daily.py index 9501688..e8b4b00 100644 --- a/src/cron/daily.py +++ b/src/cron/daily.py @@ -6,14 +6,16 @@ from database import _db_session from officers.crud import officer_terms +import github +import google + _logger = logging.getLogger(__name__) async def update_permissions(): db_session = _db_session() - # TODO: get current github permissions - - # TODO: get current google drive permissions + google_permissions = google.current_permissions() + github_permissions = github.current_permissions() one_year_ago = datetime.today() - timedelta(days=365) From 5e645eb08640f7653e759204e83693e5fdaef624 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:05:40 -0700 Subject: [PATCH 3/9] add google module --- src/google/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/google/__init__.py diff --git a/src/google/__init__.py b/src/google/__init__.py new file mode 100644 index 0000000..e69de29 From e70a981c9b69860cbb99c045d4ecb697b4a4feb3 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:10:55 -0700 Subject: [PATCH 4/9] small reformatting of github module --- src/github/github.py | 89 +++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/src/github/github.py b/src/github/github.py index 15ae943..b2dbfbe 100644 --- a/src/github/github.py +++ b/src/github/github.py @@ -23,8 +23,8 @@ class GithubTeam: slug: str async def _github_request_get( - url: str, - token: str + url: str, + token: str ) -> Response | None: result = requests.get( url, @@ -42,9 +42,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 +63,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 +82,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 +102,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. @@ -137,9 +138,9 @@ async def get_user_by_id( 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 + org: str = github_org_name, + uid: str | None = None, + email: str | None = None ) -> None: """ Takes one of either uid or email. Fails if provided both. @@ -151,57 +152,68 @@ async def add_user_to_org( 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"})) + 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() + result = await _github_request_post( + f"https://api.github.com/orgs/{org}/invitations", + os.environ.get("GITHUB_TOKEN"), + dumps({"email":email, "role":"direct_member"}) + ) + # 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"{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")) + result = await _github_request_delete( + f"https://api.github.com/orgs/{org}/memberships/{username}", + os.environ.get("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 + 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] async def add_user_to_team( - username: str, - slug: str, - org: str = github_org_name + username: str, + 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/{slug}/memberships/{username}", + os.environ.get("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, + slug: str, + org: str = github_org_name ) -> None: result = await _github_request_delete( f"https://api.github.com/orgs/{org}/teams/{slug}/memberships/{username}", @@ -209,3 +221,4 @@ async def remove_user_from_team( ) if result.status_code != 204: raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from team {slug}") + From 5ec3b949255b6b7a32591c11df549ab0b25485a8 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:37:21 -0700 Subject: [PATCH 5/9] update github module & add email function --- src/admin/email.py | 24 ++++++++++++++++++ src/github/__init__.py | 34 ++++++++++++++++++++++++++ src/github/{github.py => internals.py} | 16 +----------- src/github/types.py | 25 +++++++++++++++++++ 4 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 src/admin/email.py create mode 100644 src/github/__init__.py rename src/github/{github.py => internals.py} (96%) create mode 100644 src/github/types.py 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/github/__init__.py b/src/github/__init__.py new file mode 100644 index 0000000..646cd54 --- /dev/null +++ b/src/github/__init__.py @@ -0,0 +1,34 @@ +# TODO: does this allow importing anything from the module? + +from github.internals import +from admin.email import send_email + +# TODO: move this to github.constants.py +GITHUB_TEAMS = { + "doa" : "auto", + "election_officer": "auto", + "officers": "auto", + "w3_committee": "manual", + "wall_e": "manual", +} + +# TODO: move these functions to github.public.py + +def current_permissions() -> list[GithubUserPermissions]: + person_list = [] + + # this function should return a list of members that have permisisons + + # get info for each person in an auto github team + + # log warning if there are any unknown teams + + # log error & email if there are any missing teams + # send_email("csss-sysadmin@sfu.ca", "ERROR: Missing Team", "...") + pass + +def invite_user(github_username: str): + # invite this user to the github organization + pass + + diff --git a/src/github/github.py b/src/github/internals.py similarity index 96% rename from src/github/github.py rename to src/github/internals.py index b2dbfbe..8892446 100644 --- a/src/github/github.py +++ b/src/github/internals.py @@ -1,5 +1,4 @@ import os -from dataclasses import dataclass from json import dumps from typing import Any @@ -7,20 +6,7 @@ from constants import github_org_name from requests import Response - -@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 +from github.types import GithubUser, GithubTeam async def _github_request_get( url: str, diff --git a/src/github/types.py b/src/github/types.py new file mode 100644 index 0000000..8db3e5a --- /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 + + # used to connect the user to their officer info + username: str + + # which github teams they're in + teams: list[str] From 19e3a9f501614568cdbeaadd2742b3f22cfd4855 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 23 Aug 2024 09:20:44 -0700 Subject: [PATCH 6/9] add temp file for holding server secrets; todo: look into better methods? --- config/env.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 config/env.sh diff --git a/config/env.sh b/config/env.sh new file mode 100644 index 0000000..41eab07 --- /dev/null +++ b/config/env.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" + From 6823b731f85f3b7d824264845b7295288453b018 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:07:35 -0700 Subject: [PATCH 7/9] add function for getting current github permissions --- config/{env.sh => export_secrets.sh} | 0 src/github/__init__.py | 65 +++++++++++++++++++---- src/github/internals.py | 78 ++++++++++++++++++++-------- src/github/types.py | 2 +- src/officers/tables.py | 1 + 5 files changed, 112 insertions(+), 34 deletions(-) rename config/{env.sh => export_secrets.sh} (100%) diff --git a/config/env.sh b/config/export_secrets.sh similarity index 100% rename from config/env.sh rename to config/export_secrets.sh diff --git a/src/github/__init__.py b/src/github/__init__.py index 646cd54..7af7591 100644 --- a/src/github/__init__.py +++ b/src/github/__init__.py @@ -1,34 +1,79 @@ # TODO: does this allow importing anything from the module? -from github.internals import +from github.internals import list_members, list_teams from admin.email import send_email +# 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", + "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" +] + # TODO: move these functions to github.public.py def current_permissions() -> list[GithubUserPermissions]: - person_list = [] + """ + return a list of members in the organization (org) & their permissions + """ - # this function should return a list of members that have permisisons - - # get info for each person in an auto github team + member_list = list_members() + member_name_list = { member.name for member in member_list } - # log warning if there are any unknown teams + 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 - # log error & email if there are any missing teams - # send_email("csss-sysadmin@sfu.ca", "ERROR: Missing Team", "...") - pass + 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} not in the organization") + continue + user_permissions[member.username].teams += [team.name] + + return user_permissions.values() def invite_user(github_username: str): # invite this user to the github organization pass +def add_to_team(github_username: str): + pass diff --git a/src/github/internals.py b/src/github/internals.py index 8892446..18f30df 100644 --- a/src/github/internals.py +++ b/src/github/internals.py @@ -3,11 +3,13 @@ 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 +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + async def _github_request_get( url: str, token: str @@ -97,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": @@ -115,8 +117,9 @@ 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 @@ -124,7 +127,7 @@ async def get_user_by_id( return GithubUser(result_json["login"], result_json["id"], result_json["name"]) async def add_user_to_org( - org: str = github_org_name, + org: str = GITHUB_ORG_NAME, uid: str | None = None, email: str | None = None ) -> None: @@ -140,13 +143,13 @@ async def add_user_to_org( elif uid is not None: result = await _github_request_post( f"https://api.github.com/orgs/{org}/invitations", - os.environ.get("GITHUB_TOKEN"), + 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"), + GITHUB_TOKEN, dumps({"email":email, "role":"direct_member"}) ) @@ -160,34 +163,48 @@ async def add_user_to_org( async def delete_user_from_org( username: str, - org: str = github_org_name + 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") + 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 + 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"), + f"https://api.github.com/orgs/{org}/teams/{team_slug}/memberships/{username}", + GITHUB_TOKEN, dumps({"role":"member"}), ) @@ -198,13 +215,28 @@ async def add_user_to_team( async def remove_user_from_team( username: str, - slug: str, - org: str = github_org_name + 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 index 8db3e5a..9d1e09e 100644 --- a/src/github/types.py +++ b/src/github/types.py @@ -18,7 +18,7 @@ class GithubTeam: class GithubUserPermissions: # this class should store all the possible permissions a user might have - # used to connect the user to their officer info + # unique name used to connect the user to their officer info username: str # which github teams they're in 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 From cb0a22974c076c7e1c2fdea12710f3906003eb6e Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 30 Aug 2024 15:00:15 -0700 Subject: [PATCH 8/9] add function to update github permissions --- src/cron/daily.py | 77 ++++++++++++++++++++++++++++++++++-------- src/github/__init__.py | 23 ++++++++----- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/cron/daily.py b/src/cron/daily.py index e8b4b00..5616db0 100644 --- a/src/cron/daily.py +++ b/src/cron/daily.py @@ -5,36 +5,85 @@ from database import _db_session from officers.crud import officer_terms +from officers.constants import OfficerPosition import github import google _logger = logging.getLogger(__name__) -async def update_permissions(): - db_session = _db_session() - +async def update_google_permissions(db_session): google_permissions = google.current_permissions() - github_permissions = github.current_permissions() - - one_year_ago = datetime.today() - timedelta(days=365) + #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 permissions is not active, update them - # TODO: if github permissions is not active, update them + # TODO: if google drive permission is not active, update them pass - elif utils.end_date <= one_year_ago: - # ignore old executives - continue else: # TODO: if google drive permissions are active, remove them - # TODO: if github permissions are active, remove them - pass + pass + + _logger.info("updated google permissions") + +async def update_github_permissions(db_session): + github_permissions = github.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 term.username not in github_permissions: + # will wait another day until giving a person their required permissions + # TODO: setup a hook or something? + github.invite_user(term.username) + continue + + if utils.is_active(term): + # move all active officers to their respective teams + if term.position == OfficerPosition.DIRECTOR_OF_ARCHIVES: + github.set_user_teams( + term.username, + github_permissions[term.username].teams, + ["doa", "officers"] + ) + elif term.position == OfficerPosition.ELECTION_OFFICER: + github.set_user_teams( + term.username, + github_permissions[term.username].teams, + ["election_officer", "officers"] + ) + else: + github.set_user_teams( + term.username, + github_permissions[term.username].teams, + ["officers"] + ) + + else: + # move all inactive officers to the past_officers github organization + github.set_user_teams( + term.username, + github_permissions[term.username].teams, + ["past_officers"] + ) + + _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("Complete permissions update") + _logger.info("all permissions updated") if __name__ == "__main__": asyncio.run(update_permissions()) diff --git a/src/github/__init__.py b/src/github/__init__.py index 7af7591..2bb0434 100644 --- a/src/github/__init__.py +++ b/src/github/__init__.py @@ -28,10 +28,9 @@ if kind == "auto" ] - # TODO: move these functions to github.public.py -def current_permissions() -> list[GithubUserPermissions]: +def current_permissions() -> dict[str, GithubUserPermissions]: """ return a list of members in the organization (org) & their permissions """ @@ -64,16 +63,24 @@ def current_permissions() -> list[GithubUserPermissions]: 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} not in the organization") + _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.name] + user_permissions[member.username].teams += [team.slug] + + return user_permissions + +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) - return user_permissions.values() + 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): # invite this user to the github organization - pass - -def add_to_team(github_username: str): + # TODO: is an invited user considered a member of the organization? pass From 5537b19661fcafab846f60e91e71d018d5b61a28 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:03:46 -0700 Subject: [PATCH 9/9] add invite endpoint & simplify implementation --- src/cron/daily.py | 47 ++++++++++++----------------------------- src/github/__init__.py | 21 +++++++++++++++--- src/github/internals.py | 43 +++++++++++++++---------------------- 3 files changed, 49 insertions(+), 62 deletions(-) diff --git a/src/cron/daily.py b/src/cron/daily.py index 5616db0..f085114 100644 --- a/src/cron/daily.py +++ b/src/cron/daily.py @@ -5,7 +5,6 @@ from database import _db_session from officers.crud import officer_terms -from officers.constants import OfficerPosition import github import google @@ -30,46 +29,28 @@ async def update_google_permissions(db_session): _logger.info("updated google permissions") async def update_github_permissions(db_session): - github_permissions = github.current_permissions() - #one_year_ago = datetime.today() - timedelta(days=365) + github_permissions, team_id_map = github.all_permissions() - # 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 term.username not in github_permissions: - # will wait another day until giving a person their required permissions - # TODO: setup a hook or something? - github.invite_user(term.username) - continue - - if utils.is_active(term): + new_teams = ( # move all active officers to their respective teams - if term.position == OfficerPosition.DIRECTOR_OF_ARCHIVES: - github.set_user_teams( - term.username, - github_permissions[term.username].teams, - ["doa", "officers"] - ) - elif term.position == OfficerPosition.ELECTION_OFFICER: - github.set_user_teams( - term.username, - github_permissions[term.username].teams, - ["election_officer", "officers"] - ) - else: - github.set_user_teams( - term.username, - github_permissions[term.username].teams, - ["officers"] - ) - - else: + 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, - ["past_officers"] + new_teams ) _logger.info("updated github permissions") diff --git a/src/github/__init__.py b/src/github/__init__.py index 2bb0434..fda5809 100644 --- a/src/github/__init__.py +++ b/src/github/__init__.py @@ -3,6 +3,8 @@ 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 @@ -28,9 +30,17 @@ 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 current_permissions() -> dict[str, GithubUserPermissions]: +def all_permissions() -> dict[str, GithubUserPermissions]: """ return a list of members in the organization (org) & their permissions """ @@ -67,7 +77,12 @@ def current_permissions() -> dict[str, GithubUserPermissions]: continue user_permissions[member.username].teams += [team.slug] - return user_permissions + # 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: @@ -79,7 +94,7 @@ def set_user_teams(username: str, old_teams: list[str], new_teams: list[str]): # 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): +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/internals.py b/src/github/internals.py index 18f30df..8c7ecd6 100644 --- a/src/github/internals.py +++ b/src/github/internals.py @@ -126,38 +126,29 @@ async def get_user_by_id( else: return GithubUser(result_json["login"], result_json["id"], result_json["name"]) -async def add_user_to_org( +# 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, - uid: str | None = None, - email: str | None = None ) -> 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", - 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", - GITHUB_TOKEN, - dumps({"email":email, "role":"direct_member"}) - ) + """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']]}" ) @@ -166,7 +157,7 @@ async def delete_user_from_org( org: str = GITHUB_ORG_NAME ) -> None: if username is None: - raise Exception("Username cannot be empty") + raise ValueError("Username cannot be empty") result = await _github_request_delete( f"https://api.github.com/orgs/{org}/memberships/{username}", GITHUB_TOKEN )