external_links.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. """API routes for external sidebar links."""
  2. import logging
  3. import uuid
  4. from pathlib import Path
  5. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  6. from fastapi.responses import FileResponse
  7. from sqlalchemy import select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
  10. from backend.app.core.config import settings as app_settings
  11. from backend.app.core.database import get_db
  12. from backend.app.core.permissions import Permission
  13. from backend.app.models.external_link import ExternalLink
  14. from backend.app.models.user import User
  15. from backend.app.schemas.external_link import (
  16. ExternalLinkCreate,
  17. ExternalLinkReorder,
  18. ExternalLinkResponse,
  19. ExternalLinkUpdate,
  20. )
  21. # Directory for storing custom icons
  22. ICONS_DIR = app_settings.base_dir / "icons"
  23. ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
  24. logger = logging.getLogger(__name__)
  25. router = APIRouter(prefix="/external-links", tags=["external-links"])
  26. @router.get("/", response_model=list[ExternalLinkResponse])
  27. async def list_external_links(
  28. db: AsyncSession = Depends(get_db),
  29. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
  30. ):
  31. """List all external links ordered by sort_order."""
  32. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
  33. links = result.scalars().all()
  34. return links
  35. @router.post("/", response_model=ExternalLinkResponse)
  36. async def create_external_link(
  37. link_data: ExternalLinkCreate,
  38. db: AsyncSession = Depends(get_db),
  39. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
  40. ):
  41. """Create a new external link."""
  42. # Get the highest sort_order to place new link at end
  43. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1))
  44. last_link = result.scalar_one_or_none()
  45. next_order = (last_link.sort_order + 1) if last_link else 0
  46. link = ExternalLink(
  47. name=link_data.name,
  48. url=link_data.url,
  49. icon=link_data.icon,
  50. sort_order=next_order,
  51. )
  52. db.add(link)
  53. await db.commit()
  54. await db.refresh(link)
  55. logger.info("Created external link: %s -> %s", link.name, link.url)
  56. return link
  57. @router.get("/{link_id}", response_model=ExternalLinkResponse)
  58. async def get_external_link(
  59. link_id: int,
  60. db: AsyncSession = Depends(get_db),
  61. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
  62. ):
  63. """Get a specific external link."""
  64. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  65. link = result.scalar_one_or_none()
  66. if not link:
  67. raise HTTPException(status_code=404, detail="External link not found")
  68. return link
  69. @router.patch("/{link_id}", response_model=ExternalLinkResponse)
  70. async def update_external_link(
  71. link_id: int,
  72. update_data: ExternalLinkUpdate,
  73. db: AsyncSession = Depends(get_db),
  74. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  75. ):
  76. """Update an external link."""
  77. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  78. link = result.scalar_one_or_none()
  79. if not link:
  80. raise HTTPException(status_code=404, detail="External link not found")
  81. # Update only provided fields
  82. update_dict = update_data.model_dump(exclude_unset=True)
  83. for key, value in update_dict.items():
  84. setattr(link, key, value)
  85. await db.commit()
  86. await db.refresh(link)
  87. logger.info("Updated external link: %s", link.name)
  88. return link
  89. @router.delete("/{link_id}")
  90. async def delete_external_link(
  91. link_id: int,
  92. db: AsyncSession = Depends(get_db),
  93. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
  94. ):
  95. """Delete an external link."""
  96. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  97. link = result.scalar_one_or_none()
  98. if not link:
  99. raise HTTPException(status_code=404, detail="External link not found")
  100. name = link.name
  101. await db.delete(link)
  102. await db.commit()
  103. logger.info("Deleted external link: %s", name)
  104. return {"message": f"External link '{name}' deleted"}
  105. @router.put("/reorder", response_model=list[ExternalLinkResponse])
  106. async def reorder_external_links(
  107. reorder_data: ExternalLinkReorder,
  108. db: AsyncSession = Depends(get_db),
  109. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  110. ):
  111. """Update the sort order of external links."""
  112. # Update sort_order for each link based on position in the list
  113. for index, link_id in enumerate(reorder_data.ids):
  114. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  115. link = result.scalar_one_or_none()
  116. if link:
  117. link.sort_order = index
  118. await db.commit()
  119. # Return updated list
  120. result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
  121. links = result.scalars().all()
  122. logger.info("Reordered %s external links", len(reorder_data.ids))
  123. return links
  124. @router.post("/{link_id}/icon", response_model=ExternalLinkResponse)
  125. async def upload_icon(
  126. link_id: int,
  127. file: UploadFile = File(...),
  128. db: AsyncSession = Depends(get_db),
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  130. ):
  131. """Upload a custom icon for an external link."""
  132. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  133. link = result.scalar_one_or_none()
  134. if not link:
  135. raise HTTPException(status_code=404, detail="External link not found")
  136. # Validate file extension
  137. if not file.filename:
  138. raise HTTPException(status_code=400, detail="No filename provided")
  139. ext = Path(file.filename).suffix.lower()
  140. if ext not in ALLOWED_EXTENSIONS:
  141. raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
  142. # Create icons directory if it doesn't exist
  143. ICONS_DIR.mkdir(parents=True, exist_ok=True)
  144. # Delete old custom icon if exists
  145. if link.custom_icon:
  146. old_path = ICONS_DIR / link.custom_icon
  147. if old_path.exists():
  148. old_path.unlink()
  149. # Generate unique filename
  150. filename = f"{uuid.uuid4().hex}{ext}"
  151. filepath = ICONS_DIR / filename
  152. # Save file
  153. content = await file.read()
  154. with open(filepath, "wb") as f:
  155. f.write(content)
  156. # Update link
  157. link.custom_icon = filename
  158. await db.commit()
  159. await db.refresh(link)
  160. logger.info("Uploaded custom icon for link %s: %s", link.name, filename)
  161. return link
  162. @router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
  163. async def delete_icon(
  164. link_id: int,
  165. db: AsyncSession = Depends(get_db),
  166. _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
  167. ):
  168. """Delete the custom icon for an external link."""
  169. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  170. link = result.scalar_one_or_none()
  171. if not link:
  172. raise HTTPException(status_code=404, detail="External link not found")
  173. if link.custom_icon:
  174. filepath = ICONS_DIR / link.custom_icon
  175. if filepath.exists():
  176. filepath.unlink()
  177. link.custom_icon = None
  178. await db.commit()
  179. await db.refresh(link)
  180. logger.info("Deleted custom icon for link %s", link.name)
  181. return link
  182. @router.get("/{link_id}/icon")
  183. async def get_icon(
  184. link_id: int,
  185. db: AsyncSession = Depends(get_db),
  186. _: None = RequireCameraStreamTokenIfAuthEnabled,
  187. ):
  188. """Get the custom icon for an external link.
  189. Requires a stream token query param (?token=xxx) when auth is enabled.
  190. """
  191. result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
  192. link = result.scalar_one_or_none()
  193. if not link:
  194. raise HTTPException(status_code=404, detail="External link not found")
  195. if not link.custom_icon:
  196. raise HTTPException(status_code=404, detail="No custom icon set")
  197. filepath = ICONS_DIR / link.custom_icon
  198. if not filepath.exists():
  199. raise HTTPException(status_code=404, detail="Icon file not found")
  200. return FileResponse(filepath)