Skip to content

Commit 16fd4f1

Browse files
ImTotemclaude
andcommitted
feat: application submission + approval workflow
Phase 3 of auth-and-beginner-flow plan: - Application submission with required question validation - Duplicate application prevention (same form + member) - Application status machine: - Beginner: 납부_대기 → 납부_완료 → 승인 - Conversion: 심사_대기 → 승인 - Payment confirmation (admin) - Bulk approval with automatic member status update - Beginner form → status "Beginner" - Conversion form → status "Regular" - Application cancellation (before payment only) - Migration 005: applications + application_answers tables GraphQL: submitApplication, confirmPayment, approveApplications, cancelApplication mutations + applications, myApplications queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 03dcc3e commit 16fd4f1

File tree

11 files changed

+452
-1
lines changed

11 files changed

+452
-1
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""applications and answers
2+
3+
Revision ID: 005
4+
Revises: 004
5+
"""
6+
7+
import sqlalchemy as sa
8+
from alembic import op
9+
10+
revision = "005"
11+
down_revision = "004"
12+
13+
14+
def upgrade() -> None:
15+
op.create_table(
16+
"applications",
17+
sa.Column("id", sa.String, primary_key=True),
18+
sa.Column("form_id", sa.String, sa.ForeignKey("forms.id")),
19+
sa.Column("member_id", sa.String, sa.ForeignKey("members.id")),
20+
sa.Column("status", sa.String, nullable=False, server_default="납부_대기"),
21+
sa.Column("submitted_at", sa.String),
22+
sa.Column("approved_at", sa.String),
23+
sa.Column("approved_by", sa.String),
24+
sa.Column("updated_at", sa.String),
25+
)
26+
op.create_table(
27+
"application_answers",
28+
sa.Column("id", sa.String, primary_key=True),
29+
sa.Column("application_id", sa.String, sa.ForeignKey("applications.id", ondelete="CASCADE")),
30+
sa.Column("question_id", sa.String, sa.ForeignKey("form_questions.id")),
31+
sa.Column("value", sa.Text, nullable=False),
32+
)
33+
34+
35+
def downgrade() -> None:
36+
op.drop_table("application_answers")
37+
op.drop_table("applications")

src/bcsd_api/apply/__init__.py

Whitespace-only changes.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sqlalchemy import Connection, insert, select, update
2+
3+
from bcsd_api.repository import BaseRepository
4+
from bcsd_api.tables import application_answers, applications
5+
6+
7+
class PgApplicationRepository(BaseRepository):
8+
def __init__(self, conn: Connection):
9+
super().__init__(conn, applications)
10+
11+
def find_by_form(self, form_id: str) -> list[dict]:
12+
stmt = select(applications).where(applications.c.form_id == form_id)
13+
return [row._asdict() for row in self._conn.execute(stmt)]
14+
15+
def find_by_member(self, member_id: str) -> list[dict]:
16+
stmt = select(applications).where(applications.c.member_id == member_id)
17+
return [row._asdict() for row in self._conn.execute(stmt)]
18+
19+
def find_by_form_member(self, form_id: str, member_id: str) -> dict | None:
20+
stmt = select(applications).where(
21+
applications.c.form_id == form_id,
22+
applications.c.member_id == member_id,
23+
)
24+
row = self._conn.execute(stmt).first()
25+
if not row:
26+
return None
27+
return row._asdict()
28+
29+
def create(self, row: dict) -> None:
30+
self._conn.execute(insert(applications).values(**row))
31+
32+
def update_fields(self, app_id: str, updates: dict) -> None:
33+
self._conn.execute(
34+
update(applications).where(applications.c.id == app_id).values(**updates),
35+
)
36+
37+
38+
class PgAnswerRepository(BaseRepository):
39+
def __init__(self, conn: Connection):
40+
super().__init__(conn, application_answers)
41+
42+
def find_by_application(self, application_id: str) -> list[dict]:
43+
stmt = select(application_answers).where(
44+
application_answers.c.application_id == application_id,
45+
)
46+
return [row._asdict() for row in self._conn.execute(stmt)]
47+
48+
def create(self, row: dict) -> None:
49+
self._conn.execute(insert(application_answers).values(**row))

src/bcsd_api/apply/resolvers.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import strawberry
2+
from strawberry.types import Info
3+
4+
from bcsd_api.graphql.context import GqlContext, require_user
5+
from bcsd_api.graphql.convert import from_model
6+
7+
from . import service
8+
from .schema import AnswerRequest
9+
from .types import AnswerType, ApplicationType, SubmitInput
10+
11+
12+
def _to_app_type(app) -> ApplicationType:
13+
data = app.model_dump()
14+
data["answers"] = [AnswerType(**a) for a in data["answers"]]
15+
return ApplicationType(**data)
16+
17+
18+
def resolve_submit(info: Info[GqlContext, None], input: SubmitInput) -> ApplicationType:
19+
user = require_user(info.context)
20+
ctx = info.context
21+
answers = [AnswerRequest(question_id=a.question_id, value=a.value) for a in input.answers]
22+
app = service.submit(
23+
ctx.app_repo, ctx.ans_repo,
24+
ctx.form_repo, ctx.question_repo,
25+
input.form_id, answers, user["sub"],
26+
)
27+
return _to_app_type(app)
28+
29+
30+
def resolve_applications(
31+
info: Info[GqlContext, None], form_id: str,
32+
) -> list[ApplicationType]:
33+
require_user(info.context)
34+
ctx = info.context
35+
apps = service.list_applications(ctx.app_repo, ctx.ans_repo, form_id)
36+
return [_to_app_type(a) for a in apps]
37+
38+
39+
def resolve_application(
40+
info: Info[GqlContext, None], id: strawberry.ID,
41+
) -> ApplicationType:
42+
require_user(info.context)
43+
ctx = info.context
44+
app = service.get_application(ctx.app_repo, ctx.ans_repo, id)
45+
return _to_app_type(app)
46+
47+
48+
def resolve_my_applications(info: Info[GqlContext, None]) -> list[ApplicationType]:
49+
user = require_user(info.context)
50+
ctx = info.context
51+
apps = service.my_applications(ctx.app_repo, ctx.ans_repo, user["sub"])
52+
return [_to_app_type(a) for a in apps]
53+
54+
55+
def resolve_confirm_payment(
56+
info: Info[GqlContext, None], id: strawberry.ID,
57+
) -> ApplicationType:
58+
user = require_user(info.context)
59+
app = service.confirm_payment(info.context.app_repo, id, user["sub"])
60+
return _to_app_type(app)
61+
62+
63+
def resolve_approve(
64+
info: Info[GqlContext, None], ids: list[strawberry.ID],
65+
) -> list[ApplicationType]:
66+
user = require_user(info.context)
67+
ctx = info.context
68+
apps = service.approve(
69+
ctx.app_repo, ctx.member_repo, ctx.form_repo,
70+
ids, user["sub"],
71+
)
72+
return [_to_app_type(a) for a in apps]
73+
74+
75+
def resolve_cancel(info: Info[GqlContext, None], id: strawberry.ID) -> bool:
76+
user = require_user(info.context)
77+
service.cancel(info.context.app_repo, id, user["sub"])
78+
return True

src/bcsd_api/apply/schema.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pydantic import BaseModel
2+
3+
4+
class AnswerRequest(BaseModel):
5+
question_id: str
6+
value: str
7+
8+
9+
class SubmitRequest(BaseModel):
10+
form_id: str
11+
answers: list[AnswerRequest]
12+
13+
14+
class AnswerResponse(BaseModel):
15+
id: str
16+
question_id: str
17+
value: str
18+
19+
20+
class ApplicationResponse(BaseModel):
21+
id: str
22+
form_id: str
23+
member_id: str
24+
status: str
25+
submitted_at: str | None = None
26+
approved_at: str | None = None
27+
approved_by: str | None = None
28+
updated_at: str | None = None
29+
answers: list[AnswerResponse] = []

src/bcsd_api/apply/service.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from datetime import datetime
2+
3+
from bcsd_api.exception import BadRequest, Conflict, Forbidden, NotFound
4+
from bcsd_api.form.pg_repository import PgFormRepository, PgQuestionRepository
5+
from bcsd_api.id_gen import generate_id
6+
from bcsd_api.member.pg_repository import PgMemberRepository
7+
from bcsd_api.timezone import KST
8+
9+
from .pg_repository import PgAnswerRepository, PgApplicationRepository
10+
from .schema import AnswerRequest, AnswerResponse, ApplicationResponse
11+
12+
13+
def _now() -> str:
14+
return datetime.now(KST).isoformat()
15+
16+
17+
def submit(
18+
app_repo: PgApplicationRepository,
19+
ans_repo: PgAnswerRepository,
20+
form_repo: PgFormRepository,
21+
q_repo: PgQuestionRepository,
22+
req_form_id: str,
23+
answers: list[AnswerRequest],
24+
member_id: str,
25+
) -> ApplicationResponse:
26+
form = form_repo.find_by_id(req_form_id)
27+
if not form:
28+
raise NotFound(f"form {req_form_id} not found")
29+
_check_duplicate(app_repo, req_form_id, member_id)
30+
_validate_required(q_repo, req_form_id, answers)
31+
status = "심사_대기" if form["type"] == "conversion" else "납부_대기"
32+
return _create_app(app_repo, ans_repo, req_form_id, member_id, status, answers)
33+
34+
35+
def _check_duplicate(repo: PgApplicationRepository, form_id: str, member_id: str) -> None:
36+
if repo.find_by_form_member(form_id, member_id):
37+
raise Conflict("already applied to this form")
38+
39+
40+
def _validate_required(
41+
q_repo: PgQuestionRepository, form_id: str, answers: list[AnswerRequest],
42+
) -> None:
43+
questions = q_repo.find_by_form(form_id)
44+
required_ids = {q["id"] for q in questions if q["required"] == "true"}
45+
answered_ids = {a.question_id for a in answers}
46+
missing = required_ids - answered_ids
47+
if missing:
48+
raise BadRequest(f"missing required answers: {missing}")
49+
50+
51+
def _create_app(
52+
app_repo, ans_repo, form_id, member_id, status, answers,
53+
) -> ApplicationResponse:
54+
now = _now()
55+
app_id = generate_id("AP")
56+
row = {
57+
"id": app_id, "form_id": form_id,
58+
"member_id": member_id, "status": status,
59+
"submitted_at": now, "updated_at": now,
60+
}
61+
app_repo.create(row)
62+
saved = _save_answers(ans_repo, app_id, answers)
63+
return ApplicationResponse(**row, answers=saved)
64+
65+
66+
def _save_answers(
67+
ans_repo: PgAnswerRepository, app_id: str, answers: list[AnswerRequest],
68+
) -> list[AnswerResponse]:
69+
result = []
70+
for a in answers:
71+
row = {
72+
"id": generate_id("AA"),
73+
"application_id": app_id,
74+
"question_id": a.question_id,
75+
"value": a.value,
76+
}
77+
ans_repo.create(row)
78+
result.append(AnswerResponse(**row))
79+
return result
80+
81+
82+
def list_applications(
83+
app_repo: PgApplicationRepository, ans_repo: PgAnswerRepository, form_id: str,
84+
) -> list[ApplicationResponse]:
85+
rows = app_repo.find_by_form(form_id)
86+
return [_with_answers(ans_repo, r) for r in rows]
87+
88+
89+
def my_applications(
90+
app_repo: PgApplicationRepository, ans_repo: PgAnswerRepository, member_id: str,
91+
) -> list[ApplicationResponse]:
92+
rows = app_repo.find_by_member(member_id)
93+
return [_with_answers(ans_repo, r) for r in rows]
94+
95+
96+
def get_application(
97+
app_repo: PgApplicationRepository, ans_repo: PgAnswerRepository, app_id: str,
98+
) -> ApplicationResponse:
99+
row = app_repo.find_by_id(app_id)
100+
if not row:
101+
raise NotFound(f"application {app_id} not found")
102+
return _with_answers(ans_repo, row)
103+
104+
105+
def _with_answers(ans_repo: PgAnswerRepository, row: dict) -> ApplicationResponse:
106+
answers = [AnswerResponse(**a) for a in ans_repo.find_by_application(row["id"])]
107+
return ApplicationResponse(**row, answers=answers)
108+
109+
110+
def confirm_payment(
111+
app_repo: PgApplicationRepository, app_id: str, admin_id: str,
112+
) -> ApplicationResponse:
113+
row = app_repo.find_by_id(app_id)
114+
if not row:
115+
raise NotFound(f"application {app_id} not found")
116+
if row["status"] != "납부_대기":
117+
raise BadRequest("application is not in payment pending status")
118+
app_repo.update_fields(app_id, {"status": "납부_완료", "updated_at": _now()})
119+
return ApplicationResponse(**app_repo.find_by_id(app_id))
120+
121+
122+
def approve(
123+
app_repo: PgApplicationRepository,
124+
member_repo: PgMemberRepository,
125+
form_repo: PgFormRepository,
126+
app_ids: list[str],
127+
admin_id: str,
128+
) -> list[ApplicationResponse]:
129+
now = _now()
130+
result = []
131+
for app_id in app_ids:
132+
row = app_repo.find_by_id(app_id)
133+
if not row:
134+
continue
135+
form = form_repo.find_by_id(row["form_id"])
136+
new_status = "Regular" if form and form["type"] == "conversion" else "Beginner"
137+
app_repo.update_fields(app_id, {
138+
"status": "승인", "approved_at": now,
139+
"approved_by": admin_id, "updated_at": now,
140+
})
141+
member_repo.update_status(row["member_id"], new_status)
142+
result.append(ApplicationResponse(**app_repo.find_by_id(app_id)))
143+
return result
144+
145+
146+
def cancel(
147+
app_repo: PgApplicationRepository, app_id: str, member_id: str,
148+
) -> None:
149+
row = app_repo.find_by_id(app_id)
150+
if not row:
151+
raise NotFound(f"application {app_id} not found")
152+
if row["member_id"] != member_id:
153+
raise Forbidden("cannot cancel another member's application")
154+
if row["status"] not in ("납부_대기", "심사_대기"):
155+
raise BadRequest("cannot cancel after payment or approval")
156+
app_repo.update_fields(app_id, {"status": "취소", "updated_at": _now()})

0 commit comments

Comments
 (0)