Skip to content

Commit 0aac756

Browse files
authored
Final admin dashboard changes (#70)
* take in new officer terms in a single transaction * (debug) add daily cron file for updating officer permissions * add google module * update github module & add email function * add /auth/info endpoint * add temp file for holding server secrets; todo: look into better methods? * add /officers/my_info endpoint * fix bug adding serializable dict function * remove is_valid from database & upload subset of data through /officers/update_info endpoint * add utils, clean discord module, and start update_info endpoint validation of input * clean discord api & add checking for it in /update_info * add function for getting current github permissions * add function to update github permissions * add invite endpoint & simplify implementation * Add Google Drive API (#81) * fix bugs, add support for using discord API in user login page * get more discord users, in case of nickname collisions * discord, github, and google drive API integration tests * implement & refactor officer term * get info about any user by computing_id
1 parent 2e084f1 commit 0aac756

28 files changed

+1103
-410
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
# main
12
src/run/
23
logs/
34

5+
# google drive api
6+
google_key.json
7+
48
# Python - Byte-compiled / optimized / DLL files
59
__pycache__/
610
*.py[cod]

config/cron.sh

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# This script adds commands to the current crontab
2+
3+
# run the daily script at 1am every morning
4+
# TODO: make sure timezone is PST
5+
crontab -l | { cat; echo "0 1 * * * /home/csss-site/csss-site-backend/src/cron/daily.py"; } | crontab -
6+

config/export_secrets.sh

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# TODO: only fill out this file in production
2+
export GMAIL_USERNAME = "todo"
3+
export GMAIL_PASSWORD = "todo"
4+
export GOOGLE_DRIVE_TOKEN = "todo"
5+
export GITHUB_TOKEN = "todo"
6+
export DISCORD_TOKEN = "todo"
7+
8+
export CSSS_GUILD_ID = "todo"
9+
export SFU_API_TOKEN = "todo"
10+

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ uvicorn[standard]==0.27.1
55
sqlalchemy==2.0.27
66
asyncpg==0.29.0
77
alembic==1.13.1
8+
google-api-python-client==2.143.0
89

910
# minor
1011
pyOpenSSL==24.0.0 # for generating cryptographically secure random numbers

src/admin/email.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
import smtplib
3+
4+
# TODO: set this up
5+
GMAIL_PASSWORD = os.environ.get("GMAIL_PASSWORD")
6+
GMAIL_ADDRESS = "[email protected]"
7+
GMAIL_USERNAME = ""
8+
9+
# TODO: look into sending emails from an sfu maillist (this might be painful)
10+
def send_email(
11+
recipient_address: str,
12+
subject: str,
13+
content: str,
14+
):
15+
mail = smtplib.SMTP("smtp.gmail.com", 587)
16+
mail.ehlo()
17+
mail.starttls()
18+
mail.login(GMAIL_ADDRESS, GMAIL_PASSWORD)
19+
20+
header = f"To: {recipient_address}\nFrom: {GMAIL_USERNAME}\nSubject: {subject}"
21+
content = header + content
22+
23+
mail.sendmail(GMAIL_ADDRESS, recipient_address, content)
24+
mail.quit()
25+

src/alembic/versions/166f3772fce7_auth_officer_init.py

-2
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ def upgrade() -> None:
3939
"officer_term",
4040
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
4141
sa.Column("computing_id", sa.String(length=32), sa.ForeignKey("site_user.computing_id"), nullable=False),
42-
sa.Column("is_filled_in", sa.Boolean(), nullable=False),
4342
sa.Column("position", sa.String(length=128), nullable=False),
4443
sa.Column("start_date", sa.DateTime(), nullable=False),
4544
sa.Column("end_date", sa.DateTime(), nullable=True),
@@ -53,7 +52,6 @@ def upgrade() -> None:
5352
)
5453
op.create_table(
5554
"officer_info",
56-
sa.Column("is_filled_in", sa.Boolean(), nullable=False),
5755
sa.Column("legal_name", sa.String(length=128), nullable=False),
5856
sa.Column("discord_id", sa.String(length=18), nullable=True),
5957
sa.Column("discord_name", sa.String(length=32), nullable=True),

src/auth/crud.py

+28-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99

10-
async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str) -> None:
10+
async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str):
1111
"""
1212
Updates the past user session if one exists, so no duplicate sessions can ever occur.
1313
@@ -84,9 +84,35 @@ async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | N
8484

8585

8686
# remove all out of date user sessions
87-
async def task_clean_expired_user_sessions(db_session: AsyncSession) -> None:
87+
async def task_clean_expired_user_sessions(db_session: AsyncSession):
8888
one_day_ago = datetime.now() - timedelta(days=0.5)
8989

9090
query = sqlalchemy.delete(UserSession).where(UserSession.issue_time < one_day_ago)
9191
await db_session.execute(query)
9292
await db_session.commit()
93+
94+
95+
async def user_info(db_session: AsyncSession, session_id: str) -> None | dict:
96+
query = (
97+
sqlalchemy
98+
.select(UserSession)
99+
.where(UserSession.session_id == session_id)
100+
)
101+
user_session = await db_session.scalar(query)
102+
if user_session is None:
103+
return None
104+
105+
query = (
106+
sqlalchemy
107+
.select(SiteUser)
108+
.where(SiteUser.computing_id == user_session.computing_id)
109+
)
110+
user = await db_session.scalar(query)
111+
if user is None:
112+
return None
113+
114+
return {
115+
"computing_id": user_session.computing_id,
116+
"first_logged_in": user.first_logged_in.isoformat(),
117+
"last_logged_in": user.last_logged_in.isoformat()
118+
}

src/auth/urls.py

+23
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def login_user(
7474
return response
7575

7676

77+
# TODO: deprecate this when possible
7778
@router.get(
7879
"/check",
7980
description="Check if the current user is logged in based on session_id from cookies",
@@ -93,6 +94,28 @@ async def check_authentication(
9394
return JSONResponse(response_dict)
9495

9596

97+
@router.get(
98+
"/info",
99+
description="Get info about the current user. Only accessible by that user",
100+
)
101+
async def get_info(
102+
request: Request,
103+
db_session: database.DBSession,
104+
):
105+
"""
106+
Currently this endpoint only returns the info stored in tables in the auth module.
107+
"""
108+
session_id = request.cookies.get("session_id", None)
109+
if session_id is None:
110+
raise HTTPException(status_code=401, detail="User must be authenticated to get their info")
111+
112+
user_info = await crud.user_info(db_session, session_id)
113+
if user_info is None:
114+
raise HTTPException(status_code=401, detail="Could not find user with session_id, please log in")
115+
116+
return JSONResponse(user_info)
117+
118+
96119
@router.post(
97120
"/logout",
98121
description="Logs out the current user by invalidating the session_id cookie",

src/constants.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import os
22

33
root_ip_address = "http://localhost:8080" if os.environ.get("LOCAL") == "true" else "https://api.sfucsss.org"
4-
guild_id = "1260652618875797504" if os.environ.get("LOCAL") == "true" else "228761314644852736"
5-
github_org_name = "CSSS-Test-Organization" if os.environ.get("LOCAL") == "true" else "CSSS"
4+
GITHUB_ORG_NAME = "CSSS-Test-Organization" if os.environ.get("LOCAL") == "true" else "CSSS"
5+
6+
W3_GUILD_ID = "1260652618875797504"
7+
CSSS_GUILD_ID = "228761314644852736"
8+
ACTIVE_GUILD_ID = W3_GUILD_ID if os.environ.get("LOCAL") == "true" else CSSS_GUILD_ID
69

710
SESSION_ID_LEN = 512
811
# technically a max of 8 digits https://www.sfu.ca/computing/about/support/tips/sfu-userid.html

src/cron/daily.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""This module gets called by cron every day"""
2+
3+
import asyncio
4+
import logging
5+
6+
import github
7+
import google_api
8+
import utils
9+
from database import _db_session
10+
from officers.crud import all_officer_terms, get_user_by_username, officer_terms
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
async def update_google_permissions(db_session):
15+
# TODO: implement this function
16+
# google_permissions = google_api.all_permissions()
17+
# one_year_ago = datetime.today() - timedelta(days=365)
18+
19+
# TODO: for performance, only include officers with recent end-date (1 yr)
20+
# but measure performance first
21+
for term in await all_officer_terms(db_session):
22+
if utils.is_active(term):
23+
# TODO: if google drive permission is not active, update them
24+
pass
25+
else:
26+
# TODO: if google drive permissions are active, remove them
27+
pass
28+
29+
_logger.info("updated google permissions")
30+
31+
async def update_github_permissions(db_session):
32+
github_permissions, team_id_map = github.all_permissions()
33+
34+
for term in await all_officer_terms(db_session):
35+
new_teams = (
36+
# move all active officers to their respective teams
37+
github.officer_teams(term.position)
38+
if utils.is_active(term)
39+
# move all inactive officers to the past_officers github organization
40+
else ["past_officers"]
41+
)
42+
if term.username not in github_permissions:
43+
user = get_user_by_username(term.username)
44+
github.invite_user(
45+
user.id,
46+
[team_id_map[team] for team in new_teams],
47+
)
48+
else:
49+
github.set_user_teams(
50+
term.username,
51+
github_permissions[term.username].teams,
52+
new_teams
53+
)
54+
55+
_logger.info("updated github permissions")
56+
57+
async def update_permissions():
58+
db_session = _db_session()
59+
60+
update_google_permissions(db_session)
61+
db_session.commit()
62+
update_github_permissions(db_session)
63+
db_session.commit()
64+
65+
_logger.info("all permissions updated")
66+
67+
if __name__ == "__main__":
68+
asyncio.run(update_permissions())
69+

src/database.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,25 @@ async def session(self) -> AsyncIterator[AsyncSession]:
8484

8585

8686
if os.environ.get("DB_PORT") is not None:
87+
# using a remote (or docker) database
8788
db_port = os.environ.get("DB_PORT")
8889
SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/main"
8990
SQLALCHEMY_TEST_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/test"
9091
else:
9192
SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg:///main"
9293
SQLALCHEMY_TEST_DATABASE_URL = "postgresql+asyncpg:///test"
9394

95+
9496
# also TODO: make this nicer, using a class to hold state...
9597
# and use this in load_test_db for the test db as well?
9698
def setup_database():
9799
global sessionmanager
98100

99101
# TODO: where is sys.stdout piped to? I want all these to go to a specific logs folder
100-
if os.environ.get("LOCAL"):
101-
sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": True})
102-
else:
103-
sessionmanager = DatabaseSessionManager(SQLALCHEMY_DATABASE_URL, {"echo": True})
102+
sessionmanager = DatabaseSessionManager(
103+
SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("LOCAL") else SQLALCHEMY_DATABASE_URL,
104+
{ "echo": True },
105+
)
104106

105107
@contextlib.asynccontextmanager
106108
async def lifespan(app: FastAPI):

0 commit comments

Comments
 (0)