update: Major Overhault to all subsystems
This commit is contained in:
305
backend/crm/nextcloud_router.py
Normal file
305
backend/crm/nextcloud_router.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Nextcloud WebDAV proxy endpoints.
|
||||
|
||||
Folder convention (all paths relative to nextcloud_base_path = BellSystems/Console):
|
||||
customers/{folder_id}/media/
|
||||
customers/{folder_id}/documents/
|
||||
customers/{folder_id}/sent/
|
||||
customers/{folder_id}/received/
|
||||
|
||||
folder_id = customer.folder_id if set, else customer.id (legacy fallback).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from auth.utils import decode_access_token
|
||||
from crm import nextcloud, service
|
||||
from crm.models import MediaCreate, MediaDirection
|
||||
|
||||
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
|
||||
|
||||
DIRECTION_MAP = {
|
||||
"sent": MediaDirection.sent,
|
||||
"received": MediaDirection.received,
|
||||
"internal": MediaDirection.internal,
|
||||
"media": MediaDirection.internal,
|
||||
"documents": MediaDirection.internal,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse(
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""List immediate children of a Nextcloud folder."""
|
||||
items = await nextcloud.list_folder(path)
|
||||
return {"path": path, "items": items}
|
||||
|
||||
|
||||
@router.get("/browse-all")
|
||||
async def browse_all(
|
||||
customer_id: str = Query(..., description="Customer ID"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""
|
||||
Recursively list ALL files for a customer across all subfolders and any depth.
|
||||
Uses Depth:infinity (one WebDAV call) with automatic fallback to recursive Depth:1.
|
||||
Each file item includes a 'subfolder' key derived from its path.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
all_files = await nextcloud.list_folder_recursive(base)
|
||||
|
||||
# Tag each file with the top-level subfolder it lives under
|
||||
for item in all_files:
|
||||
parts = item["path"].split("/")
|
||||
# path looks like: customers/{nc_path}/{subfolder}/[...]/filename
|
||||
# parts[0]=customers, parts[1]={nc_path}, parts[2]={subfolder}
|
||||
item["subfolder"] = parts[2] if len(parts) > 2 else "other"
|
||||
|
||||
return {"items": all_files}
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def proxy_file(
|
||||
request: Request,
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
token: Optional[str] = Query(None, description="JWT token for browser-native requests (img src, video src, a href) that cannot send an Authorization header"),
|
||||
):
|
||||
"""
|
||||
Stream a file from Nextcloud through the backend (proxy).
|
||||
Supports HTTP Range requests so videos can be seeked and start playing immediately.
|
||||
Accepts auth via Authorization: Bearer header OR ?token= query param.
|
||||
"""
|
||||
if token is None:
|
||||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||
try:
|
||||
decode_access_token(token)
|
||||
except (JWTError, KeyError):
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
content, mime_type = await nextcloud.download_file(path)
|
||||
total = len(content)
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
# Parse "bytes=start-end"
|
||||
try:
|
||||
range_spec = range_header[6:]
|
||||
start_str, _, end_str = range_spec.partition("-")
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else total - 1
|
||||
end = min(end, total - 1)
|
||||
chunk = content[start:end + 1]
|
||||
headers = {
|
||||
"Content-Range": f"bytes {start}-{end}/{total}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Content-Type": mime_type,
|
||||
}
|
||||
return Response(content=chunk, status_code=206, headers=headers, media_type=mime_type)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=mime_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/file-put")
|
||||
async def put_file(
|
||||
request: Request,
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Overwrite a file in Nextcloud with a new body (used for TXT in-browser editing).
|
||||
Auth via ?token= query param (same pattern as /file GET).
|
||||
"""
|
||||
if token is None:
|
||||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||
try:
|
||||
decode_access_token(token)
|
||||
except (JWTError, KeyError):
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
body = await request.body()
|
||||
content_type = request.headers.get("content-type", "text/plain")
|
||||
await nextcloud.upload_file(path, body, content_type)
|
||||
return {"updated": path}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
customer_id: str = Form(...),
|
||||
subfolder: str = Form("media"), # "media" | "documents" | "sent" | "received"
|
||||
direction: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Upload a file to the customer's Nextcloud folder and record it in crm_media.
|
||||
Uses the customer's folder_id as the NC path (falls back to UUID for legacy records).
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
|
||||
target_folder = f"customers/{nc_path}/{subfolder}"
|
||||
file_path = f"{target_folder}/{file.filename}"
|
||||
|
||||
# Ensure the target subfolder exists (idempotent, fast for existing folders)
|
||||
await nextcloud.ensure_folder(target_folder)
|
||||
|
||||
# Read and upload
|
||||
content = await file.read()
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
await nextcloud.upload_file(file_path, content, mime_type)
|
||||
|
||||
# Resolve direction
|
||||
resolved_direction = None
|
||||
if direction:
|
||||
try:
|
||||
resolved_direction = MediaDirection(direction)
|
||||
except ValueError:
|
||||
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
|
||||
else:
|
||||
resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal)
|
||||
|
||||
# Save metadata record
|
||||
tag_list = [t.strip() for t in tags.split(",")] if tags else []
|
||||
media_record = await service.create_media(MediaCreate(
|
||||
customer_id=customer_id,
|
||||
filename=file.filename,
|
||||
nextcloud_path=file_path,
|
||||
mime_type=mime_type,
|
||||
direction=resolved_direction,
|
||||
tags=tag_list,
|
||||
uploaded_by=_user.name,
|
||||
))
|
||||
|
||||
return media_record
|
||||
|
||||
|
||||
@router.delete("/file")
|
||||
async def delete_file(
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""Delete a file from Nextcloud and remove the matching crm_media record if found."""
|
||||
await nextcloud.delete_file(path)
|
||||
|
||||
# Best-effort: delete the DB record if one matches this path
|
||||
media_list = await service.list_media()
|
||||
for m in media_list:
|
||||
if m.nextcloud_path == path:
|
||||
try:
|
||||
await service.delete_media(m.id)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
return {"deleted": path}
|
||||
|
||||
|
||||
@router.post("/init-customer-folder")
|
||||
async def init_customer_folder(
|
||||
customer_id: str = Form(...),
|
||||
customer_name: str = Form(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Create the standard folder structure for a customer in Nextcloud
|
||||
and write an _info.txt stub for human readability.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
for sub in ("media", "documents", "sent", "received"):
|
||||
await nextcloud.ensure_folder(f"{base}/{sub}")
|
||||
await nextcloud.write_info_file(base, customer_name, customer_id)
|
||||
return {"initialized": base}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_nextcloud_files(
|
||||
customer_id: str = Form(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Scan the customer's Nextcloud folder and register any files not yet tracked in the DB.
|
||||
Returns counts of newly synced and skipped (already tracked) files.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
# Collect all NC files recursively (handles nested folders at any depth)
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
for item in all_nc_files:
|
||||
parts = item["path"].split("/")
|
||||
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
|
||||
|
||||
# Get existing DB records for this customer
|
||||
existing = await service.list_media(customer_id=customer_id)
|
||||
tracked_paths = {m.nextcloud_path for m in existing}
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
for f in all_nc_files:
|
||||
if f["path"] in tracked_paths:
|
||||
skipped += 1
|
||||
continue
|
||||
sub = f["_subfolder"]
|
||||
direction = DIRECTION_MAP.get(sub, MediaDirection.internal)
|
||||
await service.create_media(MediaCreate(
|
||||
customer_id=customer_id,
|
||||
filename=f["filename"],
|
||||
nextcloud_path=f["path"],
|
||||
mime_type=f.get("mime_type") or "application/octet-stream",
|
||||
direction=direction,
|
||||
tags=[],
|
||||
uploaded_by="nextcloud-sync",
|
||||
))
|
||||
synced += 1
|
||||
|
||||
return {"synced": synced, "skipped": skipped}
|
||||
|
||||
|
||||
@router.post("/untrack-deleted")
|
||||
async def untrack_deleted_files(
|
||||
customer_id: str = Form(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Remove DB records for files that no longer exist in Nextcloud.
|
||||
Returns count of untracked records.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
# Collect all NC file paths recursively
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
nc_paths = {item["path"] for item in all_nc_files}
|
||||
|
||||
# Find DB records whose NC path no longer exists
|
||||
existing = await service.list_media(customer_id=customer_id)
|
||||
untracked = 0
|
||||
for m in existing:
|
||||
if m.nextcloud_path and m.nextcloud_path not in nc_paths:
|
||||
try:
|
||||
await service.delete_media(m.id)
|
||||
untracked += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"untracked": untracked}
|
||||
Reference in New Issue
Block a user