Skip to content

Commit 03dcc3e

Browse files
ImTotemclaude
andcommitted
feat: recruitment periods + dynamic form system
Phase 2 of auth-and-beginner-flow plan: - Recruitment periods: CRUD with active period by type - Dynamic forms: admin-editable questions (short_text, long_text, radio, checkbox), linked to recruitment periods - Form questions: create/update/delete with sort order - Migration 004: recruitment_periods, forms, form_questions tables - GraphQL: periods, form queries + createPeriod, updatePeriod, createForm, updateForm mutations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e2147a3 commit 03dcc3e

17 files changed

Lines changed: 663 additions & 1 deletion
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""recruitment periods and forms
2+
3+
Revision ID: 004
4+
Revises: 003
5+
"""
6+
7+
import sqlalchemy as sa
8+
from alembic import op
9+
10+
revision = "004"
11+
down_revision = "003"
12+
13+
14+
def upgrade() -> None:
15+
op.create_table(
16+
"recruitment_periods",
17+
sa.Column("id", sa.String, primary_key=True),
18+
sa.Column("title", sa.String, nullable=False),
19+
sa.Column("type", sa.String, nullable=False),
20+
sa.Column("start_date", sa.String, nullable=False),
21+
sa.Column("end_date", sa.String, nullable=False),
22+
sa.Column("is_active", sa.String, server_default="true"),
23+
sa.Column("created_by", sa.String, sa.ForeignKey("members.id")),
24+
sa.Column("created_at", sa.String),
25+
sa.Column("updated_at", sa.String),
26+
)
27+
op.create_table(
28+
"forms",
29+
sa.Column("id", sa.String, primary_key=True),
30+
sa.Column("title", sa.String, nullable=False),
31+
sa.Column("description", sa.Text),
32+
sa.Column("recruitment_id", sa.String, sa.ForeignKey("recruitment_periods.id")),
33+
sa.Column("type", sa.String, nullable=False),
34+
sa.Column("is_active", sa.String, server_default="true"),
35+
sa.Column("created_by", sa.String, sa.ForeignKey("members.id")),
36+
sa.Column("created_at", sa.String),
37+
sa.Column("updated_at", sa.String),
38+
)
39+
op.create_table(
40+
"form_questions",
41+
sa.Column("id", sa.String, primary_key=True),
42+
sa.Column("form_id", sa.String, sa.ForeignKey("forms.id", ondelete="CASCADE")),
43+
sa.Column("label", sa.String, nullable=False),
44+
sa.Column("type", sa.String, nullable=False),
45+
sa.Column("options", sa.Text),
46+
sa.Column("required", sa.String, server_default="true"),
47+
sa.Column("sort_order", sa.Integer, nullable=False),
48+
sa.Column("created_at", sa.String),
49+
)
50+
51+
52+
def downgrade() -> None:
53+
op.drop_table("form_questions")
54+
op.drop_table("forms")
55+
op.drop_table("recruitment_periods")

src/bcsd_api/dependencies.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from .email import ResendSender
1212
from .email.sender import EmailSender
1313
from .exception import Unauthorized
14+
from .form.pg_repository import PgFormRepository, PgQuestionRepository
1415
from .member.pg_repository import PgMemberRepository
16+
from .recruit.pg_repository import PgRecruitRepository
1517
from .setting.pg_repository import PgSettingRepository
1618
from .sheets.client import SheetsClient
1719
from .shorten.pg_repository import PgLinkRepository
@@ -72,6 +74,18 @@ def get_setting_repo(conn: Connection = Depends(get_conn)) -> PgSettingRepositor
7274
return PgSettingRepository(conn)
7375

7476

77+
def get_recruit_repo(conn: Connection = Depends(get_conn)) -> PgRecruitRepository:
78+
return PgRecruitRepository(conn)
79+
80+
81+
def get_form_repo(conn: Connection = Depends(get_conn)) -> PgFormRepository:
82+
return PgFormRepository(conn)
83+
84+
85+
def get_question_repo(conn: Connection = Depends(get_conn)) -> PgQuestionRepository:
86+
return PgQuestionRepository(conn)
87+
88+
7589
def current_user(
7690
request: Request,
7791
settings: Settings = Depends(get_settings),

src/bcsd_api/form/__init__.py

Whitespace-only changes.

src/bcsd_api/form/pg_repository.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from sqlalchemy import Connection, delete, insert, select, update
2+
3+
from bcsd_api.repository import BaseRepository
4+
from bcsd_api.tables import form_questions, forms
5+
6+
7+
class PgFormRepository(BaseRepository):
8+
def __init__(self, conn: Connection):
9+
super().__init__(conn, forms)
10+
11+
def find_by_recruitment(self, recruitment_id: str) -> list[dict]:
12+
stmt = select(forms).where(forms.c.recruitment_id == recruitment_id)
13+
return [row._asdict() for row in self._conn.execute(stmt)]
14+
15+
def create(self, row: dict) -> None:
16+
self._conn.execute(insert(forms).values(**row))
17+
18+
def update_fields(self, form_id: str, updates: dict) -> None:
19+
self._conn.execute(
20+
update(forms).where(forms.c.id == form_id).values(**updates),
21+
)
22+
23+
24+
class PgQuestionRepository(BaseRepository):
25+
def __init__(self, conn: Connection):
26+
super().__init__(conn, form_questions)
27+
28+
def find_by_form(self, form_id: str) -> list[dict]:
29+
stmt = (
30+
select(form_questions)
31+
.where(form_questions.c.form_id == form_id)
32+
.order_by(form_questions.c.sort_order)
33+
)
34+
return [row._asdict() for row in self._conn.execute(stmt)]
35+
36+
def create(self, row: dict) -> None:
37+
self._conn.execute(insert(form_questions).values(**row))
38+
39+
def delete_by_form(self, form_id: str) -> None:
40+
self._conn.execute(
41+
delete(form_questions).where(form_questions.c.form_id == form_id),
42+
)

src/bcsd_api/form/resolvers.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 CreateFormRequest, QuestionRequest, UpdateFormRequest
9+
from .types import CreateFormInput, FormType, QuestionType, UpdateFormInput
10+
11+
12+
def _to_questions(inputs: list) -> list[QuestionRequest]:
13+
return [
14+
QuestionRequest(
15+
label=q.label, type=q.type,
16+
options=q.options, required=q.required,
17+
sort_order=q.sort_order,
18+
)
19+
for q in inputs
20+
]
21+
22+
23+
def _to_form_type(f) -> FormType:
24+
data = f.model_dump()
25+
data["questions"] = [QuestionType(**q) for q in data["questions"]]
26+
return FormType(**data)
27+
28+
29+
def resolve_form(info: Info[GqlContext, None], id: strawberry.ID) -> FormType:
30+
require_user(info.context)
31+
ctx = info.context
32+
f = service.get_form(ctx.form_repo, ctx.question_repo, id)
33+
return _to_form_type(f)
34+
35+
36+
def resolve_forms(
37+
info: Info[GqlContext, None], recruitment_id: str | None = None,
38+
) -> list[FormType]:
39+
require_user(info.context)
40+
ctx = info.context
41+
forms = service.list_forms(ctx.form_repo, ctx.question_repo, recruitment_id)
42+
return [_to_form_type(f) for f in forms]
43+
44+
45+
def resolve_create_form(
46+
info: Info[GqlContext, None], input: CreateFormInput,
47+
) -> FormType:
48+
user = require_user(info.context)
49+
ctx = info.context
50+
req = CreateFormRequest(
51+
title=input.title, description=input.description,
52+
recruitment_id=input.recruitment_id, type=input.type,
53+
questions=_to_questions(input.questions),
54+
)
55+
f = service.create_form(ctx.form_repo, ctx.question_repo, req, user["sub"])
56+
return _to_form_type(f)
57+
58+
59+
def resolve_update_form(
60+
info: Info[GqlContext, None], id: strawberry.ID, input: UpdateFormInput,
61+
) -> FormType:
62+
require_user(info.context)
63+
ctx = info.context
64+
questions = None
65+
if input.questions is not None:
66+
questions = _to_questions(input.questions)
67+
req = UpdateFormRequest(
68+
title=input.title, description=input.description,
69+
is_active=input.is_active, questions=questions,
70+
)
71+
f = service.update_form(ctx.form_repo, ctx.question_repo, id, req)
72+
return _to_form_type(f)

src/bcsd_api/form/schema.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from pydantic import BaseModel
2+
3+
4+
class QuestionRequest(BaseModel):
5+
label: str
6+
type: str
7+
options: str | None = None
8+
required: str = "true"
9+
sort_order: int = 0
10+
11+
12+
class QuestionResponse(BaseModel):
13+
id: str
14+
form_id: str
15+
label: str
16+
type: str
17+
options: str | None = None
18+
required: str = "true"
19+
sort_order: int = 0
20+
created_at: str | None = None
21+
22+
23+
class CreateFormRequest(BaseModel):
24+
title: str
25+
description: str | None = None
26+
recruitment_id: str
27+
type: str
28+
questions: list[QuestionRequest] = []
29+
30+
31+
class UpdateFormRequest(BaseModel):
32+
title: str | None = None
33+
description: str | None = None
34+
is_active: str | None = None
35+
questions: list[QuestionRequest] | None = None
36+
37+
38+
class FormResponse(BaseModel):
39+
id: str
40+
title: str
41+
description: str | None = None
42+
recruitment_id: str
43+
type: str
44+
is_active: str
45+
created_by: str | None = None
46+
created_at: str | None = None
47+
updated_at: str | None = None
48+
questions: list[QuestionResponse] = []

src/bcsd_api/form/service.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from datetime import datetime
2+
3+
from bcsd_api.exception import NotFound
4+
from bcsd_api.id_gen import generate_id
5+
from bcsd_api.timezone import KST
6+
7+
from .pg_repository import PgFormRepository, PgQuestionRepository
8+
from .schema import (
9+
CreateFormRequest,
10+
FormResponse,
11+
QuestionRequest,
12+
QuestionResponse,
13+
UpdateFormRequest,
14+
)
15+
16+
17+
def _now() -> str:
18+
return datetime.now(KST).isoformat()
19+
20+
21+
def create_form(
22+
form_repo: PgFormRepository,
23+
q_repo: PgQuestionRepository,
24+
req: CreateFormRequest,
25+
creator_id: str,
26+
) -> FormResponse:
27+
now = _now()
28+
form_id = generate_id("F")
29+
row = {
30+
"id": form_id, "title": req.title,
31+
"description": req.description or "",
32+
"recruitment_id": req.recruitment_id,
33+
"type": req.type, "is_active": "true",
34+
"created_by": creator_id,
35+
"created_at": now, "updated_at": now,
36+
}
37+
form_repo.create(row)
38+
questions = _save_questions(q_repo, form_id, req.questions)
39+
return FormResponse(**row, questions=questions)
40+
41+
42+
def update_form(
43+
form_repo: PgFormRepository,
44+
q_repo: PgQuestionRepository,
45+
form_id: str,
46+
req: UpdateFormRequest,
47+
) -> FormResponse:
48+
_get_or_raise(form_repo, form_id)
49+
updates = {}
50+
if req.title is not None:
51+
updates["title"] = req.title
52+
if req.description is not None:
53+
updates["description"] = req.description
54+
if req.is_active is not None:
55+
updates["is_active"] = req.is_active
56+
updates["updated_at"] = _now()
57+
form_repo.update_fields(form_id, updates)
58+
if req.questions is not None:
59+
q_repo.delete_by_form(form_id)
60+
_save_questions(q_repo, form_id, req.questions)
61+
return get_form(form_repo, q_repo, form_id)
62+
63+
64+
def get_form(
65+
form_repo: PgFormRepository, q_repo: PgQuestionRepository, form_id: str,
66+
) -> FormResponse:
67+
row = _get_or_raise(form_repo, form_id)
68+
questions = [QuestionResponse(**q) for q in q_repo.find_by_form(form_id)]
69+
return FormResponse(**row, questions=questions)
70+
71+
72+
def list_forms(
73+
form_repo: PgFormRepository,
74+
q_repo: PgQuestionRepository,
75+
recruitment_id: str | None = None,
76+
) -> list[FormResponse]:
77+
if recruitment_id:
78+
rows = form_repo.find_by_recruitment(recruitment_id)
79+
else:
80+
rows = form_repo.find_all()
81+
result = []
82+
for row in rows:
83+
qs = [QuestionResponse(**q) for q in q_repo.find_by_form(row["id"])]
84+
result.append(FormResponse(**row, questions=qs))
85+
return result
86+
87+
88+
def _get_or_raise(form_repo: PgFormRepository, form_id: str) -> dict:
89+
row = form_repo.find_by_id(form_id)
90+
if not row:
91+
raise NotFound(f"form {form_id} not found")
92+
return row
93+
94+
95+
def _save_questions(
96+
q_repo: PgQuestionRepository, form_id: str, questions: list[QuestionRequest],
97+
) -> list[QuestionResponse]:
98+
result = []
99+
for i, q in enumerate(questions):
100+
row = {
101+
"id": generate_id("FQ"), "form_id": form_id,
102+
"label": q.label, "type": q.type,
103+
"options": q.options or "",
104+
"required": q.required,
105+
"sort_order": q.sort_order or i,
106+
"created_at": _now(),
107+
}
108+
q_repo.create(row)
109+
result.append(QuestionResponse(**row))
110+
return result

0 commit comments

Comments
 (0)