Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,7 @@ poetry.toml
.ruff_cache/

# LSP config files
pyrightconfig.json
pyrightconfig.json

# Local dev docs / manual test artifacts for temp-member import (not part of the codebase)
imsi_member_docs/
56 changes: 56 additions & 0 deletions alembic/versions/a1b2c3d4e5f6_add_temp_member_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""add temporary member support

Make users.email nullable (temporary members have no OAuth email) and add an
is_temporary flag plus supporting indexes for roster import.

Revision ID: a1b2c3d4e5f6
Revises: c951e903b294
Create Date: 2026-06-28 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

revision: str = "a1b2c3d4e5f6"
# Re-parented onto main's approval-requests migration after merging origin/main,
# so the two feature branches form a single linear history (no alembic fork).
down_revision: Union[str, None] = "c951e903b294"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=True,
)
op.add_column(
"users",
sa.Column(
"is_temporary",
sa.Boolean(),
nullable=False,
server_default="0",
),
)
op.create_index("idx_users_is_temporary", "users", ["is_temporary"])
op.create_index("idx_users_student_id", "users", ["student_id"])


def downgrade() -> None:
op.drop_index("idx_users_student_id", table_name="users")
op.drop_index("idx_users_is_temporary", table_name="users")
op.drop_column("users", "is_temporary")
# Reverting email to NOT NULL requires no rows with NULL email.
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=False,
)
47 changes: 47 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,53 @@ def __init__(
super().__init__("INVALID_QUALIFICATION", message, 400)


class TemporaryMemberApprovalError(AppError):
"""Cannot approve a temporary (roster-imported) member"""

def __init__(
self,
message: str = (
"Cannot approve a temporary member. They must sign up "
"(OAuth) and be linked to this record first."
),
):
super().__init__("TEMPORARY_MEMBER_CANNOT_BE_APPROVED", message, 400)


class TemporaryMemberProjectError(AppError):
"""Cannot add a temporary (roster-imported) member to a project"""

def __init__(
self,
message: str = (
"Cannot add a temporary member to a project. They must sign up "
"(OAuth) and be linked to this record first."
),
):
super().__init__("TEMPORARY_MEMBER_CANNOT_JOIN_PROJECT", message, 400)


class InvalidRosterFileError(AppError):
"""Uploaded roster file is not a valid .xlsx/.csv, or is missing a header column"""

def __init__(self, message: str = "파일 양식이 올바르지 않습니다."):
super().__init__("INVALID_ROSTER_FILE", message, 400)


class EmptyRosterError(AppError):
"""Roster file has a header but no data rows"""

def __init__(self, message: str = "명부에 회원 데이터가 없습니다."):
super().__init__("EMPTY_ROSTER", message, 422)


class RosterTooLargeError(AppError):
"""Roster exceeds the maximum allowed number of rows"""

def __init__(self, message: str = "명부가 최대 2000행을 초과했습니다."):
super().__init__("ROSTER_TOO_LARGE", message, 400)


class NoLeaderError(AppError):
"""No leader specified in project"""

Expand Down
26 changes: 24 additions & 2 deletions app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
from sqlalchemy import JSON, BigInteger, Column, Enum, Index, Integer, String, Text
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
Enum,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship

from app.config.database import Base
Expand All @@ -19,12 +29,21 @@ class User(Base, TimestampMixin, SoftDeleteMixin):

# Auth
google_id = Column(String(255), unique=True, nullable=True)
email = Column(String(255), nullable=False, unique=True)
# Nullable so temporary members (imported from a roster, never logged in)
# can exist without a real OAuth email. UNIQUE still holds because MySQL
# permits multiple NULLs in a unique index.
email = Column(String(255), nullable=True, unique=True)

# Profile (required)
name = Column(String(100), nullable=False)
generation = Column(String(20), nullable=False, default="26")

# Temporary member: created from an admin roster import with only name and
# student_id populated. Has no email/OAuth identity until the real person
# signs up. Distinct from qualification=PENDING (an OAuth signup awaiting
# admin approval), which keeps the /users/pending flow uncluttered.
is_temporary = Column(Boolean, nullable=False, default=False)

# Status
qualification = Column(
Enum(Qualification), nullable=False, default=Qualification.PENDING
Expand Down Expand Up @@ -96,4 +115,7 @@ def is_leader(self) -> bool:
Index("idx_users_qualification", "qualification"),
Index("idx_users_role", "role"),
Index("idx_users_created_at", "created_at"),
Index("idx_users_is_temporary", "is_temporary"),
# Roster import matches existing members by student_id.
Index("idx_users_student_id", "student_id"),
)
9 changes: 9 additions & 0 deletions app/routes/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
LastLeaderError,
NoLeaderError,
NotFoundError,
TemporaryMemberProjectError,
)
from app.models import MemberRole, User
from app.schemas import (
Expand Down Expand Up @@ -106,6 +107,10 @@ async def create_project(
user = UserService.get(db, member_input.user_id)
if not user:
raise NotFoundError(f"User {member_input.user_id} not found")
# Temporary members are roster placeholders with no OAuth identity and
# must not be treated as real project members (mirrors the /approve guard).
if user.is_temporary:
raise TemporaryMemberProjectError()

# Create project
project_data = request.model_dump(exclude={"members"})
Expand Down Expand Up @@ -270,6 +275,10 @@ async def add_project_member(
target_user = UserService.get(db, member_input.user_id)
if not target_user:
raise NotFoundError(f"User {member_input.user_id} not found")
# Temporary members are roster placeholders with no OAuth identity and must
# not be treated as real project members (mirrors the /approve guard).
if target_user.is_temporary:
raise TemporaryMemberProjectError()

# Add member (idempotent)
MemberService.add(
Expand Down
112 changes: 109 additions & 3 deletions app/routes/users.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy.orm import Session

from app.config.database import get_db
Expand All @@ -8,7 +8,11 @@
require_associate,
require_regular,
)
from app.exceptions import InvalidQualificationError, NotFoundError
from app.exceptions import (
InvalidQualificationError,
NotFoundError,
TemporaryMemberApprovalError,
)
from app.models import AuditAction, Qualification, User, UserRole
from app.schemas import (
ActivityCreateRequest,
Expand All @@ -20,14 +24,31 @@
ProfileUpdateRequest,
ProjectBrief,
Response,
SkippedMember,
TempMemberImportResult,
UserBrief,
UserDetail,
UserUpdateRequest,
)
from app.services import ActivityService, AuditLogService, ProjectService, UserService
from app.services.roster import parse_member_roster

router = APIRouter()


def _skip_message(name: str, student_id: str, reason: str) -> str:
"""Human-readable Korean explanation for a skipped roster row."""
if reason == "missing_student_id":
return f'"{name}"의 학번을 찾을 수 없습니다.'
if reason == "missing_name":
return f'"{student_id}"의 이름을 찾을 수 없습니다.'
if reason == "already_exists":
return f'"{student_id}"은(는) 이미 등록된 학번입니다.'
if reason == "duplicate_in_request":
return f'"{student_id}"이(가) 파일에 중복되어 있습니다.'
return f'"{name or student_id}"의 데이터 형식이 올바르지 않습니다.'


# === Own profile ===
@router.get(
"/me",
Expand Down Expand Up @@ -189,6 +210,84 @@ async def list_pending_users(
return Response(ok=True, data=users)


@router.post(
"/temporary",
response_model=Response[TempMemberImportResult],
summary="Import temporary members from a roster (.xlsx / .csv upload)",
description=(
"Upload a member roster (.xlsx or .csv) to bulk-create temporary members. "
"Admin only."
),
responses={
200: {"description": "Roster imported successfully"},
400: {"description": "파일 양식이 올바르지 않습니다 / 이름·학번 헤더 누락 / 최대 행 초과"},
401: {"description": "Not authenticated"},
403: {"description": "Admin access required"},
422: {"description": "명부에 회원 데이터가 없습니다"},
},
)
async def import_temporary_members(
file: UploadFile = File(
...,
description=(
"명부 파일 (.xlsx 또는 .csv). 첫 행은 헤더이며 이름 열(이름/성명/name)과 "
"학번 열(학번/student_id/sid)이 있어야 합니다."
),
),
_admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""
Import a member roster (.xlsx or .csv) as temporary members.

**Requires**: Admin privileges.

The uploaded file is parsed on the backend. The first row is the header, and
the name / student_id columns are located by header text (case-insensitive;
Korean or English; column order does not matter):
- name: 이름 / 성명 / name
- student_id: 학번 / student_id / sid

Each data row becomes a temporary `User` with only `name` and `student_id`
populated (no email/OAuth identity, `is_temporary=True`, `qualification=PENDING`).

A row is skipped (reported in `skipped` with a `reason` and a Korean `message`)
when a member with that student_id already exists (`already_exists`), the same
student_id appears more than once in the file (`duplicate_in_request`), the row
is missing its student_id (`missing_student_id`) or name (`missing_name`), or the
value is malformed (`invalid`). A whole-file error (400/422) is raised only for a
bad file, a missing header column, or an empty roster.

Idempotency caveats (matching is application-level, not DB-enforced):
- Re-uploading the same roster is safe (existing rows skip as `already_exists`).
- `student_id` has no UNIQUE constraint, so two *concurrent* uploads of the
same student_id could both create a row. Avoid simultaneous uploads.
- Existing members who never recorded a `student_id` cannot be matched and
will be duplicated as temporary members.
"""
content = await file.read()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 row 크기 말고는 입력 크기 제한이 없는데, 용량 기준으로도 제한하는 게 좋을 것 같습니다.

valid_rows, invalid_rows = parse_member_roster(content, file.filename)

created, skipped = UserService.bulk_create_temporary(db, valid_rows)
skipped = invalid_rows + skipped

result = TempMemberImportResult(
created_count=len(created),
skipped_count=len(skipped),
created=[UserBrief.model_validate(u) for u in created],
skipped=[
SkippedMember(
name=name,
student_id=student_id,
reason=reason,
message=_skip_message(name, student_id, reason),
)
for name, student_id, reason in skipped
],
)
return Response(ok=True, data=result)


@router.get(
"/{user_id}",
response_model=Response[UserDetail],
Expand Down Expand Up @@ -321,7 +420,9 @@ async def delete_user(
description="Approve a pending user and set their qualification level. Admin only.",
responses={
200: {"description": "User approved successfully"},
400: {"description": "Cannot approve to PENDING status"},
400: {
"description": "Cannot approve to PENDING status, or target is a temporary member"
},
401: {"description": "Not authenticated"},
403: {"description": "Admin access required"},
404: {"description": "User not found"},
Expand Down Expand Up @@ -350,6 +451,11 @@ async def approve_user(
if not user:
raise NotFoundError("User not found")

# Temporary members are roster placeholders with no OAuth identity; they are
# excluded from the pending-approval queue and must not be approved directly.
if user.is_temporary:
raise TemporaryMemberApprovalError()

# Cannot approve to pending
if request.qualification == Qualification.PENDING:
raise InvalidQualificationError("Cannot approve user to pending status")
Expand Down
4 changes: 4 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
ApproveRequest,
ProfileUpdateRequest,
SignupRequest,
SkippedMember,
TempMemberImportResult,
UserBrief,
UserDetail,
UserUpdateRequest,
Expand All @@ -64,6 +66,8 @@
"ProfileUpdateRequest",
"UserUpdateRequest",
"ApproveRequest",
"TempMemberImportResult",
"SkippedMember",
"UserBrief",
"UserDetail",
"MemberInput",
Expand Down
Loading
Loading