diff --git a/.gitignore b/.gitignore index e869bd6..12a75c6 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ +# main src/run/ logs/ +# google drive api +google_key.json + # Python - Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] 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/requirements.txt b/requirements.txt index 1a2989f..7b350cf 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ uvicorn[standard]==0.27.1 sqlalchemy==2.0.27 asyncpg==0.29.0 alembic==1.13.1 +google-api-python-client==2.143.0 # minor pyOpenSSL==24.0.0 # for generating cryptographically secure random numbers diff --git a/src/admin/email.py b/src/admin/email.py new file mode 100644 index 0000000..646512a --- /dev/null +++ b/src/admin/email.py @@ -0,0 +1,25 @@ +import os +import smtplib + +# TODO: set this up +GMAIL_PASSWORD = os.environ.get("GMAIL_PASSWORD") +GMAIL_ADDRESS = "csss-site@gmail.com" +GMAIL_USERNAME = "" + +# TODO: look into sending emails from an sfu maillist (this might be painful) +def send_email( + recipient_address: str, + subject: str, + content: 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/alembic/versions/166f3772fce7_auth_officer_init.py b/src/alembic/versions/166f3772fce7_auth_officer_init.py index 03d828c..7e1a345 100644 --- a/src/alembic/versions/166f3772fce7_auth_officer_init.py +++ b/src/alembic/versions/166f3772fce7_auth_officer_init.py @@ -39,7 +39,6 @@ def upgrade() -> None: "officer_term", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("computing_id", sa.String(length=32), sa.ForeignKey("site_user.computing_id"), nullable=False), - sa.Column("is_filled_in", sa.Boolean(), nullable=False), sa.Column("position", sa.String(length=128), nullable=False), sa.Column("start_date", sa.DateTime(), nullable=False), sa.Column("end_date", sa.DateTime(), nullable=True), @@ -53,7 +52,6 @@ def upgrade() -> None: ) op.create_table( "officer_info", - sa.Column("is_filled_in", sa.Boolean(), nullable=False), sa.Column("legal_name", sa.String(length=128), nullable=False), sa.Column("discord_id", sa.String(length=18), nullable=True), sa.Column("discord_name", sa.String(length=32), nullable=True), diff --git a/src/auth/crud.py b/src/auth/crud.py index bfad678..2d12e1f 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession -async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str) -> None: +async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str): """ Updates the past user session if one exists, so no duplicate sessions can ever occur. @@ -84,9 +84,35 @@ async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | N # remove all out of date user sessions -async def task_clean_expired_user_sessions(db_session: AsyncSession) -> None: +async def task_clean_expired_user_sessions(db_session: AsyncSession): one_day_ago = datetime.now() - timedelta(days=0.5) query = sqlalchemy.delete(UserSession).where(UserSession.issue_time < one_day_ago) await db_session.execute(query) await db_session.commit() + + +async def user_info(db_session: AsyncSession, session_id: str) -> None | dict: + query = ( + sqlalchemy + .select(UserSession) + .where(UserSession.session_id == session_id) + ) + user_session = await db_session.scalar(query) + if user_session is None: + return None + + query = ( + sqlalchemy + .select(SiteUser) + .where(SiteUser.computing_id == user_session.computing_id) + ) + user = await db_session.scalar(query) + if user is None: + return None + + return { + "computing_id": user_session.computing_id, + "first_logged_in": user.first_logged_in.isoformat(), + "last_logged_in": user.last_logged_in.isoformat() + } diff --git a/src/auth/urls.py b/src/auth/urls.py index 15fef99..9e6dbcb 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -74,6 +74,7 @@ async def login_user( return response +# TODO: deprecate this when possible @router.get( "/check", description="Check if the current user is logged in based on session_id from cookies", @@ -93,6 +94,28 @@ async def check_authentication( return JSONResponse(response_dict) +@router.get( + "/info", + description="Get info about the current user. Only accessible by that user", +) +async def get_info( + request: Request, + db_session: database.DBSession, +): + """ + Currently this endpoint only returns the info stored in tables in the auth module. + """ + session_id = request.cookies.get("session_id", None) + if session_id is None: + raise HTTPException(status_code=401, detail="User must be authenticated to get their info") + + user_info = await crud.user_info(db_session, session_id) + if user_info is None: + raise HTTPException(status_code=401, detail="Could not find user with session_id, please log in") + + return JSONResponse(user_info) + + @router.post( "/logout", description="Logs out the current user by invalidating the session_id cookie", diff --git a/src/constants.py b/src/constants.py index 1da1153..60915da 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,8 +1,11 @@ import os root_ip_address = "http://localhost:8080" if os.environ.get("LOCAL") == "true" else "https://api.sfucsss.org" -guild_id = "1260652618875797504" if os.environ.get("LOCAL") == "true" else "228761314644852736" -github_org_name = "CSSS-Test-Organization" if os.environ.get("LOCAL") == "true" else "CSSS" +GITHUB_ORG_NAME = "CSSS-Test-Organization" if os.environ.get("LOCAL") == "true" else "CSSS" + +W3_GUILD_ID = "1260652618875797504" +CSSS_GUILD_ID = "228761314644852736" +ACTIVE_GUILD_ID = W3_GUILD_ID if os.environ.get("LOCAL") == "true" else CSSS_GUILD_ID SESSION_ID_LEN = 512 # technically a max of 8 digits https://www.sfu.ca/computing/about/support/tips/sfu-userid.html diff --git a/src/cron/daily.py b/src/cron/daily.py new file mode 100644 index 0000000..40e2d0c --- /dev/null +++ b/src/cron/daily.py @@ -0,0 +1,69 @@ +"""This module gets called by cron every day""" + +import asyncio +import logging + +import github +import google_api +import utils +from database import _db_session +from officers.crud import all_officer_terms, get_user_by_username, officer_terms + +_logger = logging.getLogger(__name__) + +async def update_google_permissions(db_session): + # TODO: implement this function + # google_permissions = google_api.all_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 + for term in await all_officer_terms(db_session): + 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() + + for term in await all_officer_terms(db_session): + 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/database.py b/src/database.py index 34ac004..c1abf41 100644 --- a/src/database.py +++ b/src/database.py @@ -84,6 +84,7 @@ async def session(self) -> AsyncIterator[AsyncSession]: if os.environ.get("DB_PORT") is not None: + # using a remote (or docker) database db_port = os.environ.get("DB_PORT") SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/main" SQLALCHEMY_TEST_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/test" @@ -91,16 +92,17 @@ async def session(self) -> AsyncIterator[AsyncSession]: SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg:///main" SQLALCHEMY_TEST_DATABASE_URL = "postgresql+asyncpg:///test" + # also TODO: make this nicer, using a class to hold state... # and use this in load_test_db for the test db as well? def setup_database(): global sessionmanager # TODO: where is sys.stdout piped to? I want all these to go to a specific logs folder - if os.environ.get("LOCAL"): - sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": True}) - else: - sessionmanager = DatabaseSessionManager(SQLALCHEMY_DATABASE_URL, {"echo": True}) + sessionmanager = DatabaseSessionManager( + SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("LOCAL") else SQLALCHEMY_DATABASE_URL, + { "echo": True }, + ) @contextlib.asynccontextmanager async def lifespan(app: FastAPI): diff --git a/src/discord/discord.py b/src/discord/discord.py index 4b5c51f..b2ebba2 100644 --- a/src/discord/discord.py +++ b/src/discord/discord.py @@ -3,7 +3,7 @@ from time import sleep import requests -from constants import guild_id +from constants import ACTIVE_GUILD_ID from requests import Response # ----------------------- # @@ -13,13 +13,19 @@ ADMINISTRATOR = 0b1000 VIEW_CHANNEL = 0b0010_0000_0000 +# this is the "Application ID" +TOKEN = os.environ.get("DISCORD_TOKEN") + @dataclass class User: id: str + # this is the normal username username: str # Discriminators are what used to be the #xxxx after a discord username. Accounts which haven't # migrated over yet have them still. + # For accounts that don't have one, it's '0' discriminator: str + # this is the server-nickname global_name: str | None = None avatar: str | None = None @@ -63,7 +69,7 @@ async def _discord_request( async def get_channel_members( cid: str, # TODO: hardcode guild_id (remove it as argument) if we ever refactor this module - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[GuildMember]: """ Returns empty list if invalid channel id is provided. @@ -93,8 +99,8 @@ async def get_channel_members( assert role_everyone is not None base_permission = role_everyone["permissions"] - users = await get_guild_members(guild_id) - roles = await get_all_roles(guild_id) + users = await get_guild_members(gid) + roles = await get_all_roles(gid) users_with_access = [] # note string conversion to int @@ -136,11 +142,10 @@ async def get_channel_members( async def get_channel( cid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> Channel | None: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/channels" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() channel = next((channel for channel in result_json if channel["id"] == cid), None) @@ -150,11 +155,10 @@ async def get_channel( return Channel(channel["id"], channel["type"], channel["guild_id"], channel["name"], channel["permission_overwrites"]) async def get_all_channels( - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[str]: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/channels" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() channels = [channel for channel in result_json if channel["type"] != DISCORD_CATEGORY_ID] @@ -162,46 +166,41 @@ async def get_all_channels( return channel_names - async def get_role_name_by_id( rid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> str: roles = await get_all_roles(gid) return roles[rid][0] async def get_role_by_id( rid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> dict | None: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/roles" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() return next((role for role in result_json if role["id"] == rid), None) async def get_user_roles( uid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[str]: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/members/{uid}" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() return result_json["roles"] - async def get_all_roles( - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> dict[str, list[str]]: """ Grabs all roles in a given guild. """ - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/roles" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() roles = [([role["id"], [role["name"], role["permissions"]]]) for role in result_json] @@ -209,12 +208,11 @@ async def get_all_roles( async def get_guild_members_with_role( rid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[GuildMember]: - token = os.environ.get("TOKEN") # base case url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() @@ -233,7 +231,7 @@ async def get_guild_members_with_role( while True: url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000&after={last_uid}" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() @@ -247,48 +245,68 @@ async def get_guild_members_with_role( last_uid = res[-1].user.id async def get_guild_members( - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[GuildMember]: - token = os.environ.get("TOKEN") # base case url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000" - result = await _discord_request(url, token) - - result_json = result.json() - users = [GuildMember(User(user["user"]["id"], user["user"]["username"], user["user"]["discriminator"], user["user"]["global_name"], user["user"]["avatar"]), user["roles"]) for user in result_json] + result = await _discord_request(url, TOKEN) + + if result.status_code != 200: + raise Exception(f"Got unexpected error result: {result.json()}") + + users = [ + GuildMember( + User( + user["user"]["id"], + user["user"]["username"], + user["user"]["discriminator"], + user["user"]["global_name"], + user["user"]["avatar"], + ), + user["roles"] + ) for user in result.json() + ] last_uid = users[-1].user.id while True: url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000&after={last_uid}" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() - if len(result_json) == 0: return users - res = [GuildMember(User(user["user"]["id"], user["user"]["username"], user["user"]["discriminator"], user["user"]["global_name"], user["user"]["avatar"]), user["roles"]) for user in result_json] + res = [ + GuildMember( + User( + user["user"]["id"], + user["user"]["username"], + user["user"]["discriminator"], + user["user"]["global_name"], + user["user"]["avatar"], + ), + user["roles"] + ) for user in result_json + ] users += res last_uid = res[-1].user.id async def get_categories( - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[str]: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/channels" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() return [category["name"] for category in result_json if category["type"] == DISCORD_CATEGORY_ID] async def get_channels_by_category_name( category_name: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[Channel]: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/channels" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() # TODO: edge case if there exist duplicate category names, see get_channels_by_category_id() @@ -313,11 +331,10 @@ async def get_channels_by_category_name( async def get_channels_by_category_id( cid: str, - gid: str = guild_id + gid: str = ACTIVE_GUILD_ID ) -> list[Channel]: - token = os.environ.get("TOKEN") url = f"https://discord.com/api/v10/guilds/{gid}/channels" - result = await _discord_request(url, token) + result = await _discord_request(url, TOKEN) result_json = result.json() channels = [ @@ -334,15 +351,42 @@ async def get_channels_by_category_id( return channels async def search_user( - user: str, - gid: str = guild_id -) -> User: - token = os.environ.get("TOKEN") - url = f"https://discord.com/api/v10/guilds/{gid}/members/search?query={user}" - result = await _discord_request(url, token) - json = result.json() - - if len(json) == 0: - return None - json = json[0]["user"] - return User(json["id"], json["username"], json["discriminator"], json["global_name"], json["avatar"]) + starts_with: str, + limit: int = 1, + gid: str = ACTIVE_GUILD_ID +) -> list[User]: + """ + Returns a list of User objects "whose username or nickname starts with a provided string" + """ + if starts_with == "": + raise ValueError("starts_with must be non-empty string; use get_guild_members instead if desired.") + + url = f"https://discord.com/api/v10/guilds/{gid}/members/search?query={starts_with}&limit={limit}" + + result = await _discord_request(url, TOKEN) + return [ + User( + entry["user"]["id"], + entry["user"]["username"], + entry["user"]["discriminator"], + entry["user"]["global_name"], + entry["user"]["avatar"] + ) for entry in result.json() + ] + +async def search_username( + username_starts_with: str, + gid: str = ACTIVE_GUILD_ID +) -> list[User]: + """ + Returns a list of User objects whose username starts with a provided string. + + Will not return a user with a non-zero descriminator -> these users must update their discord version! + """ + # if there are more than 100 users with the same nickname as the "username_starts_with" string, this may fail + user_list = await search_user(username_starts_with, 99, gid) + return [ + user for user in user_list + if user.username.startswith(username_starts_with) + and user.discriminator == "0" + ] diff --git a/src/github/__init__.py b/src/github/__init__.py new file mode 100644 index 0000000..67fb7bc --- /dev/null +++ b/src/github/__init__.py @@ -0,0 +1,101 @@ +# TODO: does this allow importing anything from the module? +import logging + +#from admin.email import send_email +from officers.constants import OfficerPosition + +from github.internals import add_user_to_team, list_members, list_team_members, list_teams, remove_user_from_team +from github.types import GithubUserPermissions + +# 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 + +_logger = logging.getLogger(__name__) + +# 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 = [ + name + 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(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(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 53% rename from src/github/github.py rename to src/github/internals.py index 15ae943..15c1768 100644 --- a/src/github/github.py +++ b/src/github/internals.py @@ -1,30 +1,21 @@ 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 from requests import Response +from github.types import GithubTeam, GithubUser -@dataclass -class GithubUser: - username: str - id: int - name: str +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") -@dataclass -class GithubTeam: - id: int - url: str - name: str - # slugs are the space-free special names that github likes to use - slug: str +# TODO: go through this module & make sure that all functions check for response.status_code +# being invalid as specified by the API endpoints async def _github_request_get( - url: str, - token: str + url: str, + token: str ) -> Response | None: result = requests.get( url, @@ -42,9 +33,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 +54,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 +73,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 +93,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,13 +102,13 @@ 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": + if result.status_code == 404: return None - else: - return GithubUser(result_json["login"], result_json["id"], result_json["name"]) + + result_json = result.json() + return GithubUser(result_json["login"], result_json["id"], result_json["name"]) async def get_user_by_id( uid: str @@ -128,84 +120,120 @@ 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, ) - result_json = result.json() - if result_json["status"] == "404": + if result.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 -) -> 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() + return GithubUser(result_json["login"], result_json["id"], result_json["name"]) + +# TODO: if needed, add support for getting user by email + +# TODO: can we revoke access before an invite is accepeted? +async def invite_user( + uid: str, + team_id_list: list[str] | None = None, + org: str = GITHUB_ORG_NAME, +) -> None: + """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 | None, + 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, + page_number: int = 1, + page_size: int = 99, +) -> list[GithubUser]: + result = await _github_request_get( + f"https://api.github.com/orgs/{org}/members?per_page={page_size}&page={page_number}", + GITHUB_TOKEN + ) + + if result.status_code != 200: + raise Exception(f"Got result with status_code={result.status_code}, and contents={result.text}") + + return [ + (user["login"], user["id"]) + for user in result.json() + ] diff --git a/src/github/types.py b/src/github/types.py new file mode 100644 index 0000000..9e5f48a --- /dev/null +++ b/src/github/types.py @@ -0,0 +1,26 @@ +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_api/__init__.py b/src/google_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/google_api/constants.py b/src/google_api/constants.py new file mode 100644 index 0000000..f547817 --- /dev/null +++ b/src/google_api/constants.py @@ -0,0 +1,34 @@ +import os +import pathlib + +# this google account runs the google workspace for executives +GOOGLE_WORKSPACE_ACCOUNT = "csss@sfucsss.org" + +# any officer from the past 5 semesters has access to these +# TODO: ask the pres if we still want these rules, or not +FIVE_SEM_OFFICER_ACCESS = [ + "CSSS@SFU", +] + +EXECUTIVE_ACCESS = [ + "CSSS Gallery", + "CSSS@SFU", + "Deep-Exec", + "Exec_Photos", + "Private Gallery", +] + +# scopes are like permissions to google +GOOGLE_API_SCOPES = [ + # google drive permission + "https://www.googleapis.com/auth/drive" +] + +# TODO: make this into an enum, or something +GOOGLE_DRIVE_PERMISSION_ROLES = [ + "organizer", + "fileOrganizer", +] + +_this_file_path = pathlib.Path(__file__).parent.resolve() +SERVICE_ACCOUNT_KEY_PATH = str((_this_file_path / "../../google_key.json").resolve()) diff --git a/src/google_api/internals.py b/src/google_api/internals.py new file mode 100644 index 0000000..2246076 --- /dev/null +++ b/src/google_api/internals.py @@ -0,0 +1,68 @@ +# google workspace (shared drives) + google drive api + +from google.oauth2 import service_account +from googleapiclient.discovery import build + +from google_api.constants import GOOGLE_API_SCOPES, GOOGLE_WORKSPACE_ACCOUNT, SERVICE_ACCOUNT_KEY_PATH + +# TODO: understand how these work +credentials = service_account.Credentials.from_service_account_file( + filename=SERVICE_ACCOUNT_KEY_PATH, + scopes=GOOGLE_API_SCOPES +) +delegated_credentials = credentials.with_subject(GOOGLE_WORKSPACE_ACCOUNT) +service = build("drive", "v3", credentials=delegated_credentials) + +def _list_shared_drives() -> list: + return ( + service + .drives() + .list( + #pageSize = 50, + #q = "name contains 'CSSS'", + #useDomainAdminAccess = True, + ) + .execute() + ) + +def list_drive_permissions(drive_id: str) -> list: + return ( + service + .permissions() + .list( + fileId = drive_id, + # important to find the shared drive + supportsAllDrives = True, + fields = "*", + ) + .execute() + ) + +def create_drive_permission(drive_id: str, permission: dict): + return ( + service + .permissions() + .create( + fileId = drive_id, + + # TODO: update message + emailMessage = "You were just given permission to an SFU CSSS shared google drive!", + sendNotificationEmail = True, + supportsAllDrives = True, + + body=permission, + ) + .execute() + ) + +def delete_drive_permission(drive_id: str, permission_id: str): + return ( + service + .permissions() + .delete( + fileId = drive_id, + permissionId = permission_id, + supportsAllDrives = True, + ) + .execute() + ) diff --git a/src/load_test_db.py b/src/load_test_db.py index b0a5e5c..c784428 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -10,7 +10,7 @@ from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager from officers.constants import OfficerPosition from officers.crud import create_new_officer_info, create_new_officer_term, update_officer_info, update_officer_term -from officers.types import OfficerInfoData, OfficerTermData +from officers.tables import OfficerInfo, OfficerTerm from sqlalchemy.ext.asyncio import AsyncSession @@ -68,7 +68,7 @@ async def load_test_officers_data(db_session: AsyncSession): print("add officer info") # this person has uploaded all of their info - await create_new_officer_info(db_session, OfficerInfoData( + await create_new_officer_info(db_session, OfficerInfo( legal_name="Person A", discord_id=str(88_1234_7182_4877_1111), discord_name="person_a_yeah", @@ -80,19 +80,21 @@ async def load_test_officers_data(db_session: AsyncSession): google_drive_email="person_a@gmail.com", )) # this person has not joined the CSSS discord, so their discord name & nickname could not be found - await create_new_officer_info(db_session, OfficerInfoData( + await create_new_officer_info(db_session, OfficerInfo( + computing_id="abc22", + legal_name="Person B", + phone_number="1112223333", + discord_id=str(88_1234_7182_4877_2222), discord_name=None, discord_nickname=None, - computing_id="abc22", - phone_number="1112223333", - github_username="person_b", google_drive_email="person_b@gmail.com", + github_username="person_b", )) # this person has uploaded the minimal amount of information - await create_new_officer_info(db_session, OfficerInfoData( + await create_new_officer_info(db_session, OfficerInfo( legal_name="Person C", discord_id=None, discord_name=None, @@ -105,7 +107,8 @@ async def load_test_officers_data(db_session: AsyncSession): )) await db_session.commit() - await create_new_officer_term(db_session, OfficerTermData( + # TODO: will id autoincrement? + await create_new_officer_term(db_session, OfficerTerm( computing_id="abc11", position=OfficerPosition.VICE_PRESIDENT, @@ -122,7 +125,7 @@ async def load_test_officers_data(db_session: AsyncSession): biography="Hi! I'm person A and I do lots of cool things! :)", photo_url=None, # TODO: this should be replaced with a default image )) - await create_new_officer_term(db_session, OfficerTermData( + await create_new_officer_term(db_session, OfficerTerm( computing_id="abc11", position=OfficerPosition.EXECUTIVE_AT_LARGE, @@ -139,7 +142,7 @@ async def load_test_officers_data(db_session: AsyncSession): biography="Hi! I'm person A and I want school to be over ; _ ;", photo_url=None, # TODO: this should be replaced with a default image )) - await create_new_officer_term(db_session, OfficerTermData( + await create_new_officer_term(db_session, OfficerTerm( computing_id="abc33", position=OfficerPosition.PRESIDENT, @@ -157,7 +160,7 @@ async def load_test_officers_data(db_session: AsyncSession): photo_url=None, # TODO: this should be replaced with a default image )) # this officer term is not fully filled in - await create_new_officer_term(db_session, OfficerTermData( + await create_new_officer_term(db_session, OfficerTerm( computing_id="abc22", position=OfficerPosition.DIRECTOR_OF_ARCHIVES, @@ -176,7 +179,7 @@ async def load_test_officers_data(db_session: AsyncSession): )) await db_session.commit() - await update_officer_info(db_session, OfficerInfoData( + await update_officer_info(db_session, OfficerInfo( legal_name="Person C ----", discord_id=None, discord_name=None, @@ -188,7 +191,7 @@ async def load_test_officers_data(db_session: AsyncSession): github_username=None, google_drive_email=None, )) - await update_officer_term(db_session, OfficerTermData( + await update_officer_term(db_session, OfficerTerm( computing_id="abc33", position=OfficerPosition.PRESIDENT, @@ -212,7 +215,7 @@ async def load_sysadmin(db_session: AsyncSession): # put your computing id here for testing purposes SYSADMIN_COMPUTING_ID = "gsa92" - await create_new_officer_info(db_session, OfficerInfoData( + await create_new_officer_info(db_session, OfficerInfo( legal_name="Gabe Schulz", discord_id=None, discord_name=None, @@ -223,7 +226,7 @@ async def load_sysadmin(db_session: AsyncSession): github_username=None, google_drive_email=None, )) - await create_new_officer_term(db_session, OfficerTermData( + await create_new_officer_term(db_session, OfficerTerm( computing_id=SYSADMIN_COMPUTING_ID, position=OfficerPosition.SYSTEM_ADMINISTRATOR, diff --git a/src/officers/crud.py b/src/officers/crud.py index 9fe91df..682359a 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -6,14 +6,12 @@ import sqlalchemy import utils from auth.tables import SiteUser +from fastapi import HTTPException from officers.constants import OfficerPosition from officers.tables import OfficerInfo, OfficerTerm from officers.types import ( OfficerData, - OfficerInfoData, - OfficerPrivateData, - OfficerTermData, ) _logger = logging.getLogger(__name__) @@ -35,6 +33,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) @@ -46,6 +45,29 @@ async def current_officer_position(db_session: database.DBSession, computing_id: else: return officer_term.position +async def officer_info(db_session: database.DBSession, computing_id: str) -> OfficerInfo: + query = ( + sqlalchemy + .select(OfficerInfo) + .where(OfficerInfo.computing_id == computing_id) + ) + officer_term = await db_session.scalar(query) + if officer_term is None: + raise HTTPException(status_code=400, detail=f"officer_info for computing_id={computing_id} does not exist yet") + return officer_term + +async def officer_term(db_session: database.DBSession, term_id: int) -> OfficerTerm: + query = ( + sqlalchemy + .select(OfficerTerm) + .where(OfficerTerm.id == term_id) + ) + officer_term = await db_session.scalar(query) + if officer_term is None: + raise HTTPException(status_code=400, detail=f"Could not find officer_term with id={term_id}") + return officer_term + +# TODO: change to "get_officer_terms" naming convention (& all functions in this module) async def officer_terms( db_session: database.DBSession, computing_id: str, @@ -142,7 +164,7 @@ async def all_officer_terms( """ query = sqlalchemy.select(OfficerTerm) if view_only_filled_in: - query = query.where(OfficerTerm.is_filled_in) + query = OfficerTerm.sql_is_filled_in(query) query = query.order_by(OfficerTerm.start_date.desc()) officer_terms = (await db_session.scalars(query)).all() @@ -159,93 +181,71 @@ async def all_officer_terms( return officer_data_list -async def create_new_officer_info(db_session: database.DBSession, officer_info_data: OfficerInfoData) -> bool: +async def create_new_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfo) -> bool: """ Return False if the officer already exists """ query = sqlalchemy.select(OfficerInfo) - query = query.where(OfficerInfo.computing_id == officer_info_data.computing_id) - officer_info = await db_session.scalar(query) - if officer_info is not None: + query = query.where(OfficerInfo.computing_id == officer_info.computing_id) + stored_officer_info = await db_session.scalar(query) + if stored_officer_info is not None: return False - is_filled_in = officer_info_data.is_filled_in() - - new_user_session = OfficerInfo.from_data(is_filled_in, officer_info_data) - db_session.add(new_user_session) + db_session.add(new_officer_info) return True -async def update_officer_info(db_session: database.DBSession, officer_info_data: OfficerInfoData) -> bool: +async def create_new_officer_term( + db_session: database.DBSession, + new_officer_term: OfficerTerm +): + db_session.add(new_officer_term) + +async def update_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfo) -> bool: """ Return False if the officer doesn't exist yet """ query = sqlalchemy.select(OfficerInfo) - query = query.where(OfficerInfo.computing_id == officer_info_data.computing_id) + query = query.where(OfficerInfo.computing_id == new_officer_info.computing_id) officer_info = await db_session.scalar(query) if officer_info is None: return False - is_filled_in = officer_info_data.is_filled_in() + # TODO: how to detect an entry insert error? For example, what happens if + # we try to set our discord id to be the same as another executive's? query = ( sqlalchemy .update(OfficerInfo) .where(OfficerInfo.computing_id == officer_info.computing_id) - .values(OfficerInfo.update_dict(is_filled_in, officer_info_data)) + .values(new_officer_info.to_update_dict()) ) - await db_session.execute(query) - return True -async def create_new_officer_term( - db_session: database.DBSession, - officer_term_data: OfficerTermData -) -> bool: - query = sqlalchemy.select(OfficerTerm) - query = query.where(OfficerTerm.computing_id == officer_term_data.computing_id) - query = query.where(OfficerTerm.start_date == officer_term_data.start_date) - query = query.where(OfficerTerm.position == officer_term_data.position) - officer_data = await db_session.scalar(query) - if officer_data is not None: - # if an entry with this (computing_id, position, start_date) already exists, do nothing - return False - - is_filled_in = officer_term_data.is_filled_in() - - db_session.add(OfficerTerm.from_data(is_filled_in, officer_term_data)) return True async def update_officer_term( db_session: database.DBSession, - officer_term_data: OfficerTermData, + new_officer_term: OfficerTerm, ): """ - If there's an existing entry with the same computing_id, start_date, and position, - update the data of that term. + Update based on the term id. Returns false if the above entry does not exist. """ - # TODO: we should move towards using the term_id, so that the start_date can be updated if needed? query = ( sqlalchemy .select(OfficerTerm) - .where(OfficerTerm.computing_id == officer_term_data.computing_id) - .where(OfficerTerm.position == officer_term_data.position) - .where(OfficerTerm.start_date == officer_term_data.start_date) + .where(OfficerTerm.id == new_officer_term.id) ) officer_term = await db_session.scalar(query) if officer_term is None: return False - is_filled_in = officer_term_data.is_filled_in() query = ( sqlalchemy .update(OfficerTerm) - .where(OfficerTerm.computing_id == officer_term_data.computing_id) - .where(OfficerTerm.position == officer_term_data.position) - .where(OfficerTerm.start_date == officer_term_data.start_date) - .values(OfficerTerm.update_dict(is_filled_in, officer_term_data)) + .where(OfficerTerm.id == new_officer_term.id) + .values(new_officer_term.to_update_dict()) ) - await db_session.execute(query) return True diff --git a/src/officers/tables.py b/src/officers/tables.py index 0733937..d5ab995 100644 --- a/src/officers/tables.py +++ b/src/officers/tables.py @@ -10,17 +10,17 @@ ) from database import Base from sqlalchemy import ( - Boolean, + # Boolean, Column, DateTime, ForeignKey, Integer, + Select, String, Text, + and_, ) -from officers.types import OfficerInfoData, OfficerTermData - # A row represents an assignment of a person to a position. # An officer with multiple positions, such as Frosh Chair & DoE, is broken up into multiple assignments. @@ -34,70 +34,25 @@ class OfficerTerm(Base): nullable=False, ) - # a record will only be set as publically visible if sufficient data has been given - is_filled_in = Column(Boolean, nullable=False) - position = Column(String(128), nullable=False) start_date = Column(DateTime, nullable=False) # end_date is only not-specified for positions that don't have a length (ie. webmaster) - end_date = Column(DateTime) + end_date = Column(DateTime, nullable=True) - nickname = Column(String(128)) - favourite_course_0 = Column(String(32)) - favourite_course_1 = Column(String(32)) + nickname = Column(String(128), nullable=True) + favourite_course_0 = Column(String(32), nullable=True) + favourite_course_1 = Column(String(32), nullable=True) # programming language - favourite_pl_0 = Column(String(32)) - favourite_pl_1 = Column(String(32)) - biography = Column(Text) - photo_url = Column(Text) # some urls get big, best to let it be a string - - @staticmethod - def from_data(is_filled_in: bool, officer_term_data: OfficerTermData) -> OfficerTerm: - return OfficerTerm( - computing_id = officer_term_data.computing_id, - is_filled_in = is_filled_in, - - position = officer_term_data.position, - start_date = officer_term_data.start_date, - end_date = officer_term_data.end_date, - - nickname = officer_term_data.nickname, - favourite_course_0 = officer_term_data.favourite_course_0, - favourite_course_1 = officer_term_data.favourite_course_1, - favourite_pl_0 = officer_term_data.favourite_pl_0, - favourite_pl_1 = officer_term_data.favourite_pl_1, - biography = officer_term_data.biography, - photo_url = officer_term_data.photo_url, - ) - - @staticmethod - def update_dict(is_filled_in: bool, officer_term_data: OfficerTermData) -> dict: - # cannot update: - # - computing_id - # - start_date - # - position - return { - "is_filled_in": is_filled_in, - - "end_date": officer_term_data.end_date, - "nickname": officer_term_data.nickname, - - "favourite_course_0": officer_term_data.favourite_course_0, - "favourite_course_1": officer_term_data.favourite_course_1, - "favourite_pl_0": officer_term_data.favourite_pl_0, - "favourite_pl_1": officer_term_data.favourite_pl_1, - - "biography": officer_term_data.biography, - "photo_url": officer_term_data.photo_url, - } + favourite_pl_0 = Column(String(32), nullable=True) + favourite_pl_1 = Column(String(32), nullable=True) + biography = Column(Text, nullable=True) + photo_url = Column(Text, nullable=True) # some urls get big, best to let it be a string def serializable_dict(self) -> dict: return { "id": self.id, "computing_id": self.computing_id, - "is_filled_in": self.is_filled_in, - "position": self.position, "start_date": self.start_date.isoformat() if self.start_date is not None else None, "end_date": self.end_date.isoformat() if self.end_date is not None else None, @@ -111,70 +66,131 @@ def serializable_dict(self) -> dict: "photo_url": self.photo_url, } + # a record will only be publically visible if sufficient data has been given + def is_filled_in(self): + return ( + # photo & end_date don't have to be uploaded for the term to be "filled" + # NOTE: this definition might have to be updated + self.computing_id is not None + and self.start_date is not None + and self.nickname is not None + and self.favourite_course_0 is not None + and self.favourite_course_1 is not None + and self.favourite_pl_0 is not None + and self.favourite_pl_1 is not None + and self.biography is not None + ) + + @staticmethod + def sql_is_filled_in(query: Select) -> Select: + """Should be identical to self.is_filled_in()""" + return query.where( + and_( + OfficerTerm.computing_id is not None, + OfficerTerm.start_date is not None, + OfficerTerm.nickname is not None, + OfficerTerm.favourite_course_0 is not None, + OfficerTerm.favourite_course_1 is not None, + OfficerTerm.favourite_pl_0 is not None, + OfficerTerm.favourite_pl_1 is not None, + OfficerTerm.biography is not None, + ) + ) + + + def to_update_dict(self) -> dict: + return { + # TODO: do we want computing_id to be changeable? + # "computing_id": self.computing_id, + + "position": self.position, + "start_date": self.start_date, + "end_date": self.end_date, + + "nickname": self.nickname, + "favourite_course_0": self.favourite_course_0, + "favourite_course_1": self.favourite_course_1, + "favourite_pl_0": self.favourite_pl_0, + "favourite_pl_1": self.favourite_pl_1, + "biography": self.biography, + "photo_url": self.photo_url, + } + # this table contains information that we only need a most up-to-date version of, and # don't need to keep a history of. However, it also can't be easily updated. class OfficerInfo(Base): __tablename__ = "officer_info" - is_filled_in = Column(Boolean, nullable=False) - # TODO: we'll need to use SFU's API to get the legal name for users - legal_name = Column(String(128), nullable=False) # some people have long names, you never know - - # a null discord id would mean you don't have discord - discord_id = Column(String(DISCORD_ID_LEN)) - discord_name = Column(String(DISCORD_NAME_LEN)) - # this is their nickname in the csss server - discord_nickname = Column(String(DISCORD_NICKNAME_LEN)) - - # private info will be added last computing_id = Column( String(COMPUTING_ID_LEN), ForeignKey("user_session.computing_id"), primary_key=True, ) - phone_number = Column(String(24)) - github_username = Column(String(GITHUB_USERNAME_LEN)) + + # TODO: we'll need to use SFU's API to get the legal name for users + legal_name = Column(String(128), nullable=False) # some people have long names, you never know + phone_number = Column(String(24), nullable=True) + + # a null discord id would mean you don't have discord + # TODO: add unique constraints to these (stops users from stealing the username of someone else) + discord_id = Column(String(DISCORD_ID_LEN), nullable=True) + discord_name = Column(String(DISCORD_NAME_LEN), nullable=True) + # this is their nickname in the csss server + discord_nickname = Column(String(DISCORD_NICKNAME_LEN), nullable=True) # Technically 320 is the most common max-size for emails, but we'll use 256 instead, # since it's reasonably large (input validate this too) - google_drive_email = Column(String(256)) + # TODO: add unique constraint to this (stops users from stealing the username of someone else) + google_drive_email = Column(String(256), nullable=True) + + # TODO: add unique constraint to this (stops users from stealing the username of someone else) + github_username = Column(String(GITHUB_USERNAME_LEN), nullable=True) # NOTE: not sure if we'll need this, depending on implementation # TODO: get this data on the fly when requested, but rate limit users # to something like 1/s 100/hour # has_signed_into_bitwarden = Column(Boolean) - @staticmethod - def from_data(is_filled_in: bool, officer_info_data: OfficerInfoData) -> OfficerTerm: - return OfficerInfo( - is_filled_in = is_filled_in, - legal_name = officer_info_data.legal_name, + def serializable_dict(self) -> dict: + return { + "is_filled_in": self.is_filled_in(), - discord_id = officer_info_data.discord_id, - discord_name = officer_info_data.discord_name, - discord_nickname = officer_info_data.discord_nickname, + "legal_name": self.legal_name, + "discord_id": self.discord_id, + "discord_name": self.discord_name, + "discord_nickname": self.discord_nickname, - computing_id = officer_info_data.computing_id, - phone_number = officer_info_data.phone_number, - github_username = officer_info_data.github_username, + "computing_id": self.computing_id, + "phone_number": self.phone_number, + "github_username": self.github_username, - google_drive_email = officer_info_data.google_drive_email, + "google_drive_email": self.google_drive_email, + } + + def is_filled_in(self): + return ( + self.computing_id is not None + and self.legal_name is not None + and self.phone_number is not None + and self.discord_id is not None + and self.discord_name is not None + and self.discord_nickname is not None + and self.google_drive_email is not None + and self.github_username is not None ) - @staticmethod - def update_dict(is_filled_in: bool, officer_info_data: OfficerInfoData) -> dict: - # should only NOT contain the pkey (computing_id) + def to_update_dict(self) -> dict: return { - "is_filled_in": is_filled_in, - # TODO: if the API call to SFU's api to get legal name fails, we want to fail & not insert the entry. # for now, we should insert a default value - "legal_name": "default name" if officer_info_data.legal_name is None else officer_info_data.legal_name, - "discord_id": officer_info_data.discord_id, - "discord_name": officer_info_data.discord_name, - "discord_nickname": officer_info_data.discord_nickname, - - "phone_number": officer_info_data.phone_number, - "github_username": officer_info_data.github_username, - "google_drive_email": officer_info_data.google_drive_email, + "legal_name": "default name" if self.legal_name is None else self.legal_name, + + "discord_id": self.discord_id, + "discord_name": self.discord_name, + "discord_nickname": self.discord_nickname, + + "phone_number": self.phone_number, + "github_username": self.github_username, + "google_drive_email": self.google_drive_email, } + diff --git a/src/officers/types.py b/src/officers/types.py index 700e7fb..5b8dc07 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -1,53 +1,53 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, fields +from dataclasses import asdict, dataclass from datetime import date, datetime from constants import COMPUTING_ID_MAX from fastapi import HTTPException -import officers.tables from officers.constants import OfficerPosition +from officers.tables import OfficerInfo, OfficerTerm @dataclass -class OfficerInfoData: - computing_id: str - - legal_name: None | str = None - discord_id: None | str = None - discord_name: None | str = None - discord_nickname: None | str = None - +class OfficerInfoUpload: + # TODO: compute this using SFU's API; if unable, use a default value + legal_name: str phone_number: None | str = None + discord_name: None | str = None github_username: None | str = None google_drive_email: None | str = None def validate(self) -> None | HTTPException: - if len(self.computing_id) > COMPUTING_ID_MAX: - return HTTPException(status_code=400, detail=f"computing_id={self.computing_id} is too large") - elif self.legal_name is not None and self.legal_name == "": + if self.legal_name is not None and self.legal_name == "": return HTTPException(status_code=400, detail="legal name must not be empty") # TODO: more checks else: return None - def is_filled_in(self): - for field in fields(self): - if getattr(self, field.name) is None: - return False + def to_officer_info(self, computing_id: str, discord_id: str | None, discord_nickname: str | None) -> OfficerInfo: + return OfficerInfo( + computing_id = computing_id, + legal_name = self.legal_name, - return True + discord_id = discord_id, + discord_name = self.discord_name, + discord_nickname = discord_nickname, + phone_number = self.phone_number, + github_username = self.github_username, + google_drive_email = self.google_drive_email, + ) @dataclass -class OfficerTermData: - computing_id: str - +class OfficerTermUpload: + # only admins can change: position: str start_date: date end_date: None | date = None + # officer should change nickname: None | str = None favourite_course_0: None | str = None favourite_course_1: None | str = None @@ -59,28 +59,34 @@ class OfficerTermData: # NOTE: changing the name of this variable without changing all instances is breaking photo_url: None | str = None - def validate(self) -> None | HTTPException: - if len(self.computing_id) > COMPUTING_ID_MAX: - return HTTPException(status_code=400, detail=f"computing_id={self.computing_id} is too large") - elif self.position not in OfficerPosition.position_list(): - raise HTTPException(status_code=400, detail=f"invalid position={self.position}") - # TODO: more checks - # TODO: how to check this one? make sure date is date & not datetime? - #elif not is_iso_format(self.start_date): - # raise HTTPException(status_code=400, detail=f"start_date={self.start_date} must be a valid iso date") - else: - return None - - def is_filled_in(self): - for field in fields(self): - if field.name == "photo_url" or field.name == "end_date": - # photo & end_date don't have to be uploaded for the term to be "filled" - # NOTE: this definition might have to be updated - continue - elif getattr(self, field.name) is None: - return False - - return True + def validate(self): + """input validation""" + # NOTE: An officer can change their own data for terms that are ongoing. + if self.position not in OfficerPosition.position_list(): + raise HTTPException(status_code=400, detail=f"invalid new position={self.position}") + elif self.end_date is not None and self.start_date > self.end_date: + raise HTTPException(status_code=400, detail="end_date must be after start_date") + + + def to_officer_term(self, term_id: str, computing_id:str) -> OfficerTerm: + # TODO: many positions have a length; if the length is defined, fill it in right here + # (end date is 1st of month, 12 months after start date's month). + return OfficerTerm( + id = term_id, + computing_id = computing_id, + + position = self.position, + start_date = self.start_date, + end_date = self.end_date, + + nickname = self.nickname, + favourite_course_0 = self.favourite_course_0, + favourite_course_1 = self.favourite_course_1, + favourite_pl_0 = self.favourite_pl_0, + favourite_pl_1 = self.favourite_pl_1, + biography = self.biography, + photo_url = self.photo_url, + ) # -------------------------------------------- # @@ -126,8 +132,8 @@ def serializable_dict(self): @staticmethod def from_data( - term: officers.tables.OfficerTerm, - officer_info: officers.tables.OfficerInfo, + term: OfficerTerm, + officer_info: OfficerInfo, include_private: bool, is_active: bool, ) -> OfficerData: diff --git a/src/officers/urls.py b/src/officers/urls.py index 0cf9070..30dee1a 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -1,17 +1,22 @@ import logging +from dataclasses import dataclass from datetime import date, datetime import auth.crud import database +import github +import sqlalchemy +import utils from constants import COMPUTING_ID_MAX +from discord import discord from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse, PlainTextResponse from permission.types import OfficerPrivateInfo, WebsiteAdmin -from utils import is_iso_format import officers.crud from officers.constants import OfficerPosition -from officers.types import OfficerInfoData, OfficerTermData +from officers.tables import OfficerInfo, OfficerTerm +from officers.types import OfficerInfoUpload, OfficerTermUpload _logger = logging.getLogger(__name__) @@ -20,6 +25,7 @@ tags=["officers"], ) +# TODO: combine the following two endpoints @router.get( "/current", description="Get information about all the officers. More information is given if you're authenticated & have access to private executive data.", @@ -88,7 +94,7 @@ async def get_officer_terms( db_session: database.DBSession, computing_id: str, # the maximum number of terms to return, in chronological order - max_terms: None | int = 1, + max_terms: int | None = None, # TODO: implement the following # view_only_filled_in: bool = True, ): @@ -96,46 +102,88 @@ async def get_officer_terms( officer_terms = await officers.crud.officer_terms(db_session, computing_id, max_terms, hide_filled_in=True) return JSONResponse([term.serializable_dict() for term in officer_terms]) +@router.get( + "/info/{computing_id}", + description="Get officer info for the current user, if they've ever been an exec.", +) +async def get_officer_info( + request: Request, + db_session: database.DBSession, + computing_id: str, +): + session_id = request.cookies.get("session_id", None) + if session_id is None: + raise HTTPException(status_code=401) + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=401) + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if ( + computing_id != session_computing_id + and not await WebsiteAdmin.has_permission(db_session, session_computing_id) + ): + # the current user can only input the info for another user if they have permissions + raise HTTPException(status_code=401, detail="must have website admin permissions to get officer info about another user") + + officer_info = await officers.crud.officer_info(db_session, computing_id) + if officer_info is None: + raise HTTPException(status_code=404, detail="user has no officer info") + + return JSONResponse(officer_info.serializable_dict()) + +@dataclass +class InitialOfficerInfo: + computing_id: str + position: str + start_date: date + @router.post( - "/new_term", + "/term", description="Only the sysadmin, president, or DoA can submit this request. It will usually be the DoA. Updates the system with a new officer, and enables the user to login to the system to input their information.", ) async def new_officer_term( request: Request, db_session: database.DBSession, - computing_id: str = Body(), # request body - position: str = Body(), - # dates must have no seconds/hours - start_date: date = Body(), # noqa: B008 + officer_info_list: list[InitialOfficerInfo] = Body(), # noqa: B008 ): """ If the current computing_id is not already an officer, officer_info will be created for them. """ - if len(computing_id) > COMPUTING_ID_MAX: - raise HTTPException(status_code=400, detail=f"computing_id={computing_id} is too large") - elif position not in OfficerPosition.position_list(): - raise HTTPException(status_code=400, detail=f"invalid position={position}") - elif not is_iso_format(start_date): - raise HTTPException(status_code=400, detail=f"start_date={start_date} must be a valid iso date") + for officer_info in officer_info_list: + if len(officer_info.computing_id) > COMPUTING_ID_MAX: + raise HTTPException(status_code=400, detail=f"computing_id={officer_info.computing_id} is too large") + elif officer_info.position not in OfficerPosition.position_values(): + raise HTTPException(status_code=400, detail=f"invalid position={officer_info.position}") WebsiteAdmin.validate_request(db_session, request) - officers.crud.create_new_officer_info(db_session, OfficerInfoData( - computing_id = computing_id, - )) - success = officers.crud.create_new_officer_term(db_session, OfficerTermData( - computing_id = computing_id, - position = position, - start_date = start_date, - )) - if not success: - raise HTTPException(status_code=400, detail="Officer term already exists, no changes made") + for officer_info in officer_info_list: + # TODO: fix a bug with this stuff & test inserting & viewing mutliple executives + await officers.crud.create_new_officer_info( + db_session, + # TODO: do I need this object atm? + OfficerInfoUpload( + # TODO: use sfu api to get legal name + legal_name = "default name", + ).to_officer_info(officer_info.computing_id, None, None), + ) + # TODO: update create_new_officer_term to be the same as create_new_officer_info + success = await officers.crud.create_new_officer_term(db_session, OfficerTermUpload( + computing_id = officer_info.computing_id, + position = officer_info.position, + # TODO: remove the hours & seconds (etc.) from start_date + start_date = officer_info.start_date, + ).to_officer_term()) + if not success: + raise HTTPException(status_code=400, detail="Officer term already exists, no changes made") await db_session.commit() return PlainTextResponse("ok") -@router.post( - "/update_info", +@router.patch( + "/info/{computing_id}", description=( "After elections, officer computing ids are input into our system. " "If you have been elected as a new officer, you may authenticate with SFU CAS, " @@ -145,9 +193,10 @@ async def new_officer_term( async def update_info( request: Request, db_session: database.DBSession, - officer_info: OfficerInfoData = Body(), # noqa: B008 + computing_id: str, + officer_info_upload: OfficerInfoUpload = Body(), # noqa: B008 ): - http_exception = officer_info.validate() + http_exception = officer_info_upload.validate() if http_exception is not None: raise http_exception @@ -157,7 +206,7 @@ async def update_info( session_computing_id = await auth.crud.get_computing_id(db_session, session_id) if ( - officer_info.computing_id != session_computing_id + computing_id != session_computing_id and not await WebsiteAdmin.has_permission(db_session, session_computing_id) ): # the current user can only input the info for another user if they have permissions @@ -165,46 +214,126 @@ async def update_info( # TODO: log all important changes just to a .log file - success = await officers.crud.update_officer_info(db_session, officer_info) + old_officer_info = await officers.crud.officer_info(db_session, computing_id) + new_officer_info = officer_info_upload.to_officer_info( + computing_id=computing_id, + discord_id=None, + discord_nickname=None, + ) + + # TODO: turn this into a function + validation_failures = [] + + if not utils.is_valid_phone_number(officer_info_upload.phone_number): + validation_failures += [f"invalid phone number {officer_info_upload.phone_number}"] + new_officer_info.phone_number = old_officer_info.phone_number + + if officer_info_upload.discord_name is None or officer_info_upload.discord_name == "": + new_officer_info.discord_name = None + new_officer_info.discord_id = None + new_officer_info.discord_nickname = None + else: + discord_user_list = await discord.search_username(officer_info_upload.discord_name) + if discord_user_list == []: + validation_failures += [f"unable to find discord user with the name {officer_info_upload.discord_name}"] + new_officer_info.discord_name = old_officer_info.discord_name + new_officer_info.discord_id = old_officer_info.discord_id + new_officer_info.discord_nickname = old_officer_info.discord_nickname + elif len(discord_user_list) > 1: + validation_failures += [f"too many discord users start with {officer_info_upload.discord_name}"] + new_officer_info.discord_name = old_officer_info.discord_name + new_officer_info.discord_id = old_officer_info.discord_id + new_officer_info.discord_nickname = old_officer_info.discord_nickname + else: + discord_user = discord_user_list[0] + new_officer_info.discord_name = discord_user.username + new_officer_info.discord_id = discord_user.id + new_officer_info.discord_nickname = ( + discord_user.global_name + if discord_user.global_name is not None + else discord_user.username + ) + + # TODO: validate google-email using google module, by trying to assign the user to a permission or something + if not utils.is_valid_email(officer_info_upload.google_drive_email): + validation_failures += [f"invalid email format {officer_info_upload.google_drive_email}"] + new_officer_info.google_drive_email = old_officer_info.google_drive_email + + # validate github user is real + if await github.internals.get_user_by_username(officer_info_upload.github_username) is None: + validation_failures += [f"invalid github username {officer_info_upload.github_username}"] + new_officer_info.github_username = old_officer_info.github_username + + # TODO: invite github user + # TODO: detect if changing github username & uninvite old user + + success = await officers.crud.update_officer_info(db_session, new_officer_info) if not success: raise HTTPException(status_code=400, detail="officer_info does not exist yet, please create the officer info entry first") await db_session.commit() - return PlainTextResponse("ok") -@router.post( - "/update_term", + updated_officer_info = await officers.crud.officer_info(db_session, computing_id) + return JSONResponse({ + "updated_officer_info": updated_officer_info.serializable_dict(), + "validation_failures": validation_failures, + }) + +@router.patch( + "/term/{term_id}", ) async def update_term( request: Request, db_session: database.DBSession, - officer_term: OfficerTermData = Body(), # noqa: B008 + term_id: int, + officer_term_upload: OfficerTermUpload = Body(), # noqa: B008 ): - http_exception = officer_term.validate() - if http_exception is not None: - raise http_exception + officer_term_upload.validate() + # Refactor all of these gets & raises into small functions session_id = request.cookies.get("session_id", None) if session_id is None: raise HTTPException(status_code=401, detail="must be logged in") session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=401) + + old_officer_term = await officers.crud.officer_term(db_session, term_id) + if ( - officer_term.computing_id != session_computing_id + old_officer_term.computing_id != session_computing_id and not await WebsiteAdmin.has_permission(db_session, session_computing_id) ): # the current user can only input the info for another user if they have permissions raise HTTPException(status_code=401, detail="must have website admin permissions to update another user") - # TODO: log all important changes just to a .log file + # NOTE: Only admins can write new versions of position, start_date, and end_date. + if ( + ( + officer_term_upload.position != old_officer_term.position + or officer_term_upload.start_date != old_officer_term.start_date + or officer_term_upload.end_date != old_officer_term.end_date + ) + and not await WebsiteAdmin.has_permission(db_session, session_computing_id) + ): + raise HTTPException(status_code=401, detail="Non-admins cannot modify position, start_date, or end_date.") - success = await officers.crud.update_officer_term(db_session, officer_term) + # TODO: log all important changes just to a .log file + success = await officers.crud.update_officer_term( + db_session, + officer_term_upload.to_officer_term(term_id, old_officer_term.computing_id) + ) if not success: - raise HTTPException(status_code=400, detail="the associated officer_term does not exist yet, please create the associated officer term") + raise HTTPException(status_code=400, detail="the associated officer_term does not exist yet, please create it first") await db_session.commit() - return PlainTextResponse("ok") + new_officer_term = await officers.crud.officer_term(db_session, term_id) + return JSONResponse({ + "updated_officer_term": new_officer_term.serializable_dict(), + "validation_failures": [], # none for now, but may be important later + }) """ # TODO: test this error later @@ -212,13 +341,6 @@ async def update_term( async def raise_error(): raise ValueError("This is an error, you're welcome") -@router.get( - "/my_info", - description="Get info about whether you are still an executive or not / what your position is.", -) -async def my_info(): - return {} - @router.post( "/remove", description="Only the sysadmin, president, or DoA can submit this request. It will usually be the DoA. Removes the officer from the system entirely. BE CAREFUL WITH THIS OPTION aaaaaaaaaaaaaaaaaa.", diff --git a/src/scripts/load_officer_data_from_old_db.py b/src/scripts/load_officer_data_from_old_db.py new file mode 100644 index 0000000..21e992f --- /dev/null +++ b/src/scripts/load_officer_data_from_old_db.py @@ -0,0 +1,11 @@ +# This file loads officers from the old csss-site's database +# see: https://github.com/CSSS/csss-site + +# This file is a combination of code and manual steps to be performed. + +DB_NAME = "csss-site-db-old" +DB_PORT = 5117 + +# 1. make a local copy of the db as a docker container, as named above + +# TODO: write code to read & write between the databases diff --git a/src/utils.py b/src/utils.py index 8f2b8cb..7b7a762 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,3 +1,4 @@ +import re from datetime import datetime from officers.tables import OfficerInfo, OfficerTerm @@ -14,17 +15,23 @@ def is_iso_format(date_str: str) -> bool: except ValueError: return False - def is_active_officer(query: Select) -> Select: # TODO: assert this constraint at the SQL level, so that we don't even have to check it? - return query.where( - and_( - OfficerTerm.is_filled_in, - or_( - # executives without a specified end_date are considered active - OfficerTerm.end_date.is_(None), - # check that today's timestamp is before (smaller than) the term's end date - datetime.today() <= OfficerTerm.end_date - ) + query = query.where( + or_( + # executives without a specified end_date are considered active + OfficerTerm.end_date.is_(None), + # check that today's timestamp is before (smaller than) the term's end date + datetime.today() <= OfficerTerm.end_date ) ) + return OfficerTerm.sql_is_filled_in(query) + +def is_valid_phone_number(phone_number: str) -> bool: + return ( + len(phone_number) == 10 + and phone_number.isnumeric() + ) + +def is_valid_email(email: str): + return re.match(r"^[^@]+@[^@]+\.[a-zA-Z]*$", email) diff --git a/tests/integration/test_discord.py b/tests/integration/test_discord.py new file mode 100644 index 0000000..31f69a0 --- /dev/null +++ b/tests/integration/test_discord.py @@ -0,0 +1,9 @@ +import pytest +from discord import discord + + +# NOTE: must perform setup as described in the csss-site-backend wiki +@pytest.mark.asyncio +async def test__list_users(): + guild_members = await discord.get_guild_members() + print(guild_members) diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py new file mode 100644 index 0000000..9484af9 --- /dev/null +++ b/tests/integration/test_github.py @@ -0,0 +1,25 @@ +import github.internals +import pytest + +# NOTE: must export API key to use github api (mostly...) + +@pytest.mark.asyncio +async def test__list_users(): + member_list = await github.internals.list_members() + print(member_list) + +@pytest.mark.asyncio +async def test__get_user_by_name(): + user = await github.internals.get_user_by_username("EarthenSky") + print(user) + + user2 = await github.internals.get_user_by_username("jamieklo") + print(user2) + + user3 = await github.internals.get_user_by_username("") + assert user3 is None + print(user3) + + user4 = await github.internals.get_user_by_username("asfgkahdgOO_OPPEdkdhghk57777777777") + assert user4 is None + print(user4) diff --git a/tests/integration/test_google.py b/tests/integration/test_google.py new file mode 100644 index 0000000..c479aad --- /dev/null +++ b/tests/integration/test_google.py @@ -0,0 +1,26 @@ +from google_api import internals + + +# NOTE: must perform setup as described in the csss-site-backend wiki +def test__list_drives(): + """should not fail""" + drive_list = internals._list_shared_drives() + print(drive_list) + + drive_id = drive_list["drives"][0]["id"] + print(drive_id) + + permissions = internals.list_drive_permissions(drive_id) + print(permissions) + + # NOTE: this will raise an exception if the email address is a non-google account + """ + internals.create_drive_permission( + drive_id, + { + "type": "user", + "emailAddress": "tester_123591735013000019@gmail2.ca", + "role": "fileOrganizer", + } + ) + """