feat(system-reset): add system reset and factory reset feature
- Add FACTORY_RESET_ON_STARTUP and ENABLE_FACTORY_RESET config settings - Create app/utils/system_reset.py with core reset logic (wipe DB + files, reimport) - Create app/api/system_reset.py with admin-only API endpoints - Create app/views/system_reset.py with admin-only UI view - Create frontend/templates/system_reset.html with confirmation dialogs - Auto-reset on startup when FACTORY_RESET_ON_STARTUP=true - Re-import uses watch folder mechanism for re-ingestion - Register routers in API and views init files - Add i18n keys and SETTING_METADATA entries - Add nav links in base.html (desktop + mobile) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,10 @@ GOTENBERG_URL=http://gotenberg:3000
|
|||||||
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
||||||
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
||||||
|
|
||||||
|
# **System Reset / Factory Reset**
|
||||||
|
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
|
||||||
|
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
|
||||||
|
|
||||||
# **Logging**
|
# **Logging**
|
||||||
# LOG_LEVEL controls the Python root-logger level.
|
# LOG_LEVEL controls the Python root-logger level.
|
||||||
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
|
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from app.api.shared_links import public_router as shared_links_public_router
|
|||||||
from app.api.shared_links import router as shared_links_router
|
from app.api.shared_links import router as shared_links_router
|
||||||
from app.api.similarity import router as similarity_router
|
from app.api.similarity import router as similarity_router
|
||||||
from app.api.subscriptions import router as subscriptions_router
|
from app.api.subscriptions import router as subscriptions_router
|
||||||
|
from app.api.system_reset import router as system_reset_router
|
||||||
from app.api.translation import router as translation_router
|
from app.api.translation import router as translation_router
|
||||||
from app.api.url_upload import router as url_upload_router
|
from app.api.url_upload import router as url_upload_router
|
||||||
|
|
||||||
@@ -97,4 +98,5 @@ router.include_router(audit_logs_router)
|
|||||||
router.include_router(i18n_router)
|
router.include_router(i18n_router)
|
||||||
router.include_router(mobile_router)
|
router.include_router(mobile_router)
|
||||||
router.include_router(compliance_router)
|
router.include_router(compliance_router)
|
||||||
|
router.include_router(system_reset_router)
|
||||||
router.include_router(translation_router)
|
router.include_router(translation_router)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
System reset API endpoints for DocuElevate.
|
||||||
|
|
||||||
|
Provides admin-only REST endpoints for:
|
||||||
|
- Full system reset (wipe all user data)
|
||||||
|
- Reset with re-import (move originals → reimport folder, wipe, re-ingest)
|
||||||
|
|
||||||
|
Both operations require the ``ENABLE_FACTORY_RESET=True`` feature flag and
|
||||||
|
admin privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/admin/system-reset", tags=["system-reset"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> dict:
|
||||||
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||||
|
user = request.session.get("user")
|
||||||
|
if not user or not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_feature_enabled() -> None:
|
||||||
|
"""Raise 404 when the factory-reset feature flag is off."""
|
||||||
|
if not settings.enable_factory_reset:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="System reset is not enabled. Set ENABLE_FACTORY_RESET=True to activate.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetRequest(BaseModel):
|
||||||
|
"""Body for system reset endpoints. Requires explicit confirmation."""
|
||||||
|
|
||||||
|
confirmation: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/full")
|
||||||
|
async def full_reset(
|
||||||
|
body: ResetRequest,
|
||||||
|
_admin: AdminUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Wipe all user data (database + work-files).
|
||||||
|
|
||||||
|
The caller must send ``{"confirmation": "DELETE"}`` to proceed.
|
||||||
|
"""
|
||||||
|
_require_feature_enabled()
|
||||||
|
|
||||||
|
if body.confirmation != "DELETE":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail='Confirmation required: send {"confirmation": "DELETE"} to proceed.',
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.utils.system_reset import perform_full_reset
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = perform_full_reset(db)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Full system reset failed")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"System reset failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "ok", "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reimport")
|
||||||
|
async def reset_and_reimport(
|
||||||
|
body: ResetRequest,
|
||||||
|
_admin: AdminUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Move original files to a reimport folder, wipe everything, and
|
||||||
|
configure the reimport folder as a watch folder for automatic
|
||||||
|
re-ingestion.
|
||||||
|
|
||||||
|
The caller must send ``{"confirmation": "REIMPORT"}`` to proceed.
|
||||||
|
"""
|
||||||
|
_require_feature_enabled()
|
||||||
|
|
||||||
|
if body.confirmation != "REIMPORT":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail='Confirmation required: send {"confirmation": "REIMPORT"} to proceed.',
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.utils.system_reset import perform_reset_and_reimport
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = perform_reset_and_reimport(db)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Reset-and-reimport failed")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Reset and reimport failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "ok", "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def reset_status(_admin: AdminUser) -> dict:
|
||||||
|
"""Return whether the system reset feature is enabled."""
|
||||||
|
return {
|
||||||
|
"enabled": settings.enable_factory_reset,
|
||||||
|
"factory_reset_on_startup": settings.factory_reset_on_startup,
|
||||||
|
}
|
||||||
@@ -639,6 +639,25 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# System reset / factory reset settings
|
||||||
|
factory_reset_on_startup: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When enabled, DocuElevate wipes all user data (database rows and "
|
||||||
|
"work-files on disk) on every startup so the instance always comes "
|
||||||
|
"up in a clean, fresh state. Useful for demo or testing environments. "
|
||||||
|
"Default: False."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enable_factory_reset: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Show the 'System Reset' page in the admin UI. When enabled, "
|
||||||
|
"administrators can trigger a full data wipe or a wipe-and-reimport "
|
||||||
|
"directly from the web interface. Default: False."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# PDF/A archival conversion settings
|
# PDF/A archival conversion settings
|
||||||
enable_pdfa_conversion: bool = Field(
|
enable_pdfa_conversion: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup: Initialize database
|
# Startup: Initialize database
|
||||||
init_db() # Create tables if they don't exist
|
init_db() # Create tables if they don't exist
|
||||||
|
|
||||||
|
# Factory reset on startup — wipe all user data before anything else
|
||||||
|
if settings.factory_reset_on_startup:
|
||||||
|
from app.utils.system_reset import perform_startup_reset
|
||||||
|
|
||||||
|
perform_startup_reset()
|
||||||
|
|
||||||
# Load settings from database after DB initialization
|
# Load settings from database after DB initialization
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.utils.config_loader import load_settings_from_db
|
from app.utils.config_loader import load_settings_from_db
|
||||||
|
|||||||
@@ -1900,6 +1900,28 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
"factory_reset_on_startup": {
|
||||||
|
"category": "Feature Flags",
|
||||||
|
"description": (
|
||||||
|
"Wipe all user data on every startup so the instance always starts fresh. "
|
||||||
|
"Useful for demo/testing environments. Default: False."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"enable_factory_reset": {
|
||||||
|
"category": "Feature Flags",
|
||||||
|
"description": (
|
||||||
|
"Show the System Reset page in the admin UI. Allows administrators to "
|
||||||
|
"trigger a full data wipe or a wipe-and-reimport from the web interface. Default: False."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
# Backup / Restore
|
# Backup / Restore
|
||||||
"backup_enabled": {
|
"backup_enabled": {
|
||||||
"category": "Backup",
|
"category": "Backup",
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
System reset utilities for DocuElevate.
|
||||||
|
|
||||||
|
Provides functions to:
|
||||||
|
- Wipe all user data (database rows + work-files on disk) for a fresh start.
|
||||||
|
- Wipe with re-import: move original files to a dedicated folder, wipe
|
||||||
|
everything, then let the watch-folder mechanism re-ingest the files.
|
||||||
|
|
||||||
|
Security: All public functions in this module require admin-level access.
|
||||||
|
They MUST only be invoked from admin-guarded API/view endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Subdirectories inside *workdir* that contain user-generated data.
|
||||||
|
# Everything else (app code, static assets, config) is left untouched.
|
||||||
|
_USER_DATA_SUBDIRS = ("original", "processed", "tmp", "pdfa", "backups")
|
||||||
|
|
||||||
|
# JSON cache files written by watch-folder / ingest tasks.
|
||||||
|
_CACHE_FILES = (
|
||||||
|
"watch_folder_processed.json",
|
||||||
|
"ftp_ingest_processed.json",
|
||||||
|
"sftp_ingest_processed.json",
|
||||||
|
"dropbox_ingest_processed.json",
|
||||||
|
"gdrive_ingest_processed.json",
|
||||||
|
"onedrive_ingest_processed.json",
|
||||||
|
"nextcloud_ingest_processed.json",
|
||||||
|
"s3_ingest_processed.json",
|
||||||
|
"webdav_ingest_processed.json",
|
||||||
|
"processed_mails.json",
|
||||||
|
"credential_failures.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The folder name used for storing files prior to re-import.
|
||||||
|
REIMPORT_FOLDER_NAME = "reimport"
|
||||||
|
|
||||||
|
|
||||||
|
def _wipe_workdir_data(workdir: str) -> dict[str, int]:
|
||||||
|
"""Delete user data subdirectories and cache files inside *workdir*.
|
||||||
|
|
||||||
|
Leaves the workdir directory itself intact so the application can
|
||||||
|
continue to write into it. Also leaves any files that do not belong
|
||||||
|
to the known data subdirectories or caches.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with counts of deleted directories and files.
|
||||||
|
"""
|
||||||
|
workdir_path = Path(workdir)
|
||||||
|
deleted_dirs = 0
|
||||||
|
deleted_files = 0
|
||||||
|
|
||||||
|
# Remove data subdirectories
|
||||||
|
for subdir in _USER_DATA_SUBDIRS:
|
||||||
|
target = workdir_path / subdir
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
logger.info("Deleted data directory: %s", target)
|
||||||
|
deleted_dirs += 1
|
||||||
|
|
||||||
|
# Remove cache / state JSON files
|
||||||
|
for cache_file in _CACHE_FILES:
|
||||||
|
target = workdir_path / cache_file
|
||||||
|
if target.is_file():
|
||||||
|
target.unlink()
|
||||||
|
logger.info("Deleted cache file: %s", target)
|
||||||
|
deleted_files += 1
|
||||||
|
|
||||||
|
# Also remove user_wf_*.json files (per-user watch folder caches)
|
||||||
|
for f in workdir_path.glob("user_wf_*.json"):
|
||||||
|
f.unlink()
|
||||||
|
logger.info("Deleted user watch-folder cache: %s", f)
|
||||||
|
deleted_files += 1
|
||||||
|
|
||||||
|
# Remove loose files in workdir root that are user uploads (uuid-named
|
||||||
|
# files like "a1b2c3d4-…pdf") but NOT application config files.
|
||||||
|
for entry in workdir_path.iterdir():
|
||||||
|
if entry.is_file() and entry.suffix.lower() in {
|
||||||
|
".pdf",
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
".docx",
|
||||||
|
".doc",
|
||||||
|
".xlsx",
|
||||||
|
".xls",
|
||||||
|
".pptx",
|
||||||
|
".heic",
|
||||||
|
".heif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".gif",
|
||||||
|
".txt",
|
||||||
|
".rtf",
|
||||||
|
".odt",
|
||||||
|
".ods",
|
||||||
|
".odp",
|
||||||
|
".csv",
|
||||||
|
".pages",
|
||||||
|
".numbers",
|
||||||
|
".keynote",
|
||||||
|
}:
|
||||||
|
entry.unlink()
|
||||||
|
logger.info("Deleted loose workdir file: %s", entry)
|
||||||
|
deleted_files += 1
|
||||||
|
|
||||||
|
return {"deleted_dirs": deleted_dirs, "deleted_files": deleted_files}
|
||||||
|
|
||||||
|
|
||||||
|
def _wipe_database(db: Session) -> dict[str, int]:
|
||||||
|
"""Delete all user-generated rows from the database.
|
||||||
|
|
||||||
|
Preserves schema (tables, migrations) and system-seeded rows that will
|
||||||
|
be re-created on the next startup (subscription plans, default pipeline,
|
||||||
|
scheduled jobs, compliance templates).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict mapping table name → number of rows deleted.
|
||||||
|
"""
|
||||||
|
from app.models import (
|
||||||
|
AuditLog,
|
||||||
|
BackupRecord,
|
||||||
|
DocumentMetadata,
|
||||||
|
FileProcessingStep,
|
||||||
|
FileRecord,
|
||||||
|
InAppNotification,
|
||||||
|
ProcessingLog,
|
||||||
|
SavedSearch,
|
||||||
|
SettingsAuditLog,
|
||||||
|
SharedLink,
|
||||||
|
UserImapAccount,
|
||||||
|
UserIntegration,
|
||||||
|
UserNotificationPreference,
|
||||||
|
UserNotificationTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order matters: delete children before parents to respect FK constraints.
|
||||||
|
tables_to_wipe: list[tuple[str, type]] = [
|
||||||
|
("file_processing_steps", FileProcessingStep),
|
||||||
|
("processing_logs", ProcessingLog),
|
||||||
|
("shared_links", SharedLink),
|
||||||
|
("in_app_notifications", InAppNotification),
|
||||||
|
("user_notification_preferences", UserNotificationPreference),
|
||||||
|
("user_notification_targets", UserNotificationTarget),
|
||||||
|
("user_imap_accounts", UserImapAccount),
|
||||||
|
("user_integrations", UserIntegration),
|
||||||
|
("saved_searches", SavedSearch),
|
||||||
|
("settings_audit_log", SettingsAuditLog),
|
||||||
|
("audit_logs", AuditLog),
|
||||||
|
("backup_records", BackupRecord),
|
||||||
|
("document_metadata", DocumentMetadata),
|
||||||
|
("files", FileRecord),
|
||||||
|
]
|
||||||
|
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for table_name, model in tables_to_wipe:
|
||||||
|
try:
|
||||||
|
count = db.query(model).delete()
|
||||||
|
result[table_name] = count
|
||||||
|
logger.info("Wiped %d rows from %s", count, table_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to wipe table %s", table_name)
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def perform_full_reset(db: Session) -> dict:
|
||||||
|
"""Perform a complete system reset: wipe database rows + work-files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: An active SQLAlchemy session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with ``database`` and ``filesystem`` sub-dicts.
|
||||||
|
"""
|
||||||
|
logger.warning(">>> SYSTEM RESET: wiping all user data <<<")
|
||||||
|
|
||||||
|
db_result = _wipe_database(db)
|
||||||
|
fs_result = _wipe_workdir_data(settings.workdir)
|
||||||
|
|
||||||
|
logger.warning(">>> SYSTEM RESET complete <<<")
|
||||||
|
return {"database": db_result, "filesystem": fs_result}
|
||||||
|
|
||||||
|
|
||||||
|
def perform_reset_and_reimport(db: Session) -> dict:
|
||||||
|
"""Move original files to a reimport folder, wipe everything, then
|
||||||
|
configure the reimport folder as a watch folder for re-ingestion.
|
||||||
|
|
||||||
|
The watch-folder scanner (``scan_all_watch_folders``) will pick up
|
||||||
|
the files on its next periodic run and process them exactly as if
|
||||||
|
they had been freshly uploaded — respecting the same backoff
|
||||||
|
strategy, size limits, and rate limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: An active SQLAlchemy session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with ``database``, ``filesystem``, and ``reimport`` sub-dicts.
|
||||||
|
"""
|
||||||
|
workdir_path = Path(settings.workdir)
|
||||||
|
reimport_dir = workdir_path / REIMPORT_FOLDER_NAME
|
||||||
|
original_dir = workdir_path / "original"
|
||||||
|
|
||||||
|
# 1. Collect original files
|
||||||
|
files_moved = 0
|
||||||
|
reimport_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if original_dir.is_dir():
|
||||||
|
for entry in original_dir.iterdir():
|
||||||
|
if entry.is_file():
|
||||||
|
dest = reimport_dir / entry.name
|
||||||
|
# Avoid overwriting: append counter if name clash
|
||||||
|
if dest.exists():
|
||||||
|
stem = dest.stem
|
||||||
|
suffix = dest.suffix
|
||||||
|
counter = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = reimport_dir / f"{stem}_{counter}{suffix}"
|
||||||
|
counter += 1
|
||||||
|
shutil.copy2(str(entry), str(dest))
|
||||||
|
files_moved += 1
|
||||||
|
|
||||||
|
logger.info("Copied %d original files to reimport folder: %s", files_moved, reimport_dir)
|
||||||
|
|
||||||
|
# 2. Perform the full reset (wipe DB + other workdir data)
|
||||||
|
reset_result = perform_full_reset(db)
|
||||||
|
|
||||||
|
# 3. Ensure the reimport folder survived the wipe (it's not in _USER_DATA_SUBDIRS)
|
||||||
|
# and set up watch folder config to point at it.
|
||||||
|
_configure_reimport_watch_folder(str(reimport_dir))
|
||||||
|
|
||||||
|
reset_result["reimport"] = {
|
||||||
|
"files_moved": files_moved,
|
||||||
|
"reimport_folder": str(reimport_dir),
|
||||||
|
}
|
||||||
|
logger.warning(">>> SYSTEM RESET with re-import configured — %d files staged <<<", files_moved)
|
||||||
|
return reset_result
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_reimport_watch_folder(reimport_path: str) -> None:
|
||||||
|
"""Append *reimport_path* to the application's watch-folder list.
|
||||||
|
|
||||||
|
The watch-folder scanner uses ``settings.watch_folders`` (a
|
||||||
|
comma-separated string). We mutate the runtime setting so the
|
||||||
|
next scan picks up the folder. We also set
|
||||||
|
``watch_folder_delete_after_process = True`` so files are cleaned
|
||||||
|
up after successful processing.
|
||||||
|
"""
|
||||||
|
current = getattr(settings, "watch_folders", None) or ""
|
||||||
|
folders = [f.strip() for f in current.split(",") if f.strip()]
|
||||||
|
|
||||||
|
if reimport_path not in folders:
|
||||||
|
folders.append(reimport_path)
|
||||||
|
|
||||||
|
# Mutate runtime settings (not persisted to .env — ephemeral)
|
||||||
|
object.__setattr__(settings, "watch_folders", ",".join(folders))
|
||||||
|
object.__setattr__(settings, "watch_folder_delete_after_process", True)
|
||||||
|
logger.info("Configured reimport watch folder: %s", reimport_path)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_startup_reset() -> None:
|
||||||
|
"""Called during application startup when ``FACTORY_RESET_ON_STARTUP=True``.
|
||||||
|
|
||||||
|
Wipes database and filesystem data so the instance starts completely
|
||||||
|
fresh. Uses its own DB session so it runs before the normal lifespan
|
||||||
|
seeding logic.
|
||||||
|
"""
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
logger.warning("FACTORY_RESET_ON_STARTUP is enabled — wiping all data")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
perform_full_reset(db)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Factory reset on startup failed")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -34,6 +34,7 @@ from app.views.share import router as share_router
|
|||||||
from app.views.shared_links import router as shared_links_router
|
from app.views.shared_links import router as shared_links_router
|
||||||
from app.views.status import router as status_router
|
from app.views.status import router as status_router
|
||||||
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
||||||
|
from app.views.system_reset import router as system_reset_router # System reset / factory reset
|
||||||
from app.views.wizard import router as wizard_router
|
from app.views.wizard import router as wizard_router
|
||||||
|
|
||||||
# Create a main router that includes all the view routers
|
# Create a main router that includes all the view routers
|
||||||
@@ -67,3 +68,4 @@ router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs
|
|||||||
router.include_router(audit_logs_router) # Comprehensive audit log viewer
|
router.include_router(audit_logs_router) # Comprehensive audit log viewer
|
||||||
router.include_router(help_router) # Built-in help / How-To docs
|
router.include_router(help_router) # Built-in help / How-To docs
|
||||||
router.include_router(compliance_router) # Compliance templates dashboard
|
router.include_router(compliance_router) # Compliance templates dashboard
|
||||||
|
router.include_router(system_reset_router) # System reset / factory reset
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ def _inject_global_context(ctx: dict) -> None:
|
|||||||
"allow_signup",
|
"allow_signup",
|
||||||
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
||||||
)
|
)
|
||||||
|
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False))
|
||||||
|
|
||||||
req = ctx.get("request")
|
req = ctx.get("request")
|
||||||
if req is not None:
|
if req is not None:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
System reset view — admin-only UI page.
|
||||||
|
|
||||||
|
Renders a confirmation-heavy page that allows administrators to:
|
||||||
|
1. **Full Reset** — wipe all user data (DB + disk) for a fresh start.
|
||||||
|
2. **Reset & Re-import** — move originals to a reimport folder, wipe,
|
||||||
|
and let the watch-folder mechanism re-ingest them.
|
||||||
|
|
||||||
|
Both options are gated behind the ``ENABLE_FACTORY_RESET`` feature flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/system-reset")
|
||||||
|
@require_login
|
||||||
|
@require_admin_access
|
||||||
|
async def system_reset_page(request: Request, db: Session = Depends(get_db)) -> Response:
|
||||||
|
"""Render the system reset administration page."""
|
||||||
|
if not settings.enable_factory_reset:
|
||||||
|
return RedirectResponse(url="/settings", status_code=302)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"system_reset.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"factory_reset_on_startup": settings.factory_reset_on_startup,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -177,6 +177,11 @@
|
|||||||
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||||
</a>
|
</a>
|
||||||
|
{% if enable_factory_reset %}
|
||||||
|
<a href="/admin/system-reset" role="menuitem" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||||
|
<i class="fas fa-skull-crossbones w-4 mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
|
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
|
||||||
</a>
|
</a>
|
||||||
@@ -445,6 +450,11 @@
|
|||||||
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||||
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||||
</a>
|
</a>
|
||||||
|
{% if enable_factory_reset %}
|
||||||
|
<a href="/admin/system-reset" class="block px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50">
|
||||||
|
<i class="fas fa-skull-crossbones mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||||
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
||||||
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
|
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.reset-card { transition: box-shadow 0.2s ease; }
|
||||||
|
.reset-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
|
||||||
|
.confirmation-input { font-family: 'Courier New', monospace; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 py-8" x-data="systemResetApp()">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-skull-crossbones mr-2 text-red-600" aria-hidden="true"></i>
|
||||||
|
{{ _("system_reset.heading") }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ _("system_reset.subtitle") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Factory Reset on Startup banner -->
|
||||||
|
{% if factory_reset_on_startup %}
|
||||||
|
<div class="mb-6 rounded-lg border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-700 p-4" role="alert">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-yellow-800 dark:text-yellow-200">{{ _("system_reset.startup_reset_active") }}</p>
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">{{ _("system_reset.startup_reset_desc") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Warning banner -->
|
||||||
|
<div class="mb-8 rounded-lg border-2 border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-6" role="alert">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fas fa-radiation text-red-600 text-2xl mt-0.5" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-red-800 dark:text-red-200 text-lg">{{ _("system_reset.danger_zone") }}</p>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ _("system_reset.danger_desc") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
|
||||||
|
<!-- Full Reset Card -->
|
||||||
|
<div class="reset-card rounded-xl border-2 border-red-200 dark:border-red-800 bg-white dark:bg-gray-800 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center">
|
||||||
|
<i class="fas fa-trash-alt text-red-600" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.full_reset_title") }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.full_reset_subtitle") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.full_reset_desc") }}</p>
|
||||||
|
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-disc">
|
||||||
|
<li>{{ _("system_reset.full_reset_item_db") }}</li>
|
||||||
|
<li>{{ _("system_reset.full_reset_item_files") }}</li>
|
||||||
|
<li>{{ _("system_reset.full_reset_item_cache") }}</li>
|
||||||
|
<li>{{ _("system_reset.full_reset_item_settings_kept") }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label for="fullResetConfirm" class="block text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||||
|
{{ _("system_reset.type_delete") }}
|
||||||
|
</label>
|
||||||
|
<input id="fullResetConfirm"
|
||||||
|
type="text"
|
||||||
|
x-model="fullResetInput"
|
||||||
|
class="confirmation-input w-full px-3 py-2 border-2 border-red-300 dark:border-red-700 rounded-lg
|
||||||
|
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||||
|
placeholder="DELETE"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-describedby="fullResetHelp" />
|
||||||
|
<p id="fullResetHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_delete_help") }}</p>
|
||||||
|
|
||||||
|
<button @click="executeFullReset()"
|
||||||
|
:disabled="fullResetInput !== 'DELETE' || loading"
|
||||||
|
type="button"
|
||||||
|
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||||
|
text-base font-bold rounded-lg text-white
|
||||||
|
bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||||
|
aria-label="{{ _('system_reset.full_reset_button') }}">
|
||||||
|
<template x-if="loading && activeAction === 'full'">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||||
|
</template>
|
||||||
|
<i x-show="!(loading && activeAction === 'full')" class="fas fa-trash-alt mr-2" aria-hidden="true"></i>
|
||||||
|
{{ _("system_reset.full_reset_button") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset & Re-import Card -->
|
||||||
|
<div class="reset-card rounded-xl border-2 border-orange-200 dark:border-orange-800 bg-white dark:bg-gray-800 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-orange-100 dark:bg-orange-900 flex items-center justify-center">
|
||||||
|
<i class="fas fa-recycle text-orange-600" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.reimport_title") }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.reimport_subtitle") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.reimport_desc") }}</p>
|
||||||
|
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-decimal">
|
||||||
|
<li>{{ _("system_reset.reimport_step_1") }}</li>
|
||||||
|
<li>{{ _("system_reset.reimport_step_2") }}</li>
|
||||||
|
<li>{{ _("system_reset.reimport_step_3") }}</li>
|
||||||
|
</ol>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 italic">{{ _("system_reset.reimport_note") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label for="reimportConfirm" class="block text-sm font-semibold text-orange-700 dark:text-orange-400 mb-2">
|
||||||
|
{{ _("system_reset.type_reimport") }}
|
||||||
|
</label>
|
||||||
|
<input id="reimportConfirm"
|
||||||
|
type="text"
|
||||||
|
x-model="reimportInput"
|
||||||
|
class="confirmation-input w-full px-3 py-2 border-2 border-orange-300 dark:border-orange-700 rounded-lg
|
||||||
|
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="REIMPORT"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-describedby="reimportHelp" />
|
||||||
|
<p id="reimportHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_reimport_help") }}</p>
|
||||||
|
|
||||||
|
<button @click="executeReimport()"
|
||||||
|
:disabled="reimportInput !== 'REIMPORT' || loading"
|
||||||
|
type="button"
|
||||||
|
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||||
|
text-base font-bold rounded-lg text-white
|
||||||
|
bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||||
|
aria-label="{{ _('system_reset.reimport_button') }}">
|
||||||
|
<template x-if="loading && activeAction === 'reimport'">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||||
|
</template>
|
||||||
|
<i x-show="!(loading && activeAction === 'reimport')" class="fas fa-recycle mr-2" aria-hidden="true"></i>
|
||||||
|
{{ _("system_reset.reimport_button") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result banner (shown after a reset completes) -->
|
||||||
|
<div x-show="resultMessage" x-cloak
|
||||||
|
class="mt-8 rounded-lg p-4"
|
||||||
|
:class="resultSuccess ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700' :
|
||||||
|
'bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700'"
|
||||||
|
role="status" aria-live="polite">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i :class="resultSuccess ? 'fas fa-check-circle text-green-600' : 'fas fa-times-circle text-red-600'" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold" :class="resultSuccess ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'"
|
||||||
|
x-text="resultMessage"></p>
|
||||||
|
<pre x-show="resultDetail" x-text="resultDetail"
|
||||||
|
class="mt-2 text-xs overflow-x-auto whitespace-pre-wrap"
|
||||||
|
:class="resultSuccess ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function systemResetApp() {
|
||||||
|
const __i18n = {
|
||||||
|
successFull: {{ _("system_reset.js_success_full") | tojson }},
|
||||||
|
successReimport: {{ _("system_reset.js_success_reimport") | tojson }},
|
||||||
|
errorGeneric: {{ _("system_reset.js_error_generic") | tojson }},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullResetInput: '',
|
||||||
|
reimportInput: '',
|
||||||
|
loading: false,
|
||||||
|
activeAction: null,
|
||||||
|
resultMessage: null,
|
||||||
|
resultDetail: null,
|
||||||
|
resultSuccess: false,
|
||||||
|
|
||||||
|
async executeFullReset() {
|
||||||
|
if (this.fullResetInput !== 'DELETE') return;
|
||||||
|
this.loading = true;
|
||||||
|
this.activeAction = 'full';
|
||||||
|
this.resultMessage = null;
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
const resp = await fetch('/api/admin/system-reset/full', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||||
|
body: JSON.stringify({ confirmation: 'DELETE' }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
this.resultSuccess = true;
|
||||||
|
this.resultMessage = __i18n.successFull;
|
||||||
|
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||||
|
} else {
|
||||||
|
this.resultSuccess = false;
|
||||||
|
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.resultSuccess = false;
|
||||||
|
this.resultMessage = __i18n.errorGeneric;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.fullResetInput = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async executeReimport() {
|
||||||
|
if (this.reimportInput !== 'REIMPORT') return;
|
||||||
|
this.loading = true;
|
||||||
|
this.activeAction = 'reimport';
|
||||||
|
this.resultMessage = null;
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
const resp = await fetch('/api/admin/system-reset/reimport', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||||
|
body: JSON.stringify({ confirmation: 'REIMPORT' }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
this.resultSuccess = true;
|
||||||
|
this.resultMessage = __i18n.successReimport;
|
||||||
|
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||||
|
} else {
|
||||||
|
this.resultSuccess = false;
|
||||||
|
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.resultSuccess = false;
|
||||||
|
this.resultMessage = __i18n.errorGeneric;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.reimportInput = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1184,6 +1184,7 @@
|
|||||||
"nav.skip_to_content": "Skip to main content",
|
"nav.skip_to_content": "Skip to main content",
|
||||||
"nav.status": "Status",
|
"nav.status": "Status",
|
||||||
"nav.subscription": "Subscription",
|
"nav.subscription": "Subscription",
|
||||||
|
"nav.system_reset": "System Reset",
|
||||||
"nav.toggle_dark_mode": "Toggle dark mode",
|
"nav.toggle_dark_mode": "Toggle dark mode",
|
||||||
"nav.toggle_nav": "Toggle navigation menu",
|
"nav.toggle_nav": "Toggle navigation menu",
|
||||||
"nav.upload": "Upload",
|
"nav.upload": "Upload",
|
||||||
@@ -1723,6 +1724,36 @@
|
|||||||
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
|
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
|
||||||
"subscription.upgrade_to_prefix": "Upgrade to",
|
"subscription.upgrade_to_prefix": "Upgrade to",
|
||||||
"subscription.usage_heading": "Usage",
|
"subscription.usage_heading": "Usage",
|
||||||
|
"system_reset.danger_desc": "The actions below will permanently destroy data. They cannot be undone. Application settings and configuration are preserved, but all documents, files, processing history, and audit logs will be deleted.",
|
||||||
|
"system_reset.danger_zone": "Danger Zone — Irreversible Actions",
|
||||||
|
"system_reset.full_reset_button": "Wipe All Data",
|
||||||
|
"system_reset.full_reset_desc": "Permanently deletes all user data from the database and removes all work files from disk. The application will be in its initial state after this operation.",
|
||||||
|
"system_reset.full_reset_item_cache": "All watch-folder caches and ingestion state",
|
||||||
|
"system_reset.full_reset_item_db": "All document records, processing logs, and audit history",
|
||||||
|
"system_reset.full_reset_item_files": "All original, processed, and temporary files on disk",
|
||||||
|
"system_reset.full_reset_item_settings_kept": "Application settings and configuration are preserved",
|
||||||
|
"system_reset.full_reset_subtitle": "Wipe everything and start fresh",
|
||||||
|
"system_reset.full_reset_title": "Full System Reset",
|
||||||
|
"system_reset.heading": "System Reset",
|
||||||
|
"system_reset.js_error_generic": "An error occurred. Please check the server logs for details.",
|
||||||
|
"system_reset.js_success_full": "System reset complete. All user data has been wiped.",
|
||||||
|
"system_reset.js_success_reimport": "Reset complete. Original files have been staged for re-import via the watch folder.",
|
||||||
|
"system_reset.page_title": "System Reset",
|
||||||
|
"system_reset.reimport_button": "Reset & Re-import",
|
||||||
|
"system_reset.reimport_desc": "Copies your original files to a special reimport folder, wipes everything, then lets the watch-folder mechanism re-process them as if they were freshly uploaded.",
|
||||||
|
"system_reset.reimport_note": "Re-imported files will go through the full processing pipeline with the same rate limits and backoff strategy as regular uploads.",
|
||||||
|
"system_reset.reimport_step_1": "Original files are copied to a dedicated reimport folder",
|
||||||
|
"system_reset.reimport_step_2": "All data (database + work files) is wiped clean",
|
||||||
|
"system_reset.reimport_step_3": "The reimport folder is configured as a watch folder for automatic re-ingestion",
|
||||||
|
"system_reset.reimport_subtitle": "Wipe and re-process all original files",
|
||||||
|
"system_reset.reimport_title": "Reset & Re-import",
|
||||||
|
"system_reset.startup_reset_active": "Factory Reset on Startup is ACTIVE",
|
||||||
|
"system_reset.startup_reset_desc": "FACTORY_RESET_ON_STARTUP is enabled. All user data is wiped every time the application starts.",
|
||||||
|
"system_reset.subtitle": "Reset DocuElevate to a clean, fresh state. All user data will be permanently deleted.",
|
||||||
|
"system_reset.type_delete": "Type DELETE to confirm",
|
||||||
|
"system_reset.type_delete_help": "You must type the word DELETE in capital letters to enable the reset button.",
|
||||||
|
"system_reset.type_reimport": "Type REIMPORT to confirm",
|
||||||
|
"system_reset.type_reimport_help": "You must type the word REIMPORT in capital letters to enable the button.",
|
||||||
"terms.cookie_link": "Cookie Policy",
|
"terms.cookie_link": "Cookie Policy",
|
||||||
"terms.heading": "Terms of Service",
|
"terms.heading": "Terms of Service",
|
||||||
"terms.last_updated": "Last Updated:",
|
"terms.last_updated": "Last Updated:",
|
||||||
|
|||||||
Reference in New Issue
Block a user