Merge pull request #343 from christianlouis/copilot/add-admin-file-manager

Admin File Manager, Menu Refactor, Dashboard Improvements & Kubernetes Env Var Quote Stripping
This commit is contained in:
Christian Krakau-Louis
2026-02-21 10:27:06 +01:00
committed by GitHub
8 changed files with 1027 additions and 121 deletions
+21 -2
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
import os
from typing import List, Optional, Union
from typing import Any, List, Optional, Union
from pydantic import Field, field_validator
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -267,6 +267,25 @@ class Settings(BaseSettings):
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
)
@model_validator(mode="before")
@classmethod
def strip_outer_quotes(cls, data: Any) -> Any:
"""
Strip matching surrounding quotes from string values.
In Kubernetes (and some other environments) env var values can arrive
with literal quote characters included, e.g. the value for DATABASE_URL
may be ``"postgresql://..."`` (with the quotes as part of the string)
rather than just ``postgresql://...``. Docker Compose strips these
automatically; Kubernetes does not.
"""
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, str) and len(value) >= 2:
if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
data[key] = value[1:-1]
return data
@field_validator("notification_urls", mode="before")
@classmethod
def parse_notification_urls(cls, v: str | list[str]) -> list[str]:
+2
View File
@@ -5,6 +5,7 @@ Aggregated view routers for the application.
from fastapi import APIRouter
from app.views.dropbox import router as dropbox_router
from app.views.filemanager import router as filemanager_router
# Import all the view routers
from app.views.general import router as general_router
@@ -25,3 +26,4 @@ router.include_router(dropbox_router)
router.include_router(google_drive_router)
router.include_router(license_router) # Include the license router
router.include_router(settings_router)
router.include_router(filemanager_router)
+333
View File
@@ -0,0 +1,333 @@
"""
Admin-only file manager view for browsing the workdir directory.
Supports three views:
filesystem navigate the raw workdir tree, each file tagged against DB records
database list all FileRecord rows, each tagged with on-disk existence
reconcile show only the delta: orphan disk files and ghost DB records
"""
import logging
import mimetypes
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Set
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.config import settings
from app.models import FileRecord
from app.views.base import APIRouter, get_db, require_login, templates
from app.views.settings import require_admin_access
logger = logging.getLogger(__name__)
router = APIRouter()
_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _format_size(size_bytes: int) -> str:
"""Format file size in human-readable form."""
size = float(size_bytes)
for unit in _SIZE_UNITS:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} PB"
def _safe_path(workdir: str, rel_path: str) -> Path:
"""
Resolve a relative path inside workdir safely.
Raises ValueError if the resolved path escapes workdir.
"""
base = Path(workdir).resolve()
target = (base / rel_path).resolve()
if not target.is_relative_to(base):
raise ValueError("Path traversal detected")
return target
def _db_path_set(db: Session) -> Set[str]:
"""
Return the set of all absolute, normalised paths that the DB references
across local_filename, original_file_path, and processed_file_path.
"""
paths: Set[str] = set()
for row in db.query(
FileRecord.local_filename,
FileRecord.original_file_path,
FileRecord.processed_file_path,
).all():
for p in row:
if p:
paths.add(str(Path(p).resolve()))
return paths
def _file_icon(mime_type: str, is_dir: bool) -> str:
"""Return a Font Awesome class for a file/directory."""
if is_dir:
return "fas fa-folder text-yellow-400"
if mime_type.startswith("image/"):
return "fas fa-file-image text-blue-400"
if mime_type == "application/pdf":
return "fas fa-file-pdf text-red-400"
if "text/" in mime_type:
return "fas fa-file-alt text-gray-400"
if "json" in mime_type:
return "fas fa-file-code text-green-400"
return "fas fa-file text-gray-400"
def _scan_dir(target: Path, workdir_base: Path, db_paths: Set[str]) -> List[Dict[str, Any]]:
"""
List one directory level; annotate each file with its DB status.
db_status values:
"in_db" path found in DB
"orphan" on disk but not in DB
"" directories (not checked against DB)
"""
entries = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
try:
stat = item.stat()
except (PermissionError, OSError) as exc:
logger.warning(f"Could not stat {item}: {exc}")
continue
item_rel = str(item.relative_to(workdir_base))
mime_type, _ = mimetypes.guess_type(item.name)
mime_type = mime_type or ""
if item.is_dir():
db_status = ""
else:
abs_str = str(item.resolve())
db_status = "in_db" if abs_str in db_paths else "orphan"
entries.append(
{
"name": item.name,
"rel_path": item_rel,
"abs_path": str(item.resolve()),
"is_dir": item.is_dir(),
"size": _format_size(stat.st_size) if not item.is_dir() else "",
"size_bytes": stat.st_size if not item.is_dir() else 0,
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
"mime_type": mime_type,
"icon": _file_icon(mime_type, item.is_dir()),
"db_status": db_status,
}
)
return entries
def _walk_all_files(base: Path, db_paths: Set[str]) -> List[Dict[str, Any]]:
"""
Walk the entire workdir tree and return every file (not directory).
Used for the reconciliation view.
"""
entries = []
for item in sorted(base.rglob("*"), key=lambda p: str(p).lower()):
if item.is_dir():
continue
try:
stat = item.stat()
except (PermissionError, OSError):
continue
abs_str = str(item.resolve())
mime_type, _ = mimetypes.guess_type(item.name)
mime_type = mime_type or ""
entries.append(
{
"name": item.name,
"rel_path": str(item.relative_to(base)),
"abs_path": abs_str,
"is_dir": False,
"size": _format_size(stat.st_size),
"size_bytes": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
"mime_type": mime_type,
"icon": _file_icon(mime_type, False),
"db_status": "in_db" if abs_str in db_paths else "orphan",
}
)
return entries
def _db_records(db: Session, workdir_base: Path) -> List[Dict[str, Any]]:
"""
Return every FileRecord annotated with on-disk existence for each stored path.
"""
rows = []
for rec in db.query(FileRecord).order_by(FileRecord.id.desc()).all():
def _check(p: str | None) -> Dict[str, Any]:
if not p:
return {"path": None, "exists": None, "rel": None}
resolved = Path(p).resolve()
exists = resolved.exists()
try:
rel = str(resolved.relative_to(workdir_base))
except ValueError:
rel = p # outside workdir show full path
return {"path": p, "exists": exists, "rel": rel}
local = _check(rec.local_filename)
original = _check(rec.original_file_path)
processed = _check(rec.processed_file_path)
any_missing = any(info["exists"] is False for info in [local, original, processed])
rows.append(
{
"id": rec.id,
"original_filename": rec.original_filename or "",
"file_size": _format_size(rec.file_size) if rec.file_size else "",
"mime_type": rec.mime_type or "",
"created_at": rec.created_at.strftime("%Y-%m-%d %H:%M") if rec.created_at else "",
"is_duplicate": rec.is_duplicate,
"filehash": (rec.filehash or "")[:12],
"local": local,
"original": original,
"processed": processed,
# overall health flag
"health": "missing" if any_missing else "ok",
}
)
return rows
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/admin/files")
@require_login
@require_admin_access
async def filemanager(request: Request, db: Session = Depends(get_db)):
"""
Admin-only file manager with three views:
?view=filesystem (default) navigate workdir tree
?view=database list all DB FileRecord rows
?view=reconcile show only deltas (orphans + ghost records)
"""
workdir = settings.workdir
workdir_base = Path(workdir).resolve()
view = request.query_params.get("view", "filesystem")
# Build the DB path set once (used by all views)
db_paths = _db_path_set(db)
# ── Filesystem view ───────────────────────────────────────────────────
rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".")
try:
target = _safe_path(workdir, rel_path)
except ValueError:
logger.warning(f"Path traversal attempt blocked: path={rel_path!r}")
target = workdir_base
rel_path = ""
if not target.exists():
target = workdir_base
rel_path = ""
fs_entries: List[Dict[str, Any]] = []
if view == "filesystem" and target.is_dir():
fs_entries = _scan_dir(target, workdir_base, db_paths)
# Breadcrumb for filesystem view
breadcrumbs = []
if rel_path:
accumulated = Path()
for part in Path(rel_path).parts:
accumulated = accumulated / part
breadcrumbs.append({"name": part, "path": str(accumulated)})
parent_path = ""
if rel_path:
parent = str(Path(rel_path).parent)
parent_path = "" if parent == "." else parent
# ── Database view ─────────────────────────────────────────────────────
db_records: List[Dict[str, Any]] = []
if view in ("database", "reconcile"):
db_records = _db_records(db, workdir_base)
# ── Reconciliation view ───────────────────────────────────────────────
orphan_files: List[Dict[str, Any]] = []
ghost_records: List[Dict[str, Any]] = []
if view == "reconcile":
all_disk = _walk_all_files(workdir_base, db_paths)
orphan_files = [f for f in all_disk if f["db_status"] == "orphan"]
ghost_records = [r for r in db_records if r["health"] == "missing"]
# ── Summary counts ────────────────────────────────────────────────────
# Disk file count is only computed for non-filesystem views to avoid
# the overhead of walking the full tree on every directory navigation.
if view in ("database", "reconcile") and workdir_base.exists():
total_disk = sum(1 for p in workdir_base.rglob("*") if p.is_file())
else:
total_disk = None # deferred; not shown on filesystem tab header
total_db = db.query(FileRecord).count()
return templates.TemplateResponse(
"filemanager.html",
{
"request": request,
# view selector
"view": view,
# filesystem tab
"fs_entries": fs_entries,
"current_path": rel_path,
"parent_path": parent_path,
"breadcrumbs": breadcrumbs,
# database tab
"db_records": db_records,
# reconcile tab
"orphan_files": orphan_files,
"ghost_records": ghost_records,
# summary
"total_disk": total_disk,
"total_db": total_db,
"workdir": workdir,
"app_version": settings.version,
},
)
@router.get("/admin/files/download")
@require_login
@require_admin_access
async def filemanager_download(request: Request):
"""
Admin-only endpoint to download a file from workdir.
"""
workdir = settings.workdir
rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".")
try:
target = _safe_path(workdir, rel_path)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid path")
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
mime_type, _ = mimetypes.guess_type(target.name)
return FileResponse(
path=str(target),
filename=target.name,
media_type=mime_type or "application/octet-stream",
)
+12
View File
@@ -16,6 +16,18 @@
// Get the display name (prefer name, fall back to preferred_username, then email)
const displayName = data.name || data.preferred_username || data.email;
// Show admin menu items if the user is an admin
if (data.is_admin) {
const adminMenuContainer = document.getElementById("adminMenuContainer");
if (adminMenuContainer) {
adminMenuContainer.classList.remove("hidden");
}
const mobileAdminSection = document.getElementById("mobileAdminSection");
if (mobileAdminSection) {
mobileAdminSection.classList.remove("hidden");
}
}
// User is logged in - use DOM API to prevent XSS
if (authSection) {
authSection.textContent = ''; // Clear existing content
+69 -5
View File
@@ -41,13 +41,55 @@
</div>
<!-- Menu Items - using x-data for mobile menu toggle -->
<div x-data="{ mobileMenuOpen: false }">
<div x-data="{ mobileMenuOpen: false, adminMenuOpen: false }">
<div class="hidden md:flex space-x-4 items-center">
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
<a href="/settings" class="text-gray-700 hover:text-gray-900">Settings</a>
<!-- Admin dropdown (shown only for admin users via JS) -->
<div id="adminMenuContainer" class="relative hidden">
<button
@click="adminMenuOpen = !adminMenuOpen"
@click.outside="adminMenuOpen = false"
type="button"
class="inline-flex items-center text-gray-700 hover:text-gray-900 focus:outline-none"
aria-haspopup="true"
:aria-expanded="adminMenuOpen"
>
<i class="fas fa-shield-alt mr-1 text-red-500"></i>
Admin
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div
x-show="adminMenuOpen"
x-transition:enter="transition ease-out duration-100 transform"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75 transform"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
>
<div class="py-1">
<a href="/settings" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-cog w-4 mr-2 text-gray-500"></i> Settings
</a>
<a href="/env" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-terminal w-4 mr-2 text-gray-500"></i> Environment
</a>
<a href="/admin/files" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-folder-open w-4 mr-2 text-gray-500"></i> File Manager
</a>
</div>
</div>
</div>
<!-- Status (de-emphasized) -->
<a href="/status" class="text-gray-500 hover:text-gray-700 text-sm" title="System Status">
<i class="fas fa-circle-dot mr-0.5"></i> Status
</a>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<!-- Dynamic Auth Section -->
@@ -82,8 +124,30 @@
<a href="/" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
<a href="/upload" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
<a href="/status" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Status</a>
<a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Settings</a>
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
<div id="mobileAdminSection" class="hidden">
<div class="border-t border-gray-200 mt-1 pt-1">
<p class="px-3 py-1 text-xs font-semibold text-red-600 uppercase tracking-wider flex items-center">
<i class="fas fa-shield-alt mr-1"></i> Admin
</p>
<a href="/settings" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-cog mr-2 text-gray-400"></i> Settings
</a>
<a href="/env" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-terminal mr-2 text-gray-400"></i> Environment
</a>
<a href="/admin/files" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-folder-open mr-2 text-gray-400"></i> File Manager
</a>
</div>
</div>
<!-- Status (de-emphasized) in mobile -->
<a href="/status" class="block px-3 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-50">
<i class="fas fa-circle-dot mr-1"></i> Status
</a>
<a href="/about" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a>
<!-- Mobile Auth Section -->
+362
View File
@@ -0,0 +1,362 @@
{% extends "base.html" %}
{% block title %}File Manager Admin{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
<i class="fas fa-folder-open text-blue-500"></i>
File Manager
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
</h1>
<p class="text-gray-400 text-sm mt-1">
<code class="bg-gray-100 rounded px-1 text-xs">{{ workdir }}</code>
</p>
</div>
<!-- Summary badges -->
<div class="flex gap-3 text-sm flex-wrap">
<span class="inline-flex items-center gap-1.5 bg-blue-50 border border-blue-200 text-blue-700 rounded-full px-3 py-1">
<i class="fas fa-hdd text-xs"></i>
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} on disk
</span>
<span class="inline-flex items-center gap-1.5 bg-green-50 border border-green-200 text-green-700 rounded-full px-3 py-1">
<i class="fas fa-database text-xs"></i> {{ total_db }} in DB
</span>
{% if total_disk is not none and total_disk != total_db %}
<span class="inline-flex items-center gap-1.5 bg-amber-50 border border-amber-300 text-amber-700 rounded-full px-3 py-1 font-medium">
<i class="fas fa-triangle-exclamation text-xs"></i> Delta detected
</span>
{% endif %}
</div>
</div>
<!-- ── View tabs ────────────────────────────────────────────────────────── -->
<div class="flex border-b border-gray-200 mb-6 gap-1">
<a href="?view=filesystem"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'filesystem' %}border-blue-600 text-blue-600 bg-blue-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-folder-tree mr-1.5"></i>Filesystem
</a>
<a href="?view=database"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'database' %}border-green-600 text-green-600 bg-green-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-database mr-1.5"></i>Database Records
</a>
<a href="?view=reconcile"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'reconcile' %}border-amber-500 text-amber-600 bg-amber-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-code-compare mr-1.5"></i>Reconcile
{% if total_disk != total_db %}
<span class="ml-1 bg-amber-500 text-white rounded-full text-xs px-1.5 py-0.5">!</span>
{% endif %}
</a>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
FILESYSTEM TAB
═══════════════════════════════════════════════════════════════════ -->
{% if view == 'filesystem' %}
<!-- Legend -->
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> Found in DB</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> Not in DB (orphan)</span>
</div>
<!-- Breadcrumb -->
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 flex-wrap">
<li>
<a href="?view=filesystem" class="text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-home"></i> workdir
</a>
</li>
{% for crumb in breadcrumbs %}
<li class="flex items-center">
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs"></i>
{% if loop.last %}
<span class="text-gray-700 font-medium">{{ crumb.name }}</span>
{% else %}
<a href="?view=filesystem&path={{ crumb.path | urlencode }}" class="text-blue-600 hover:underline">{{ crumb.name }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
<!-- Directory listing -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-8"></th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">DB</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% if current_path %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder"></i></td>
<td class="px-4 py-3 font-medium text-gray-900" colspan="4">
<a href="?view=filesystem{% if parent_path %}&path={{ parent_path | urlencode }}{% endif %}"
class="text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-level-up-alt text-xs"></i> ..
</a>
</td>
<td></td>
</tr>
{% endif %}
{% if fs_entries %}
{% for entry in fs_entries %}
<tr class="hover:bg-gray-50 {% if entry.db_status == 'orphan' %}bg-amber-50{% endif %}">
<td class="px-4 py-3"><i class="{{ entry.icon }}"></i></td>
<td class="px-4 py-3 font-medium text-gray-900 max-w-xs truncate">
{% if entry.is_dir %}
<a href="?view=filesystem&path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
{{ entry.name }}
</a>
{% else %}
<span title="{{ entry.rel_path }}">{{ entry.name }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ entry.size }}</td>
<td class="px-4 py-3 text-gray-400 hidden md:table-cell">{{ entry.modified }}</td>
<td class="px-4 py-3">
{% if entry.db_status == 'in_db' %}
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> In DB
</span>
{% elif entry.db_status == 'orphan' %}
<span class="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> Orphan
</span>
{% endif %}
</td>
<td class="px-4 py-3">
{% if not entry.is_dir %}
<a href="/admin/files/download?path={{ entry.rel_path | urlencode }}"
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
<i class="fas fa-download text-xs"></i> Download
</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-10 text-center text-sm text-gray-400">
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block"></i>
This directory is empty.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endif %}
<!-- ══════════════════════════════════════════════════════════════════════
DATABASE TAB
═══════════════════════════════════════════════════════════════════ -->
{% if view == 'database' %}
<!-- Legend -->
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> File exists on disk</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> File missing from disk</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> Path not set</span>
</div>
{% if db_records %}
<div class="bg-white rounded-lg shadow overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">Ingested</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">local_filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">original_file_path</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">processed_file_path</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Health</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for rec in db_records %}
<tr class="hover:bg-gray-50 {% if rec.health == 'missing' %}bg-red-50{% endif %}">
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
{% if rec.is_duplicate %}
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">dup</span>
{% endif %}
{{ rec.original_filename }}
</td>
<td class="px-4 py-3 text-gray-500 hidden md:table-cell">{{ rec.file_size }}</td>
<td class="px-4 py-3 text-gray-400 text-xs hidden lg:table-cell">{{ rec.created_at }}</td>
{# Helper macro: render a path cell #}
{% for path_info in [rec.local, rec.original, rec.processed] %}
<td class="px-4 py-3 text-xs font-mono max-w-xs">
{% if path_info.path is none %}
<span class="text-gray-300"></span>
{% elif path_info.exists %}
<span class="flex items-start gap-1">
<span class="h-2 w-2 rounded-full bg-green-400 mt-0.5 flex-shrink-0"></span>
<span class="text-gray-600 truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
</span>
{% else %}
<span class="flex items-start gap-1">
<span class="h-2 w-2 rounded-full bg-red-400 mt-0.5 flex-shrink-0"></span>
<span class="text-red-700 line-through truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
</span>
{% endif %}
</td>
{% endfor %}
<td class="px-4 py-3">
{% if rec.health == 'ok' %}
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
<i class="fas fa-check text-xs"></i> OK
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
<i class="fas fa-triangle-exclamation text-xs"></i> Missing
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-white rounded-lg shadow p-10 text-center text-gray-400">
<i class="fas fa-database text-4xl mb-3 block text-gray-200"></i>
No file records found in the database.
</div>
{% endif %}
{% endif %}
<!-- ══════════════════════════════════════════════════════════════════════
RECONCILE TAB
═══════════════════════════════════════════════════════════════════ -->
{% if view == 'reconcile' %}
{% if orphan_files or ghost_records %}
<div class="bg-amber-50 border border-amber-300 rounded-lg px-5 py-4 mb-6 flex items-start gap-3">
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5"></i>
<div class="text-sm text-amber-800">
<strong>Delta detected.</strong>
Found <strong>{{ orphan_files | length }}</strong> orphan file(s) on disk with no DB record,
and <strong>{{ ghost_records | length }}</strong> DB record(s) with missing files on disk.
</div>
</div>
{% else %}
<div class="bg-green-50 border border-green-300 rounded-lg px-5 py-4 mb-6 flex items-center gap-3">
<i class="fas fa-circle-check text-green-500"></i>
<span class="text-sm text-green-800 font-medium">No delta found — filesystem and database are in sync.</span>
</div>
{% endif %}
<!-- Orphan files (on disk, not in DB) -->
<div class="mb-8">
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-amber-400 inline-block"></span>
Orphan files <span class="text-gray-400 font-normal text-sm ml-1">(on disk, no DB record)</span>
<span class="text-xs bg-amber-100 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5 ml-auto">{{ orphan_files | length }}</span>
</h2>
{% if orphan_files %}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-amber-50">
<tr>
<th class="px-4 py-3 text-left w-8"></th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Path (relative to workdir)</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for f in orphan_files %}
<tr class="hover:bg-amber-50">
<td class="px-4 py-3"><i class="{{ f.icon }}"></i></td>
<td class="px-4 py-3 font-mono text-xs text-amber-800 max-w-xs truncate" title="{{ f.rel_path }}">{{ f.rel_path }}</td>
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ f.size }}</td>
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ f.modified }}</td>
<td class="px-4 py-3">
<a href="/admin/files/download?path={{ f.rel_path | urlencode }}"
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
<i class="fas fa-download text-xs"></i> Download
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">No orphan files found.</p>
{% endif %}
</div>
<!-- Ghost records (in DB, file missing on disk) -->
<div>
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-red-400 inline-block"></span>
Ghost records <span class="text-gray-400 font-normal text-sm ml-1">(in DB, file(s) missing on disk)</span>
<span class="text-xs bg-red-100 text-red-700 border border-red-200 rounded-full px-2 py-0.5 ml-auto">{{ ghost_records | length }}</span>
</h2>
{% if ghost_records %}
<div class="bg-white rounded-lg shadow overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-red-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Ingested</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Missing paths</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for rec in ghost_records %}
<tr class="hover:bg-red-50">
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
{{ rec.original_filename }}
</td>
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ rec.created_at }}</td>
<td class="px-4 py-3 text-xs font-mono">
{% for path_info in [rec.local, rec.original, rec.processed] %}
{% if path_info.exists is sameas false %}
<div class="flex items-start gap-1 text-red-700">
<span class="h-2 w-2 rounded-full bg-red-400 mt-0.5 flex-shrink-0"></span>
<span class="line-through truncate" title="{{ path_info.path }}">{{ path_info.rel }}</span>
</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">No ghost records found.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+137 -114
View File
@@ -1,136 +1,159 @@
{% extends "base.html" %}
{% block title %}Home - DocuElevate{% endblock %}
{% block title %}Dashboard DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Hero Section -->
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 rounded-lg shadow-lg text-white p-8 mb-8">
<h1 class="text-4xl font-bold mb-4">Welcome to DocuElevate</h1>
<p class="text-xl mb-6">
Your intelligent solution for processing, managing, and organizing documents effortlessly.
</p>
<div class="flex flex-wrap gap-4">
<a href="/upload" class="bg-white text-blue-600 hover:bg-gray-100 font-bold py-2 px-4 rounded-lg transition duration-300 flex items-center">
<i class="fas fa-upload mr-2"></i> Upload Document
</a>
<a href="/status" class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-600 font-bold py-2 px-4 rounded-lg transition duration-300 flex items-center">
<i class="fas fa-cog mr-2"></i> System Status
</a>
</div>
</div>
<!-- Stats Section -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">System Overview</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="border-r border-gray-200 px-4">
<p class="text-4xl font-bold text-blue-600">{{ stats.processed_files|default('0') }}</p>
<p class="text-gray-600">Documents Processed</p>
<!-- Hero / Quick Actions -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold mb-1">DocuElevate Dashboard</h1>
<p class="text-blue-100 text-sm">Intelligent document processing &amp; management</p>
</div>
<div class="border-r border-gray-200 px-4">
<p class="text-4xl font-bold text-green-600">{{ stats.active_integrations|default('0') }}</p>
<p class="text-gray-600">Active Integrations</p>
</div>
<div class="px-4">
<p class="text-4xl font-bold text-indigo-600">{{ stats.storage_targets|default('0') }}</p>
<p class="text-gray-600">Storage Targets</p>
<div class="flex flex-wrap gap-3">
<a href="/upload" class="bg-white text-blue-700 hover:bg-blue-50 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center shadow-sm">
<i class="fas fa-upload mr-2"></i> Upload
</a>
<a href="/files" class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-700 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center">
<i class="fas fa-list mr-2"></i> Browse Files
</a>
</div>
</div>
</div>
<!-- Features Section -->
<h2 class="text-3xl font-semibold mb-6">Core Features</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-file-alt fa-2x"></i>
<!-- Stats Row -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
<div class="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-alt text-blue-600 text-xl"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Document Processing</h3>
<p class="text-gray-600 mb-4">
Upload and process documents with OCR, metadata extraction, and intelligent classification.
</p>
<a href="/upload" class="text-blue-600 hover:text-blue-800 font-medium">
Start processing &rarr;
</a>
<div>
<p class="text-3xl font-bold text-blue-600">{{ stats.processed_files | default(0) }}</p>
<p class="text-gray-500 text-sm">Documents Processed</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
<div class="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-plug text-green-600 text-xl"></i>
</div>
<div>
<p class="text-3xl font-bold text-green-600">{{ stats.active_integrations | default(0) }}</p>
<p class="text-gray-500 text-sm">Active Integrations</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-6 flex items-center gap-4">
<div class="h-12 w-12 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-database text-indigo-600 text-xl"></i>
</div>
<div>
<p class="text-3xl font-bold text-indigo-600">{{ stats.storage_targets | default(0) }}</p>
<p class="text-gray-500 text-sm">Storage Targets</p>
</div>
</div>
</div>
<!-- Widget Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Quick Actions Widget -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-bolt text-yellow-500"></i> Quick Actions
</h2>
<ul class="space-y-2">
<li>
<a href="/upload" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-200">
<i class="fas fa-upload text-blue-600 text-sm"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">Upload Document</p>
<p class="text-xs text-gray-400">Process a new file</p>
</div>
</a>
</li>
<li>
<a href="/files" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 group-hover:bg-indigo-200">
<i class="fas fa-list text-indigo-600 text-sm"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">View All Files</p>
<p class="text-xs text-gray-400">Browse processed documents</p>
</div>
</a>
</li>
<li>
<a href="/status" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 group-hover:bg-green-200">
<i class="fas fa-heartbeat text-green-600 text-sm"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">System Status</p>
<p class="text-xs text-gray-400">Check integration health</p>
</div>
</a>
</li>
</ul>
</div>
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-cloud fa-2x"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Multiple Storage Options</h3>
<p class="text-gray-600 mb-4">
Connect to Dropbox, NextCloud, OneDrive and other storage solutions seamlessly.
</p>
<a href="/status" class="text-blue-600 hover:text-blue-800 font-medium">
Configure storage &rarr;
</a>
<!-- Capabilities Widget -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-cubes text-blue-500"></i> Capabilities
</h2>
<ul class="space-y-3 text-sm text-gray-600">
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
<span>OCR &amp; metadata extraction with AI</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
<span>Cloud storage: Dropbox, OneDrive, Google Drive, NextCloud</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
<span>Paperless-ngx integration for document management</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
<span>Email &amp; URL-based document ingestion</span>
</li>
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0"></i>
<span>Automated classification &amp; routing workflows</span>
</li>
</ul>
</div>
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-robot fa-2x"></i>
<!-- Getting Started / Help Widget -->
<div class="bg-white rounded-xl shadow p-6 flex flex-col justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-compass text-indigo-500"></i> Getting Started
</h2>
<ol class="space-y-3 text-sm text-gray-600 list-none">
<li class="flex items-start gap-2">
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">1</span>
Configure integrations via <a href="/status" class="text-blue-600 hover:underline">System Status</a>
</li>
<li class="flex items-start gap-2">
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">2</span>
<a href="/upload" class="text-blue-600 hover:underline">Upload</a> your first document
</li>
<li class="flex items-start gap-2">
<span class="h-5 w-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center flex-shrink-0 font-bold mt-0.5">3</span>
Review results in <a href="/files" class="text-blue-600 hover:underline">Files</a>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-2">AI-Powered Analysis</h3>
<p class="text-gray-600 mb-4">
Leverage AI to extract metadata, classify documents, and organize your information.
</p>
<a href="/files" class="text-blue-600 hover:text-blue-800 font-medium">
View processed files &rarr;
</a>
</div>
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-envelope fa-2x"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Email Integration</h3>
<p class="text-gray-600 mb-4">
Auto-process documents received via email with our IMAP polling capabilities.
</p>
<a href="/status" class="text-blue-600 hover:text-blue-800 font-medium">
Set up email &rarr;
</a>
</div>
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-cogs fa-2x"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Workflow Automation</h3>
<p class="text-gray-600 mb-4">
Create workflows to automatically process and route documents based on content.
</p>
<a href="/about" class="text-blue-600 hover:text-blue-800 font-medium">
Learn more &rarr;
</a>
</div>
<div class="bg-white shadow rounded-lg p-6 hover:shadow-lg transition duration-300">
<div class="text-blue-600 mb-4">
<i class="fas fa-shield-alt fa-2x"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Secure & Private</h3>
<p class="text-gray-600 mb-4">
Your documents are processed securely with privacy-focused design principles.
</p>
<a href="/privacy" class="text-blue-600 hover:text-blue-800 font-medium">
Privacy policy &rarr;
<a href="/about" class="mt-6 inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
Learn more about DocuElevate <i class="fas fa-arrow-right ml-1 text-xs"></i>
</a>
</div>
</div>
<!-- Getting Started Section -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="mb-6 md:mb-0">
<h2 class="text-2xl font-bold mb-2">Ready to get started?</h2>
<p class="text-gray-600">Upload your first document and see DocuElevate in action.</p>
</div>
<a href="/upload" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300">
Try it now
</a>
</div>
</div>
</div>
{% endblock %}
+91
View File
@@ -289,3 +289,94 @@ class TestSecurityConfiguration:
# Should not raise an error
assert config.database_url == "sqlite:///test.db"
# ---------------------------------------------------------------------------
# Helpers shared across quote-stripping tests
# ---------------------------------------------------------------------------
_BASE_KWARGS = dict(
auth_enabled=False,
azure_ai_key="test",
azure_region="eastus",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
openai_api_key="sk-test",
redis_url="redis://localhost:6379",
)
@pytest.mark.unit
class TestOuterQuoteStripping:
"""
Tests that Settings strips surrounding quotes from string env var values.
In Kubernetes env vars can arrive with literal quote characters included
(e.g. DATABASE_URL="postgresql://..." with the quotes as part of the value).
Docker Compose strips these automatically; Kubernetes does not.
"""
def test_double_quotes_stripped_from_url(self):
"""Double-quoted URL value has quotes removed."""
config = Settings(
database_url='"sqlite:///test.db"',
**_BASE_KWARGS,
)
assert config.database_url == "sqlite:///test.db"
def test_single_quotes_stripped_from_url(self):
"""Single-quoted URL value has quotes removed."""
config = Settings(
database_url="'sqlite:///test.db'",
**_BASE_KWARGS,
)
assert config.database_url == "sqlite:///test.db"
def test_double_quotes_stripped_from_optional_field(self):
"""Quotes are stripped from optional string fields too."""
config = Settings(
database_url="sqlite:///test.db",
dropbox_app_key='"my-app-key"',
**_BASE_KWARGS,
)
assert config.dropbox_app_key == "my-app-key"
def test_unquoted_value_unchanged(self):
"""Values without surrounding quotes are left as-is."""
config = Settings(
database_url="sqlite:///test.db",
**_BASE_KWARGS,
)
assert config.database_url == "sqlite:///test.db"
def test_mismatched_quotes_not_stripped(self):
"""Mismatched quotes (open with one type, close with another) are NOT stripped."""
raw = '"sqlite:///test.db\''
config = Settings(
database_url=raw,
**_BASE_KWARGS,
)
assert config.database_url == raw
def test_single_quote_char_not_stripped(self):
"""A single-character string that is just one quote is NOT modified."""
# A value of exactly one character cannot have matching outer quotes
config = Settings(
database_url="sqlite:///test.db",
openai_model='"',
**_BASE_KWARGS,
)
assert config.openai_model == '"'
def test_multiple_fields_stripped_simultaneously(self):
"""Multiple quoted fields in the same config are all stripped."""
config = Settings(
database_url='"sqlite:///test.db"',
redis_url='"redis://localhost:6379"',
workdir='"/data/workdir"',
**{k: v for k, v in _BASE_KWARGS.items() if k not in ("redis_url", "workdir")},
)
assert config.database_url == "sqlite:///test.db"
assert config.redis_url == "redis://localhost:6379"
assert config.workdir == "/data/workdir"