update: Major Overhaul to all subsystems

This commit is contained in:
2026-03-07 11:32:18 +02:00
parent 810e81b323
commit c62188fda6
107 changed files with 20414 additions and 929 deletions

314
backend/crm/nextcloud.py Normal file
View File

@@ -0,0 +1,314 @@
"""
Nextcloud WebDAV client.
All paths passed to these functions are relative to `settings.nextcloud_base_path`.
The full WebDAV URL is:
{nextcloud_url}/remote.php/dav/files/{username}/{base_path}/{relative_path}
"""
import xml.etree.ElementTree as ET
from typing import List
from urllib.parse import unquote
import httpx
from fastapi import HTTPException
from config import settings
DAV_NS = "DAV:"
# Default timeout for all Nextcloud WebDAV requests (seconds)
_TIMEOUT = 60.0
# Shared async client — reuses TCP connections across requests so Nextcloud
# doesn't see rapid connection bursts that trigger brute-force throttling.
_http_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": "BellSystems-CP/1.0"},
)
return _http_client
async def close_client() -> None:
"""Close the shared HTTP client. Call this on application shutdown."""
global _http_client
if _http_client and not _http_client.is_closed:
await _http_client.aclose()
_http_client = None
async def keepalive_ping() -> None:
"""
Send a lightweight PROPFIND Depth:0 to the Nextcloud base folder to keep
the TCP connection alive. Safe to call even if Nextcloud is not configured.
"""
if not settings.nextcloud_url:
return
try:
url = _base_url()
client = _get_client()
await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
except Exception as e:
print(f"[NEXTCLOUD KEEPALIVE] ping failed: {e}")
def _dav_user() -> str:
"""The username used in the WebDAV URL path (may differ from the login username)."""
return settings.nextcloud_dav_user or settings.nextcloud_username
def _base_url() -> str:
if not settings.nextcloud_url:
raise HTTPException(status_code=503, detail="Nextcloud not configured")
return (
f"{settings.nextcloud_url.rstrip('/')}"
f"/remote.php/dav/files/{_dav_user()}"
f"/{settings.nextcloud_base_path}"
)
def _auth() -> tuple[str, str]:
return (settings.nextcloud_username, settings.nextcloud_password)
def _full_url(relative_path: str) -> str:
"""Build full WebDAV URL for a relative path."""
path = relative_path.strip("/")
base = _base_url()
return f"{base}/{path}" if path else base
def _parse_propfind(xml_bytes: bytes, base_path_prefix: str) -> List[dict]:
"""
Parse a PROPFIND XML response.
Returns list of file/folder entries, skipping the root itself.
"""
root = ET.fromstring(xml_bytes)
results = []
# The prefix we need to strip from D:href to get the relative path back
# href looks like: /remote.php/dav/files/user/BellSystems/Console/customers/abc/
dav_prefix = (
f"/remote.php/dav/files/{_dav_user()}"
f"/{settings.nextcloud_base_path}/"
)
for response in root.findall(f"{{{DAV_NS}}}response"):
href_el = response.find(f"{{{DAV_NS}}}href")
if href_el is None:
continue
href = unquote(href_el.text or "")
# Strip DAV prefix to get relative path within base_path
if href.startswith(dav_prefix):
rel = href[len(dav_prefix):].rstrip("/")
else:
rel = href
# Skip the folder itself (the root of the PROPFIND request)
if rel == base_path_prefix.strip("/"):
continue
propstat = response.find(f"{{{DAV_NS}}}propstat")
if propstat is None:
continue
prop = propstat.find(f"{{{DAV_NS}}}prop")
if prop is None:
continue
# is_dir: resourcetype contains D:collection
resource_type = prop.find(f"{{{DAV_NS}}}resourcetype")
is_dir = resource_type is not None and resource_type.find(f"{{{DAV_NS}}}collection") is not None
content_type_el = prop.find(f"{{{DAV_NS}}}getcontenttype")
mime_type = content_type_el.text if content_type_el is not None else (
"inode/directory" if is_dir else "application/octet-stream"
)
size_el = prop.find(f"{{{DAV_NS}}}getcontentlength")
size = int(size_el.text) if size_el is not None and size_el.text else 0
modified_el = prop.find(f"{{{DAV_NS}}}getlastmodified")
last_modified = modified_el.text if modified_el is not None else None
filename = rel.split("/")[-1] if rel else ""
results.append({
"filename": filename,
"path": rel,
"mime_type": mime_type,
"size": size,
"last_modified": last_modified,
"is_dir": is_dir,
})
return results
async def ensure_folder(relative_path: str) -> None:
"""
Create a folder (and all parents) in Nextcloud via MKCOL.
Includes the base_path segments so the full hierarchy is created from scratch.
Silently succeeds if folders already exist.
"""
# Build the complete path list: base_path segments + relative_path segments
base_parts = settings.nextcloud_base_path.strip("/").split("/")
rel_parts = relative_path.strip("/").split("/") if relative_path.strip("/") else []
all_parts = base_parts + rel_parts
dav_root = f"{settings.nextcloud_url.rstrip('/')}/remote.php/dav/files/{_dav_user()}"
client = _get_client()
built = ""
for part in all_parts:
built = f"{built}/{part}" if built else part
url = f"{dav_root}/{built}"
resp = await client.request("MKCOL", url, auth=_auth())
# 201 = created, 405/409 = already exists — both are fine
if resp.status_code not in (201, 405, 409):
raise HTTPException(
status_code=502,
detail=f"Failed to create Nextcloud folder '{built}': {resp.status_code}",
)
async def write_info_file(customer_folder: str, customer_name: str, customer_id: str) -> None:
"""Write a _info.txt stub into a new customer folder for human browsability."""
content = f"Customer: {customer_name}\nID: {customer_id}\n"
await upload_file(
f"{customer_folder}/_info.txt",
content.encode("utf-8"),
"text/plain",
)
_PROPFIND_BODY = b"""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getcontenttype/>
<D:getcontentlength/>
<D:getlastmodified/>
</D:prop>
</D:propfind>"""
async def list_folder(relative_path: str) -> List[dict]:
"""
PROPFIND at depth=1 to list a folder's immediate children.
relative_path is relative to nextcloud_base_path.
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if resp.status_code == 404:
return []
if resp.status_code not in (207, 200):
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
return _parse_propfind(resp.content, relative_path)
async def list_folder_recursive(relative_path: str) -> List[dict]:
"""
Recursively list ALL files under a folder (any depth).
Tries Depth:infinity first (single call). Falls back to manual recursion
via Depth:1 if the server returns 403/400 (some servers disable infinity).
Returns only file entries (is_dir=False).
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request(
"PROPFIND",
url,
auth=_auth(),
headers={"Depth": "infinity", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if resp.status_code in (207, 200):
all_items = _parse_propfind(resp.content, relative_path)
return [item for item in all_items if not item["is_dir"]]
# Depth:infinity not supported — fall back to recursive Depth:1
if resp.status_code in (403, 400, 412):
return await _list_recursive_fallback(relative_path)
if resp.status_code == 404:
return []
raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}")
async def _list_recursive_fallback(relative_path: str) -> List[dict]:
"""Manually recurse via Depth:1 calls when Depth:infinity is blocked."""
items = await list_folder(relative_path)
files = []
dirs = []
for item in items:
if item["is_dir"]:
dirs.append(item["path"])
else:
files.append(item)
for dir_path in dirs:
child_files = await _list_recursive_fallback(dir_path)
files.extend(child_files)
return files
async def upload_file(relative_path: str, content: bytes, mime_type: str) -> str:
"""
PUT a file to Nextcloud. Returns the relative_path on success.
relative_path includes filename, e.g. "customers/abc123/media/photo.jpg"
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.put(
url,
auth=_auth(),
content=content,
headers={"Content-Type": mime_type},
)
if resp.status_code not in (200, 201, 204):
raise HTTPException(status_code=502, detail=f"Nextcloud upload failed: {resp.status_code}")
return relative_path
async def download_file(relative_path: str) -> tuple[bytes, str]:
"""
GET a file from Nextcloud. Returns (bytes, mime_type).
"""
url = _full_url(relative_path)
client = _get_client()
resp = await client.get(url, auth=_auth())
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="File not found in Nextcloud")
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Nextcloud download failed: {resp.status_code}")
mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
return resp.content, mime
async def delete_file(relative_path: str) -> None:
"""DELETE a file from Nextcloud."""
url = _full_url(relative_path)
client = _get_client()
resp = await client.request("DELETE", url, auth=_auth())
if resp.status_code not in (200, 204, 404):
raise HTTPException(status_code=502, detail=f"Nextcloud delete failed: {resp.status_code}")