Skip to content

Commit 95820eb

Browse files
committed
Merge branch 'feat/14-notice-crud'
2 parents 6ca3be6 + 37b7ba9 commit 95820eb

10 files changed

Lines changed: 767 additions & 7 deletions

File tree

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from app.modules.member.router import router as member_router
1515
from app.modules.project.router import router as project_router
1616
from app.modules.work.router import work_router, project_work_router
17+
from app.modules.notice.router import router as notice_router
1718
from app.modules.skill.models import Skill
1819
from app.modules.member.models import Member
1920
from app.modules.favorite.models import Favorite
@@ -47,6 +48,7 @@ def create_app() -> FastAPI:
4748
app.include_router(project_router)
4849
app.include_router(work_router)
4950
app.include_router(project_work_router)
51+
app.include_router(notice_router)
5052

5153
# Static Files
5254
app.mount("/static", StaticFiles(directory="app/static"), name="static")

app/modules/notice/dependencies.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Annotated
2+
3+
from fastapi import Depends
4+
5+
from app.core.database import DbSessionDep
6+
from app.modules.notice.repository import NoticeRepository
7+
from app.modules.notice.service import NoticeService
8+
9+
10+
def get_notice_service(session: DbSessionDep) -> NoticeService:
11+
repository = NoticeRepository(session)
12+
return NoticeService(session, repository)
13+
14+
15+
NoticeServiceDep = Annotated[NoticeService, Depends(get_notice_service)]

app/modules/notice/repository.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from datetime import datetime, timezone
2+
from uuid import UUID
3+
4+
from sqlalchemy import func, or_
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlmodel import select
7+
8+
from app.modules.notice.models import Notice
9+
10+
11+
class NoticeRepository:
12+
def __init__(self, session: AsyncSession):
13+
self.session = session
14+
15+
async def get_by_id(self, notice_id: UUID, *, include_deleted: bool = False) -> Notice | None:
16+
query = select(Notice).where(Notice.id == notice_id)
17+
if not include_deleted:
18+
query = query.where(Notice.is_deleted == False)
19+
return await self.session.scalar(query)
20+
21+
async def list(
22+
self,
23+
*,
24+
keyword: str | None = None,
25+
project_id: UUID | None = None,
26+
offset: int = 0,
27+
limit: int = 100,
28+
include_deleted: bool = False,
29+
) -> list[Notice]:
30+
stmt = select(Notice)
31+
32+
if keyword:
33+
stmt = stmt.where(
34+
or_(
35+
Notice.title.ilike(f"%{keyword}%"),
36+
Notice.detail.ilike(f"%{keyword}%")
37+
)
38+
)
39+
40+
if project_id:
41+
stmt = stmt.where(Notice.project_id == project_id)
42+
43+
if not include_deleted:
44+
stmt = stmt.where(Notice.is_deleted == False)
45+
46+
stmt = stmt.order_by(Notice.created_at.desc()).offset(offset).limit(limit)
47+
return (await self.session.scalars(stmt)).all()
48+
49+
async def count(
50+
self,
51+
*,
52+
keyword: str | None = None,
53+
project_id: UUID | None = None,
54+
include_deleted: bool = False
55+
) -> int:
56+
stmt = select(func.count()).select_from(Notice)
57+
58+
if keyword:
59+
stmt = stmt.where(
60+
or_(
61+
Notice.title.ilike(f"%{keyword}%"),
62+
Notice.detail.ilike(f"%{keyword}%")
63+
)
64+
)
65+
66+
if project_id:
67+
stmt = stmt.where(Notice.project_id == project_id)
68+
69+
if not include_deleted:
70+
stmt = stmt.where(Notice.is_deleted == False)
71+
72+
return int(await self.session.scalar(stmt) or 0)
73+
74+
async def save(self, notice: Notice) -> Notice:
75+
self.session.add(notice)
76+
await self.session.flush()
77+
await self.session.refresh(notice)
78+
return notice
79+
80+
async def soft_delete(self, notice: Notice) -> Notice:
81+
notice.is_deleted = True
82+
notice.deleted_at = datetime.now(timezone.utc)
83+
84+
await self.session.flush()
85+
await self.session.refresh(notice)
86+
return notice
87+
88+
async def hard_delete(self, notice: Notice) -> None:
89+
await self.session.delete(notice)
90+
await self.session.flush()

