|
| 1 | +"""V2 Project Router - ID-based project management operations. |
| 2 | +
|
| 3 | +This router provides ID-based CRUD operations for projects, replacing the |
| 4 | +name-based identifiers used in v1 with direct integer ID lookups. |
| 5 | +
|
| 6 | +Key improvements: |
| 7 | +- Direct database lookups via integer primary keys |
| 8 | +- Stable references that don't change with project renames |
| 9 | +- Better performance through indexed queries |
| 10 | +- Consistent with v2 entity operations |
| 11 | +""" |
| 12 | + |
| 13 | +import os |
| 14 | +from typing import Optional |
| 15 | + |
| 16 | +from fastapi import APIRouter, HTTPException, Body, Query |
| 17 | +from loguru import logger |
| 18 | + |
| 19 | +from basic_memory.deps import ( |
| 20 | + ProjectServiceDep, |
| 21 | + ProjectRepositoryDep, |
| 22 | + ProjectIdPathDep, |
| 23 | +) |
| 24 | +from basic_memory.schemas.project_info import ( |
| 25 | + ProjectItem, |
| 26 | + ProjectStatusResponse, |
| 27 | +) |
| 28 | +from basic_memory.utils import normalize_project_path |
| 29 | + |
| 30 | +router = APIRouter(prefix="/projects", tags=["project_management-v2"]) |
| 31 | + |
| 32 | + |
| 33 | +@router.get("/{project_id}", response_model=ProjectItem) |
| 34 | +async def get_project_by_id( |
| 35 | + project_id: ProjectIdPathDep, |
| 36 | + project_repository: ProjectRepositoryDep, |
| 37 | +) -> ProjectItem: |
| 38 | + """Get project by its numeric ID. |
| 39 | +
|
| 40 | + This is the primary project retrieval method in v2, using direct database |
| 41 | + lookups for maximum performance. |
| 42 | +
|
| 43 | + Args: |
| 44 | + project_id: Numeric project ID |
| 45 | +
|
| 46 | + Returns: |
| 47 | + Project information |
| 48 | +
|
| 49 | + Raises: |
| 50 | + HTTPException: 404 if project not found |
| 51 | +
|
| 52 | + Example: |
| 53 | + GET /v2/projects/3 |
| 54 | + """ |
| 55 | + logger.info(f"API v2 request: get_project_by_id for project_id={project_id}") |
| 56 | + |
| 57 | + project = await project_repository.get_by_id(project_id) |
| 58 | + if not project: |
| 59 | + raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found") |
| 60 | + |
| 61 | + return ProjectItem( |
| 62 | + id=project.id, |
| 63 | + name=project.name, |
| 64 | + path=normalize_project_path(project.path), |
| 65 | + is_default=project.is_default or False, |
| 66 | + ) |
| 67 | + |
| 68 | + |
| 69 | +@router.patch("/{project_id}", response_model=ProjectStatusResponse) |
| 70 | +async def update_project_by_id( |
| 71 | + project_id: ProjectIdPathDep, |
| 72 | + project_service: ProjectServiceDep, |
| 73 | + project_repository: ProjectRepositoryDep, |
| 74 | + path: Optional[str] = Body(None, description="New absolute path for the project"), |
| 75 | + is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"), |
| 76 | +) -> ProjectStatusResponse: |
| 77 | + """Update a project's information by ID. |
| 78 | +
|
| 79 | + Args: |
| 80 | + project_id: Numeric project ID |
| 81 | + path: Optional new absolute path for the project |
| 82 | + is_active: Optional status update for the project |
| 83 | +
|
| 84 | + Returns: |
| 85 | + Response confirming the project was updated |
| 86 | +
|
| 87 | + Raises: |
| 88 | + HTTPException: 400 if validation fails, 404 if project not found |
| 89 | +
|
| 90 | + Example: |
| 91 | + PATCH /v2/projects/3 |
| 92 | + {"path": "/new/path"} |
| 93 | + """ |
| 94 | + logger.info(f"API v2 request: update_project_by_id for project_id={project_id}") |
| 95 | + |
| 96 | + try: |
| 97 | + # Validate that path is absolute if provided |
| 98 | + if path and not os.path.isabs(path): |
| 99 | + raise HTTPException(status_code=400, detail="Path must be absolute") |
| 100 | + |
| 101 | + # Get original project info for the response |
| 102 | + old_project = await project_repository.get_by_id(project_id) |
| 103 | + if not old_project: |
| 104 | + raise HTTPException( |
| 105 | + status_code=404, detail=f"Project with ID {project_id} not found" |
| 106 | + ) |
| 107 | + |
| 108 | + old_project_info = ProjectItem( |
| 109 | + id=old_project.id, |
| 110 | + name=old_project.name, |
| 111 | + path=old_project.path, |
| 112 | + is_default=old_project.is_default or False, |
| 113 | + ) |
| 114 | + |
| 115 | + # Update using project name (service layer still uses names internally) |
| 116 | + if path: |
| 117 | + await project_service.move_project(old_project.name, path) |
| 118 | + elif is_active is not None: |
| 119 | + await project_service.update_project(old_project.name, is_active=is_active) |
| 120 | + |
| 121 | + # Get updated project info |
| 122 | + updated_project = await project_repository.get_by_id(project_id) |
| 123 | + if not updated_project: |
| 124 | + raise HTTPException( |
| 125 | + status_code=404, detail=f"Project with ID {project_id} not found after update" |
| 126 | + ) |
| 127 | + |
| 128 | + return ProjectStatusResponse( |
| 129 | + message=f"Project '{updated_project.name}' updated successfully", |
| 130 | + status="success", |
| 131 | + default=(old_project.name == project_service.default_project), |
| 132 | + old_project=old_project_info, |
| 133 | + new_project=ProjectItem( |
| 134 | + id=updated_project.id, |
| 135 | + name=updated_project.name, |
| 136 | + path=updated_project.path, |
| 137 | + is_default=updated_project.is_default or False, |
| 138 | + ), |
| 139 | + ) |
| 140 | + except ValueError as e: |
| 141 | + raise HTTPException(status_code=400, detail=str(e)) |
| 142 | + |
| 143 | + |
| 144 | +@router.delete("/{project_id}", response_model=ProjectStatusResponse) |
| 145 | +async def delete_project_by_id( |
| 146 | + project_id: ProjectIdPathDep, |
| 147 | + project_service: ProjectServiceDep, |
| 148 | + project_repository: ProjectRepositoryDep, |
| 149 | + delete_notes: bool = Query( |
| 150 | + False, description="If True, delete project directory from filesystem" |
| 151 | + ), |
| 152 | +) -> ProjectStatusResponse: |
| 153 | + """Delete a project by ID. |
| 154 | +
|
| 155 | + Args: |
| 156 | + project_id: Numeric project ID |
| 157 | + delete_notes: If True, delete the project directory from the filesystem |
| 158 | +
|
| 159 | + Returns: |
| 160 | + Response confirming the project was deleted |
| 161 | +
|
| 162 | + Raises: |
| 163 | + HTTPException: 400 if trying to delete default project, 404 if not found |
| 164 | +
|
| 165 | + Example: |
| 166 | + DELETE /v2/projects/3?delete_notes=false |
| 167 | + """ |
| 168 | + logger.info( |
| 169 | + f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}" |
| 170 | + ) |
| 171 | + |
| 172 | + try: |
| 173 | + old_project = await project_repository.get_by_id(project_id) |
| 174 | + if not old_project: |
| 175 | + raise HTTPException( |
| 176 | + status_code=404, detail=f"Project with ID {project_id} not found" |
| 177 | + ) |
| 178 | + |
| 179 | + # Check if trying to delete the default project |
| 180 | + if old_project.name == project_service.default_project: |
| 181 | + available_projects = await project_service.list_projects() |
| 182 | + other_projects = [p.name for p in available_projects if p.id != project_id] |
| 183 | + detail = f"Cannot delete default project '{old_project.name}'. " |
| 184 | + if other_projects: |
| 185 | + detail += ( |
| 186 | + f"Set another project as default first. Available: {', '.join(other_projects)}" |
| 187 | + ) |
| 188 | + else: |
| 189 | + detail += "This is the only project in your configuration." |
| 190 | + raise HTTPException(status_code=400, detail=detail) |
| 191 | + |
| 192 | + # Delete using project name (service layer still uses names internally) |
| 193 | + await project_service.remove_project(old_project.name, delete_notes=delete_notes) |
| 194 | + |
| 195 | + return ProjectStatusResponse( |
| 196 | + message=f"Project '{old_project.name}' removed successfully", |
| 197 | + status="success", |
| 198 | + default=False, |
| 199 | + old_project=ProjectItem( |
| 200 | + id=old_project.id, |
| 201 | + name=old_project.name, |
| 202 | + path=old_project.path, |
| 203 | + is_default=old_project.is_default or False, |
| 204 | + ), |
| 205 | + new_project=None, |
| 206 | + ) |
| 207 | + except ValueError as e: |
| 208 | + raise HTTPException(status_code=400, detail=str(e)) |
| 209 | + |
| 210 | + |
| 211 | +@router.put("/{project_id}/default", response_model=ProjectStatusResponse) |
| 212 | +async def set_default_project_by_id( |
| 213 | + project_id: ProjectIdPathDep, |
| 214 | + project_service: ProjectServiceDep, |
| 215 | + project_repository: ProjectRepositoryDep, |
| 216 | +) -> ProjectStatusResponse: |
| 217 | + """Set a project as the default project by ID. |
| 218 | +
|
| 219 | + Args: |
| 220 | + project_id: Numeric project ID to set as default |
| 221 | +
|
| 222 | + Returns: |
| 223 | + Response confirming the project was set as default |
| 224 | +
|
| 225 | + Raises: |
| 226 | + HTTPException: 404 if project not found |
| 227 | +
|
| 228 | + Example: |
| 229 | + PUT /v2/projects/3/default |
| 230 | + """ |
| 231 | + logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}") |
| 232 | + |
| 233 | + try: |
| 234 | + # Get the old default project |
| 235 | + default_name = project_service.default_project |
| 236 | + default_project = await project_service.get_project(default_name) |
| 237 | + if not default_project: |
| 238 | + raise HTTPException( |
| 239 | + status_code=404, detail=f"Default Project: '{default_name}' does not exist" |
| 240 | + ) |
| 241 | + |
| 242 | + # Get the new default project |
| 243 | + new_default_project = await project_repository.get_by_id(project_id) |
| 244 | + if not new_default_project: |
| 245 | + raise HTTPException( |
| 246 | + status_code=404, detail=f"Project with ID {project_id} not found" |
| 247 | + ) |
| 248 | + |
| 249 | + # Set as default using project name (service layer still uses names internally) |
| 250 | + await project_service.set_default_project(new_default_project.name) |
| 251 | + |
| 252 | + return ProjectStatusResponse( |
| 253 | + message=f"Project '{new_default_project.name}' set as default successfully", |
| 254 | + status="success", |
| 255 | + default=True, |
| 256 | + old_project=ProjectItem( |
| 257 | + id=default_project.id, |
| 258 | + name=default_name, |
| 259 | + path=default_project.path, |
| 260 | + is_default=False, |
| 261 | + ), |
| 262 | + new_project=ProjectItem( |
| 263 | + id=new_default_project.id, |
| 264 | + name=new_default_project.name, |
| 265 | + path=new_default_project.path, |
| 266 | + is_default=True, |
| 267 | + ), |
| 268 | + ) |
| 269 | + except ValueError as e: |
| 270 | + raise HTTPException(status_code=400, detail=str(e)) |
0 commit comments