Skip to content
This repository was archived by the owner on Mar 18, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a6f4968
add sharing logic
pheonix8 May 23, 2025
5adc29a
updated backend
pheonix8 May 24, 2025
7363620
poc history logic
pheonix8 May 25, 2025
e9a75fd
added tests for history feature
pheonix8 May 26, 2025
7be1df8
use bookmark instead of is_favorite
Janooski May 27, 2025
8769ac9
add plan,user import
Janooski May 15, 2025
bc8ff83
start bookmark plans feature
Janooski May 15, 2025
5dcb882
refactor to use bookmark
Janooski May 19, 2025
4ed4219
run linter
Janooski May 19, 2025
6b87fea
add tests
Janooski May 19, 2025
b0097f5
update tests
pheonix8 May 17, 2025
2913817
fix linting errors
pheonix8 May 17, 2025
3b992b7
rebase main into feature/bookmark-plans
Janooski May 15, 2025
b43c7f4
refactor to use bookmark
Janooski May 19, 2025
f8cdde6
rebase main into feature/bookmark-plans
Janooski May 19, 2025
ff2c678
fixed merge conflicts
Janooski May 26, 2025
b39296a
fix router
Janooski May 26, 2025
fa00c20
changed router.patch to return 204
Janooski May 26, 2025
bb3703f
uv run ruff passing
Janooski May 26, 2025
54c1047
bookmark_plan returns none, add TestBadDB test
Janooski May 27, 2025
db562ea
run linter
Janooski May 19, 2025
01cf704
update tests
pheonix8 May 17, 2025
ecdb9d1
fix linting errors
pheonix8 May 17, 2025
25ece44
rebase main into feature/bookmark-plans
Janooski May 19, 2025
5dd8563
fixed merge conflicts
Janooski May 26, 2025
0b46097
changed router.patch to return 204
Janooski May 26, 2025
9154b7a
error 401
Janooski Jun 1, 2025
56cacf2
correct bookmark plan
Janooski Jun 1, 2025
01bb3ce
ran linter and pytests
Janooski Jun 1, 2025
cb7cd7c
uv run ruff format
Janooski Jun 1, 2025
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
3 changes: 2 additions & 1 deletion service/app/models/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
47 changes: 45 additions & 2 deletions service/app/routers/plan_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}


Expand All @@ -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:
Expand All @@ -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
7 changes: 6 additions & 1 deletion service/app/schemas/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ 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


class PlanCreate(SQLModel):
name: str
content: str


class PlanUpdate(SQLModel):
content: str
82 changes: 77 additions & 5 deletions service/app/services/plan_service.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,105 @@
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)
return PlanRead.model_validate(created_plan)


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)
2 changes: 1 addition & 1 deletion service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ skip_empty = true
omit = [
"app/middlewares/*",
"app/create_tables.py",
]
]
3 changes: 2 additions & 1 deletion service/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
91 changes: 90 additions & 1 deletion service/tests/test_plan_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"}

Expand All @@ -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"]