Skip to content

Commit 5184725

Browse files
committed
Merge: accept incoming changes for conflicted files
2 parents 81df237 + dc420fe commit 5184725

33 files changed

Lines changed: 2975 additions & 932 deletions

.claude/settings.local.json

Lines changed: 0 additions & 8 deletions
This file was deleted.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ __pycache__/
66
# C extensions
77
*.so
88

9+
.claude
10+
911
# Distribution / packaging
1012
.Python
1113
build/

.tmp_enum_check.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ dependencies = [
4747
"typing-inspection==0.4.2",
4848
"urllib3==2.6.3",
4949
"uvicorn==0.42.0",
50+
"pytest-cov>=7.1.0",
5051
]
5152

5253
[tool.pytest.ini_options]
5354
pythonpath = ["."]
55+
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=50"
5456
filterwarnings = [
5557
"ignore:datetime.datetime.utcnow\\(\\) is deprecated:DeprecationWarning",
5658
"ignore:The `dict` method is deprecated:DeprecationWarning",

src/api/app.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.middleware.cors import CORSMiddleware
77

88
from src import config
9-
from src.api.dependencies import get_account_from_bearer
9+
from src.api.dependencies import get_active_account
1010
from src.database.session import get_session
1111
from src.database.account.models import Account, Notification
1212

@@ -20,10 +20,10 @@
2020
from src.api.roles.client.client import router as client_router
2121
from src.api.roles.shared.fitness import router as shared_fitness_router
2222
from src.api.roles.shared.account import router as shared_account_router
23-
from src.api.roles.shared.chat import router as shared_chat_router
24-
from src.api.roles.shared.client_coach_relationship import router as shared_client_coach_relationship_router
25-
from src.api.roles.shared.notifications import router as shared_notifications_router
26-
from src.api.roles.client.fitness import router as client_fitness_router
23+
from src.api.roles.shared.chat import router as shared_chat_router
24+
from src.api.roles.shared.client_coach_relationship import router as shared_client_coach_relationship_router
25+
from src.api.roles.shared.notifications import router as shared_notifications_router
26+
from src.api.roles.client.fitness import router as client_fitness_router
2727
from src.api.roles.client.telemetry import router as client_telemetry_router
2828
from src.api.roles.coach.fitness import router as coach_fitness_router
2929
from src.api.roles.admin.admin import router as admin_router
@@ -43,16 +43,16 @@
4343
app.include_router(client_router)
4444
app.include_router(shared_fitness_router)
4545
app.include_router(shared_account_router)
46-
app.include_router(shared_chat_router)
47-
app.include_router(shared_client_coach_relationship_router)
48-
app.include_router(shared_notifications_router)
49-
app.include_router(client_fitness_router)
46+
app.include_router(shared_chat_router)
47+
app.include_router(shared_client_coach_relationship_router)
48+
app.include_router(shared_notifications_router)
49+
app.include_router(client_fitness_router)
5050
app.include_router(client_telemetry_router)
5151
app.include_router(coach_fitness_router)
5252
app.include_router(admin_router)
5353

5454
@app.get("/me") # get_current_account assumes they pass a valid jwt as bearer
55-
def read_current_account(user = Depends(get_account_from_bearer)):
55+
def read_current_account(user = Depends(get_active_account)):
5656
return serialize_account(user)
5757

5858
@app.get("/")

src/api/auth/auth.py

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
import os
66
import secrets
77
import requests
8+
from jose import JWTError, jwt
9+
from datetime import datetime, timedelta
10+
from src import config
811

912
from src.api.auth.domain import AuthTokenResponse, LoginRequest, SignupRequest
1013
from src.api.auth.services import create_account
1114
from src.api.dependencies import authenticate_user, create_jwt_token
12-
from src.api.dependencies import get_account_from_bearer
15+
from src.api.dependencies import get_account_even_if_inactive
1316

1417
from src.database.account.models import Account
1518
from src.database.session import get_session
@@ -23,10 +26,13 @@ def issue_token(user: Account) -> AuthTokenResponse:
2326
return AuthTokenResponse(access_token=token)
2427

2528
@router.get("/roles")
26-
def read_current_roles(user = Depends(get_account_from_bearer), db = Depends(get_session)):
29+
def read_current_roles(user: Account = Depends(get_account_even_if_inactive), db = Depends(get_session)):
2730
"""Returns a list of the current user's roles. Mainly for frontend role-based rendering."""
2831
roles: list[str] = []
2932

33+
if not user.is_active:
34+
return ["deactivated"]
35+
3036
if user.client_id is not None:
3137
roles.append("client")
3238

@@ -97,45 +103,62 @@ def token(
97103
return issue_token(user)
98104

99105

100-
@router.get("/google")
101-
def google_oauth(request: Request, code: str | None = None, state: str | None = None, db: Session = Depends(get_session)):
102-
"""
103-
OAuth2 Authorization Code flow for Google.
106+
from fastapi.responses import JSONResponse
104107

105-
- Calling GET /auth/google with no query params redirects to Google's consent screen.
108+
@router.get("/google/url")
109+
def google_oauth_url():
110+
"""
111+
Returns the Google OAuth login URL and sets the OAuth state cookie.
106112
"""
107-
108113
client_id = os.getenv("GCP_CLIENT_ID")
109114
client_secret = os.getenv("GCP_CLIENT_SECRET")
110115
if not client_id or not client_secret:
111116
raise HTTPException(status_code=500, detail="GCP_CLIENT_ID and GCP_CLIENT_SECRET must be configured")
112117

113-
redirect_uri = "https://api.till-failure.us/auth/google"
118+
redirect_uri = os.getenv("OAUTH_REDIRECT_URI", "https://api.till-failure.us/auth/google")
114119

115-
if code is None:
116-
oauth_state = secrets.token_urlsafe(16)
117-
params = {
118-
"client_id": client_id,
119-
"response_type": "code",
120-
"scope": "openid email profile",
121-
"redirect_uri": redirect_uri,
122-
"state": oauth_state,
123-
"access_type": "offline",
124-
"prompt": "consent",
125-
}
126-
url = "https://accounts.google.com/o/oauth2/v2/auth"
127-
qs = "?" + "&".join(f"{k}={requests.utils.requote_uri(str(v))}" for k, v in params.items())
128-
resp = RedirectResponse(url + qs)
129-
130-
# store state in a cookie to verify on callback
131-
resp.set_cookie("oauth_state", oauth_state, httponly=True, secure=True, samesite="lax")
132-
return resp
120+
oauth_state = secrets.token_urlsafe(16)
121+
# create signed state to make flow completely stateless
122+
to_encode = {"state": oauth_state, "exp": datetime.utcnow() + timedelta(minutes=15)}
123+
signed_state = jwt.encode(to_encode, config.JWT_SECRET, algorithm=config.ALGORITHM)
124+
125+
params = {
126+
"client_id": client_id,
127+
"response_type": "code",
128+
"scope": "openid email profile",
129+
"redirect_uri": redirect_uri,
130+
"state": signed_state,
131+
"access_type": "offline",
132+
"prompt": "consent",
133+
}
134+
url = "https://accounts.google.com/o/oauth2/v2/auth"
135+
qs = "?" + "&".join(f"{k}={requests.utils.requote_uri(str(v))}" for k, v in params.items())
136+
137+
return JSONResponse({"url": url + qs})
133138

134-
# Verify state from callback
135-
cookie_state = request.cookies.get("oauth_state")
136-
if cookie_state is None or state is None or cookie_state != state:
139+
@router.get("/google")
140+
def google_oauth_callback(request: Request, code: str | None = None, state: str | None = None, db: Session = Depends(get_session)):
141+
"""
142+
OAuth2 Authorization Code flow callback for Google.
143+
"""
144+
if code is None:
145+
raise HTTPException(status_code=400, detail="Missing code parameter")
146+
if state is None:
147+
raise HTTPException(status_code=400, detail="Missing state parameter")
148+
149+
# Verify stateless signed state
150+
try:
151+
jwt.decode(state, config.JWT_SECRET, algorithms=[config.ALGORITHM])
152+
except JWTError:
137153
raise HTTPException(status_code=400, detail="Invalid OAuth state")
138154

155+
# Re-declare credentials needed for token exchange
156+
client_id = os.getenv("GCP_CLIENT_ID")
157+
client_secret = os.getenv("GCP_CLIENT_SECRET")
158+
if not client_id or not client_secret:
159+
raise HTTPException(status_code=500, detail="GCP_CLIENT_ID and GCP_CLIENT_SECRET must be configured")
160+
redirect_uri = os.getenv("OAUTH_REDIRECT_URI", "https://api.till-failure.us/auth/google")
161+
139162
# Exchange code for tokens
140163
token_url = "https://oauth2.googleapis.com/token"
141164
data = {
@@ -192,22 +215,24 @@ def google_oauth(request: Request, code: str | None = None, state: str | None =
192215
token_resp = issue_token(user)
193216
jwt_token = token_resp.access_token
194217

195-
redirect_to = "https://till-failure.us/onboarding"
218+
frontend_url = os.getenv("FRONTEND_URL", "https://till-failure.us")
219+
redirect_to = f"{frontend_url}/onboarding"
196220
resp = RedirectResponse(redirect_to)
197221

198222
cookie_value = requests.utils.requote_uri(jwt_token)
199223
cookie_args = {
200224
"httponly": False,
201225
"secure": True,
202226
"samesite": "none",
203-
"domain": ".till-failure.us",
204227
"max_age": 60 * 60 * 24 * 30, # 30 days
205228
}
229+
230+
cookie_domain = os.getenv("COOKIE_DOMAIN", ".till-failure.us")
231+
232+
if cookie_domain:
233+
cookie_args["domain"] = cookie_domain
206234

207235
# Set the readable cookie `jwt` so frontend JS can access it if needed.
208236
resp.set_cookie("jwt", cookie_value, **cookie_args)
209237

210-
# cleanup
211-
resp.delete_cookie("oauth_state")
212-
213238
return resp

src/api/dependencies.py

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,44 +58,18 @@ def get_account_from_bearer(
5858

5959
if user is None:
6060
raise credentials_exception
61-
62-
if not user.is_active:
63-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account deactivated")
6461

6562
return user
6663

67-
def get_active_account(account: Account = Depends(get_account_from_bearer)) -> Account:
68-
if not account.is_active:
69-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account deactivated")
70-
return account
64+
def get_active_account(account: Account = Depends(get_account_from_bearer)) -> Account:
65+
if not account.is_active:
66+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account deactivated")
67+
return account
7168

7269
def get_account_even_if_inactive(
73-
token: str = Depends(oauth2_scheme),
74-
db: Session = Depends(get_session),
70+
acc: Account = Depends(get_account_from_bearer),
7571
) -> Account:
76-
credentials_exception = HTTPException(
77-
status_code=status.HTTP_401_UNAUTHORIZED,
78-
detail="Could not validate credentials",
79-
headers={"WWW-Authenticate": "Bearer"},
80-
)
81-
82-
try:
83-
payload = jwt.decode(token, config.JWT_SECRET, algorithms=[config.ALGORITHM])
84-
account_id_str = payload.get("sub")
85-
86-
if account_id_str is None:
87-
raise credentials_exception
88-
89-
account_id = int(account_id_str)
90-
except (JWTError, ValueError):
91-
raise credentials_exception
92-
93-
user = db.get(Account, account_id)
94-
95-
if user is None:
96-
raise credentials_exception
97-
98-
return user
72+
return acc
9973

10074

10175
"""

src/api/roles/admin/admin.py

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,73 @@
1-
from fastapi import APIRouter, Depends, HTTPException
2-
from sqlmodel import Session, select
3-
from sqlalchemy import delete
4-
from datetime import datetime
5-
from typing import List
1+
from fastapi import APIRouter, Depends, HTTPException, Query
2+
from sqlmodel import Session, select
3+
from sqlalchemy import delete
4+
from datetime import datetime
5+
from typing import List, Literal
66

77
from src.database.session import get_session
88
from src.database.account.models import Account
99
from src.database.coach.models import Coach, Experience, Certifications, CoachExperience, CoachCertifications
1010
from src.database.admin.models import Admin
11-
from src.api.dependencies import get_admin_account, PaginationParams
12-
from src.database.role_management.models import CoachRequest, RolePromotionResolution, Roles
13-
from src.database.payment.models import Invoice
14-
from src.api.roles.admin.domain import ResolveCoachRequestInput, PotentialCoachItem, AdminTransactionsResponse
11+
from src.api.dependencies import get_admin_account, PaginationParams
12+
from src.database.role_management.models import CoachRequest, RolePromotionResolution, Roles
13+
from src.database.payment.models import Invoice
14+
from src.api.roles.admin.domain import AdminAccountItem, ResolveCoachRequestInput, PotentialCoachItem, AdminTransactionsResponse
1515

1616
from sqlmodel import func
1717

18-
router = APIRouter(prefix="/roles/admin", tags=["admin"])
19-
20-
@router.get("/total_transactions", response_model=AdminTransactionsResponse)
21-
def get_total_transactions(db = Depends(get_session), acc: Account = Depends(get_admin_account)):
18+
router = APIRouter(prefix="/roles/admin", tags=["admin"])
19+
20+
def admin_account_role(account: Account) -> str:
21+
if account.admin_id is not None:
22+
return "admin"
23+
if account.coach_id is not None:
24+
return "coach"
25+
return "client"
26+
27+
def admin_account_roles(account: Account) -> List[str]:
28+
roles: List[str] = []
29+
if account.client_id is not None:
30+
roles.append("client")
31+
if account.coach_id is not None:
32+
roles.append("coach")
33+
if account.admin_id is not None:
34+
roles.append("admin")
35+
return roles or ["client"]
36+
37+
def serialize_admin_account(account: Account) -> AdminAccountItem:
38+
return AdminAccountItem(
39+
id=account.id,
40+
name=account.name,
41+
email=str(account.email),
42+
role=admin_account_role(account),
43+
roles=admin_account_roles(account),
44+
status="active" if account.is_active else "deactivated",
45+
is_active=account.is_active,
46+
created_at=account.created_at,
47+
last_active=None,
48+
)
49+
50+
@router.get("/accounts", response_model=List[AdminAccountItem])
51+
def query_accounts(
52+
pagination: PaginationParams = Depends(PaginationParams),
53+
sort_by: Literal["name", "email"] = Query("name", description="Account field to sort by"),
54+
sort_dir: Literal["asc", "desc"] = Query("asc", description="Sort direction"),
55+
db: Session = Depends(get_session),
56+
acc: Account = Depends(get_admin_account),
57+
):
58+
sort_column = Account.name if sort_by == "name" else Account.email
59+
sort_expression = sort_column.desc() if sort_dir == "desc" else sort_column.asc()
60+
accounts = db.exec(
61+
select(Account)
62+
.order_by(sort_expression, Account.id.asc())
63+
.offset(pagination.skip)
64+
.limit(pagination.limit)
65+
).all()
66+
67+
return [serialize_admin_account(account) for account in accounts if account.id is not None]
68+
69+
@router.get("/total_transactions", response_model=AdminTransactionsResponse)
70+
def get_total_transactions(db = Depends(get_session), acc: Account = Depends(get_admin_account)):
2271
"""
2372
Get all money transacted on the website (sum of all paid invoice amounts minus their outstanding balance)
2473
"""
@@ -130,4 +179,4 @@ def resolve_coach_request(
130179

131180
db.commit()
132181

133-
return {"message": "Coach request resolved successfully", "resolution_id": resolution.id}
182+
return {"message": "Coach request resolved successfully", "resolution_id": resolution.id}

0 commit comments

Comments
 (0)