app/modules/notice/router.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from typing import Annotated
2+
from uuid import UUID
3+
4+
from fastapi import APIRouter, Query, Path
5+
from starlette import status
6+
7+
from app.modules.project.dependencies import ProjectParticipantDep, ProjectLeaderDep
8+
from app.modules.notice.dependencies import NoticeServiceDep
9+
from app.modules.notice.schemas import NoticeOut, NoticeCreateIn, NoticeUpdateIn
10+
from app.shared.schemas import ApiResponse, PageOut
11+
12+
router = APIRouter(prefix="/project/{project_id}/notice", tags=["Notice"])
13+
14+
@router.get(
15+
path="",
16+
response_model=ApiResponse[PageOut[NoticeOut]],
17+
summary="프로젝트 공지 목록 조회",
18+
description="해당 프로젝트의 공지 목록을 조회합니다. 프로젝트 참여자만 가능합니다.",
19+
)
20+
async def list_project_notices(
21+
project: ProjectParticipantDep,
22+
service: NoticeServiceDep,
23+
keyword: Annotated[str | None, Query(description="검색 키워드(제목, 상세내용)")] = None,
24+
page: Annotated[int, Query(ge=1, description="페이지 번호")] = 1,
25+
size: Annotated[int, Query(ge=1, le=100, description="페이지 크기")] = 50,
26+
include_deleted: Annotated[bool, Query(description="soft delete된 데이터 포함 여부")] = False,
27+
):
28+
result = await service.list(
29+
keyword=keyword,
30+
project_id=project.id,
31+
page=page,
32+
size=size,
33+
include_deleted=include_deleted,
34+
)
35+
36+
return ApiResponse.success(
37+
code="PROJECT_NOTICE_LIST_FETCHED",
38+
message="프로젝트 공지 목록 조회 성공",
39+
data=PageOut[NoticeOut](
40+
items=[NoticeOut.model_validate(item) for item in result["items"]],
41+
page=result["page"],
42+
size=result["size"],
43+
total=result["total"],
44+
)
45+
)
46+
47+
@router.post(
48+
path="",
49+
response_model=ApiResponse[NoticeOut],
50+
status_code=status.HTTP_201_CREATED,
51+
summary="공지 생성",
52+
description="프로젝트 내에 공지를 생성합니다. 프로젝트 리더만 가능합니다.",
53+
)
54+
async def create_notice(
55+
project: ProjectLeaderDep,
56+
data: NoticeCreateIn,
57+
service: NoticeServiceDep,
58+
):
59+
# 입력된 project_id가 경로의 project_id와 일치하는지 확인 (보안 강화)
60+
if data.project_id != project.id:
61+
from app.core.exceptions import AppError
62+
raise AppError.bad_request("경로의 프로젝트 ID와 요청 바디의 프로젝트 ID가 일치하지 않습니다.")
63+
64+
created = await service.create(data=data)
65+
return ApiResponse.success(
66+
code="NOTICE_CREATED",
67+
message="공지 생성 성공",
68+
data=NoticeOut.model_validate(created)
69+
)
70+
71+
@router.get(
72+
path="/{notice_id}",
73+
response_model=ApiResponse[NoticeOut],
74+
summary="공지 단건 조회",
75+
description="프로젝트 내의 특정 공지를 조회합니다.",
76+
)
77+
async def get_project_notice(
78+
project: ProjectParticipantDep,
79+
service: NoticeServiceDep,
80+
notice_id: Annotated[UUID, Path(..., description="조회할 notice의 id")],
81+
include_deleted: Annotated[bool, Query(description="soft delete된 데이터 포함 여부")] = False,
82+
):
83+
notice = await service.get(notice_id, include_deleted=include_deleted)
84+
85+
# 해당 공지가 해당 프로젝트의 것인지 확인
86+
if notice.project_id != project.id:
87+
from app.core.exceptions import AppError
88+
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
89+
90+
return ApiResponse.success(
91+
code="NOTICE_FETCHED",
92+
message="공지 조회 성공",
93+
data=NoticeOut.model_validate(notice)
94+
)
95+
96+
@router.patch(
97+
path="/{notice_id}",
98+
response_model=ApiResponse[NoticeOut],
99+
summary="공지 정보 수정",
100+
description="기존 공지를 수정합니다. 프로젝트 리더만 가능합니다."
101+
)
102+
async def update_project_notice(
103+
project: ProjectLeaderDep,
104+
service: NoticeServiceDep,
105+
notice_id: Annotated[UUID, Path(description="수정할 notice의 ID")],
106+
data: NoticeUpdateIn,
107+
):
108+
# 해당 공지가 해당 프로젝트의 것인지 확인
109+
notice = await service.get(notice_id)
110+
if notice.project_id != project.id:
111+
from app.core.exceptions import AppError
112+
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
113+
114+
updated = await service.update(
115+
target_notice_id=notice_id,
116+
data=data
117+
)
118+
return ApiResponse.success(
119+
code="NOTICE_UPDATED",
120+
message="공지 수정 성공",
121+
data=NoticeOut.model_validate(updated),
122+
)
123+
124+
@router.delete(
125+
path="/{notice_id}",
126+
response_model=ApiResponse[None],
127+
summary="공지 삭제",
128+
description="기존 공지를 삭제합니다. 프로젝트 리더만 가능합니다.",
129+
)
130+
async def delete_project_notice(
131+
project: ProjectLeaderDep,
132+
service: NoticeServiceDep,
133+
notice_id: Annotated[UUID, Path(description="삭제할 공지의 ID")],
134+
hard: Annotated[bool, Query(description="true면 hard delete, false면 soft delete")] = False,
135+
):
136+
# 해당 공지가 해당 프로젝트의 것인지 확인
137+
notice = await service.get(notice_id, include_deleted=True)
138+
if notice.project_id != project.id:
139+
from app.core.exceptions import AppError
140+
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
141+
142+
await service.delete(
143+
target_notice_id=notice_id,
144+
hard=hard
145+
)
146+
return ApiResponse.success(
147+
code="NOTICE_DELETED",
148+
message="공지 삭제 성공",
149+
data=None
150+
)
151+
152+
@router.patch(
153+
path="/{notice_id}/restore",
154+
response_model=ApiResponse[NoticeOut],
155+
summary="공지 복구",
156+
description="soft delete 된 공지를 복구합니다. 프로젝트 리더만 가능합니다."
157+
)
158+
async def restore_project_notice(
159+
project: ProjectLeaderDep,
160+
service: NoticeServiceDep,
161+
notice_id: Annotated[UUID, Path(description="복구할 notice의 ID")],
162+
):
163+
# 해당 공지가 해당 프로젝트의 것인지 확인
164+
notice = await service.get(notice_id, include_deleted=True)
165+
if notice.project_id != project.id:
166+
from app.core.exceptions import AppError
167+
raise AppError.not_found("해당 프로젝트에서 해당 공지를 찾을 수 없습니다.")
168+
169+
restored = await service.restore(notice_id)
170+
return ApiResponse.success(
171+
code="NOTICE_RESTORED",
172+
message="공지 복구 성공",
173+
data=NoticeOut.model_validate(restored)
174+
)

