Skip to content

Commit e2147a3

Browse files
ImTotemclaude
andcommitted
feat(auth): PG migration + grade field + routing + settings
Phase 1 of auth-and-beginner-flow plan: - Migrate auth from SheetsClient to PgMemberRepository - login(): sheets.find_row → repo.find_by_email - register(): sheets.append_row → repo.create - Add grade field to RegisterRequest and members table - RegisterResponse returns routing ("beginner" | "conversion") based on admin-configurable grade_threshold setting - New setting package: key-value admin settings (app_settings table) - Migration 003: grade column + app_settings table - PgMemberRepository: add find_by_email, create, update_status Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ead65be commit e2147a3

14 files changed

Lines changed: 207 additions & 48 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add grade column and app_settings table
2+
3+
Revision ID: 003
4+
Revises: 002
5+
"""
6+
7+
import sqlalchemy as sa
8+
from alembic import op
9+
10+
revision = "003"
11+
down_revision = "002"
12+
13+
14+
def upgrade() -> None:
15+
op.add_column("members", sa.Column("grade", sa.String))
16+
op.create_table(
17+
"app_settings",
18+
sa.Column("key", sa.String, primary_key=True),
19+
sa.Column("value", sa.String, nullable=False),
20+
sa.Column("updated_at", sa.String),
21+
sa.Column("updated_by", sa.String),
22+
)
23+
op.execute(
24+
"INSERT INTO app_settings (key, value) VALUES ('grade_threshold', '3')"
25+
)
26+
27+
28+
def downgrade() -> None:
29+
op.drop_table("app_settings")
30+
op.drop_column("members", "grade")

src/bcsd_api/auth/router.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from fastapi import APIRouter, Depends, Response
2+
from sqlalchemy import Connection
23

34
from bcsd_api.config import Settings
45
from bcsd_api.dependencies import (
5-
current_user, get_email_sender, get_settings, get_sheets,
6+
current_user, get_conn, get_email_sender, get_member_repo, get_settings,
67
)
78
from bcsd_api.email.sender import EmailSender
8-
from bcsd_api.sheets.client import SheetsClient
9+
from bcsd_api.member.pg_repository import PgMemberRepository
910

1011
from . import service
1112
from .schema import (
@@ -16,6 +17,7 @@
1617
MeResponse,
1718
MessageResponse,
1819
RegisterRequest,
20+
RegisterResponse,
1921
VerifyEmailRequest,
2022
)
2123

@@ -39,9 +41,9 @@ def post_login(
3941
body: LoginRequest,
4042
response: Response,
4143
settings: Settings = Depends(get_settings),
42-
sheets: SheetsClient = Depends(get_sheets),
44+
repo: PgMemberRepository = Depends(get_member_repo),
4345
) -> LoginResponse:
44-
token = service.login(body.google_token, settings, sheets)
46+
token = service.login(body.google_token, settings, repo)
4547
_set_cookie(response, token, settings)
4648
return LoginResponse(access_token=token)
4749

@@ -61,20 +63,22 @@ def post_confirm(body: ConfirmEmailRequest) -> ConfirmEmailResponse:
6163
return ConfirmEmailResponse(verified=result)
6264

6365

64-
@router.post("/register", response_model=LoginResponse)
66+
@router.post("/register", response_model=RegisterResponse)
6567
def post_register(
6668
body: RegisterRequest,
6769
response: Response,
6870
settings: Settings = Depends(get_settings),
69-
sheets: SheetsClient = Depends(get_sheets),
70-
) -> LoginResponse:
71-
token = service.register(
71+
repo: PgMemberRepository = Depends(get_member_repo),
72+
conn: Connection = Depends(get_conn),
73+
) -> RegisterResponse:
74+
token, routing = service.register(
7275
body.google_token, body.name, body.department,
7376
body.student_id, body.school_email,
74-
body.phone, body.track, settings, sheets,
77+
body.phone, body.track, body.grade,
78+
settings, repo, conn,
7579
)
7680
_set_cookie(response, token, settings)
77-
return LoginResponse(access_token=token)
81+
return RegisterResponse(access_token=token, routing=routing)
7882

7983

8084
@router.get("/me", response_model=MeResponse)

src/bcsd_api/auth/schema.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ class RegisterRequest(BaseModel):
3131
school_email: str
3232
phone: str
3333
track: str
34+
grade: str
35+
36+
37+
class RegisterResponse(BaseModel):
38+
access_token: str
39+
token_type: str = "bearer"
40+
routing: str
3441

3542

3643
class MeResponse(BaseModel):

src/bcsd_api/auth/service.py

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
from datetime import datetime
22

3+
from sqlalchemy import Connection, select
4+
35
from bcsd_api.config import Settings
46
from bcsd_api.email.sender import EmailSender
57
from bcsd_api.exception import Conflict, Unauthorized
68
from bcsd_api.id_gen import generate_id
7-
from bcsd_api.sheets.client import SheetsClient
9+
from bcsd_api.member.pg_repository import PgMemberRepository
10+
from bcsd_api.tables import app_settings
11+
from bcsd_api.timezone import KST
812

913
from . import google as google_auth
1014
from . import token as jwt_token
1115
from . import verify
12-
from bcsd_api.timezone import KST
1316

1417

15-
def login(google_token: str, settings: Settings, sheets: SheetsClient) -> str:
18+
def login(
19+
google_token: str, settings: Settings, repo: PgMemberRepository,
20+
) -> str:
1621
profile = google_auth.verify_token(google_token, settings.google_client_id)
17-
member = sheets.find_row("members", "email", profile["email"])
22+
member = repo.find_by_email(profile["email"])
1823
if not member:
1924
raise Unauthorized("member not found, registration required")
2025
payload = {"sub": member["id"], "email": profile["email"]}
@@ -30,26 +35,22 @@ def confirm_verify(email: str, code: str) -> bool:
3035

3136

3237
def register(
33-
google_token: str,
34-
name: str,
35-
department: str,
36-
student_id: str,
37-
school_email: str,
38-
phone: str,
39-
track: str,
40-
settings: Settings,
41-
sheets: SheetsClient,
42-
) -> str:
38+
google_token: str, name: str, department: str,
39+
student_id: str, school_email: str, phone: str,
40+
track: str, grade: str,
41+
settings: Settings, repo: PgMemberRepository, conn: Connection,
42+
) -> tuple[str, str]:
4343
profile = google_auth.verify_token(google_token, settings.google_client_id)
44-
_check_duplicate(profile["email"], sheets)
44+
_check_duplicate(profile["email"], repo)
4545
member_id = generate_id("M")
4646
row = _build_row(
4747
member_id, name, profile["email"],
48-
department, student_id, school_email, phone, track,
48+
department, student_id, school_email, phone, track, grade,
4949
)
50-
sheets.append_row("members", row)
51-
payload = {"sub": member_id, "email": profile["email"]}
52-
return _issue_jwt(payload, settings)
50+
repo.create(row)
51+
routing = _resolve_routing(grade, conn)
52+
token = _issue_jwt({"sub": member_id, "email": profile["email"]}, settings)
53+
return token, routing
5354

5455

5556
def _issue_jwt(payload: dict, settings: Settings) -> str:
@@ -59,8 +60,8 @@ def _issue_jwt(payload: dict, settings: Settings) -> str:
5960
)
6061

6162

62-
def _check_duplicate(email: str, sheets: SheetsClient) -> None:
63-
if sheets.find_row("members", "email", email):
63+
def _check_duplicate(email: str, repo: PgMemberRepository) -> None:
64+
if repo.find_by_email(email):
6465
raise Conflict("member already registered")
6566

6667

@@ -71,24 +72,34 @@ def _now_kst() -> str:
7172
def _build_row(
7273
member_id: str, name: str, email: str,
7374
department: str, student_id: str,
74-
school_email: str, phone: str, track: str,
75+
school_email: str, phone: str, track: str, grade: str,
7576
) -> dict:
7677
now = _now_kst()
77-
base = _base_fields(member_id, name, email)
78-
extra = {
78+
return {
79+
"id": member_id, "name": name, "email": email,
7980
"department": department, "student_id": student_id,
80-
"school_email": school_email, "phone": phone, "track": track,
81+
"school_email": school_email, "phone": phone,
82+
"track": track, "grade": grade,
83+
"status": "Beginner", "team": "", "payment_status": "미납",
84+
"join_date": now, "last_updated": now,
8185
}
82-
timestamps = {"join_date": now, "last_updated": now}
83-
return {**base, **extra, **timestamps}
8486

8587

86-
def _base_fields(member_id: str, name: str, email: str) -> dict:
87-
return {
88-
"id": member_id,
89-
"name": name,
90-
"email": email,
91-
"status": "Beginner",
92-
"team": "",
93-
"payment_status": "미납",
94-
}
88+
_GRADE_MAP = {"1학년": 1, "2학년": 2, "3학년": 3, "4학년": 4, "대학원": 5}
89+
90+
91+
def _resolve_routing(grade: str, conn: Connection) -> str:
92+
threshold = _grade_threshold(conn)
93+
level = _GRADE_MAP.get(grade, 1)
94+
if level >= threshold:
95+
return "conversion"
96+
return "beginner"
97+
98+
99+
def _grade_threshold(conn: Connection) -> int:
100+
row = conn.execute(
101+
select(app_settings.c.value).where(app_settings.c.key == "grade_threshold"),
102+
).first()
103+
if not row:
104+
return 3
105+
return int(row[0])

src/bcsd_api/dependencies.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .email.sender import EmailSender
1313
from .exception import Unauthorized
1414
from .member.pg_repository import PgMemberRepository
15+
from .setting.pg_repository import PgSettingRepository
1516
from .sheets.client import SheetsClient
1617
from .shorten.pg_repository import PgLinkRepository
1718

@@ -67,6 +68,10 @@ def get_link_repo(conn: Connection = Depends(get_conn)) -> PgLinkRepository:
6768
return PgLinkRepository(conn)
6869

6970

71+
def get_setting_repo(conn: Connection = Depends(get_conn)) -> PgSettingRepository:
72+
return PgSettingRepository(conn)
73+
74+
7075
def current_user(
7176
request: Request,
7277
settings: Settings = Depends(get_settings),

src/bcsd_api/graphql/context.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88
get_conn,
99
get_link_repo,
1010
get_member_repo,
11+
get_setting_repo,
1112
get_settings,
1213
)
1314
from bcsd_api.exception import Unauthorized
1415
from bcsd_api.member.pg_repository import PgMemberRepository
16+
from bcsd_api.setting.pg_repository import PgSettingRepository
1517
from bcsd_api.shorten.pg_repository import PgLinkRepository
1618

1719

1820
class GqlContext(BaseContext):
19-
def __init__(self, conn, member_repo, link_repo, user):
21+
def __init__(self, conn, member_repo, link_repo, setting_repo, user):
2022
self.conn = conn
2123
self.member_repo = member_repo
2224
self.link_repo = link_repo
25+
self.setting_repo = setting_repo
2326
self.user = user
2427

2528

@@ -42,11 +45,13 @@ async def context_getter(
4245
conn: Connection = Depends(get_conn),
4346
member_repo: PgMemberRepository = Depends(get_member_repo),
4447
link_repo: PgLinkRepository = Depends(get_link_repo),
48+
setting_repo: PgSettingRepository = Depends(get_setting_repo),
4549
) -> GqlContext:
4650
user = _try_auth(request, settings)
4751
return GqlContext(
4852
conn=conn,
4953
member_repo=member_repo,
5054
link_repo=link_repo,
55+
setting_repo=setting_repo,
5156
user=user,
5257
)

src/bcsd_api/graphql/schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from bcsd_api.exception.base import AppException
99
from bcsd_api.member import resolvers as member_resolvers
10+
from bcsd_api.setting import resolvers as setting_resolvers
1011
from bcsd_api.member.types import (
1112
FiltersType,
1213
MeType,
@@ -44,6 +45,8 @@ def health(self) -> str:
4445
link: LinkDetailType = strawberry.field(resolver=link_resolvers.resolve_link)
4546
link_filters: LinkFiltersType = strawberry.field(resolver=link_resolvers.resolve_link_filters)
4647

48+
setting: str | None = strawberry.field(resolver=setting_resolvers.resolve_setting)
49+
4750

4851
@strawberry.type
4952
class Mutation:
@@ -52,6 +55,8 @@ class Mutation:
5255
toggle_link: LinkType = strawberry.mutation(resolver=link_resolvers.resolve_toggle)
5356
delete_link: bool = strawberry.mutation(resolver=link_resolvers.resolve_delete)
5457

58+
set_setting: bool = strawberry.mutation(resolver=setting_resolvers.resolve_set_setting)
59+
5560

5661
logger = logging.getLogger("strawberry.execution")
5762

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from sqlalchemy import Connection
1+
from sqlalchemy import Connection, insert, select, update
22

33
from bcsd_api.repository import BaseRepository
44
from bcsd_api.tables import members
@@ -7,3 +7,19 @@
77
class PgMemberRepository(BaseRepository):
88
def __init__(self, conn: Connection):
99
super().__init__(conn, members)
10+
11+
def find_by_email(self, email: str) -> dict | None:
12+
row = self._conn.execute(
13+
select(members).where(members.c.email == email),
14+
).first()
15+
if not row:
16+
return None
17+
return row._asdict()
18+
19+
def create(self, row: dict) -> None:
20+
self._conn.execute(insert(members).values(**row))
21+
22+
def update_status(self, member_id: str, status: str) -> None:
23+
self._conn.execute(
24+
update(members).where(members.c.id == member_id).values(status=status),
25+
)

src/bcsd_api/setting/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from sqlalchemy import Connection, select
2+
3+
from bcsd_api.tables import app_settings
4+
5+
6+
class PgSettingRepository:
7+
def __init__(self, conn: Connection):
8+
self._conn = conn
9+
10+
def get(self, key: str) -> str | None:
11+
row = self._conn.execute(
12+
select(app_settings.c.value).where(app_settings.c.key == key),
13+
).first()
14+
if not row:
15+
return None
16+
return row[0]
17+
18+
def upsert(self, key: str, value: str, updated_by: str) -> None:
19+
from datetime import datetime
20+
21+
from bcsd_api.timezone import KST
22+
23+
now = datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")
24+
existing = self.get(key)
25+
if existing is not None:
26+
self._conn.execute(
27+
app_settings.update().where(app_settings.c.key == key).values(
28+
value=value, updated_at=now, updated_by=updated_by,
29+
),
30+
)
31+
return
32+
self._conn.execute(
33+
app_settings.insert().values(
34+
key=key, value=value, updated_at=now, updated_by=updated_by,
35+
),
36+
)

0 commit comments

Comments
 (0)