diff --git a/service/app/models/plan.py b/service/app/models/plan.py index c130030..c3260fb 100644 --- a/service/app/models/plan.py +++ b/service/app/models/plan.py @@ -9,6 +9,7 @@ class Plan(SQLModel, table=True): group_version_id: uuid.UUID = Field(default_factory=uuid.uuid4) name: str = Field(nullable=False) content: str - is_favorite: bool = Field(default=False) + public_slug: str | None + bookmark: bool = Field(default=False) created_at: datetime = Field(default_factory=datetime.now) user_id: uuid.UUID = Field(nullable=False, foreign_key="user.id") diff --git a/service/app/routers/plan_router.py b/service/app/routers/plan_router.py index 35662b7..4fa6ec2 100644 --- a/service/app/routers/plan_router.py +++ b/service/app/routers/plan_router.py @@ -7,7 +7,7 @@ from ..database import get_session from ..middlewares.auth_middleware import auth_dependency -from ..schemas.plan import PlanCreate, PlanRead +from ..schemas.plan import PlanCreate, PlanRead, PlanUpdate from ..services import plan_service router = APIRouter() @@ -20,7 +20,29 @@ async def get_plans( try: plans = plan_service.get_plans(request.state.user.id, session) except Exception as e: - raise HTTPException(status_code=500, detail="Failed to delete plan. Please report to page-admin") from e + raise HTTPException(status_code=500, detail="Failed to get plans. Please report to page-admin") from e + return {"plans": plans} + + +@router.get("/plans/shared/{public_slug}") +async def get_plan_by_public_slug(public_slug: str, session: Annotated[Session, Depends(get_session)]) -> PlanRead: + try: + plan = plan_service.get_plan_by_public_slug(public_slug, session) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to get plan. Please report to page-admin") from e + return plan + + +@router.get("/plans/history/{plan_id}", dependencies=[Depends(auth_dependency)]) +async def get_plan_history( + request: Request, plan_id: UUID, session: Annotated[Session, Depends(get_session)] +) -> dict[str, Sequence[PlanRead]]: + try: + plans = plan_service.get_plan_history(request.state.user.id, plan_id, session) + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to get plans. Please report to page-admin") from e return {"plans": plans} @@ -35,6 +57,17 @@ async def create_plan( return created_plan +@router.post("/plans/{plan_id}/update", dependencies=[Depends(auth_dependency)], status_code=201) +async def update_plan( + request: Request, plan_id: UUID, plan_data: PlanUpdate, session: Annotated[Session, Depends(get_session)] +) -> PlanRead: + try: + created_plan = plan_service.update_plan(request.state.user.id, plan_id, plan_data, session) + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to create plan. Please report to page-admin") from e + return created_plan + + @router.delete("/plans/{plan_id}", dependencies=[Depends(auth_dependency)], status_code=204) async def delete_plan(request: Request, plan_id: UUID, session: Annotated[Session, Depends(get_session)]) -> None: try: @@ -43,3 +76,13 @@ async def delete_plan(request: Request, plan_id: UUID, session: Annotated[Sessio raise HTTPException(status_code=404, detail=str(e)) from e except Exception as e: raise HTTPException(status_code=500, detail="Failed to delete plan. Please report to page-admin") from e + + +@router.patch("/plans/bookmark/{plan_id}", dependencies=[Depends(auth_dependency)], status_code=204) +async def bookmark_plan(request: Request, plan_id: UUID, session: Annotated[Session, Depends(get_session)]) -> None: + try: + plan_service.bookmark_plan(request.state.user.id, plan_id, session) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail="Failed to (un)bookmark plan. Please report to page-admin") from e diff --git a/service/app/schemas/plan.py b/service/app/schemas/plan.py index 2f21ab4..02931e5 100644 --- a/service/app/schemas/plan.py +++ b/service/app/schemas/plan.py @@ -9,7 +9,8 @@ class PlanRead(SQLModel): group_version_id: UUID name: str content: str - is_favorite: bool + public_slug: str + bookmark: bool created_at: datetime user_id: UUID @@ -17,3 +18,7 @@ class PlanRead(SQLModel): class PlanCreate(SQLModel): name: str content: str + + +class PlanUpdate(SQLModel): + content: str diff --git a/service/app/services/plan_service.py b/service/app/services/plan_service.py index c18457f..d02f1a0 100644 --- a/service/app/services/plan_service.py +++ b/service/app/services/plan_service.py @@ -1,23 +1,56 @@ +import base64 from collections.abc import Sequence from uuid import UUID +from sqlalchemy import and_, func from sqlmodel import Session, select from ..models.plan import Plan -from ..schemas.plan import PlanCreate, PlanRead +from ..schemas.plan import PlanCreate, PlanRead, PlanUpdate def get_plans(user_id: UUID, session: Session) -> Sequence[PlanRead]: - statement = select(Plan).where(Plan.user_id == user_id) + subquery = ( + select(Plan.group_version_id, func.max(Plan.created_at).label("max_created_at")) + .where(Plan.user_id == user_id) + .group_by(Plan.group_version_id) + .subquery() + ) + statement = ( + select(Plan) + .join( + subquery, + and_(Plan.group_version_id == subquery.c.group_version_id, Plan.created_at == subquery.c.max_created_at), + ) + .where(Plan.user_id == user_id) + ) + plans = session.exec(statement).all() + return [PlanRead.model_validate(plan) for plan in plans] + + +def get_plan_by_public_slug(public_slug: str, session: Session) -> PlanRead: + statement = select(Plan).where(Plan.public_slug == public_slug).order_by(Plan.created_at.desc()) + plan = session.exec(statement).first() + if not plan: + error_msg = "Plan not found" + raise ValueError(error_msg) + return PlanRead.model_validate(plan) + +def get_plan_history(plan_id: UUID, session: Session) -> Sequence[PlanRead]: + current_plan = session.get(Plan, plan_id) + statement = ( + select(Plan).where(Plan.group_version_id == current_plan.group_version_id).order_by(Plan.created_at.desc()) + ) plans = session.exec(statement).all() return [PlanRead.model_validate(plan) for plan in plans] def write_plan(user_id: UUID, plan_data: PlanCreate, session: Session) -> PlanRead: - plan_dict = plan_data.model_dump() # Convert PlanCreate to a dictionary + plan_dict = plan_data.model_dump() plan_dict["user_id"] = user_id created_plan = Plan(**plan_dict) + created_plan.public_slug = create_public_slug(created_plan.group_version_id) session.add(created_plan) session.commit() session.refresh(created_plan) @@ -25,9 +58,48 @@ def write_plan(user_id: UUID, plan_data: PlanCreate, session: Session) -> PlanRe def delete_plan(user_id: UUID, plan_id: UUID, session: Session) -> None: + current_plan = session.get(Plan, plan_id) + if not current_plan or current_plan.user_id != user_id: + error_msg = "Plan not found or access denied" + raise ValueError(error_msg) + statement = select(Plan).where(Plan.group_version_id == current_plan.group_version_id) + plan_group = session.exec(statement).all() + for plan in plan_group: + session.delete(plan) + session.commit() + + +def create_public_slug(group_id: UUID) -> str: + return base64.urlsafe_b64encode(group_id.bytes).rstrip(b"=").decode("ascii") + + +def update_plan(user_id: UUID, plan_id: UUID, plan_data: PlanUpdate, session: Session) -> PlanRead: + current_plan = session.get(Plan, plan_id) + new_plan_data = { + "group_version_id": current_plan.group_version_id, + "name": current_plan.name, + "content": plan_data.content, + "public_slug": current_plan.public_slug, + "bookmark": current_plan.bookmark, + "user_id": user_id, + } + new_plan = Plan(**new_plan_data) + session.add(new_plan) + session.commit() + session.refresh(new_plan) + return PlanRead.model_validate(new_plan) + + +def bookmark_plan(user_id: UUID, plan_id: UUID, session: Session) -> None: plan = session.get(Plan, plan_id) - if not plan or plan.user_id != user_id: + if not plan: + error_msg = f"Plan {plan_id} not found." + raise ValueError(error_msg) + if plan.user_id != user_id: error_msg = "Plan not found or access denied" raise ValueError(error_msg) - session.delete(plan) + + plan.bookmark = not plan.bookmark + session.add(plan) session.commit() + session.refresh(plan) diff --git a/service/pyproject.toml b/service/pyproject.toml index 0dad8ce..44a0df5 100644 --- a/service/pyproject.toml +++ b/service/pyproject.toml @@ -43,4 +43,4 @@ skip_empty = true omit = [ "app/middlewares/*", "app/create_tables.py", -] \ No newline at end of file +] diff --git a/service/tests/conftest.py b/service/tests/conftest.py index 7ec43d2..fe5796e 100644 --- a/service/tests/conftest.py +++ b/service/tests/conftest.py @@ -4,6 +4,7 @@ import pytest from fastapi import Depends, Request from fastapi.testclient import TestClient +from sqlalchemy import StaticPool from sqlalchemy.exc import OperationalError from sqlalchemy.orm import sessionmaker from sqlmodel import Session, create_engine @@ -28,7 +29,7 @@ async def override_auth_dependency(request: Request, session: Annotated[Session, def override_get_session() -> Generator[Session, Any]: - engine = create_engine("sqlite:///:memory:") + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) session = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session) db = session() diff --git a/service/tests/test_plan_router.py b/service/tests/test_plan_router.py index 195462d..0dad734 100644 --- a/service/tests/test_plan_router.py +++ b/service/tests/test_plan_router.py @@ -49,11 +49,37 @@ def test_delete_plan(self, test_client: TestClient) -> None: assert len(response.json()["plans"]) == 0 def test_delete_nonexisting_plan(self, test_client: TestClient) -> None: + response = test_client.delete(f"/plans/{uuid.uuid4()}") + assert response.status_code == 404 + + +class TestBookmarkPlan: + def test_bookmark_plan(self, test_client: TestClient) -> None: + request_data = {"name": "Test Plan", "content": "Test Content"} + response = test_client.post("/plans", json=request_data) + assert response.status_code == 201 + plan_id = response.json()["id"] + + response = test_client.patch(f"/plans/bookmark/{plan_id}") + assert response.status_code == 204 + + def test_bookmark_plan_twice(self, test_client: TestClient) -> None: + request_data = {"name": "Test Plan", "content": "Test Content"} + response = test_client.post("/plans", json=request_data) + assert response.status_code == 201 + plan_id = response.json()["id"] + + response = test_client.patch(f"/plans/bookmark/{plan_id}") + assert response.status_code == 204 + response = test_client.patch(f"/plans/bookmark/{plan_id}") + assert response.status_code == 204 + + def test_bookmark_nonexisting_plan(self, test_client: TestClient) -> None: response = test_client.get("/plans") assert response.status_code == 200 assert len(response.json()["plans"]) == 0 - response = test_client.delete(f"/plans/{uuid.uuid4()}") + response = test_client.patch(f"/plans/bookmark/{uuid.uuid4()}") assert response.status_code == 404 @@ -63,6 +89,10 @@ def test_get_plans(self, test_client: TestClient) -> None: response = test_client.get("/plans") assert response.status_code == 500 + def test_get_plan(self, test_client: TestClient) -> None: + response = test_client.get("/plans/shared/testSlug") + assert response.status_code == 500 + def test_create_plan(self, test_client: TestClient) -> None: request_data = {"name": "Test Plan", "content": "Test Content"} @@ -72,3 +102,62 @@ def test_create_plan(self, test_client: TestClient) -> None: def test_delete_plan(self, test_client: TestClient) -> None: response = test_client.delete(f"/plans/{uuid.uuid4()}") assert response.status_code == 500 + + def test_update_plan(self, test_client: TestClient) -> None: + request_data = {"content": "Test Content"} + + response = test_client.post(f"/plans/{uuid.uuid4()}/update", json=request_data) + assert response.status_code == 500 + + def test_get_plan_history(self, test_client: TestClient) -> None: + response = test_client.get(f"/plans/history/{uuid.uuid4()}") + assert response.status_code == 500 + + +class TestPublicSlug: + def test_get_plan_by_public_slug(self, test_client: TestClient) -> None: + request_data = {"name": "Test Plan", "content": "Test Content"} + + response = test_client.post("/plans", json=request_data) + assert response.status_code == 201 + public_slug = response.json()["public_slug"] + + response = test_client.get(f"/plans/shared/{public_slug}") + assert response.status_code == 200 + data = response.json() + assert data["name"] == request_data["name"] + assert data["content"] == request_data["content"] + + def test_get_nonexisting_plan(self, test_client: TestClient) -> None: + response = test_client.get("/plans/shared/testSlug") + assert response.status_code == 404 + + +class TestPlanHistory: + def test_update_plan(self, test_client: TestClient) -> None: + request_data = {"name": "Test Plan", "content": "Test Content"} + response = test_client.post("/plans", json=request_data) + assert response.status_code == 201 + + response = test_client.get("/plans") + assert response.status_code == 200 + data = response.json() + assert len(data["plans"]) == 1 + plan_id = data["plans"][0]["id"] + + request_data = {"content": "New Test Content"} + response = test_client.post(f"/plans/{plan_id}/update", json=request_data) + assert response.status_code == 201 + data = response.json() + assert data["content"] == request_data["content"] + + def test_get_plan_history(self, test_client: TestClient) -> None: + request_data = {"name": "Test Plan", "content": "Test Content"} + response = test_client.post("/plans", json=request_data) + assert response.status_code == 201 + plan_id = response.json()["id"] + + request_data = {"content": "New Test Content"} + response = test_client.post(f"/plans/{plan_id}/update", json=request_data) + assert response.status_code == 201 + plan_id = response.json()["id"]