-
Notifications
You must be signed in to change notification settings - Fork 3
fix: team match logic #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,27 @@ | ||
| from fastapi import APIRouter, Depends | ||
| from sqlalchemy.orm import Session | ||
| from core.database import get_db | ||
| from schemas.team_schema import TeamCreateRequest, TeamResponse | ||
| from schemas.team_schema import TeamCreateRequest, TeamCreateResponse, TeamStatusResponse | ||
| from services.team_service import create_team | ||
| from core.dependencies import get_current_user | ||
| from core.websocket import notify_invitation | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| @router.post("/create", response_model=TeamResponse) | ||
| def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db)): | ||
| return create_team(db, req) | ||
| @router.post("/create", response_model=TeamCreateResponse) | ||
| async def create_team_route(req: TeamCreateRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): | ||
| res = create_team(db, int(current_user["sub"]), req.member_ids) | ||
| return TeamCreateResponse(**res) | ||
|
|
||
| # @router.post("/{team_id}/confirm") | ||
| # def confirm_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): | ||
| # return confirm_membership(db, team_id, current_user["sub"]) | ||
|
|
||
| @router.post("/{team_id}/cancel") | ||
| def cancel_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): | ||
| return cancel_team(db, team_id, current_user["sub"]) | ||
|
|
||
| @router.get("/{team_id}/status", response_model=TeamStatusResponse) | ||
| def status_route(team_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)): | ||
| res = get_team_status(db, team_id, current_user["sub"]) | ||
| return TeamStatusResponse(**res) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| from fastapi import WebSocket, WebSocketDisconnect, APIRouter, Depends | ||
| from core.dependencies import get_current_user | ||
| from core.security import verify_access_token | ||
|
|
||
| router = APIRouter() | ||
| active_sockets: dict[int, WebSocket] = {} | ||
|
|
||
| @router.websocket("/invitations") | ||
| async def websocket_endpoint(websocket: WebSocket): | ||
| try: | ||
| user_id = verify_access_token(websocket.headers.get("Sec-Websocket-Protocol"))["sub"] | ||
| except HTTPException: | ||
| await websocket.close(code=1008) | ||
| return | ||
|
|
||
| await websocket.accept() | ||
| active_sockets[user_id] = websocket | ||
| try: | ||
| while True: | ||
| await websocket.receive_text() | ||
| except WebSocketDisconnect: | ||
| del active_sockets[user_id] | ||
|
|
||
| async def notify_invitation(member_ids, team): | ||
| for uid in member_ids: | ||
| if uid in active_sockets: | ||
| await active_sockets[uid].send_json({ | ||
| "event": "team_invitation", | ||
| "team_id": team.id, | ||
| "members": member_ids, | ||
| "expires_at": str(team.expires_at) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,37 @@ | ||
| from core.database import Base | ||
| from sqlalchemy import Column, Integer, Boolean, UniqueConstraint | ||
| from sqlalchemy import Column, Integer, Boolean, UniqueConstraint, Enum, DateTime, ForeignKey, String | ||
| import enum | ||
| from datetime import datetime, timedelta | ||
| from sqlalchemy.orm import relationship | ||
|
|
||
| class TeamStatus(str, enum.Enum): | ||
| PENDING = "PENDING" | ||
| ACTIVE = "ACTIVE" | ||
| CANCELLED = "CANCELLED" | ||
| FAILED = "FAILED" | ||
|
|
||
|
|
||
| class Team(Base): | ||
| __tablename__ = "teams" | ||
|
|
||
| id = Column(Integer, primary_key=True, index=True) | ||
| team_id = Column(Integer, index=True) | ||
| user_id = Column(Integer, index=True) | ||
| is_active = Column(Boolean, default=True) | ||
| group_hash = Column(String, index=True) | ||
| status = Column(Enum(TeamStatus), nullable=False, default=TeamStatus.PENDING) | ||
| created_at = Column(DateTime(timezone=True), default=datetime.utcnow) | ||
|
|
||
| members = relationship("TeamMember", back_populates="team", cascade="all, delete-orphan") | ||
|
|
||
|
|
||
| class TeamMember(Base): | ||
| __tablename__ = "team_members" | ||
|
|
||
| id = Column(Integer, primary_key=True, index=True) | ||
| team_id = Column(Integer, ForeignKey("teams.id", ondelete="CASCADE"), index=True, nullable=False) | ||
| user_id = Column(Integer, index=True, nullable=False) | ||
| confirmed = Column(Boolean, default=False) | ||
|
|
||
| team = relationship("Team", back_populates="members") | ||
|
|
||
| __table_args__ = ( | ||
| UniqueConstraint('team_id', 'user_id', name='_team_user_uc'), | ||
| __table_args__ = ( | ||
| UniqueConstraint("team_id", "user_id", name="u_team_user"), | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,17 @@ | ||
| from pydantic import BaseModel, Field | ||
| from typing import List | ||
| from typing import List, Optional | ||
| from datetime import datetime | ||
|
|
||
|
|
||
| class TeamCreateRequest(BaseModel): | ||
| my_id: int | ||
| member_ids: List[int] = Field(..., min_items=4, max_items=4, description="ํ์ 4๋ช ์ ID") | ||
| member_ids: List[int] = Field(..., min_items=1, max_items=4, description="ํ์ 4๋ช ์ ID") | ||
|
|
||
| class Config: | ||
| json_schema_extra = { | ||
| "example": { | ||
| "my_id": 1, | ||
| "member_ids": [2, 3, 4, 5] | ||
| } | ||
| } | ||
| class TeamCreateResponse(BaseModel): | ||
| team_id: int | ||
| status: str | ||
| message: str | ||
|
|
||
| class TeamResponse(BaseModel): | ||
| class TeamStatusResponse(BaseModel): | ||
| team_id: int | ||
| members_ids: List[int] | ||
| status: str | ||
| members_ready: List[int] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,58 +1,164 @@ | ||
| from models.challenges import UserChallengeStatus | ||
| from sqlalchemy import func | ||
| from sqlalchemy.orm import Session | ||
| from models.teams import Team | ||
| from schemas.team_schema import TeamCreateRequest, TeamResponse | ||
| from models.teams import Team, TeamMember, TeamStatus | ||
| from schemas.team_schema import TeamCreateRequest | ||
| from fastapi import HTTPException | ||
| from typing import List | ||
| import os | ||
| from datetime import datetime, timedelta | ||
|
|
||
| def create_team(db: Session, req: TeamCreateRequest) -> TeamResponse: | ||
| all_ids = [req.my_id] + req.member_ids | ||
| TEAM_SIZE = int(os.getenv("TEAM_SIZE", "0")) | ||
| PENDING_TIMEOUT_MINUTES = int(os.getenv("PENDING_TIMEOUT_MINUTES", "0")) | ||
|
|
||
| records = ( | ||
| db.query(Team.user_id, Team.is_active, UserChallengeStatus.is_correct) | ||
| .outerjoin(UserChallengeStatus, Team.user_id == UserChallengeStatus.user_id) | ||
| .filter(Team.user_id.in_(all_ids)) | ||
| def _now(): | ||
| return datetime.utcnow() | ||
|
|
||
| def create_team(db: Session, my_id: int, member_ids: List[int]): | ||
| all_ids = sorted([my_id] + member_ids) | ||
|
|
||
| if len(all_ids) != TEAM_SIZE: | ||
| raise HTTPException(status_code=400, detail="Team must have exactly 5 members (you + 4).") | ||
| if len(set(all_ids)) != TEAM_SIZE: | ||
| raise HTTPException(status_code=400, detail="Duplicate user IDs in request.") | ||
|
|
||
| corrected = db.query(UserChallengeStatus.user_id).filter( | ||
| UserChallengeStatus.user_id.in_(all_ids), | ||
| UserChallengeStatus.is_correct == True | ||
| ).all() | ||
|
|
||
| if corrected: | ||
| corrected_ids = [r[0] for r in corrected] | ||
| raise HTTPException(status_code=400, detail=f"Users already corrected: {corrected_ids}") | ||
|
|
||
| blocking_users = ( | ||
| db.query(TeamMember.user_id) | ||
| .join(Team) | ||
| .filter( | ||
| TeamMember.user_id.in_(all_ids), | ||
| Team.status == TeamStatus.ACTIVE | ||
| ) | ||
| .all() | ||
| ) | ||
| if blocking_users: | ||
| raise HTTPException(status_code=400, | ||
| detail=f"Users already in another team: {[u[0] for u in blocking_users]}") | ||
|
|
||
| group_hash = "-".join(map(str, all_ids)) | ||
|
|
||
| active_ids = [r.user_id for r in records if r.is_active] | ||
| corrected_ids = [r.user_id for r in records if r.is_correct] | ||
| team = ( | ||
| db.query(Team) | ||
| .filter(Team.group_hash == group_hash, | ||
| Team.status.in_([TeamStatus.PENDING, TeamStatus.ACTIVE])) | ||
| .first() | ||
| ) | ||
|
|
||
| if active_ids: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Users already in active team: {active_ids}", | ||
| ) | ||
| target_team = None | ||
|
|
||
| if team and team.status == TeamStatus.PENDING: | ||
| if _now() - team.created_at.replace(tzinfo=None) > timedelta(minutes=PENDING_TIMEOUT_MINUTES): | ||
| team.status = TeamStatus.CANCELLED | ||
| db.commit() | ||
| else: | ||
| my_member = ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. my_member ๋ผ๋ ํํ์ ์ข ์ด์ํ๋ค์. my_member ๋์ me ๋ก ๋ฐ๊พธ๋ฉด ์ด๋จ๊น์? ๐ |
||
| db.query(TeamMember) | ||
| .filter(TeamMember.team_id == team.id, TeamMember.user_id == my_id) | ||
| .first() | ||
| ) | ||
| if my_member: | ||
| my_member.confirmed = True | ||
|
|
||
| db.flush() | ||
|
|
||
| if corrected_ids: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Users already corrected: {corrected_ids}", | ||
| ) | ||
| ready_count = ( | ||
| db.query(TeamMember) | ||
| .filter(TeamMember.team_id == team.id, TeamMember.confirmed == True) | ||
| .count() | ||
| ) | ||
|
|
||
| last_team_id = db.query(func.max(Team.team_id)).scalar() | ||
| new_team_id = (last_team_id or 0) + 1 | ||
| if ready_count == TEAM_SIZE: | ||
| team.status = TeamStatus.ACTIVE | ||
| db.commit() | ||
| return {"status": "ACTIVE", "team_id": team.id, "message": "Team matched"} | ||
|
|
||
| teams_to_add = [ | ||
| {"team_id": new_team_id, "user_id": uid, "is_active": True} | ||
| for uid in all_ids | ||
| ] | ||
| db.commit() | ||
| return {"status": "PENDING", "team_id": team.id, "message": "Joined pending team request"} | ||
|
|
||
| db.bulk_insert_mappings(Team, teams_to_add) | ||
| if team and team.status == TeamStatus.ACTIVE: | ||
| raise HTTPException(status_code=400, detail="This team is already active.") | ||
|
|
||
| team = Team(group_hash=group_hash, status=TeamStatus.PENDING) | ||
| db.add(team) | ||
| db.flush() | ||
|
|
||
| members = [ | ||
| TeamMember(team_id=team.id, user_id=uid, confirmed=(uid == my_id)) | ||
| for uid in all_ids | ||
| ] | ||
| db.bulk_save_objects(members) | ||
| db.commit() | ||
|
|
||
| return TeamResponse(team_id=new_team_id, members_ids=all_ids) | ||
| return {"status": "PENDING", "team_id": team.id, "message": "New team request created"} | ||
|
|
||
| def dissolve_team_by_user(db: Session, user_id: int): | ||
| team_entry = db.query(Team).filter(Team.user_id == user_id, Team.is_active == True).first() | ||
| if not team_entry: | ||
| raise HTTPException(status_code=400, detail="User not in active team") | ||
| def cancel_team(db: Session, team_id: int, user_id: int): | ||
| team_entry = ( | ||
| db.query(Team) | ||
| .join(TeamMember) | ||
| .filter( | ||
| TeamMember.user_id == user_id, | ||
| Team.status == TeamStatus.PENDING | ||
| ).first() | ||
| ) | ||
|
|
||
| if team_entry: | ||
| team_entry.status = TeamStatus.CANCELLED | ||
| db.commit() | ||
| return {"message": "Team request cancelled"} | ||
|
|
||
| raise HTTPException(status_code=400, detail="No pending team found") | ||
|
|
||
| def get_team_status(db: Session, team_id: int, user_id: int): | ||
| last_member_entry = ( | ||
| db.query(TeamMember) | ||
| .join(Team) | ||
| .filter(TeamMember.user_id == user_id) | ||
| .order_by(Team.created_at.desc()) | ||
| .first() | ||
| ) | ||
|
|
||
| if not last_member_entry: | ||
| return {"status": "NONE"} | ||
|
|
||
| team_id = team_entry.team_id | ||
| team_members = db.query(Team).filter(Team.team_id == team_id, Team.is_active == True).all() | ||
| team = last_member_entry.team | ||
|
|
||
| for member in team_members: | ||
| member.is_active = False | ||
| if team.status == TeamStatus.PENDING: | ||
| time_diff = datetime.now() - team.created_at | ||
| if time_diff > timedelta(minutes=5): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5 ๋ ์์๋ก ๋นผ๋ฉด ์ข์ ๊ฒ ๊ฐ์์. ( |
||
| team.status = TeamStatus.CANCELLED | ||
| db.commit() | ||
| return {"status": "EXPIRED"} | ||
|
|
||
| return { | ||
| "team_id": team.id, | ||
| "status": team.status.value, | ||
| "members_ready": [m.user_id for m in team.members if m.confirmed] | ||
| } | ||
|
|
||
| def dissolve_team_by_user(db: Session, user_id: int): | ||
| team_entry = ( | ||
| db.query(Team) | ||
| .join(TeamMember) | ||
| .filter( | ||
| TeamMember.user_id == user_id, | ||
| Team.status == TeamStatus.ACTIVE | ||
| ).first() | ||
| ) | ||
|
|
||
| if not team_entry: | ||
| raise HTTPException(status_code=400, detail="User is not in an active team") | ||
|
|
||
| team_entry.status = TeamStatus.FAILED | ||
|
|
||
| db.commit() | ||
| return {"message": f"Team {team_id} dissolved", "team_id": team_id} | ||
|
|
||
| return {"message": f"Team {team_entry.id} dissolved due to quiz failure.", "team_id": team_entry.id} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
์ ์ฐ๋ ์ฝ๋๋ ์ง์ ์ฃผ์ธ์. ๐