fix: Bugs created after the overhaul, performance and layout fixes
This commit is contained in:
@@ -10,6 +10,7 @@ Folder convention (all paths relative to nextcloud_base_path = BellSystems/Conso
|
||||
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 fastapi.responses import StreamingResponse
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError
|
||||
@@ -17,7 +18,9 @@ from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from auth.utils import decode_access_token
|
||||
from crm import nextcloud, service
|
||||
from config import settings
|
||||
from crm.models import MediaCreate, MediaDirection
|
||||
from crm.thumbnails import generate_thumbnail
|
||||
|
||||
router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"])
|
||||
|
||||
@@ -30,6 +33,29 @@ DIRECTION_MAP = {
|
||||
}
|
||||
|
||||
|
||||
@router.get("/web-url")
|
||||
async def get_web_url(
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""
|
||||
Return the Nextcloud Files web-UI URL for a given file path.
|
||||
Opens the parent folder with the file highlighted.
|
||||
"""
|
||||
if not settings.nextcloud_url:
|
||||
raise HTTPException(status_code=503, detail="Nextcloud not configured")
|
||||
base = settings.nextcloud_base_path.strip("/")
|
||||
# path is relative to base, e.g. "customers/abc/media/photo.jpg"
|
||||
parts = path.rsplit("/", 1)
|
||||
folder_rel = parts[0] if len(parts) == 2 else ""
|
||||
filename = parts[-1]
|
||||
nc_dir = f"/{base}/{folder_rel}" if folder_rel else f"/{base}"
|
||||
from urllib.parse import urlencode, quote
|
||||
qs = urlencode({"dir": nc_dir, "scrollto": filename})
|
||||
url = f"{settings.nextcloud_url.rstrip('/')}/index.php/apps/files/?{qs}"
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse(
|
||||
path: str = Query(..., description="Path relative to nextcloud_base_path"),
|
||||
@@ -56,6 +82,14 @@ async def browse_all(
|
||||
|
||||
all_files = await nextcloud.list_folder_recursive(base)
|
||||
|
||||
# Exclude _info.txt stubs — human-readable only, should never appear in the UI.
|
||||
# .thumbs/ files are kept: the frontend needs them to build the thumbnail map
|
||||
# (it already filters them out of the visible file list itself).
|
||||
all_files = [
|
||||
f for f in all_files
|
||||
if not f["path"].endswith("/_info.txt")
|
||||
]
|
||||
|
||||
# Tag each file with the top-level subfolder it lives under
|
||||
for item in all_files:
|
||||
parts = item["path"].split("/")
|
||||
@@ -84,33 +118,54 @@ async def proxy_file(
|
||||
except (JWTError, KeyError):
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
content, mime_type = await nextcloud.download_file(path)
|
||||
total = len(content)
|
||||
|
||||
# Forward the Range header to Nextcloud so we get a true partial response
|
||||
# without buffering the whole file into memory.
|
||||
nc_url = nextcloud._full_url(path)
|
||||
nc_auth = nextcloud._auth()
|
||||
forward_headers = {}
|
||||
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
|
||||
if range_header:
|
||||
forward_headers["Range"] = range_header
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
import httpx as _httpx
|
||||
|
||||
# Use a dedicated streaming client — httpx.stream() keeps the connection open
|
||||
# for the lifetime of the generator, so we can't reuse the shared persistent client.
|
||||
# We enter the stream context here to get headers immediately (no body buffering),
|
||||
# then hand the body iterator to StreamingResponse.
|
||||
stream_client = _httpx.AsyncClient(timeout=None, follow_redirects=True)
|
||||
nc_resp_ctx = stream_client.stream("GET", nc_url, auth=nc_auth, headers=forward_headers)
|
||||
nc_resp = await nc_resp_ctx.__aenter__()
|
||||
|
||||
if nc_resp.status_code == 404:
|
||||
await nc_resp_ctx.__aexit__(None, None, None)
|
||||
await stream_client.aclose()
|
||||
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
|
||||
if nc_resp.status_code not in (200, 206):
|
||||
await nc_resp_ctx.__aexit__(None, None, None)
|
||||
await stream_client.aclose()
|
||||
raise HTTPException(status_code=502, detail=f"Nextcloud returned {nc_resp.status_code}")
|
||||
|
||||
mime_type = nc_resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
|
||||
|
||||
resp_headers = {"Accept-Ranges": "bytes"}
|
||||
for h in ("content-range", "content-length"):
|
||||
if h in nc_resp.headers:
|
||||
resp_headers[h.title()] = nc_resp.headers[h]
|
||||
|
||||
async def _stream():
|
||||
try:
|
||||
async for chunk in nc_resp.aiter_bytes(chunk_size=64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
await nc_resp_ctx.__aexit__(None, None, None)
|
||||
await stream_client.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
status_code=nc_resp.status_code,
|
||||
media_type=mime_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(total)},
|
||||
headers=resp_headers,
|
||||
)
|
||||
|
||||
|
||||
@@ -164,6 +219,24 @@ async def upload_file(
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
await nextcloud.upload_file(file_path, content, mime_type)
|
||||
|
||||
# Generate and upload thumbnail (best-effort, non-blocking)
|
||||
# Always stored as {stem}.jpg regardless of source extension so the thumb
|
||||
# filename is unambiguous and the existence check can never false-positive.
|
||||
thumb_path = None
|
||||
try:
|
||||
thumb_bytes = generate_thumbnail(content, mime_type, file.filename)
|
||||
if thumb_bytes:
|
||||
thumb_folder = f"{target_folder}/.thumbs"
|
||||
stem = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
|
||||
thumb_filename = f"{stem}.jpg"
|
||||
thumb_nc_path = f"{thumb_folder}/{thumb_filename}"
|
||||
await nextcloud.ensure_folder(thumb_folder)
|
||||
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
|
||||
thumb_path = thumb_nc_path
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Thumbnail generation failed for %s: %s", file.filename, e)
|
||||
|
||||
# Resolve direction
|
||||
resolved_direction = None
|
||||
if direction:
|
||||
@@ -184,6 +257,7 @@ async def upload_file(
|
||||
direction=resolved_direction,
|
||||
tags=tag_list,
|
||||
uploaded_by=_user.name,
|
||||
thumbnail_path=thumb_path,
|
||||
))
|
||||
|
||||
return media_record
|
||||
@@ -244,6 +318,11 @@ async def sync_nextcloud_files(
|
||||
|
||||
# Collect all NC files recursively (handles nested folders at any depth)
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
# Skip .thumbs/ folder contents and the _info.txt stub — these are internal
|
||||
all_nc_files = [
|
||||
f for f in all_nc_files
|
||||
if "/.thumbs/" not in f["path"] and not f["path"].endswith("/_info.txt")
|
||||
]
|
||||
for item in all_nc_files:
|
||||
parts = item["path"].split("/")
|
||||
item["_subfolder"] = parts[2] if len(parts) > 2 else "media"
|
||||
@@ -274,6 +353,105 @@ async def sync_nextcloud_files(
|
||||
return {"synced": synced, "skipped": skipped}
|
||||
|
||||
|
||||
@router.post("/generate-thumbs")
|
||||
async def generate_thumbs(
|
||||
customer_id: str = Form(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Scan all customer files in Nextcloud and generate thumbnails for any file
|
||||
that doesn't already have one in the corresponding .thumbs/ sub-folder.
|
||||
Skips files inside .thumbs/ itself and file types that can't be thumbnailed.
|
||||
Returns counts of generated, skipped (already exists), and failed files.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
|
||||
# Build a set of existing thumb paths for O(1) lookup
|
||||
existing_thumbs = {
|
||||
f["path"] for f in all_nc_files if "/.thumbs/" in f["path"]
|
||||
}
|
||||
|
||||
# Only process real files (not thumbs themselves)
|
||||
candidates = [f for f in all_nc_files if "/.thumbs/" not in f["path"]]
|
||||
|
||||
generated = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for f in candidates:
|
||||
# Derive where the thumb would live
|
||||
path = f["path"] # e.g. customers/{nc_path}/{subfolder}/photo.jpg
|
||||
parts = path.rsplit("/", 1)
|
||||
if len(parts) != 2:
|
||||
skipped += 1
|
||||
continue
|
||||
parent_folder, filename = parts
|
||||
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
||||
thumb_filename = f"{stem}.jpg"
|
||||
thumb_nc_path = f"{parent_folder}/.thumbs/{thumb_filename}"
|
||||
|
||||
if thumb_nc_path in existing_thumbs:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Download the file, generate thumb, upload
|
||||
try:
|
||||
content, mime_type = await nextcloud.download_file(path)
|
||||
thumb_bytes = generate_thumbnail(content, mime_type, filename)
|
||||
if not thumb_bytes:
|
||||
skipped += 1 # unsupported file type
|
||||
continue
|
||||
thumb_folder = f"{parent_folder}/.thumbs"
|
||||
await nextcloud.ensure_folder(thumb_folder)
|
||||
await nextcloud.upload_file(thumb_nc_path, thumb_bytes, "image/jpeg")
|
||||
generated += 1
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Thumb gen failed for %s: %s", path, e)
|
||||
failed += 1
|
||||
|
||||
return {"generated": generated, "skipped": skipped, "failed": failed}
|
||||
|
||||
|
||||
@router.post("/clear-thumbs")
|
||||
async def clear_thumbs(
|
||||
customer_id: str = Form(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
"""
|
||||
Delete all .thumbs sub-folders for a customer across all subfolders.
|
||||
This lets you regenerate thumbnails from scratch.
|
||||
Returns count of .thumbs folders deleted.
|
||||
"""
|
||||
customer = service.get_customer(customer_id)
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
|
||||
# Collect unique .thumbs folder paths
|
||||
thumb_folders = set()
|
||||
for f in all_nc_files:
|
||||
if "/.thumbs/" in f["path"]:
|
||||
folder = f["path"].split("/.thumbs/")[0] + "/.thumbs"
|
||||
thumb_folders.add(folder)
|
||||
|
||||
deleted = 0
|
||||
for folder in thumb_folders:
|
||||
try:
|
||||
await nextcloud.delete_file(folder)
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to delete .thumbs folder %s: %s", folder, e)
|
||||
|
||||
return {"deleted_folders": deleted}
|
||||
|
||||
|
||||
@router.post("/untrack-deleted")
|
||||
async def untrack_deleted_files(
|
||||
customer_id: str = Form(...),
|
||||
@@ -287,15 +465,22 @@ async def untrack_deleted_files(
|
||||
nc_path = service.get_customer_nc_path(customer)
|
||||
base = f"customers/{nc_path}"
|
||||
|
||||
# Collect all NC file paths recursively
|
||||
# Collect all NC file paths recursively (excluding thumbs and info stub)
|
||||
all_nc_files = await nextcloud.list_folder_recursive(base)
|
||||
nc_paths = {item["path"] for item in all_nc_files}
|
||||
nc_paths = {
|
||||
item["path"] for item in all_nc_files
|
||||
if "/.thumbs/" not in item["path"] and not item["path"].endswith("/_info.txt")
|
||||
}
|
||||
|
||||
# Find DB records whose NC path no longer exists
|
||||
# Find DB records whose NC path no longer exists, OR that are internal files
|
||||
# (_info.txt / .thumbs/) which should never have been tracked in the first place.
|
||||
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:
|
||||
is_internal = m.nextcloud_path and (
|
||||
"/.thumbs/" in m.nextcloud_path or m.nextcloud_path.endswith("/_info.txt")
|
||||
)
|
||||
if m.nextcloud_path and (is_internal or m.nextcloud_path not in nc_paths):
|
||||
try:
|
||||
await service.delete_media(m.id)
|
||||
untracked += 1
|
||||
|
||||
Reference in New Issue
Block a user