app/modules/notice/schemas.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from datetime import datetime
2+
from uuid import UUID
3+
4+
from sqlmodel import SQLModel, Field
5+
6+
7+
class NoticeCreateIn(SQLModel):
8+
project_id: UUID = Field(description="프로젝트 고유키")
9+
title: str = Field(description="공지 제목")
10+
detail: str = Field(description="공지 상세 내용")
11+
12+
model_config = {
13+
"json_schema_extra": {
14+
"example": {
15+
"project_id": "3e1672cf-8d99-4b1c-9b5e-9c3ece11b089",
16+
"title": "공지사항 제목",
17+
"detail": "공지사항 상세 내용입니다."
18+
}
19+
}
20+
}
21+
22+
23+
class NoticeUpdateIn(SQLModel):
24+
title: str | None = Field(default=None, description="공지 제목")
25+
detail: str | None = Field(default=None, description="공지 상세 내용")
26+
27+
model_config = {
28+
"json_schema_extra": {
29+
"example": {
30+
"title": "수정된 공지 제목",
31+
"detail": "수정된 공지 상세 내용"
32+
}
33+
}
34+
}
35+
36+
37+
class NoticeOut(SQLModel):
38+
id: UUID = Field(description="공지 ID")
39+
project_id: UUID = Field(description="프로젝트 고유키")
40+
title: str = Field(description="공지 제목")
41+
detail: str = Field(description="공지 상세 내용")
42+
created_at: datetime = Field(description="생성 일시")
43+
updated_at: datetime | None = Field(default=None, description="수정 일시")
44+
45+
model_config = {
46+
"json_schema_extra": {
47+
"example": {
48+
"id": "5f1672kf-8d99-4b1c-9b5e-9c3ece11b089",
49+
"project_id": "3e1672cf-8d99-4b1c-9b5e-9c3ece11b089",
50+
"title": "공지사항 제목",
51+
"detail": "공지사항 상세 내용입니다.",
52+
"created_at": "2026-04-27T10:00:00"
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)