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
14 changes: 14 additions & 0 deletions app/api/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,32 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user, get_db
from app.auth.permissions import check_project_access
from app.models.team import Team
from app.models.user import User
from app.schemas.channel import ChannelCreate, ChannelResponse
from app.services import project_service

router = APIRouter(prefix="/api/teams/{team_id}/channels", tags=["channels"])


async def _get_team_project_id(db: AsyncSession, team_id: uuid.UUID) -> uuid.UUID:
"""팀의 프로젝트 ID를 조회"""
team = await db.get(Team, team_id)
if not team:
raise HTTPException(status_code=404, detail="Team not found")
return team.project_id


@router.post("", response_model=ChannelResponse, status_code=status.HTTP_201_CREATED)
async def add_channel(
team_id: uuid.UUID,
body: ChannelCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project_id = await _get_team_project_id(db, team_id)
await check_project_access(db, project_id, user.id)
channel = await project_service.add_channel(db, team_id, body.name)
return channel

Expand All @@ -30,4 +42,6 @@ async def list_channels(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project_id = await _get_team_project_id(db, team_id)
await check_project_access(db, project_id, user.id)
return await project_service.get_channels(db, team_id)
9 changes: 9 additions & 0 deletions app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sqlalchemy import select

from app.auth.jwt import decode_token
from app.auth.permissions import check_project_access
from app.database import async_session
from app.models.channel import Channel
from app.models.team import Team
Expand Down Expand Up @@ -96,6 +97,14 @@ async def websocket_chat(
await websocket.close(code=4001, reason="인증 실패")
return

# 1.5. 프로젝트 멤버 확인
try:
async with async_session() as db:
await check_project_access(db, project_id, user.id)
except Exception:
await websocket.close(code=4003, reason="프로젝트 접근 권한이 없습니다")
return

# 2. ConnectionManager 에 등록
project_id_str = str(project_id)
await connection_manager.connect(project_id_str, websocket)
Expand Down
85 changes: 85 additions & 0 deletions app/api/members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""프로젝트 멤버 관리 API"""

import uuid

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user, get_db
from app.auth.permissions import add_member, check_project_access, check_project_owner, remove_member
from app.models.project import ProjectMember
from app.models.user import User

router = APIRouter(prefix="/api/projects/{project_id}/members", tags=["members"])


class MemberAddRequest(BaseModel):
user_id: uuid.UUID
role: str = "member"


class MemberResponse(BaseModel):
user_id: uuid.UUID
role: str
username: str
email: str
model_config = {"from_attributes": True}


@router.get("", response_model=list[MemberResponse])
async def list_members(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""멤버 목록 조회 (멤버 전원 가능)"""
await check_project_access(db, project_id, user.id)
result = await db.execute(
select(ProjectMember)
.where(ProjectMember.project_id == project_id)
.options()
)
members = result.scalars().all()
response = []
for m in members:
member_user = await db.get(User, m.user_id)
response.append(
MemberResponse(
user_id=m.user_id,
role=m.role,
username=member_user.username if member_user else "unknown",
email=member_user.email if member_user else "unknown",
)
)
return response


@router.post("", status_code=status.HTTP_201_CREATED)
async def invite_member(
project_id: uuid.UUID,
body: MemberAddRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""멤버 초대 (owner만 가능)"""
await check_project_owner(db, project_id, user.id)
# 대상 유저 존재 확인
target_user = await db.get(User, body.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="유저를 찾을 수 없습니다")
await add_member(db, project_id, body.user_id, body.role)
return {"detail": "멤버가 추가되었습니다"}


@router.delete("/{member_user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def kick_member(
project_id: uuid.UUID,
member_user_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""멤버 제거 (owner만 가능)"""
await check_project_owner(db, project_id, user.id)
await remove_member(db, project_id, member_user_id)
3 changes: 3 additions & 0 deletions app/api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user, get_db
from app.auth.permissions import check_project_access
from app.models.user import User
from app.schemas.notification import NotificationCreate, NotificationResponse
from app.services import notification_service, project_service
Expand All @@ -20,6 +21,7 @@ async def create_notification(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_access(db, project_id, user.id)
project = await project_service.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
Expand All @@ -34,6 +36,7 @@ async def list_notifications(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_access(db, project_id, user.id)
parsed_cursor: uuid.UUID | None = None
if cursor:
try:
Expand Down
5 changes: 4 additions & 1 deletion app/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user, get_db
from app.auth.permissions import check_project_access, check_project_owner
from app.models.user import User
from app.schemas.project import ProjectCreate, ProjectDetailResponse, ProjectResponse
from app.services import project_service
Expand All @@ -31,7 +32,7 @@ async def list_projects(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await project_service.list_projects(db)
return await project_service.list_projects(db, user.id)


@router.get("/{project_id}", response_model=ProjectDetailResponse)
Expand All @@ -40,6 +41,7 @@ async def get_project(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_access(db, project_id, user.id)
project = await project_service.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
Expand All @@ -52,6 +54,7 @@ async def delete_project(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_owner(db, project_id, user.id)
deleted = await project_service.delete_project(db, project_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
3 changes: 3 additions & 0 deletions app/api/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user, get_db
from app.auth.permissions import check_project_access
from app.models.user import User
from app.schemas.team import TeamCreate, TeamDetailResponse, TeamResponse
from app.services import project_service
Expand All @@ -20,6 +21,7 @@ async def add_team(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_access(db, project_id, user.id)
project = await project_service.get_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
Expand All @@ -33,4 +35,5 @@ async def list_teams(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await check_project_access(db, project_id, user.id)
return await project_service.get_teams(db, project_id)
76 changes: 76 additions & 0 deletions app/auth/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""프로젝트 권한 핸들링"""

import uuid

from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.project import ProjectMember


async def check_project_access(
db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID
) -> ProjectMember:
"""프로젝트 멤버인지 확인. 아니면 403."""
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="프로젝트 접근 권한이 없습니다",
)
return member


async def check_project_owner(
db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID
) -> ProjectMember:
"""프로젝트 owner인지 확인. 아니면 403."""
member = await check_project_access(db, project_id, user_id)
if member.role != "owner":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="프로젝트 소유자만 가능합니다",
)
return member


async def add_member(
db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID, role: str = "member"
):
"""프로젝트에 멤버 추가"""
existing = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="이미 프로젝트 멤버입니다")
db.add(ProjectMember(project_id=project_id, user_id=user_id, role=role))
await db.commit()


async def remove_member(
db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID
):
"""프로젝트에서 멤버 제거"""
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="멤버를 찾을 수 없습니다")
if member.role == "owner":
raise HTTPException(status_code=400, detail="소유자는 제거할 수 없습니다")
await db.delete(member)
await db.commit()
9 changes: 7 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

from app.api import auth, channels, chat, devices, notifications, projects, teams
from app.database import engine
from app.api import auth, channels, chat, devices, members, notifications, projects, teams
from app.database import Base, engine

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
# 앱 시작 시 테이블 자동 생성 (SQLite 파일 DB)
import app.models # noqa: F401 — 모든 모델 등록
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()

Expand All @@ -38,6 +42,7 @@ async def lifespan(app: FastAPI):
app.include_router(teams.router)
app.include_router(channels.router)
app.include_router(notifications.router)
app.include_router(members.router)
app.include_router(devices.router)

# WebSocket 채팅
Expand Down
8 changes: 6 additions & 2 deletions app/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ async def get_project_by_name(db: AsyncSession, name: str) -> Project | None:
return result.scalar_one_or_none()


async def list_projects(db: AsyncSession) -> list[Project]:
async def list_projects(db: AsyncSession, user_id: uuid.UUID) -> list[Project]:
result = await db.execute(
select(Project).options(selectinload(Project.teams)).order_by(Project.created_at)
select(Project)
.join(ProjectMember)
.where(ProjectMember.user_id == user_id)
.options(selectinload(Project.teams))
.order_by(Project.created_at)
)
return list(result.scalars().all())

Expand Down
4 changes: 3 additions & 1 deletion tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from app.auth.jwt import create_access_token
from app.models.channel import Channel
from app.models.project import Project
from app.models.project import Project, ProjectMember
from app.models.team import Team
from app.models.user import User
from app.main import app
Expand All @@ -27,6 +27,8 @@ async def project_with_channel(db: AsyncSession, test_user: User):
db.add(project)
await db.flush()

db.add(ProjectMember(project_id=project.id, user_id=test_user.id, role="owner"))

team = Team(project_id=project.id, name="테스트팀")
db.add(team)
await db.flush()
Expand Down
4 changes: 3 additions & 1 deletion tests/test_llm_providers_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.auth.jwt import create_access_token
from app.main import app
from app.models.channel import Channel
from app.models.project import Project
from app.models.project import Project, ProjectMember
from app.models.team import Team
from app.models.user import User
from app.services.llm_service import LLMProvider, LLMService
Expand Down Expand Up @@ -79,6 +79,8 @@ async def project_with_channel(db: AsyncSession, test_user: User):
db.add(project)
await db.flush()

db.add(ProjectMember(project_id=project.id, user_id=test_user.id, role="owner"))

team = Team(project_id=project.id, name="테스트팀")
db.add(team)
await db.flush()
Expand Down
Loading