Merge pull request #148 from christianlouis/copilot/fix-flake8-linter-errors
fix: resolve all 1080 Flake8 linter errors across app/ directory
This commit is contained in:
+14
-11
@@ -1,21 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
API Router module that combines all API endpoints
|
API Router module that combines all API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.azure import router as azure_router
|
||||||
|
from app.api.diagnostic import router as diagnostic_router
|
||||||
|
from app.api.dropbox import router as dropbox_router
|
||||||
|
from app.api.files import router as files_router
|
||||||
|
from app.api.google_drive import router as google_drive_router
|
||||||
|
from app.api.logs import router as logs_router
|
||||||
|
from app.api.onedrive import router as onedrive_router
|
||||||
|
from app.api.openai import router as openai_router
|
||||||
|
from app.api.process import router as process_router
|
||||||
|
from app.api.settings import router as settings_router
|
||||||
|
|
||||||
# Import all the individual routers
|
# Import all the individual routers
|
||||||
from app.api.user import router as user_router
|
from app.api.user import router as user_router
|
||||||
from app.api.files import router as files_router
|
|
||||||
from app.api.process import router as process_router
|
|
||||||
from app.api.diagnostic import router as diagnostic_router
|
|
||||||
from app.api.onedrive import router as onedrive_router
|
|
||||||
from app.api.dropbox import router as dropbox_router
|
|
||||||
from app.api.openai import router as openai_router
|
|
||||||
from app.api.azure import router as azure_router
|
|
||||||
from app.api.google_drive import router as google_drive_router
|
|
||||||
from app.api.logs import router as logs_router
|
|
||||||
from app.api.settings import router as settings_router
|
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
+35
-45
@@ -1,24 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Azure AI API endpoints
|
Azure AI API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, HTTPException, status
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.auth import require_login
|
import logging
|
||||||
from app.config import settings
|
|
||||||
|
import azure.core.exceptions
|
||||||
|
from azure.ai.documentintelligence import DocumentIntelligenceAdministrationClient
|
||||||
|
|
||||||
# Import the Azure modules including the administration client
|
# Import the Azure modules including the administration client
|
||||||
from azure.core.credentials import AzureKeyCredential
|
from azure.core.credentials import AzureKeyCredential
|
||||||
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
from fastapi import APIRouter, Request
|
||||||
from azure.ai.documentintelligence import DocumentIntelligenceAdministrationClient
|
|
||||||
import azure.core.exceptions
|
from app.auth import require_login
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/azure/test")
|
@router.get("/azure/test")
|
||||||
@require_login
|
@require_login
|
||||||
async def test_azure_connection(request: Request):
|
async def test_azure_connection(request: Request):
|
||||||
@@ -28,7 +29,7 @@ async def test_azure_connection(request: Request):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Testing Azure Document Intelligence connection")
|
logger.info("Testing Azure Document Intelligence connection")
|
||||||
|
|
||||||
# Check if Azure configuration is present
|
# Check if Azure configuration is present
|
||||||
if not settings.azure_endpoint or not settings.azure_ai_key:
|
if not settings.azure_endpoint or not settings.azure_ai_key:
|
||||||
logger.warning("Azure Document Intelligence configuration is incomplete")
|
logger.warning("Azure Document Intelligence configuration is incomplete")
|
||||||
@@ -37,88 +38,77 @@ async def test_azure_connection(request: Request):
|
|||||||
missing.append("endpoint")
|
missing.append("endpoint")
|
||||||
if not settings.azure_ai_key:
|
if not settings.azure_ai_key:
|
||||||
missing.append("API key")
|
missing.append("API key")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Azure Document Intelligence configuration is incomplete. Missing: {', '.join(missing)}"
|
"message": f"Azure Document Intelligence configuration is incomplete. Missing: {', '.join(missing)}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to initialize the admin client and make a request to list operations
|
# Try to initialize the admin client and make a request to list operations
|
||||||
try:
|
try:
|
||||||
# Initialize the admin client with credentials
|
# Initialize the admin client with credentials
|
||||||
admin_client = DocumentIntelligenceAdministrationClient(
|
admin_client = DocumentIntelligenceAdministrationClient(
|
||||||
endpoint=settings.azure_endpoint,
|
endpoint=settings.azure_endpoint, credential=AzureKeyCredential(settings.azure_ai_key)
|
||||||
credential=AzureKeyCredential(settings.azure_ai_key)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test the connection by listing operations - this is a documented method in the admin client
|
# Test the connection by listing operations - this is a documented method in the admin client
|
||||||
operations = list(admin_client.list_operations())
|
operations = list(admin_client.list_operations())
|
||||||
|
|
||||||
# Successfully initialized client and made a request
|
# Successfully initialized client and made a request
|
||||||
logger.info("Azure Document Intelligence Admin connection successfully tested")
|
logger.info("Azure Document Intelligence Admin connection successfully tested")
|
||||||
|
|
||||||
# Return success with available operations info
|
# Return success with available operations info
|
||||||
operations_info = []
|
operations_info = []
|
||||||
try:
|
try:
|
||||||
for op in operations:
|
for op in operations:
|
||||||
if hasattr(op, 'operation_id') and op.operation_id:
|
if hasattr(op, "operation_id") and op.operation_id:
|
||||||
op_info = {
|
op_info = {
|
||||||
"id": op.operation_id,
|
"id": op.operation_id,
|
||||||
"status": op.status if hasattr(op, 'status') else "Unknown",
|
"status": op.status if hasattr(op, "status") else "Unknown",
|
||||||
"created": str(op.created_on) if hasattr(op, 'created_on') else "Unknown",
|
"created": str(op.created_on) if hasattr(op, "created_on") else "Unknown",
|
||||||
"kind": op.kind if hasattr(op, 'kind') else "Unknown"
|
"kind": op.kind if hasattr(op, "kind") else "Unknown",
|
||||||
}
|
}
|
||||||
operations_info.append(op_info)
|
operations_info.append(op_info)
|
||||||
|
|
||||||
operation_count = len(operations_info)
|
operation_count = len(operations_info)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Azure Document Intelligence connection is valid. Found {operation_count} operations.",
|
"message": f"Azure Document Intelligence connection is valid. Found {operation_count} operations.",
|
||||||
"endpoint": settings.azure_endpoint,
|
"endpoint": settings.azure_endpoint,
|
||||||
"operations_count": operation_count,
|
"operations_count": operation_count,
|
||||||
"recent_operations": operations_info[:3] if operations_info else []
|
"recent_operations": operations_info[:3] if operations_info else [],
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If error occurs while processing operations info, still return success
|
# If error occurs while processing operations info, still return success
|
||||||
logger.warning(f"Connected to Azure but couldn't parse operations: {e}")
|
logger.warning(f"Connected to Azure but couldn't parse operations: {e}")
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Azure Document Intelligence connection is valid, but couldn't retrieve operations details.",
|
"message": "Azure Document Intelligence connection is valid, "
|
||||||
"endpoint": settings.azure_endpoint
|
"but couldn't retrieve operations details.",
|
||||||
|
"endpoint": settings.azure_endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
except azure.core.exceptions.ClientAuthenticationError as e:
|
except azure.core.exceptions.ClientAuthenticationError as e:
|
||||||
logger.error(f"Azure authentication error: {e}")
|
logger.error(f"Azure authentication error: {e}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Authentication error: Invalid API key or credentials",
|
"message": "Authentication error: Invalid API key or credentials",
|
||||||
"detail": str(e)
|
"detail": str(e),
|
||||||
}
|
}
|
||||||
except azure.core.exceptions.ServiceRequestError as e:
|
except azure.core.exceptions.ServiceRequestError as e:
|
||||||
logger.error(f"Azure service request error: {e}")
|
logger.error(f"Azure service request error: {e}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Service request error: Could not reach the Azure endpoint",
|
"message": "Service request error: Could not reach the Azure endpoint",
|
||||||
"detail": str(e)
|
"detail": str(e),
|
||||||
}
|
}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(f"Azure configuration value error: {e}")
|
logger.error(f"Azure configuration value error: {e}")
|
||||||
return {
|
return {"status": "error", "message": f"Configuration error: {str(e)}", "detail": str(e)}
|
||||||
"status": "error",
|
|
||||||
"message": f"Configuration error: {str(e)}",
|
|
||||||
"detail": str(e)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Azure connection test failed with unexpected error: {e}")
|
logger.error(f"Azure connection test failed with unexpected error: {e}")
|
||||||
return {
|
return {"status": "error", "message": "Connection test failed with unexpected error", "detail": str(e)}
|
||||||
"status": "error",
|
|
||||||
"message": f"Connection test failed with unexpected error",
|
|
||||||
"detail": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unexpected error testing Azure Document Intelligence connection")
|
logger.exception("Unexpected error testing Azure Document Intelligence connection")
|
||||||
return {
|
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
|
||||||
"status": "error",
|
|
||||||
"message": f"Unexpected error: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1
@@ -5,10 +5,11 @@ Common utilities for API routes
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from app.database import SessionLocal
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
+39
-33
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Diagnostic API endpoints
|
Diagnostic API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, Depends
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.auth import require_login, get_current_user
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
from app.auth import get_current_user, require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
@@ -12,6 +14,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/diagnostic/settings")
|
@router.get("/diagnostic/settings")
|
||||||
@require_login
|
@require_login
|
||||||
async def diagnostic_settings(request: Request, current_user: dict = Depends(get_current_user)):
|
async def diagnostic_settings(request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
@@ -19,82 +22,85 @@ async def diagnostic_settings(request: Request, current_user: dict = Depends(get
|
|||||||
API endpoint to dump settings to the log and view basic config information
|
API endpoint to dump settings to the log and view basic config information
|
||||||
This endpoint doesn't expose sensitive information like passwords or tokens
|
This endpoint doesn't expose sensitive information like passwords or tokens
|
||||||
"""
|
"""
|
||||||
from app.utils.config_validator import dump_all_settings, get_settings_for_display
|
from app.utils.config_validator import dump_all_settings
|
||||||
|
|
||||||
# Dump full settings to log for admin to see
|
# Dump full settings to log for admin to see
|
||||||
dump_all_settings()
|
dump_all_settings()
|
||||||
|
|
||||||
# Return safe subset of settings for API response
|
# Return safe subset of settings for API response
|
||||||
safe_settings = {
|
safe_settings = {
|
||||||
"workdir": settings.workdir,
|
"workdir": settings.workdir,
|
||||||
"external_hostname": settings.external_hostname,
|
"external_hostname": settings.external_hostname,
|
||||||
"configured_services": {
|
"configured_services": {
|
||||||
"email": bool(getattr(settings, 'email_host', None)),
|
"email": bool(getattr(settings, "email_host", None)),
|
||||||
"s3": bool(getattr(settings, 's3_bucket_name', None)),
|
"s3": bool(getattr(settings, "s3_bucket_name", None)),
|
||||||
"dropbox": bool(getattr(settings, 'dropbox_refresh_token', None)),
|
"dropbox": bool(getattr(settings, "dropbox_refresh_token", None)),
|
||||||
"onedrive": bool(getattr(settings, 'onedrive_refresh_token', None)),
|
"onedrive": bool(getattr(settings, "onedrive_refresh_token", None)),
|
||||||
"nextcloud": bool(getattr(settings, 'nextcloud_upload_url', None)),
|
"nextcloud": bool(getattr(settings, "nextcloud_upload_url", None)),
|
||||||
"sftp": bool(getattr(settings, 'sftp_host', None)),
|
"sftp": bool(getattr(settings, "sftp_host", None)),
|
||||||
"paperless": bool(getattr(settings, 'paperless_host', None)),
|
"paperless": bool(getattr(settings, "paperless_host", None)),
|
||||||
"google_drive": bool(getattr(settings, 'google_drive_credentials_json', None)),
|
"google_drive": bool(getattr(settings, "google_drive_credentials_json", None)),
|
||||||
"uptime_kuma": bool(getattr(settings, 'uptime_kuma_url', None)),
|
"uptime_kuma": bool(getattr(settings, "uptime_kuma_url", None)),
|
||||||
"auth": bool(getattr(settings, 'authentik_config_url', None)),
|
"auth": bool(getattr(settings, "authentik_config_url", None)),
|
||||||
"openai": bool(getattr(settings, 'openai_api_key', None)),
|
"openai": bool(getattr(settings, "openai_api_key", None)),
|
||||||
"azure": bool(getattr(settings, 'azure_api_key', None) and getattr(settings, 'azure_endpoint', None)),
|
"azure": bool(getattr(settings, "azure_api_key", None) and getattr(settings, "azure_endpoint", None)),
|
||||||
},
|
},
|
||||||
"imap_enabled": bool(getattr(settings, 'imap1_host', None) or getattr(settings, 'imap2_host', None)),
|
"imap_enabled": bool(getattr(settings, "imap1_host", None) or getattr(settings, "imap2_host", None)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"settings": safe_settings,
|
"settings": safe_settings,
|
||||||
"message": "Full settings have been dumped to application logs"
|
"message": "Full settings have been dumped to application logs",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/diagnostic/test-notification")
|
@router.post("/diagnostic/test-notification")
|
||||||
@require_login
|
@require_login
|
||||||
async def test_notification(request: Request):
|
async def test_notification(request: Request):
|
||||||
# Add request_time to request.state
|
# Add request_time to request.state
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
request.state.request_time = datetime.datetime.utcnow().isoformat()
|
request.state.request_time = datetime.datetime.utcnow().isoformat()
|
||||||
"""
|
"""
|
||||||
Send a test notification through all configured notification channels
|
Send a test notification through all configured notification channels
|
||||||
"""
|
"""
|
||||||
from app.utils.notification import send_notification
|
from app.utils.notification import send_notification
|
||||||
|
|
||||||
try:
|
try:
|
||||||
notification_urls = getattr(settings, 'notification_urls', [])
|
notification_urls = getattr(settings, "notification_urls", [])
|
||||||
if not notification_urls:
|
if not notification_urls:
|
||||||
return {
|
return {
|
||||||
"status": "warning",
|
"status": "warning",
|
||||||
"message": "No notification services configured. Add notification URLs to your configuration."
|
"message": "No notification services configured. Add notification URLs to your configuration.",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send a test notification
|
# Send a test notification
|
||||||
hostname = settings.external_hostname or "Document Processor"
|
hostname = settings.external_hostname or "Document Processor"
|
||||||
result = send_notification(
|
result = send_notification(
|
||||||
title=f"Test Notification from {hostname}",
|
title=f"Test Notification from {hostname}",
|
||||||
message=f"This is a test notification sent at {request.state.request_time}. If you're receiving this, notifications are working!",
|
message=(
|
||||||
|
f"This is a test notification sent at {request.state.request_time}. "
|
||||||
|
"If you're receiving this, notifications are working!"
|
||||||
|
),
|
||||||
notification_type="success",
|
notification_type="success",
|
||||||
tags=["test", "notification", "diagnostic"]
|
tags=["test", "notification", "diagnostic"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("Test notification sent successfully")
|
logger.info("Test notification sent successfully")
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Test notification sent successfully to {len(notification_urls)} service(s)",
|
"message": f"Test notification sent successfully to {len(notification_urls)} service(s)",
|
||||||
"services_count": len(notification_urls)
|
"services_count": len(notification_urls),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.warning("Test notification send attempt returned False")
|
logger.warning("Test notification send attempt returned False")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Failed to send test notification. Check application logs for details."
|
"message": "Failed to send test notification. Check application logs for details.",
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error sending test notification: {e}")
|
logger.exception(f"Error sending test notification: {e}")
|
||||||
return {
|
return {"status": "error", "message": f"Error sending notification: {str(e)}"}
|
||||||
"status": "error",
|
|
||||||
"message": f"Error sending notification: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1
@@ -2,10 +2,11 @@
|
|||||||
Dropbox API endpoints
|
Dropbox API endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, status, Form
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|||||||
+8
-8
@@ -435,15 +435,15 @@ def retry_subtask(
|
|||||||
|
|
||||||
# Map subtask names to their corresponding Celery tasks
|
# Map subtask names to their corresponding Celery tasks
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
|
||||||
from app.tasks.upload_to_s3 import upload_to_s3
|
|
||||||
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp
|
|
||||||
from app.tasks.upload_to_sftp import upload_to_sftp
|
|
||||||
from app.tasks.upload_to_email import upload_to_email
|
from app.tasks.upload_to_email import upload_to_email
|
||||||
|
from app.tasks.upload_to_ftp import upload_to_ftp
|
||||||
|
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
||||||
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||||
|
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
||||||
|
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||||
|
from app.tasks.upload_to_s3 import upload_to_s3
|
||||||
|
from app.tasks.upload_to_sftp import upload_to_sftp
|
||||||
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
||||||
|
|
||||||
task_map = {
|
task_map = {
|
||||||
"upload_to_dropbox": upload_to_dropbox,
|
"upload_to_dropbox": upload_to_dropbox,
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
Google Drive API endpoints
|
Google Drive API endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, status, Form
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|||||||
+65
-76
@@ -1,21 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Processing logs API endpoints
|
Processing logs API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, HTTPException, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import desc
|
|
||||||
from typing import Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import require_login
|
import logging
|
||||||
from app.models import ProcessingLog, FileRecord
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.common import get_db
|
from app.api.common import get_db
|
||||||
|
from app.auth import require_login
|
||||||
|
from app.models import FileRecord, ProcessingLog
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs")
|
@router.get("/logs")
|
||||||
@require_login
|
@require_login
|
||||||
def list_processing_logs(
|
def list_processing_logs(
|
||||||
@@ -23,17 +26,17 @@ def list_processing_logs(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
file_id: Optional[int] = Query(None, description="Filter by file ID"),
|
file_id: Optional[int] = Query(None, description="Filter by file ID"),
|
||||||
task_id: Optional[str] = Query(None, description="Filter by task ID"),
|
task_id: Optional[str] = Query(None, description="Filter by task ID"),
|
||||||
limit: int = Query(100, ge=1, le=1000, description="Number of logs to return")
|
limit: int = Query(100, ge=1, le=1000, description="Number of logs to return"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Returns a JSON list of ProcessingLog entries.
|
Returns a JSON list of ProcessingLog entries.
|
||||||
Protected by `@require_login`, so only logged-in sessions can access.
|
Protected by `@require_login`, so only logged-in sessions can access.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- file_id: Optional filter by file ID
|
- file_id: Optional filter by file ID
|
||||||
- task_id: Optional filter by task ID
|
- task_id: Optional filter by task ID
|
||||||
- limit: Maximum number of logs to return (default 100, max 1000)
|
- limit: Maximum number of logs to return (default 100, max 1000)
|
||||||
|
|
||||||
Example response:
|
Example response:
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -49,116 +52,102 @@ def list_processing_logs(
|
|||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
query = db.query(ProcessingLog)
|
query = db.query(ProcessingLog)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if file_id is not None:
|
if file_id is not None:
|
||||||
query = query.filter(ProcessingLog.file_id == file_id)
|
query = query.filter(ProcessingLog.file_id == file_id)
|
||||||
if task_id is not None:
|
if task_id is not None:
|
||||||
query = query.filter(ProcessingLog.task_id == task_id)
|
query = query.filter(ProcessingLog.task_id == task_id)
|
||||||
|
|
||||||
# Order by timestamp descending and limit
|
# Order by timestamp descending and limit
|
||||||
logs = query.order_by(desc(ProcessingLog.timestamp)).limit(limit).all()
|
logs = query.order_by(desc(ProcessingLog.timestamp)).limit(limit).all()
|
||||||
|
|
||||||
# Return a simple list of dicts
|
# Return a simple list of dicts
|
||||||
result = []
|
result = []
|
||||||
for log in logs:
|
for log in logs:
|
||||||
result.append({
|
result.append(
|
||||||
"id": log.id,
|
{
|
||||||
"file_id": log.file_id,
|
"id": log.id,
|
||||||
"task_id": log.task_id,
|
"file_id": log.file_id,
|
||||||
"step_name": log.step_name,
|
"task_id": log.task_id,
|
||||||
"status": log.status,
|
"step_name": log.step_name,
|
||||||
"message": log.message,
|
"status": log.status,
|
||||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None
|
"message": log.message,
|
||||||
})
|
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/file/{file_id}")
|
@router.get("/logs/file/{file_id}")
|
||||||
@require_login
|
@require_login
|
||||||
def get_file_processing_logs(
|
def get_file_processing_logs(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
|
||||||
file_id: int,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get all processing logs for a specific file.
|
Get all processing logs for a specific file.
|
||||||
Returns logs ordered by timestamp (oldest first to show processing flow).
|
Returns logs ordered by timestamp (oldest first to show processing flow).
|
||||||
|
|
||||||
Also includes file metadata if the file exists.
|
Also includes file metadata if the file exists.
|
||||||
"""
|
"""
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
|
||||||
status_code=404,
|
|
||||||
detail=f"File with ID {file_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all logs for this file
|
# Get all logs for this file
|
||||||
logs = db.query(ProcessingLog).filter(
|
logs = db.query(ProcessingLog).filter(ProcessingLog.file_id == file_id).order_by(ProcessingLog.timestamp).all()
|
||||||
ProcessingLog.file_id == file_id
|
|
||||||
).order_by(ProcessingLog.timestamp).all()
|
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
log_list = []
|
log_list = []
|
||||||
for log in logs:
|
for log in logs:
|
||||||
log_list.append({
|
log_list.append(
|
||||||
"id": log.id,
|
{
|
||||||
"task_id": log.task_id,
|
"id": log.id,
|
||||||
"step_name": log.step_name,
|
"task_id": log.task_id,
|
||||||
"status": log.status,
|
"step_name": log.step_name,
|
||||||
"message": log.message,
|
"status": log.status,
|
||||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None
|
"message": log.message,
|
||||||
})
|
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file": {
|
"file": {
|
||||||
"id": file_record.id,
|
"id": file_record.id,
|
||||||
"original_filename": file_record.original_filename,
|
"original_filename": file_record.original_filename,
|
||||||
"file_size": file_record.file_size,
|
"file_size": file_record.file_size,
|
||||||
"mime_type": file_record.mime_type,
|
"mime_type": file_record.mime_type,
|
||||||
"created_at": file_record.created_at.isoformat() if file_record.created_at else None
|
"created_at": file_record.created_at.isoformat() if file_record.created_at else None,
|
||||||
},
|
},
|
||||||
"logs": log_list,
|
"logs": log_list,
|
||||||
"total_logs": len(log_list)
|
"total_logs": len(log_list),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/task/{task_id}")
|
@router.get("/logs/task/{task_id}")
|
||||||
@require_login
|
@require_login
|
||||||
def get_task_processing_logs(
|
def get_task_processing_logs(request: Request, task_id: str, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
|
||||||
task_id: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get all processing logs for a specific task.
|
Get all processing logs for a specific task.
|
||||||
Returns logs ordered by timestamp (oldest first to show processing flow).
|
Returns logs ordered by timestamp (oldest first to show processing flow).
|
||||||
"""
|
"""
|
||||||
# Get all logs for this task
|
# Get all logs for this task
|
||||||
logs = db.query(ProcessingLog).filter(
|
logs = db.query(ProcessingLog).filter(ProcessingLog.task_id == task_id).order_by(ProcessingLog.timestamp).all()
|
||||||
ProcessingLog.task_id == task_id
|
|
||||||
).order_by(ProcessingLog.timestamp).all()
|
|
||||||
|
|
||||||
if not logs:
|
if not logs:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=f"No logs found for task {task_id}")
|
||||||
status_code=404,
|
|
||||||
detail=f"No logs found for task {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
log_list = []
|
log_list = []
|
||||||
for log in logs:
|
for log in logs:
|
||||||
log_list.append({
|
log_list.append(
|
||||||
"id": log.id,
|
{
|
||||||
"file_id": log.file_id,
|
"id": log.id,
|
||||||
"step_name": log.step_name,
|
"file_id": log.file_id,
|
||||||
"status": log.status,
|
"step_name": log.step_name,
|
||||||
"message": log.message,
|
"status": log.status,
|
||||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None
|
"message": log.message,
|
||||||
})
|
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||||
|
}
|
||||||
return {
|
)
|
||||||
"task_id": task_id,
|
|
||||||
"logs": log_list,
|
return {"task_id": task_id, "logs": log_list, "total_logs": len(log_list)}
|
||||||
"total_logs": len(log_list)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-2
@@ -2,12 +2,13 @@
|
|||||||
OneDrive API endpoints
|
OneDrive API endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, status, Form
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.oauth_helper import exchange_oauth_token
|
from app.utils.oauth_helper import exchange_oauth_token
|
||||||
|
|||||||
+17
-25
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
OpenAI API endpoints
|
OpenAI API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, HTTPException, status
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import requests
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/openai/test")
|
@router.get("/openai/test")
|
||||||
@require_login
|
@require_login
|
||||||
async def test_openai_connection(request: Request):
|
async def test_openai_connection(request: Request):
|
||||||
@@ -22,54 +23,45 @@ async def test_openai_connection(request: Request):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import openai
|
import openai
|
||||||
|
|
||||||
logger.info("Testing OpenAI API key validity")
|
logger.info("Testing OpenAI API key validity")
|
||||||
|
|
||||||
# Check if API key is configured
|
# Check if API key is configured
|
||||||
if not settings.openai_api_key:
|
if not settings.openai_api_key:
|
||||||
logger.warning("No OpenAI API key configured")
|
logger.warning("No OpenAI API key configured")
|
||||||
return {
|
return {"status": "error", "message": "No OpenAI API key is configured"}
|
||||||
"status": "error",
|
|
||||||
"message": "No OpenAI API key is configured"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Configure the client
|
# Configure the client
|
||||||
client = openai.OpenAI(api_key=settings.openai_api_key)
|
client = openai.OpenAI(api_key=settings.openai_api_key)
|
||||||
|
|
||||||
# Try to make a simple request to validate the key
|
# Try to make a simple request to validate the key
|
||||||
try:
|
try:
|
||||||
# Use a models list endpoint as a simple validation
|
# Use a models list endpoint as a simple validation
|
||||||
models = client.models.list()
|
models = client.models.list()
|
||||||
|
|
||||||
# If we got here, the key is valid
|
# If we got here, the key is valid
|
||||||
logger.info("OpenAI API key is valid")
|
logger.info("OpenAI API key is valid")
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "OpenAI API key is valid",
|
"message": "OpenAI API key is valid",
|
||||||
"models_available": len(models.data) if hasattr(models, "data") else "Unknown"
|
"models_available": len(models.data) if hasattr(models, "data") else "Unknown",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"OpenAI API key test failed: {error_msg}")
|
logger.error(f"OpenAI API key test failed: {error_msg}")
|
||||||
|
|
||||||
# Determine if this is an authentication error
|
# Determine if this is an authentication error
|
||||||
is_auth_error = "auth" in error_msg.lower() or "api key" in error_msg.lower()
|
is_auth_error = "auth" in error_msg.lower() or "api key" in error_msg.lower()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"API key validation failed: {error_msg}",
|
"message": f"API key validation failed: {error_msg}",
|
||||||
"is_auth_error": is_auth_error
|
"is_auth_error": is_auth_error,
|
||||||
}
|
}
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.exception("OpenAI package not installed")
|
logger.exception("OpenAI package not installed")
|
||||||
return {
|
return {"status": "error", "message": "OpenAI package not installed"}
|
||||||
"status": "error",
|
|
||||||
"message": "OpenAI package not installed"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unexpected error testing OpenAI connection")
|
logger.exception("Unexpected error testing OpenAI connection")
|
||||||
return {
|
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
|
||||||
"status": "error",
|
|
||||||
"message": f"Unexpected error: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|||||||
+50
-109
@@ -3,21 +3,22 @@ API endpoints for managing application settings.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Any, Dict, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
||||||
from sqlalchemy.orm import Session
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
from app.utils.settings_service import (
|
from app.utils.settings_service import (
|
||||||
get_all_settings_from_db,
|
SETTING_METADATA,
|
||||||
save_setting_to_db,
|
|
||||||
delete_setting_from_db,
|
delete_setting_from_db,
|
||||||
|
get_all_settings_from_db,
|
||||||
get_setting_metadata,
|
get_setting_metadata,
|
||||||
get_settings_by_category,
|
get_settings_by_category,
|
||||||
|
save_setting_to_db,
|
||||||
validate_setting_value,
|
validate_setting_value,
|
||||||
SETTING_METADATA,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,27 +29,26 @@ def require_admin(request: Request) -> dict:
|
|||||||
"""
|
"""
|
||||||
Dependency to ensure the user is an admin.
|
Dependency to ensure the user is an admin.
|
||||||
Raises HTTPException if not admin.
|
Raises HTTPException if not admin.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User dict from session
|
User dict from session
|
||||||
"""
|
"""
|
||||||
user = request.session.get("user")
|
user = request.session.get("user")
|
||||||
if not user or not user.get("is_admin"):
|
if not user or not user.get("is_admin"):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Admin access required"
|
|
||||||
)
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
class SettingUpdate(BaseModel):
|
class SettingUpdate(BaseModel):
|
||||||
"""Model for updating a setting"""
|
"""Model for updating a setting"""
|
||||||
|
|
||||||
key: str = Field(..., description="Setting key")
|
key: str = Field(..., description="Setting key")
|
||||||
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
||||||
|
|
||||||
|
|
||||||
class SettingResponse(BaseModel):
|
class SettingResponse(BaseModel):
|
||||||
"""Model for setting response"""
|
"""Model for setting response"""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
value: Optional[str]
|
value: Optional[str]
|
||||||
metadata: Dict[str, Any]
|
metadata: Dict[str, Any]
|
||||||
@@ -56,17 +56,14 @@ class SettingResponse(BaseModel):
|
|||||||
|
|
||||||
class SettingsListResponse(BaseModel):
|
class SettingsListResponse(BaseModel):
|
||||||
"""Model for list of settings"""
|
"""Model for list of settings"""
|
||||||
|
|
||||||
settings: Dict[str, Any]
|
settings: Dict[str, Any]
|
||||||
categories: Dict[str, list]
|
categories: Dict[str, list]
|
||||||
db_settings: Dict[str, str]
|
db_settings: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=SettingsListResponse)
|
@router.get("/", response_model=SettingsListResponse)
|
||||||
async def get_settings(
|
async def get_settings(request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)):
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get all application settings with metadata.
|
Get all application settings with metadata.
|
||||||
Admin only.
|
Admin only.
|
||||||
@@ -77,37 +74,22 @@ async def get_settings(
|
|||||||
for key in SETTING_METADATA.keys():
|
for key in SETTING_METADATA.keys():
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
value = getattr(settings, key)
|
value = getattr(settings, key)
|
||||||
current_settings[key] = {
|
current_settings[key] = {"value": value, "metadata": get_setting_metadata(key)}
|
||||||
"value": value,
|
|
||||||
"metadata": get_setting_metadata(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get settings stored in database
|
# Get settings stored in database
|
||||||
db_settings = get_all_settings_from_db(db)
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
|
||||||
# Get settings organized by category
|
# Get settings organized by category
|
||||||
categories = get_settings_by_category()
|
categories = get_settings_by_category()
|
||||||
|
|
||||||
return SettingsListResponse(
|
return SettingsListResponse(settings=current_settings, categories=categories, db_settings=db_settings)
|
||||||
settings=current_settings,
|
|
||||||
categories=categories,
|
|
||||||
db_settings=db_settings
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving settings: {e}")
|
logger.error(f"Error retrieving settings: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve settings")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to retrieve settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}", response_model=SettingResponse)
|
@router.get("/{key}", response_model=SettingResponse)
|
||||||
async def get_setting(
|
async def get_setting(key: str, request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)):
|
||||||
key: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get a specific setting by key.
|
Get a specific setting by key.
|
||||||
Admin only.
|
Admin only.
|
||||||
@@ -115,20 +97,15 @@ async def get_setting(
|
|||||||
try:
|
try:
|
||||||
# Get current value
|
# Get current value
|
||||||
value = getattr(settings, key, None)
|
value = getattr(settings, key, None)
|
||||||
|
|
||||||
# Get metadata
|
# Get metadata
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
|
|
||||||
return SettingResponse(
|
return SettingResponse(key=key, value=str(value) if value is not None else None, metadata=metadata)
|
||||||
key=key,
|
|
||||||
value=str(value) if value is not None else None,
|
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving setting {key}: {e}")
|
logger.error(f"Error retrieving setting {key}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve setting: {key}"
|
||||||
detail=f"Failed to retrieve setting: {key}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +115,7 @@ async def update_setting(
|
|||||||
setting: SettingUpdate,
|
setting: SettingUpdate,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_admin)
|
admin: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update a specific setting.
|
Update a specific setting.
|
||||||
@@ -149,46 +126,38 @@ async def update_setting(
|
|||||||
if setting.value is not None:
|
if setting.value is not None:
|
||||||
is_valid, error_message = validate_setting_value(key, setting.value)
|
is_valid, error_message = validate_setting_value(key, setting.value)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=error_message
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
success = save_setting_to_db(db, key, setting.value)
|
success = save_setting_to_db(db, key, setting.value)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save setting to database"
|
||||||
detail="Failed to save setting to database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get metadata
|
# Get metadata
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
restart_required = metadata.get("restart_required", False)
|
restart_required = metadata.get("restart_required", False)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Setting '{key}' updated successfully",
|
"message": f"Setting '{key}' updated successfully",
|
||||||
"restart_required": restart_required,
|
"restart_required": restart_required,
|
||||||
"key": key,
|
"key": key,
|
||||||
"value": setting.value
|
"value": setting.value,
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating setting {key}: {e}")
|
logger.error(f"Error updating setting {key}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update setting: {key}"
|
||||||
detail=f"Failed to update setting: {key}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key}")
|
@router.delete("/{key}")
|
||||||
async def delete_setting(
|
async def delete_setting(
|
||||||
key: str,
|
key: str, request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin: dict = Depends(require_admin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete a setting from the database (reverts to environment variable or default).
|
Delete a setting from the database (reverts to environment variable or default).
|
||||||
@@ -197,31 +166,24 @@ async def delete_setting(
|
|||||||
try:
|
try:
|
||||||
success = delete_setting_from_db(db, key)
|
success = delete_setting_from_db(db, key)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Setting '{key}' not found in database")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Setting '{key}' not found in database"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Setting '{key}' deleted from database (will use environment variable or default)"
|
"message": f"Setting '{key}' deleted from database (will use environment variable or default)",
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting setting {key}: {e}")
|
logger.error(f"Error deleting setting {key}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete setting: {key}"
|
||||||
detail=f"Failed to delete setting: {key}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-update")
|
@router.post("/bulk-update")
|
||||||
async def bulk_update_settings(
|
async def bulk_update_settings(
|
||||||
updates: list[SettingUpdate],
|
updates: list[SettingUpdate], request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
admin: dict = Depends(require_admin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update multiple settings at once.
|
Update multiple settings at once.
|
||||||
@@ -229,47 +191,26 @@ async def bulk_update_settings(
|
|||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for update in updates:
|
for update in updates:
|
||||||
try:
|
try:
|
||||||
# Validate the setting value
|
# Validate the setting value
|
||||||
if update.value is not None:
|
if update.value is not None:
|
||||||
is_valid, error_message = validate_setting_value(update.key, update.value)
|
is_valid, error_message = validate_setting_value(update.key, update.value)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
errors.append({
|
errors.append({"key": update.key, "error": error_message})
|
||||||
"key": update.key,
|
|
||||||
"error": error_message
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
success = save_setting_to_db(db, update.key, update.value)
|
success = save_setting_to_db(db, update.key, update.value)
|
||||||
if success:
|
if success:
|
||||||
results.append({
|
results.append({"key": update.key, "value": update.value, "status": "success"})
|
||||||
"key": update.key,
|
|
||||||
"value": update.value,
|
|
||||||
"status": "success"
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
errors.append({
|
errors.append({"key": update.key, "error": "Failed to save to database"})
|
||||||
"key": update.key,
|
|
||||||
"error": "Failed to save to database"
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating setting {update.key}: {e}")
|
logger.error(f"Error updating setting {update.key}: {e}")
|
||||||
errors.append({
|
errors.append({"key": update.key, "error": str(e)})
|
||||||
"key": update.key,
|
|
||||||
"error": str(e)
|
restart_required = any(get_setting_metadata(result["key"]).get("restart_required", False) for result in results)
|
||||||
})
|
|
||||||
|
return {"success": len(errors) == 0, "updated": results, "errors": errors, "restart_required": restart_required}
|
||||||
restart_required = any(
|
|
||||||
get_setting_metadata(result["key"]).get("restart_required", False)
|
|
||||||
for result in results
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": len(errors) == 0,
|
|
||||||
"updated": results,
|
|
||||||
"errors": errors,
|
|
||||||
"restart_required": restart_required
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-4
@@ -1,15 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
User-related API endpoints
|
User-related API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
|
||||||
from hashlib import md5
|
|
||||||
import logging
|
import logging
|
||||||
|
from hashlib import md5
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def whoami_handler(request: Request):
|
async def whoami_handler(request: Request):
|
||||||
"""
|
"""
|
||||||
Returns user info if logged in, else 401.
|
Returns user info if logged in, else 401.
|
||||||
@@ -26,18 +29,20 @@ async def whoami_handler(request: Request):
|
|||||||
# MD5 is used here for Gravatar's URL generation (not for security), so usedforsecurity=False
|
# MD5 is used here for Gravatar's URL generation (not for security), so usedforsecurity=False
|
||||||
email_hash = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
|
email_hash = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
|
||||||
gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
|
gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
|
||||||
|
|
||||||
# Add the gravatar URL to the user object instead of creating a new response
|
# Add the gravatar URL to the user object instead of creating a new response
|
||||||
user_response = user.copy() # Create a copy to avoid modifying the session
|
user_response = user.copy() # Create a copy to avoid modifying the session
|
||||||
user_response["picture"] = gravatar_url
|
user_response["picture"] = gravatar_url
|
||||||
|
|
||||||
return user_response
|
return user_response
|
||||||
|
|
||||||
|
|
||||||
# Register the same handler under two different paths
|
# Register the same handler under two different paths
|
||||||
@router.get("/whoami")
|
@router.get("/whoami")
|
||||||
async def whoami(request: Request):
|
async def whoami(request: Request):
|
||||||
return await whoami_handler(request)
|
return await whoami_handler(request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/whoami")
|
@router.get("/auth/whoami")
|
||||||
async def auth_whoami(request: Request):
|
async def auth_whoami(request: Request):
|
||||||
return await whoami_handler(request)
|
return await whoami_handler(request)
|
||||||
|
|||||||
+25
-37
@@ -1,13 +1,12 @@
|
|||||||
import os
|
|
||||||
import inspect
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import pathlib
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from authlib.integrations.starlette_client import OAuth
|
||||||
from fastapi import APIRouter, Request, status
|
from fastapi import APIRouter, Request, status
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import pathlib
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -63,35 +62,33 @@ def get_gravatar_url(email):
|
|||||||
"""Generate a Gravatar URL for the given email"""
|
"""Generate a Gravatar URL for the given email"""
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
# MD5 is used here for Gravatar's URL generation (not for security), so usedforsecurity=False
|
# MD5 is used here for Gravatar's URL generation (not for security), so usedforsecurity=False
|
||||||
email_hash = hashlib.md5(email.encode('utf-8'), usedforsecurity=False).hexdigest()
|
email_hash = hashlib.md5(email.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
|
return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
|
||||||
|
|
||||||
|
|
||||||
if AUTH_ENABLED:
|
if AUTH_ENABLED:
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
"""Show login page with appropriate authentication options"""
|
"""Show login page with appropriate authentication options"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"error": request.query_params.get("error"),
|
"error": request.query_params.get("error"),
|
||||||
"message": request.query_params.get("message"),
|
"message": request.query_params.get("message"),
|
||||||
"show_oauth": OAUTH_CONFIGURED,
|
"show_oauth": OAUTH_CONFIGURED,
|
||||||
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
||||||
"app_version": settings.version # Changed from app_version to version
|
"app_version": settings.version, # Changed from app_version to version
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/oauth-login")
|
@router.get("/oauth-login")
|
||||||
async def oauth_login(request: Request):
|
async def oauth_login(request: Request):
|
||||||
"""Handle OAuth login flow"""
|
"""Handle OAuth login flow"""
|
||||||
if not OAUTH_CONFIGURED:
|
if not OAUTH_CONFIGURED:
|
||||||
return RedirectResponse(
|
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
|
||||||
url="/login?error=OAuth+not+configured",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
redirect_uri = request.url_for("oauth_callback")
|
redirect_uri = request.url_for("oauth_callback")
|
||||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
@@ -103,17 +100,16 @@ if AUTH_ENABLED:
|
|||||||
userinfo = token.get("userinfo")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo:
|
if not userinfo:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url="/login?error=Failed+to+retrieve+user+information",
|
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store user info in session
|
# Store user info in session
|
||||||
user_data = dict(userinfo)
|
user_data = dict(userinfo)
|
||||||
|
|
||||||
# Add Gravatar picture if no picture is provided
|
# Add Gravatar picture if no picture is provided
|
||||||
if not user_data.get("picture") and user_data.get("email"):
|
if not user_data.get("picture") and user_data.get("email"):
|
||||||
user_data["picture"] = get_gravatar_url(user_data["email"])
|
user_data["picture"] = get_gravatar_url(user_data["email"])
|
||||||
|
|
||||||
# Check if user is admin based on OAuth groups or specific email
|
# Check if user is admin based on OAuth groups or specific email
|
||||||
# You can customize this logic based on your OAuth provider's attributes
|
# You can customize this logic based on your OAuth provider's attributes
|
||||||
# For example, check if user has an "admin" group or specific email domain
|
# For example, check if user has an "admin" group or specific email domain
|
||||||
@@ -122,23 +118,22 @@ if AUTH_ENABLED:
|
|||||||
# Check if user is in admin group
|
# Check if user is in admin group
|
||||||
groups = user_data.get("groups", [])
|
groups = user_data.get("groups", [])
|
||||||
is_admin = "admin" in groups or "administrators" in groups
|
is_admin = "admin" in groups or "administrators" in groups
|
||||||
|
|
||||||
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
|
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
|
||||||
user_data["is_admin"] = is_admin
|
user_data["is_admin"] = is_admin
|
||||||
|
|
||||||
request.session["user"] = user_data
|
request.session["user"] = user_data
|
||||||
|
|
||||||
# Log the successful authentication
|
# Log the successful authentication
|
||||||
print(f"User authenticated via OAuth: {user_data.get('email', 'No email')} (admin: {is_admin})")
|
print(f"User authenticated via OAuth: {user_data.get('email', 'No email')} (admin: {is_admin})")
|
||||||
|
|
||||||
# Redirect to original destination or default
|
# Redirect to original destination or default
|
||||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||||
return RedirectResponse(url=redirect_url)
|
return RedirectResponse(url=redirect_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"OAuth authentication error: {str(e)}")
|
print(f"OAuth authentication error: {str(e)}")
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"/login?error=Authentication+failed:+{str(e)}",
|
url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/auth")
|
@router.post("/auth")
|
||||||
@@ -147,9 +142,8 @@ if AUTH_ENABLED:
|
|||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
username = form_data.get("username")
|
username = form_data.get("username")
|
||||||
password = form_data.get("password")
|
password = form_data.get("password")
|
||||||
|
|
||||||
if (username == settings.admin_username and
|
if username == settings.admin_username and password == settings.admin_password:
|
||||||
password == settings.admin_password):
|
|
||||||
# Create user session
|
# Create user session
|
||||||
request.session["user"] = {
|
request.session["user"] = {
|
||||||
"id": "admin",
|
"id": "admin",
|
||||||
@@ -157,25 +151,19 @@ if AUTH_ENABLED:
|
|||||||
"email": f"{username}@local.docuelevate",
|
"email": f"{username}@local.docuelevate",
|
||||||
"preferred_username": username,
|
"preferred_username": username,
|
||||||
"picture": "/static/images/default-avatar.svg",
|
"picture": "/static/images/default-avatar.svg",
|
||||||
"is_admin": True
|
"is_admin": True,
|
||||||
}
|
}
|
||||||
# Redirect to original destination or default
|
# Redirect to original destination or default
|
||||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
else:
|
else:
|
||||||
return RedirectResponse(
|
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
|
||||||
url="/login?error=Invalid+username+or+password",
|
|
||||||
status_code=302
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
"""Handle user logout"""
|
"""Handle user logout"""
|
||||||
request.session.pop("user", None)
|
request.session.pop("user", None)
|
||||||
return RedirectResponse(
|
return RedirectResponse(url="/login?message=You+have+been+logged+out+successfully", status_code=302)
|
||||||
url="/login?message=You+have+been+logged+out+successfully",
|
|
||||||
status_code=302
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/auth/whoami")
|
@router.get("/api/auth/whoami")
|
||||||
|
|||||||
+13
-10
@@ -1,6 +1,8 @@
|
|||||||
# app/celery_app.py
|
# app/celery_app.py
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.signals import task_failure
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
celery = Celery(
|
celery = Celery(
|
||||||
@@ -14,29 +16,30 @@ celery = Celery(
|
|||||||
celery.conf.broker_connection_retry_on_startup = True
|
celery.conf.broker_connection_retry_on_startup = True
|
||||||
|
|
||||||
# Set the default queue and routing so that tasks are enqueued on "document_processor"
|
# Set the default queue and routing so that tasks are enqueued on "document_processor"
|
||||||
celery.conf.task_default_queue = 'document_processor'
|
celery.conf.task_default_queue = "document_processor"
|
||||||
celery.conf.task_routes = {
|
celery.conf.task_routes = {
|
||||||
"app.tasks.*": {"queue": "document_processor"},
|
"app.tasks.*": {"queue": "document_processor"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Task failure notification handler
|
|
||||||
from celery.signals import task_failure
|
|
||||||
|
|
||||||
@task_failure.connect
|
@task_failure.connect
|
||||||
def task_failure_handler(sender=None, task_id=None, exception=None, args=None,
|
def task_failure_handler(
|
||||||
kwargs=None, traceback=None, einfo=None, **kw):
|
sender=None, task_id=None, exception=None, args=None, kwargs=None, traceback=None, einfo=None, **kw
|
||||||
|
):
|
||||||
"""Handler for Celery task failures to send notifications"""
|
"""Handler for Celery task failures to send notifications"""
|
||||||
if getattr(settings, 'notify_on_task_failure', True):
|
if getattr(settings, "notify_on_task_failure", True):
|
||||||
try:
|
try:
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from app.utils.notification import notify_celery_failure
|
from app.utils.notification import notify_celery_failure
|
||||||
|
|
||||||
notify_celery_failure(
|
notify_celery_failure(
|
||||||
task_name=sender.name if sender else "Unknown",
|
task_name=sender.name if sender else "Unknown",
|
||||||
task_id=task_id or "N/A",
|
task_id=task_id or "N/A",
|
||||||
exc=exception,
|
exc=exception,
|
||||||
args=args or [],
|
args=args or [],
|
||||||
kwargs=kwargs or {}
|
kwargs=kwargs or {},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.exception(f"Failed to send task failure notification: {e}")
|
logging.exception(f"Failed to send task failure notification: {e}")
|
||||||
|
|||||||
+47
-40
@@ -1,65 +1,72 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from app.config import settings
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
# Ensure tasks are loaded
|
||||||
|
from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can register tasks
|
||||||
|
|
||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
# Ensure tasks are loaded
|
from app.tasks.check_credentials import check_credentials
|
||||||
from app import tasks # <— This imports app/tasks.py so Celery can register tasks
|
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
|
||||||
|
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf # noqa: F401
|
||||||
|
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # noqa: F401
|
||||||
|
from app.tasks.imap_tasks import pull_all_inboxes # noqa: F401
|
||||||
|
|
||||||
# **Ensure all tasks are imported before Celery starts**
|
# **Ensure all tasks are imported before Celery starts**
|
||||||
from app.tasks.process_document import process_document
|
from app.tasks.process_document import process_document # noqa: F401
|
||||||
from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence
|
from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence # noqa: F401
|
||||||
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
|
from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
|
||||||
from app.tasks.refine_text_with_gpt import refine_text_with_gpt
|
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
|
||||||
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
|
||||||
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
|
|
||||||
from app.tasks.convert_to_pdf import convert_to_pdf
|
|
||||||
|
|
||||||
# Import new send tasks
|
# Import new send tasks
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
from app.tasks.upload_to_email import upload_to_email # noqa: F401
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
|
||||||
from app.tasks.upload_to_webdav import upload_to_webdav
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401
|
||||||
from app.tasks.upload_to_s3 import upload_to_s3
|
from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp
|
from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401
|
||||||
from app.tasks.upload_to_sftp import upload_to_sftp
|
from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401
|
||||||
from app.tasks.upload_to_email import upload_to_email
|
from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401
|
||||||
|
from app.tasks.uptime_kuma_tasks import ping_uptime_kuma # noqa: F401
|
||||||
from app.tasks.imap_tasks import pull_all_inboxes
|
|
||||||
from app.tasks.send_to_all import send_to_all_destinations
|
|
||||||
from app.tasks.uptime_kuma_tasks import ping_uptime_kuma
|
|
||||||
from app.tasks.check_credentials import check_credentials
|
|
||||||
|
|
||||||
celery.conf.task_routes = {
|
celery.conf.task_routes = {
|
||||||
"app.tasks.*": {"queue": "default"},
|
"app.tasks.*": {"queue": "default"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery.task
|
@celery.task
|
||||||
def test_task():
|
def test_task():
|
||||||
return "Celery is working!"
|
return "Celery is working!"
|
||||||
|
|
||||||
# If you want Celery Beat to run the poll task every minute, add:
|
|
||||||
from celery.schedules import crontab
|
|
||||||
|
|
||||||
# Run the check_credentials task at startup
|
# Run the check_credentials task at startup
|
||||||
check_credentials.apply_async(countdown=10) # Run 10 seconds after worker starts
|
check_credentials.apply_async(countdown=10) # Run 10 seconds after worker starts
|
||||||
|
|
||||||
celery.conf.beat_schedule = {
|
celery.conf.beat_schedule = {
|
||||||
"poll-inboxes-every-minute": {
|
"poll-inboxes-every-minute": (
|
||||||
"task": "app.tasks.imap_tasks.pull_all_inboxes",
|
{
|
||||||
"schedule": crontab(minute="*/1"), # every 1 minute
|
"task": "app.tasks.imap_tasks.pull_all_inboxes",
|
||||||
"options": {"expires": 55}, # Ensure tasks don't pile up
|
"schedule": crontab(minute="*/1"), # every 1 minute
|
||||||
} if (settings.imap1_host or settings.imap2_host) else None,
|
"options": {"expires": 55}, # Ensure tasks don't pile up
|
||||||
|
}
|
||||||
|
if (settings.imap1_host or settings.imap2_host)
|
||||||
|
else None
|
||||||
|
),
|
||||||
# Add Uptime Kuma ping task if configured
|
# Add Uptime Kuma ping task if configured
|
||||||
"ping-uptime-kuma": {
|
"ping-uptime-kuma": (
|
||||||
"task": "app.tasks.uptime_kuma_tasks.ping_uptime_kuma",
|
{
|
||||||
"schedule": crontab(minute=f"*/{settings.uptime_kuma_ping_interval}"),
|
"task": "app.tasks.uptime_kuma_tasks.ping_uptime_kuma",
|
||||||
"options": {"expires": 55}, # Ensure tasks don't pile up
|
"schedule": crontab(minute=f"*/{settings.uptime_kuma_ping_interval}"),
|
||||||
} if settings.uptime_kuma_url else None,
|
"options": {"expires": 55}, # Ensure tasks don't pile up
|
||||||
|
}
|
||||||
|
if settings.uptime_kuma_url
|
||||||
|
else None
|
||||||
|
),
|
||||||
# Check credentials every 5 minutes
|
# Check credentials every 5 minutes
|
||||||
"check-credentials-regularly": {
|
"check-credentials-regularly": {
|
||||||
"task": "app.tasks.check_credentials.check_credentials",
|
"task": "app.tasks.check_credentials.check_credentials",
|
||||||
@@ -71,8 +78,8 @@ celery.conf.beat_schedule = {
|
|||||||
"task": "app.tasks.check_credentials.check_credentials",
|
"task": "app.tasks.check_credentials.check_credentials",
|
||||||
"schedule": crontab(hour="0", minute="0"), # Midnight
|
"schedule": crontab(hour="0", minute="0"), # Midnight
|
||||||
"options": {"expires": 3600}, # 1 hour expiry
|
"options": {"expires": 3600}, # 1 hour expiry
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove None entries from beat_schedule
|
# Remove None entries from beat_schedule
|
||||||
celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None}
|
celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None}
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from typing import Any, List, Optional, Union
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
|
|
||||||
from pydantic import Field, validator
|
from pydantic import Field, validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
# app/database.py
|
# app/database.py
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from sqlalchemy import create_engine, exc
|
from sqlalchemy import create_engine, exc
|
||||||
|
from sqlalchemy.engine.url import make_url
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.engine.url import make_url
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def init_db():
|
|||||||
if url.get_backend_name() == "sqlite":
|
if url.get_backend_name() == "sqlite":
|
||||||
# 2. Extract the database path from the URL
|
# 2. Extract the database path from the URL
|
||||||
database_path = url.database # e.g. "/workdir/db/database.db" or ":memory:"
|
database_path = url.database # e.g. "/workdir/db/database.db" or ":memory:"
|
||||||
|
|
||||||
if database_path != ":memory:":
|
if database_path != ":memory:":
|
||||||
# 3. Ensure directory exists
|
# 3. Ensure directory exists
|
||||||
db_dir = os.path.dirname(database_path)
|
db_dir = os.path.dirname(database_path)
|
||||||
@@ -43,7 +43,7 @@ def init_db():
|
|||||||
if not os.path.exists(database_path):
|
if not os.path.exists(database_path):
|
||||||
logger.info(f"Creating new SQLite database file at {database_path}")
|
logger.info(f"Creating new SQLite database file at {database_path}")
|
||||||
open(database_path, "a").close()
|
open(database_path, "a").close()
|
||||||
|
|
||||||
# 5. Now create tables if they don't exist yet
|
# 5. Now create tables if they don't exist yet
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
Frontend routes for the application.
|
Frontend routes for the application.
|
||||||
This module is now a re-export of the modularized view routers.
|
This module is now a re-export of the modularized view routers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Import and re-export the router from the views package
|
# Import and re-export the router from the views package
|
||||||
from app.views import router
|
from app.views import router # noqa: F401
|
||||||
|
|
||||||
# Keep the original router name for compatibility
|
# Keep the original router name for compatibility
|
||||||
# This allows existing imports in main.py to continue working
|
# This allows existing imports in main.py to continue working
|
||||||
|
|
||||||
|
|||||||
+41
-49
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -8,21 +8,20 @@ from fastapi import FastAPI, HTTPException, Request, status
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
|
||||||
from starlette.config import Config
|
from starlette.config import Config
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.database import init_db
|
from app.api import router as api_router
|
||||||
|
from app.auth import router as auth_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import init_db
|
||||||
from app.utils.config_validator import check_all_configs
|
from app.utils.config_validator import check_all_configs
|
||||||
from app.utils.notification import init_apprise, send_notification, notify_startup, notify_shutdown
|
from app.utils.notification import init_apprise, notify_shutdown, notify_startup
|
||||||
|
|
||||||
# Import the routers - now using views directly instead of frontend
|
# Import the routers - now using views directly instead of frontend
|
||||||
from app.views import router as frontend_router
|
from app.views import router as frontend_router
|
||||||
from app.api import router as api_router
|
|
||||||
from app.auth import router as auth_router
|
|
||||||
|
|
||||||
# Explicitly include the files router
|
# Explicitly include the files router
|
||||||
from app.views.files import router as files_router
|
from app.views.files import router as files_router
|
||||||
@@ -32,8 +31,13 @@ config = Config(".env")
|
|||||||
# Use settings.session_secret which has proper validation
|
# Use settings.session_secret which has proper validation
|
||||||
# Fallback to raising an error if not set when auth is enabled
|
# Fallback to raising an error if not set when auth is enabled
|
||||||
if settings.auth_enabled and not settings.session_secret:
|
if settings.auth_enabled and not settings.session_secret:
|
||||||
raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True. Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'")
|
raise ValueError(
|
||||||
SESSION_SECRET = settings.session_secret or "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
|
"SESSION_SECRET must be set when AUTH_ENABLED=True. "
|
||||||
|
"Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'"
|
||||||
|
)
|
||||||
|
SESSION_SECRET = (
|
||||||
|
settings.session_secret or "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -44,11 +48,11 @@ 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
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
load_settings_from_db(settings, db)
|
load_settings_from_db(settings, db)
|
||||||
@@ -57,37 +61,38 @@ async def lifespan(app: FastAPI):
|
|||||||
logging.error(f"Failed to load database settings: {e}")
|
logging.error(f"Failed to load database settings: {e}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Force settings dump to log for troubleshooting
|
# Force settings dump to log for troubleshooting
|
||||||
from app.utils.config_validator import dump_all_settings
|
from app.utils.config_validator import dump_all_settings
|
||||||
|
|
||||||
dump_all_settings()
|
dump_all_settings()
|
||||||
|
|
||||||
# Validate configuration
|
# Validate configuration
|
||||||
config_issues = check_all_configs()
|
config_issues = check_all_configs()
|
||||||
|
|
||||||
# Log overall status
|
# Log overall status
|
||||||
has_issues = any(config_issues['email']) or any(
|
has_issues = any(config_issues["email"]) or any(
|
||||||
len(issues) > 0 for provider, issues in config_issues['storage'].items()
|
len(issues) > 0 for provider, issues in config_issues["storage"].items()
|
||||||
)
|
)
|
||||||
if has_issues:
|
if has_issues:
|
||||||
logging.warning("Application started with configuration issues - some features may be unavailable")
|
logging.warning("Application started with configuration issues - some features may be unavailable")
|
||||||
else:
|
else:
|
||||||
logging.info("Application started with valid configuration")
|
logging.info("Application started with valid configuration")
|
||||||
|
|
||||||
logging.info("Router organization: Using refactored API routers from app/api/ directory")
|
logging.info("Router organization: Using refactored API routers from app/api/ directory")
|
||||||
|
|
||||||
# Initialize notification system
|
# Initialize notification system
|
||||||
init_apprise()
|
init_apprise()
|
||||||
|
|
||||||
# Send startup notification
|
# Send startup notification
|
||||||
notify_startup()
|
notify_startup()
|
||||||
|
|
||||||
# Application is now running
|
# Application is now running
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown: Cleanup tasks
|
# Shutdown: Cleanup tasks
|
||||||
logging.info("Application shutting down")
|
logging.info("Application shutting down")
|
||||||
|
|
||||||
# Send shutdown notification
|
# Send shutdown notification
|
||||||
notify_shutdown()
|
notify_shutdown()
|
||||||
|
|
||||||
@@ -101,11 +106,7 @@ app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
|||||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||||
|
|
||||||
# 3) (Optional but recommended) Restrict valid hosts:
|
# 3) (Optional but recommended) Restrict valid hosts:
|
||||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.external_hostname, "localhost", "127.0.0.1"])
|
||||||
settings.external_hostname,
|
|
||||||
"localhost",
|
|
||||||
"127.0.0.1"
|
|
||||||
])
|
|
||||||
|
|
||||||
# Mount the static files directory
|
# Mount the static files directory
|
||||||
static_dir = pathlib.Path(__file__).parents[1] / "frontend" / "static"
|
static_dir = pathlib.Path(__file__).parents[1] / "frontend" / "static"
|
||||||
@@ -114,6 +115,7 @@ if os.path.exists(static_dir):
|
|||||||
else:
|
else:
|
||||||
print(f"WARNING: Static directory not found at {static_dir}. Static files will not be served.")
|
print(f"WARNING: Static directory not found at {static_dir}. Static files will not be served.")
|
||||||
|
|
||||||
|
|
||||||
# Custom exception handlers that return JSON for API routes and HTML for frontend routes
|
# Custom exception handlers that return JSON for API routes and HTML for frontend routes
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
@@ -123,30 +125,24 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||||||
"""
|
"""
|
||||||
# For API routes, always return JSON
|
# For API routes, always return JSON
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
status_code=exc.status_code,
|
|
||||||
content={"detail": exc.detail}
|
|
||||||
)
|
|
||||||
|
|
||||||
# For frontend routes, return appropriate HTML templates
|
# For frontend routes, return appropriate HTML templates
|
||||||
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
|
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
|
||||||
|
|
||||||
# Handle 404 errors with a custom template
|
# Handle 404 errors with a custom template
|
||||||
if exc.status_code == 404:
|
if exc.status_code == 404:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND)
|
||||||
"404.html",
|
|
||||||
{"request": request},
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# For other HTTP errors, we could create specific templates or use a generic one
|
# For other HTTP errors, we could create specific templates or use a generic one
|
||||||
# For now, return a simple error page
|
# For now, return a simple error page
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
||||||
{"request": request},
|
{"request": request},
|
||||||
status_code=exc.status_code
|
status_code=exc.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(500)
|
@app.exception_handler(500)
|
||||||
async def custom_500_handler(request: Request, exc: Exception):
|
async def custom_500_handler(request: Request, exc: Exception):
|
||||||
"""
|
"""
|
||||||
@@ -156,27 +152,23 @@ async def custom_500_handler(request: Request, exc: Exception):
|
|||||||
# For API routes, return JSON instead of HTML
|
# For API routes, return JSON instead of HTML
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Internal server error"}
|
||||||
content={"detail": "Internal server error"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Serve the 500 template for non-API routes
|
# Serve the 500 template for non-API routes
|
||||||
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
|
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"500.html",
|
"500.html", {"request": request, "exc": exc}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
{"request": request, "exc": exc},
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/test-500")
|
@app.get("/test-500")
|
||||||
def test_500():
|
def test_500():
|
||||||
raise RuntimeError("Testing forced 500 error!")
|
raise RuntimeError("Testing forced 500 error!")
|
||||||
|
|
||||||
|
|
||||||
# Include the routers
|
# Include the routers
|
||||||
app.include_router(frontend_router)
|
app.include_router(frontend_router)
|
||||||
app.include_router(files_router) # Explicitly include the files router
|
app.include_router(files_router) # Explicitly include the files router
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -1,10 +1,10 @@
|
|||||||
# app/models.py
|
# app/models.py
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class DocumentMetadata(Base):
|
class DocumentMetadata(Base):
|
||||||
__tablename__ = "documents"
|
__tablename__ = "documents"
|
||||||
|
|
||||||
@@ -15,11 +15,12 @@ class DocumentMetadata(Base):
|
|||||||
tags = Column(String)
|
tags = Column(String)
|
||||||
summary = Column(String)
|
summary = Column(String)
|
||||||
|
|
||||||
|
|
||||||
class FileRecord(Base):
|
class FileRecord(Base):
|
||||||
__tablename__ = "files"
|
__tablename__ = "files"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
# Hash of the file content (e.g. SHA-256)
|
# Hash of the file content (e.g. SHA-256)
|
||||||
filehash = Column(String, unique=True, index=True, nullable=False)
|
filehash = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
|
||||||
@@ -38,20 +39,23 @@ class FileRecord(Base):
|
|||||||
# Timestamp when we inserted this record
|
# Timestamp when we inserted this record
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class ProcessingLog(Base):
|
class ProcessingLog(Base):
|
||||||
__tablename__ = "processing_logs"
|
__tablename__ = "processing_logs"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=True) # Optional file association
|
file_id = Column(Integer, ForeignKey("files.id"), nullable=True) # Optional file association
|
||||||
task_id = Column(String, index=True) # Celery task ID
|
task_id = Column(String, index=True) # Celery task ID
|
||||||
step_name = Column(String) # e.g., "OCR", "convert_to_pdf", "upload_s3"
|
step_name = Column(String) # e.g., "OCR", "convert_to_pdf", "upload_s3"
|
||||||
status = Column(String) # "pending", "in_progress", "success", "failure"
|
status = Column(String) # "pending", "in_progress", "success", "failure"
|
||||||
message = Column(String, nullable=True) # Error text or success note
|
message = Column(String, nullable=True) # Error text or success note
|
||||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class ApplicationSettings(Base):
|
class ApplicationSettings(Base):
|
||||||
"""Store application settings in database with precedence over environment variables"""
|
"""Store application settings in database with precedence over environment variables"""
|
||||||
|
|
||||||
__tablename__ = "application_settings"
|
__tablename__ = "application_settings"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
key = Column(String, unique=True, index=True, nullable=False) # Setting key (e.g., 'database_url')
|
key = Column(String, unique=True, index=True, nullable=False) # Setting key (e.g., 'database_url')
|
||||||
value = Column(String, nullable=True) # Setting value (stored as string, converted as needed)
|
value = Column(String, nullable=True) # Setting value (stored as string, converted as needed)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/licenses/lgpl.txt", response_class=PlainTextResponse)
|
@router.get("/licenses/lgpl.txt", response_class=PlainTextResponse)
|
||||||
async def get_lgpl_license():
|
async def get_lgpl_license():
|
||||||
"""
|
"""
|
||||||
@@ -13,6 +14,6 @@ async def get_lgpl_license():
|
|||||||
license_path = Path("frontend/static/licenses/lgpl.txt")
|
license_path = Path("frontend/static/licenses/lgpl.txt")
|
||||||
if not license_path.exists():
|
if not license_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="License file not found")
|
raise HTTPException(status_code=404, detail="License file not found")
|
||||||
|
|
||||||
with open(license_path, "r") as f:
|
with open(license_path, "r") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Import tasks so they can be discovered by Celery
|
# Import tasks so they can be discovered by Celery
|
||||||
from app.tasks.process_document import process_document
|
from app.tasks.process_document import process_document # noqa: F401
|
||||||
from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence
|
from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence # noqa: F401
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import logging
|
|
||||||
from app.celery_app import celery
|
|
||||||
from app.config import settings
|
|
||||||
from app.utils.notification import notify_credential_failure
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
# Import the test functions from API routes
|
|
||||||
from app.api.openai import test_openai_connection
|
|
||||||
from app.api.azure import test_azure_connection
|
from app.api.azure import test_azure_connection
|
||||||
from app.api.dropbox import test_dropbox_token
|
from app.api.dropbox import test_dropbox_token
|
||||||
from app.api.google_drive import test_google_drive_token
|
from app.api.google_drive import test_google_drive_token
|
||||||
from app.api.onedrive import test_onedrive_token
|
from app.api.onedrive import test_onedrive_token
|
||||||
|
|
||||||
|
# Import the test functions from API routes
|
||||||
|
from app.api.openai import test_openai_connection
|
||||||
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
# Import config validation utilities
|
# Import config validation utilities
|
||||||
from app.utils.config_validator import validate_storage_configs, get_provider_status
|
from app.utils.config_validator import get_provider_status, validate_storage_configs
|
||||||
|
from app.utils.notification import notify_credential_failure
|
||||||
|
|
||||||
|
|
||||||
# Create an enhanced mock Request object for API functions that expect it
|
# Create an enhanced mock Request object for API functions that expect it
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
"""Mock request object with session and other attributes needed for API functions"""
|
"""Mock request object with session and other attributes needed for API functions"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = {"user": {"id": "credential_checker", "name": "System Credential Checker"}}
|
self.session = {"user": {"id": "credential_checker", "name": "System Credential Checker"}}
|
||||||
self.app = None
|
self.app = None
|
||||||
@@ -34,31 +37,35 @@ class MockRequest:
|
|||||||
async def form(self):
|
async def form(self):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Path to store failure counts
|
# Path to store failure counts
|
||||||
FAILURE_STATE_FILE = os.path.join(settings.workdir, 'credential_failures.json')
|
FAILURE_STATE_FILE = os.path.join(settings.workdir, "credential_failures.json")
|
||||||
|
|
||||||
|
|
||||||
def get_failure_state():
|
def get_failure_state():
|
||||||
"""Read the failure state from file"""
|
"""Read the failure state from file"""
|
||||||
try:
|
try:
|
||||||
if os.path.exists(FAILURE_STATE_FILE):
|
if os.path.exists(FAILURE_STATE_FILE):
|
||||||
with open(FAILURE_STATE_FILE, 'r') as f:
|
with open(FAILURE_STATE_FILE, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading failure state file: {e}")
|
logger.error(f"Error reading failure state file: {e}")
|
||||||
|
|
||||||
# Default empty state
|
# Default empty state
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_failure_state(state):
|
def save_failure_state(state):
|
||||||
"""Save failure state to file"""
|
"""Save failure state to file"""
|
||||||
try:
|
try:
|
||||||
with open(FAILURE_STATE_FILE, 'w') as f:
|
with open(FAILURE_STATE_FILE, "w") as f:
|
||||||
json.dump(state, f)
|
json.dump(state, f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving failure state file: {e}")
|
logger.error(f"Error saving failure state file: {e}")
|
||||||
|
|
||||||
|
|
||||||
# Helper function to get the inner function without the decorator
|
# Helper function to get the inner function without the decorator
|
||||||
def unwrap_decorated_function(func):
|
def unwrap_decorated_function(func):
|
||||||
"""Get the original function from a decorated function"""
|
"""Get the original function from a decorated function"""
|
||||||
@@ -66,6 +73,7 @@ def unwrap_decorated_function(func):
|
|||||||
return unwrap_decorated_function(func.__wrapped__)
|
return unwrap_decorated_function(func.__wrapped__)
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|
||||||
# Create synchronous versions of the test functions that bypass authentication
|
# Create synchronous versions of the test functions that bypass authentication
|
||||||
def sync_test_openai_connection():
|
def sync_test_openai_connection():
|
||||||
"""Synchronous wrapper for the OpenAI test function that bypasses auth"""
|
"""Synchronous wrapper for the OpenAI test function that bypasses auth"""
|
||||||
@@ -76,6 +84,7 @@ def sync_test_openai_connection():
|
|||||||
return asyncio.run(inner_func(request))
|
return asyncio.run(inner_func(request))
|
||||||
return inner_func(request)
|
return inner_func(request)
|
||||||
|
|
||||||
|
|
||||||
def sync_test_azure_connection():
|
def sync_test_azure_connection():
|
||||||
"""Synchronous wrapper for the Azure test function that bypasses auth"""
|
"""Synchronous wrapper for the Azure test function that bypasses auth"""
|
||||||
inner_func = unwrap_decorated_function(test_azure_connection)
|
inner_func = unwrap_decorated_function(test_azure_connection)
|
||||||
@@ -84,6 +93,7 @@ def sync_test_azure_connection():
|
|||||||
return asyncio.run(inner_func(request))
|
return asyncio.run(inner_func(request))
|
||||||
return inner_func(request)
|
return inner_func(request)
|
||||||
|
|
||||||
|
|
||||||
def sync_test_dropbox_token():
|
def sync_test_dropbox_token():
|
||||||
"""Synchronous wrapper for the Dropbox test function that bypasses auth"""
|
"""Synchronous wrapper for the Dropbox test function that bypasses auth"""
|
||||||
inner_func = unwrap_decorated_function(test_dropbox_token)
|
inner_func = unwrap_decorated_function(test_dropbox_token)
|
||||||
@@ -92,6 +102,7 @@ def sync_test_dropbox_token():
|
|||||||
return asyncio.run(inner_func(request))
|
return asyncio.run(inner_func(request))
|
||||||
return inner_func(request)
|
return inner_func(request)
|
||||||
|
|
||||||
|
|
||||||
def sync_test_google_drive_token():
|
def sync_test_google_drive_token():
|
||||||
"""Synchronous wrapper for the Google Drive test function that bypasses auth"""
|
"""Synchronous wrapper for the Google Drive test function that bypasses auth"""
|
||||||
inner_func = unwrap_decorated_function(test_google_drive_token)
|
inner_func = unwrap_decorated_function(test_google_drive_token)
|
||||||
@@ -100,6 +111,7 @@ def sync_test_google_drive_token():
|
|||||||
return asyncio.run(inner_func(request))
|
return asyncio.run(inner_func(request))
|
||||||
return inner_func(request)
|
return inner_func(request)
|
||||||
|
|
||||||
|
|
||||||
def sync_test_onedrive_token():
|
def sync_test_onedrive_token():
|
||||||
"""Synchronous wrapper for the OneDrive test function that bypasses auth"""
|
"""Synchronous wrapper for the OneDrive test function that bypasses auth"""
|
||||||
inner_func = unwrap_decorated_function(test_onedrive_token)
|
inner_func = unwrap_decorated_function(test_onedrive_token)
|
||||||
@@ -108,160 +120,160 @@ def sync_test_onedrive_token():
|
|||||||
return asyncio.run(inner_func(request))
|
return asyncio.run(inner_func(request))
|
||||||
return inner_func(request)
|
return inner_func(request)
|
||||||
|
|
||||||
|
|
||||||
@celery.task
|
@celery.task
|
||||||
def check_credentials():
|
def check_credentials():
|
||||||
"""Check all configured credentials and notify if any are invalid"""
|
"""Check all configured credentials and notify if any are invalid"""
|
||||||
logger.info("Starting credential check task")
|
logger.info("Starting credential check task")
|
||||||
|
|
||||||
# Load current failure state
|
# Load current failure state
|
||||||
failure_state = get_failure_state()
|
failure_state = get_failure_state()
|
||||||
|
|
||||||
# Track failures
|
# Track failures
|
||||||
failures = []
|
failures = []
|
||||||
|
|
||||||
# Get provider configurations from config_validator
|
# Get provider configurations from config_validator
|
||||||
provider_status = get_provider_status()
|
provider_status = get_provider_status()
|
||||||
storage_configs = validate_storage_configs()
|
storage_configs = validate_storage_configs()
|
||||||
|
|
||||||
# Define services with their test functions and configuration status
|
# Define services with their test functions and configuration status
|
||||||
services = [
|
services = [
|
||||||
{
|
{
|
||||||
"name": "OpenAI",
|
"name": "OpenAI",
|
||||||
"check_func": sync_test_openai_connection,
|
"check_func": sync_test_openai_connection,
|
||||||
"configured": provider_status.get("OpenAI", {}).get("configured", False),
|
"configured": provider_status.get("OpenAI", {}).get("configured", False),
|
||||||
"config_issues": [] # OpenAI isn't in storage_configs
|
"config_issues": [], # OpenAI isn't in storage_configs
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Azure Document Intelligence",
|
"name": "Azure Document Intelligence",
|
||||||
"check_func": sync_test_azure_connection,
|
"check_func": sync_test_azure_connection,
|
||||||
"configured": provider_status.get("Azure AI", {}).get("configured", False),
|
"configured": provider_status.get("Azure AI", {}).get("configured", False),
|
||||||
"config_issues": [] # Azure isn't in storage_configs
|
"config_issues": [], # Azure isn't in storage_configs
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dropbox",
|
"name": "Dropbox",
|
||||||
"check_func": sync_test_dropbox_token,
|
"check_func": sync_test_dropbox_token,
|
||||||
"configured": provider_status.get("Dropbox", {}).get("configured", False),
|
"configured": provider_status.get("Dropbox", {}).get("configured", False),
|
||||||
"config_issues": storage_configs.get("dropbox", [])
|
"config_issues": storage_configs.get("dropbox", []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Google Drive",
|
"name": "Google Drive",
|
||||||
"check_func": sync_test_google_drive_token,
|
"check_func": sync_test_google_drive_token,
|
||||||
"configured": provider_status.get("Google Drive", {}).get("configured", False),
|
"configured": provider_status.get("Google Drive", {}).get("configured", False),
|
||||||
"config_issues": storage_configs.get("google_drive", [])
|
"config_issues": storage_configs.get("google_drive", []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "OneDrive",
|
"name": "OneDrive",
|
||||||
"check_func": sync_test_onedrive_token,
|
"check_func": sync_test_onedrive_token,
|
||||||
"configured": provider_status.get("OneDrive", {}).get("configured", False),
|
"configured": provider_status.get("OneDrive", {}).get("configured", False),
|
||||||
"config_issues": storage_configs.get("onedrive", [])
|
"config_issues": storage_configs.get("onedrive", []),
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check each service
|
# Check each service
|
||||||
results = {}
|
results = {}
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
|
|
||||||
for service in services:
|
for service in services:
|
||||||
service_name = service["name"]
|
service_name = service["name"]
|
||||||
logger.info(f"Checking credentials for {service_name}")
|
logger.info(f"Checking credentials for {service_name}")
|
||||||
|
|
||||||
# Skip services that aren't configured
|
# Skip services that aren't configured
|
||||||
if not service["configured"]:
|
if not service["configured"]:
|
||||||
config_issues = service["config_issues"]
|
config_issues = service["config_issues"]
|
||||||
issue_msg = f"Not properly configured" + (f": {', '.join(config_issues)}" if config_issues else "")
|
issue_msg = "Not properly configured" + (f": {', '.join(config_issues)}" if config_issues else "")
|
||||||
logger.info(f"Skipping {service_name}: {issue_msg}")
|
logger.info(f"Skipping {service_name}: {issue_msg}")
|
||||||
|
|
||||||
results[service_name] = {
|
results[service_name] = {"status": "unconfigured", "message": issue_msg}
|
||||||
"status": "unconfigured",
|
|
||||||
"message": issue_msg
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Call the synchronized test function and get the result
|
# Call the synchronized test function and get the result
|
||||||
result = service["check_func"]()
|
result = service["check_func"]()
|
||||||
|
|
||||||
# All test functions return a dict with "status" field
|
# All test functions return a dict with "status" field
|
||||||
is_valid = result.get("status") == "success"
|
is_valid = result.get("status") == "success"
|
||||||
error_message = result.get("message", "Unknown error")
|
error_message = result.get("message", "Unknown error")
|
||||||
|
|
||||||
# Store the result
|
# Store the result
|
||||||
results[service_name] = {
|
results[service_name] = {"status": "valid" if is_valid else "invalid", "message": error_message}
|
||||||
"status": "valid" if is_valid else "invalid",
|
|
||||||
"message": error_message
|
|
||||||
}
|
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
failures.append(service_name)
|
failures.append(service_name)
|
||||||
|
|
||||||
# Get current failure count for this service
|
# Get current failure count for this service
|
||||||
service_state = failure_state.get(service_name, {"count": 0, "last_notified": 0})
|
service_state = failure_state.get(service_name, {"count": 0, "last_notified": 0})
|
||||||
service_state["count"] = service_state.get("count", 0) + 1
|
service_state["count"] = service_state.get("count", 0) + 1
|
||||||
|
|
||||||
# Only notify if we haven't reached the notification threshold (3 failures)
|
# Only notify if we haven't reached the notification threshold (3 failures)
|
||||||
# or if this is the first failure after a recovery
|
# or if this is the first failure after a recovery
|
||||||
if service_state["count"] <= 3 or service_state.get("recovered", False):
|
if service_state["count"] <= 3 or service_state.get("recovered", False):
|
||||||
notify_credential_failure(service_name, error_message)
|
notify_credential_failure(service_name, error_message)
|
||||||
service_state["last_notified"] = current_time
|
service_state["last_notified"] = current_time
|
||||||
service_state["recovered"] = False
|
service_state["recovered"] = False
|
||||||
logger.warning(f"{service_name} credentials check failed ({service_state['count']} times): {error_message}")
|
logger.warning(
|
||||||
|
f"{service_name} credentials check failed ({service_state['count']} times): {error_message}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# We're in cooldown mode
|
# We're in cooldown mode
|
||||||
logger.warning(f"{service_name} credentials check failed ({service_state['count']} times): {error_message} - notification suppressed")
|
logger.warning(
|
||||||
|
f"{service_name} credentials check failed ({service_state['count']} times): "
|
||||||
|
f"{error_message} - notification suppressed"
|
||||||
|
)
|
||||||
|
|
||||||
# Update failure state
|
# Update failure state
|
||||||
failure_state[service_name] = service_state
|
failure_state[service_name] = service_state
|
||||||
else:
|
else:
|
||||||
logger.info(f"{service_name} credentials are valid")
|
logger.info(f"{service_name} credentials are valid")
|
||||||
|
|
||||||
# Check if this was previously failing and now recovered
|
# Check if this was previously failing and now recovered
|
||||||
if service_name in failure_state and failure_state[service_name].get("count", 0) > 0:
|
if service_name in failure_state and failure_state[service_name].get("count", 0) > 0:
|
||||||
logger.info(f"{service_name} has recovered after {failure_state[service_name]['count']} failures")
|
logger.info(f"{service_name} has recovered after {failure_state[service_name]['count']} failures")
|
||||||
|
|
||||||
# Mark it as recovered and reset count
|
# Mark it as recovered and reset count
|
||||||
failure_state[service_name] = {"count": 0, "recovered": True, "last_notified": 0}
|
failure_state[service_name] = {"count": 0, "recovered": True, "last_notified": 0}
|
||||||
elif service_name in failure_state:
|
elif service_name in failure_state:
|
||||||
# Just make sure recovered flag is cleared if it was there
|
# Just make sure recovered flag is cleared if it was there
|
||||||
failure_state[service_name]["recovered"] = True
|
failure_state[service_name]["recovered"] = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking {service_name} credentials: {e}", exc_info=True)
|
logger.error(f"Error checking {service_name} credentials: {e}", exc_info=True)
|
||||||
failures.append(service_name)
|
failures.append(service_name)
|
||||||
error_message = f"Exception during credential check: {str(e)}"
|
error_message = f"Exception during credential check: {str(e)}"
|
||||||
|
|
||||||
# Get current failure count for this service
|
# Get current failure count for this service
|
||||||
service_state = failure_state.get(service_name, {"count": 0, "last_notified": 0})
|
service_state = failure_state.get(service_name, {"count": 0, "last_notified": 0})
|
||||||
service_state["count"] = service_state.get("count", 0) + 1
|
service_state["count"] = service_state.get("count", 0) + 1
|
||||||
|
|
||||||
# Only notify if we haven't reached the notification threshold or if we just recovered
|
# Only notify if we haven't reached the notification threshold or if we just recovered
|
||||||
if service_state["count"] <= 3 or service_state.get("recovered", False):
|
if service_state["count"] <= 3 or service_state.get("recovered", False):
|
||||||
notify_credential_failure(service_name, error_message)
|
notify_credential_failure(service_name, error_message)
|
||||||
service_state["last_notified"] = current_time
|
service_state["last_notified"] = current_time
|
||||||
service_state["recovered"] = False
|
service_state["recovered"] = False
|
||||||
|
|
||||||
# Update failure state
|
# Update failure state
|
||||||
failure_state[service_name] = service_state
|
failure_state[service_name] = service_state
|
||||||
|
|
||||||
# Store the error result
|
# Store the error result
|
||||||
results[service_name] = {
|
results[service_name] = {"status": "error", "message": error_message}
|
||||||
"status": "error",
|
|
||||||
"message": error_message
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save updated failure state
|
# Save updated failure state
|
||||||
save_failure_state(failure_state)
|
save_failure_state(failure_state)
|
||||||
|
|
||||||
# Count only services that were actually checked (configured services)
|
# Count only services that were actually checked (configured services)
|
||||||
configured_services = [s for s in services if s["configured"]]
|
configured_services = [s for s in services if s["configured"]]
|
||||||
num_configured = len(configured_services)
|
num_configured = len(configured_services)
|
||||||
|
|
||||||
# Summarize results
|
# Summarize results
|
||||||
logger.info(f"Credential check completed. Configured services: {num_configured}, Valid: {num_configured - len(failures)}, Invalid: {len(failures)}")
|
logger.info(
|
||||||
|
f"Credential check completed. Configured services: {num_configured}, "
|
||||||
|
f"Valid: {num_configured - len(failures)}, Invalid: {len(failures)}"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"checked": num_configured,
|
"checked": num_configured,
|
||||||
"unconfigured": len(services) - num_configured,
|
"unconfigured": len(services) - num_configured,
|
||||||
"failures": len(failures),
|
"failures": len(failures),
|
||||||
"results": results,
|
"results": results,
|
||||||
"failure_state": failure_state
|
"failure_state": failure_state,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
|
||||||
import PyPDF2 # Replace fitz with PyPDF2
|
import PyPDF2 # Replace fitz with PyPDF2
|
||||||
import json
|
|
||||||
from app.config import settings
|
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
from app.tasks.finalize_document_storage import finalize_document_storage
|
|
||||||
|
|
||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app.utils import log_task_progress
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
from app.tasks.finalize_document_storage import finalize_document_storage
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
TMP_SUBDIR = "tmp"
|
TMP_SUBDIR = "tmp"
|
||||||
PROCESSED_SUBDIR = "processed"
|
PROCESSED_SUBDIR = "processed"
|
||||||
|
|
||||||
|
|
||||||
def unique_filepath(directory, base_filename, extension=".pdf"):
|
def unique_filepath(directory, base_filename, extension=".pdf"):
|
||||||
"""
|
"""
|
||||||
Returns a unique filepath in the specified directory.
|
Returns a unique filepath in the specified directory.
|
||||||
@@ -41,6 +43,7 @@ def unique_filepath(directory, base_filename, extension=".pdf"):
|
|||||||
return candidate
|
return candidate
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
def persist_metadata(metadata, final_pdf_path):
|
def persist_metadata(metadata, final_pdf_path):
|
||||||
"""
|
"""
|
||||||
Saves the metadata dictionary to a JSON file with the same base name as the final PDF.
|
Saves the metadata dictionary to a JSON file with the same base name as the final PDF.
|
||||||
@@ -53,6 +56,7 @@ def persist_metadata(metadata, final_pdf_path):
|
|||||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||||
return json_path
|
return json_path
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, metadata: dict, file_id: int = None):
|
def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, metadata: dict, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
@@ -70,15 +74,21 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
|||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting metadata embedding for: {local_file_path}")
|
logger.info(f"[{task_id}] Starting metadata embedding for: {local_file_path}")
|
||||||
log_task_progress(task_id, "embed_metadata_into_pdf", "in_progress", f"Embedding metadata into {os.path.basename(local_file_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"embed_metadata_into_pdf",
|
||||||
|
"in_progress",
|
||||||
|
f"Embedding metadata into {os.path.basename(local_file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Get file_id from database if not provided (fallback only, prefer passing file_id explicitly)
|
# Get file_id from database if not provided (fallback only, prefer passing file_id explicitly)
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
file_record = db.query(FileRecord).filter_by(local_filename=local_file_path).first()
|
file_record = db.query(FileRecord).filter_by(local_filename=local_file_path).first()
|
||||||
if file_record:
|
if file_record:
|
||||||
file_id = file_record.id
|
file_id = file_record.id
|
||||||
|
|
||||||
# Check for file existence; if not found, try the known shared tmp directory.
|
# Check for file existence; if not found, try the known shared tmp directory.
|
||||||
if not os.path.exists(local_file_path):
|
if not os.path.exists(local_file_path):
|
||||||
alt_path = os.path.join(settings.workdir, TMP_SUBDIR, os.path.basename(local_file_path))
|
alt_path = os.path.join(settings.workdir, TMP_SUBDIR, os.path.basename(local_file_path))
|
||||||
@@ -93,7 +103,7 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
|||||||
original_file = local_file_path
|
original_file = local_file_path
|
||||||
# Create a temporary file with the same extension as the original
|
# Create a temporary file with the same extension as the original
|
||||||
_, ext = os.path.splitext(local_file_path)
|
_, ext = os.path.splitext(local_file_path)
|
||||||
tmp_file = tempfile.NamedTemporaryFile(mode='wb', suffix=ext, prefix='processed_', delete=False)
|
tmp_file = tempfile.NamedTemporaryFile(mode="wb", suffix=ext, prefix="processed_", delete=False)
|
||||||
processed_file = tmp_file.name
|
processed_file = tmp_file.name
|
||||||
tmp_file.close()
|
tmp_file.close()
|
||||||
|
|
||||||
@@ -105,24 +115,26 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
|||||||
log_task_progress(task_id, "modify_pdf", "in_progress", "Modifying PDF metadata", file_id=file_id)
|
log_task_progress(task_id, "modify_pdf", "in_progress", "Modifying PDF metadata", file_id=file_id)
|
||||||
|
|
||||||
# Open the PDF and modify metadata
|
# Open the PDF and modify metadata
|
||||||
with open(processed_file, 'rb') as file:
|
with open(processed_file, "rb") as file:
|
||||||
pdf_reader = PyPDF2.PdfReader(file)
|
pdf_reader = PyPDF2.PdfReader(file)
|
||||||
pdf_writer = PyPDF2.PdfWriter()
|
pdf_writer = PyPDF2.PdfWriter()
|
||||||
|
|
||||||
# Copy all pages from the reader to the writer
|
# Copy all pages from the reader to the writer
|
||||||
for page in pdf_reader.pages:
|
for page in pdf_reader.pages:
|
||||||
pdf_writer.add_page(page)
|
pdf_writer.add_page(page)
|
||||||
|
|
||||||
# Set PDF metadata
|
# Set PDF metadata
|
||||||
pdf_writer.add_metadata({
|
pdf_writer.add_metadata(
|
||||||
"/Title": metadata.get("filename", "Unknown Document"),
|
{
|
||||||
"/Author": metadata.get("absender", "Unknown"),
|
"/Title": metadata.get("filename", "Unknown Document"),
|
||||||
"/Subject": metadata.get("document_type", "Unknown"),
|
"/Author": metadata.get("absender", "Unknown"),
|
||||||
"/Keywords": ", ".join(metadata.get("tags", []))
|
"/Subject": metadata.get("document_type", "Unknown"),
|
||||||
})
|
"/Keywords": ", ".join(metadata.get("tags", [])),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Write the modified PDF
|
# Write the modified PDF
|
||||||
with open(processed_file, 'wb') as output_file:
|
with open(processed_file, "wb") as output_file:
|
||||||
pdf_writer.write(output_file)
|
pdf_writer.write(output_file)
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Metadata embedded successfully in {processed_file}")
|
logger.info(f"[{task_id}] Metadata embedded successfully in {processed_file}")
|
||||||
@@ -139,24 +151,36 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
|||||||
final_file_path = unique_filepath(final_dir, suggested_filename, extension=".pdf")
|
final_file_path = unique_filepath(final_dir, suggested_filename, extension=".pdf")
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Moving file to: {final_file_path}")
|
logger.info(f"[{task_id}] Moving file to: {final_file_path}")
|
||||||
log_task_progress(task_id, "move_to_processed", "in_progress", f"Moving to processed: {suggested_filename}.pdf", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"move_to_processed",
|
||||||
|
"in_progress",
|
||||||
|
f"Moving to processed: {suggested_filename}.pdf",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
# Move the processed file using shutil.move to handle cross-device moves.
|
# Move the processed file using shutil.move to handle cross-device moves.
|
||||||
shutil.move(processed_file, final_file_path)
|
shutil.move(processed_file, final_file_path)
|
||||||
# Ensure the temporary file is deleted if it still exists.
|
# Ensure the temporary file is deleted if it still exists.
|
||||||
if os.path.exists(processed_file):
|
if os.path.exists(processed_file):
|
||||||
os.remove(processed_file)
|
os.remove(processed_file)
|
||||||
log_task_progress(task_id, "move_to_processed", "success", f"Moved to: {os.path.basename(final_file_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "move_to_processed", "success", f"Moved to: {os.path.basename(final_file_path)}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
# Persist the metadata into a JSON file with the same base name.
|
# Persist the metadata into a JSON file with the same base name.
|
||||||
logger.info(f"[{task_id}] Persisting metadata to JSON")
|
logger.info(f"[{task_id}] Persisting metadata to JSON")
|
||||||
log_task_progress(task_id, "save_metadata_json", "in_progress", "Saving metadata JSON", file_id=file_id)
|
log_task_progress(task_id, "save_metadata_json", "in_progress", "Saving metadata JSON", file_id=file_id)
|
||||||
json_path = persist_metadata(metadata, final_file_path)
|
json_path = persist_metadata(metadata, final_file_path)
|
||||||
logger.info(f"[{task_id}] Metadata persisted to {json_path}")
|
logger.info(f"[{task_id}] Metadata persisted to {json_path}")
|
||||||
log_task_progress(task_id, "save_metadata_json", "success", f"Saved: {os.path.basename(json_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "save_metadata_json", "success", f"Saved: {os.path.basename(json_path)}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
# Trigger the next step: final storage.
|
# Trigger the next step: final storage.
|
||||||
logger.info(f"[{task_id}] Queueing final storage task")
|
logger.info(f"[{task_id}] Queueing final storage task")
|
||||||
log_task_progress(task_id, "embed_metadata_into_pdf", "success", "Metadata embedded, queuing finalization", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "embed_metadata_into_pdf", "success", "Metadata embedded, queuing finalization", file_id=file_id
|
||||||
|
)
|
||||||
finalize_document_storage.delay(original_file, final_file_path, metadata, file_id=file_id)
|
finalize_document_storage.delay(original_file, final_file_path, metadata, file_id=file_id)
|
||||||
|
|
||||||
# After triggering final storage, delete the original file if it is in workdir/tmp.
|
# After triggering final storage, delete the original file if it is in workdir/tmp.
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import logging
|
||||||
import os
|
import os
|
||||||
from app.config import settings
|
import re
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
|
import openai
|
||||||
|
|
||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
import openai
|
from app.config import settings
|
||||||
import logging
|
|
||||||
from app.utils import log_task_progress
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize OpenAI client dynamically with better error handling
|
# Initialize OpenAI client dynamically with better error handling
|
||||||
try:
|
try:
|
||||||
client = openai.OpenAI(
|
client = openai.OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
|
||||||
api_key=settings.openai_api_key,
|
|
||||||
base_url=settings.openai_base_url
|
|
||||||
)
|
|
||||||
logger.info("OpenAI client initialized successfully")
|
logger.info("OpenAI client initialized successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize OpenAI client: {e}")
|
logger.error(f"Failed to initialize OpenAI client: {e}")
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
|
|
||||||
def extract_json_from_text(text):
|
def extract_json_from_text(text):
|
||||||
"""
|
"""
|
||||||
Try to extract a JSON object from the text.
|
Try to extract a JSON object from the text.
|
||||||
@@ -42,16 +41,19 @@ def extract_json_from_text(text):
|
|||||||
start = text.find("{")
|
start = text.find("{")
|
||||||
end = text.rfind("}")
|
end = text.rfind("}")
|
||||||
if start != -1 and end != -1 and end > start:
|
if start != -1 and end != -1 and end > start:
|
||||||
return text[start:end+1]
|
return text[start : end + 1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
|
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
|
||||||
"""Uses OpenAI to classify document metadata."""
|
"""Uses OpenAI to classify document metadata."""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
|
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
|
||||||
log_task_progress(task_id, "extract_metadata_with_gpt", "in_progress", f"Extracting metadata for {filename}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "extract_metadata_with_gpt", "in_progress", f"Extracting metadata for {filename}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
# Get file_id from database if not provided
|
# Get file_id from database if not provided
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
tmp_dir = os.path.join(settings.workdir, "tmp")
|
tmp_dir = os.path.join(settings.workdir, "tmp")
|
||||||
@@ -61,38 +63,39 @@ def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: i
|
|||||||
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
|
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
|
||||||
if file_record:
|
if file_record:
|
||||||
file_id = file_record.id
|
file_id = file_record.id
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
You are a specialized document analyzer trained to extract structured metadata from documents.
|
|
||||||
Your task is to analyze the given text and return a well-structured JSON object.
|
|
||||||
|
|
||||||
Extract and return the following fields:
|
prompt = (
|
||||||
1. **filename**: Machine-readable filename (YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).
|
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
|
||||||
2. **empfaenger**: The recipient, or "Unknown" if not found.
|
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
|
||||||
3. **absender**: The sender, or "Unknown" if not found.
|
"Extract and return the following fields:\n"
|
||||||
4. **correspondent**: The entity or company that issued the document (shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").
|
"1. **filename**: Machine-readable filename "
|
||||||
5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].
|
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
|
||||||
6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, Private_Korrespondenz, Sonstige_Informationen].
|
"2. **empfaenger**: The recipient, or \"Unknown\" if not found.\n"
|
||||||
7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).
|
"3. **absender**: The sender, or \"Unknown\" if not found.\n"
|
||||||
8. **tags**: A list of up to 4 relevant thematic keywords.
|
"4. **correspondent**: The entity or company that issued the document "
|
||||||
9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").
|
"(shortest possible name, e.g., \"Amazon\" instead of \"Amazon EU SARL, German branch\").\n"
|
||||||
10. **title**: A human-readable title summarizing the document content.
|
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
|
||||||
11. **confidence_score**: A numeric value (0-100) indicating the confidence level of the extracted metadata.
|
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
|
||||||
12. **reference_number**: Extracted invoice/order/reference number if available.
|
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
|
||||||
13. **monetary_amounts**: A list of key monetary values detected in the document.
|
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
|
||||||
|
"Private_Korrespondenz, Sonstige_Informationen].\n"
|
||||||
### Important Rules:
|
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
|
||||||
- **OCR Correction**: Assume the text has been corrected for OCR errors.
|
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
|
||||||
- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.
|
"9. **language**: Detected document language (ISO 639-1 code, e.g., \"de\" or \"en\").\n"
|
||||||
- **Title**: Concise, no addresses, and contains key identifying features.
|
"10. **title**: A human-readable title summarizing the document content.\n"
|
||||||
- **Date Selection**: Use the most relevant date if multiple are found.
|
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
|
||||||
- **Output Language**: Maintain the document's original language.
|
"of the extracted metadata.\n"
|
||||||
|
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
|
||||||
Extracted text:
|
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
|
||||||
{cleaned_text}
|
"### Important Rules:\n"
|
||||||
|
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
|
||||||
Return only valid JSON with no additional commentary.
|
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
|
||||||
"""
|
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
|
||||||
|
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
|
||||||
|
"- **Output Language**: Maintain the document's original language.\n\n"
|
||||||
|
f"Extracted text:\n{cleaned_text}\n\n"
|
||||||
|
"Return only valid JSON with no additional commentary.\n"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"[{task_id}] Sending classification request for {filename}...")
|
logger.info(f"[{task_id}] Sending classification request for {filename}...")
|
||||||
@@ -101,9 +104,9 @@ Return only valid JSON with no additional commentary.
|
|||||||
model=settings.openai_model,
|
model=settings.openai_model,
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": "You are an intelligent document classifier."},
|
{"role": "system", "content": "You are an intelligent document classifier."},
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
temperature=0
|
temperature=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
content = completion.choices[0].message.content
|
content = completion.choices[0].message.content
|
||||||
@@ -113,16 +116,22 @@ Return only valid JSON with no additional commentary.
|
|||||||
json_text = extract_json_from_text(content)
|
json_text = extract_json_from_text(content)
|
||||||
if not json_text:
|
if not json_text:
|
||||||
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
|
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
|
||||||
log_task_progress(task_id, "extract_metadata_with_gpt", "failure", "Invalid JSON in response", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "extract_metadata_with_gpt", "failure", "Invalid JSON in response", file_id=file_id
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
metadata = json.loads(json_text)
|
metadata = json.loads(json_text)
|
||||||
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
|
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
|
||||||
log_task_progress(task_id, "parse_metadata", "success", f"Parsed metadata: {list(metadata.keys())}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "parse_metadata", "success", f"Parsed metadata: {list(metadata.keys())}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
# Trigger the next step: embedding metadata into the PDF
|
# Trigger the next step: embedding metadata into the PDF
|
||||||
logger.info(f"[{task_id}] Queueing metadata embedding task")
|
logger.info(f"[{task_id}] Queueing metadata embedding task")
|
||||||
log_task_progress(task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
|
||||||
|
)
|
||||||
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
|
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
|
||||||
|
|
||||||
return {"s3_file": filename, "metadata": metadata}
|
return {"s3_file": filename, "metadata": metadata}
|
||||||
|
|||||||
@@ -2,21 +2,22 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from app.config import settings
|
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import FileRecord
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
|
||||||
# Import the aggregator task and validator
|
# Import the aggregator task and validator
|
||||||
from app.tasks.send_to_all import send_to_all_destinations, get_configured_services_from_validator
|
from app.tasks.send_to_all import get_configured_services_from_validator, send_to_all_destinations
|
||||||
|
|
||||||
# Import notification utility
|
|
||||||
from app.utils.notification import notify_file_processed
|
|
||||||
|
|
||||||
# Import database and logging utils from main
|
# Import database and logging utils from main
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
from app.database import SessionLocal
|
|
||||||
from app.models import FileRecord
|
# Import notification utility
|
||||||
|
from app.utils.notification import notify_file_processed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,18 +31,22 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
|||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Finalizing document storage for {processed_file}")
|
logger.info(f"[{task_id}] Finalizing document storage for {processed_file}")
|
||||||
|
|
||||||
# 1. Update Database Status (From Main)
|
# 1. Update Database Status (From Main)
|
||||||
log_task_progress(task_id, "finalize_document_storage", "in_progress", f"Finalizing: {os.path.basename(processed_file)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"finalize_document_storage",
|
||||||
|
"in_progress",
|
||||||
|
f"Finalizing: {os.path.basename(processed_file)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Get file_id from database if not provided (fallback logic from Main)
|
# Get file_id from database if not provided (fallback logic from Main)
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# Only as a last resort, try to find by exact match on local_filename
|
# Only as a last resort, try to find by exact match on local_filename
|
||||||
tmp_path = os.path.join(settings.workdir, "tmp", os.path.basename(original_file))
|
tmp_path = os.path.join(settings.workdir, "tmp", os.path.basename(original_file))
|
||||||
file_record = db.query(FileRecord).filter(
|
file_record = db.query(FileRecord).filter(FileRecord.local_filename == tmp_path).first()
|
||||||
FileRecord.local_filename == tmp_path
|
|
||||||
).first()
|
|
||||||
if file_record:
|
if file_record:
|
||||||
file_id = file_record.id
|
file_id = file_record.id
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
|||||||
for service_name, is_configured in configured_services.items():
|
for service_name, is_configured in configured_services.items():
|
||||||
if is_configured:
|
if is_configured:
|
||||||
# Format service names for display
|
# Format service names for display
|
||||||
display_name = service_name.replace('_', ' ').title()
|
display_name = service_name.replace("_", " ").title()
|
||||||
configured_destinations.append(display_name)
|
configured_destinations.append(display_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WARNING] Could not determine configured destinations: {e}")
|
logger.warning(f"[WARNING] Could not determine configured destinations: {e}")
|
||||||
@@ -63,8 +68,10 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
|||||||
# 3. Queue Uploads (Merged)
|
# 3. Queue Uploads (Merged)
|
||||||
# Uses Main branch signature to ensure file_id is passed, but keeps logic structure
|
# Uses Main branch signature to ensure file_id is passed, but keeps logic structure
|
||||||
logger.info(f"[{task_id}] Queueing uploads to all destinations")
|
logger.info(f"[{task_id}] Queueing uploads to all destinations")
|
||||||
log_task_progress(task_id, "finalize_document_storage", "success", "Queuing uploads to destinations", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "finalize_document_storage", "success", "Queuing uploads to destinations", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
# Note: send_to_all_destinations is asynchronous and queues upload tasks
|
# Note: send_to_all_destinations is asynchronous and queues upload tasks
|
||||||
# We pass 'True' (delete_after) and 'file_id' as per Main branch requirements
|
# We pass 'True' (delete_after) and 'file_id' as per Main branch requirements
|
||||||
send_to_all_destinations.delay(processed_file, True, file_id)
|
send_to_all_destinations.delay(processed_file, True, file_id)
|
||||||
@@ -76,17 +83,11 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
|
|||||||
# Get file information
|
# Get file information
|
||||||
file_size = os.path.getsize(processed_file) if os.path.exists(processed_file) else 0
|
file_size = os.path.getsize(processed_file) if os.path.exists(processed_file) else 0
|
||||||
filename = os.path.basename(processed_file)
|
filename = os.path.basename(processed_file)
|
||||||
|
|
||||||
notify_file_processed(
|
notify_file_processed(
|
||||||
filename=filename,
|
filename=filename, file_size=file_size, metadata=metadata, destinations=configured_destinations
|
||||||
file_size=file_size,
|
|
||||||
metadata=metadata,
|
|
||||||
destinations=configured_destinations
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WARNING] Failed to send file processed notification: {e}")
|
logger.warning(f"[WARNING] Failed to send file processed notification: {e}")
|
||||||
|
|
||||||
return {
|
return {"status": "Completed", "file": processed_file}
|
||||||
"status": "Completed",
|
|
||||||
"file": processed_file
|
|
||||||
}
|
|
||||||
|
|||||||
+29
-34
@@ -1,16 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import email
|
import email
|
||||||
import imaplib
|
import imaplib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import redis
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import redis
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.process_document import process_document # Updated import
|
|
||||||
from app.tasks.convert_to_pdf import convert_to_pdf # new conversion task
|
from app.tasks.convert_to_pdf import convert_to_pdf # new conversion task
|
||||||
|
from app.tasks.process_document import process_document # Updated import
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
redis_client = redis.StrictRedis.from_url(settings.redis_url, decode_responses=True)
|
redis_client = redis.StrictRedis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
|
||||||
LOCK_KEY = "imap_lock" # Unique key for locking
|
LOCK_KEY = "imap_lock" # Unique key for locking
|
||||||
LOCK_EXPIRE = 300 # Lock expires in 5 minutes
|
LOCK_EXPIRE = 300 # Lock expires in 5 minutes
|
||||||
|
|
||||||
# Local cache file for tracking processed emails
|
# Local cache file for tracking processed emails
|
||||||
CACHE_FILE = os.path.join(settings.workdir, "processed_mails.json")
|
CACHE_FILE = os.path.join(settings.workdir, "processed_mails.json")
|
||||||
@@ -141,20 +143,18 @@ def check_and_pull_mailbox(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def pull_inbox(mailbox_key, host, port, username, password, use_ssl,
|
def pull_inbox(mailbox_key, host, port, username, password, use_ssl, delete_after_process):
|
||||||
delete_after_process):
|
|
||||||
"""
|
"""
|
||||||
Connects to the IMAP inbox, fetches new unread emails from the last 3 days,
|
Connects to the IMAP inbox, fetches new unread emails from the last 3 days,
|
||||||
and processes attachments while preserving the original unread status.
|
and processes attachments while preserving the original unread status.
|
||||||
|
|
||||||
For Gmail:
|
For Gmail:
|
||||||
- Attempts to select the localized All Mail folder.
|
- Attempts to select the localized All Mail folder.
|
||||||
- Runs an X-GM-RAW query: "in:anywhere in:unread newer_than:3d has:attachment".
|
- Runs an X-GM-RAW query: "in:anywhere in:unread newer_than:3d has:attachment".
|
||||||
|
|
||||||
For non-Gmail mailboxes, it falls back to selecting the INBOX with a SINCE/UNSEEN filter.
|
For non-Gmail mailboxes, it falls back to selecting the INBOX with a SINCE/UNSEEN filter.
|
||||||
"""
|
"""
|
||||||
logger.info("Connecting to %s at %s:%s (SSL=%s)",
|
logger.info("Connecting to %s at %s:%s (SSL=%s)", mailbox_key, host, port, use_ssl)
|
||||||
mailbox_key, host, port, use_ssl)
|
|
||||||
processed_emails = load_processed_emails()
|
processed_emails = load_processed_emails()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -177,13 +177,11 @@ def pull_inbox(mailbox_key, host, port, username, password, use_ssl,
|
|||||||
else:
|
else:
|
||||||
# For non-Gmail, select INBOX and use SINCE/UNSEEN query.
|
# For non-Gmail, select INBOX and use SINCE/UNSEEN query.
|
||||||
mail.select("INBOX")
|
mail.select("INBOX")
|
||||||
since_date = (datetime.now(timezone.utc) - timedelta(days=3)
|
since_date = (datetime.now(timezone.utc) - timedelta(days=3)).strftime("%d-%b-%Y")
|
||||||
).strftime("%d-%b-%Y")
|
status, search_data = mail.search(None, f"(SINCE {since_date} UNSEEN)")
|
||||||
status, search_data = mail.search(None, f'(SINCE {since_date} UNSEEN)')
|
|
||||||
|
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
logger.warning("Search failed on mailbox %s. Status=%s",
|
logger.warning("Search failed on mailbox %s. Status=%s", mailbox_key, status)
|
||||||
mailbox_key, status)
|
|
||||||
mail.close()
|
mail.close()
|
||||||
mail.logout()
|
mail.logout()
|
||||||
return
|
return
|
||||||
@@ -194,8 +192,7 @@ def pull_inbox(mailbox_key, host, port, username, password, use_ssl,
|
|||||||
for num in msg_numbers:
|
for num in msg_numbers:
|
||||||
status, msg_data = mail.fetch(num, "(RFC822)")
|
status, msg_data = mail.fetch(num, "(RFC822)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
logger.warning("Failed to fetch message %s in %s. Status=%s",
|
logger.warning("Failed to fetch message %s in %s. Status=%s", num, mailbox_key, status)
|
||||||
num, mailbox_key, status)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw_email = msg_data[0][1]
|
raw_email = msg_data[0][1]
|
||||||
@@ -213,8 +210,7 @@ def pull_inbox(mailbox_key, host, port, username, password, use_ssl,
|
|||||||
# For Gmail, check if the email already has the "Ingested" label.
|
# For Gmail, check if the email already has the "Ingested" label.
|
||||||
if is_gmail_host:
|
if is_gmail_host:
|
||||||
if email_already_has_label(mail, num, "Ingested"):
|
if email_already_has_label(mail, num, "Ingested"):
|
||||||
logger.info("Skipping email %s in %s, already labeled 'Ingested'.",
|
logger.info("Skipping email %s in %s, already labeled 'Ingested'.", msg_id, mailbox_key)
|
||||||
msg_id, mailbox_key)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Process attachments (and convert non-PDF files).
|
# Process attachments (and convert non-PDF files).
|
||||||
@@ -248,28 +244,28 @@ def pull_inbox(mailbox_key, host, port, username, password, use_ssl,
|
|||||||
def fetch_attachments_and_enqueue(email_message):
|
def fetch_attachments_and_enqueue(email_message):
|
||||||
"""
|
"""
|
||||||
Extracts attachments from the email and processes only allowed file types.
|
Extracts attachments from the email and processes only allowed file types.
|
||||||
|
|
||||||
Files are accepted if either:
|
Files are accepted if either:
|
||||||
1. They have a MIME type from the ALLOWED_MIME_TYPES set, OR
|
1. They have a MIME type from the ALLOWED_MIME_TYPES set, OR
|
||||||
2. They have a '.pdf' file extension (regardless of MIME type)
|
2. They have a '.pdf' file extension (regardless of MIME type)
|
||||||
|
|
||||||
Allowed file types include:
|
Allowed file types include:
|
||||||
- PDF: application/pdf or *.pdf extension
|
- PDF: application/pdf or *.pdf extension
|
||||||
- Microsoft Office files:
|
- Microsoft Office files:
|
||||||
- Word: application/msword,
|
- Word: application/msword,
|
||||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
- Excel: application/vnd.ms-excel,
|
- Excel: application/vnd.ms-excel,
|
||||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||||
- PowerPoint: application/vnd.ms-powerpoint,
|
- PowerPoint: application/vnd.ms-powerpoint,
|
||||||
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||||
- Other meaningful attachments:
|
- Other meaningful attachments:
|
||||||
- Plain text: text/plain
|
- Plain text: text/plain
|
||||||
- CSV: text/csv
|
- CSV: text/csv
|
||||||
- Rich Text Format: application/rtf, text/rtf
|
- Rich Text Format: application/rtf, text/rtf
|
||||||
|
|
||||||
If the attachment is a PDF (by extension or MIME type), it is enqueued for upload;
|
If the attachment is a PDF (by extension or MIME type), it is enqueued for upload;
|
||||||
any other allowed file is enqueued for conversion to PDF.
|
any other allowed file is enqueued for conversion to PDF.
|
||||||
|
|
||||||
Returns True if at least one allowed attachment was processed.
|
Returns True if at least one allowed attachment was processed.
|
||||||
"""
|
"""
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
@@ -285,7 +281,7 @@ def fetch_attachments_and_enqueue(email_message):
|
|||||||
"application/rtf",
|
"application/rtf",
|
||||||
"text/rtf",
|
"text/rtf",
|
||||||
}
|
}
|
||||||
|
|
||||||
has_attachment = False
|
has_attachment = False
|
||||||
for part in email_message.walk():
|
for part in email_message.walk():
|
||||||
if part.get_content_maintype() == "multipart":
|
if part.get_content_maintype() == "multipart":
|
||||||
@@ -296,13 +292,12 @@ def fetch_attachments_and_enqueue(email_message):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if it's a PDF file by extension, regardless of MIME type
|
# Check if it's a PDF file by extension, regardless of MIME type
|
||||||
is_pdf_by_extension = filename.lower().endswith('.pdf')
|
is_pdf_by_extension = filename.lower().endswith(".pdf")
|
||||||
|
|
||||||
mime_type = part.get_content_type()
|
mime_type = part.get_content_type()
|
||||||
# Accept file if it has an allowed MIME type OR it's a PDF by extension
|
# Accept file if it has an allowed MIME type OR it's a PDF by extension
|
||||||
if mime_type not in ALLOWED_MIME_TYPES and not is_pdf_by_extension:
|
if mime_type not in ALLOWED_MIME_TYPES and not is_pdf_by_extension:
|
||||||
logger.info("Skipping attachment %s with MIME type %s",
|
logger.info("Skipping attachment %s with MIME type %s", filename, mime_type)
|
||||||
filename, mime_type)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_path = os.path.join(settings.workdir, filename)
|
file_path = os.path.join(settings.workdir, filename)
|
||||||
@@ -331,7 +326,7 @@ def email_already_has_label(mail, msg_id, label="Ingested"):
|
|||||||
# Convert msg_id to bytes if it's an integer
|
# Convert msg_id to bytes if it's an integer
|
||||||
if isinstance(msg_id, int):
|
if isinstance(msg_id, int):
|
||||||
msg_id = str(msg_id).encode()
|
msg_id = str(msg_id).encode()
|
||||||
|
|
||||||
label_status, label_data = mail.fetch(msg_id, "(X-GM-LABELS)")
|
label_status, label_data = mail.fetch(msg_id, "(X-GM-LABELS)")
|
||||||
if label_status == "OK" and label_data and len(label_data) > 0:
|
if label_status == "OK" and label_data and len(label_data) > 0:
|
||||||
raw_labels = label_data[0][1].decode("utf-8", errors="ignore")
|
raw_labels = label_data[0][1].decode("utf-8", errors="ignore")
|
||||||
@@ -401,7 +396,7 @@ def find_all_mail_xlist(mail):
|
|||||||
Returns the folder name if found, otherwise None.
|
Returns the folder name if found, otherwise None.
|
||||||
"""
|
"""
|
||||||
tag = mail._new_tag().decode("ascii")
|
tag = mail._new_tag().decode("ascii")
|
||||||
command_str = f"{tag} XLIST \"\" \"*\""
|
command_str = f'{tag} XLIST "" "*"'
|
||||||
mail.send((command_str + "\r\n").encode("utf-8"))
|
mail.send((command_str + "\r\n").encode("utf-8"))
|
||||||
|
|
||||||
all_mail_folder = None
|
all_mail_folder = None
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import azure.core.exceptions
|
||||||
import PyPDF2
|
import PyPDF2
|
||||||
from azure.core.credentials import AzureKeyCredential
|
|
||||||
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
||||||
from azure.ai.documentintelligence.models import AnalyzeOutputOption, AnalyzeResult
|
from azure.ai.documentintelligence.models import AnalyzeOutputOption, AnalyzeResult
|
||||||
import azure.core.exceptions
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
|
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
|
||||||
from app.celery_app import celery
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize Azure Document Intelligence client with error handling
|
# Initialize Azure Document Intelligence client with error handling
|
||||||
try:
|
try:
|
||||||
document_intelligence_client = DocumentIntelligenceClient(
|
document_intelligence_client = DocumentIntelligenceClient(
|
||||||
endpoint=settings.azure_endpoint,
|
endpoint=settings.azure_endpoint, credential=AzureKeyCredential(settings.azure_ai_key)
|
||||||
credential=AzureKeyCredential(settings.azure_ai_key)
|
|
||||||
)
|
)
|
||||||
logger.info("Azure Document Intelligence client initialized successfully")
|
logger.info("Azure Document Intelligence client initialized successfully")
|
||||||
except (ValueError, azure.core.exceptions.ClientAuthenticationError) as e:
|
except (ValueError, azure.core.exceptions.ClientAuthenticationError) as e:
|
||||||
@@ -33,36 +33,38 @@ AZURE_DOC_INTELLIGENCE_LIMITS = {
|
|||||||
"max_pages": 2000,
|
"max_pages": 2000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_pdf_page_count(file_path):
|
def get_pdf_page_count(file_path):
|
||||||
"""Get the number of pages in a PDF file."""
|
"""Get the number of pages in a PDF file."""
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'rb') as file:
|
with open(file_path, "rb") as file:
|
||||||
pdf_reader = PyPDF2.PdfReader(file)
|
pdf_reader = PyPDF2.PdfReader(file)
|
||||||
return len(pdf_reader.pages)
|
return len(pdf_reader.pages)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting PDF page count: {e}")
|
logger.error(f"Error getting PDF page count: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def check_page_rotation(result, filename):
|
def check_page_rotation(result, filename):
|
||||||
"""
|
"""
|
||||||
Checks if pages in the document are rotated and logs the rotation information.
|
Checks if pages in the document are rotated and logs the rotation information.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result: The AnalyzeResult from Azure Document Intelligence API
|
result: The AnalyzeResult from Azure Document Intelligence API
|
||||||
filename: The name of the file being processed
|
filename: The name of the file being processed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Dictionary mapping page indices (integers) to rotation angles
|
dict: Dictionary mapping page indices (integers) to rotation angles
|
||||||
"""
|
"""
|
||||||
logger.error(f"Checking rotation for document: {filename}")
|
logger.error(f"Checking rotation for document: {filename}")
|
||||||
rotation_data = {}
|
rotation_data = {}
|
||||||
|
|
||||||
if not hasattr(result, 'pages') or not result.pages:
|
if not hasattr(result, "pages") or not result.pages:
|
||||||
logger.error(f"No page information available for rotation check: {filename}")
|
logger.error(f"No page information available for rotation check: {filename}")
|
||||||
return rotation_data
|
return rotation_data
|
||||||
|
|
||||||
for i, page in enumerate(result.pages):
|
for i, page in enumerate(result.pages):
|
||||||
if hasattr(page, 'angle'):
|
if hasattr(page, "angle"):
|
||||||
rotation_angle = page.angle
|
rotation_angle = page.angle
|
||||||
if rotation_angle != 0:
|
if rotation_angle != 0:
|
||||||
logger.error(f"Page {i+1} is rotated by {rotation_angle} degrees")
|
logger.error(f"Page {i+1} is rotated by {rotation_angle} degrees")
|
||||||
@@ -72,15 +74,16 @@ def check_page_rotation(result, filename):
|
|||||||
logger.error(f"Page {i+1} has no rotation (0 degrees)")
|
logger.error(f"Page {i+1} has no rotation (0 degrees)")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Page {i+1} rotation information not available")
|
logger.error(f"Page {i+1} rotation information not available")
|
||||||
|
|
||||||
return rotation_data
|
return rotation_data
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry)
|
@celery.task(base=BaseTaskWithRetry)
|
||||||
def process_with_azure_document_intelligence(filename: str, file_id: int = None):
|
def process_with_azure_document_intelligence(filename: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Processes a PDF document using Azure Document Intelligence and overlays OCR text onto
|
Processes a PDF document using Azure Document Intelligence and overlays OCR text onto
|
||||||
the local temporary file (stored under <workdir>/tmp).
|
the local temporary file (stored under <workdir>/tmp).
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
0. Verify the file meets Azure Document Intelligence service limits
|
0. Verify the file meets Azure Document Intelligence service limits
|
||||||
1. Uploads the document for OCR using Azure Document Intelligence.
|
1. Uploads the document for OCR using Azure Document Intelligence.
|
||||||
@@ -88,7 +91,7 @@ def process_with_azure_document_intelligence(filename: str, file_id: int = None)
|
|||||||
3. Saves the OCR-processed PDF locally in the same location as before.
|
3. Saves the OCR-processed PDF locally in the same location as before.
|
||||||
4. Checks for page rotation and triggers page rotation if needed.
|
4. Checks for page rotation and triggers page rotation if needed.
|
||||||
5. Triggers downstream metadata extraction.
|
5. Triggers downstream metadata extraction.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Name of the file to process
|
filename: Name of the file to process
|
||||||
file_id: Optional file ID to pass through to subsequent tasks
|
file_id: Optional file ID to pass through to subsequent tasks
|
||||||
@@ -101,13 +104,15 @@ def process_with_azure_document_intelligence(filename: str, file_id: int = None)
|
|||||||
# Check file size against service limits
|
# Check file size against service limits
|
||||||
file_size = os.path.getsize(tmp_file_path)
|
file_size = os.path.getsize(tmp_file_path)
|
||||||
if file_size > AZURE_DOC_INTELLIGENCE_LIMITS["max_file_size_bytes"]:
|
if file_size > AZURE_DOC_INTELLIGENCE_LIMITS["max_file_size_bytes"]:
|
||||||
error_msg = f"File size ({file_size / (1024 * 1024):.2f} MB) exceeds Azure Document Intelligence limit of 500 MB"
|
error_msg = (
|
||||||
|
f"File size ({file_size / (1024 * 1024):.2f} MB) exceeds Azure Document Intelligence limit of 500 MB"
|
||||||
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"error": error_msg, "file": filename, "status": "Failed - Size limit exceeded"}
|
return {"error": error_msg, "file": filename, "status": "Failed - Size limit exceeded"}
|
||||||
|
|
||||||
# For PDF files, check page count against service limits
|
# For PDF files, check page count against service limits
|
||||||
# "Fail open" approach: only reject if we're sure it exceeds the limit
|
# "Fail open" approach: only reject if we're sure it exceeds the limit
|
||||||
if filename.lower().endswith('.pdf'):
|
if filename.lower().endswith(".pdf"):
|
||||||
page_count = get_pdf_page_count(tmp_file_path)
|
page_count = get_pdf_page_count(tmp_file_path)
|
||||||
if page_count is not None and page_count > AZURE_DOC_INTELLIGENCE_LIMITS["max_pages"]:
|
if page_count is not None and page_count > AZURE_DOC_INTELLIGENCE_LIMITS["max_pages"]:
|
||||||
error_msg = f"PDF page count ({page_count}) exceeds Azure Document Intelligence limit of 2000 pages"
|
error_msg = f"PDF page count ({page_count}) exceeds Azure Document Intelligence limit of 2000 pages"
|
||||||
@@ -130,9 +135,7 @@ def process_with_azure_document_intelligence(filename: str, file_id: int = None)
|
|||||||
rotation_data = check_page_rotation(result, filename)
|
rotation_data = check_page_rotation(result, filename)
|
||||||
|
|
||||||
# Retrieve the processed searchable PDF
|
# Retrieve the processed searchable PDF
|
||||||
response = document_intelligence_client.get_analyze_result_pdf(
|
response = document_intelligence_client.get_analyze_result_pdf(model_id=result.model_id, result_id=operation_id)
|
||||||
model_id=result.model_id, result_id=operation_id
|
|
||||||
)
|
|
||||||
searchable_pdf_path = tmp_file_path # Overwrite the original PDF location
|
searchable_pdf_path = tmp_file_path # Overwrite the original PDF location
|
||||||
with open(searchable_pdf_path, "wb") as writer:
|
with open(searchable_pdf_path, "wb") as writer:
|
||||||
writer.writelines(response)
|
writer.writelines(response)
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
import openai
|
import openai
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
|
|
||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
|
||||||
# Initialize OpenAI client dynamically
|
# Initialize OpenAI client dynamically
|
||||||
client = openai.OpenAI(
|
client = openai.OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
|
||||||
api_key=settings.openai_api_key,
|
|
||||||
base_url=settings.openai_base_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry)
|
@celery.task(base=BaseTaskWithRetry)
|
||||||
def refine_text_with_gpt(filename: str, raw_text: str):
|
def refine_text_with_gpt(filename: str, raw_text: str):
|
||||||
@@ -19,16 +17,22 @@ def refine_text_with_gpt(filename: str, raw_text: str):
|
|||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=settings.openai_model,
|
model=settings.openai_model,
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": "Clean and format the following text. The idea is that the text you see comes from an OCR system and your task is to eliminate OCR errors. Keep the original language when doing so."},
|
{
|
||||||
{"role": "user", "content": raw_text}
|
"role": "system",
|
||||||
]
|
"content": (
|
||||||
|
"Clean and format the following text. The idea is that the text you see comes from an OCR "
|
||||||
|
"system and your task is to eliminate OCR errors. Keep the original language when doing so."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
cleaned_text = response.choices[0].message.content
|
cleaned_text = response.choices[0].message.content
|
||||||
|
|
||||||
# Trigger next task (import locally if needed to avoid circular imports)
|
# Trigger next task (import locally if needed to avoid circular imports)
|
||||||
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
||||||
|
|
||||||
extract_metadata_with_gpt.delay(filename, cleaned_text)
|
extract_metadata_with_gpt.delay(filename, cleaned_text)
|
||||||
|
|
||||||
return {"filename": filename, "cleaned_text": cleaned_text}
|
return {"filename": filename, "cleaned_text": cleaned_text}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from celery import Task
|
from celery import Task
|
||||||
|
|
||||||
|
|
||||||
class BaseTaskWithRetry(Task):
|
class BaseTaskWithRetry(Task):
|
||||||
autoretry_for = (Exception,)
|
autoretry_for = (Exception,)
|
||||||
retry_kwargs = {"max_retries": 3, "countdown": 10} # 3 retries, 10s delay
|
retry_kwargs = {"max_retries": 3, "countdown": 10} # 3 retries, 10s delay
|
||||||
retry_backoff = True # Exponential backoff
|
retry_backoff = True # Exponential backoff
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
import PyPDF2
|
|
||||||
import math
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import PyPDF2
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def determine_rotation_angle(detected_angle):
|
def determine_rotation_angle(detected_angle):
|
||||||
"""
|
"""
|
||||||
Determine the optimal rotation angle based on detected angle.
|
Determine the optimal rotation angle based on detected angle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
detected_angle: The angle detected by Azure Document Intelligence
|
detected_angle: The angle detected by Azure Document Intelligence
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: The angle to rotate the page in PyPDF2 (must be multiple of 90 degrees)
|
int: The angle to rotate the page in PyPDF2 (must be multiple of 90 degrees)
|
||||||
"""
|
"""
|
||||||
@@ -25,11 +26,11 @@ def determine_rotation_angle(detected_angle):
|
|||||||
normalized_angle = detected_angle % 360
|
normalized_angle = detected_angle % 360
|
||||||
if normalized_angle < 0:
|
if normalized_angle < 0:
|
||||||
normalized_angle += 360
|
normalized_angle += 360
|
||||||
|
|
||||||
# If angle is very small (< 1 degree), don't rotate
|
# If angle is very small (< 1 degree), don't rotate
|
||||||
if abs(normalized_angle) < 1 or abs(normalized_angle - 360) < 1:
|
if abs(normalized_angle) < 1 or abs(normalized_angle - 360) < 1:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# For angles close to 90, 180, or 270 degrees (±5°), round to nearest 90° increment
|
# For angles close to 90, 180, or 270 degrees (±5°), round to nearest 90° increment
|
||||||
for target in [90, 180, 270]:
|
for target in [90, 180, 270]:
|
||||||
if abs(normalized_angle - target) < 5:
|
if abs(normalized_angle - target) < 5:
|
||||||
@@ -37,7 +38,7 @@ def determine_rotation_angle(detected_angle):
|
|||||||
rotation_value = (360 - target) % 360
|
rotation_value = (360 - target) % 360
|
||||||
logger.info(f"Detected angle {detected_angle}° is close to {target}°, will rotate by {rotation_value}°")
|
logger.info(f"Detected angle {detected_angle}° is close to {target}°, will rotate by {rotation_value}°")
|
||||||
return rotation_value
|
return rotation_value
|
||||||
|
|
||||||
# For other significant angles, round to nearest 90° increment
|
# For other significant angles, round to nearest 90° increment
|
||||||
# (PyPDF2 only supports rotations in 90-degree increments)
|
# (PyPDF2 only supports rotations in 90-degree increments)
|
||||||
closest_90_multiple = round(normalized_angle / 90) * 90
|
closest_90_multiple = round(normalized_angle / 90) * 90
|
||||||
@@ -46,11 +47,12 @@ def determine_rotation_angle(detected_angle):
|
|||||||
logger.info(f"Detected angle {detected_angle}° rounded to {closest_90_multiple}°, will rotate by {rotation_value}°")
|
logger.info(f"Detected angle {detected_angle}° rounded to {closest_90_multiple}°, will rotate by {rotation_value}°")
|
||||||
return rotation_value
|
return rotation_value
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry)
|
@celery.task(base=BaseTaskWithRetry)
|
||||||
def rotate_pdf_pages(filename: str, extracted_text: str, rotation_data=None, file_id: int = None):
|
def rotate_pdf_pages(filename: str, extracted_text: str, rotation_data=None, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Rotates pages in a PDF document based on detected rotation angles.
|
Rotates pages in a PDF document based on detected rotation angles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: The name of the file to rotate
|
filename: The name of the file to rotate
|
||||||
extracted_text: The extracted text from the document
|
extracted_text: The extracted text from the document
|
||||||
@@ -61,7 +63,7 @@ def rotate_pdf_pages(filename: str, extracted_text: str, rotation_data=None, fil
|
|||||||
pdf_path = os.path.join(settings.workdir, "tmp", filename)
|
pdf_path = os.path.join(settings.workdir, "tmp", filename)
|
||||||
if not os.path.exists(pdf_path):
|
if not os.path.exists(pdf_path):
|
||||||
raise FileNotFoundError(f"PDF file not found: {pdf_path}")
|
raise FileNotFoundError(f"PDF file not found: {pdf_path}")
|
||||||
|
|
||||||
# Skip rotation if no rotation data provided
|
# Skip rotation if no rotation data provided
|
||||||
if not rotation_data:
|
if not rotation_data:
|
||||||
logger.info(f"No rotation data provided for {filename}, proceeding with metadata extraction")
|
logger.info(f"No rotation data provided for {filename}, proceeding with metadata extraction")
|
||||||
@@ -80,53 +82,62 @@ def rotate_pdf_pages(filename: str, extracted_text: str, rotation_data=None, fil
|
|||||||
logger.info(f"No significant rotations detected in {filename}, proceeding with metadata extraction")
|
logger.info(f"No significant rotations detected in {filename}, proceeding with metadata extraction")
|
||||||
extract_metadata_with_gpt.delay(filename, extracted_text, file_id)
|
extract_metadata_with_gpt.delay(filename, extracted_text, file_id)
|
||||||
return {"file": filename, "status": "no_rotation_needed"}
|
return {"file": filename, "status": "no_rotation_needed"}
|
||||||
|
|
||||||
logger.info(f"Rotating {len(normalized_rotation_data)} pages in {filename}")
|
logger.info(f"Rotating {len(normalized_rotation_data)} pages in {filename}")
|
||||||
applied_rotations = {}
|
applied_rotations = {}
|
||||||
|
|
||||||
# Load the PDF
|
# Load the PDF
|
||||||
with open(pdf_path, 'rb') as file:
|
with open(pdf_path, "rb") as file:
|
||||||
pdf_reader = PyPDF2.PdfReader(file)
|
pdf_reader = PyPDF2.PdfReader(file)
|
||||||
pdf_writer = PyPDF2.PdfWriter()
|
pdf_writer = PyPDF2.PdfWriter()
|
||||||
|
|
||||||
# Process each page
|
# Process each page
|
||||||
for page_idx in range(len(pdf_reader.pages)):
|
for page_idx in range(len(pdf_reader.pages)):
|
||||||
page = pdf_reader.pages[page_idx]
|
page = pdf_reader.pages[page_idx]
|
||||||
|
|
||||||
# Apply rotation if this page has rotation data
|
# Apply rotation if this page has rotation data
|
||||||
if page_idx in normalized_rotation_data and abs(normalized_rotation_data[page_idx]) > 0:
|
if page_idx in normalized_rotation_data and abs(normalized_rotation_data[page_idx]) > 0:
|
||||||
detected_angle = normalized_rotation_data[page_idx]
|
detected_angle = normalized_rotation_data[page_idx]
|
||||||
rotation_angle = determine_rotation_angle(detected_angle)
|
rotation_angle = determine_rotation_angle(detected_angle)
|
||||||
|
|
||||||
if rotation_angle > 0:
|
if rotation_angle > 0:
|
||||||
# PyPDF2 uses clockwise rotation in 90-degree increments
|
# PyPDF2 uses clockwise rotation in 90-degree increments
|
||||||
page.rotate(rotation_angle)
|
page.rotate(rotation_angle)
|
||||||
logger.info(f"Page {page_idx+1} rotated by {rotation_angle}° (from detected {detected_angle}°)")
|
logger.info(
|
||||||
|
f"Page {page_idx+1} rotated by {rotation_angle}° "
|
||||||
|
f"(from detected {detected_angle}°)"
|
||||||
|
)
|
||||||
applied_rotations[str(page_idx)] = rotation_angle
|
applied_rotations[str(page_idx)] = rotation_angle
|
||||||
else:
|
else:
|
||||||
logger.info(f"Page {page_idx+1} had detected angle {detected_angle}° but determined it doesn't need rotation")
|
logger.info(
|
||||||
|
f"Page {page_idx+1} had detected angle {detected_angle}° "
|
||||||
|
"but determined it doesn't need rotation"
|
||||||
|
)
|
||||||
|
|
||||||
pdf_writer.add_page(page)
|
pdf_writer.add_page(page)
|
||||||
|
|
||||||
# Save the rotated PDF
|
# Save the rotated PDF
|
||||||
with open(pdf_path, 'wb') as output_file:
|
with open(pdf_path, "wb") as output_file:
|
||||||
pdf_writer.write(output_file)
|
pdf_writer.write(output_file)
|
||||||
|
|
||||||
if applied_rotations:
|
if applied_rotations:
|
||||||
logger.info(f"Successfully rotated PDF: {filename} with rotations: {json.dumps(applied_rotations)}")
|
logger.info(f"Successfully rotated PDF: {filename} with rotations: {json.dumps(applied_rotations)}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Detected rotations in {filename} but no rotations were actually applied (angles too small or not multiples of 90°)")
|
logger.info(
|
||||||
|
f"Detected rotations in {filename} but no rotations were actually applied "
|
||||||
|
"(angles too small or not multiples of 90°)"
|
||||||
|
)
|
||||||
|
|
||||||
# Continue with metadata extraction
|
# Continue with metadata extraction
|
||||||
extract_metadata_with_gpt.delay(filename, extracted_text, file_id)
|
extract_metadata_with_gpt.delay(filename, extracted_text, file_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file": filename,
|
"file": filename,
|
||||||
"status": "rotated" if applied_rotations else "no_rotation_needed",
|
"status": "rotated" if applied_rotations else "no_rotation_needed",
|
||||||
"detected_rotations": rotation_data,
|
"detected_rotations": rotation_data,
|
||||||
"applied_rotations": applied_rotations
|
"applied_rotations": applied_rotations,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error rotating PDF {filename}: {e}")
|
logger.error(f"Error rotating PDF {filename}: {e}")
|
||||||
# Continue with metadata extraction despite rotation failure
|
# Continue with metadata extraction despite rotation failure
|
||||||
|
|||||||
+83
-77
@@ -1,83 +1,80 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
from app.config import settings
|
import os
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
|
||||||
from app.tasks.upload_to_webdav import upload_to_webdav
|
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp
|
|
||||||
from app.tasks.upload_to_sftp import upload_to_sftp
|
|
||||||
from app.tasks.upload_to_email import upload_to_email
|
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
|
||||||
from app.tasks.upload_to_s3 import upload_to_s3
|
|
||||||
from app.utils.config_validator import get_provider_status
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app.utils import log_task_progress
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
||||||
|
from app.tasks.upload_to_email import upload_to_email
|
||||||
|
from app.tasks.upload_to_ftp import upload_to_ftp
|
||||||
|
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
||||||
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||||
|
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
||||||
|
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||||
|
from app.tasks.upload_to_s3 import upload_to_s3
|
||||||
|
from app.tasks.upload_to_sftp import upload_to_sftp
|
||||||
|
from app.tasks.upload_to_webdav import upload_to_webdav
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
from app.utils.config_validator import get_provider_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _should_upload_to_dropbox():
|
def _should_upload_to_dropbox():
|
||||||
return (settings.dropbox_app_key and
|
return settings.dropbox_app_key and settings.dropbox_app_secret and settings.dropbox_refresh_token
|
||||||
settings.dropbox_app_secret and
|
|
||||||
settings.dropbox_refresh_token)
|
|
||||||
|
|
||||||
def _should_upload_to_nextcloud():
|
def _should_upload_to_nextcloud():
|
||||||
return (settings.nextcloud_upload_url and
|
return settings.nextcloud_upload_url and settings.nextcloud_username and settings.nextcloud_password
|
||||||
settings.nextcloud_username and
|
|
||||||
settings.nextcloud_password)
|
|
||||||
|
|
||||||
def _should_upload_to_paperless():
|
def _should_upload_to_paperless():
|
||||||
return (settings.paperless_ngx_api_token and
|
return settings.paperless_ngx_api_token and settings.paperless_host
|
||||||
settings.paperless_host)
|
|
||||||
|
|
||||||
def _should_upload_to_google_drive():
|
def _should_upload_to_google_drive():
|
||||||
# Check for OAuth configuration
|
# Check for OAuth configuration
|
||||||
if getattr(settings, 'google_drive_use_oauth', False):
|
if getattr(settings, "google_drive_use_oauth", False):
|
||||||
return (settings.google_drive_client_id and
|
return (
|
||||||
settings.google_drive_client_secret and
|
settings.google_drive_client_id
|
||||||
settings.google_drive_refresh_token and
|
and settings.google_drive_client_secret
|
||||||
settings.google_drive_folder_id)
|
and settings.google_drive_refresh_token
|
||||||
|
and settings.google_drive_folder_id
|
||||||
|
)
|
||||||
# Or check for service account configuration
|
# Or check for service account configuration
|
||||||
else:
|
else:
|
||||||
return (settings.google_drive_credentials_json and
|
return settings.google_drive_credentials_json and settings.google_drive_folder_id
|
||||||
settings.google_drive_folder_id)
|
|
||||||
|
|
||||||
def _should_upload_to_webdav():
|
def _should_upload_to_webdav():
|
||||||
return (settings.webdav_url and
|
return settings.webdav_url and settings.webdav_username and settings.webdav_password
|
||||||
settings.webdav_username and
|
|
||||||
settings.webdav_password)
|
|
||||||
|
|
||||||
def _should_upload_to_ftp():
|
def _should_upload_to_ftp():
|
||||||
return (settings.ftp_host and
|
return settings.ftp_host and settings.ftp_username and settings.ftp_password
|
||||||
settings.ftp_username and
|
|
||||||
settings.ftp_password)
|
|
||||||
|
|
||||||
def _should_upload_to_sftp():
|
def _should_upload_to_sftp():
|
||||||
return (settings.sftp_host and
|
return settings.sftp_host and settings.sftp_username and (settings.sftp_password or settings.sftp_private_key)
|
||||||
settings.sftp_username and
|
|
||||||
(settings.sftp_password or settings.sftp_private_key))
|
|
||||||
|
|
||||||
def _should_upload_to_email():
|
def _should_upload_to_email():
|
||||||
return (settings.email_host and
|
return (
|
||||||
settings.email_username and
|
settings.email_host and settings.email_username and settings.email_password and settings.email_default_recipient
|
||||||
settings.email_password and
|
)
|
||||||
settings.email_default_recipient)
|
|
||||||
|
|
||||||
def _should_upload_to_onedrive():
|
def _should_upload_to_onedrive():
|
||||||
return (settings.onedrive_client_id and
|
return settings.onedrive_client_id and settings.onedrive_client_secret and settings.onedrive_refresh_token
|
||||||
settings.onedrive_client_secret and
|
|
||||||
settings.onedrive_refresh_token)
|
|
||||||
|
|
||||||
def _should_upload_to_s3():
|
def _should_upload_to_s3():
|
||||||
return (settings.s3_bucket_name and
|
return settings.s3_bucket_name and settings.aws_access_key_id and settings.aws_secret_access_key
|
||||||
settings.aws_access_key_id and
|
|
||||||
settings.aws_secret_access_key)
|
|
||||||
|
|
||||||
def get_configured_services_from_validator():
|
def get_configured_services_from_validator():
|
||||||
"""
|
"""
|
||||||
@@ -86,7 +83,7 @@ def get_configured_services_from_validator():
|
|||||||
whether they're properly configured.
|
whether they're properly configured.
|
||||||
"""
|
"""
|
||||||
providers = get_provider_status()
|
providers = get_provider_status()
|
||||||
|
|
||||||
service_map = {
|
service_map = {
|
||||||
"Dropbox": "dropbox",
|
"Dropbox": "dropbox",
|
||||||
"NextCloud": "nextcloud",
|
"NextCloud": "nextcloud",
|
||||||
@@ -97,21 +94,22 @@ def get_configured_services_from_validator():
|
|||||||
"SFTP Storage": "sftp",
|
"SFTP Storage": "sftp",
|
||||||
"Email": "email",
|
"Email": "email",
|
||||||
"OneDrive": "onedrive",
|
"OneDrive": "onedrive",
|
||||||
"S3 Storage": "s3"
|
"S3 Storage": "s3",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for provider_name, internal_name in service_map.items():
|
for provider_name, internal_name in service_map.items():
|
||||||
if provider_name in providers:
|
if provider_name in providers:
|
||||||
result[internal_name] = providers[provider_name].get('configured', False)
|
result[internal_name] = providers[provider_name].get("configured", False)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def send_to_all_destinations(self, file_path: str, use_validator=True, file_id: int = None):
|
def send_to_all_destinations(self, file_path: str, use_validator=True, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Distribute a file to all configured storage destinations.
|
Distribute a file to all configured storage destinations.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to distribute
|
file_path: Path to the file to distribute
|
||||||
use_validator: Whether to use the config validator to determine enabled services
|
use_validator: Whether to use the config validator to determine enabled services
|
||||||
@@ -119,28 +117,36 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
logger.error(f"[{task_id}] File not found: {file_path}")
|
logger.error(f"[{task_id}] File not found: {file_path}")
|
||||||
log_task_progress(task_id, "send_to_all_destinations", "failure", "File not found", file_id=file_id)
|
log_task_progress(task_id, "send_to_all_destinations", "failure", "File not found", file_id=file_id)
|
||||||
raise FileNotFoundError(f"File not found: {file_path}")
|
raise FileNotFoundError(f"File not found: {file_path}")
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Sending {file_path} to all configured destinations")
|
logger.info(f"[{task_id}] Sending {file_path} to all configured destinations")
|
||||||
log_task_progress(task_id, "send_to_all_destinations", "in_progress", f"Distributing: {os.path.basename(file_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"send_to_all_destinations",
|
||||||
|
"in_progress",
|
||||||
|
f"Distributing: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Get file_id from database if not provided (fallback only, prefer passing file_id explicitly)
|
# Get file_id from database if not provided (fallback only, prefer passing file_id explicitly)
|
||||||
if file_id is None:
|
if file_id is None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# Only as a last resort, try to find by basename match
|
# Only as a last resort, try to find by basename match
|
||||||
# This should not be needed if file_id is passed correctly through the chain
|
# This should not be needed if file_id is passed correctly through the chain
|
||||||
file_record = db.query(FileRecord).filter(
|
file_record = (
|
||||||
FileRecord.local_filename == os.path.join(settings.workdir, "tmp", os.path.basename(file_path))
|
db.query(FileRecord)
|
||||||
).first()
|
.filter(FileRecord.local_filename == os.path.join(settings.workdir, "tmp", os.path.basename(file_path)))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if file_record:
|
if file_record:
|
||||||
file_id = file_record.id
|
file_id = file_record.id
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# Define service configurations
|
# Define service configurations
|
||||||
services = [
|
services = [
|
||||||
{
|
{
|
||||||
@@ -194,7 +200,7 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
"upload_func": upload_to_s3,
|
"upload_func": upload_to_s3,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optionally get configuration status from validator
|
# Optionally get configuration status from validator
|
||||||
configured_services = {}
|
configured_services = {}
|
||||||
if use_validator:
|
if use_validator:
|
||||||
@@ -204,12 +210,12 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{task_id}] Failed to get configuration from validator: {str(e)}")
|
logger.warning(f"[{task_id}] Failed to get configuration from validator: {str(e)}")
|
||||||
use_validator = False
|
use_validator = False
|
||||||
|
|
||||||
# Process each service
|
# Process each service
|
||||||
queued_count = 0
|
queued_count = 0
|
||||||
for service in services:
|
for service in services:
|
||||||
service_name = service["name"]
|
service_name = service["name"]
|
||||||
|
|
||||||
# Determine if service is configured
|
# Determine if service is configured
|
||||||
is_configured = False
|
is_configured = False
|
||||||
if use_validator and service_name in configured_services:
|
if use_validator and service_name in configured_services:
|
||||||
@@ -222,26 +228,26 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{task_id}] Error checking configuration for {service_name}: {str(e)}")
|
logger.error(f"[{task_id}] Error checking configuration for {service_name}: {str(e)}")
|
||||||
is_configured = False
|
is_configured = False
|
||||||
|
|
||||||
# Queue the upload task if service is configured
|
# Queue the upload task if service is configured
|
||||||
if is_configured:
|
if is_configured:
|
||||||
logger.info(f"[{task_id}] Queueing {file_path} for {service_name} upload")
|
logger.info(f"[{task_id}] Queueing {file_path} for {service_name} upload")
|
||||||
log_task_progress(task_id, f"queue_{service_name}", "in_progress", f"Queueing upload to {service_name}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, f"queue_{service_name}", "in_progress", f"Queueing upload to {service_name}", file_id=file_id
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
task = service["upload_func"].delay(file_path, file_id=file_id)
|
task = service["upload_func"].delay(file_path, file_id=file_id)
|
||||||
results[f"{service_name}_task_id"] = task.id
|
results[f"{service_name}_task_id"] = task.id
|
||||||
queued_count += 1
|
queued_count += 1
|
||||||
log_task_progress(task_id, f"queue_{service_name}", "success", f"Queued for {service_name}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, f"queue_{service_name}", "success", f"Queued for {service_name}", file_id=file_id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{task_id}] Failed to queue {service_name} task: {str(e)}")
|
logger.error(f"[{task_id}] Failed to queue {service_name} task: {str(e)}")
|
||||||
results[f"{service_name}_error"] = str(e)
|
results[f"{service_name}_error"] = str(e)
|
||||||
log_task_progress(task_id, f"queue_{service_name}", "failure", f"Failed: {str(e)}", file_id=file_id)
|
log_task_progress(task_id, f"queue_{service_name}", "failure", f"Failed: {str(e)}", file_id=file_id)
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Queued {queued_count} upload tasks")
|
logger.info(f"[{task_id}] Queued {queued_count} upload tasks")
|
||||||
log_task_progress(task_id, "send_to_all_destinations", "success", f"Queued {queued_count} uploads", file_id=file_id)
|
log_task_progress(task_id, "send_to_all_destinations", "success", f"Queued {queued_count} uploads", file_id=file_id)
|
||||||
|
|
||||||
return {
|
return {"status": "Queued", "file_path": file_path, "tasks": results}
|
||||||
"status": "Queued",
|
|
||||||
"file_path": file_path,
|
|
||||||
"tasks": results
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import os
|
||||||
|
|
||||||
import dropbox
|
import dropbox
|
||||||
|
import requests
|
||||||
from dropbox.exceptions import ApiError, AuthError
|
from dropbox.exceptions import ApiError, AuthError
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils.filename_utils import get_unique_filename, sanitize_filename, extract_remote_path
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
from app.database import SessionLocal
|
from app.utils.filename_utils import extract_remote_path, get_unique_filename
|
||||||
from app.models import FileRecord
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _validate_dropbox_settings():
|
def _validate_dropbox_settings():
|
||||||
"""Validate that all required Dropbox settings are available."""
|
"""Validate that all required Dropbox settings are available."""
|
||||||
missing = []
|
missing = []
|
||||||
|
|
||||||
if not hasattr(settings, 'dropbox_refresh_token') or not settings.dropbox_refresh_token:
|
if not hasattr(settings, "dropbox_refresh_token") or not settings.dropbox_refresh_token:
|
||||||
missing.append("refresh token")
|
missing.append("refresh token")
|
||||||
|
|
||||||
if not hasattr(settings, 'dropbox_app_key') or not settings.dropbox_app_key:
|
if not hasattr(settings, "dropbox_app_key") or not settings.dropbox_app_key:
|
||||||
missing.append("app key")
|
missing.append("app key")
|
||||||
|
|
||||||
if not hasattr(settings, 'dropbox_app_secret') or not settings.dropbox_app_secret:
|
if not hasattr(settings, "dropbox_app_secret") or not settings.dropbox_app_secret:
|
||||||
missing.append("app secret")
|
missing.append("app secret")
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
logger.error(f"Cannot refresh Dropbox token: Missing {', '.join(missing)}")
|
logger.error(f"Cannot refresh Dropbox token: Missing {', '.join(missing)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_dropbox_access_token():
|
def get_dropbox_access_token():
|
||||||
"""Refresh the Dropbox access token using the stored refresh token from ENV."""
|
"""Refresh the Dropbox access token using the stored refresh token from ENV."""
|
||||||
|
|
||||||
# Check if needed settings are available
|
# Check if needed settings are available
|
||||||
if not _validate_dropbox_settings():
|
if not _validate_dropbox_settings():
|
||||||
return None
|
return None
|
||||||
@@ -49,7 +51,7 @@ def get_dropbox_access_token():
|
|||||||
"client_id": settings.dropbox_app_key,
|
"client_id": settings.dropbox_app_key,
|
||||||
"client_secret": settings.dropbox_app_secret,
|
"client_secret": settings.dropbox_app_secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(token_url, headers=headers, data=data, timeout=settings.http_request_timeout)
|
response = requests.post(token_url, headers=headers, data=data, timeout=settings.http_request_timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -59,13 +61,14 @@ def get_dropbox_access_token():
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
|
||||||
def get_dropbox_client():
|
def get_dropbox_client():
|
||||||
"""
|
"""
|
||||||
Create and return an authenticated Dropbox client using the configured refresh token.
|
Create and return an authenticated Dropbox client using the configured refresh token.
|
||||||
Create and return an authenticated Dropbox client using the configured refresh token.
|
|
||||||
Returns:
|
Returns:
|
||||||
dropbox.Dropbox: Authenticated Dropbox client instance
|
dropbox.Dropbox: Authenticated Dropbox client instance
|
||||||
dropbox.Dropbox: Authenticated Dropbox client instance
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If required Dropbox configuration is missing
|
ValueError: If required Dropbox configuration is missing
|
||||||
AuthError: If authentication with Dropbox fails
|
AuthError: If authentication with Dropbox fails
|
||||||
@@ -73,72 +76,80 @@ def get_dropbox_client():
|
|||||||
app_key = settings.dropbox_app_key
|
app_key = settings.dropbox_app_key
|
||||||
app_secret = settings.dropbox_app_secret
|
app_secret = settings.dropbox_app_secret
|
||||||
refresh_token = settings.dropbox_refresh_token
|
refresh_token = settings.dropbox_refresh_token
|
||||||
refresh_token = settings.dropbox_refresh_token
|
|
||||||
# Validate configuration
|
# Validate configuration
|
||||||
if not app_key or not app_secret:
|
if not app_key or not app_secret:
|
||||||
raise ValueError("Dropbox app key or app secret is not configured")
|
raise ValueError("Dropbox app key or app secret is not configured")
|
||||||
raise ValueError("Dropbox app key or app secret is not configured")
|
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
raise ValueError("Dropbox refresh token is not configured")
|
raise ValueError("Dropbox refresh token is not configured")
|
||||||
raise ValueError("Dropbox refresh token is not configured")
|
|
||||||
# Create a Dropbox client with refresh token
|
# Create a Dropbox client with refresh token
|
||||||
try:
|
try:
|
||||||
try:
|
dbx = dropbox.Dropbox(app_key=app_key, app_secret=app_secret, oauth2_refresh_token=refresh_token)
|
||||||
dbx = dropbox.Dropbox(
|
|
||||||
app_key=app_key,
|
|
||||||
app_secret=app_secret,
|
|
||||||
oauth2_refresh_token=refresh_token
|
|
||||||
)
|
|
||||||
# Test the connection
|
# Test the connection
|
||||||
dbx.users_get_current_account()
|
dbx.users_get_current_account()
|
||||||
logger.info("Successfully authenticated with Dropbox")
|
logger.info("Successfully authenticated with Dropbox")
|
||||||
return dbx
|
return dbx
|
||||||
return dbx
|
|
||||||
except AuthError as auth_error:
|
except AuthError as auth_error:
|
||||||
logger.error(f"Dropbox authentication failed: {str(auth_error)}")
|
logger.error(f"Dropbox authentication failed: {str(auth_error)}")
|
||||||
raise
|
raise
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating Dropbox client: {str(e)}")
|
logger.error(f"Error creating Dropbox client: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_dropbox(self, file_path: str, file_id: int = None):
|
def upload_to_dropbox(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Upload a file to Dropbox.
|
Upload a file to Dropbox.
|
||||||
Upload a file to Dropbox.
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting Dropbox upload: {file_path}")
|
logger.info(f"[{task_id}] Starting Dropbox upload: {file_path}")
|
||||||
logger.info(f"[{task_id}] Starting Dropbox upload: {file_path}")
|
log_task_progress(
|
||||||
log_task_progress(task_id, "upload_to_dropbox", "in_progress", f"Uploading to Dropbox: {os.path.basename(file_path)}", file_id=file_id)
|
task_id,
|
||||||
|
"upload_to_dropbox",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to Dropbox: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_dropbox", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_dropbox", "failure", error_msg, file_id=file_id)
|
||||||
raise FileNotFoundError(error_msg)
|
raise FileNotFoundError(error_msg)
|
||||||
raise FileNotFoundError(error_msg)
|
|
||||||
# Check if Dropbox is properly configured
|
# Check if Dropbox is properly configured
|
||||||
# Check if Dropbox is properly configured
|
if not (
|
||||||
if not (hasattr(settings, 'dropbox_app_key') and settings.dropbox_app_key and
|
hasattr(settings, "dropbox_app_key")
|
||||||
hasattr(settings, 'dropbox_app_secret') and settings.dropbox_app_secret and
|
and settings.dropbox_app_key
|
||||||
|
and hasattr(settings, "dropbox_app_secret")
|
||||||
|
and settings.dropbox_app_secret
|
||||||
|
and hasattr(settings, "dropbox_refresh_token")
|
||||||
|
and settings.dropbox_refresh_token
|
||||||
|
):
|
||||||
logger.info(f"[{task_id}] Dropbox upload skipped: Missing configuration")
|
logger.info(f"[{task_id}] Dropbox upload skipped: Missing configuration")
|
||||||
log_task_progress(task_id, "upload_to_dropbox", "success", "Skipped: Not configured", file_id=file_id)
|
log_task_progress(task_id, "upload_to_dropbox", "success", "Skipped: Not configured", file_id=file_id)
|
||||||
return {"status": "Skipped", "reason": "Dropbox settings not configured"}
|
return {"status": "Skipped", "reason": "Dropbox settings not configured"}
|
||||||
return {"status": "Skipped", "reason": "Dropbox settings not configured"}
|
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
filename = os.path.basename(file_path)
|
|
||||||
try:
|
try:
|
||||||
# Get the Dropbox client
|
# Get the Dropbox client
|
||||||
dbx = get_dropbox_client()
|
dbx = get_dropbox_client()
|
||||||
dbx = get_dropbox_client()
|
|
||||||
# Calculate remote path based on local file structure
|
# Calculate remote path based on local file structure
|
||||||
remote_base = settings.dropbox_folder or ""
|
remote_base = settings.dropbox_folder or ""
|
||||||
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
||||||
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
|
||||||
# Function to check if file exists in Dropbox
|
# Function to check if file exists in Dropbox
|
||||||
def check_exists_in_dropbox(path):
|
def check_exists_in_dropbox(path):
|
||||||
try:
|
try:
|
||||||
@@ -148,29 +159,29 @@ def upload_to_dropbox(self, file_path: str, file_id: int = None):
|
|||||||
if e.error.is_path() and e.error.get_path().is_not_found():
|
if e.error.is_path() and e.error.get_path().is_not_found():
|
||||||
return False
|
return False
|
||||||
raise
|
raise
|
||||||
raise
|
|
||||||
# Get a unique path in case of collision
|
# Get a unique path in case of collision
|
||||||
remote_full_path = f"/{remote_path}" # Dropbox paths should start with /
|
remote_full_path = f"/{remote_path}" # Dropbox paths should start with /
|
||||||
remote_full_path = f"/{remote_path}" # Dropbox paths should start with /
|
remote_full_path = remote_full_path.replace("//", "/") # Clean double slashes
|
||||||
remote_full_path = remote_full_path.replace('//', '/') # Clean double slashes
|
|
||||||
# Check for potential file collision and get a unique name if needed
|
# Check for potential file collision and get a unique name if needed
|
||||||
dropbox_path = get_unique_filename(remote_full_path, check_exists_in_dropbox)
|
dropbox_path = get_unique_filename(remote_full_path, check_exists_in_dropbox)
|
||||||
dropbox_path = get_unique_filename(remote_full_path, check_exists_in_dropbox)
|
|
||||||
# Upload the file
|
# Upload the file
|
||||||
logger.info(f"[{task_id}] Uploading {filename} to Dropbox at {dropbox_path}")
|
logger.info(f"[{task_id}] Uploading {filename} to Dropbox at {dropbox_path}")
|
||||||
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {dropbox_path}", file_id=file_id)
|
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {dropbox_path}", file_id=file_id)
|
||||||
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {dropbox_path}", file_id=file_id)
|
with open(file_path, "rb") as file_data:
|
||||||
# Use files_upload_session for large files to avoid timeouts
|
# Use files_upload_session for large files to avoid timeouts
|
||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
if file_size > 10 * 1024 * 1024: # 10 MB threshold for chunked upload
|
if file_size > 10 * 1024 * 1024: # 10 MB threshold for chunked upload
|
||||||
cursor = None
|
cursor = None
|
||||||
chunk_size = 4 * 1024 * 1024 # 4 MB chunks
|
chunk_size = 4 * 1024 * 1024 # 4 MB chunks
|
||||||
file_data.seek(0)
|
file_data.seek(0)
|
||||||
file_data.seek(0)
|
|
||||||
# Start upload session
|
# Start upload session
|
||||||
session_start = dbx.files_upload_session_start(file_data.read(chunk_size))
|
session_start = dbx.files_upload_session_start(file_data.read(chunk_size))
|
||||||
cursor = dropbox.files.UploadSessionCursor(session_start.session_id, file_data.tell())
|
cursor = dropbox.files.UploadSessionCursor(session_start.session_id, file_data.tell())
|
||||||
cursor = dropbox.files.UploadSessionCursor(session_start.session_id, file_data.tell())
|
|
||||||
# Upload chunks until we reach the end
|
# Upload chunks until we reach the end
|
||||||
while file_data.tell() < file_size:
|
while file_data.tell() < file_size:
|
||||||
if (file_size - file_data.tell()) <= chunk_size:
|
if (file_size - file_data.tell()) <= chunk_size:
|
||||||
@@ -178,7 +189,7 @@ def upload_to_dropbox(self, file_path: str, file_id: int = None):
|
|||||||
dbx.files_upload_session_finish(
|
dbx.files_upload_session_finish(
|
||||||
file_data.read(chunk_size),
|
file_data.read(chunk_size),
|
||||||
cursor,
|
cursor,
|
||||||
cursor,
|
dropbox.files.CommitInfo(path=dropbox_path, mode=dropbox.files.WriteMode.overwrite),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# More chunks to upload
|
# More chunks to upload
|
||||||
@@ -187,20 +198,14 @@ def upload_to_dropbox(self, file_path: str, file_id: int = None):
|
|||||||
else:
|
else:
|
||||||
# Small file, direct upload
|
# Small file, direct upload
|
||||||
file_data.seek(0)
|
file_data.seek(0)
|
||||||
file_data.seek(0)
|
dbx.files_upload(file_data.read(), dropbox_path, mode=dropbox.files.WriteMode.overwrite)
|
||||||
dbx.files_upload(
|
|
||||||
file_data.read(),
|
|
||||||
dropbox_path,
|
|
||||||
mode=dropbox.files.WriteMode.overwrite
|
|
||||||
)
|
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to Dropbox at {dropbox_path}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to Dropbox at {dropbox_path}")
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to Dropbox at {dropbox_path}")
|
log_task_progress(
|
||||||
log_task_progress(task_id, "upload_to_dropbox", "success", f"Uploaded to Dropbox: {dropbox_path}", file_id=file_id)
|
task_id, "upload_to_dropbox", "success", f"Uploaded to Dropbox: {dropbox_path}", file_id=file_id
|
||||||
return {
|
)
|
||||||
"status": "Completed",
|
return {"status": "Completed", "file_path": file_path, "dropbox_path": dropbox_path}
|
||||||
"file_path": file_path,
|
|
||||||
"dropbox_path": dropbox_path
|
|
||||||
}
|
|
||||||
except AuthError:
|
except AuthError:
|
||||||
error_msg = f"Authentication failed while uploading {filename} to Dropbox. Check token."
|
error_msg = f"Authentication failed while uploading {filename} to Dropbox. Check token."
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import smtplib
|
import smtplib
|
||||||
import socket
|
import socket
|
||||||
import logging
|
from datetime import datetime
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
from email.mime.image import MIMEImage
|
from email.mime.image import MIMEImage
|
||||||
from datetime import datetime
|
from email.mime.multipart import MIMEMultipart
|
||||||
from pathlib import Path
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_email_template(template_name="default.html"):
|
def get_email_template(template_name="default.html"):
|
||||||
"""
|
"""
|
||||||
Load email template from one of these locations in order of precedence:
|
Load email template from one of these locations in order of precedence:
|
||||||
@@ -30,26 +32,22 @@ def get_email_template(template_name="default.html"):
|
|||||||
workdir_template_path = os.path.join(settings.workdir, "templates", "email")
|
workdir_template_path = os.path.join(settings.workdir, "templates", "email")
|
||||||
if os.path.exists(workdir_template_path):
|
if os.path.exists(workdir_template_path):
|
||||||
env = Environment(
|
env = Environment(
|
||||||
loader=FileSystemLoader(workdir_template_path),
|
loader=FileSystemLoader(workdir_template_path), autoescape=select_autoescape(["html", "xml"])
|
||||||
autoescape=select_autoescape(['html', 'xml'])
|
|
||||||
)
|
)
|
||||||
env.globals['now'] = datetime.now # Add the now function to the Jinja environment
|
env.globals["now"] = datetime.now # Add the now function to the Jinja environment
|
||||||
template = env.get_template(template_name)
|
template = env.get_template(template_name)
|
||||||
logger.info(f"Using custom email template from workdir: {template_name}")
|
logger.info(f"Using custom email template from workdir: {template_name}")
|
||||||
return template
|
return template
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load custom email template: {str(e)}")
|
logger.warning(f"Failed to load custom email template: {str(e)}")
|
||||||
|
|
||||||
# Fallback to built-in template
|
# Fallback to built-in template
|
||||||
try:
|
try:
|
||||||
# Get the app directory path (where this file is)
|
# Get the app directory path (where this file is)
|
||||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
app_template_path = os.path.join(current_dir, "templates", "email")
|
app_template_path = os.path.join(current_dir, "templates", "email")
|
||||||
env = Environment(
|
env = Environment(loader=FileSystemLoader(app_template_path), autoescape=select_autoescape(["html", "xml"]))
|
||||||
loader=FileSystemLoader(app_template_path),
|
env.globals["now"] = datetime.now # Add the now function to the Jinja environment
|
||||||
autoescape=select_autoescape(['html', 'xml'])
|
|
||||||
)
|
|
||||||
env.globals['now'] = datetime.now # Add the now function to the Jinja environment
|
|
||||||
template = env.get_template(template_name)
|
template = env.get_template(template_name)
|
||||||
logger.info(f"Using built-in email template: {template_name}")
|
logger.info(f"Using built-in email template: {template_name}")
|
||||||
return template
|
return template
|
||||||
@@ -57,32 +55,34 @@ def get_email_template(template_name="default.html"):
|
|||||||
logger.error(f"Failed to load built-in email template: {str(e)}")
|
logger.error(f"Failed to load built-in email template: {str(e)}")
|
||||||
raise ValueError(f"Could not find any valid email template: {str(e)}")
|
raise ValueError(f"Could not find any valid email template: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata_from_file(file_path):
|
def extract_metadata_from_file(file_path):
|
||||||
"""
|
"""
|
||||||
Try to extract metadata from a file using several methods:
|
Try to extract metadata from a file using several methods:
|
||||||
1. Check for a .json metadata file with the same name
|
1. Check for a .json metadata file with the same name
|
||||||
2. Extract metadata from PDF if it's embedded
|
2. Extract metadata from PDF if it's embedded
|
||||||
|
|
||||||
Returns a dictionary of metadata or None if not found
|
Returns a dictionary of metadata or None if not found
|
||||||
"""
|
"""
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
|
||||||
# Check for separate metadata JSON file
|
# Check for separate metadata JSON file
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.json'
|
metadata_path = os.path.splitext(file_path)[0] + ".json"
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
||||||
return metadata
|
return metadata
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
||||||
|
|
||||||
# TODO: For PDF files, try to extract embedded metadata using PyPDF2
|
# TODO: For PDF files, try to extract embedded metadata using PyPDF2
|
||||||
# This would require additional dependencies, so for now we'll just check for external JSON
|
# This would require additional dependencies, so for now we'll just check for external JSON
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
def attach_logo(msg):
|
def attach_logo(msg):
|
||||||
"""Attach the DocuElevate logo to the email with proper Content-ID."""
|
"""Attach the DocuElevate logo to the email with proper Content-ID."""
|
||||||
try:
|
try:
|
||||||
@@ -97,27 +97,28 @@ def attach_logo(msg):
|
|||||||
# Fallback to logo in frontend/static if app/static doesn't exist
|
# Fallback to logo in frontend/static if app/static doesn't exist
|
||||||
if not os.path.exists(logo_path):
|
if not os.path.exists(logo_path):
|
||||||
logo_path = os.path.join(app_dir, "..", "frontend", "static", "logo.png")
|
logo_path = os.path.join(app_dir, "..", "frontend", "static", "logo.png")
|
||||||
|
|
||||||
if os.path.exists(logo_path):
|
if os.path.exists(logo_path):
|
||||||
with open(logo_path, 'rb') as img:
|
with open(logo_path, "rb") as img:
|
||||||
logo_data = img.read()
|
logo_data = img.read()
|
||||||
|
|
||||||
# Determine image MIME type based on extension
|
# Determine image MIME type based on extension
|
||||||
mimetype = 'image/svg+xml' if logo_path.endswith('.svg') else 'image/png'
|
mimetype = "image/svg+xml" if logo_path.endswith(".svg") else "image/png"
|
||||||
logo_attach = MIMEImage(logo_data, mimetype)
|
logo_attach = MIMEImage(logo_data, mimetype)
|
||||||
logo_attach.add_header('Content-ID', '<logo>')
|
logo_attach.add_header("Content-ID", "<logo>")
|
||||||
logo_attach.add_header('Content-Disposition', 'inline', filename='logo.png')
|
logo_attach.add_header("Content-Disposition", "inline", filename="logo.png")
|
||||||
msg.attach(logo_attach)
|
msg.attach(logo_attach)
|
||||||
logger.info(f"Logo attached from {logo_path}")
|
logger.info(f"Logo attached from {logo_path}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not find logo file")
|
logger.warning("Could not find logo file")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error attaching logo: {str(e)}")
|
logger.warning(f"Error attaching logo: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _prepare_recipients(recipients):
|
def _prepare_recipients(recipients):
|
||||||
"""Helper function to prepare email recipients list."""
|
"""Helper function to prepare email recipients list."""
|
||||||
if not recipients:
|
if not recipients:
|
||||||
@@ -130,22 +131,23 @@ def _prepare_recipients(recipients):
|
|||||||
return [recipients], None # Convert single email to list
|
return [recipients], None # Convert single email to list
|
||||||
return recipients, None
|
return recipients, None
|
||||||
|
|
||||||
|
|
||||||
def _send_email_with_smtp(msg, filename, recipients):
|
def _send_email_with_smtp(msg, filename, recipients):
|
||||||
"""Helper function to handle SMTP connection and sending."""
|
"""Helper function to handle SMTP connection and sending."""
|
||||||
try:
|
try:
|
||||||
# First try to resolve the hostname
|
# First try to resolve the hostname
|
||||||
socket.gethostbyname(settings.email_host)
|
socket.gethostbyname(settings.email_host)
|
||||||
|
|
||||||
# Connect to the SMTP server
|
# Connect to the SMTP server
|
||||||
with smtplib.SMTP(settings.email_host, settings.email_port, timeout=30) as server:
|
with smtplib.SMTP(settings.email_host, settings.email_port, timeout=30) as server:
|
||||||
# Use TLS if specified
|
# Use TLS if specified
|
||||||
if settings.email_use_tls:
|
if settings.email_use_tls:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
|
|
||||||
# Login if credentials are provided
|
# Login if credentials are provided
|
||||||
if settings.email_username and settings.email_password:
|
if settings.email_username and settings.email_password:
|
||||||
server.login(settings.email_username, settings.email_password)
|
server.login(settings.email_username, settings.email_password)
|
||||||
|
|
||||||
# Send the email
|
# Send the email
|
||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
|
|
||||||
@@ -160,12 +162,22 @@ def _send_email_with_smtp(msg, filename, recipients):
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"status": "Failed", "reason": error_msg, "error": str(e)}
|
return {"status": "Failed", "reason": error_msg, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_email(self, file_path: str, recipients=None, subject=None, message=None, template_name="default.html", include_metadata=True, file_id: int = None):
|
def upload_to_email(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
recipients=None,
|
||||||
|
subject=None,
|
||||||
|
message=None,
|
||||||
|
template_name="default.html",
|
||||||
|
include_metadata=True,
|
||||||
|
file_id: int = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Sends a file via email to the specified recipients.
|
Sends a file via email to the specified recipients.
|
||||||
If recipients is None, uses the configured default email recipient.
|
If recipients is None, uses the configured default email recipient.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to send
|
file_path: Path to the file to send
|
||||||
recipients: Optional list of recipient email addresses
|
recipients: Optional list of recipient email addresses
|
||||||
@@ -180,7 +192,7 @@ def upload_to_email(self, file_path: str, recipients=None, subject=None, message
|
|||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id, "upload_to_email", "in_progress", f"Sending via email: {os.path.basename(file_path)}", file_id=file_id
|
task_id, "upload_to_email", "in_progress", f"Sending via email: {os.path.basename(file_path)}", file_id=file_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
@@ -189,17 +201,19 @@ def upload_to_email(self, file_path: str, recipients=None, subject=None, message
|
|||||||
|
|
||||||
# Extract filename
|
# Extract filename
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
|
|
||||||
# Check if email settings are configured
|
# Check if email settings are configured
|
||||||
if not settings.email_host:
|
if not settings.email_host:
|
||||||
error_msg = "Email host is not configured"
|
error_msg = "Email host is not configured"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_email", "skipped", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_email", "skipped", error_msg, file_id=file_id)
|
||||||
return {"status": "Skipped", "reason": error_msg}
|
return {"status": "Skipped", "reason": error_msg}
|
||||||
|
|
||||||
# Log email configuration for debugging
|
# Log email configuration for debugging
|
||||||
logger.debug(f"[{task_id}] Email config - Host: {settings.email_host}, Port: {settings.email_port}, "
|
logger.debug(
|
||||||
f"Username: {settings.email_username}, TLS: {settings.email_use_tls}")
|
f"[{task_id}] Email config - Host: {settings.email_host}, Port: {settings.email_port}, "
|
||||||
|
f"Username: {settings.email_username}, TLS: {settings.email_use_tls}"
|
||||||
|
)
|
||||||
|
|
||||||
# Process recipients
|
# Process recipients
|
||||||
recipients, error = _prepare_recipients(recipients)
|
recipients, error = _prepare_recipients(recipients)
|
||||||
@@ -218,21 +232,21 @@ def upload_to_email(self, file_path: str, recipients=None, subject=None, message
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Create the email
|
# Create the email
|
||||||
msg = MIMEMultipart('related')
|
msg = MIMEMultipart("related")
|
||||||
msg['From'] = settings.email_sender or settings.email_username
|
msg["From"] = settings.email_sender or settings.email_username
|
||||||
msg['To'] = ", ".join(recipients)
|
msg["To"] = ", ".join(recipients)
|
||||||
msg['Subject'] = subject
|
msg["Subject"] = subject
|
||||||
|
|
||||||
# Create alternative part for HTML content
|
# Create alternative part for HTML content
|
||||||
alt_part = MIMEMultipart('alternative')
|
alt_part = MIMEMultipart("alternative")
|
||||||
msg.attach(alt_part)
|
msg.attach(alt_part)
|
||||||
|
|
||||||
# Attach logo to the email
|
# Attach logo to the email
|
||||||
has_logo = attach_logo(msg)
|
has_logo = attach_logo(msg)
|
||||||
|
|
||||||
# Load and render template
|
# Load and render template
|
||||||
template = get_email_template(template_name)
|
template = get_email_template(template_name)
|
||||||
|
|
||||||
# Context data for the template
|
# Context data for the template
|
||||||
context = {
|
context = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -243,38 +257,38 @@ def upload_to_email(self, file_path: str, recipients=None, subject=None, message
|
|||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"has_metadata": bool(metadata),
|
"has_metadata": bool(metadata),
|
||||||
"has_logo": has_logo,
|
"has_logo": has_logo,
|
||||||
"current_year": datetime.now().year
|
"current_year": datetime.now().year,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render HTML body
|
# Render HTML body
|
||||||
html_content = template.render(**context)
|
html_content = template.render(**context)
|
||||||
alt_part.attach(MIMEText(html_content, 'html'))
|
alt_part.attach(MIMEText(html_content, "html"))
|
||||||
|
|
||||||
# Attach the file
|
# Attach the file
|
||||||
with open(file_path, "rb") as file:
|
with open(file_path, "rb") as file:
|
||||||
attachment = MIMEApplication(file.read(), _subtype="pdf")
|
attachment = MIMEApplication(file.read(), _subtype="pdf")
|
||||||
attachment.add_header('Content-Disposition', f'attachment; filename="{filename}"')
|
attachment.add_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||||
msg.attach(attachment)
|
msg.attach(attachment)
|
||||||
|
|
||||||
# Send the email through SMTP
|
# Send the email through SMTP
|
||||||
error_result = _send_email_with_smtp(msg, filename, recipients)
|
error_result = _send_email_with_smtp(msg, filename, recipients)
|
||||||
if error_result:
|
if error_result:
|
||||||
logger.error(f"[{task_id}] Failed to send email: {error_result.get('reason')}")
|
logger.error(f"[{task_id}] Failed to send email: {error_result.get('reason')}")
|
||||||
log_task_progress(task_id, "upload_to_email", "failure", error_result.get('reason'), file_id=file_id)
|
log_task_progress(task_id, "upload_to_email", "failure", error_result.get("reason"), file_id=file_id)
|
||||||
return error_result
|
return error_result
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Successfully sent {filename} via email to {len(recipients)} recipients")
|
logger.info(f"[{task_id}] Successfully sent {filename} via email to {len(recipients)} recipients")
|
||||||
log_task_progress(task_id, "upload_to_email", "success", f"Sent via email: {filename}", file_id=file_id)
|
log_task_progress(task_id, "upload_to_email", "success", f"Sent via email: {filename}", file_id=file_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file": file_path,
|
"file": file_path,
|
||||||
"recipients": recipients,
|
"recipients": recipients,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"metadata_included": bool(metadata),
|
"metadata_included": bool(metadata),
|
||||||
"logo_included": has_logo
|
"logo_included": has_logo,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to send {filename} via email: {str(e)}"
|
error_msg = f"Failed to send {filename} via email: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
+35
-51
@@ -1,26 +1,28 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
# Security Warning: FTP is an insecure protocol. FTPS (FTP_TLS) is strongly recommended.
|
# Security Warning: FTP is an insecure protocol. FTPS (FTP_TLS) is strongly recommended.
|
||||||
# This module attempts to use FTPS by default and falls back to plaintext FTP only if configured.
|
# This module attempts to use FTPS by default and falls back to plaintext FTP only if configured.
|
||||||
import ftplib # nosec B402 - FTP usage is intentional for legacy server support
|
import ftplib # nosec B402 - FTP usage is intentional for legacy server support
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_ftp(self, file_path: str, file_id: int = None):
|
def upload_to_ftp(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Uploads a file to an FTP server in the configured folder.
|
Uploads a file to an FTP server in the configured folder.
|
||||||
|
|
||||||
Security Note: This function prefers FTPS (FTP with TLS) for secure connections.
|
Security Note: This function prefers FTPS (FTP with TLS) for secure connections.
|
||||||
Plaintext FTP is only used if FTPS fails and ftp_allow_plaintext=True (default).
|
Plaintext FTP is only used if FTPS fails and ftp_allow_plaintext=True (default).
|
||||||
For security-critical environments, set ftp_allow_plaintext=False and ftp_use_tls=True.
|
For security-critical environments, set ftp_allow_plaintext=False and ftp_use_tls=True.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
@@ -49,24 +51,18 @@ def upload_to_ftp(self, file_path: str, file_id: int = None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# First attempt FTPS (FTP with TLS)
|
# First attempt FTPS (FTP with TLS)
|
||||||
use_tls = getattr(settings, 'ftp_use_tls', True) # Default to try TLS
|
use_tls = getattr(settings, "ftp_use_tls", True) # Default to try TLS
|
||||||
allow_plaintext = getattr(settings, 'ftp_allow_plaintext', True) # Default to allow plaintext fallback
|
allow_plaintext = getattr(settings, "ftp_allow_plaintext", True) # Default to allow plaintext fallback
|
||||||
|
|
||||||
if use_tls:
|
if use_tls:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Attempting FTPS connection to {settings.ftp_host}")
|
logger.info(f"Attempting FTPS connection to {settings.ftp_host}")
|
||||||
ftp = ftplib.FTP_TLS()
|
ftp = ftplib.FTP_TLS()
|
||||||
ftp.connect(
|
ftp.connect(host=settings.ftp_host, port=settings.ftp_port or 21)
|
||||||
host=settings.ftp_host,
|
|
||||||
port=settings.ftp_port or 21
|
|
||||||
)
|
|
||||||
|
|
||||||
# Login with credentials
|
# Login with credentials
|
||||||
ftp.login(
|
ftp.login(user=settings.ftp_username, passwd=settings.ftp_password)
|
||||||
user=settings.ftp_username,
|
|
||||||
passwd=settings.ftp_password
|
|
||||||
)
|
|
||||||
|
|
||||||
# Enable data protection - encrypt the data channel
|
# Enable data protection - encrypt the data channel
|
||||||
ftp.prot_p()
|
ftp.prot_p()
|
||||||
logger.info("Successfully established FTPS connection with TLS")
|
logger.info("Successfully established FTPS connection with TLS")
|
||||||
@@ -79,53 +75,41 @@ def upload_to_ftp(self, file_path: str, file_id: int = None):
|
|||||||
logger.warning(f"FTPS connection failed, falling back to regular FTP: {str(e)}")
|
logger.warning(f"FTPS connection failed, falling back to regular FTP: {str(e)}")
|
||||||
# Fall back to regular FTP - only if explicitly allowed by configuration
|
# Fall back to regular FTP - only if explicitly allowed by configuration
|
||||||
ftp = ftplib.FTP() # nosec B321 - Fallback to FTP intentional when configured
|
ftp = ftplib.FTP() # nosec B321 - Fallback to FTP intentional when configured
|
||||||
ftp.connect(
|
ftp.connect(host=settings.ftp_host, port=settings.ftp_port or 21)
|
||||||
host=settings.ftp_host,
|
|
||||||
port=settings.ftp_port or 21
|
|
||||||
)
|
|
||||||
|
|
||||||
# Login with credentials
|
# Login with credentials
|
||||||
ftp.login(
|
ftp.login(user=settings.ftp_username, passwd=settings.ftp_password)
|
||||||
user=settings.ftp_username,
|
|
||||||
passwd=settings.ftp_password
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Check if plaintext is allowed when TLS is explicitly disabled
|
# Check if plaintext is allowed when TLS is explicitly disabled
|
||||||
if not allow_plaintext:
|
if not allow_plaintext:
|
||||||
error_msg = "Plaintext FTP is forbidden by configuration"
|
error_msg = "Plaintext FTP is forbidden by configuration"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
# Directly use regular FTP if TLS is explicitly disabled
|
# Directly use regular FTP if TLS is explicitly disabled
|
||||||
logger.warning("Using plaintext FTP - connection is NOT encrypted!")
|
logger.warning("Using plaintext FTP - connection is NOT encrypted!")
|
||||||
ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when explicitly configured
|
ftp = ftplib.FTP() # nosec B321 - Plaintext FTP intentional when explicitly configured
|
||||||
ftp.connect(
|
ftp.connect(host=settings.ftp_host, port=settings.ftp_port or 21)
|
||||||
host=settings.ftp_host,
|
|
||||||
port=settings.ftp_port or 21
|
|
||||||
)
|
|
||||||
|
|
||||||
# Login with credentials
|
# Login with credentials
|
||||||
ftp.login(
|
ftp.login(user=settings.ftp_username, passwd=settings.ftp_password)
|
||||||
user=settings.ftp_username,
|
|
||||||
passwd=settings.ftp_password
|
|
||||||
)
|
|
||||||
|
|
||||||
# Change to target directory if specified
|
# Change to target directory if specified
|
||||||
if settings.ftp_folder:
|
if settings.ftp_folder:
|
||||||
try:
|
try:
|
||||||
# Try to navigate to the directory, create if it doesn't exist
|
# Try to navigate to the directory, create if it doesn't exist
|
||||||
ftp_folder = settings.ftp_folder
|
ftp_folder = settings.ftp_folder
|
||||||
# Remove leading slash if present
|
# Remove leading slash if present
|
||||||
if ftp_folder.startswith('/'):
|
if ftp_folder.startswith("/"):
|
||||||
ftp_folder = ftp_folder[1:]
|
ftp_folder = ftp_folder[1:]
|
||||||
|
|
||||||
# Try to change to the directory
|
# Try to change to the directory
|
||||||
try:
|
try:
|
||||||
ftp.cwd(ftp_folder)
|
ftp.cwd(ftp_folder)
|
||||||
except ftplib.error_perm:
|
except ftplib.error_perm:
|
||||||
# Create directory structure if it doesn't exist
|
# Create directory structure if it doesn't exist
|
||||||
folders = ftp_folder.split('/')
|
folders = ftp_folder.split("/")
|
||||||
current_dir = ''
|
current_dir = ""
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
if folder:
|
if folder:
|
||||||
current_dir += f"/{folder}"
|
current_dir += f"/{folder}"
|
||||||
@@ -138,24 +122,24 @@ def upload_to_ftp(self, file_path: str, file_id: int = None):
|
|||||||
error_msg = f"Failed to change/create directory on FTP server: {str(e)}"
|
error_msg = f"Failed to change/create directory on FTP server: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
# Upload the file
|
# Upload the file
|
||||||
with open(file_path, 'rb') as file_data:
|
with open(file_path, "rb") as file_data:
|
||||||
ftp.storbinary(f'STOR {filename}', file_data)
|
ftp.storbinary(f"STOR {filename}", file_data)
|
||||||
|
|
||||||
# Close FTP connection
|
# Close FTP connection
|
||||||
ftp.quit()
|
ftp.quit()
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to FTP server at {settings.ftp_host}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to FTP server at {settings.ftp_host}")
|
||||||
log_task_progress(task_id, "upload_to_ftp", "success", f"Uploaded to FTP: {filename}", file_id=file_id)
|
log_task_progress(task_id, "upload_to_ftp", "success", f"Uploaded to FTP: {filename}", file_id=file_id)
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file": file_path,
|
"file": file_path,
|
||||||
"ftp_host": settings.ftp_host,
|
"ftp_host": settings.ftp_host,
|
||||||
"ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename,
|
"ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename,
|
||||||
"used_tls": isinstance(ftp, ftplib.FTP_TLS)
|
"used_tls": isinstance(ftp, ftplib.FTP_TLS),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to upload {filename} to FTP server: {str(e)}"
|
error_msg = f"Failed to upload {filename} to FTP server: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
@@ -2,24 +2,25 @@
|
|||||||
app/tasks/upload_to_google_drive.py
|
app/tasks/upload_to_google_drive.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from google.auth.exceptions import RefreshError
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2.credentials import Credentials as OAuthCredentials
|
||||||
|
from google.oauth2.service_account import Credentials
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.http import MediaFileUpload
|
from googleapiclient.http import MediaFileUpload
|
||||||
from google.oauth2.service_account import Credentials
|
|
||||||
from google.oauth2.credentials import Credentials as OAuthCredentials
|
|
||||||
from google_auth_oauthlib.flow import Flow
|
|
||||||
from google.auth.transport.requests import Request
|
|
||||||
from google.auth.exceptions import RefreshError
|
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_drive_service_oauth():
|
def get_drive_service_oauth():
|
||||||
"""
|
"""
|
||||||
Get Google Drive service using OAuth credentials.
|
Get Google Drive service using OAuth credentials.
|
||||||
@@ -27,12 +28,14 @@ def get_drive_service_oauth():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check for required OAuth settings
|
# Check for required OAuth settings
|
||||||
if not (settings.google_drive_client_id and
|
if not (
|
||||||
settings.google_drive_client_secret and
|
settings.google_drive_client_id
|
||||||
settings.google_drive_refresh_token):
|
and settings.google_drive_client_secret
|
||||||
|
and settings.google_drive_refresh_token
|
||||||
|
):
|
||||||
logger.error("Google Drive OAuth credentials not fully configured")
|
logger.error("Google Drive OAuth credentials not fully configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create credentials object from refresh token
|
# Create credentials object from refresh token
|
||||||
credentials = OAuthCredentials(
|
credentials = OAuthCredentials(
|
||||||
None, # No access token initially, will be refreshed
|
None, # No access token initially, will be refreshed
|
||||||
@@ -41,16 +44,16 @@ def get_drive_service_oauth():
|
|||||||
client_id=settings.google_drive_client_id,
|
client_id=settings.google_drive_client_id,
|
||||||
client_secret=settings.google_drive_client_secret,
|
client_secret=settings.google_drive_client_secret,
|
||||||
# Use only drive.file scope
|
# Use only drive.file scope
|
||||||
scopes=['https://www.googleapis.com/auth/drive.file']
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh the access token
|
# Refresh the access token
|
||||||
credentials.refresh(Request())
|
credentials.refresh(Request())
|
||||||
|
|
||||||
# Build and return the service
|
# Build and return the service
|
||||||
service = build('drive', 'v3', credentials=credentials)
|
service = build("drive", "v3", credentials=credentials)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
logger.error(f"Failed to refresh Google Drive token: {str(e)}")
|
logger.error(f"Failed to refresh Google Drive token: {str(e)}")
|
||||||
raise
|
raise
|
||||||
@@ -58,6 +61,7 @@ def get_drive_service_oauth():
|
|||||||
logger.error(f"Failed to authenticate with Google Drive OAuth: {str(e)}")
|
logger.error(f"Failed to authenticate with Google Drive OAuth: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_google_drive_service():
|
def get_google_drive_service():
|
||||||
"""
|
"""
|
||||||
Authenticate with Google Drive API using service account credentials
|
Authenticate with Google Drive API using service account credentials
|
||||||
@@ -65,52 +69,53 @@ def get_google_drive_service():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if we should use OAuth instead of service account
|
# Check if we should use OAuth instead of service account
|
||||||
if getattr(settings, 'google_drive_use_oauth', False):
|
if getattr(settings, "google_drive_use_oauth", False):
|
||||||
return get_drive_service_oauth()
|
return get_drive_service_oauth()
|
||||||
|
|
||||||
# Load service account credentials from settings
|
# Load service account credentials from settings
|
||||||
if not settings.google_drive_credentials_json:
|
if not settings.google_drive_credentials_json:
|
||||||
logger.error("Google Drive credentials not configured")
|
logger.error("Google Drive credentials not configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
credentials_dict = json.loads(settings.google_drive_credentials_json)
|
credentials_dict = json.loads(settings.google_drive_credentials_json)
|
||||||
credentials = Credentials.from_service_account_info(
|
credentials = Credentials.from_service_account_info(
|
||||||
credentials_dict,
|
credentials_dict, scopes=["https://www.googleapis.com/auth/drive"]
|
||||||
scopes=['https://www.googleapis.com/auth/drive']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delegate to user if specified
|
# Delegate to user if specified
|
||||||
if settings.google_drive_delegate_to:
|
if settings.google_drive_delegate_to:
|
||||||
credentials = credentials.with_subject(settings.google_drive_delegate_to)
|
credentials = credentials.with_subject(settings.google_drive_delegate_to)
|
||||||
|
|
||||||
# Build and return the service
|
# Build and return the service
|
||||||
service = build('drive', 'v3', credentials=credentials)
|
service = build("drive", "v3", credentials=credentials)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to authenticate with Google Drive: {str(e)}")
|
logger.error(f"Failed to authenticate with Google Drive: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata_from_file(file_path):
|
def extract_metadata_from_file(file_path):
|
||||||
"""
|
"""
|
||||||
Try to extract metadata from a file using several methods:
|
Try to extract metadata from a file using several methods:
|
||||||
1. Check for a .json metadata file with the same name
|
1. Check for a .json metadata file with the same name
|
||||||
|
|
||||||
Returns a dictionary of metadata or empty dict if not found
|
Returns a dictionary of metadata or empty dict if not found
|
||||||
"""
|
"""
|
||||||
# Check for separate metadata JSON file
|
# Check for separate metadata JSON file
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.json'
|
metadata_path = os.path.splitext(file_path)[0] + ".json"
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
|
||||||
return metadata
|
return metadata
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def truncate_property_value(key, value, max_bytes=100):
|
def truncate_property_value(key, value, max_bytes=100):
|
||||||
"""
|
"""
|
||||||
Truncate a property value to ensure the key+value stays under the byte limit.
|
Truncate a property value to ensure the key+value stays under the byte limit.
|
||||||
@@ -119,35 +124,36 @@ def truncate_property_value(key, value, max_bytes=100):
|
|||||||
"""
|
"""
|
||||||
# Convert to string if not already
|
# Convert to string if not already
|
||||||
str_value = str(value)
|
str_value = str(value)
|
||||||
|
|
||||||
# Calculate current size of key and value in bytes
|
# Calculate current size of key and value in bytes
|
||||||
key_bytes = len(key.encode('utf-8'))
|
key_bytes = len(key.encode("utf-8"))
|
||||||
value_bytes = len(str_value.encode('utf-8'))
|
value_bytes = len(str_value.encode("utf-8"))
|
||||||
total_bytes = key_bytes + value_bytes
|
total_bytes = key_bytes + value_bytes
|
||||||
|
|
||||||
# If under limit, return original value
|
# If under limit, return original value
|
||||||
if total_bytes <= max_bytes:
|
if total_bytes <= max_bytes:
|
||||||
return str_value
|
return str_value
|
||||||
|
|
||||||
# Calculate how many bytes we need to trim from value
|
# Calculate how many bytes we need to trim from value
|
||||||
# Leave a small buffer to be safe
|
# Leave a small buffer to be safe
|
||||||
bytes_to_trim = total_bytes - max_bytes + 4
|
bytes_to_trim = total_bytes - max_bytes + 4
|
||||||
|
|
||||||
# Iteratively truncate the string until it's under the byte limit
|
# Iteratively truncate the string until it's under the byte limit
|
||||||
while len(str_value.encode('utf-8')) > value_bytes - bytes_to_trim:
|
while len(str_value.encode("utf-8")) > value_bytes - bytes_to_trim:
|
||||||
str_value = str_value[:-1]
|
str_value = str_value[:-1]
|
||||||
|
|
||||||
# Add ellipsis to indicate truncation
|
# Add ellipsis to indicate truncation
|
||||||
if str_value != str(value):
|
if str_value != str(value):
|
||||||
str_value = str_value[:-3] + "..."
|
str_value = str_value[:-3] + "..."
|
||||||
|
|
||||||
return str_value
|
return str_value
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id: int = None):
|
def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Uploads a file to Google Drive in the configured folder with optional metadata.
|
Uploads a file to Google Drive in the configured folder with optional metadata.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
include_metadata: Whether to include metadata in the upload
|
include_metadata: Whether to include metadata in the upload
|
||||||
@@ -156,7 +162,11 @@ def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id:
|
|||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting Google Drive upload: {file_path}")
|
logger.info(f"[{task_id}] Starting Google Drive upload: {file_path}")
|
||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id, "upload_to_google_drive", "in_progress", f"Uploading to Google Drive: {os.path.basename(file_path)}", file_id=file_id
|
task_id,
|
||||||
|
"upload_to_google_drive",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to Google Drive: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -184,18 +194,18 @@ def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id:
|
|||||||
|
|
||||||
# Prepare the file metadata
|
# Prepare the file metadata
|
||||||
file_metadata = {
|
file_metadata = {
|
||||||
'name': filename,
|
"name": filename,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If folder ID is specified, set parent folder
|
# If folder ID is specified, set parent folder
|
||||||
if settings.google_drive_folder_id:
|
if settings.google_drive_folder_id:
|
||||||
file_metadata['parents'] = [settings.google_drive_folder_id]
|
file_metadata["parents"] = [settings.google_drive_folder_id]
|
||||||
|
|
||||||
# Add custom properties if metadata exists
|
# Add custom properties if metadata exists
|
||||||
if metadata:
|
if metadata:
|
||||||
# Google Drive properties must be strings and can't be nested objects
|
# Google Drive properties must be strings and can't be nested objects
|
||||||
file_metadata['properties'] = {}
|
file_metadata["properties"] = {}
|
||||||
|
|
||||||
# Only add a few important top-level metadata fields as properties
|
# Only add a few important top-level metadata fields as properties
|
||||||
# Skip nested objects and long values to avoid the 124-byte limit
|
# Skip nested objects and long values to avoid the 124-byte limit
|
||||||
safe_properties = {}
|
safe_properties = {}
|
||||||
@@ -203,61 +213,59 @@ def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id:
|
|||||||
# Skip nested structures completely - they'll be in the description
|
# Skip nested structures completely - they'll be in the description
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Try to add simple values with truncation if needed
|
# Try to add simple values with truncation if needed
|
||||||
try:
|
try:
|
||||||
truncated_value = truncate_property_value(key, value)
|
truncated_value = truncate_property_value(key, value)
|
||||||
safe_properties[key] = truncated_value
|
safe_properties[key] = truncated_value
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Skipping metadata property {key}: {str(e)}")
|
logger.warning(f"Skipping metadata property {key}: {str(e)}")
|
||||||
|
|
||||||
# Only use the safe properties
|
# Only use the safe properties
|
||||||
file_metadata['properties'] = safe_properties
|
file_metadata["properties"] = safe_properties
|
||||||
|
|
||||||
# Add minimal appProperties
|
# Add minimal appProperties
|
||||||
file_metadata['appProperties'] = {
|
file_metadata["appProperties"] = {"docuelevate": "true"}
|
||||||
'docuelevate': 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add metadata to file description for better visibility in Google Drive UI
|
# Add metadata to file description for better visibility in Google Drive UI
|
||||||
# Description has much higher size limits than properties
|
# Description has much higher size limits than properties
|
||||||
formatted_json = json.dumps(metadata, indent=2)
|
formatted_json = json.dumps(metadata, indent=2)
|
||||||
file_metadata['description'] = f"Document Metadata:\n\n```json\n{formatted_json}\n```"
|
file_metadata["description"] = f"Document Metadata:\n\n```json\n{formatted_json}\n```"
|
||||||
|
|
||||||
logger.debug(f"Adding metadata to Google Drive file: {json.dumps(file_metadata['properties'])}")
|
logger.debug(f"Adding metadata to Google Drive file: {json.dumps(file_metadata['properties'])}")
|
||||||
|
|
||||||
# Upload file with metadata
|
# Upload file with metadata
|
||||||
media = MediaFileUpload(
|
media = MediaFileUpload(file_path, mimetype="application/pdf", resumable=True)
|
||||||
file_path,
|
|
||||||
mimetype='application/pdf',
|
file = (
|
||||||
resumable=True
|
service.files()
|
||||||
|
.create(
|
||||||
|
body=file_metadata, media_body=media, fields="id,name,webViewLink,properties,appProperties,description"
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
)
|
)
|
||||||
|
|
||||||
file = service.files().create(
|
|
||||||
body=file_metadata,
|
|
||||||
media_body=media,
|
|
||||||
fields='id,name,webViewLink,properties,appProperties,description'
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
# Log success details
|
# Log success details
|
||||||
google_drive_file_id = file.get('id')
|
google_drive_file_id = file.get("id")
|
||||||
web_view_link = file.get('webViewLink')
|
web_view_link = file.get("webViewLink")
|
||||||
|
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to Google Drive with ID: {google_drive_file_id}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to Google Drive with ID: {google_drive_file_id}")
|
||||||
logger.info(f"[{task_id}] File accessible at: {web_view_link}")
|
logger.info(f"[{task_id}] File accessible at: {web_view_link}")
|
||||||
log_task_progress(task_id, "upload_to_google_drive", "success", f"Uploaded to Google Drive: {filename}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "upload_to_google_drive", "success", f"Uploaded to Google Drive: {filename}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"google_drive_file_id": google_drive_file_id,
|
"google_drive_file_id": google_drive_file_id,
|
||||||
"google_drive_web_link": web_view_link
|
"google_drive_web_link": web_view_link,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add metadata info to result if included
|
# Add metadata info to result if included
|
||||||
if metadata:
|
if metadata:
|
||||||
result["metadata_included"] = True
|
result["metadata_included"] = True
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,143 +1,153 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.auth import HTTPBasicAuth
|
from requests.auth import HTTPBasicAuth
|
||||||
from app.config import settings
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.utils.filename_utils import get_unique_filename, sanitize_filename, extract_remote_path
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
from app.database import SessionLocal
|
from app.utils.filename_utils import extract_remote_path, get_unique_filename
|
||||||
from app.models import FileRecord
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_nextcloud(self, file_path: str, file_id: int = None):
|
def upload_to_nextcloud(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Upload a file to Nextcloud WebDAV.
|
Upload a file to Nextcloud WebDAV.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}")
|
logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}")
|
||||||
log_task_progress(task_id, "upload_to_nextcloud", "in_progress", f"Uploading to Nextcloud: {os.path.basename(file_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"upload_to_nextcloud",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to Nextcloud: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
|
||||||
raise FileNotFoundError(error_msg)
|
raise FileNotFoundError(error_msg)
|
||||||
|
|
||||||
# For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url'
|
# For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url'
|
||||||
# This is what's shown in your env view
|
# This is what's shown in your env view
|
||||||
if not (getattr(settings, 'nextcloud_upload_url', None) and
|
if not (
|
||||||
getattr(settings, 'nextcloud_username', None) and
|
getattr(settings, "nextcloud_upload_url", None)
|
||||||
getattr(settings, 'nextcloud_password', None)):
|
and getattr(settings, "nextcloud_username", None)
|
||||||
|
and getattr(settings, "nextcloud_password", None)
|
||||||
|
):
|
||||||
logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration")
|
logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration")
|
||||||
log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id)
|
log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id)
|
||||||
return {"status": "Skipped", "reason": "Nextcloud settings not configured"}
|
return {"status": "Skipped", "reason": "Nextcloud settings not configured"}
|
||||||
|
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
sanitized_filename = sanitize_filename(filename)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url
|
# Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url
|
||||||
webdav_url = settings.nextcloud_upload_url
|
webdav_url = settings.nextcloud_upload_url
|
||||||
if not webdav_url.endswith('/'):
|
if not webdav_url.endswith("/"):
|
||||||
webdav_url += '/'
|
webdav_url += "/"
|
||||||
|
|
||||||
# Calculate remote path based on local file structure
|
# Calculate remote path based on local file structure
|
||||||
remote_base = getattr(settings, 'nextcloud_folder', '') or ""
|
remote_base = getattr(settings, "nextcloud_folder", "") or ""
|
||||||
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
||||||
full_url = f"{webdav_url}/{remote_path}"
|
full_url = f"{webdav_url}/{remote_path}"
|
||||||
|
|
||||||
# Remove any double slashes (except in http://)
|
# Remove any double slashes (except in http://)
|
||||||
full_url = full_url.replace('://', '$PLACEHOLDER$')
|
full_url = full_url.replace("://", "$PLACEHOLDER$")
|
||||||
while '//' in full_url:
|
while "//" in full_url:
|
||||||
full_url = full_url.replace('//', '/')
|
full_url = full_url.replace("//", "/")
|
||||||
full_url = full_url.replace('$PLACEHOLDER$', '://')
|
full_url = full_url.replace("$PLACEHOLDER$", "://")
|
||||||
|
|
||||||
# Function to check if file exists in Nextcloud
|
# Function to check if file exists in Nextcloud
|
||||||
def check_exists_in_nextcloud(path):
|
def check_exists_in_nextcloud(path):
|
||||||
check_url = f"{webdav_url}{os.path.dirname(path)}"
|
check_url = f"{webdav_url}{os.path.dirname(path)}"
|
||||||
try:
|
try:
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
'PROPFIND',
|
"PROPFIND",
|
||||||
check_url,
|
check_url,
|
||||||
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
||||||
headers={'Depth': '1'},
|
headers={"Depth": "1"},
|
||||||
timeout=10
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
return path in response.text
|
return path in response.text
|
||||||
except Exception:
|
except Exception:
|
||||||
# If we can't check, assume it doesn't exist
|
# If we can't check, assume it doesn't exist
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for potential file collision and get a unique name if needed
|
# Check for potential file collision and get a unique name if needed
|
||||||
remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud)
|
remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud)
|
||||||
full_url = f"{webdav_url}/{remote_path}"
|
full_url = f"{webdav_url}/{remote_path}"
|
||||||
|
|
||||||
# Fix double slashes again
|
# Fix double slashes again
|
||||||
full_url = full_url.replace('://', '$PLACEHOLDER$')
|
full_url = full_url.replace("://", "$PLACEHOLDER$")
|
||||||
while '//' in full_url:
|
while "//" in full_url:
|
||||||
full_url = full_url.replace('//', '/')
|
full_url = full_url.replace("//", "/")
|
||||||
full_url = full_url.replace('$PLACEHOLDER$', '://')
|
full_url = full_url.replace("$PLACEHOLDER$", "://")
|
||||||
|
|
||||||
# Create necessary parent folders
|
# Create necessary parent folders
|
||||||
parent_dirs = os.path.dirname(remote_path)
|
parent_dirs = os.path.dirname(remote_path)
|
||||||
if parent_dirs:
|
if parent_dirs:
|
||||||
current_path = ""
|
current_path = ""
|
||||||
for folder in parent_dirs.split('/'):
|
for folder in parent_dirs.split("/"):
|
||||||
if not folder:
|
if not folder:
|
||||||
continue
|
continue
|
||||||
current_path += f"{folder}/"
|
current_path += f"{folder}/"
|
||||||
mkdir_url = f"{webdav_url}/{current_path}"
|
mkdir_url = f"{webdav_url}/{current_path}"
|
||||||
# Fix double slashes
|
# Fix double slashes
|
||||||
mkdir_url = mkdir_url.replace('://', '$PLACEHOLDER$')
|
mkdir_url = mkdir_url.replace("://", "$PLACEHOLDER$")
|
||||||
while '//' in mkdir_url:
|
while "//" in mkdir_url:
|
||||||
mkdir_url = mkdir_url.replace('//', '/')
|
mkdir_url = mkdir_url.replace("//", "/")
|
||||||
mkdir_url = mkdir_url.replace('$PLACEHOLDER$', '://')
|
mkdir_url = mkdir_url.replace("$PLACEHOLDER$", "://")
|
||||||
|
|
||||||
requests.request(
|
requests.request(
|
||||||
'MKCOL',
|
"MKCOL",
|
||||||
mkdir_url,
|
mkdir_url,
|
||||||
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
||||||
timeout=10
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Upload the file
|
# Upload the file
|
||||||
logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}")
|
logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}")
|
||||||
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id)
|
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id)
|
||||||
with open(file_path, 'rb') as file_data:
|
with open(file_path, "rb") as file_data:
|
||||||
response = requests.put(
|
response = requests.put(
|
||||||
full_url,
|
full_url,
|
||||||
data=file_data,
|
data=file_data,
|
||||||
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
|
||||||
headers={'Content-Type': 'application/octet-stream'},
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
timeout=settings.http_request_timeout # Use configured timeout for large files
|
timeout=settings.http_request_timeout, # Use configured timeout for large files
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code in (201, 204): # Created or No Content
|
if response.status_code in (201, 204): # Created or No Content
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}")
|
||||||
log_task_progress(task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"nextcloud_path": remote_path,
|
"nextcloud_path": remote_path,
|
||||||
"response_code": response.status_code
|
"response_code": response.status_code,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}"
|
error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}"
|
error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
import msal
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
|
import msal
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_onedrive_token():
|
def get_onedrive_token():
|
||||||
"""
|
"""
|
||||||
Get an access token for Microsoft Graph API using the appropriate flow.
|
Get an access token for Microsoft Graph API using the appropriate flow.
|
||||||
@@ -22,94 +25,92 @@ def get_onedrive_token():
|
|||||||
# Check for required settings
|
# Check for required settings
|
||||||
if not settings.onedrive_client_id or not settings.onedrive_client_secret:
|
if not settings.onedrive_client_id or not settings.onedrive_client_secret:
|
||||||
raise ValueError("OneDrive client ID and client secret must be configured")
|
raise ValueError("OneDrive client ID and client secret must be configured")
|
||||||
|
|
||||||
# Log more details about the configuration
|
# Log more details about the configuration
|
||||||
tenant = settings.onedrive_tenant_id or "common"
|
tenant = settings.onedrive_tenant_id or "common"
|
||||||
logger.info(f"Using OneDrive tenant: {tenant}")
|
logger.info(f"Using OneDrive tenant: {tenant}")
|
||||||
|
|
||||||
# Define scopes consistently
|
# Define scopes consistently
|
||||||
scopes = ["https://graph.microsoft.com/.default"]
|
scopes = ["https://graph.microsoft.com/.default"]
|
||||||
|
|
||||||
# Use refresh token flow (works for both personal and org accounts)
|
# Use refresh token flow (works for both personal and org accounts)
|
||||||
if settings.onedrive_refresh_token:
|
if settings.onedrive_refresh_token:
|
||||||
# Use MSAL's ConfidentialClientApplication instead of PublicClientApplication
|
# Use MSAL's ConfidentialClientApplication instead of PublicClientApplication
|
||||||
app = msal.ConfidentialClientApplication(
|
app = msal.ConfidentialClientApplication(
|
||||||
client_id=settings.onedrive_client_id,
|
client_id=settings.onedrive_client_id,
|
||||||
client_credential=settings.onedrive_client_secret,
|
client_credential=settings.onedrive_client_secret,
|
||||||
authority=f"https://login.microsoftonline.com/{tenant}"
|
authority=f"https://login.microsoftonline.com/{tenant}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Request new token using refresh token
|
# Request new token using refresh token
|
||||||
logger.info("Attempting to acquire token using refresh token")
|
logger.info("Attempting to acquire token using refresh token")
|
||||||
token_response = app.acquire_token_by_refresh_token(
|
token_response = app.acquire_token_by_refresh_token(
|
||||||
refresh_token=settings.onedrive_refresh_token,
|
refresh_token=settings.onedrive_refresh_token, scopes=scopes
|
||||||
scopes=scopes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if "access_token" not in token_response:
|
if "access_token" not in token_response:
|
||||||
error = token_response.get("error", "")
|
error = token_response.get("error", "")
|
||||||
error_desc = token_response.get("error_description", "Unknown error")
|
error_desc = token_response.get("error_description", "Unknown error")
|
||||||
|
|
||||||
# Log more details about the error
|
# Log more details about the error
|
||||||
logger.error(f"Failed to get access token using refresh token")
|
logger.error("Failed to get access token using refresh token")
|
||||||
logger.error(f"Error code: {error}")
|
logger.error(f"Error code: {error}")
|
||||||
logger.error(f"Error description: {error_desc}")
|
logger.error(f"Error description: {error_desc}")
|
||||||
|
|
||||||
if error == "invalid_grant":
|
if error == "invalid_grant":
|
||||||
logger.error("The refresh token appears to be expired or revoked")
|
logger.error("The refresh token appears to be expired or revoked")
|
||||||
logger.error("A new authorization flow is required to obtain a fresh token")
|
logger.error("A new authorization flow is required to obtain a fresh token")
|
||||||
|
|
||||||
raise ValueError(f"Failed to get access token: {error} - {error_desc}")
|
raise ValueError(f"Failed to get access token: {error} - {error_desc}")
|
||||||
|
|
||||||
# Check if we received a new refresh token and update it
|
# Check if we received a new refresh token and update it
|
||||||
if "refresh_token" in token_response:
|
if "refresh_token" in token_response:
|
||||||
new_refresh_token = token_response["refresh_token"]
|
new_refresh_token = token_response["refresh_token"]
|
||||||
logger.info("Received new refresh token from Microsoft")
|
logger.info("Received new refresh token from Microsoft")
|
||||||
|
|
||||||
# Update the refresh token in memory
|
# Update the refresh token in memory
|
||||||
settings.onedrive_refresh_token = new_refresh_token
|
settings.onedrive_refresh_token = new_refresh_token
|
||||||
logger.info("Updated refresh token in memory")
|
logger.info("Updated refresh token in memory")
|
||||||
|
|
||||||
return token_response["access_token"]
|
return token_response["access_token"]
|
||||||
|
|
||||||
# No refresh token - try client credentials (only works for org accounts)
|
# No refresh token - try client credentials (only works for org accounts)
|
||||||
elif settings.onedrive_tenant_id and settings.onedrive_tenant_id != "common":
|
elif settings.onedrive_tenant_id and settings.onedrive_tenant_id != "common":
|
||||||
authority = f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}"
|
authority = f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}"
|
||||||
app = msal.ConfidentialClientApplication(
|
app = msal.ConfidentialClientApplication(
|
||||||
client_id=settings.onedrive_client_id,
|
client_id=settings.onedrive_client_id,
|
||||||
client_credential=settings.onedrive_client_secret,
|
client_credential=settings.onedrive_client_secret,
|
||||||
authority=authority
|
authority=authority,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Acquire token for application
|
# Acquire token for application
|
||||||
token_response = app.acquire_token_for_client(
|
token_response = app.acquire_token_for_client(scopes=scopes)
|
||||||
scopes=scopes
|
|
||||||
)
|
|
||||||
|
|
||||||
if "access_token" not in token_response:
|
if "access_token" not in token_response:
|
||||||
error = token_response.get("error", "")
|
error = token_response.get("error", "")
|
||||||
error_desc = token_response.get("error_description", "Unknown error")
|
error_desc = token_response.get("error_description", "Unknown error")
|
||||||
raise ValueError(f"Failed to get access token: {error} - {error_desc}")
|
raise ValueError(f"Failed to get access token: {error} - {error_desc}")
|
||||||
|
|
||||||
return token_response["access_token"]
|
return token_response["access_token"]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("For personal Microsoft accounts, ONEDRIVE_REFRESH_TOKEN must be configured")
|
raise ValueError("For personal Microsoft accounts, ONEDRIVE_REFRESH_TOKEN must be configured")
|
||||||
|
|
||||||
|
|
||||||
def create_upload_session(filename, folder_path, access_token):
|
def create_upload_session(filename, folder_path, access_token):
|
||||||
"""Creates an upload session for large files in Microsoft Graph API."""
|
"""Creates an upload session for large files in Microsoft Graph API."""
|
||||||
# Construct the API endpoint
|
# Construct the API endpoint
|
||||||
base_url = "https://graph.microsoft.com/v1.0/me/drive"
|
base_url = "https://graph.microsoft.com/v1.0/me/drive"
|
||||||
|
|
||||||
# Format the folder path correctly and properly encode for URL
|
# Format the folder path correctly and properly encode for URL
|
||||||
if folder_path:
|
if folder_path:
|
||||||
# Remove leading/trailing slashes
|
# Remove leading/trailing slashes
|
||||||
folder_path = folder_path.strip('/')
|
folder_path = folder_path.strip("/")
|
||||||
|
|
||||||
# URL encode the path components separately
|
# URL encode the path components separately
|
||||||
path_components = folder_path.split('/')
|
path_components = folder_path.split("/")
|
||||||
encoded_path = '/'.join(urllib.parse.quote(component) for component in path_components)
|
encoded_path = "/".join(urllib.parse.quote(component) for component in path_components)
|
||||||
|
|
||||||
# Also encode the filename
|
# Also encode the filename
|
||||||
encoded_filename = urllib.parse.quote(filename)
|
encoded_filename = urllib.parse.quote(filename)
|
||||||
item_path = f"/root:/{encoded_path}/{encoded_filename}:/createUploadSession"
|
item_path = f"/root:/{encoded_path}/{encoded_filename}:/createUploadSession"
|
||||||
@@ -117,25 +118,18 @@ def create_upload_session(filename, folder_path, access_token):
|
|||||||
# Just encode the filename
|
# Just encode the filename
|
||||||
encoded_filename = urllib.parse.quote(filename)
|
encoded_filename = urllib.parse.quote(filename)
|
||||||
item_path = f"/root:/{encoded_filename}:/createUploadSession"
|
item_path = f"/root:/{encoded_filename}:/createUploadSession"
|
||||||
|
|
||||||
url = f"{base_url}{item_path}"
|
url = f"{base_url}{item_path}"
|
||||||
|
|
||||||
# Add required request body (can be empty JSON object)
|
# Add required request body (can be empty JSON object)
|
||||||
request_body = {
|
request_body = {"item": {"@microsoft.graph.conflictBehavior": "replace"}}
|
||||||
"item": {
|
|
||||||
"@microsoft.graph.conflictBehavior": "replace"
|
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {access_token}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Creating upload session for {filename} at path {folder_path}")
|
logger.info(f"Creating upload session for {filename} at path {folder_path}")
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=request_body, timeout=settings.http_request_timeout)
|
response = requests.post(url, headers=headers, json=request_body, timeout=settings.http_request_timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
upload_url = response.json().get("uploadUrl")
|
upload_url = response.json().get("uploadUrl")
|
||||||
logger.info(f"Upload session created successfully for {filename}")
|
logger.info(f"Upload session created successfully for {filename}")
|
||||||
@@ -148,6 +142,7 @@ def create_upload_session(filename, folder_path, access_token):
|
|||||||
logger.error(f"Request body: {request_body}")
|
logger.error(f"Request body: {request_body}")
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
|
||||||
def upload_large_file(file_path, upload_url):
|
def upload_large_file(file_path, upload_url):
|
||||||
"""
|
"""
|
||||||
Upload a large file to OneDrive using the upload session URL.
|
Upload a large file to OneDrive using the upload session URL.
|
||||||
@@ -155,45 +150,39 @@ def upload_large_file(file_path, upload_url):
|
|||||||
"""
|
"""
|
||||||
# Get file size
|
# Get file size
|
||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
# Define chunk size (10 MB)
|
# Define chunk size (10 MB)
|
||||||
chunk_size = 10 * 1024 * 1024
|
chunk_size = 10 * 1024 * 1024
|
||||||
|
|
||||||
# Open and read file in chunks
|
# Open and read file in chunks
|
||||||
with open(file_path, 'rb') as f:
|
with open(file_path, "rb") as f:
|
||||||
# Process file in chunks
|
# Process file in chunks
|
||||||
chunk_number = 0
|
chunk_number = 0
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(chunk_size)
|
chunk = f.read(chunk_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get the position in the file
|
# Get the position in the file
|
||||||
chunk_start = chunk_number * chunk_size
|
chunk_start = chunk_number * chunk_size
|
||||||
chunk_end = chunk_start + len(chunk) - 1
|
chunk_end = chunk_start + len(chunk) - 1
|
||||||
|
|
||||||
# Prepare content range header
|
# Prepare content range header
|
||||||
content_range = f"bytes {chunk_start}-{chunk_end}/{file_size}"
|
content_range = f"bytes {chunk_start}-{chunk_end}/{file_size}"
|
||||||
|
|
||||||
# Upload chunk
|
# Upload chunk
|
||||||
headers = {
|
headers = {"Content-Length": str(len(chunk)), "Content-Range": content_range}
|
||||||
"Content-Length": str(len(chunk)),
|
|
||||||
"Content-Range": content_range
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to upload chunk with retries
|
# Try to upload chunk with retries
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
retry_delay = 2 # seconds
|
retry_delay = 2 # seconds
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = requests.put(
|
response = requests.put(
|
||||||
upload_url,
|
upload_url, headers=headers, data=chunk, timeout=settings.http_request_timeout
|
||||||
headers=headers,
|
|
||||||
data=chunk,
|
|
||||||
timeout=settings.http_request_timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if successful
|
# Check if successful
|
||||||
if response.status_code in (201, 202):
|
if response.status_code in (201, 202):
|
||||||
# 201 = Created (final chunk), 202 = Accepted (more chunks coming)
|
# 201 = Created (final chunk), 202 = Accepted (more chunks coming)
|
||||||
@@ -206,22 +195,25 @@ def upload_large_file(file_path, upload_url):
|
|||||||
logger.warning(f"Chunk upload error (attempt {attempt+1}): {str(e)}")
|
logger.warning(f"Chunk upload error (attempt {attempt+1}): {str(e)}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
time.sleep(retry_delay * (attempt + 1))
|
time.sleep(retry_delay * (attempt + 1))
|
||||||
|
|
||||||
if response.status_code not in (201, 202):
|
if response.status_code not in (201, 202):
|
||||||
raise Exception(f"Failed to upload chunk after {max_retries} attempts: {response.status_code} - {response.text}")
|
raise Exception(
|
||||||
|
f"Failed to upload chunk after {max_retries} attempts: {response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
# Move to next chunk
|
# Move to next chunk
|
||||||
chunk_number += 1
|
chunk_number += 1
|
||||||
|
|
||||||
# If we get here, all chunks were uploaded successfully
|
# If we get here, all chunks were uploaded successfully
|
||||||
# The last response should contain the file metadata
|
# The last response should contain the file metadata
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_onedrive(self, file_path: str, file_id: int = None):
|
def upload_to_onedrive(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Uploads a file to OneDrive in the configured folder.
|
Uploads a file to OneDrive in the configured folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
@@ -235,7 +227,7 @@ def upload_to_onedrive(self, file_path: str, file_id: int = None):
|
|||||||
f"Uploading to OneDrive: {os.path.basename(file_path)}",
|
f"Uploading to OneDrive: {os.path.basename(file_path)}",
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
@@ -255,13 +247,13 @@ def upload_to_onedrive(self, file_path: str, file_id: int = None):
|
|||||||
try:
|
try:
|
||||||
# Get access token
|
# Get access token
|
||||||
access_token = get_onedrive_token()
|
access_token = get_onedrive_token()
|
||||||
|
|
||||||
# Create upload session
|
# Create upload session
|
||||||
upload_url = create_upload_session(filename, settings.onedrive_folder_path, access_token)
|
upload_url = create_upload_session(filename, settings.onedrive_folder_path, access_token)
|
||||||
|
|
||||||
# Upload the file
|
# Upload the file
|
||||||
result = upload_large_file(file_path, upload_url)
|
result = upload_large_file(file_path, upload_url)
|
||||||
|
|
||||||
# Log success
|
# Log success
|
||||||
web_url = result.get("webUrl", "Not available")
|
web_url = result.get("webUrl", "Not available")
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to OneDrive at path {settings.onedrive_folder_path}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to OneDrive at path {settings.onedrive_folder_path}")
|
||||||
@@ -269,14 +261,14 @@ def upload_to_onedrive(self, file_path: str, file_id: int = None):
|
|||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id, "upload_to_onedrive", "success", f"Uploaded to OneDrive: {filename}", file_id=file_id
|
task_id, "upload_to_onedrive", "success", f"Uploaded to OneDrive: {filename}", file_id=file_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"onedrive_path": f"{settings.onedrive_folder_path}/{filename}",
|
"onedrive_path": f"{settings.onedrive_folder_path}/{filename}",
|
||||||
"web_url": web_url
|
"web_url": web_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to upload {filename} to OneDrive: {str(e)}"
|
error_msg = f"Failed to upload {filename} to OneDrive: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import requests
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
from app.database import SessionLocal
|
|
||||||
from app.models import FileRecord
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
POLL_MAX_ATTEMPTS = 10
|
POLL_MAX_ATTEMPTS = 10
|
||||||
POLL_INTERVAL_SEC = 3
|
POLL_INTERVAL_SEC = 3
|
||||||
|
|
||||||
|
|
||||||
def _get_headers():
|
def _get_headers():
|
||||||
"""Returns HTTP headers for Paperless-ngx API calls."""
|
"""Returns HTTP headers for Paperless-ngx API calls."""
|
||||||
return {
|
return {"Authorization": f"Token {settings.paperless_ngx_api_token}"}
|
||||||
"Authorization": f"Token {settings.paperless_ngx_api_token}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def _paperless_api_url(path: str) -> str:
|
def _paperless_api_url(path: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -35,6 +32,7 @@ def _paperless_api_url(path: str) -> str:
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
return f"{host}{path}"
|
return f"{host}{path}"
|
||||||
|
|
||||||
|
|
||||||
def poll_task_for_document_id(task_id: str) -> int:
|
def poll_task_for_document_id(task_id: str) -> int:
|
||||||
"""
|
"""
|
||||||
Polls /api/tasks/?task_id=<uuid> until we get status=SUCCESS or FAILURE,
|
Polls /api/tasks/?task_id=<uuid> until we get status=SUCCESS or FAILURE,
|
||||||
@@ -49,14 +47,13 @@ def poll_task_for_document_id(task_id: str) -> int:
|
|||||||
|
|
||||||
while attempts < POLL_MAX_ATTEMPTS:
|
while attempts < POLL_MAX_ATTEMPTS:
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, headers=_get_headers(), params={"task_id": task_id}, timeout=settings.http_request_timeout)
|
resp = requests.get(
|
||||||
|
url, headers=_get_headers(), params={"task_id": task_id}, timeout=settings.http_request_timeout
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
tasks_data = resp.json()
|
tasks_data = resp.json()
|
||||||
except requests.exceptions.RequestException as exc:
|
except requests.exceptions.RequestException as exc:
|
||||||
logger.warning(
|
logger.warning("Failed to poll for task_id='%s'. Attempt=%d Error=%s", task_id, attempts + 1, exc)
|
||||||
"Failed to poll for task_id='%s'. Attempt=%d Error=%s",
|
|
||||||
task_id, attempts + 1, exc
|
|
||||||
)
|
|
||||||
time.sleep(POLL_INTERVAL_SEC)
|
time.sleep(POLL_INTERVAL_SEC)
|
||||||
attempts += 1
|
attempts += 1
|
||||||
continue
|
continue
|
||||||
@@ -71,31 +68,34 @@ def poll_task_for_document_id(task_id: str) -> int:
|
|||||||
doc_str = task_info.get("related_document")
|
doc_str = task_info.get("related_document")
|
||||||
if doc_str:
|
if doc_str:
|
||||||
return int(doc_str)
|
return int(doc_str)
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Task {task_id} completed but no doc ID found. Task info: {task_info}")
|
||||||
f"Task {task_id} completed but no doc ID found. Task info: {task_info}"
|
|
||||||
)
|
|
||||||
elif status == "FAILURE":
|
elif status == "FAILURE":
|
||||||
raise RuntimeError(f"Task {task_id} failed: {task_info.get('result')}")
|
raise RuntimeError(f"Task {task_id} failed: {task_info.get('result')}")
|
||||||
|
|
||||||
attempts += 1
|
attempts += 1
|
||||||
time.sleep(POLL_INTERVAL_SEC)
|
time.sleep(POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
raise TimeoutError(
|
raise TimeoutError(f"Task {task_id} didn't reach SUCCESS within {POLL_MAX_ATTEMPTS} attempts.")
|
||||||
f"Task {task_id} didn't reach SUCCESS within {POLL_MAX_ATTEMPTS} attempts."
|
|
||||||
)
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_paperless(self, file_path: str, file_id: int = None):
|
def upload_to_paperless(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Uploads a file to Paperless-ngx.
|
Uploads a file to Paperless-ngx.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
"""
|
"""
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting Paperless upload: {file_path}")
|
logger.info(f"[{task_id}] Starting Paperless upload: {file_path}")
|
||||||
log_task_progress(task_id, "upload_to_paperless", "in_progress", f"Uploading to Paperless: {os.path.basename(file_path)}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"upload_to_paperless",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to Paperless: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
@@ -125,13 +125,17 @@ def upload_to_paperless(self, file_path: str, file_id: int = None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug("Posting document to Paperless: file=%s", filename)
|
logger.debug("Posting document to Paperless: file=%s", filename)
|
||||||
resp = requests.post(post_url, headers=_get_headers(), files=files, data=data, timeout=settings.http_request_timeout)
|
resp = requests.post(
|
||||||
|
post_url, headers=_get_headers(), files=files, data=data, timeout=settings.http_request_timeout
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except requests.exceptions.RequestException as exc:
|
except requests.exceptions.RequestException as exc:
|
||||||
error_msg = f"Failed to upload to Paperless: {exc}"
|
error_msg = f"Failed to upload to Paperless: {exc}"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[{task_id}] Failed to upload document '%s' to Paperless. Error: %s. Response=%s",
|
f"[{task_id}] Failed to upload document '%s' to Paperless. Error: %s. Response=%s",
|
||||||
file_path, exc, getattr(exc.response, "text", "<no response>")
|
file_path,
|
||||||
|
exc,
|
||||||
|
getattr(exc.response, "text", "<no response>"),
|
||||||
)
|
)
|
||||||
log_task_progress(task_id, "upload_to_paperless", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_paperless", "failure", error_msg, file_id=file_id)
|
||||||
raise
|
raise
|
||||||
@@ -145,11 +149,13 @@ def upload_to_paperless(self, file_path: str, file_id: int = None):
|
|||||||
log_task_progress(task_id, "poll_task", "in_progress", "Waiting for Paperless processing", file_id=file_id)
|
log_task_progress(task_id, "poll_task", "in_progress", "Waiting for Paperless processing", file_id=file_id)
|
||||||
doc_id = poll_task_for_document_id(raw_task_id)
|
doc_id = poll_task_for_document_id(raw_task_id)
|
||||||
logger.info(f"[{task_id}] Document {file_path} successfully ingested => ID={doc_id}")
|
logger.info(f"[{task_id}] Document {file_path} successfully ingested => ID={doc_id}")
|
||||||
log_task_progress(task_id, "upload_to_paperless", "success", f"Uploaded to Paperless: Doc ID {doc_id}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "upload_to_paperless", "success", f"Uploaded to Paperless: Doc ID {doc_id}", file_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"paperless_task_id": raw_task_id,
|
"paperless_task_id": raw_task_id,
|
||||||
"paperless_document_id": doc_id,
|
"paperless_document_id": doc_id,
|
||||||
"file_path": file_path
|
"file_path": file_path,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -76,12 +78,7 @@ def upload_to_s3(self, file_path: str, file_id: int = None):
|
|||||||
extra_args["ACL"] = settings.s3_acl
|
extra_args["ACL"] = settings.s3_acl
|
||||||
|
|
||||||
# Upload file
|
# Upload file
|
||||||
s3_client.upload_file(
|
s3_client.upload_file(file_path, settings.s3_bucket_name, s3_key, ExtraArgs=extra_args)
|
||||||
file_path,
|
|
||||||
settings.s3_bucket_name,
|
|
||||||
s3_key,
|
|
||||||
ExtraArgs=extra_args
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate URL to the file (useful for public files)
|
# Generate URL to the file (useful for public files)
|
||||||
# For private files, this is just a reference and won't be accessible directly
|
# For private files, this is just a reference and won't be accessible directly
|
||||||
|
|||||||
+35
-37
@@ -1,22 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
from pathlib import Path
|
|
||||||
from app.config import settings
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.utils.filename_utils import get_unique_filename, sanitize_filename, extract_remote_path
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
|
from app.utils.filename_utils import extract_remote_path, get_unique_filename, sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_sftp(self, file_path: str, file_id: int = None):
|
def upload_to_sftp(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Upload a file to an SFTP server.
|
Upload a file to an SFTP server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
@@ -26,29 +28,29 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id, "upload_to_sftp", "in_progress", f"Uploading to SFTP: {os.path.basename(file_path)}", file_id=file_id
|
task_id, "upload_to_sftp", "in_progress", f"Uploading to SFTP: {os.path.basename(file_path)}", file_id=file_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
error_msg = f"File not found: {file_path}"
|
error_msg = f"File not found: {file_path}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_sftp", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_sftp", "failure", error_msg, file_id=file_id)
|
||||||
raise FileNotFoundError(error_msg)
|
raise FileNotFoundError(error_msg)
|
||||||
|
|
||||||
if not (settings.sftp_host and settings.sftp_port and settings.sftp_username):
|
if not (settings.sftp_host and settings.sftp_port and settings.sftp_username):
|
||||||
error_msg = "SFTP upload skipped: Missing configuration"
|
error_msg = "SFTP upload skipped: Missing configuration"
|
||||||
logger.info(f"[{task_id}] {error_msg}")
|
logger.info(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_sftp", "skipped", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_sftp", "skipped", error_msg, file_id=file_id)
|
||||||
return {"status": "Skipped", "reason": "SFTP settings not configured"}
|
return {"status": "Skipped", "reason": "SFTP settings not configured"}
|
||||||
|
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
sanitized_filename = sanitize_filename(filename)
|
sanitized_filename = sanitize_filename(filename)
|
||||||
|
|
||||||
# SSH client for SFTP connection
|
# SSH client for SFTP connection
|
||||||
ssh = paramiko.SSHClient()
|
ssh = paramiko.SSHClient()
|
||||||
|
|
||||||
# Security: Host key verification
|
# Security: Host key verification
|
||||||
# WARNING: AutoAddPolicy automatically trusts unknown host keys (vulnerable to MITM attacks)
|
# WARNING: AutoAddPolicy automatically trusts unknown host keys (vulnerable to MITM attacks)
|
||||||
# For production, use RejectPolicy and configure known_hosts, or WarningPolicy at minimum
|
# For production, use RejectPolicy and configure known_hosts, or WarningPolicy at minimum
|
||||||
if getattr(settings, 'sftp_disable_host_key_verification', True):
|
if getattr(settings, "sftp_disable_host_key_verification", True):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SFTP host key verification is DISABLED - connections are vulnerable to MITM attacks. "
|
"SFTP host key verification is DISABLED - connections are vulnerable to MITM attacks. "
|
||||||
"For production, set SFTP_DISABLE_HOST_KEY_VERIFICATION=false and configure known_hosts."
|
"For production, set SFTP_DISABLE_HOST_KEY_VERIFICATION=false and configure known_hosts."
|
||||||
@@ -58,7 +60,7 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
# Use system known_hosts for host key verification (more secure)
|
# Use system known_hosts for host key verification (more secure)
|
||||||
ssh.load_system_host_keys()
|
ssh.load_system_host_keys()
|
||||||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Setup connection parameters
|
# Setup connection parameters
|
||||||
connect_kwargs = {
|
connect_kwargs = {
|
||||||
@@ -66,11 +68,11 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
"port": settings.sftp_port,
|
"port": settings.sftp_port,
|
||||||
"username": settings.sftp_username,
|
"username": settings.sftp_username,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for authentication methods - use key if available, otherwise try password
|
# Check for authentication methods - use key if available, otherwise try password
|
||||||
sftp_key_path = getattr(settings, 'sftp_private_key', None)
|
sftp_key_path = getattr(settings, "sftp_private_key", None)
|
||||||
sftp_key_passphrase = getattr(settings, 'sftp_private_key_passphrase', None)
|
sftp_key_passphrase = getattr(settings, "sftp_private_key_passphrase", None)
|
||||||
|
|
||||||
if sftp_key_path and os.path.exists(sftp_key_path):
|
if sftp_key_path and os.path.exists(sftp_key_path):
|
||||||
logger.info(f"Using SSH key authentication with key: {sftp_key_path}")
|
logger.info(f"Using SSH key authentication with key: {sftp_key_path}")
|
||||||
connect_kwargs["key_filename"] = sftp_key_path
|
connect_kwargs["key_filename"] = sftp_key_path
|
||||||
@@ -83,22 +85,22 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
error_msg = "No authentication method available for SFTP (no key or password)"
|
error_msg = "No authentication method available for SFTP (no key or password)"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
# Connect to the server
|
# Connect to the server
|
||||||
logger.info(f"Connecting to SFTP server at {settings.sftp_host}:{settings.sftp_port}")
|
logger.info(f"Connecting to SFTP server at {settings.sftp_host}:{settings.sftp_port}")
|
||||||
ssh.connect(**connect_kwargs)
|
ssh.connect(**connect_kwargs)
|
||||||
|
|
||||||
# Open SFTP session
|
# Open SFTP session
|
||||||
sftp = ssh.open_sftp()
|
sftp = ssh.open_sftp()
|
||||||
|
|
||||||
# Calculate remote path based on local file structure
|
# Calculate remote path based on local file structure
|
||||||
remote_base = settings.sftp_folder or ""
|
remote_base = settings.sftp_folder or ""
|
||||||
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
|
||||||
|
|
||||||
# Ensure the remote path starts with a slash if the base folder does
|
# Ensure the remote path starts with a slash if the base folder does
|
||||||
if remote_base.startswith('/') and not remote_path.startswith('/'):
|
if remote_base.startswith("/") and not remote_path.startswith("/"):
|
||||||
remote_path = '/' + remote_path
|
remote_path = "/" + remote_path
|
||||||
|
|
||||||
# Function to check if file exists in SFTP server
|
# Function to check if file exists in SFTP server
|
||||||
def check_exists_in_sftp(path):
|
def check_exists_in_sftp(path):
|
||||||
try:
|
try:
|
||||||
@@ -106,10 +108,10 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
return True
|
return True
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for potential file collision and get a unique name if needed
|
# Check for potential file collision and get a unique name if needed
|
||||||
remote_path = get_unique_filename(remote_path, check_exists_in_sftp)
|
remote_path = get_unique_filename(remote_path, check_exists_in_sftp)
|
||||||
|
|
||||||
# Create parent directories if needed
|
# Create parent directories if needed
|
||||||
remote_dir = os.path.dirname(remote_path)
|
remote_dir = os.path.dirname(remote_path)
|
||||||
if remote_dir:
|
if remote_dir:
|
||||||
@@ -127,32 +129,28 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
|
|||||||
sftp.mkdir(current_dir)
|
sftp.mkdir(current_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to create directory structure {remote_dir}: {str(e)}")
|
logger.warning(f"Failed to create directory structure {remote_dir}: {str(e)}")
|
||||||
|
|
||||||
# Upload the file
|
# Upload the file
|
||||||
logger.info(f"[{task_id}] Uploading {filename} to SFTP at {remote_path}")
|
logger.info(f"[{task_id}] Uploading {filename} to SFTP at {remote_path}")
|
||||||
sftp.put(file_path, remote_path)
|
sftp.put(file_path, remote_path)
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to SFTP at {remote_path}")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to SFTP at {remote_path}")
|
||||||
log_task_progress(task_id, "upload_to_sftp", "success", f"Uploaded to SFTP: {filename}", file_id=file_id)
|
log_task_progress(task_id, "upload_to_sftp", "success", f"Uploaded to SFTP: {filename}", file_id=file_id)
|
||||||
|
|
||||||
# Close connections
|
# Close connections
|
||||||
sftp.close()
|
sftp.close()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
|
|
||||||
return {
|
return {"status": "Completed", "file_path": file_path, "sftp_path": remote_path}
|
||||||
"status": "Completed",
|
|
||||||
"file_path": file_path,
|
|
||||||
"sftp_path": remote_path
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Make sure connections are closed
|
# Make sure connections are closed
|
||||||
try:
|
try:
|
||||||
if 'sftp' in locals():
|
if "sftp" in locals():
|
||||||
sftp.close()
|
sftp.close()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
error_msg = f"Failed to upload {filename} to SFTP server: {str(e)}"
|
error_msg = f"Failed to upload {filename} to SFTP server: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_sftp", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_sftp", "failure", error_msg, file_id=file_id)
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
from app.utils import log_task_progress
|
from app.utils import log_task_progress
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def upload_to_webdav(self, file_path: str, file_id: int = None):
|
def upload_to_webdav(self, file_path: str, file_id: int = None):
|
||||||
"""
|
"""
|
||||||
Uploads a file to a WebDAV server in the configured folder.
|
Uploads a file to a WebDAV server in the configured folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the file to upload
|
file_path: Path to the file to upload
|
||||||
file_id: Optional file ID to associate with logs
|
file_id: Optional file ID to associate with logs
|
||||||
@@ -23,7 +26,11 @@ def upload_to_webdav(self, file_path: str, file_id: int = None):
|
|||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
logger.info(f"[{task_id}] Starting WebDAV upload: {file_path}")
|
logger.info(f"[{task_id}] Starting WebDAV upload: {file_path}")
|
||||||
log_task_progress(
|
log_task_progress(
|
||||||
task_id, "upload_to_webdav", "in_progress", f"Uploading to WebDAV: {os.path.basename(file_path)}", file_id=file_id
|
task_id,
|
||||||
|
"upload_to_webdav",
|
||||||
|
"in_progress",
|
||||||
|
f"Uploading to WebDAV: {os.path.basename(file_path)}",
|
||||||
|
file_id=file_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -47,13 +54,13 @@ def upload_to_webdav(self, file_path: str, file_id: int = None):
|
|||||||
# Ensure folder doesn't have leading slash if we're joining it to the base URL
|
# Ensure folder doesn't have leading slash if we're joining it to the base URL
|
||||||
if webdav_folder and webdav_folder.startswith("/"):
|
if webdav_folder and webdav_folder.startswith("/"):
|
||||||
webdav_folder = webdav_folder[1:]
|
webdav_folder = webdav_folder[1:]
|
||||||
|
|
||||||
# Join the base URL and folder path
|
# Join the base URL and folder path
|
||||||
target_url = urljoin(settings.webdav_url, webdav_folder)
|
target_url = urljoin(settings.webdav_url, webdav_folder)
|
||||||
# Ensure URL ends with a slash for proper joining with filename
|
# Ensure URL ends with a slash for proper joining with filename
|
||||||
if not target_url.endswith("/"):
|
if not target_url.endswith("/"):
|
||||||
target_url += "/"
|
target_url += "/"
|
||||||
|
|
||||||
# Construct final URL with filename
|
# Construct final URL with filename
|
||||||
webdav_url = urljoin(target_url, filename)
|
webdav_url = urljoin(target_url, filename)
|
||||||
|
|
||||||
@@ -65,20 +72,22 @@ def upload_to_webdav(self, file_path: str, file_id: int = None):
|
|||||||
auth=(settings.webdav_username, settings.webdav_password),
|
auth=(settings.webdav_username, settings.webdav_password),
|
||||||
data=file_data,
|
data=file_data,
|
||||||
verify=settings.webdav_verify_ssl if hasattr(settings, "webdav_verify_ssl") else True,
|
verify=settings.webdav_verify_ssl if hasattr(settings, "webdav_verify_ssl") else True,
|
||||||
timeout=settings.http_request_timeout
|
timeout=settings.http_request_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if upload was successful
|
# Check if upload was successful
|
||||||
if response.status_code in (200, 201, 204):
|
if response.status_code in (200, 201, 204):
|
||||||
logger.info(f"[{task_id}] Successfully uploaded {filename} to WebDAV at {webdav_url}.")
|
logger.info(f"[{task_id}] Successfully uploaded {filename} to WebDAV at {webdav_url}.")
|
||||||
log_task_progress(task_id, "upload_to_webdav", "success", f"Uploaded to WebDAV: {filename}", file_id=file_id)
|
log_task_progress(
|
||||||
|
task_id, "upload_to_webdav", "success", f"Uploaded to WebDAV: {filename}", file_id=file_id
|
||||||
|
)
|
||||||
return {"status": "Completed", "file": file_path, "url": webdav_url}
|
return {"status": "Completed", "file": file_path, "url": webdav_url}
|
||||||
else:
|
else:
|
||||||
error_msg = f"Failed to upload {filename} to WebDAV: {response.status_code} - {response.text}"
|
error_msg = f"Failed to upload {filename} to WebDAV: {response.status_code} - {response.text}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
log_task_progress(task_id, "upload_to_webdav", "failure", error_msg, file_id=file_id)
|
log_task_progress(task_id, "upload_to_webdav", "failure", error_msg, file_id=file_id)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error uploading {filename} to WebDAV: {str(e)}"
|
error_msg = f"Error uploading {filename} to WebDAV: {str(e)}"
|
||||||
logger.error(f"[{task_id}] {error_msg}")
|
logger.error(f"[{task_id}] {error_msg}")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import logging
|
|
||||||
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.celery_app import celery
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ from app.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def ping_uptime_kuma():
|
def ping_uptime_kuma():
|
||||||
"""
|
"""
|
||||||
@@ -18,7 +20,7 @@ def ping_uptime_kuma():
|
|||||||
if not settings.uptime_kuma_url:
|
if not settings.uptime_kuma_url:
|
||||||
logger.debug("Uptime Kuma URL not configured, skipping ping")
|
logger.debug("Uptime Kuma URL not configured, skipping ping")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Pinging Uptime Kuma at {settings.uptime_kuma_url}")
|
logger.info(f"Pinging Uptime Kuma at {settings.uptime_kuma_url}")
|
||||||
response = requests.get(settings.uptime_kuma_url, timeout=10)
|
response = requests.get(settings.uptime_kuma_url, timeout=10)
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# app/utils.py
|
# app/utils.py
|
||||||
# This file is deprecated. Functions have been moved to the utils package.
|
# This file is deprecated. Functions have been moved to the utils package.
|
||||||
# To avoid breaking existing imports, we'll import and re-export the functions
|
# To avoid breaking existing imports, we'll import and re-export the functions
|
||||||
from app.utils.file_operations import hash_file
|
from app.utils.file_operations import hash_file # noqa: F401
|
||||||
from app.utils.logging import log_task_progress
|
from app.utils.logging import log_task_progress # noqa: F401
|
||||||
|
|
||||||
# These functions are now available directly from the app.utils package
|
# These functions are now available directly from the app.utils package
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ from app.utils.file_operations import hash_file
|
|||||||
from app.utils.logging import log_task_progress
|
from app.utils.logging import log_task_progress
|
||||||
|
|
||||||
# Export all the functions that should be available when importing from app.utils
|
# Export all the functions that should be available when importing from app.utils
|
||||||
__all__ = ['hash_file', 'log_task_progress']
|
__all__ = ["hash_file", "log_task_progress"]
|
||||||
|
|||||||
+22
-21
@@ -9,6 +9,7 @@ This module provides functionality to:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ApplicationSettings
|
from app.models import ApplicationSettings
|
||||||
@@ -19,27 +20,27 @@ logger = logging.getLogger(__name__)
|
|||||||
def load_settings_from_db(settings_obj, db_session: Session) -> None:
|
def load_settings_from_db(settings_obj, db_session: Session) -> None:
|
||||||
"""
|
"""
|
||||||
Load settings from database and apply them to the settings object.
|
Load settings from database and apply them to the settings object.
|
||||||
|
|
||||||
Database settings take precedence over environment variables and defaults.
|
Database settings take precedence over environment variables and defaults.
|
||||||
This function should be called after database initialization.
|
This function should be called after database initialization.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings_obj: The Settings instance to update
|
settings_obj: The Settings instance to update
|
||||||
db_session: Database session to use for loading settings
|
db_session: Database session to use for loading settings
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
db_settings = db_session.query(ApplicationSettings).all()
|
db_settings = db_session.query(ApplicationSettings).all()
|
||||||
|
|
||||||
if not db_settings:
|
if not db_settings:
|
||||||
logger.info("No database settings found, using environment/defaults")
|
logger.info("No database settings found, using environment/defaults")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply database settings to the settings object
|
# Apply database settings to the settings object
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
for db_setting in db_settings:
|
for db_setting in db_settings:
|
||||||
key = db_setting.key
|
key = db_setting.key
|
||||||
value = db_setting.value
|
value = db_setting.value
|
||||||
|
|
||||||
# Check if the setting exists in the Settings class
|
# Check if the setting exists in the Settings class
|
||||||
if hasattr(settings_obj, key):
|
if hasattr(settings_obj, key):
|
||||||
# Get the field info to determine the type
|
# Get the field info to determine the type
|
||||||
@@ -47,17 +48,17 @@ def load_settings_from_db(settings_obj, db_session: Session) -> None:
|
|||||||
if field_info:
|
if field_info:
|
||||||
# Convert value to the appropriate type
|
# Convert value to the appropriate type
|
||||||
converted_value = convert_setting_value(value, field_info.annotation)
|
converted_value = convert_setting_value(value, field_info.annotation)
|
||||||
|
|
||||||
# Set the attribute
|
# Set the attribute
|
||||||
setattr(settings_obj, key, converted_value)
|
setattr(settings_obj, key, converted_value)
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
logger.debug(f"Applied database setting: {key}")
|
logger.debug(f"Applied database setting: {key}")
|
||||||
|
|
||||||
if updated_count > 0:
|
if updated_count > 0:
|
||||||
logger.info(f"Loaded {updated_count} settings from database")
|
logger.info(f"Loaded {updated_count} settings from database")
|
||||||
else:
|
else:
|
||||||
logger.info("No applicable database settings found")
|
logger.info("No applicable database settings found")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading settings from database: {e}")
|
logger.error(f"Error loading settings from database: {e}")
|
||||||
# Don't fail application startup if database settings can't be loaded
|
# Don't fail application startup if database settings can't be loaded
|
||||||
@@ -67,27 +68,27 @@ def load_settings_from_db(settings_obj, db_session: Session) -> None:
|
|||||||
def convert_setting_value(value: Optional[str], field_type: Any) -> Any:
|
def convert_setting_value(value: Optional[str], field_type: Any) -> Any:
|
||||||
"""
|
"""
|
||||||
Convert a string value from database to the appropriate type.
|
Convert a string value from database to the appropriate type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: String value from database
|
value: String value from database
|
||||||
field_type: Target type from Pydantic field annotation
|
field_type: Target type from Pydantic field annotation
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Converted value in the appropriate type
|
Converted value in the appropriate type
|
||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Handle Optional types
|
# Handle Optional types
|
||||||
origin = getattr(field_type, '__origin__', None)
|
origin = getattr(field_type, "__origin__", None)
|
||||||
if origin is Union:
|
if origin is Union:
|
||||||
# Get the non-None type from Union (for Optional)
|
# Get the non-None type from Union (for Optional)
|
||||||
args = getattr(field_type, '__args__', ())
|
args = getattr(field_type, "__args__", ())
|
||||||
field_type = next((arg for arg in args if arg is not type(None)), str)
|
field_type = next((arg for arg in args if arg is not type(None)), str)
|
||||||
|
|
||||||
# Convert based on type
|
# Convert based on type
|
||||||
if field_type == bool:
|
if field_type == bool:
|
||||||
return value.lower() in ('true', '1', 'yes', 'y', 't')
|
return value.lower() in ("true", "1", "yes", "y", "t")
|
||||||
elif field_type == int:
|
elif field_type == int:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
@@ -100,10 +101,10 @@ def convert_setting_value(value: Optional[str], field_type: Any) -> Any:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Failed to convert '{value}' to float, returning 0.0")
|
logger.warning(f"Failed to convert '{value}' to float, returning 0.0")
|
||||||
return 0.0
|
return 0.0
|
||||||
elif field_type == list or getattr(field_type, '__origin__', None) == list:
|
elif field_type == list or getattr(field_type, "__origin__", None) == list:
|
||||||
# Handle list types - assume comma-separated values
|
# Handle list types - assume comma-separated values
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return [item.strip() for item in value.split(',') if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
return value
|
return value
|
||||||
else:
|
else:
|
||||||
# Default to string
|
# Default to string
|
||||||
@@ -113,19 +114,19 @@ def convert_setting_value(value: Optional[str], field_type: Any) -> Any:
|
|||||||
def reload_settings_from_db(settings_obj) -> bool:
|
def reload_settings_from_db(settings_obj) -> bool:
|
||||||
"""
|
"""
|
||||||
Reload settings from database.
|
Reload settings from database.
|
||||||
|
|
||||||
This is useful after settings have been updated through the UI.
|
This is useful after settings have been updated through the UI.
|
||||||
Note: Some settings require application restart to take effect.
|
Note: Some settings require application restart to take effect.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings_obj: The Settings instance to update
|
settings_obj: The Settings instance to update
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if reload was successful, False otherwise
|
True if reload was successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
load_settings_from_db(settings_obj, db)
|
load_settings_from_db(settings_obj, db)
|
||||||
|
|||||||
@@ -4,29 +4,25 @@ Configuration validation for the application.
|
|||||||
This file serves as a backward-compatible interface to the config_validator package.
|
This file serves as a backward-compatible interface to the config_validator package.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Import and re-export all functions from the new package
|
|
||||||
from app.utils.config_validator.validators import (
|
|
||||||
validate_email_config,
|
|
||||||
validate_storage_configs,
|
|
||||||
validate_notification_config,
|
|
||||||
check_all_configs
|
|
||||||
)
|
|
||||||
from app.utils.config_validator.masking import mask_sensitive_value
|
from app.utils.config_validator.masking import mask_sensitive_value
|
||||||
from app.utils.config_validator.providers import get_provider_status
|
from app.utils.config_validator.providers import get_provider_status
|
||||||
from app.utils.config_validator.settings_display import (
|
from app.utils.config_validator.settings_display import dump_all_settings, get_settings_for_display
|
||||||
get_settings_for_display,
|
|
||||||
dump_all_settings
|
# Import and re-export all functions from the new package
|
||||||
|
from app.utils.config_validator.validators import (
|
||||||
|
check_all_configs,
|
||||||
|
validate_email_config,
|
||||||
|
validate_notification_config,
|
||||||
|
validate_storage_configs,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'validate_email_config',
|
"validate_email_config",
|
||||||
'validate_storage_configs',
|
"validate_storage_configs",
|
||||||
'validate_notification_config',
|
"validate_notification_config",
|
||||||
'mask_sensitive_value',
|
"mask_sensitive_value",
|
||||||
'get_provider_status',
|
"get_provider_status",
|
||||||
'get_settings_for_display',
|
"get_settings_for_display",
|
||||||
'dump_all_settings',
|
"dump_all_settings",
|
||||||
'check_all_configs'
|
"check_all_configs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,28 +2,25 @@
|
|||||||
Configuration validation package for the application.
|
Configuration validation package for the application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.utils.config_validator.validators import (
|
|
||||||
validate_email_config,
|
|
||||||
validate_storage_configs,
|
|
||||||
validate_notification_config,
|
|
||||||
validate_auth_config,
|
|
||||||
check_all_configs
|
|
||||||
)
|
|
||||||
from app.utils.config_validator.masking import mask_sensitive_value
|
from app.utils.config_validator.masking import mask_sensitive_value
|
||||||
from app.utils.config_validator.providers import get_provider_status
|
from app.utils.config_validator.providers import get_provider_status
|
||||||
from app.utils.config_validator.settings_display import (
|
from app.utils.config_validator.settings_display import dump_all_settings, get_settings_for_display
|
||||||
get_settings_for_display,
|
from app.utils.config_validator.validators import (
|
||||||
dump_all_settings
|
check_all_configs,
|
||||||
|
validate_auth_config,
|
||||||
|
validate_email_config,
|
||||||
|
validate_notification_config,
|
||||||
|
validate_storage_configs,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'validate_email_config',
|
"validate_email_config",
|
||||||
'validate_storage_configs',
|
"validate_storage_configs",
|
||||||
'validate_notification_config',
|
"validate_notification_config",
|
||||||
'validate_auth_config',
|
"validate_auth_config",
|
||||||
'mask_sensitive_value',
|
"mask_sensitive_value",
|
||||||
'get_provider_status',
|
"get_provider_status",
|
||||||
'get_settings_for_display',
|
"get_settings_for_display",
|
||||||
'dump_all_settings',
|
"dump_all_settings",
|
||||||
'check_all_configs'
|
"check_all_configs",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Module for masking sensitive information in configuration values
|
Module for masking sensitive information in configuration values
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def mask_sensitive_value(value):
|
def mask_sensitive_value(value):
|
||||||
"""
|
"""
|
||||||
Masks sensitive values like API keys in logs and output
|
Masks sensitive values like API keys in logs and output
|
||||||
|
|||||||
@@ -5,294 +5,323 @@ Module for handling provider status information
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.config_validator.masking import mask_sensitive_value
|
from app.utils.config_validator.masking import mask_sensitive_value
|
||||||
|
|
||||||
|
|
||||||
def get_provider_status():
|
def get_provider_status():
|
||||||
"""
|
"""
|
||||||
Returns status information for all configured providers
|
Returns status information for all configured providers
|
||||||
"""
|
"""
|
||||||
providers = {}
|
providers = {}
|
||||||
|
|
||||||
# Add Authentication configuration
|
# Add Authentication configuration
|
||||||
auth_enabled = getattr(settings, 'auth_enabled', False)
|
auth_enabled = getattr(settings, "auth_enabled", False)
|
||||||
using_oidc = bool(getattr(settings, 'authentik_client_id', None) and
|
using_oidc = bool(
|
||||||
getattr(settings, 'authentik_client_secret', None) and
|
getattr(settings, "authentik_client_id", None)
|
||||||
getattr(settings, 'authentik_config_url', None))
|
and getattr(settings, "authentik_client_secret", None)
|
||||||
|
and getattr(settings, "authentik_config_url", None)
|
||||||
|
)
|
||||||
|
|
||||||
auth_method = "OIDC" if using_oidc else "Basic Auth" if auth_enabled else "None"
|
auth_method = "OIDC" if using_oidc else "Basic Auth" if auth_enabled else "None"
|
||||||
|
|
||||||
providers["Authentication"] = {
|
providers["Authentication"] = {
|
||||||
"name": "Authentication",
|
"name": "Authentication",
|
||||||
"icon": "fa-solid fa-lock",
|
"icon": "fa-solid fa-lock",
|
||||||
"configured": bool(auth_enabled and
|
"configured": bool(auth_enabled and (getattr(settings, "admin_username", None) or using_oidc)),
|
||||||
(getattr(settings, 'admin_username', None) or
|
|
||||||
using_oidc)),
|
|
||||||
"enabled": auth_enabled,
|
"enabled": auth_enabled,
|
||||||
"description": "Access control and user authentication",
|
"description": "Access control and user authentication",
|
||||||
"details": {
|
"details": {
|
||||||
"method": auth_method,
|
"method": auth_method,
|
||||||
"provider_name": getattr(settings, 'oauth_provider_name', 'Not set') if using_oidc else "N/A",
|
"provider_name": getattr(settings, "oauth_provider_name", "Not set") if using_oidc else "N/A",
|
||||||
"session_security": "Configured" if getattr(settings, 'session_secret', None) else "Not configured"
|
"session_security": "Configured" if getattr(settings, "session_secret", None) else "Not configured",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add Notification configuration - Make sure this provider is near the top of the list
|
# Add Notification configuration - Make sure this provider is near the top of the list
|
||||||
providers["Notifications"] = {
|
providers["Notifications"] = {
|
||||||
"name": "Notifications",
|
"name": "Notifications",
|
||||||
"icon": "fa-solid fa-bell",
|
"icon": "fa-solid fa-bell",
|
||||||
"configured": bool(getattr(settings, 'notification_urls', None)),
|
"configured": bool(getattr(settings, "notification_urls", None)),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Send system notifications via various services",
|
"description": "Send system notifications via various services",
|
||||||
"details": {
|
"details": {
|
||||||
"services": str(len(getattr(settings, 'notification_urls', []))) + " service(s) configured" if getattr(settings, 'notification_urls', None) else "Not configured",
|
"services": (
|
||||||
"task_failure": getattr(settings, 'notify_on_task_failure', True),
|
str(len(getattr(settings, "notification_urls", []))) + " service(s) configured"
|
||||||
"credential_failure": getattr(settings, 'notify_on_credential_failure', True),
|
if getattr(settings, "notification_urls", None)
|
||||||
"startup": getattr(settings, 'notify_on_startup', True),
|
else "Not configured"
|
||||||
"shutdown": getattr(settings, 'notify_on_shutdown', False)
|
),
|
||||||
|
"task_failure": getattr(settings, "notify_on_task_failure", True),
|
||||||
|
"credential_failure": getattr(settings, "notify_on_credential_failure", True),
|
||||||
|
"startup": getattr(settings, "notify_on_startup", True),
|
||||||
|
"shutdown": getattr(settings, "notify_on_shutdown", False),
|
||||||
},
|
},
|
||||||
"testable": True,
|
"testable": True,
|
||||||
"test_endpoint": "/api/diagnostic/test-notification"
|
"test_endpoint": "/api/diagnostic/test-notification",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add AI services first
|
# Add AI services first
|
||||||
providers["OpenAI"] = {
|
providers["OpenAI"] = {
|
||||||
"name": "OpenAI",
|
"name": "OpenAI",
|
||||||
"icon": "fa-brands fa-openai",
|
"icon": "fa-brands fa-openai",
|
||||||
"configured": bool(getattr(settings, 'openai_api_key', None) and
|
"configured": bool(
|
||||||
str(getattr(settings, 'openai_api_key', '')).startswith('sk-')),
|
getattr(settings, "openai_api_key", None) and str(getattr(settings, "openai_api_key", "")).startswith("sk-")
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "AI-powered document analysis and metadata extraction",
|
"description": "AI-powered document analysis and metadata extraction",
|
||||||
"details": {
|
"details": {
|
||||||
"api_key": mask_sensitive_value(getattr(settings, 'openai_api_key', None)),
|
"api_key": mask_sensitive_value(getattr(settings, "openai_api_key", None)),
|
||||||
"base_url": getattr(settings, 'openai_base_url', 'https://api.openai.com/v1'),
|
"base_url": getattr(settings, "openai_base_url", "https://api.openai.com/v1"),
|
||||||
"model": getattr(settings, 'openai_model', 'gpt-4')
|
"model": getattr(settings, "openai_model", "gpt-4"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
providers["Azure AI"] = {
|
providers["Azure AI"] = {
|
||||||
"name": "Azure AI",
|
"name": "Azure AI",
|
||||||
"icon": "fa-solid fa-robot",
|
"icon": "fa-solid fa-robot",
|
||||||
"configured": bool(getattr(settings, 'azure_ai_key', None) and
|
"configured": bool(getattr(settings, "azure_ai_key", None) and getattr(settings, "azure_endpoint", None)),
|
||||||
getattr(settings, 'azure_endpoint', None)),
|
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Microsoft Azure Document Intelligence",
|
"description": "Microsoft Azure Document Intelligence",
|
||||||
"details": {
|
"details": {
|
||||||
"api_key": mask_sensitive_value(getattr(settings, 'azure_ai_key', None)),
|
"api_key": mask_sensitive_value(getattr(settings, "azure_ai_key", None)),
|
||||||
"endpoint": getattr(settings, 'azure_endpoint', 'Not set'),
|
"endpoint": getattr(settings, "azure_endpoint", "Not set"),
|
||||||
"region": getattr(settings, 'azure_region', 'Not set')
|
"region": getattr(settings, "azure_region", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add Dropbox configuration - alphabetically ordered providers
|
# Add Dropbox configuration - alphabetically ordered providers
|
||||||
providers["Dropbox"] = {
|
providers["Dropbox"] = {
|
||||||
"name": "Dropbox",
|
"name": "Dropbox",
|
||||||
"icon": "fa-brands fa-dropbox",
|
"icon": "fa-brands fa-dropbox",
|
||||||
"configured": bool(getattr(settings, 'dropbox_app_key', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'dropbox_app_secret', None) and
|
getattr(settings, "dropbox_app_key", None)
|
||||||
getattr(settings, 'dropbox_refresh_token', None)),
|
and getattr(settings, "dropbox_app_secret", None)
|
||||||
|
and getattr(settings, "dropbox_refresh_token", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Upload files to Dropbox cloud storage",
|
"description": "Upload files to Dropbox cloud storage",
|
||||||
"details": {
|
"details": {
|
||||||
"folder": getattr(settings, 'dropbox_folder', 'Not set'),
|
"folder": getattr(settings, "dropbox_folder", "Not set"),
|
||||||
"app_key": getattr(settings, 'dropbox_app_key', 'Not set'),
|
"app_key": getattr(settings, "dropbox_app_key", "Not set"),
|
||||||
"app_secret": mask_sensitive_value(getattr(settings, 'dropbox_app_secret', None)),
|
"app_secret": mask_sensitive_value(getattr(settings, "dropbox_app_secret", None)),
|
||||||
"refresh_token": mask_sensitive_value(getattr(settings, 'dropbox_refresh_token', None))
|
"refresh_token": mask_sensitive_value(getattr(settings, "dropbox_refresh_token", None)),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add Email configuration
|
# Add Email configuration
|
||||||
providers["Email"] = {
|
providers["Email"] = {
|
||||||
"name": "Email",
|
"name": "Email",
|
||||||
"icon": "fa-solid fa-envelope",
|
"icon": "fa-solid fa-envelope",
|
||||||
"configured": bool(getattr(settings, 'email_host', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'email_default_recipient', None)),
|
getattr(settings, "email_host", None) and getattr(settings, "email_default_recipient", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Send documents via email",
|
"description": "Send documents via email",
|
||||||
"details": {
|
"details": {
|
||||||
"host": getattr(settings, 'email_host', 'Not set'),
|
"host": getattr(settings, "email_host", "Not set"),
|
||||||
"port": getattr(settings, 'email_port', 'Not set'),
|
"port": getattr(settings, "email_port", "Not set"),
|
||||||
"username": getattr(settings, 'email_username', 'Not set'),
|
"username": getattr(settings, "email_username", "Not set"),
|
||||||
"password": mask_sensitive_value(getattr(settings, 'email_password', None)),
|
"password": mask_sensitive_value(getattr(settings, "email_password", None)),
|
||||||
"use_tls": getattr(settings, 'email_use_tls', 'Not set'),
|
"use_tls": getattr(settings, "email_use_tls", "Not set"),
|
||||||
"sender": getattr(settings, 'email_sender', 'Not set'),
|
"sender": getattr(settings, "email_sender", "Not set"),
|
||||||
"default_recipient": getattr(settings, 'email_default_recipient', 'Not set')
|
"default_recipient": getattr(settings, "email_default_recipient", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add FTP configuration to providers
|
# Add FTP configuration to providers
|
||||||
providers["FTP Storage"] = {
|
providers["FTP Storage"] = {
|
||||||
"name": "FTP Storage",
|
"name": "FTP Storage",
|
||||||
"icon": "fa-solid fa-server",
|
"icon": "fa-solid fa-server",
|
||||||
"configured": bool(getattr(settings, 'ftp_host', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'ftp_username', None) and
|
getattr(settings, "ftp_host", None)
|
||||||
getattr(settings, 'ftp_password', None)),
|
and getattr(settings, "ftp_username", None)
|
||||||
|
and getattr(settings, "ftp_password", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Upload files to FTP server",
|
"description": "Upload files to FTP server",
|
||||||
"details": {
|
"details": {
|
||||||
"host": getattr(settings, 'ftp_host', 'Not set'),
|
"host": getattr(settings, "ftp_host", "Not set"),
|
||||||
"port": getattr(settings, 'ftp_port', 'Not set'),
|
"port": getattr(settings, "ftp_port", "Not set"),
|
||||||
"username": getattr(settings, 'ftp_username', 'Not set'),
|
"username": getattr(settings, "ftp_username", "Not set"),
|
||||||
"password": mask_sensitive_value(getattr(settings, 'ftp_password', None)),
|
"password": mask_sensitive_value(getattr(settings, "ftp_password", None)),
|
||||||
"folder": getattr(settings, 'ftp_folder', 'Not set'),
|
"folder": getattr(settings, "ftp_folder", "Not set"),
|
||||||
"tls": getattr(settings, 'ftp_use_tls', True),
|
"tls": getattr(settings, "ftp_use_tls", True),
|
||||||
"allow_plaintext": getattr(settings, 'ftp_allow_plaintext', True)
|
"allow_plaintext": getattr(settings, "ftp_allow_plaintext", True),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check Google Drive configuration
|
# Check Google Drive configuration
|
||||||
gdrive_oauth_configured = bool(getattr(settings, 'google_drive_client_id', None) and
|
gdrive_oauth_configured = bool(
|
||||||
getattr(settings, 'google_drive_client_secret', None) and
|
getattr(settings, "google_drive_client_id", None)
|
||||||
getattr(settings, 'google_drive_refresh_token', None))
|
and getattr(settings, "google_drive_client_secret", None)
|
||||||
|
and getattr(settings, "google_drive_refresh_token", None)
|
||||||
gdrive_sa_configured = bool(getattr(settings, 'google_drive_credentials_json', None))
|
)
|
||||||
|
|
||||||
|
gdrive_sa_configured = bool(getattr(settings, "google_drive_credentials_json", None))
|
||||||
|
|
||||||
# Determine if using OAuth or service account
|
# Determine if using OAuth or service account
|
||||||
use_oauth = getattr(settings, 'google_drive_use_oauth', False)
|
use_oauth = getattr(settings, "google_drive_use_oauth", False)
|
||||||
|
|
||||||
is_configured = (use_oauth and gdrive_oauth_configured) or (not use_oauth and gdrive_sa_configured)
|
is_configured = (use_oauth and gdrive_oauth_configured) or (not use_oauth and gdrive_sa_configured)
|
||||||
|
|
||||||
providers["Google Drive"] = {
|
providers["Google Drive"] = {
|
||||||
"name": "Google Drive",
|
"name": "Google Drive",
|
||||||
"icon": "fa-brands fa-google-drive",
|
"icon": "fa-brands fa-google-drive",
|
||||||
"configured": is_configured and bool(getattr(settings, 'google_drive_folder_id', None)),
|
"configured": is_configured and bool(getattr(settings, "google_drive_folder_id", None)),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Store documents in Google Drive",
|
"description": "Store documents in Google Drive",
|
||||||
"details": {
|
"details": {
|
||||||
"auth_type": "OAuth" if use_oauth else "Service Account",
|
"auth_type": "OAuth" if use_oauth else "Service Account",
|
||||||
"client_id": getattr(settings, 'google_drive_client_id', 'Not set') if use_oauth else 'N/A',
|
"client_id": getattr(settings, "google_drive_client_id", "Not set") if use_oauth else "N/A",
|
||||||
"client_secret": mask_sensitive_value(getattr(settings, 'google_drive_client_secret', None)) if use_oauth else 'N/A',
|
"client_secret": (
|
||||||
"refresh_token": mask_sensitive_value(getattr(settings, 'google_drive_refresh_token', None)) if use_oauth else 'N/A',
|
mask_sensitive_value(getattr(settings, "google_drive_client_secret", None)) if use_oauth else "N/A"
|
||||||
"credentials_json": mask_sensitive_value(getattr(settings, 'google_drive_credentials_json', None)) if not use_oauth else 'N/A',
|
),
|
||||||
"folder_id": getattr(settings, 'google_drive_folder_id', 'Not set'),
|
"refresh_token": (
|
||||||
"delegate": getattr(settings, 'google_drive_delegate_to', 'Not set') if not use_oauth else 'N/A'
|
mask_sensitive_value(getattr(settings, "google_drive_refresh_token", None)) if use_oauth else "N/A"
|
||||||
}
|
),
|
||||||
|
"credentials_json": (
|
||||||
|
mask_sensitive_value(getattr(settings, "google_drive_credentials_json", None))
|
||||||
|
if not use_oauth
|
||||||
|
else "N/A"
|
||||||
|
),
|
||||||
|
"folder_id": getattr(settings, "google_drive_folder_id", "Not set"),
|
||||||
|
"delegate": getattr(settings, "google_drive_delegate_to", "Not set") if not use_oauth else "N/A",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check NextCloud configuration
|
# Check NextCloud configuration
|
||||||
nextcloud_url = getattr(settings, 'nextcloud_upload_url', 'Not set')
|
nextcloud_url = getattr(settings, "nextcloud_upload_url", "Not set")
|
||||||
# Extract base URL from WebDAV URL (remove the /remote.php part and everything after it)
|
# Extract base URL from WebDAV URL (remove the /remote.php part and everything after it)
|
||||||
nextcloud_base_url = nextcloud_url
|
nextcloud_base_url = nextcloud_url
|
||||||
if nextcloud_url != 'Not set' and nextcloud_url is not None and '/remote.php' in nextcloud_url:
|
if nextcloud_url != "Not set" and nextcloud_url is not None and "/remote.php" in nextcloud_url:
|
||||||
nextcloud_base_url = nextcloud_url.split('/remote.php')[0]
|
nextcloud_base_url = nextcloud_url.split("/remote.php")[0]
|
||||||
|
|
||||||
providers["NextCloud"] = {
|
providers["NextCloud"] = {
|
||||||
"name": "NextCloud",
|
"name": "NextCloud",
|
||||||
"icon": "fa-solid fa-cloud",
|
"icon": "fa-solid fa-cloud",
|
||||||
"configured": bool(getattr(settings, 'nextcloud_upload_url', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'nextcloud_username', None) and
|
getattr(settings, "nextcloud_upload_url", None)
|
||||||
getattr(settings, 'nextcloud_password', None)),
|
and getattr(settings, "nextcloud_username", None)
|
||||||
|
and getattr(settings, "nextcloud_password", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Store documents in NextCloud",
|
"description": "Store documents in NextCloud",
|
||||||
"details": {
|
"details": {
|
||||||
"url": getattr(settings, 'nextcloud_upload_url', 'Not set'),
|
"url": getattr(settings, "nextcloud_upload_url", "Not set"),
|
||||||
"base_url": nextcloud_base_url,
|
"base_url": nextcloud_base_url,
|
||||||
"username": getattr(settings, 'nextcloud_username', 'Not set'),
|
"username": getattr(settings, "nextcloud_username", "Not set"),
|
||||||
"password": mask_sensitive_value(getattr(settings, 'nextcloud_password', None)),
|
"password": mask_sensitive_value(getattr(settings, "nextcloud_password", None)),
|
||||||
"folder": getattr(settings, 'nextcloud_folder', 'Not set')
|
"folder": getattr(settings, "nextcloud_folder", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check OneDrive configuration
|
# Check OneDrive configuration
|
||||||
providers["OneDrive"] = {
|
providers["OneDrive"] = {
|
||||||
"name": "OneDrive",
|
"name": "OneDrive",
|
||||||
"icon": "fa-brands fa-microsoft",
|
"icon": "fa-brands fa-microsoft",
|
||||||
"configured": bool(getattr(settings, 'onedrive_client_id', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'onedrive_client_secret', None) and
|
getattr(settings, "onedrive_client_id", None)
|
||||||
getattr(settings, 'onedrive_refresh_token', None)),
|
and getattr(settings, "onedrive_client_secret", None)
|
||||||
|
and getattr(settings, "onedrive_refresh_token", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Store documents in Microsoft OneDrive",
|
"description": "Store documents in Microsoft OneDrive",
|
||||||
"details": {
|
"details": {
|
||||||
"client_id": getattr(settings, 'onedrive_client_id', 'Not set'),
|
"client_id": getattr(settings, "onedrive_client_id", "Not set"),
|
||||||
"client_secret": mask_sensitive_value(getattr(settings, 'onedrive_client_secret', None)),
|
"client_secret": mask_sensitive_value(getattr(settings, "onedrive_client_secret", None)),
|
||||||
"tenant_id": getattr(settings, 'onedrive_tenant_id', 'Not set'),
|
"tenant_id": getattr(settings, "onedrive_tenant_id", "Not set"),
|
||||||
"refresh_token": mask_sensitive_value(getattr(settings, 'onedrive_refresh_token', None)),
|
"refresh_token": mask_sensitive_value(getattr(settings, "onedrive_refresh_token", None)),
|
||||||
"folder": getattr(settings, 'onedrive_folder_path', 'Not set')
|
"folder": getattr(settings, "onedrive_folder_path", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check Paperless configuration
|
# Check Paperless configuration
|
||||||
providers["Paperless-ngx"] = {
|
providers["Paperless-ngx"] = {
|
||||||
"name": "Paperless-ngx",
|
"name": "Paperless-ngx",
|
||||||
"icon": "fa-solid fa-file-lines",
|
"icon": "fa-solid fa-file-lines",
|
||||||
"configured": bool(getattr(settings, 'paperless_host', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'paperless_ngx_api_token', None)),
|
getattr(settings, "paperless_host", None) and getattr(settings, "paperless_ngx_api_token", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Document management system for digital archives",
|
"description": "Document management system for digital archives",
|
||||||
"details": {
|
"details": {
|
||||||
"host": getattr(settings, 'paperless_host', 'Not set'),
|
"host": getattr(settings, "paperless_host", "Not set"),
|
||||||
"api_token": mask_sensitive_value(getattr(settings, 'paperless_ngx_api_token', None))
|
"api_token": mask_sensitive_value(getattr(settings, "paperless_ngx_api_token", None)),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check S3 configuration
|
# Check S3 configuration
|
||||||
providers["S3 Storage"] = {
|
providers["S3 Storage"] = {
|
||||||
"name": "S3 Storage",
|
"name": "S3 Storage",
|
||||||
"icon": "fa-brands fa-aws",
|
"icon": "fa-brands fa-aws",
|
||||||
"configured": bool(getattr(settings, 's3_bucket_name', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'aws_access_key_id', None) and
|
getattr(settings, "s3_bucket_name", None)
|
||||||
getattr(settings, 'aws_secret_access_key', None)),
|
and getattr(settings, "aws_access_key_id", None)
|
||||||
|
and getattr(settings, "aws_secret_access_key", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Store documents in S3-compatible object storage",
|
"description": "Store documents in S3-compatible object storage",
|
||||||
"details": {
|
"details": {
|
||||||
"bucket": getattr(settings, 's3_bucket_name', 'Not set'),
|
"bucket": getattr(settings, "s3_bucket_name", "Not set"),
|
||||||
"region": getattr(settings, 'aws_region', 'Not set'),
|
"region": getattr(settings, "aws_region", "Not set"),
|
||||||
"access_key_id": getattr(settings, 'aws_access_key_id', 'Not set'),
|
"access_key_id": getattr(settings, "aws_access_key_id", "Not set"),
|
||||||
"secret_access_key": mask_sensitive_value(getattr(settings, 'aws_secret_access_key', None)),
|
"secret_access_key": mask_sensitive_value(getattr(settings, "aws_secret_access_key", None)),
|
||||||
"folder_prefix": getattr(settings, 's3_folder_prefix', 'Not set'),
|
"folder_prefix": getattr(settings, "s3_folder_prefix", "Not set"),
|
||||||
"storage_class": getattr(settings, 's3_storage_class', 'Not set'),
|
"storage_class": getattr(settings, "s3_storage_class", "Not set"),
|
||||||
"acl": getattr(settings, 's3_acl', 'Not set')
|
"acl": getattr(settings, "s3_acl", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check SFTP configuration
|
# Check SFTP configuration
|
||||||
providers["SFTP Storage"] = {
|
providers["SFTP Storage"] = {
|
||||||
"name": "SFTP Storage",
|
"name": "SFTP Storage",
|
||||||
"icon": "fa-solid fa-lock",
|
"icon": "fa-solid fa-lock",
|
||||||
"configured": bool(getattr(settings, 'sftp_host', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'sftp_username', None) and
|
getattr(settings, "sftp_host", None)
|
||||||
(getattr(settings, 'sftp_password', None) or
|
and getattr(settings, "sftp_username", None)
|
||||||
getattr(settings, 'sftp_private_key', None))),
|
and (getattr(settings, "sftp_password", None) or getattr(settings, "sftp_private_key", None))
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Upload files to SFTP server",
|
"description": "Upload files to SFTP server",
|
||||||
"details": {
|
"details": {
|
||||||
"host": getattr(settings, 'sftp_host', 'Not set'),
|
"host": getattr(settings, "sftp_host", "Not set"),
|
||||||
"port": getattr(settings, 'sftp_port', 'Not set'),
|
"port": getattr(settings, "sftp_port", "Not set"),
|
||||||
"username": getattr(settings, 'sftp_username', 'Not set'),
|
"username": getattr(settings, "sftp_username", "Not set"),
|
||||||
"password": mask_sensitive_value(getattr(settings, 'sftp_password', None)),
|
"password": mask_sensitive_value(getattr(settings, "sftp_password", None)),
|
||||||
"private_key": getattr(settings, 'sftp_private_key', 'Not set'),
|
"private_key": getattr(settings, "sftp_private_key", "Not set"),
|
||||||
"private_key_passphrase": mask_sensitive_value(getattr(settings, 'sftp_private_key_passphrase', None)),
|
"private_key_passphrase": mask_sensitive_value(getattr(settings, "sftp_private_key_passphrase", None)),
|
||||||
"folder": getattr(settings, 'sftp_folder', 'Not set')
|
"folder": getattr(settings, "sftp_folder", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add Uptime Kuma configuration
|
# Add Uptime Kuma configuration
|
||||||
providers["Uptime Kuma"] = {
|
providers["Uptime Kuma"] = {
|
||||||
"name": "Uptime Kuma",
|
"name": "Uptime Kuma",
|
||||||
"icon": "fa-solid fa-heart-pulse",
|
"icon": "fa-solid fa-heart-pulse",
|
||||||
"configured": bool(getattr(settings, 'uptime_kuma_url', None)),
|
"configured": bool(getattr(settings, "uptime_kuma_url", None)),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Server monitoring and status page",
|
"description": "Server monitoring and status page",
|
||||||
"details": {
|
"details": {
|
||||||
"url": getattr(settings, 'uptime_kuma_url', 'Not set'),
|
"url": getattr(settings, "uptime_kuma_url", "Not set"),
|
||||||
"ping_interval": getattr(settings, 'uptime_kuma_ping_interval', 'Not set')
|
"ping_interval": getattr(settings, "uptime_kuma_ping_interval", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check WebDAV configuration
|
# Check WebDAV configuration
|
||||||
providers["WebDAV"] = {
|
providers["WebDAV"] = {
|
||||||
"name": "WebDAV",
|
"name": "WebDAV",
|
||||||
"icon": "fa-solid fa-globe",
|
"icon": "fa-solid fa-globe",
|
||||||
"configured": bool(getattr(settings, 'webdav_url', None) and
|
"configured": bool(
|
||||||
getattr(settings, 'webdav_username', None) and
|
getattr(settings, "webdav_url", None)
|
||||||
getattr(settings, 'webdav_password', None)),
|
and getattr(settings, "webdav_username", None)
|
||||||
|
and getattr(settings, "webdav_password", None)
|
||||||
|
),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"description": "Store documents on WebDAV servers",
|
"description": "Store documents on WebDAV servers",
|
||||||
"details": {
|
"details": {
|
||||||
"url": getattr(settings, 'webdav_url', 'Not set'),
|
"url": getattr(settings, "webdav_url", "Not set"),
|
||||||
"username": getattr(settings, 'webdav_username', 'Not set'),
|
"username": getattr(settings, "webdav_username", "Not set"),
|
||||||
"password": mask_sensitive_value(getattr(settings, 'webdav_password', None)),
|
"password": mask_sensitive_value(getattr(settings, "webdav_password", None)),
|
||||||
"folder": getattr(settings, 'webdav_folder', 'Not set'),
|
"folder": getattr(settings, "webdav_folder", "Not set"),
|
||||||
"verify_ssl": getattr(settings, 'webdav_verify_ssl', 'Not set')
|
"verify_ssl": getattr(settings, "webdav_verify_ssl", "Not set"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return providers
|
return providers
|
||||||
|
|||||||
@@ -3,31 +3,47 @@ Module for displaying and organizing settings information
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.config_validator.masking import mask_sensitive_value
|
from app.utils.config_validator.masking import mask_sensitive_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def dump_all_settings():
|
def dump_all_settings():
|
||||||
"""Log all settings values for diagnostic purposes"""
|
"""Log all settings values for diagnostic purposes"""
|
||||||
logger.info("--- DUMPING ALL SETTINGS FOR DIAGNOSTIC PURPOSES ---")
|
logger.info("--- DUMPING ALL SETTINGS FOR DIAGNOSTIC PURPOSES ---")
|
||||||
for key in dir(settings):
|
for key in dir(settings):
|
||||||
if not key.startswith('_') and not callable(getattr(settings, key)):
|
if not key.startswith("_") and not callable(getattr(settings, key)):
|
||||||
value = getattr(settings, key)
|
value = getattr(settings, key)
|
||||||
# Mask sensitive values in logs
|
# Mask sensitive values in logs
|
||||||
if key.lower().find('password') >= 0 or key.lower().find('secret') >= 0 or key.lower().find('token') >= 0 or key.lower().find('key') >= 0:
|
if (
|
||||||
|
key.lower().find("password") >= 0
|
||||||
|
or key.lower().find("secret") >= 0
|
||||||
|
or key.lower().find("token") >= 0
|
||||||
|
or key.lower().find("key") >= 0
|
||||||
|
):
|
||||||
if value:
|
if value:
|
||||||
if isinstance(value, str) and len(value) > 10:
|
if isinstance(value, str) and len(value) > 10:
|
||||||
visible_start = max(1, len(value) // 3)
|
visible_start = max(1, len(value) // 3)
|
||||||
visible_end = max(1, len(value) // 4)
|
visible_end = max(1, len(value) // 4)
|
||||||
value = f"{value[:visible_start]}{'*' * (len(value) - visible_start - visible_end)}{value[-visible_end:]}"
|
value = (
|
||||||
|
f"{value[:visible_start]}"
|
||||||
|
f"{'*' * (len(value) - visible_start - visible_end)}"
|
||||||
|
f"{value[-visible_end:]}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
value = f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" if isinstance(value, str) and len(value) > 4 else "****"
|
value = (
|
||||||
|
f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
|
||||||
|
if isinstance(value, str) and len(value) > 4
|
||||||
|
else "****"
|
||||||
|
)
|
||||||
|
|
||||||
# Special handling for notification URLs
|
# Special handling for notification URLs
|
||||||
if key == 'notification_urls' and value:
|
if key == "notification_urls" and value:
|
||||||
try:
|
try:
|
||||||
from app.utils.notification import _mask_sensitive_url
|
from app.utils.notification import _mask_sensitive_url
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
masked_urls = [_mask_sensitive_url(url) for url in value]
|
masked_urls = [_mask_sensitive_url(url) for url in value]
|
||||||
logger.info(f"{key}: {masked_urls}")
|
logger.info(f"{key}: {masked_urls}")
|
||||||
@@ -36,44 +52,37 @@ def dump_all_settings():
|
|||||||
continue # Skip the default logging
|
continue # Skip the default logging
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
pass # Fall back to default logging if _mask_sensitive_url is not available
|
pass # Fall back to default logging if _mask_sensitive_url is not available
|
||||||
|
|
||||||
logger.info(f"{key}: {value}")
|
logger.info(f"{key}: {value}")
|
||||||
logger.info("--- END OF SETTINGS DUMP ---")
|
logger.info("--- END OF SETTINGS DUMP ---")
|
||||||
|
|
||||||
|
|
||||||
def get_settings_for_display(show_values=False):
|
def get_settings_for_display(show_values=False):
|
||||||
"""
|
"""
|
||||||
Group settings into logical categories and check if they are configured.
|
Group settings into logical categories and check if they are configured.
|
||||||
Returns a dictionary with categories as keys and lists of setting items as values.
|
Returns a dictionary with categories as keys and lists of setting items as values.
|
||||||
Each setting item is a dict with name, value, and is_configured.
|
Each setting item is a dict with name, value, and is_configured.
|
||||||
|
|
||||||
If show_values is False, sensitive values are masked.
|
If show_values is False, sensitive values are masked.
|
||||||
"""
|
"""
|
||||||
# First include system info with version in result
|
# First include system info with version in result
|
||||||
result = {
|
result = {
|
||||||
"System Info": [
|
"System Info": [
|
||||||
{
|
{"name": "App Version", "value": settings.version, "is_configured": True},
|
||||||
"name": "App Version",
|
{"name": "Build Date", "value": settings.build_date, "is_configured": True},
|
||||||
"value": settings.version,
|
|
||||||
"is_configured": True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Build Date",
|
|
||||||
"value": settings.build_date,
|
|
||||||
"is_configured": True
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Define categories and their settings
|
# Define categories and their settings
|
||||||
categories = {
|
categories = {
|
||||||
"Core": [
|
"Core": [
|
||||||
"debug", # Explicitly include debug setting
|
"debug", # Explicitly include debug setting
|
||||||
"external_hostname",
|
"external_hostname",
|
||||||
"workdir",
|
"workdir",
|
||||||
"database_url",
|
"database_url",
|
||||||
"redis_url",
|
"redis_url",
|
||||||
"gotenberg_url",
|
"gotenberg_url",
|
||||||
"allow_file_delete" # Added allow_file_delete to Core settings
|
"allow_file_delete", # Added allow_file_delete to Core settings
|
||||||
],
|
],
|
||||||
"Authentication": [
|
"Authentication": [
|
||||||
"auth_enabled",
|
"auth_enabled",
|
||||||
@@ -83,7 +92,7 @@ def get_settings_for_display(show_values=False):
|
|||||||
"authentik_client_id",
|
"authentik_client_id",
|
||||||
"authentik_client_secret",
|
"authentik_client_secret",
|
||||||
"authentik_config_url",
|
"authentik_config_url",
|
||||||
"oauth_provider_name"
|
"oauth_provider_name",
|
||||||
],
|
],
|
||||||
"Email": [
|
"Email": [
|
||||||
"email_host",
|
"email_host",
|
||||||
@@ -92,7 +101,7 @@ def get_settings_for_display(show_values=False):
|
|||||||
"email_password",
|
"email_password",
|
||||||
"email_use_tls",
|
"email_use_tls",
|
||||||
"email_sender",
|
"email_sender",
|
||||||
"email_default_recipient"
|
"email_default_recipient",
|
||||||
],
|
],
|
||||||
"IMAP": [
|
"IMAP": [
|
||||||
"imap1_host",
|
"imap1_host",
|
||||||
@@ -108,47 +117,28 @@ def get_settings_for_display(show_values=False):
|
|||||||
"imap2_password",
|
"imap2_password",
|
||||||
"imap2_ssl",
|
"imap2_ssl",
|
||||||
"imap2_poll_interval_minutes",
|
"imap2_poll_interval_minutes",
|
||||||
"imap2_delete_after_process"
|
"imap2_delete_after_process",
|
||||||
],
|
|
||||||
"Dropbox": [
|
|
||||||
"dropbox_app_key",
|
|
||||||
"dropbox_app_secret",
|
|
||||||
"dropbox_folder",
|
|
||||||
"dropbox_refresh_token"
|
|
||||||
],
|
|
||||||
"NextCloud": [
|
|
||||||
"nextcloud_upload_url",
|
|
||||||
"nextcloud_username",
|
|
||||||
"nextcloud_password",
|
|
||||||
"nextcloud_folder"
|
|
||||||
],
|
|
||||||
"Paperless": [
|
|
||||||
"paperless_host",
|
|
||||||
"paperless_ngx_api_token"
|
|
||||||
],
|
],
|
||||||
|
"Dropbox": ["dropbox_app_key", "dropbox_app_secret", "dropbox_folder", "dropbox_refresh_token"],
|
||||||
|
"NextCloud": ["nextcloud_upload_url", "nextcloud_username", "nextcloud_password", "nextcloud_folder"],
|
||||||
|
"Paperless": ["paperless_host", "paperless_ngx_api_token"],
|
||||||
"Google Drive": [
|
"Google Drive": [
|
||||||
"google_drive_use_oauth",
|
"google_drive_use_oauth",
|
||||||
"google_drive_client_id",
|
"google_drive_client_id",
|
||||||
"google_drive_client_secret",
|
"google_drive_client_secret",
|
||||||
"google_drive_refresh_token",
|
"google_drive_refresh_token",
|
||||||
"google_drive_credentials_json",
|
"google_drive_credentials_json",
|
||||||
"google_drive_folder_id",
|
"google_drive_folder_id",
|
||||||
"google_drive_delegate_to"
|
"google_drive_delegate_to",
|
||||||
],
|
],
|
||||||
"OneDrive": [
|
"OneDrive": [
|
||||||
"onedrive_client_id",
|
"onedrive_client_id",
|
||||||
"onedrive_client_secret",
|
"onedrive_client_secret",
|
||||||
"onedrive_tenant_id",
|
"onedrive_tenant_id",
|
||||||
"onedrive_refresh_token",
|
"onedrive_refresh_token",
|
||||||
"onedrive_folder_path"
|
"onedrive_folder_path",
|
||||||
],
|
|
||||||
"WebDAV": [
|
|
||||||
"webdav_url",
|
|
||||||
"webdav_username",
|
|
||||||
"webdav_password",
|
|
||||||
"webdav_folder",
|
|
||||||
"webdav_verify_ssl"
|
|
||||||
],
|
],
|
||||||
|
"WebDAV": ["webdav_url", "webdav_username", "webdav_password", "webdav_folder", "webdav_verify_ssl"],
|
||||||
"SFTP": [
|
"SFTP": [
|
||||||
"sftp_host",
|
"sftp_host",
|
||||||
"sftp_port",
|
"sftp_port",
|
||||||
@@ -156,7 +146,7 @@ def get_settings_for_display(show_values=False):
|
|||||||
"sftp_password",
|
"sftp_password",
|
||||||
"sftp_folder",
|
"sftp_folder",
|
||||||
"sftp_private_key",
|
"sftp_private_key",
|
||||||
"sftp_private_key_passphrase"
|
"sftp_private_key_passphrase",
|
||||||
],
|
],
|
||||||
"FTP": [
|
"FTP": [
|
||||||
"ftp_host",
|
"ftp_host",
|
||||||
@@ -165,7 +155,7 @@ def get_settings_for_display(show_values=False):
|
|||||||
"ftp_password",
|
"ftp_password",
|
||||||
"ftp_folder",
|
"ftp_folder",
|
||||||
"ftp_use_tls",
|
"ftp_use_tls",
|
||||||
"ftp_allow_plaintext"
|
"ftp_allow_plaintext",
|
||||||
],
|
],
|
||||||
"S3/AWS": [
|
"S3/AWS": [
|
||||||
"aws_access_key_id",
|
"aws_access_key_id",
|
||||||
@@ -174,7 +164,7 @@ def get_settings_for_display(show_values=False):
|
|||||||
"s3_bucket_name",
|
"s3_bucket_name",
|
||||||
"s3_folder_prefix",
|
"s3_folder_prefix",
|
||||||
"s3_storage_class",
|
"s3_storage_class",
|
||||||
"s3_acl"
|
"s3_acl",
|
||||||
],
|
],
|
||||||
"AI Services": [
|
"AI Services": [
|
||||||
"openai_api_key",
|
"openai_api_key",
|
||||||
@@ -182,84 +172,84 @@ def get_settings_for_display(show_values=False):
|
|||||||
"openai_model",
|
"openai_model",
|
||||||
"azure_ai_key",
|
"azure_ai_key",
|
||||||
"azure_endpoint",
|
"azure_endpoint",
|
||||||
"azure_region"
|
"azure_region",
|
||||||
],
|
|
||||||
"Monitoring": [
|
|
||||||
"uptime_kuma_url",
|
|
||||||
"uptime_kuma_ping_interval"
|
|
||||||
],
|
],
|
||||||
|
"Monitoring": ["uptime_kuma_url", "uptime_kuma_ping_interval"],
|
||||||
"Notifications": [
|
"Notifications": [
|
||||||
"notification_urls",
|
"notification_urls",
|
||||||
"notify_on_task_failure",
|
"notify_on_task_failure",
|
||||||
"notify_on_credential_failure",
|
"notify_on_credential_failure",
|
||||||
"notify_on_startup",
|
"notify_on_startup",
|
||||||
"notify_on_shutdown"
|
"notify_on_shutdown",
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle any settings that don't fit into the predefined categories
|
# Handle any settings that don't fit into the predefined categories
|
||||||
all_settings = set([key for key in dir(settings)
|
all_settings = set(
|
||||||
if not key.startswith('_') and
|
[
|
||||||
not callable(getattr(settings, key)) and
|
key
|
||||||
key not in ["model_computed_fields", "model_config",
|
for key in dir(settings)
|
||||||
"model_extra", "model_fields",
|
if not key.startswith("_")
|
||||||
"model_fields_set"]])
|
and not callable(getattr(settings, key))
|
||||||
|
and key not in ["model_computed_fields", "model_config", "model_extra", "model_fields", "model_fields_set"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure 'version' is excluded since we display it separately
|
# Ensure 'version' is excluded since we display it separately
|
||||||
all_settings.discard("version")
|
all_settings.discard("version")
|
||||||
|
|
||||||
categorized_settings = set()
|
categorized_settings = set()
|
||||||
for cat_settings in categories.values():
|
for cat_settings in categories.values():
|
||||||
categorized_settings.update(cat_settings)
|
categorized_settings.update(cat_settings)
|
||||||
|
|
||||||
uncategorized = all_settings - categorized_settings
|
uncategorized = all_settings - categorized_settings
|
||||||
if uncategorized:
|
if uncategorized:
|
||||||
categories["Other"] = list(uncategorized)
|
categories["Other"] = list(uncategorized)
|
||||||
|
|
||||||
# Build the result
|
# Build the result
|
||||||
for category, setting_keys in categories.items():
|
for category, setting_keys in categories.items():
|
||||||
items = []
|
items = []
|
||||||
for key in setting_keys:
|
for key in setting_keys:
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
value = getattr(settings, key)
|
value = getattr(settings, key)
|
||||||
|
|
||||||
# List of patterns that indicate sensitive values
|
# List of patterns that indicate sensitive values
|
||||||
sensitive_patterns = [
|
sensitive_patterns = [
|
||||||
'password', 'secret', 'token', 'api_key', 'private_key',
|
"password",
|
||||||
'credentials', 'access_key', 'ai_key'
|
"secret",
|
||||||
|
"token",
|
||||||
|
"api_key",
|
||||||
|
"private_key",
|
||||||
|
"credentials",
|
||||||
|
"access_key",
|
||||||
|
"ai_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check if this is a sensitive value that should be masked
|
# Check if this is a sensitive value that should be masked
|
||||||
is_sensitive = any(
|
is_sensitive = any(pattern in key.lower() for pattern in sensitive_patterns)
|
||||||
pattern in key.lower() for pattern in sensitive_patterns
|
|
||||||
)
|
|
||||||
|
|
||||||
# Special handling for "auth" to avoid matching prefixes like "authentik"
|
# Special handling for "auth" to avoid matching prefixes like "authentik"
|
||||||
if not is_sensitive and "auth" in key.lower():
|
if not is_sensitive and "auth" in key.lower():
|
||||||
# Only mark as sensitive if "auth" is a standalone word or at the end
|
# Only mark as sensitive if "auth" is a standalone word or at the end
|
||||||
# This avoids matching "authentik" as sensitive
|
# This avoids matching "authentik" as sensitive
|
||||||
parts = key.lower().split('_')
|
parts = key.lower().split("_")
|
||||||
is_sensitive = any(part == "auth" for part in parts) or key.lower().endswith("auth")
|
is_sensitive = any(part == "auth" for part in parts) or key.lower().endswith("auth")
|
||||||
|
|
||||||
# Mask sensitive values regardless of debug mode
|
# Mask sensitive values regardless of debug mode
|
||||||
# Other values are only hidden if debug mode is off AND show_values is False
|
# Other values are only hidden if debug mode is off AND show_values is False
|
||||||
if (is_sensitive or not show_values) and value:
|
if (is_sensitive or not show_values) and value:
|
||||||
if is_sensitive:
|
if is_sensitive:
|
||||||
value = mask_sensitive_value(value)
|
value = mask_sensitive_value(value)
|
||||||
|
|
||||||
# Check if the setting is configured (has a non-None value)
|
# Check if the setting is configured (has a non-None value)
|
||||||
# For boolean settings, consider them configured even if False
|
# For boolean settings, consider them configured even if False
|
||||||
is_configured = value is not None
|
is_configured = value is not None
|
||||||
if is_configured and isinstance(value, str):
|
if is_configured and isinstance(value, str):
|
||||||
is_configured = len(value) > 0
|
is_configured = len(value) > 0
|
||||||
|
|
||||||
items.append({
|
items.append({"name": key, "value": value, "is_configured": is_configured})
|
||||||
"name": key,
|
|
||||||
"value": value,
|
|
||||||
"is_configured": is_configured
|
|
||||||
})
|
|
||||||
|
|
||||||
if items: # Only add categories that have items
|
if items: # Only add categories that have items
|
||||||
result[category] = items
|
result[category] = items
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import logging
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def validate_email_config():
|
def validate_email_config():
|
||||||
"""Validates email configuration settings"""
|
"""Validates email configuration settings"""
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# Check for required email settings
|
# Check for required email settings
|
||||||
if not getattr(settings, 'email_host', None):
|
if not getattr(settings, "email_host", None):
|
||||||
issues.append("EMAIL_HOST is not configured")
|
issues.append("EMAIL_HOST is not configured")
|
||||||
if not getattr(settings, 'email_port', None):
|
if not getattr(settings, "email_port", None):
|
||||||
issues.append("EMAIL_PORT is not configured")
|
issues.append("EMAIL_PORT is not configured")
|
||||||
|
|
||||||
# Test SMTP server connectivity if host is configured
|
# Test SMTP server connectivity if host is configured
|
||||||
if getattr(settings, 'email_host', None) and getattr(settings, 'email_port', None):
|
if getattr(settings, "email_host", None) and getattr(settings, "email_port", None):
|
||||||
try:
|
try:
|
||||||
# Attempt to resolve the hostname
|
# Attempt to resolve the hostname
|
||||||
socket.gethostbyname(settings.email_host)
|
socket.gethostbyname(settings.email_host)
|
||||||
@@ -26,199 +28,212 @@ def validate_email_config():
|
|||||||
issues.append(f"Cannot resolve email host: {settings.email_host}")
|
issues.append(f"Cannot resolve email host: {settings.email_host}")
|
||||||
|
|
||||||
# Check for authentication settings
|
# Check for authentication settings
|
||||||
if not getattr(settings, 'email_username', None):
|
if not getattr(settings, "email_username", None):
|
||||||
issues.append("EMAIL_USERNAME is not configured")
|
issues.append("EMAIL_USERNAME is not configured")
|
||||||
if not getattr(settings, 'email_password', None):
|
if not getattr(settings, "email_password", None):
|
||||||
issues.append("EMAIL_PASSWORD is not configured")
|
issues.append("EMAIL_PASSWORD is not configured")
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def validate_auth_config():
|
def validate_auth_config():
|
||||||
"""Validates authentication configuration settings"""
|
"""Validates authentication configuration settings"""
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# If auth is enabled, check for required settings
|
# If auth is enabled, check for required settings
|
||||||
if getattr(settings, 'auth_enabled', False):
|
if getattr(settings, "auth_enabled", False):
|
||||||
# Check for session secret
|
# Check for session secret
|
||||||
if not getattr(settings, 'session_secret', None):
|
if not getattr(settings, "session_secret", None):
|
||||||
issues.append("SESSION_SECRET is not configured but AUTH_ENABLED is True")
|
issues.append("SESSION_SECRET is not configured but AUTH_ENABLED is True")
|
||||||
elif len(getattr(settings, 'session_secret', '')) < 32:
|
elif len(getattr(settings, "session_secret", "")) < 32:
|
||||||
issues.append("SESSION_SECRET must be at least 32 characters long")
|
issues.append("SESSION_SECRET must be at least 32 characters long")
|
||||||
|
|
||||||
# Check if using simple authentication or OIDC
|
# Check if using simple authentication or OIDC
|
||||||
using_simple_auth = bool(getattr(settings, 'admin_username', None) and
|
using_simple_auth = bool(
|
||||||
getattr(settings, 'admin_password', None))
|
getattr(settings, "admin_username", None) and getattr(settings, "admin_password", None)
|
||||||
|
)
|
||||||
using_oidc = bool(getattr(settings, 'authentik_client_id', None) and
|
|
||||||
getattr(settings, 'authentik_client_secret', None) and
|
using_oidc = bool(
|
||||||
getattr(settings, 'authentik_config_url', None))
|
getattr(settings, "authentik_client_id", None)
|
||||||
|
and getattr(settings, "authentik_client_secret", None)
|
||||||
|
and getattr(settings, "authentik_config_url", None)
|
||||||
|
)
|
||||||
|
|
||||||
if not using_simple_auth and not using_oidc:
|
if not using_simple_auth and not using_oidc:
|
||||||
issues.append("Neither simple authentication nor OIDC are properly configured")
|
issues.append("Neither simple authentication nor OIDC are properly configured")
|
||||||
|
|
||||||
# If using OIDC, check for provider name
|
# If using OIDC, check for provider name
|
||||||
if using_oidc and not getattr(settings, 'oauth_provider_name', None):
|
if using_oidc and not getattr(settings, "oauth_provider_name", None):
|
||||||
issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled")
|
issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled")
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def validate_storage_configs():
|
def validate_storage_configs():
|
||||||
"""Validates configuration for all storage providers"""
|
"""Validates configuration for all storage providers"""
|
||||||
issues = {}
|
issues = {}
|
||||||
|
|
||||||
# Validate Dropbox config
|
# Validate Dropbox config
|
||||||
dropbox_issues = []
|
dropbox_issues = []
|
||||||
if not (getattr(settings, 'dropbox_app_key', None) and
|
if not (
|
||||||
getattr(settings, 'dropbox_app_secret', None) and
|
getattr(settings, "dropbox_app_key", None)
|
||||||
getattr(settings, 'dropbox_refresh_token', None)):
|
and getattr(settings, "dropbox_app_secret", None)
|
||||||
|
and getattr(settings, "dropbox_refresh_token", None)
|
||||||
|
):
|
||||||
dropbox_issues.append("Dropbox credentials are not fully configured")
|
dropbox_issues.append("Dropbox credentials are not fully configured")
|
||||||
issues['dropbox'] = dropbox_issues
|
issues["dropbox"] = dropbox_issues
|
||||||
|
|
||||||
# Validate Nextcloud config
|
# Validate Nextcloud config
|
||||||
nextcloud_issues = []
|
nextcloud_issues = []
|
||||||
if not (getattr(settings, 'nextcloud_upload_url', None) and
|
if not (
|
||||||
getattr(settings, 'nextcloud_username', None) and
|
getattr(settings, "nextcloud_upload_url", None)
|
||||||
getattr(settings, 'nextcloud_password', None)):
|
and getattr(settings, "nextcloud_username", None)
|
||||||
|
and getattr(settings, "nextcloud_password", None)
|
||||||
|
):
|
||||||
nextcloud_issues.append("Nextcloud credentials are not fully configured")
|
nextcloud_issues.append("Nextcloud credentials are not fully configured")
|
||||||
issues['nextcloud'] = nextcloud_issues
|
issues["nextcloud"] = nextcloud_issues
|
||||||
|
|
||||||
# Validate SFTP config
|
# Validate SFTP config
|
||||||
sftp_issues = []
|
sftp_issues = []
|
||||||
if not getattr(settings, 'sftp_host', None):
|
if not getattr(settings, "sftp_host", None):
|
||||||
sftp_issues.append("SFTP_HOST is not configured")
|
sftp_issues.append("SFTP_HOST is not configured")
|
||||||
|
|
||||||
sftp_key_path = getattr(settings, 'sftp_private_key', None)
|
sftp_key_path = getattr(settings, "sftp_private_key", None)
|
||||||
if sftp_key_path and not os.path.exists(sftp_key_path):
|
if sftp_key_path and not os.path.exists(sftp_key_path):
|
||||||
sftp_issues.append(f"SFTP_KEY_PATH file not found: {sftp_key_path}")
|
sftp_issues.append(f"SFTP_KEY_PATH file not found: {sftp_key_path}")
|
||||||
|
|
||||||
if not sftp_key_path and not getattr(settings, 'sftp_password', None):
|
if not sftp_key_path and not getattr(settings, "sftp_password", None):
|
||||||
sftp_issues.append("Neither SFTP_KEY_PATH nor SFTP_PASSWORD is configured")
|
sftp_issues.append("Neither SFTP_KEY_PATH nor SFTP_PASSWORD is configured")
|
||||||
|
|
||||||
issues['sftp'] = sftp_issues
|
issues["sftp"] = sftp_issues
|
||||||
|
|
||||||
# Validate Email sending
|
# Validate Email sending
|
||||||
email_issues = []
|
email_issues = []
|
||||||
if not getattr(settings, 'email_host', None):
|
if not getattr(settings, "email_host", None):
|
||||||
email_issues.append("EMAIL_HOST is not configured")
|
email_issues.append("EMAIL_HOST is not configured")
|
||||||
if not getattr(settings, 'email_default_recipient', None):
|
if not getattr(settings, "email_default_recipient", None):
|
||||||
email_issues.append("EMAIL_DEFAULT_RECIPIENT is not configured")
|
email_issues.append("EMAIL_DEFAULT_RECIPIENT is not configured")
|
||||||
issues['email'] = email_issues
|
issues["email"] = email_issues
|
||||||
|
|
||||||
# Validate S3
|
# Validate S3
|
||||||
s3_issues = []
|
s3_issues = []
|
||||||
if not getattr(settings, 's3_bucket_name', None):
|
if not getattr(settings, "s3_bucket_name", None):
|
||||||
s3_issues.append("S3_BUCKET_NAME is not configured")
|
s3_issues.append("S3_BUCKET_NAME is not configured")
|
||||||
if not (getattr(settings, 'aws_access_key_id', None) and
|
if not (getattr(settings, "aws_access_key_id", None) and getattr(settings, "aws_secret_access_key", None)):
|
||||||
getattr(settings, 'aws_secret_access_key', None)):
|
|
||||||
s3_issues.append("AWS credentials are not configured")
|
s3_issues.append("AWS credentials are not configured")
|
||||||
issues['s3'] = s3_issues
|
issues["s3"] = s3_issues
|
||||||
|
|
||||||
# Validate FTP
|
# Validate FTP
|
||||||
ftp_issues = []
|
ftp_issues = []
|
||||||
if not getattr(settings, 'ftp_host', None):
|
if not getattr(settings, "ftp_host", None):
|
||||||
ftp_issues.append("FTP_HOST is not configured")
|
ftp_issues.append("FTP_HOST is not configured")
|
||||||
if not getattr(settings, 'ftp_username', None):
|
if not getattr(settings, "ftp_username", None):
|
||||||
ftp_issues.append("FTP_USERNAME is not configured")
|
ftp_issues.append("FTP_USERNAME is not configured")
|
||||||
if not getattr(settings, 'ftp_password', None):
|
if not getattr(settings, "ftp_password", None):
|
||||||
ftp_issues.append("FTP_PASSWORD is not configured")
|
ftp_issues.append("FTP_PASSWORD is not configured")
|
||||||
issues['ftp'] = ftp_issues
|
issues["ftp"] = ftp_issues
|
||||||
|
|
||||||
# Validate WebDAV
|
# Validate WebDAV
|
||||||
webdav_issues = []
|
webdav_issues = []
|
||||||
if not getattr(settings, 'webdav_url', None):
|
if not getattr(settings, "webdav_url", None):
|
||||||
webdav_issues.append("WEBDAV_URL is not configured")
|
webdav_issues.append("WEBDAV_URL is not configured")
|
||||||
if not getattr(settings, 'webdav_username', None):
|
if not getattr(settings, "webdav_username", None):
|
||||||
webdav_issues.append("WEBDAV_USERNAME is not configured")
|
webdav_issues.append("WEBDAV_USERNAME is not configured")
|
||||||
if not getattr(settings, 'webdav_password', None):
|
if not getattr(settings, "webdav_password", None):
|
||||||
webdav_issues.append("WEBDAV_PASSWORD is not configured")
|
webdav_issues.append("WEBDAV_PASSWORD is not configured")
|
||||||
issues['webdav'] = webdav_issues
|
issues["webdav"] = webdav_issues
|
||||||
|
|
||||||
# Validate Google Drive
|
# Validate Google Drive
|
||||||
gdrive_issues = []
|
gdrive_issues = []
|
||||||
if not getattr(settings, 'google_drive_credentials_json', None):
|
if not getattr(settings, "google_drive_credentials_json", None):
|
||||||
gdrive_issues.append("GOOGLE_DRIVE_CREDENTIALS_JSON is not configured")
|
gdrive_issues.append("GOOGLE_DRIVE_CREDENTIALS_JSON is not configured")
|
||||||
if not getattr(settings, 'google_drive_folder_id', None):
|
if not getattr(settings, "google_drive_folder_id", None):
|
||||||
gdrive_issues.append("GOOGLE_DRIVE_FOLDER_ID is not configured")
|
gdrive_issues.append("GOOGLE_DRIVE_FOLDER_ID is not configured")
|
||||||
issues['google_drive'] = gdrive_issues
|
issues["google_drive"] = gdrive_issues
|
||||||
|
|
||||||
# Validate Paperless
|
# Validate Paperless
|
||||||
paperless_issues = []
|
paperless_issues = []
|
||||||
if not getattr(settings, 'paperless_host', None):
|
if not getattr(settings, "paperless_host", None):
|
||||||
paperless_issues.append("PAPERLESS_HOST is not configured")
|
paperless_issues.append("PAPERLESS_HOST is not configured")
|
||||||
if not getattr(settings, 'paperless_ngx_api_token', None):
|
if not getattr(settings, "paperless_ngx_api_token", None):
|
||||||
paperless_issues.append("PAPERLESS_NGX_API_TOKEN is not configured")
|
paperless_issues.append("PAPERLESS_NGX_API_TOKEN is not configured")
|
||||||
issues['paperless'] = paperless_issues
|
issues["paperless"] = paperless_issues
|
||||||
|
|
||||||
# Validate OneDrive
|
# Validate OneDrive
|
||||||
onedrive_issues = []
|
onedrive_issues = []
|
||||||
if not (getattr(settings, 'onedrive_client_id', None) and
|
if not (
|
||||||
getattr(settings, 'onedrive_client_secret', None) and
|
getattr(settings, "onedrive_client_id", None)
|
||||||
getattr(settings, 'onedrive_refresh_token', None)):
|
and getattr(settings, "onedrive_client_secret", None)
|
||||||
|
and getattr(settings, "onedrive_refresh_token", None)
|
||||||
|
):
|
||||||
onedrive_issues.append("OneDrive credentials are not fully configured")
|
onedrive_issues.append("OneDrive credentials are not fully configured")
|
||||||
issues['onedrive'] = onedrive_issues
|
issues["onedrive"] = onedrive_issues
|
||||||
|
|
||||||
# Validate Uptime Kuma
|
# Validate Uptime Kuma
|
||||||
uptime_kuma_issues = []
|
uptime_kuma_issues = []
|
||||||
if not getattr(settings, 'uptime_kuma_url', None):
|
if not getattr(settings, "uptime_kuma_url", None):
|
||||||
uptime_kuma_issues.append("UPTIME_KUMA_URL is not configured")
|
uptime_kuma_issues.append("UPTIME_KUMA_URL is not configured")
|
||||||
issues['uptime_kuma'] = uptime_kuma_issues
|
issues["uptime_kuma"] = uptime_kuma_issues
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def validate_notification_config():
|
def validate_notification_config():
|
||||||
"""Check notification configuration"""
|
"""Check notification configuration"""
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# Check if any notification URLs are configured
|
# Check if any notification URLs are configured
|
||||||
if not getattr(settings, 'notification_urls', None):
|
if not getattr(settings, "notification_urls", None):
|
||||||
issues.append("No notification URLs configured")
|
issues.append("No notification URLs configured")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
# Try initializing Apprise to validate URLs
|
# Try initializing Apprise to validate URLs
|
||||||
import apprise
|
import apprise
|
||||||
|
|
||||||
a = apprise.Apprise()
|
a = apprise.Apprise()
|
||||||
|
|
||||||
for url in settings.notification_urls:
|
for url in settings.notification_urls:
|
||||||
try:
|
try:
|
||||||
if not a.add(url):
|
if not a.add(url):
|
||||||
issues.append(f"Invalid notification URL format: {url}")
|
issues.append(f"Invalid notification URL format: {url}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
issues.append(f"Error with notification URL: {str(e)}")
|
issues.append(f"Error with notification URL: {str(e)}")
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
issues.append("Apprise module not installed")
|
issues.append("Apprise module not installed")
|
||||||
|
|
||||||
if not issues:
|
if not issues:
|
||||||
logger.info("Notification configuration valid")
|
logger.info("Notification configuration valid")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Notification configuration issues: {', '.join(issues)}")
|
logger.warning(f"Notification configuration issues: {', '.join(issues)}")
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def check_all_configs():
|
def check_all_configs():
|
||||||
"""Run all configuration validations and log results"""
|
"""Run all configuration validations and log results"""
|
||||||
from app.utils.config_validator.settings_display import dump_all_settings
|
from app.utils.config_validator.settings_display import dump_all_settings
|
||||||
|
|
||||||
logger.info("Validating application configuration...")
|
logger.info("Validating application configuration...")
|
||||||
|
|
||||||
# Check if debug is enabled and dump all settings if it is
|
# Check if debug is enabled and dump all settings if it is
|
||||||
if hasattr(settings, 'debug') and settings.debug:
|
if hasattr(settings, "debug") and settings.debug:
|
||||||
dump_all_settings()
|
dump_all_settings()
|
||||||
|
|
||||||
# Check auth config
|
# Check auth config
|
||||||
auth_issues = validate_auth_config()
|
auth_issues = validate_auth_config()
|
||||||
if auth_issues:
|
if auth_issues:
|
||||||
logger.warning(f"Authentication configuration issues: {', '.join(auth_issues)}")
|
logger.warning(f"Authentication configuration issues: {', '.join(auth_issues)}")
|
||||||
else:
|
else:
|
||||||
logger.info("Authentication configuration OK")
|
logger.info("Authentication configuration OK")
|
||||||
|
|
||||||
# Check email config
|
# Check email config
|
||||||
email_issues = validate_email_config()
|
email_issues = validate_email_config()
|
||||||
if email_issues:
|
if email_issues:
|
||||||
logger.warning(f"Email configuration issues: {', '.join(email_issues)}")
|
logger.warning(f"Email configuration issues: {', '.join(email_issues)}")
|
||||||
else:
|
else:
|
||||||
logger.info("Email configuration OK")
|
logger.info("Email configuration OK")
|
||||||
|
|
||||||
# Check storage configs
|
# Check storage configs
|
||||||
storage_issues = validate_storage_configs()
|
storage_issues = validate_storage_configs()
|
||||||
for provider, issues in storage_issues.items():
|
for provider, issues in storage_issues.items():
|
||||||
@@ -226,18 +241,13 @@ def check_all_configs():
|
|||||||
logger.warning(f"{provider.capitalize()} configuration issues: {', '.join(issues)}")
|
logger.warning(f"{provider.capitalize()} configuration issues: {', '.join(issues)}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"{provider.capitalize()} configuration OK")
|
logger.info(f"{provider.capitalize()} configuration OK")
|
||||||
|
|
||||||
# Check notification configuration
|
# Check notification configuration
|
||||||
notification_issues = validate_notification_config()
|
notification_issues = validate_notification_config()
|
||||||
if notification_issues:
|
if notification_issues:
|
||||||
logger.warning(f"Notification configuration issues: {', '.join(notification_issues)}")
|
logger.warning(f"Notification configuration issues: {', '.join(notification_issues)}")
|
||||||
else:
|
else:
|
||||||
logger.info("Notification configuration OK")
|
logger.info("Notification configuration OK")
|
||||||
|
|
||||||
# Return all identified issues
|
# Return all identified issues
|
||||||
return {
|
return {"auth": auth_issues, "email": email_issues, "storage": storage_issues, "notification": notification_issues}
|
||||||
'auth': auth_issues,
|
|
||||||
'email': email_issues,
|
|
||||||
'storage': storage_issues,
|
|
||||||
'notification': notification_issues
|
|
||||||
}
|
|
||||||
|
|||||||
+29
-28
@@ -5,9 +5,9 @@ Uses Fernet symmetric encryption with a key derived from SESSION_SECRET.
|
|||||||
This provides encryption at rest for sensitive configuration values.
|
This provides encryption at rest for sensitive configuration values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,33 +19,34 @@ _cipher_suite = None
|
|||||||
def _get_cipher_suite():
|
def _get_cipher_suite():
|
||||||
"""
|
"""
|
||||||
Get or create the Fernet cipher suite for encryption/decryption.
|
Get or create the Fernet cipher suite for encryption/decryption.
|
||||||
|
|
||||||
The encryption key is derived from SESSION_SECRET to ensure:
|
The encryption key is derived from SESSION_SECRET to ensure:
|
||||||
1. Settings are encrypted at rest in the database
|
1. Settings are encrypted at rest in the database
|
||||||
2. The same key is used across app restarts
|
2. The same key is used across app restarts
|
||||||
3. No additional secret management needed
|
3. No additional secret management needed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Fernet cipher suite instance
|
Fernet cipher suite instance
|
||||||
"""
|
"""
|
||||||
global _cipher_suite
|
global _cipher_suite
|
||||||
|
|
||||||
if _cipher_suite is None:
|
if _cipher_suite is None:
|
||||||
try:
|
try:
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Derive a Fernet-compatible key from SESSION_SECRET
|
# Derive a Fernet-compatible key from SESSION_SECRET
|
||||||
# Fernet requires a 32-byte base64-encoded key
|
# Fernet requires a 32-byte base64-encoded key
|
||||||
secret = settings.session_secret.encode('utf-8')
|
secret = settings.session_secret.encode("utf-8")
|
||||||
|
|
||||||
# Use SHA256 to get exactly 32 bytes, then base64 encode
|
# Use SHA256 to get exactly 32 bytes, then base64 encode
|
||||||
key_bytes = hashlib.sha256(secret).digest()
|
key_bytes = hashlib.sha256(secret).digest()
|
||||||
fernet_key = base64.urlsafe_b64encode(key_bytes)
|
fernet_key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
|
||||||
_cipher_suite = Fernet(fernet_key)
|
_cipher_suite = Fernet(fernet_key)
|
||||||
logger.debug("Encryption cipher suite initialized")
|
logger.debug("Encryption cipher suite initialized")
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cryptography library not installed. "
|
"cryptography library not installed. "
|
||||||
@@ -56,34 +57,34 @@ def _get_cipher_suite():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize encryption: {e}")
|
logger.error(f"Failed to initialize encryption: {e}")
|
||||||
_cipher_suite = None
|
_cipher_suite = None
|
||||||
|
|
||||||
return _cipher_suite
|
return _cipher_suite
|
||||||
|
|
||||||
|
|
||||||
def encrypt_value(plaintext: Optional[str]) -> Optional[str]:
|
def encrypt_value(plaintext: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Encrypt a plaintext value for storage in the database.
|
Encrypt a plaintext value for storage in the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plaintext: The value to encrypt (or None)
|
plaintext: The value to encrypt (or None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Encrypted value as base64 string, or plaintext if encryption unavailable
|
Encrypted value as base64 string, or plaintext if encryption unavailable
|
||||||
"""
|
"""
|
||||||
if plaintext is None or plaintext == "":
|
if plaintext is None or plaintext == "":
|
||||||
return plaintext
|
return plaintext
|
||||||
|
|
||||||
cipher = _get_cipher_suite()
|
cipher = _get_cipher_suite()
|
||||||
|
|
||||||
if cipher is None:
|
if cipher is None:
|
||||||
# Encryption not available, store in plaintext with warning
|
# Encryption not available, store in plaintext with warning
|
||||||
logger.warning("Storing sensitive value in plaintext (encryption unavailable)")
|
logger.warning("Storing sensitive value in plaintext (encryption unavailable)")
|
||||||
return plaintext
|
return plaintext
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encrypted_bytes = cipher.encrypt(plaintext.encode('utf-8'))
|
encrypted_bytes = cipher.encrypt(plaintext.encode("utf-8"))
|
||||||
# Prefix with "enc:" to identify encrypted values
|
# Prefix with "enc:" to identify encrypted values
|
||||||
return "enc:" + encrypted_bytes.decode('utf-8')
|
return "enc:" + encrypted_bytes.decode("utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Encryption failed: {e}")
|
logger.error(f"Encryption failed: {e}")
|
||||||
# Fall back to plaintext
|
# Fall back to plaintext
|
||||||
@@ -93,32 +94,32 @@ def encrypt_value(plaintext: Optional[str]) -> Optional[str]:
|
|||||||
def decrypt_value(ciphertext: Optional[str]) -> Optional[str]:
|
def decrypt_value(ciphertext: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Decrypt a value from the database.
|
Decrypt a value from the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ciphertext: The encrypted value (or plaintext if not encrypted)
|
ciphertext: The encrypted value (or plaintext if not encrypted)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decrypted plaintext value
|
Decrypted plaintext value
|
||||||
"""
|
"""
|
||||||
if ciphertext is None or ciphertext == "":
|
if ciphertext is None or ciphertext == "":
|
||||||
return ciphertext
|
return ciphertext
|
||||||
|
|
||||||
# Check if value is encrypted (has "enc:" prefix)
|
# Check if value is encrypted (has "enc:" prefix)
|
||||||
if not ciphertext.startswith("enc:"):
|
if not ciphertext.startswith("enc:"):
|
||||||
# Not encrypted, return as-is
|
# Not encrypted, return as-is
|
||||||
return ciphertext
|
return ciphertext
|
||||||
|
|
||||||
cipher = _get_cipher_suite()
|
cipher = _get_cipher_suite()
|
||||||
|
|
||||||
if cipher is None:
|
if cipher is None:
|
||||||
logger.error("Cannot decrypt value: encryption not available")
|
logger.error("Cannot decrypt value: encryption not available")
|
||||||
return "[ENCRYPTED - Cannot decrypt]"
|
return "[ENCRYPTED - Cannot decrypt]"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Remove "enc:" prefix and decrypt
|
# Remove "enc:" prefix and decrypt
|
||||||
encrypted_bytes = ciphertext[4:].encode('utf-8')
|
encrypted_bytes = ciphertext[4:].encode("utf-8")
|
||||||
plaintext_bytes = cipher.decrypt(encrypted_bytes)
|
plaintext_bytes = cipher.decrypt(encrypted_bytes)
|
||||||
return plaintext_bytes.decode('utf-8')
|
return plaintext_bytes.decode("utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Decryption failed: {e}")
|
logger.error(f"Decryption failed: {e}")
|
||||||
return "[DECRYPTION FAILED]"
|
return "[DECRYPTION FAILED]"
|
||||||
@@ -127,10 +128,10 @@ def decrypt_value(ciphertext: Optional[str]) -> Optional[str]:
|
|||||||
def is_encrypted(value: Optional[str]) -> bool:
|
def is_encrypted(value: Optional[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a value is encrypted.
|
Check if a value is encrypted.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: The value to check
|
value: The value to check
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the value is encrypted, False otherwise
|
True if the value is encrypted, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -140,7 +141,7 @@ def is_encrypted(value: Optional[str]) -> bool:
|
|||||||
def is_encryption_available() -> bool:
|
def is_encryption_available() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if encryption is available.
|
Check if encryption is available.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if cryptography library is installed and encryption is working
|
True if cryptography library is installed and encryption is working
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
def hash_file(filepath, chunk_size=65536):
|
def hash_file(filepath, chunk_size=65536):
|
||||||
"""
|
"""
|
||||||
Returns the SHA-256 hash of the file at 'filepath'.
|
Returns the SHA-256 hash of the file at 'filepath'.
|
||||||
|
|||||||
+29
-33
@@ -1,89 +1,90 @@
|
|||||||
"""
|
"""
|
||||||
Utility functions for file processing status determination.
|
Utility functions for file processing status determination.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ProcessingLog
|
from app.models import ProcessingLog
|
||||||
|
|
||||||
|
|
||||||
def get_file_processing_status(db: Session, file_id: int) -> Dict:
|
def get_file_processing_status(db: Session, file_id: int) -> Dict:
|
||||||
"""
|
"""
|
||||||
Get the processing status for a file by checking its processing logs.
|
Get the processing status for a file by checking its processing logs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
file_id: ID of the file
|
file_id: ID of the file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with status, last_step, and has_errors
|
dict with status, last_step, and has_errors
|
||||||
"""
|
"""
|
||||||
# Get all logs for this file
|
# Get all logs for this file
|
||||||
logs = db.query(ProcessingLog).filter(
|
logs = (
|
||||||
ProcessingLog.file_id == file_id
|
db.query(ProcessingLog).filter(ProcessingLog.file_id == file_id).order_by(ProcessingLog.timestamp.desc()).all()
|
||||||
).order_by(ProcessingLog.timestamp.desc()).all()
|
)
|
||||||
|
|
||||||
return _compute_status_from_logs(logs)
|
return _compute_status_from_logs(logs)
|
||||||
|
|
||||||
|
|
||||||
def get_files_processing_status(db: Session, file_ids: List[int]) -> Dict[int, Dict]:
|
def get_files_processing_status(db: Session, file_ids: List[int]) -> Dict[int, Dict]:
|
||||||
"""
|
"""
|
||||||
Get processing status for multiple files efficiently.
|
Get processing status for multiple files efficiently.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
file_ids: List of file IDs
|
file_ids: List of file IDs
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict mapping file_id to status dict
|
dict mapping file_id to status dict
|
||||||
"""
|
"""
|
||||||
# Get all logs for these files in one query
|
# Get all logs for these files in one query
|
||||||
logs = db.query(ProcessingLog).filter(
|
logs = (
|
||||||
ProcessingLog.file_id.in_(file_ids)
|
db.query(ProcessingLog)
|
||||||
).order_by(ProcessingLog.file_id, ProcessingLog.timestamp.desc()).all()
|
.filter(ProcessingLog.file_id.in_(file_ids))
|
||||||
|
.order_by(ProcessingLog.file_id, ProcessingLog.timestamp.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
# Group logs by file_id
|
# Group logs by file_id
|
||||||
logs_by_file = {}
|
logs_by_file = {}
|
||||||
for log in logs:
|
for log in logs:
|
||||||
if log.file_id not in logs_by_file:
|
if log.file_id not in logs_by_file:
|
||||||
logs_by_file[log.file_id] = []
|
logs_by_file[log.file_id] = []
|
||||||
logs_by_file[log.file_id].append(log)
|
logs_by_file[log.file_id].append(log)
|
||||||
|
|
||||||
# Compute status for each file
|
# Compute status for each file
|
||||||
result = {}
|
result = {}
|
||||||
for file_id in file_ids:
|
for file_id in file_ids:
|
||||||
file_logs = logs_by_file.get(file_id, [])
|
file_logs = logs_by_file.get(file_id, [])
|
||||||
result[file_id] = _compute_status_from_logs(file_logs)
|
result[file_id] = _compute_status_from_logs(file_logs)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _compute_status_from_logs(logs: List[ProcessingLog]) -> Dict:
|
def _compute_status_from_logs(logs: List[ProcessingLog]) -> Dict:
|
||||||
"""
|
"""
|
||||||
Compute processing status from a list of processing logs.
|
Compute processing status from a list of processing logs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
logs: List of ProcessingLog objects (should be ordered by timestamp desc)
|
logs: List of ProcessingLog objects (should be ordered by timestamp desc)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with status, last_step, has_errors, and total_steps
|
dict with status, last_step, has_errors, and total_steps
|
||||||
"""
|
"""
|
||||||
if not logs:
|
if not logs:
|
||||||
return {
|
return {"status": "pending", "last_step": None, "has_errors": False, "total_steps": 0}
|
||||||
"status": "pending",
|
|
||||||
"last_step": None,
|
|
||||||
"has_errors": False,
|
|
||||||
"total_steps": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for failures
|
# Check for failures
|
||||||
has_errors = any(log.status == "failure" for log in logs)
|
has_errors = any(log.status == "failure" for log in logs)
|
||||||
|
|
||||||
# Check if any in progress
|
# Check if any in progress
|
||||||
in_progress = any(log.status == "in_progress" for log in logs)
|
in_progress = any(log.status == "in_progress" for log in logs)
|
||||||
|
|
||||||
# Get the latest log
|
# Get the latest log
|
||||||
latest_log = logs[0]
|
latest_log = logs[0]
|
||||||
|
|
||||||
# Determine overall status
|
# Determine overall status
|
||||||
if has_errors:
|
if has_errors:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
@@ -93,10 +94,5 @@ def _compute_status_from_logs(logs: List[ProcessingLog]) -> Dict:
|
|||||||
status = "completed"
|
status = "completed"
|
||||||
else:
|
else:
|
||||||
status = "pending"
|
status = "pending"
|
||||||
|
|
||||||
return {
|
return {"status": status, "last_step": latest_log.step_name, "has_errors": has_errors, "total_steps": len(logs)}
|
||||||
"status": status,
|
|
||||||
"last_step": latest_log.step_name,
|
|
||||||
"has_errors": has_errors,
|
|
||||||
"total_steps": len(logs)
|
|
||||||
}
|
|
||||||
|
|||||||
+36
-33
@@ -1,55 +1,56 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_unique_filename(original_path, check_exists_func=None):
|
def get_unique_filename(original_path, check_exists_func=None):
|
||||||
"""
|
"""
|
||||||
Generates a unique filename by appending a timestamp or counter when a collision occurs.
|
Generates a unique filename by appending a timestamp or counter when a collision occurs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
original_path (str): The original file path
|
original_path (str): The original file path
|
||||||
check_exists_func (callable): Function that checks if file exists in target system.
|
check_exists_func (callable): Function that checks if file exists in target system.
|
||||||
Takes a path string and returns True if exists, False otherwise.
|
Takes a path string and returns True if exists, False otherwise.
|
||||||
If None, will use local filesystem check.
|
If None, will use local filesystem check.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A unique filename that doesn't collide with existing files
|
str: A unique filename that doesn't collide with existing files
|
||||||
"""
|
"""
|
||||||
if check_exists_func is None:
|
if check_exists_func is None:
|
||||||
check_exists_func = os.path.exists
|
check_exists_func = os.path.exists
|
||||||
|
|
||||||
path = Path(original_path)
|
path = Path(original_path)
|
||||||
directory = str(path.parent)
|
directory = str(path.parent)
|
||||||
filename = path.name
|
filename = path.name
|
||||||
name, ext = os.path.splitext(filename)
|
name, ext = os.path.splitext(filename)
|
||||||
|
|
||||||
# If file doesn't exist, return the original
|
# If file doesn't exist, return the original
|
||||||
if not check_exists_func(original_path):
|
if not check_exists_func(original_path):
|
||||||
return original_path
|
return original_path
|
||||||
|
|
||||||
# Try timestamp-based suffix first (more user-friendly)
|
# Try timestamp-based suffix first (more user-friendly)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
new_filename = f"{name}_{timestamp}{ext}"
|
new_filename = f"{name}_{timestamp}{ext}"
|
||||||
new_path = os.path.join(directory, new_filename)
|
new_path = os.path.join(directory, new_filename)
|
||||||
|
|
||||||
if not check_exists_func(new_path):
|
if not check_exists_func(new_path):
|
||||||
logger.info(f"Renamed '{filename}' to '{new_filename}' to avoid collision")
|
logger.info(f"Renamed '{filename}' to '{new_filename}' to avoid collision")
|
||||||
return new_path
|
return new_path
|
||||||
|
|
||||||
# If timestamp-based name also exists, try random UUID
|
# If timestamp-based name also exists, try random UUID
|
||||||
uuid_str = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for brevity
|
uuid_str = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for brevity
|
||||||
new_filename = f"{name}_{uuid_str}{ext}"
|
new_filename = f"{name}_{uuid_str}{ext}"
|
||||||
new_path = os.path.join(directory, new_filename)
|
new_path = os.path.join(directory, new_filename)
|
||||||
|
|
||||||
if not check_exists_func(new_path):
|
if not check_exists_func(new_path):
|
||||||
logger.info(f"Renamed '{filename}' to '{new_filename}' using UUID to avoid collision")
|
logger.info(f"Renamed '{filename}' to '{new_filename}' using UUID to avoid collision")
|
||||||
return new_path
|
return new_path
|
||||||
|
|
||||||
# If that still exists (very unlikely), use incremental numbering
|
# If that still exists (very unlikely), use incremental numbering
|
||||||
counter = 1
|
counter = 1
|
||||||
while counter < 1000: # Limit to avoid infinite loop
|
while counter < 1000: # Limit to avoid infinite loop
|
||||||
@@ -59,77 +60,79 @@ def get_unique_filename(original_path, check_exists_func=None):
|
|||||||
logger.info(f"Renamed '{filename}' to '{new_filename}' using counter to avoid collision")
|
logger.info(f"Renamed '{filename}' to '{new_filename}' using counter to avoid collision")
|
||||||
return new_path
|
return new_path
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
# If we got here, something is weird - just use a full UUID
|
# If we got here, something is weird - just use a full UUID
|
||||||
new_filename = f"{name}_{str(uuid.uuid4())}{ext}"
|
new_filename = f"{name}_{str(uuid.uuid4())}{ext}"
|
||||||
new_path = os.path.join(directory, new_filename)
|
new_path = os.path.join(directory, new_filename)
|
||||||
logger.warning(f"Had to use full UUID to rename '{filename}' to '{new_filename}'")
|
logger.warning(f"Had to use full UUID to rename '{filename}' to '{new_filename}'")
|
||||||
|
|
||||||
return new_path
|
return new_path
|
||||||
|
|
||||||
|
|
||||||
def sanitize_filename(filename):
|
def sanitize_filename(filename):
|
||||||
"""
|
"""
|
||||||
Sanitize a filename to ensure it's valid across different file systems.
|
Sanitize a filename to ensure it's valid across different file systems.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename (str): The filename to sanitize
|
filename (str): The filename to sanitize
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A sanitized filename
|
str: A sanitized filename
|
||||||
"""
|
"""
|
||||||
# Replace characters that are problematic in various filesystems
|
# Replace characters that are problematic in various filesystems
|
||||||
# Keep only alphanumeric, dash, underscore, period, and space
|
# Keep only alphanumeric, dash, underscore, period, and space
|
||||||
sanitized = re.sub(r'[^\w\-\. ]', '_', filename)
|
sanitized = re.sub(r"[^\w\-\. ]", "_", filename)
|
||||||
|
|
||||||
# Replace multiple spaces/underscores with single ones
|
# Replace multiple spaces/underscores with single ones
|
||||||
sanitized = re.sub(r'__+', '_', sanitized)
|
sanitized = re.sub(r"__+", "_", sanitized)
|
||||||
sanitized = re.sub(r' +', ' ', sanitized)
|
sanitized = re.sub(r" +", " ", sanitized)
|
||||||
|
|
||||||
# Trim leading/trailing spaces and periods which cause issues in Windows
|
# Trim leading/trailing spaces and periods which cause issues in Windows
|
||||||
sanitized = sanitized.strip('. ')
|
sanitized = sanitized.strip(". ")
|
||||||
|
|
||||||
# Ensure the filename isn't empty after sanitization
|
# Ensure the filename isn't empty after sanitization
|
||||||
if not sanitized or sanitized == '.':
|
if not sanitized or sanitized == ".":
|
||||||
sanitized = f"document_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
sanitized = f"document_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
def extract_remote_path(file_path, base_dir, remote_base=""):
|
def extract_remote_path(file_path, base_dir, remote_base=""):
|
||||||
"""
|
"""
|
||||||
Extract a remote path for a file by preserving its directory structure
|
Extract a remote path for a file by preserving its directory structure
|
||||||
relative to the base directory, but with a new remote base path.
|
relative to the base directory, but with a new remote base path.
|
||||||
|
|
||||||
Modified to skip 'processed' directory in the remote path.
|
Modified to skip 'processed' directory in the remote path.
|
||||||
"""
|
"""
|
||||||
# Normalize paths for consistent handling across platforms
|
# Normalize paths for consistent handling across platforms
|
||||||
file_path = os.path.normpath(file_path)
|
file_path = os.path.normpath(file_path)
|
||||||
base_dir = os.path.normpath(base_dir)
|
base_dir = os.path.normpath(base_dir)
|
||||||
|
|
||||||
# Get relative path from base directory
|
# Get relative path from base directory
|
||||||
if file_path.startswith(base_dir):
|
if file_path.startswith(base_dir):
|
||||||
rel_path = os.path.relpath(file_path, base_dir)
|
rel_path = os.path.relpath(file_path, base_dir)
|
||||||
else:
|
else:
|
||||||
# If not a subdirectory of base_dir, just use the filename
|
# If not a subdirectory of base_dir, just use the filename
|
||||||
rel_path = os.path.basename(file_path)
|
rel_path = os.path.basename(file_path)
|
||||||
|
|
||||||
# Skip 'processed' directory if it's in the path
|
# Skip 'processed' directory if it's in the path
|
||||||
path_parts = rel_path.split(os.sep)
|
path_parts = rel_path.split(os.sep)
|
||||||
if 'processed' in path_parts:
|
if "processed" in path_parts:
|
||||||
# Remove 'processed' from the path
|
# Remove 'processed' from the path
|
||||||
path_parts.remove('processed')
|
path_parts.remove("processed")
|
||||||
rel_path = os.path.join(*path_parts)
|
rel_path = os.path.join(*path_parts)
|
||||||
|
|
||||||
# Combine with remote base path
|
# Combine with remote base path
|
||||||
if remote_base:
|
if remote_base:
|
||||||
if remote_base.startswith('/'):
|
if remote_base.startswith("/"):
|
||||||
# Handle absolute path for services like Dropbox
|
# Handle absolute path for services like Dropbox
|
||||||
remote_path = os.path.join(remote_base[1:], rel_path)
|
remote_path = os.path.join(remote_base[1:], rel_path)
|
||||||
else:
|
else:
|
||||||
remote_path = os.path.join(remote_base, rel_path)
|
remote_path = os.path.join(remote_base, rel_path)
|
||||||
else:
|
else:
|
||||||
remote_path = rel_path
|
remote_path = rel_path
|
||||||
|
|
||||||
# Convert to forward slashes for compatibility with most cloud services
|
# Convert to forward slashes for compatibility with most cloud services
|
||||||
remote_path = remote_path.replace(os.sep, '/')
|
remote_path = remote_path.replace(os.sep, "/")
|
||||||
|
|
||||||
return remote_path
|
return remote_path
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import ProcessingLog
|
from app.models import ProcessingLog
|
||||||
|
|
||||||
|
|
||||||
def log_task_progress(task_id, step_name, status, message=None, file_id=None):
|
def log_task_progress(task_id, step_name, status, message=None, file_id=None):
|
||||||
"""
|
"""
|
||||||
Logs the progress of a Celery task to the database.
|
Logs the progress of a Celery task to the database.
|
||||||
|
|||||||
+55
-69
@@ -1,6 +1,7 @@
|
|||||||
import apprise
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Dict, Any, Union
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import apprise
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -9,13 +10,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# Global Apprise instance
|
# Global Apprise instance
|
||||||
_apprise = None
|
_apprise = None
|
||||||
|
|
||||||
|
|
||||||
def init_apprise() -> apprise.Apprise:
|
def init_apprise() -> apprise.Apprise:
|
||||||
"""Initialize the Apprise instance with configured notification services"""
|
"""Initialize the Apprise instance with configured notification services"""
|
||||||
global _apprise
|
global _apprise
|
||||||
|
|
||||||
if _apprise is None:
|
if _apprise is None:
|
||||||
_apprise = apprise.Apprise()
|
_apprise = apprise.Apprise()
|
||||||
|
|
||||||
# Add all configured notification services
|
# Add all configured notification services
|
||||||
if settings.notification_urls:
|
if settings.notification_urls:
|
||||||
for url in settings.notification_urls:
|
for url in settings.notification_urls:
|
||||||
@@ -26,31 +28,34 @@ def init_apprise() -> apprise.Apprise:
|
|||||||
logger.error(f"Failed to add notification service: {str(e)}")
|
logger.error(f"Failed to add notification service: {str(e)}")
|
||||||
else:
|
else:
|
||||||
logger.warning("No notification services configured")
|
logger.warning("No notification services configured")
|
||||||
|
|
||||||
return _apprise
|
return _apprise
|
||||||
|
|
||||||
|
|
||||||
def _mask_sensitive_url(url: str) -> str:
|
def _mask_sensitive_url(url: str) -> str:
|
||||||
"""Mask sensitive parts of notification URLs for logging"""
|
"""Mask sensitive parts of notification URLs for logging"""
|
||||||
# Simple masking for common URL formats with credentials
|
# Simple masking for common URL formats with credentials
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Match patterns like user:pass@host or token in URL parameters
|
# Match patterns like user:pass@host or token in URL parameters
|
||||||
masked = re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', url)
|
masked = re.sub(r"://([^:]+):([^@]+)@", r"://\1:****@", url)
|
||||||
masked = re.sub(r'(discord://)[^/]+/[^/]+', r'\1webhook_id/****', masked)
|
masked = re.sub(r"(discord://)[^/]+/[^/]+", r"\1webhook_id/****", masked)
|
||||||
masked = re.sub(r'(tgram://)[^/]+/[^/]+', r'\1bot_token/****', masked)
|
masked = re.sub(r"(tgram://)[^/]+/[^/]+", r"\1bot_token/****", masked)
|
||||||
masked = re.sub(r'([?&](token|key|api_key|password|secret)=)([^&]+)', r'\1****', masked)
|
masked = re.sub(r"([?&](token|key|api_key|password|secret)=)([^&]+)", r"\1****", masked)
|
||||||
return masked
|
return masked
|
||||||
|
|
||||||
|
|
||||||
def send_notification(
|
def send_notification(
|
||||||
title: str,
|
title: str,
|
||||||
message: str,
|
message: str,
|
||||||
notification_type: str = "info",
|
notification_type: str = "info",
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
attachments: Optional[List[str]] = None,
|
attachments: Optional[List[str]] = None,
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: Optional[Dict[str, Any]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Send a notification through all configured channels
|
Send a notification through all configured channels
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: The notification title
|
title: The notification title
|
||||||
message: The notification body message
|
message: The notification body message
|
||||||
@@ -58,17 +63,17 @@ def send_notification(
|
|||||||
tags: Optional list of tags for filtering notifications
|
tags: Optional list of tags for filtering notifications
|
||||||
attachments: Optional list of file paths to attach
|
attachments: Optional list of file paths to attach
|
||||||
data: Optional additional data for the notification
|
data: Optional additional data for the notification
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if notification was sent successfully to at least one service
|
bool: True if notification was sent successfully to at least one service
|
||||||
"""
|
"""
|
||||||
if not settings.notification_urls:
|
if not settings.notification_urls:
|
||||||
logger.debug(f"Notification not sent (no services configured): {title}")
|
logger.debug(f"Notification not sent (no services configured): {title}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
apprise_obj = init_apprise()
|
apprise_obj = init_apprise()
|
||||||
|
|
||||||
# Set notification type
|
# Set notification type
|
||||||
notify_type = apprise.NotifyType.INFO
|
notify_type = apprise.NotifyType.INFO
|
||||||
if notification_type == "success":
|
if notification_type == "success":
|
||||||
@@ -77,25 +82,20 @@ def send_notification(
|
|||||||
notify_type = apprise.NotifyType.WARNING
|
notify_type = apprise.NotifyType.WARNING
|
||||||
elif notification_type in ("failure", "error", "failed"):
|
elif notification_type in ("failure", "error", "failed"):
|
||||||
notify_type = apprise.NotifyType.FAILURE
|
notify_type = apprise.NotifyType.FAILURE
|
||||||
|
|
||||||
# Send the notification to each service individually for better error reporting
|
# Send the notification to each service individually for better error reporting
|
||||||
if not apprise_obj.servers: # Access servers as an attribute, not a method
|
if not apprise_obj.servers: # Access servers as an attribute, not a method
|
||||||
logger.warning("No notification servers available despite having URLs configured")
|
logger.warning("No notification servers available despite having URLs configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
total_services = len(apprise_obj.servers)
|
total_services = len(apprise_obj.servers)
|
||||||
successful_services = 0
|
successful_services = 0
|
||||||
|
|
||||||
for server in apprise_obj.servers: # Iterate through the list directly
|
for server in apprise_obj.servers: # Iterate through the list directly
|
||||||
try:
|
try:
|
||||||
service_name = str(server).split("://")[0] if "://" in str(server) else str(server)
|
service_name = str(server).split("://")[0] if "://" in str(server) else str(server)
|
||||||
service_result = server.notify(
|
service_result = server.notify(title=title, body=message, notify_type=notify_type, attach=attachments)
|
||||||
title=title,
|
|
||||||
body=message,
|
|
||||||
notify_type=notify_type,
|
|
||||||
attach=attachments
|
|
||||||
)
|
|
||||||
|
|
||||||
if service_result:
|
if service_result:
|
||||||
successful_services += 1
|
successful_services += 1
|
||||||
logger.debug(f"Notification sent via {service_name}")
|
logger.debug(f"Notification sent via {service_name}")
|
||||||
@@ -103,25 +103,26 @@ def send_notification(
|
|||||||
logger.warning(f"Failed to send notification via {service_name}")
|
logger.warning(f"Failed to send notification via {service_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending notification via {str(server)}: {str(e)}")
|
logger.error(f"Error sending notification via {str(server)}: {str(e)}")
|
||||||
|
|
||||||
overall_result = successful_services > 0
|
overall_result = successful_services > 0
|
||||||
|
|
||||||
if overall_result:
|
if overall_result:
|
||||||
logger.debug(f"Notification sent: '{title}' (successful: {successful_services}/{total_services})")
|
logger.debug(f"Notification sent: '{title}' (successful: {successful_services}/{total_services})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to send notification to ALL services: '{title}' (0/{total_services})")
|
logger.warning(f"Failed to send notification to ALL services: '{title}' (0/{total_services})")
|
||||||
|
|
||||||
return overall_result
|
return overall_result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error sending notification: {e}")
|
logger.exception(f"Error sending notification: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def notify_celery_failure(task_name: str, task_id: str, exc: Exception, args: list, kwargs: dict) -> bool:
|
def notify_celery_failure(task_name: str, task_id: str, exc: Exception, args: list, kwargs: dict) -> bool:
|
||||||
"""Send a notification about a failed Celery task"""
|
"""Send a notification about a failed Celery task"""
|
||||||
if not settings.notify_on_task_failure:
|
if not settings.notify_on_task_failure:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
title = f"Task Failed: {task_name}"
|
title = f"Task Failed: {task_name}"
|
||||||
message = f"""
|
message = f"""
|
||||||
Task {task_name} ({task_id}) failed with error:
|
Task {task_name} ({task_id}) failed with error:
|
||||||
@@ -131,17 +132,15 @@ Arguments: {args}
|
|||||||
Keyword arguments: {kwargs}
|
Keyword arguments: {kwargs}
|
||||||
"""
|
"""
|
||||||
return send_notification(
|
return send_notification(
|
||||||
title=title,
|
title=title, message=message, notification_type="failure", tags=["celery", "failure", task_name]
|
||||||
message=message,
|
|
||||||
notification_type="failure",
|
|
||||||
tags=["celery", "failure", task_name]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_credential_failure(service_name: str, error: str) -> bool:
|
def notify_credential_failure(service_name: str, error: str) -> bool:
|
||||||
"""Send a notification about a credential failure"""
|
"""Send a notification about a credential failure"""
|
||||||
if not settings.notify_on_credential_failure:
|
if not settings.notify_on_credential_failure:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
title = f"Credential Failure: {service_name}"
|
title = f"Credential Failure: {service_name}"
|
||||||
message = f"""
|
message = f"""
|
||||||
The credentials for {service_name} have failed:
|
The credentials for {service_name} have failed:
|
||||||
@@ -150,57 +149,47 @@ The credentials for {service_name} have failed:
|
|||||||
Please check and update the credentials in the system settings.
|
Please check and update the credentials in the system settings.
|
||||||
"""
|
"""
|
||||||
return send_notification(
|
return send_notification(
|
||||||
title=title,
|
title=title, message=message, notification_type="warning", tags=["credentials", "warning", service_name]
|
||||||
message=message,
|
|
||||||
notification_type="warning",
|
|
||||||
tags=["credentials", "warning", service_name]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_startup() -> bool:
|
def notify_startup() -> bool:
|
||||||
"""Send a notification that the application has started"""
|
"""Send a notification that the application has started"""
|
||||||
if not settings.notify_on_startup:
|
if not settings.notify_on_startup:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
title = f"DocuElevate Started"
|
title = "DocuElevate Started"
|
||||||
message = f"DocuElevate has been started successfully on {settings.external_hostname}"
|
message = f"DocuElevate has been started successfully on {settings.external_hostname}"
|
||||||
return send_notification(
|
return send_notification(title=title, message=message, notification_type="success", tags=["system", "startup"])
|
||||||
title=title,
|
|
||||||
message=message,
|
|
||||||
notification_type="success",
|
|
||||||
tags=["system", "startup"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def notify_shutdown() -> bool:
|
def notify_shutdown() -> bool:
|
||||||
"""Send a notification that the application is shutting down"""
|
"""Send a notification that the application is shutting down"""
|
||||||
if not settings.notify_on_shutdown:
|
if not settings.notify_on_shutdown:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
title = f"DocuElevate Shutting Down"
|
title = "DocuElevate Shutting Down"
|
||||||
message = f"DocuElevate on {settings.external_hostname} is shutting down"
|
message = f"DocuElevate on {settings.external_hostname} is shutting down"
|
||||||
return send_notification(
|
return send_notification(title=title, message=message, notification_type="info", tags=["system", "shutdown"])
|
||||||
title=title,
|
|
||||||
message=message,
|
|
||||||
notification_type="info",
|
|
||||||
tags=["system", "shutdown"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def notify_file_processed(filename: str, file_size: int, metadata: dict, destinations: list) -> bool:
|
def notify_file_processed(filename: str, file_size: int, metadata: dict, destinations: list) -> bool:
|
||||||
"""Send a notification that a file has been successfully processed"""
|
"""Send a notification that a file has been successfully processed"""
|
||||||
if not settings.notify_on_file_processed:
|
if not settings.notify_on_file_processed:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Format file size for display
|
# Format file size for display
|
||||||
size_mb = file_size / (1024 * 1024)
|
size_mb = file_size / (1024 * 1024)
|
||||||
size_str = f"{size_mb:.2f} MB" if size_mb >= 1 else f"{file_size / 1024:.2f} KB"
|
size_str = f"{size_mb:.2f} MB" if size_mb >= 1 else f"{file_size / 1024:.2f} KB"
|
||||||
|
|
||||||
# Extract key metadata fields
|
# Extract key metadata fields
|
||||||
doc_type = metadata.get('document_type', 'Unknown')
|
doc_type = metadata.get("document_type", "Unknown")
|
||||||
tags = metadata.get('tags', [])
|
tags = metadata.get("tags", [])
|
||||||
tags_str = ', '.join(tags) if tags else 'None'
|
tags_str = ", ".join(tags) if tags else "None"
|
||||||
|
|
||||||
# Format destinations
|
# Format destinations
|
||||||
destinations_str = ', '.join(destinations) if destinations else 'None configured'
|
destinations_str = ", ".join(destinations) if destinations else "None configured"
|
||||||
|
|
||||||
title = f"File Processed: {filename}"
|
title = f"File Processed: {filename}"
|
||||||
message = f"""
|
message = f"""
|
||||||
File: {filename}
|
File: {filename}
|
||||||
@@ -211,10 +200,7 @@ Destinations: {destinations_str}
|
|||||||
|
|
||||||
The file has been successfully processed and is being uploaded to all configured destinations.
|
The file has been successfully processed and is being uploaded to all configured destinations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return send_notification(
|
return send_notification(
|
||||||
title=title,
|
title=title, message=message.strip(), notification_type="success", tags=["document", "processed", "success"]
|
||||||
message=message.strip(),
|
|
||||||
notification_type="success",
|
|
||||||
tags=["document", "processed", "success"]
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Shared across multiple OAuth providers to reduce code duplication.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ This module provides functionality to:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import ApplicationSettings
|
from app.models import ApplicationSettings
|
||||||
|
|
||||||
@@ -67,7 +68,6 @@ SETTING_METADATA = {
|
|||||||
"required": True,
|
"required": True,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Authentication Settings
|
# Authentication Settings
|
||||||
"auth_enabled": {
|
"auth_enabled": {
|
||||||
"category": "Authentication",
|
"category": "Authentication",
|
||||||
@@ -133,7 +133,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
|
||||||
# AI Services
|
# AI Services
|
||||||
"openai_api_key": {
|
"openai_api_key": {
|
||||||
"category": "AI Services",
|
"category": "AI Services",
|
||||||
@@ -183,7 +182,6 @@ SETTING_METADATA = {
|
|||||||
"required": True,
|
"required": True,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - Dropbox
|
# Storage Providers - Dropbox
|
||||||
"dropbox_app_key": {
|
"dropbox_app_key": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -217,7 +215,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - Nextcloud
|
# Storage Providers - Nextcloud
|
||||||
"nextcloud_upload_url": {
|
"nextcloud_upload_url": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -251,7 +248,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - Paperless-ngx
|
# Storage Providers - Paperless-ngx
|
||||||
"paperless_ngx_api_token": {
|
"paperless_ngx_api_token": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -269,7 +265,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - Google Drive
|
# Storage Providers - Google Drive
|
||||||
"google_drive_credentials_json": {
|
"google_drive_credentials_json": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -327,7 +322,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - OneDrive
|
# Storage Providers - OneDrive
|
||||||
"onedrive_client_id": {
|
"onedrive_client_id": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -369,7 +363,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - WebDAV
|
# Storage Providers - WebDAV
|
||||||
"webdav_url": {
|
"webdav_url": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -411,7 +404,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - FTP
|
# Storage Providers - FTP
|
||||||
"ftp_host": {
|
"ftp_host": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -469,7 +461,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - SFTP
|
# Storage Providers - SFTP
|
||||||
"sftp_host": {
|
"sftp_host": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -535,7 +526,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Storage Providers - AWS S3
|
# Storage Providers - AWS S3
|
||||||
"aws_access_key_id": {
|
"aws_access_key_id": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
@@ -593,7 +583,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
"email_host": {
|
"email_host": {
|
||||||
"category": "Email",
|
"category": "Email",
|
||||||
@@ -651,7 +640,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# IMAP Settings - Account 1
|
# IMAP Settings - Account 1
|
||||||
"imap1_host": {
|
"imap1_host": {
|
||||||
"category": "IMAP",
|
"category": "IMAP",
|
||||||
@@ -709,7 +697,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# IMAP Settings - Account 2
|
# IMAP Settings - Account 2
|
||||||
"imap2_host": {
|
"imap2_host": {
|
||||||
"category": "IMAP",
|
"category": "IMAP",
|
||||||
@@ -767,7 +754,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Monitoring - Uptime Kuma
|
# Monitoring - Uptime Kuma
|
||||||
"uptime_kuma_url": {
|
"uptime_kuma_url": {
|
||||||
"category": "Monitoring",
|
"category": "Monitoring",
|
||||||
@@ -785,7 +771,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Processing Settings
|
# Processing Settings
|
||||||
"http_request_timeout": {
|
"http_request_timeout": {
|
||||||
"category": "Processing",
|
"category": "Processing",
|
||||||
@@ -811,7 +796,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Notifications Settings
|
# Notifications Settings
|
||||||
"notification_urls": {
|
"notification_urls": {
|
||||||
"category": "Notifications",
|
"category": "Notifications",
|
||||||
@@ -861,7 +845,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
|
||||||
# Feature Flags
|
# Feature Flags
|
||||||
"allow_file_delete": {
|
"allow_file_delete": {
|
||||||
"category": "Feature Flags",
|
"category": "Feature Flags",
|
||||||
@@ -877,13 +860,13 @@ SETTING_METADATA = {
|
|||||||
def get_setting_from_db(db: Session, key: str) -> Optional[str]:
|
def get_setting_from_db(db: Session, key: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Retrieve a setting value from the database.
|
Retrieve a setting value from the database.
|
||||||
|
|
||||||
Automatically decrypts sensitive values if encryption is enabled.
|
Automatically decrypts sensitive values if encryption is enabled.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
key: Setting key to retrieve
|
key: Setting key to retrieve
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Setting value as string (decrypted if necessary), or None if not found
|
Setting value as string (decrypted if necessary), or None if not found
|
||||||
"""
|
"""
|
||||||
@@ -891,13 +874,14 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]:
|
|||||||
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
||||||
if not setting:
|
if not setting:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if this setting is sensitive and should be decrypted
|
# Check if this setting is sensitive and should be decrypted
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
if metadata.get("sensitive", False):
|
if metadata.get("sensitive", False):
|
||||||
from app.utils.encryption import decrypt_value
|
from app.utils.encryption import decrypt_value
|
||||||
|
|
||||||
return decrypt_value(setting.value)
|
return decrypt_value(setting.value)
|
||||||
|
|
||||||
return setting.value
|
return setting.value
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Error retrieving setting {key} from database: {e}")
|
logger.error(f"Error retrieving setting {key} from database: {e}")
|
||||||
@@ -907,14 +891,14 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]:
|
|||||||
def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Save or update a setting in the database.
|
Save or update a setting in the database.
|
||||||
|
|
||||||
Automatically encrypts sensitive values if encryption is enabled.
|
Automatically encrypts sensitive values if encryption is enabled.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
key: Setting key
|
key: Setting key
|
||||||
value: Setting value (as string)
|
value: Setting value (as string)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -922,16 +906,16 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
|||||||
# Check if this setting is sensitive and should be encrypted
|
# Check if this setting is sensitive and should be encrypted
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
storage_value = value
|
storage_value = value
|
||||||
|
|
||||||
if metadata.get("sensitive", False) and value:
|
if metadata.get("sensitive", False) and value:
|
||||||
from app.utils.encryption import encrypt_value, is_encryption_available
|
from app.utils.encryption import encrypt_value, is_encryption_available
|
||||||
|
|
||||||
if is_encryption_available():
|
if is_encryption_available():
|
||||||
storage_value = encrypt_value(value)
|
storage_value = encrypt_value(value)
|
||||||
logger.debug(f"Encrypted sensitive setting: {key}")
|
logger.debug(f"Encrypted sensitive setting: {key}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Storing sensitive setting {key} in plaintext (encryption unavailable)")
|
logger.warning(f"Storing sensitive setting {key} in plaintext (encryption unavailable)")
|
||||||
|
|
||||||
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = storage_value
|
setting.value = storage_value
|
||||||
@@ -950,28 +934,29 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
|||||||
def get_all_settings_from_db(db: Session) -> Dict[str, str]:
|
def get_all_settings_from_db(db: Session) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Retrieve all settings from the database.
|
Retrieve all settings from the database.
|
||||||
|
|
||||||
Automatically decrypts sensitive values if encryption is enabled.
|
Automatically decrypts sensitive values if encryption is enabled.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of setting key-value pairs (decrypted)
|
Dictionary of setting key-value pairs (decrypted)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
settings = db.query(ApplicationSettings).all()
|
settings = db.query(ApplicationSettings).all()
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for setting in settings:
|
for setting in settings:
|
||||||
# Check if this setting is sensitive and should be decrypted
|
# Check if this setting is sensitive and should be decrypted
|
||||||
metadata = get_setting_metadata(setting.key)
|
metadata = get_setting_metadata(setting.key)
|
||||||
if metadata.get("sensitive", False):
|
if metadata.get("sensitive", False):
|
||||||
from app.utils.encryption import decrypt_value
|
from app.utils.encryption import decrypt_value
|
||||||
|
|
||||||
result[setting.key] = decrypt_value(setting.value)
|
result[setting.key] = decrypt_value(setting.value)
|
||||||
else:
|
else:
|
||||||
result[setting.key] = setting.value
|
result[setting.key] = setting.value
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Error retrieving all settings from database: {e}")
|
logger.error(f"Error retrieving all settings from database: {e}")
|
||||||
@@ -981,11 +966,11 @@ def get_all_settings_from_db(db: Session) -> Dict[str, str]:
|
|||||||
def delete_setting_from_db(db: Session, key: str) -> bool:
|
def delete_setting_from_db(db: Session, key: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a setting from the database.
|
Delete a setting from the database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
key: Setting key to delete
|
key: Setting key to delete
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -1006,27 +991,30 @@ def delete_setting_from_db(db: Session, key: str) -> bool:
|
|||||||
def get_setting_metadata(key: str) -> Dict[str, Any]:
|
def get_setting_metadata(key: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get metadata for a specific setting.
|
Get metadata for a specific setting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Setting key
|
key: Setting key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing setting metadata
|
Dictionary containing setting metadata
|
||||||
"""
|
"""
|
||||||
return SETTING_METADATA.get(key, {
|
return SETTING_METADATA.get(
|
||||||
"category": "Other",
|
key,
|
||||||
"description": f"Setting: {key}",
|
{
|
||||||
"type": "string",
|
"category": "Other",
|
||||||
"sensitive": False,
|
"description": f"Setting: {key}",
|
||||||
"required": False,
|
"type": "string",
|
||||||
"restart_required": False,
|
"sensitive": False,
|
||||||
})
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_settings_by_category() -> Dict[str, List[str]]:
|
def get_settings_by_category() -> Dict[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
Get settings organized by category.
|
Get settings organized by category.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping category names to lists of setting keys
|
Dictionary mapping category names to lists of setting keys
|
||||||
"""
|
"""
|
||||||
@@ -1042,34 +1030,34 @@ def get_settings_by_category() -> Dict[str, List[str]]:
|
|||||||
def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]:
|
def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Validate a setting value based on its metadata.
|
Validate a setting value based on its metadata.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Setting key
|
key: Setting key
|
||||||
value: Setting value to validate
|
value: Setting value to validate
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
Tuple of (is_valid, error_message)
|
||||||
"""
|
"""
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
setting_type = metadata.get("type", "string")
|
setting_type = metadata.get("type", "string")
|
||||||
|
|
||||||
# Check required fields
|
# Check required fields
|
||||||
if metadata.get("required", False) and not value:
|
if metadata.get("required", False) and not value:
|
||||||
return False, f"{key} is required"
|
return False, f"{key} is required"
|
||||||
|
|
||||||
# Type-specific validation
|
# Type-specific validation
|
||||||
if setting_type == "boolean":
|
if setting_type == "boolean":
|
||||||
if value.lower() not in ["true", "false", "1", "0", "yes", "no"]:
|
if value.lower() not in ["true", "false", "1", "0", "yes", "no"]:
|
||||||
return False, f"{key} must be a boolean value (true/false)"
|
return False, f"{key} must be a boolean value (true/false)"
|
||||||
|
|
||||||
elif setting_type == "integer":
|
elif setting_type == "integer":
|
||||||
try:
|
try:
|
||||||
int(value)
|
int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False, f"{key} must be an integer"
|
return False, f"{key} must be an integer"
|
||||||
|
|
||||||
# Special validation for specific keys
|
# Special validation for specific keys
|
||||||
if key == "session_secret" and value and len(value) < 32:
|
if key == "session_secret" and value and len(value) < 32:
|
||||||
return False, "session_secret must be at least 32 characters"
|
return False, "session_secret must be at least 32 characters"
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|||||||
+30
-28
@@ -5,7 +5,8 @@ Detects if the system needs initial setup and provides required settings list.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -14,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def get_required_settings() -> List[Dict[str, Any]]:
|
def get_required_settings() -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of settings that are absolutely required for the system to operate.
|
Get list of settings that are absolutely required for the system to operate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of required setting definitions with metadata
|
List of required setting definitions with metadata
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +28,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "sqlite:///./app/database.db",
|
"default": "sqlite:///./app/database.db",
|
||||||
"wizard_step": 1,
|
"wizard_step": 1,
|
||||||
"wizard_category": "Core Infrastructure"
|
"wizard_category": "Core Infrastructure",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "redis_url",
|
"key": "redis_url",
|
||||||
@@ -37,7 +38,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "redis://localhost:6379/0",
|
"default": "redis://localhost:6379/0",
|
||||||
"wizard_step": 1,
|
"wizard_step": 1,
|
||||||
"wizard_category": "Core Infrastructure"
|
"wizard_category": "Core Infrastructure",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "workdir",
|
"key": "workdir",
|
||||||
@@ -47,7 +48,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "/workdir",
|
"default": "/workdir",
|
||||||
"wizard_step": 1,
|
"wizard_step": 1,
|
||||||
"wizard_category": "Core Infrastructure"
|
"wizard_category": "Core Infrastructure",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "gotenberg_url",
|
"key": "gotenberg_url",
|
||||||
@@ -57,7 +58,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "http://gotenberg:3000",
|
"default": "http://gotenberg:3000",
|
||||||
"wizard_step": 1,
|
"wizard_step": 1,
|
||||||
"wizard_category": "Core Infrastructure"
|
"wizard_category": "Core Infrastructure",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "session_secret",
|
"key": "session_secret",
|
||||||
@@ -67,7 +68,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": True,
|
"sensitive": True,
|
||||||
"default": None, # Should be generated
|
"default": None, # Should be generated
|
||||||
"wizard_step": 2,
|
"wizard_step": 2,
|
||||||
"wizard_category": "Security"
|
"wizard_category": "Security",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "admin_username",
|
"key": "admin_username",
|
||||||
@@ -77,7 +78,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "admin",
|
"default": "admin",
|
||||||
"wizard_step": 2,
|
"wizard_step": 2,
|
||||||
"wizard_category": "Security"
|
"wizard_category": "Security",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "admin_password",
|
"key": "admin_password",
|
||||||
@@ -87,7 +88,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": True,
|
"sensitive": True,
|
||||||
"default": None, # Must be set
|
"default": None, # Must be set
|
||||||
"wizard_step": 2,
|
"wizard_step": 2,
|
||||||
"wizard_category": "Security"
|
"wizard_category": "Security",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "openai_api_key",
|
"key": "openai_api_key",
|
||||||
@@ -97,7 +98,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": True,
|
"sensitive": True,
|
||||||
"default": None,
|
"default": None,
|
||||||
"wizard_step": 3,
|
"wizard_step": 3,
|
||||||
"wizard_category": "AI Services"
|
"wizard_category": "AI Services",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "azure_ai_key",
|
"key": "azure_ai_key",
|
||||||
@@ -107,7 +108,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": True,
|
"sensitive": True,
|
||||||
"default": None,
|
"default": None,
|
||||||
"wizard_step": 3,
|
"wizard_step": 3,
|
||||||
"wizard_category": "AI Services"
|
"wizard_category": "AI Services",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "azure_region",
|
"key": "azure_region",
|
||||||
@@ -117,7 +118,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": "eastus",
|
"default": "eastus",
|
||||||
"wizard_step": 3,
|
"wizard_step": 3,
|
||||||
"wizard_category": "AI Services"
|
"wizard_category": "AI Services",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "azure_endpoint",
|
"key": "azure_endpoint",
|
||||||
@@ -127,7 +128,7 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"default": None,
|
"default": None,
|
||||||
"wizard_step": 3,
|
"wizard_step": 3,
|
||||||
"wizard_category": "AI Services"
|
"wizard_category": "AI Services",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -135,9 +136,9 @@ def get_required_settings() -> List[Dict[str, Any]]:
|
|||||||
def is_setup_required() -> bool:
|
def is_setup_required() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the system requires initial setup.
|
Check if the system requires initial setup.
|
||||||
|
|
||||||
Returns True if any critical required settings are missing or have placeholder values.
|
Returns True if any critical required settings are missing or have placeholder values.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if setup wizard should be shown, False otherwise
|
True if setup wizard should be shown, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -149,16 +150,16 @@ def is_setup_required() -> bool:
|
|||||||
("openai_api_key", [None, "", "<OPENAI_API_KEY>", "test-key"]),
|
("openai_api_key", [None, "", "<OPENAI_API_KEY>", "test-key"]),
|
||||||
("azure_ai_key", [None, "", "<AZURE_AI_KEY>", "test-key"]),
|
("azure_ai_key", [None, "", "<AZURE_AI_KEY>", "test-key"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
for setting_key, invalid_values in critical_settings:
|
for setting_key, invalid_values in critical_settings:
|
||||||
value = getattr(settings, setting_key, None)
|
value = getattr(settings, setting_key, None)
|
||||||
if value in invalid_values:
|
if value in invalid_values:
|
||||||
logger.warning(f"Setup required: {setting_key} has placeholder or missing value")
|
logger.warning(f"Setup required: {setting_key} has placeholder or missing value")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# All critical settings are configured
|
# All critical settings are configured
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking if setup required: {e}")
|
logger.error(f"Error checking if setup required: {e}")
|
||||||
# If we can't check, assume setup is not required (fail open)
|
# If we can't check, assume setup is not required (fail open)
|
||||||
@@ -168,45 +169,46 @@ def is_setup_required() -> bool:
|
|||||||
def get_missing_required_settings() -> List[str]:
|
def get_missing_required_settings() -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of required settings that are missing or have placeholder values.
|
Get list of required settings that are missing or have placeholder values.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of setting keys that need to be configured
|
List of setting keys that need to be configured
|
||||||
"""
|
"""
|
||||||
missing = []
|
missing = []
|
||||||
|
|
||||||
for required_setting in get_required_settings():
|
for required_setting in get_required_settings():
|
||||||
key = required_setting["key"]
|
key = required_setting["key"]
|
||||||
value = getattr(settings, key, None)
|
value = getattr(settings, key, None)
|
||||||
|
|
||||||
# Check if value is missing or is a placeholder
|
# Check if value is missing or is a placeholder
|
||||||
placeholder_values = [
|
placeholder_values = [
|
||||||
None, "",
|
None,
|
||||||
|
"",
|
||||||
f"<{key.upper()}>",
|
f"<{key.upper()}>",
|
||||||
"test-key",
|
"test-key",
|
||||||
"your_secure_password",
|
"your_secure_password",
|
||||||
"changeme",
|
"changeme",
|
||||||
"INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
|
"INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS",
|
||||||
]
|
]
|
||||||
|
|
||||||
if value in placeholder_values:
|
if value in placeholder_values:
|
||||||
missing.append(key)
|
missing.append(key)
|
||||||
|
|
||||||
return missing
|
return missing
|
||||||
|
|
||||||
|
|
||||||
def get_wizard_steps() -> Dict[int, List[Dict[str, Any]]]:
|
def get_wizard_steps() -> Dict[int, List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Get setup wizard steps organized by step number.
|
Get setup wizard steps organized by step number.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping step number to list of settings in that step
|
Dictionary mapping step number to list of settings in that step
|
||||||
"""
|
"""
|
||||||
steps = {}
|
steps = {}
|
||||||
|
|
||||||
for setting in get_required_settings():
|
for setting in get_required_settings():
|
||||||
step_num = setting.get("wizard_step", 1)
|
step_num = setting.get("wizard_step", 1)
|
||||||
if step_num not in steps:
|
if step_num not in steps:
|
||||||
steps[step_num] = []
|
steps[step_num] = []
|
||||||
steps[step_num].append(setting)
|
steps[step_num].append(setting)
|
||||||
|
|
||||||
return steps
|
return steps
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Aggregated view routers for the application.
|
Aggregated view routers for the application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.views.dropbox import router as dropbox_router
|
||||||
|
|
||||||
# Import all the view routers
|
# Import all the view routers
|
||||||
from app.views.general import router as general_router
|
from app.views.general import router as general_router
|
||||||
from app.views.status import router as status_router
|
|
||||||
from app.views.onedrive import router as onedrive_router
|
|
||||||
from app.views.dropbox import router as dropbox_router
|
|
||||||
from app.views.google_drive import router as google_drive_router
|
from app.views.google_drive import router as google_drive_router
|
||||||
from app.views.license_routes import router as license_router # Add the license router
|
from app.views.license_routes import router as license_router # Add the license router
|
||||||
|
from app.views.onedrive import router as onedrive_router
|
||||||
from app.views.settings import router as settings_router
|
from app.views.settings import router as settings_router
|
||||||
|
from app.views.status import router as status_router
|
||||||
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
|
||||||
|
|||||||
+12
-7
@@ -1,15 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Base setup for views, containing shared functionality and imports.
|
Base setup for views, containing shared functionality and imports.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from pathlib import Path
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import require_login
|
import logging
|
||||||
from app.database import SessionLocal
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request # noqa: F401
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session # noqa: F401
|
||||||
|
|
||||||
|
from app.auth import require_login # noqa: F401
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
# Set up Jinja2 templates
|
# Set up Jinja2 templates
|
||||||
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
|
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
|
||||||
@@ -22,6 +24,7 @@ templates.env.globals["max"] = max
|
|||||||
# Customize Jinja2Templates to include app_version in all templates
|
# Customize Jinja2Templates to include app_version in all templates
|
||||||
original_template_response = templates.TemplateResponse
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
|
||||||
def template_response_with_version(*args, **kwargs):
|
def template_response_with_version(*args, **kwargs):
|
||||||
"""Wrapper for TemplateResponse to include version in all templates"""
|
"""Wrapper for TemplateResponse to include version in all templates"""
|
||||||
# If context dict is provided, add version to it
|
# If context dict is provided, add version to it
|
||||||
@@ -31,11 +34,13 @@ def template_response_with_version(*args, **kwargs):
|
|||||||
kwargs["context"].setdefault("version", settings.version)
|
kwargs["context"].setdefault("version", settings.version)
|
||||||
return original_template_response(*args, **kwargs)
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
templates.TemplateResponse = template_response_with_version
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Dependency to get a database session.
|
Dependency to get a database session.
|
||||||
|
|||||||
+15
-18
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Dropbox integration views for setup and OAuth callback.
|
Dropbox integration views for setup and OAuth callback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, settings
|
from app.views.base import APIRouter, require_login, settings, templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dropbox-setup")
|
@router.get("/dropbox-setup")
|
||||||
@require_login
|
@require_login
|
||||||
async def dropbox_setup_page(request: Request):
|
async def dropbox_setup_page(request: Request):
|
||||||
@@ -15,10 +17,8 @@ async def dropbox_setup_page(request: Request):
|
|||||||
Shows configuration status and setup instructions.
|
Shows configuration status and setup instructions.
|
||||||
"""
|
"""
|
||||||
# Check Dropbox configuration
|
# Check Dropbox configuration
|
||||||
is_configured = bool(settings.dropbox_app_key and
|
is_configured = bool(settings.dropbox_app_key and settings.dropbox_app_secret and settings.dropbox_refresh_token)
|
||||||
settings.dropbox_app_secret and
|
|
||||||
settings.dropbox_refresh_token)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dropbox.html",
|
"dropbox.html",
|
||||||
{
|
{
|
||||||
@@ -27,10 +27,11 @@ async def dropbox_setup_page(request: Request):
|
|||||||
"app_key_value": settings.dropbox_app_key or "",
|
"app_key_value": settings.dropbox_app_key or "",
|
||||||
"app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "",
|
"app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "",
|
||||||
"refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "",
|
"refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "",
|
||||||
"folder_path": settings.dropbox_folder or "/Documents/Uploads" # Default folder path
|
"folder_path": settings.dropbox_folder or "/Documents/Uploads", # Default folder path
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dropbox-callback")
|
@router.get("/dropbox-callback")
|
||||||
@require_login
|
@require_login
|
||||||
async def dropbox_callback(request: Request, code: str = None, error: str = None):
|
async def dropbox_callback(request: Request, code: str = None, error: str = None):
|
||||||
@@ -39,27 +40,23 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None
|
|||||||
Automatically exchanges the code for a token and saves it to the configuration.
|
Automatically exchanges the code for a token and saves it to the configuration.
|
||||||
"""
|
"""
|
||||||
if error:
|
if error:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("dropbox_callback_error.html", {"request": request, "error": error})
|
||||||
"dropbox_callback_error.html",
|
|
||||||
{"request": request, "error": error}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dropbox_callback_error.html",
|
"dropbox_callback_error.html", {"request": request, "error": "No authorization code received from Dropbox"}
|
||||||
{"request": request, "error": "No authorization code received from Dropbox"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display the processing page with automatic token exchange
|
# Display the processing page with automatic token exchange
|
||||||
# Note: We provide empty strings for app_key_value and app_secret_value
|
# Note: We provide empty strings for app_key_value and app_secret_value
|
||||||
# to prevent overriding what's in sessionStorage
|
# to prevent overriding what's in sessionStorage
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dropbox_callback.html",
|
"dropbox_callback.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"code": code,
|
"code": code,
|
||||||
"app_key_value": "", # The callback will prioritize sessionStorage values
|
"app_key_value": "", # The callback will prioritize sessionStorage values
|
||||||
"app_secret_value": "", # The callback will prioritize sessionStorage values
|
"app_secret_value": "", # The callback will prioritize sessionStorage values
|
||||||
"folder_path": "" # The callback will prioritize sessionStorage values
|
"folder_path": "", # The callback will prioritize sessionStorage values
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-6
@@ -2,13 +2,14 @@
|
|||||||
File management views for displaying and managing files.
|
File management views for displaying and managing files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, get_db, logger
|
from fastapi import Depends, Query, Request
|
||||||
from app.utils.file_status import get_files_processing_status
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.utils.file_status import get_files_processing_status
|
||||||
|
from app.views.base import APIRouter, get_db, logger, require_login, templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -31,8 +32,9 @@ def files_page(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Import the model here to avoid circular imports
|
# Import the model here to avoid circular imports
|
||||||
|
from sqlalchemy import asc, desc, or_
|
||||||
|
|
||||||
from app.models import FileRecord, ProcessingLog
|
from app.models import FileRecord, ProcessingLog
|
||||||
from sqlalchemy import desc, asc, or_
|
|
||||||
|
|
||||||
# Start with base query
|
# Start with base query
|
||||||
query = db.query(FileRecord)
|
query = db.query(FileRecord)
|
||||||
@@ -159,9 +161,10 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
|
|||||||
Return the file detail page showing processing history and file information
|
Return the file detail page showing processing history and file information
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.models import FileRecord, ProcessingLog
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from app.models import FileRecord, ProcessingLog
|
||||||
|
|
||||||
# Find the file record
|
# Find the file record
|
||||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
|
||||||
|
|||||||
+38
-30
@@ -1,75 +1,83 @@
|
|||||||
"""
|
"""
|
||||||
General routes for the application homepage and basic pages.
|
General routes for the application homepage and basic pages.
|
||||||
"""
|
"""
|
||||||
from fastapi import Request, HTTPException, Depends
|
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, get_db, logger
|
|
||||||
from app.utils.config_validator import get_provider_status, validate_storage_configs
|
from app.utils.config_validator import get_provider_status, validate_storage_configs
|
||||||
|
from app.views.base import APIRouter, get_db, logger, require_login, templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", include_in_schema=False)
|
@router.get("/", include_in_schema=False)
|
||||||
async def serve_index(request: Request, db: Session = Depends(get_db)):
|
async def serve_index(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Serve the index/home page.
|
Serve the index/home page.
|
||||||
|
|
||||||
If the system requires initial setup, redirect to the setup wizard.
|
If the system requires initial setup, redirect to the setup wizard.
|
||||||
"""
|
"""
|
||||||
# Check if setup wizard is needed
|
# Check if setup wizard is needed
|
||||||
from app.utils.setup_wizard import is_setup_required
|
|
||||||
from app.utils.settings_service import get_setting_from_db
|
from app.utils.settings_service import get_setting_from_db
|
||||||
|
from app.utils.setup_wizard import is_setup_required
|
||||||
|
|
||||||
# Check if setup was explicitly skipped
|
# Check if setup was explicitly skipped
|
||||||
setup_skipped = get_setting_from_db(db, "_setup_wizard_skipped")
|
setup_skipped = get_setting_from_db(db, "_setup_wizard_skipped")
|
||||||
|
|
||||||
# Check setup completion query param
|
# Check setup completion query param
|
||||||
setup_complete = request.query_params.get("setup") == "complete"
|
setup_complete = request.query_params.get("setup") == "complete"
|
||||||
|
|
||||||
if not setup_skipped and not setup_complete and is_setup_required():
|
if not setup_skipped and not setup_complete and is_setup_required():
|
||||||
logger.info("System requires initial setup, redirecting to wizard")
|
logger.info("System requires initial setup, redirecting to wizard")
|
||||||
return RedirectResponse(url="/setup?step=1", status_code=303)
|
return RedirectResponse(url="/setup?step=1", status_code=303)
|
||||||
|
|
||||||
# Get provider information from config validator
|
# Get provider information from config validator
|
||||||
providers = get_provider_status()
|
providers = get_provider_status()
|
||||||
|
|
||||||
# Count configured providers
|
# Count configured providers
|
||||||
configured_providers = sum(1 for provider in providers.values() if provider['configured'])
|
configured_providers = sum(1 for provider in providers.values() if provider["configured"])
|
||||||
|
|
||||||
# Count different types of storage targets
|
# Count different types of storage targets
|
||||||
storage_issues = validate_storage_configs()
|
storage_issues = validate_storage_configs()
|
||||||
configured_storage_targets = sum(1 for provider, issues in storage_issues.items()
|
configured_storage_targets = sum(
|
||||||
if not issues and provider in ['dropbox', 'nextcloud', 'sftp',
|
1
|
||||||
's3', 'ftp', 'webdav',
|
for provider, issues in storage_issues.items()
|
||||||
'google_drive', 'onedrive'])
|
if not issues
|
||||||
|
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
|
||||||
|
)
|
||||||
|
|
||||||
# Query the actual file count from the database
|
# Query the actual file count from the database
|
||||||
processed_files = 0
|
processed_files = 0
|
||||||
try:
|
try:
|
||||||
# Import the model here to avoid circular imports
|
# Import the model here to avoid circular imports
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
|
||||||
processed_files = db.query(FileRecord).count()
|
processed_files = db.query(FileRecord).count()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but continue (don't break the page if DB query fails)
|
# Log error but continue (don't break the page if DB query fails)
|
||||||
logger.error(f"Error counting files: {str(e)}")
|
logger.error(f"Error counting files: {str(e)}")
|
||||||
|
|
||||||
# Create stats object to pass to the template
|
# Create stats object to pass to the template
|
||||||
stats = {
|
stats = {
|
||||||
"processed_files": processed_files,
|
"processed_files": processed_files,
|
||||||
"active_integrations": configured_providers,
|
"active_integrations": configured_providers,
|
||||||
"storage_targets": configured_storage_targets
|
"storage_targets": configured_storage_targets,
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
|
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/about", include_in_schema=False)
|
@router.get("/about", include_in_schema=False)
|
||||||
async def serve_about(request: Request):
|
async def serve_about(request: Request):
|
||||||
"""Serve the about page."""
|
"""Serve the about page."""
|
||||||
return templates.TemplateResponse("about.html", {"request": request})
|
return templates.TemplateResponse("about.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/privacy", include_in_schema=False)
|
@router.get("/privacy", include_in_schema=False)
|
||||||
async def serve_privacy(request: Request):
|
async def serve_privacy(request: Request):
|
||||||
"""Serve the privacy policy page."""
|
"""Serve the privacy policy page."""
|
||||||
@@ -77,17 +85,20 @@ async def serve_privacy(request: Request):
|
|||||||
current_date = date.today().strftime("%B %d, %Y")
|
current_date = date.today().strftime("%B %d, %Y")
|
||||||
return templates.TemplateResponse("privacy.html", {"request": request, "current_date": current_date})
|
return templates.TemplateResponse("privacy.html", {"request": request, "current_date": current_date})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/imprint", include_in_schema=False)
|
@router.get("/imprint", include_in_schema=False)
|
||||||
async def serve_imprint(request: Request):
|
async def serve_imprint(request: Request):
|
||||||
"""Serve the imprint/impressum page."""
|
"""Serve the imprint/impressum page."""
|
||||||
return templates.TemplateResponse("imprint.html", {"request": request})
|
return templates.TemplateResponse("imprint.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/upload", include_in_schema=False)
|
@router.get("/upload", include_in_schema=False)
|
||||||
@require_login
|
@require_login
|
||||||
async def serve_upload(request: Request):
|
async def serve_upload(request: Request):
|
||||||
"""Serve the upload page."""
|
"""Serve the upload page."""
|
||||||
return templates.TemplateResponse("upload.html", {"request": request})
|
return templates.TemplateResponse("upload.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico", include_in_schema=False)
|
@router.get("/favicon.ico", include_in_schema=False)
|
||||||
def favicon():
|
def favicon():
|
||||||
"""Serve the favicon."""
|
"""Serve the favicon."""
|
||||||
@@ -97,6 +108,7 @@ def favicon():
|
|||||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||||
return FileResponse(favicon_path)
|
return FileResponse(favicon_path)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/license", include_in_schema=False)
|
@router.get("/license", include_in_schema=False)
|
||||||
async def serve_license(request: Request):
|
async def serve_license(request: Request):
|
||||||
"""Serve the license page."""
|
"""Serve the license page."""
|
||||||
@@ -108,7 +120,7 @@ async def serve_license(request: Request):
|
|||||||
]
|
]
|
||||||
|
|
||||||
license_text = None
|
license_text = None
|
||||||
|
|
||||||
# Try to read from any of the possible locations
|
# Try to read from any of the possible locations
|
||||||
for path in possible_locations:
|
for path in possible_locations:
|
||||||
try:
|
try:
|
||||||
@@ -117,7 +129,7 @@ async def serve_license(request: Request):
|
|||||||
break # File found and read, exit loop
|
break # File found and read, exit loop
|
||||||
except (FileNotFoundError, PermissionError):
|
except (FileNotFoundError, PermissionError):
|
||||||
continue # Try next location
|
continue # Try next location
|
||||||
|
|
||||||
# If license text is still None, use embedded text
|
# If license text is still None, use embedded text
|
||||||
if license_text is None:
|
if license_text is None:
|
||||||
license_text = """
|
license_text = """
|
||||||
@@ -130,13 +142,8 @@ The full license text could not be located on this system.
|
|||||||
Please visit http://www.apache.org/licenses/LICENSE-2.0 for the complete license text.
|
Please visit http://www.apache.org/licenses/LICENSE-2.0 for the complete license text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("license.html", {"request": request, "license_text": license_text})
|
||||||
"license.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"license_text": license_text
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/cookies", include_in_schema=False)
|
@router.get("/cookies", include_in_schema=False)
|
||||||
async def serve_cookies(request: Request):
|
async def serve_cookies(request: Request):
|
||||||
@@ -144,6 +151,7 @@ async def serve_cookies(request: Request):
|
|||||||
current_date = date.today().strftime("%B %d, %Y")
|
current_date = date.today().strftime("%B %d, %Y")
|
||||||
return templates.TemplateResponse("cookies.html", {"request": request, "current_date": current_date})
|
return templates.TemplateResponse("cookies.html", {"request": request, "current_date": current_date})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/terms", include_in_schema=False)
|
@router.get("/terms", include_in_schema=False)
|
||||||
async def serve_terms(request: Request):
|
async def serve_terms(request: Request):
|
||||||
"""Serve the terms of service page."""
|
"""Serve the terms of service page."""
|
||||||
|
|||||||
+31
-42
@@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Google Drive integration views for setup and OAuth callback.
|
Google Drive integration views for setup and OAuth callback.
|
||||||
"""
|
"""
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, settings
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.views.base import APIRouter, require_login, settings, templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/google-drive-setup")
|
@router.get("/google-drive-setup")
|
||||||
@require_login
|
@require_login
|
||||||
async def google_drive_setup_page(request: Request):
|
async def google_drive_setup_page(request: Request):
|
||||||
@@ -17,24 +20,24 @@ async def google_drive_setup_page(request: Request):
|
|||||||
Shows configuration status and setup instructions.
|
Shows configuration status and setup instructions.
|
||||||
"""
|
"""
|
||||||
# Check if using OAuth
|
# Check if using OAuth
|
||||||
use_oauth = getattr(settings, 'google_drive_use_oauth', False)
|
use_oauth = getattr(settings, "google_drive_use_oauth", False)
|
||||||
|
|
||||||
# Check Google Drive OAuth configuration
|
# Check Google Drive OAuth configuration
|
||||||
oauth_configured = bool(settings.google_drive_client_id and
|
oauth_configured = bool(
|
||||||
settings.google_drive_client_secret and
|
settings.google_drive_client_id and settings.google_drive_client_secret and settings.google_drive_refresh_token
|
||||||
settings.google_drive_refresh_token)
|
)
|
||||||
|
|
||||||
# Check Google Drive service account configuration
|
# Check Google Drive service account configuration
|
||||||
sa_configured = bool(settings.google_drive_credentials_json)
|
sa_configured = bool(settings.google_drive_credentials_json)
|
||||||
|
|
||||||
# Overall configuration status
|
# Overall configuration status
|
||||||
is_configured = (use_oauth and oauth_configured) or (not use_oauth and sa_configured)
|
is_configured = (use_oauth and oauth_configured) or (not use_oauth and sa_configured)
|
||||||
|
|
||||||
if settings.google_drive_folder_id:
|
if settings.google_drive_folder_id:
|
||||||
is_configured = is_configured and True
|
is_configured = is_configured and True
|
||||||
else:
|
else:
|
||||||
is_configured = False
|
is_configured = False
|
||||||
|
|
||||||
# Get configuration values to display status (hide sensitive values)
|
# Get configuration values to display status (hide sensitive values)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"google_drive.html",
|
"google_drive.html",
|
||||||
@@ -51,10 +54,11 @@ async def google_drive_setup_page(request: Request):
|
|||||||
"refresh_token": bool(settings.google_drive_refresh_token),
|
"refresh_token": bool(settings.google_drive_refresh_token),
|
||||||
"refresh_token_value": settings.google_drive_refresh_token or "",
|
"refresh_token_value": settings.google_drive_refresh_token or "",
|
||||||
"folder_id": settings.google_drive_folder_id or "",
|
"folder_id": settings.google_drive_folder_id or "",
|
||||||
"has_credentials_json": bool(settings.google_drive_credentials_json)
|
"has_credentials_json": bool(settings.google_drive_credentials_json),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/google-drive-callback")
|
@router.get("/google-drive-callback")
|
||||||
@require_login
|
@require_login
|
||||||
async def google_drive_callback(request: Request, code: str = None, error: str = None, state: str = None):
|
async def google_drive_callback(request: Request, code: str = None, error: str = None, state: str = None):
|
||||||
@@ -63,48 +67,33 @@ async def google_drive_callback(request: Request, code: str = None, error: str =
|
|||||||
Now automatically exchanges the code for a token and saves it to the configuration.
|
Now automatically exchanges the code for a token and saves it to the configuration.
|
||||||
"""
|
"""
|
||||||
if error:
|
if error:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("google_drive_callback_error.html", {"request": request, "error": error})
|
||||||
"google_drive_callback_error.html",
|
|
||||||
{"request": request, "error": error}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"google_drive_callback_error.html",
|
"google_drive_callback_error.html",
|
||||||
{"request": request, "error": "No authorization code received from Google"}
|
{"request": request, "error": "No authorization code received from Google"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display the processing page with automatic token exchange
|
# Display the processing page with automatic token exchange
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("google_drive_callback.html", {"request": request, "code": code, "state": state})
|
||||||
"google_drive_callback.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"code": code,
|
|
||||||
"state": state
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/google-drive-auth-start")
|
@router.get("/google-drive-auth-start")
|
||||||
@require_login
|
@require_login
|
||||||
async def google_drive_auth_start(
|
async def google_drive_auth_start(request: Request, client_id: str, redirect_uri: str = None):
|
||||||
request: Request,
|
|
||||||
client_id: str,
|
|
||||||
redirect_uri: str = None
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Start the Google Drive OAuth flow by redirecting to Google's authorization page.
|
Start the Google Drive OAuth flow by redirecting to Google's authorization page.
|
||||||
"""
|
"""
|
||||||
if not redirect_uri:
|
if not redirect_uri:
|
||||||
redirect_uri = f"{request.url.scheme}://{request.url.netloc}/google-drive-callback"
|
redirect_uri = f"{request.url.scheme}://{request.url.netloc}/google-drive-callback"
|
||||||
|
|
||||||
# Create the authorization URL with required scopes
|
# Create the authorization URL with required scopes
|
||||||
# Use only drive.file scope to minimize required permissions
|
# Use only drive.file scope to minimize required permissions
|
||||||
scopes = [
|
scopes = ["https://www.googleapis.com/auth/drive.file"] # Access to files created or opened by the app
|
||||||
"https://www.googleapis.com/auth/drive.file" # Access to files created or opened by the app
|
|
||||||
]
|
scope_str = urllib.parse.quote(" ".join(scopes))
|
||||||
|
|
||||||
scope_str = urllib.parse.quote(' '.join(scopes))
|
|
||||||
|
|
||||||
auth_url = (
|
auth_url = (
|
||||||
f"https://accounts.google.com/o/oauth2/auth"
|
f"https://accounts.google.com/o/oauth2/auth"
|
||||||
f"?client_id={client_id}"
|
f"?client_id={client_id}"
|
||||||
@@ -114,5 +103,5 @@ async def google_drive_auth_start(
|
|||||||
f"&access_type=offline"
|
f"&access_type=offline"
|
||||||
f"&prompt=consent" # Force to show consent screen to get refresh token
|
f"&prompt=consent" # Force to show consent screen to get refresh token
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(url=auth_url)
|
return RedirectResponse(url=auth_url)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
|
|
||||||
from app.views.base import templates, require_login
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||||
|
|
||||||
|
from app.views.base import templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/licenses/lgpl.txt", response_class=PlainTextResponse)
|
@router.get("/licenses/lgpl.txt", response_class=PlainTextResponse)
|
||||||
async def get_lgpl_license():
|
async def get_lgpl_license():
|
||||||
"""
|
"""
|
||||||
@@ -15,10 +16,11 @@ async def get_lgpl_license():
|
|||||||
license_path = Path("frontend/static/licenses/lgpl.txt")
|
license_path = Path("frontend/static/licenses/lgpl.txt")
|
||||||
if not license_path.exists():
|
if not license_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="License file not found")
|
raise HTTPException(status_code=404, detail="License file not found")
|
||||||
|
|
||||||
with open(license_path, "r") as f:
|
with open(license_path, "r") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/attribution", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/attribution", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def serve_attribution(request: Request):
|
async def serve_attribution(request: Request):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+17
-17
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
OneDrive integration views for setup and OAuth callback.
|
OneDrive integration views for setup and OAuth callback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, settings
|
from app.views.base import APIRouter, require_login, settings, templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/onedrive-setup")
|
@router.get("/onedrive-setup")
|
||||||
@require_login
|
@require_login
|
||||||
async def onedrive_setup_page(request: Request):
|
async def onedrive_setup_page(request: Request):
|
||||||
@@ -15,10 +17,10 @@ async def onedrive_setup_page(request: Request):
|
|||||||
Shows configuration status and setup instructions.
|
Shows configuration status and setup instructions.
|
||||||
"""
|
"""
|
||||||
# Check OneDrive configuration
|
# Check OneDrive configuration
|
||||||
is_configured = bool(settings.onedrive_client_id and
|
is_configured = bool(
|
||||||
settings.onedrive_client_secret and
|
settings.onedrive_client_id and settings.onedrive_client_secret and settings.onedrive_refresh_token
|
||||||
settings.onedrive_refresh_token)
|
)
|
||||||
|
|
||||||
# Get configuration values to display status (hide sensitive values)
|
# Get configuration values to display status (hide sensitive values)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"onedrive.html",
|
"onedrive.html",
|
||||||
@@ -32,10 +34,11 @@ async def onedrive_setup_page(request: Request):
|
|||||||
"tenant_id": settings.onedrive_tenant_id,
|
"tenant_id": settings.onedrive_tenant_id,
|
||||||
"refresh_token": bool(settings.onedrive_refresh_token),
|
"refresh_token": bool(settings.onedrive_refresh_token),
|
||||||
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
|
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
|
||||||
"folder_path": settings.onedrive_folder_path or "Documents/Uploads" # Default folder path
|
"folder_path": settings.onedrive_folder_path or "Documents/Uploads", # Default folder path
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/onedrive-callback")
|
@router.get("/onedrive-callback")
|
||||||
@require_login
|
@require_login
|
||||||
async def onedrive_callback(request: Request, code: str = None, error: str = None):
|
async def onedrive_callback(request: Request, code: str = None, error: str = None):
|
||||||
@@ -44,25 +47,22 @@ async def onedrive_callback(request: Request, code: str = None, error: str = Non
|
|||||||
Now automatically exchanges the code for a token and saves it to the configuration.
|
Now automatically exchanges the code for a token and saves it to the configuration.
|
||||||
"""
|
"""
|
||||||
if error:
|
if error:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse("onedrive_callback_error.html", {"request": request, "error": error})
|
||||||
"onedrive_callback_error.html",
|
|
||||||
{"request": request, "error": error}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"onedrive_callback_error.html",
|
"onedrive_callback_error.html",
|
||||||
{"request": request, "error": "No authorization code received from Microsoft"}
|
{"request": request, "error": "No authorization code received from Microsoft"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display the processing page with automatic token exchange
|
# Display the processing page with automatic token exchange
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"onedrive_callback.html",
|
"onedrive_callback.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"code": code,
|
"code": code,
|
||||||
"client_id_value": settings.onedrive_client_id or "",
|
"client_id_value": settings.onedrive_client_id or "",
|
||||||
"client_secret_value": settings.onedrive_client_secret or "",
|
"client_secret_value": settings.onedrive_client_secret or "",
|
||||||
"tenant_id": settings.onedrive_tenant_id or "common"
|
"tenant_id": settings.onedrive_tenant_id or "common",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-35
@@ -2,17 +2,18 @@
|
|||||||
Settings management views for the application.
|
Settings management views for the application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from fastapi import Request, Depends, HTTPException, status
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, settings, get_db
|
|
||||||
from app.utils.settings_service import get_settings_by_category, get_setting_metadata, SETTING_METADATA
|
|
||||||
from app.utils.config_validator.masking import mask_sensitive_value
|
from app.utils.config_validator.masking import mask_sensitive_value
|
||||||
|
from app.utils.settings_service import get_setting_metadata, get_settings_by_category
|
||||||
|
from app.views.base import APIRouter, get_db, require_login, settings, templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -21,23 +22,25 @@ router = APIRouter()
|
|||||||
def require_admin_access(func):
|
def require_admin_access(func):
|
||||||
"""
|
"""
|
||||||
Decorator to require admin access for a route.
|
Decorator to require admin access for a route.
|
||||||
|
|
||||||
This decorator checks if the user in the session has admin privileges.
|
This decorator checks if the user in the session has admin privileges.
|
||||||
If not, redirects to the home page. Works with both sync and async functions,
|
If not, redirects to the home page. Works with both sync and async functions,
|
||||||
though FastAPI route handlers should always be async.
|
though FastAPI route handlers should always be async.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(request: Request, *args, **kwargs):
|
async def wrapper(request: Request, *args, **kwargs):
|
||||||
user = request.session.get("user")
|
user = request.session.get("user")
|
||||||
if not user or not user.get("is_admin"):
|
if not user or not user.get("is_admin"):
|
||||||
logger.warning(f"Non-admin user attempted to access admin-only route")
|
logger.warning("Non-admin user attempted to access admin-only route")
|
||||||
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
# FastAPI route handlers are async, but we support sync for flexibility
|
# FastAPI route handlers are async, but we support sync for flexibility
|
||||||
if inspect.iscoroutinefunction(func):
|
if inspect.iscoroutinefunction(func):
|
||||||
return await func(request, *args, **kwargs)
|
return await func(request, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
return func(request, *args, **kwargs)
|
return func(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -47,19 +50,20 @@ def require_admin_access(func):
|
|||||||
async def settings_page(request: Request, db: Session = Depends(get_db)):
|
async def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Settings management page - admin only.
|
Settings management page - admin only.
|
||||||
|
|
||||||
This page is a convenience feature to view and edit settings.
|
This page is a convenience feature to view and edit settings.
|
||||||
Values are displayed in precedence order: Database > Environment > Defaults
|
Values are displayed in precedence order: Database > Environment > Defaults
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get settings from database
|
# Get settings from database
|
||||||
from app.utils.settings_service import get_all_settings_from_db
|
from app.utils.settings_service import get_all_settings_from_db
|
||||||
|
|
||||||
db_settings = get_all_settings_from_db(db)
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
|
||||||
# Get settings organized by category
|
# Get settings organized by category
|
||||||
categories = get_settings_by_category()
|
categories = get_settings_by_category()
|
||||||
|
|
||||||
# Build settings data for display
|
# Build settings data for display
|
||||||
settings_data = {}
|
settings_data = {}
|
||||||
for category, keys in categories.items():
|
for category, keys in categories.items():
|
||||||
@@ -67,7 +71,7 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
for key in keys:
|
for key in keys:
|
||||||
# Get current value from settings (already has precedence applied)
|
# Get current value from settings (already has precedence applied)
|
||||||
value = getattr(settings, key, None)
|
value = getattr(settings, key, None)
|
||||||
|
|
||||||
# Determine the source of this setting
|
# Determine the source of this setting
|
||||||
# Check if it's in the database
|
# Check if it's in the database
|
||||||
if key in db_settings:
|
if key in db_settings:
|
||||||
@@ -84,35 +88,29 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
source = "default"
|
source = "default"
|
||||||
source_label = "DEFAULT"
|
source_label = "DEFAULT"
|
||||||
source_color = "gray"
|
source_color = "gray"
|
||||||
|
|
||||||
# Get metadata
|
# Get metadata
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
|
|
||||||
# Mask sensitive values
|
# Mask sensitive values
|
||||||
display_value = value
|
display_value = value
|
||||||
if metadata.get("sensitive") and value:
|
if metadata.get("sensitive") and value:
|
||||||
display_value = mask_sensitive_value(value)
|
display_value = mask_sensitive_value(value)
|
||||||
|
|
||||||
settings_data[category].append({
|
settings_data[category].append(
|
||||||
"key": key,
|
{
|
||||||
"display_value": display_value if display_value is not None else "",
|
"key": key,
|
||||||
"metadata": metadata,
|
"display_value": display_value if display_value is not None else "",
|
||||||
"source": source,
|
"metadata": metadata,
|
||||||
"source_label": source_label,
|
"source": source,
|
||||||
"source_color": source_color
|
"source_label": source_label,
|
||||||
})
|
"source_color": source_color,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"settings.html",
|
"settings.html", {"request": request, "settings_data": settings_data, "app_version": settings.version}
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"settings_data": settings_data,
|
|
||||||
"app_version": settings.version
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading settings page: {e}")
|
logger.error(f"Error loading settings page: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load settings page")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to load settings page"
|
|
||||||
)
|
|
||||||
|
|||||||
+44
-42
@@ -1,17 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Status and configuration views for the application.
|
Status and configuration views for the application.
|
||||||
"""
|
"""
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from datetime import datetime
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, settings
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.views.base import APIRouter, require_login, settings, templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@require_login
|
@require_login
|
||||||
async def status_dashboard(request: Request):
|
async def status_dashboard(request: Request):
|
||||||
@@ -19,75 +21,74 @@ async def status_dashboard(request: Request):
|
|||||||
Status dashboard showing all configured integration targets
|
Status dashboard showing all configured integration targets
|
||||||
"""
|
"""
|
||||||
from app.utils.config_validator import get_provider_status
|
from app.utils.config_validator import get_provider_status
|
||||||
|
|
||||||
# Get provider status
|
# Get provider status
|
||||||
providers = get_provider_status()
|
providers = get_provider_status()
|
||||||
|
|
||||||
# Get build date from settings
|
# Get build date from settings
|
||||||
build_date = getattr(settings, 'build_date', 'Unknown')
|
build_date = getattr(settings, "build_date", "Unknown")
|
||||||
|
|
||||||
# Try to get container information
|
# Try to get container information
|
||||||
container_info = {}
|
container_info = {}
|
||||||
try:
|
try:
|
||||||
# Check for Docker environment
|
# Check for Docker environment
|
||||||
if os.path.exists('/.dockerenv'):
|
if os.path.exists("/.dockerenv"):
|
||||||
# We're inside a Docker container
|
# We're inside a Docker container
|
||||||
container_info['is_docker'] = True
|
container_info["is_docker"] = True
|
||||||
|
|
||||||
# Try to get container ID
|
# Try to get container ID
|
||||||
try:
|
try:
|
||||||
with open('/proc/self/cgroup', 'r') as f:
|
with open("/proc/self/cgroup", "r") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if 'docker' in line:
|
if "docker" in line:
|
||||||
container_id = line.split('/')[-1].strip()
|
container_id = line.split("/")[-1].strip()
|
||||||
container_info['id'] = container_id[:12] # Short ID format
|
container_info["id"] = container_id[:12] # Short ID format
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
container_info['id'] = 'Unknown'
|
container_info["id"] = "Unknown"
|
||||||
|
|
||||||
# Get Git commit SHA from settings
|
# Get Git commit SHA from settings
|
||||||
try:
|
try:
|
||||||
git_sha = settings.git_sha
|
git_sha = settings.git_sha
|
||||||
container_info['git_sha'] = git_sha[:7] if git_sha and git_sha != 'unknown' else 'Unknown'
|
container_info["git_sha"] = git_sha[:7] if git_sha and git_sha != "unknown" else "Unknown"
|
||||||
except Exception:
|
except Exception:
|
||||||
container_info['git_sha'] = 'Unknown'
|
container_info["git_sha"] = "Unknown"
|
||||||
|
|
||||||
# Try to get runtime information
|
# Try to get runtime information
|
||||||
try:
|
try:
|
||||||
container_info['runtime_info'] = settings.runtime_info
|
container_info["runtime_info"] = settings.runtime_info
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
container_info['is_docker'] = False
|
container_info["is_docker"] = False
|
||||||
|
|
||||||
# If not in Docker, get Git info from settings
|
# If not in Docker, get Git info from settings
|
||||||
try:
|
try:
|
||||||
git_sha = settings.git_sha
|
git_sha = settings.git_sha
|
||||||
container_info['git_sha'] = git_sha[:7] if git_sha and git_sha != 'unknown' else 'Unknown'
|
container_info["git_sha"] = git_sha[:7] if git_sha and git_sha != "unknown" else "Unknown"
|
||||||
except Exception:
|
except Exception:
|
||||||
container_info['git_sha'] = 'Unknown'
|
container_info["git_sha"] = "Unknown"
|
||||||
except Exception:
|
except Exception:
|
||||||
container_info = {'is_docker': False, 'id': 'Unknown', 'git_sha': 'Unknown'}
|
container_info = {"is_docker": False, "id": "Unknown", "git_sha": "Unknown"}
|
||||||
|
|
||||||
# Get notification URLs for the notification box
|
# Get notification URLs for the notification box
|
||||||
notification_urls = getattr(settings, 'notification_urls', [])
|
notification_urls = getattr(settings, "notification_urls", [])
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"status_dashboard.html",
|
"status_dashboard.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
"build_date": build_date,
|
"build_date": build_date,
|
||||||
"debug_enabled": getattr(settings, 'debug', False),
|
"debug_enabled": getattr(settings, "debug", False),
|
||||||
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"container_info": container_info,
|
"container_info": container_info,
|
||||||
"settings": {
|
"settings": {"notification_urls": notification_urls},
|
||||||
"notification_urls": notification_urls
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env")
|
@router.get("/env")
|
||||||
@require_login
|
@require_login
|
||||||
async def env_debug(request: Request):
|
async def env_debug(request: Request):
|
||||||
@@ -97,17 +98,18 @@ async def env_debug(request: Request):
|
|||||||
"""
|
"""
|
||||||
# Use the actual debug setting from configuration
|
# Use the actual debug setting from configuration
|
||||||
debug_enabled = settings.debug
|
debug_enabled = settings.debug
|
||||||
|
|
||||||
# Get settings data
|
# Get settings data
|
||||||
from app.utils.config_validator import get_settings_for_display
|
from app.utils.config_validator import get_settings_for_display
|
||||||
|
|
||||||
settings_data = get_settings_for_display(show_values=debug_enabled)
|
settings_data = get_settings_for_display(show_values=debug_enabled)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"env_debug.html",
|
"env_debug.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"settings": settings_data,
|
"settings": settings_data,
|
||||||
"debug_enabled": debug_enabled,
|
"debug_enabled": debug_enabled,
|
||||||
"app_version": settings.version
|
"app_version": settings.version,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+21
-30
@@ -2,21 +2,16 @@
|
|||||||
Setup wizard views for initial system configuration.
|
Setup wizard views for initial system configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from fastapi import Request, Depends, Form
|
|
||||||
|
from fastapi import Depends, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, get_db
|
|
||||||
from app.utils.setup_wizard import (
|
|
||||||
is_setup_required,
|
|
||||||
get_required_settings,
|
|
||||||
get_wizard_steps,
|
|
||||||
get_missing_required_settings
|
|
||||||
)
|
|
||||||
from app.utils.settings_service import save_setting_to_db
|
from app.utils.settings_service import save_setting_to_db
|
||||||
|
from app.utils.setup_wizard import get_wizard_steps
|
||||||
|
from app.views.base import APIRouter, get_db, templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,26 +21,26 @@ router = APIRouter()
|
|||||||
async def setup_wizard(request: Request, step: int = 1):
|
async def setup_wizard(request: Request, step: int = 1):
|
||||||
"""
|
"""
|
||||||
Setup wizard for first-time configuration.
|
Setup wizard for first-time configuration.
|
||||||
|
|
||||||
This wizard guides users through configuring essential settings
|
This wizard guides users through configuring essential settings
|
||||||
needed for the system to operate properly.
|
needed for the system to operate properly.
|
||||||
"""
|
"""
|
||||||
# Get wizard steps
|
# Get wizard steps
|
||||||
wizard_steps = get_wizard_steps()
|
wizard_steps = get_wizard_steps()
|
||||||
max_step = max(wizard_steps.keys())
|
max_step = max(wizard_steps.keys())
|
||||||
|
|
||||||
# Validate step number
|
# Validate step number
|
||||||
if step < 1:
|
if step < 1:
|
||||||
step = 1
|
step = 1
|
||||||
elif step > max_step:
|
elif step > max_step:
|
||||||
step = max_step
|
step = max_step
|
||||||
|
|
||||||
# Get settings for current step
|
# Get settings for current step
|
||||||
current_settings = wizard_steps.get(step, [])
|
current_settings = wizard_steps.get(step, [])
|
||||||
|
|
||||||
# Get step category (all settings in a step should have same category)
|
# Get step category (all settings in a step should have same category)
|
||||||
step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration"
|
step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration"
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"setup_wizard.html",
|
"setup_wizard.html",
|
||||||
{
|
{
|
||||||
@@ -54,59 +49,55 @@ async def setup_wizard(request: Request, step: int = 1):
|
|||||||
"max_step": max_step,
|
"max_step": max_step,
|
||||||
"settings": current_settings,
|
"settings": current_settings,
|
||||||
"step_category": step_category,
|
"step_category": step_category,
|
||||||
"progress_percent": int((step / max_step) * 100)
|
"progress_percent": int((step / max_step) * 100),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setup")
|
@router.post("/setup")
|
||||||
async def setup_wizard_save(
|
async def setup_wizard_save(request: Request, step: int = Form(...), db: Session = Depends(get_db)):
|
||||||
request: Request,
|
|
||||||
step: int = Form(...),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Save settings from the current wizard step.
|
Save settings from the current wizard step.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get form data
|
# Get form data
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
# Get settings for current step
|
# Get settings for current step
|
||||||
wizard_steps = get_wizard_steps()
|
wizard_steps = get_wizard_steps()
|
||||||
current_settings = wizard_steps.get(step, [])
|
current_settings = wizard_steps.get(step, [])
|
||||||
|
|
||||||
# Save each setting from the form
|
# Save each setting from the form
|
||||||
saved_count = 0
|
saved_count = 0
|
||||||
for setting in current_settings:
|
for setting in current_settings:
|
||||||
key = setting["key"]
|
key = setting["key"]
|
||||||
value = form_data.get(key)
|
value = form_data.get(key)
|
||||||
|
|
||||||
# Skip empty values unless it's explicitly allowed
|
# Skip empty values unless it's explicitly allowed
|
||||||
if value and value.strip():
|
if value and value.strip():
|
||||||
# Auto-generate session_secret if needed
|
# Auto-generate session_secret if needed
|
||||||
if key == "session_secret" and value == "auto-generate":
|
if key == "session_secret" and value == "auto-generate":
|
||||||
value = secrets.token_hex(32)
|
value = secrets.token_hex(32)
|
||||||
logger.info("Auto-generated session secret")
|
logger.info("Auto-generated session secret")
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
if save_setting_to_db(db, key, value):
|
if save_setting_to_db(db, key, value):
|
||||||
saved_count += 1
|
saved_count += 1
|
||||||
logger.info(f"Setup wizard: Saved {key}")
|
logger.info(f"Setup wizard: Saved {key}")
|
||||||
|
|
||||||
logger.info(f"Setup wizard step {step}: Saved {saved_count} settings")
|
logger.info(f"Setup wizard step {step}: Saved {saved_count} settings")
|
||||||
|
|
||||||
# Determine next step
|
# Determine next step
|
||||||
max_step = max(wizard_steps.keys())
|
max_step = max(wizard_steps.keys())
|
||||||
next_step = step + 1
|
next_step = step + 1
|
||||||
|
|
||||||
if next_step > max_step:
|
if next_step > max_step:
|
||||||
# Setup complete, redirect to home
|
# Setup complete, redirect to home
|
||||||
return RedirectResponse(url="/?setup=complete", status_code=303)
|
return RedirectResponse(url="/?setup=complete", status_code=303)
|
||||||
else:
|
else:
|
||||||
# Go to next step
|
# Go to next step
|
||||||
return RedirectResponse(url=f"/setup?step={next_step}", status_code=303)
|
return RedirectResponse(url=f"/setup?step={next_step}", status_code=303)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving wizard settings: {e}")
|
logger.error(f"Error saving wizard settings: {e}")
|
||||||
return RedirectResponse(url=f"/setup?step={step}&error=save_failed", status_code=303)
|
return RedirectResponse(url=f"/setup?step={step}&error=save_failed", status_code=303)
|
||||||
@@ -116,7 +107,7 @@ async def setup_wizard_save(
|
|||||||
async def setup_wizard_skip(request: Request):
|
async def setup_wizard_skip(request: Request):
|
||||||
"""
|
"""
|
||||||
Skip the setup wizard (for advanced users).
|
Skip the setup wizard (for advanced users).
|
||||||
|
|
||||||
Creates a marker to indicate setup was skipped.
|
Creates a marker to indicate setup was skipped.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user