Add file processing notifications feature
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -146,6 +146,7 @@ NOTIFY_ON_TASK_FAILURE=True
|
|||||||
NOTIFY_ON_CREDENTIAL_FAILURE=True
|
NOTIFY_ON_CREDENTIAL_FAILURE=True
|
||||||
NOTIFY_ON_STARTUP=True
|
NOTIFY_ON_STARTUP=True
|
||||||
NOTIFY_ON_SHUTDOWN=False
|
NOTIFY_ON_SHUTDOWN=False
|
||||||
|
NOTIFY_ON_FILE_PROCESSED=True
|
||||||
|
|
||||||
# Uptime Kuma
|
# Uptime Kuma
|
||||||
UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up
|
UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Send notifications when application shuts down"
|
description="Send notifications when application shuts down"
|
||||||
)
|
)
|
||||||
|
notify_on_file_processed: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Send notifications when files are successfully processed"
|
||||||
|
)
|
||||||
|
|
||||||
@validator('notification_urls', pre=True)
|
@validator('notification_urls', pre=True)
|
||||||
def parse_notification_urls(cls, v):
|
def parse_notification_urls(cls, v):
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
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
|
||||||
|
|
||||||
# 1) Import the aggregator task
|
# 1) Import the aggregator task
|
||||||
from app.tasks.send_to_all import send_to_all_destinations
|
from app.tasks.send_to_all import send_to_all_destinations, get_configured_services_from_validator
|
||||||
|
|
||||||
|
# Import notification utility
|
||||||
|
from app.utils.notification import notify_file_processed
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=BaseTaskWithRetry)
|
@celery.task(base=BaseTaskWithRetry)
|
||||||
@@ -14,12 +18,42 @@ def finalize_document_storage(original_file: str, processed_file: str, metadata:
|
|||||||
"""
|
"""
|
||||||
Final storage step after embedding metadata.
|
Final storage step after embedding metadata.
|
||||||
We will now call 'send_to_all_destinations' to push the final PDF to Dropbox/Nextcloud/Paperless.
|
We will now call 'send_to_all_destinations' to push the final PDF to Dropbox/Nextcloud/Paperless.
|
||||||
|
After uploading, send a notification about the processed file.
|
||||||
"""
|
"""
|
||||||
print(f"[INFO] Finalizing document storage for {processed_file}")
|
print(f"[INFO] Finalizing document storage for {processed_file}")
|
||||||
|
|
||||||
|
# Determine which destinations are configured
|
||||||
|
configured_destinations = []
|
||||||
|
try:
|
||||||
|
configured_services = get_configured_services_from_validator()
|
||||||
|
# Get list of service names that are configured
|
||||||
|
for service_name, is_configured in configured_services.items():
|
||||||
|
if is_configured:
|
||||||
|
# Format service names for display
|
||||||
|
display_name = service_name.replace('_', ' ').title()
|
||||||
|
configured_destinations.append(display_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Could not determine configured destinations: {e}")
|
||||||
|
configured_destinations = ["configured destinations"]
|
||||||
|
|
||||||
# 2) Enqueue uploads to all destinations (Dropbox, Nextcloud, Paperless)
|
# 2) Enqueue uploads to all destinations (Dropbox, Nextcloud, Paperless)
|
||||||
send_to_all_destinations.delay(processed_file)
|
send_to_all_destinations.delay(processed_file)
|
||||||
|
|
||||||
|
# 3) Send notification about successful file processing
|
||||||
|
try:
|
||||||
|
# Get file information
|
||||||
|
file_size = os.path.getsize(processed_file) if os.path.exists(processed_file) else 0
|
||||||
|
filename = os.path.basename(processed_file)
|
||||||
|
|
||||||
|
notify_file_processed(
|
||||||
|
filename=filename,
|
||||||
|
file_size=file_size,
|
||||||
|
metadata=metadata,
|
||||||
|
destinations=configured_destinations
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Failed to send file processed notification: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "Completed",
|
"status": "Completed",
|
||||||
"file": processed_file
|
"file": processed_file
|
||||||
|
|||||||
@@ -183,3 +183,38 @@ def notify_shutdown() -> bool:
|
|||||||
notification_type="info",
|
notification_type="info",
|
||||||
tags=["system", "shutdown"]
|
tags=["system", "shutdown"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def notify_file_processed(filename: str, file_size: int, metadata: dict, destinations: list) -> bool:
|
||||||
|
"""Send a notification that a file has been successfully processed"""
|
||||||
|
if not settings.notify_on_file_processed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Format file size for display
|
||||||
|
size_mb = file_size / (1024 * 1024)
|
||||||
|
size_str = f"{size_mb:.2f} MB" if size_mb >= 1 else f"{file_size / 1024:.2f} KB"
|
||||||
|
|
||||||
|
# Extract key metadata fields
|
||||||
|
doc_type = metadata.get('document_type', 'Unknown')
|
||||||
|
tags = metadata.get('tags', [])
|
||||||
|
tags_str = ', '.join(tags) if tags else 'None'
|
||||||
|
|
||||||
|
# Format destinations
|
||||||
|
destinations_str = ', '.join(destinations) if destinations else 'None configured'
|
||||||
|
|
||||||
|
title = f"File Processed: {filename}"
|
||||||
|
message = f"""
|
||||||
|
File: {filename}
|
||||||
|
Size: {size_str}
|
||||||
|
Document Type: {doc_type}
|
||||||
|
Tags: {tags_str}
|
||||||
|
Destinations: {destinations_str}
|
||||||
|
|
||||||
|
The file has been successfully processed and uploaded to all configured destinations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_notification(
|
||||||
|
title=title,
|
||||||
|
message=message.strip(),
|
||||||
|
notification_type="success",
|
||||||
|
tags=["document", "processed", "success"]
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ This guide explains how to set up the notification system for DocuElevate, which
|
|||||||
| `NOTIFY_ON_CREDENTIAL_FAILURE` | Send notifications on credential failures (`True`/`False`) |
|
| `NOTIFY_ON_CREDENTIAL_FAILURE` | Send notifications on credential failures (`True`/`False`) |
|
||||||
| `NOTIFY_ON_STARTUP` | Send notification when system starts (`True`/`False`) |
|
| `NOTIFY_ON_STARTUP` | Send notification when system starts (`True`/`False`) |
|
||||||
| `NOTIFY_ON_SHUTDOWN` | Send notification when system shuts down (`True`/`False`)|
|
| `NOTIFY_ON_SHUTDOWN` | Send notification when system shuts down (`True`/`False`)|
|
||||||
|
| `NOTIFY_ON_FILE_PROCESSED` | Send notifications when files are processed (`True`/`False`)|
|
||||||
|
|
||||||
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
||||||
|
|
||||||
@@ -61,6 +62,42 @@ NOTIFY_ON_TASK_FAILURE=True # Notify when background tasks fail
|
|||||||
NOTIFY_ON_CREDENTIAL_FAILURE=True # Notify when service credentials fail (e.g. API token expired)
|
NOTIFY_ON_CREDENTIAL_FAILURE=True # Notify when service credentials fail (e.g. API token expired)
|
||||||
NOTIFY_ON_STARTUP=True # Notify when the system starts
|
NOTIFY_ON_STARTUP=True # Notify when the system starts
|
||||||
NOTIFY_ON_SHUTDOWN=False # Notify when the system shuts down
|
NOTIFY_ON_SHUTDOWN=False # Notify when the system shuts down
|
||||||
|
NOTIFY_ON_FILE_PROCESSED=True # Notify when files are successfully processed
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Processing Notifications
|
||||||
|
|
||||||
|
DocuElevate can notify you whenever a document is successfully processed and uploaded to your configured destinations. When enabled, you'll receive a notification containing:
|
||||||
|
|
||||||
|
- **File name**: The name of the processed document
|
||||||
|
- **File size**: Size of the processed file
|
||||||
|
- **Document type**: The type of document detected by AI (invoice, receipt, contract, etc.)
|
||||||
|
- **Tags**: Any tags extracted from the document
|
||||||
|
- **Destinations**: List of destinations where the file was uploaded
|
||||||
|
|
||||||
|
This feature is particularly useful for:
|
||||||
|
- Tracking document processing in real-time
|
||||||
|
- Confirming important documents have been processed
|
||||||
|
- Monitoring the document processing workflow
|
||||||
|
- Receiving immediate feedback when uploading documents via email or API
|
||||||
|
|
||||||
|
To enable file processing notifications:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NOTIFY_ON_FILE_PROCESSED=True
|
||||||
|
```
|
||||||
|
|
||||||
|
Example notification:
|
||||||
|
```
|
||||||
|
Subject: File Processed: Invoice_2024-01-15.pdf
|
||||||
|
|
||||||
|
File: Invoice_2024-01-15.pdf
|
||||||
|
Size: 1.23 MB
|
||||||
|
Document Type: Invoice
|
||||||
|
Tags: financial, vendor-acme, urgent
|
||||||
|
Destinations: Dropbox, Google Drive, Paperless-ngx
|
||||||
|
|
||||||
|
The file has been successfully processed and uploaded to all configured destinations.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Automated Credential Checking
|
## Automated Credential Checking
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from app.utils.notification import notify_file_processed, send_notification
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileProcessedNotification(unittest.TestCase):
|
||||||
|
"""Test file processing notification functionality"""
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_when_enabled(self, mock_send):
|
||||||
|
"""Test that notification is sent when NOTIFY_ON_FILE_PROCESSED is True"""
|
||||||
|
# Arrange
|
||||||
|
mock_send.return_value = True
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = True
|
||||||
|
|
||||||
|
filename = "test_document.pdf"
|
||||||
|
file_size = 1024 * 1024 # 1 MB
|
||||||
|
metadata = {
|
||||||
|
'document_type': 'Invoice',
|
||||||
|
'tags': ['financial', 'urgent']
|
||||||
|
}
|
||||||
|
destinations = ['Dropbox', 'Google Drive']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(result)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
# Check the call arguments
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
self.assertIn("test_document.pdf", call_args[1]['title'])
|
||||||
|
self.assertIn("Invoice", call_args[1]['message'])
|
||||||
|
self.assertIn("financial, urgent", call_args[1]['message'])
|
||||||
|
self.assertIn("Dropbox, Google Drive", call_args[1]['message'])
|
||||||
|
self.assertEqual(call_args[1]['notification_type'], "success")
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_when_disabled(self, mock_send):
|
||||||
|
"""Test that notification is not sent when NOTIFY_ON_FILE_PROCESSED is False"""
|
||||||
|
# Arrange
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = False
|
||||||
|
|
||||||
|
filename = "test_document.pdf"
|
||||||
|
file_size = 1024 * 1024
|
||||||
|
metadata = {'document_type': 'Invoice', 'tags': []}
|
||||||
|
destinations = ['Dropbox']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertFalse(result)
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_with_no_destinations(self, mock_send):
|
||||||
|
"""Test notification when no destinations are configured"""
|
||||||
|
# Arrange
|
||||||
|
mock_send.return_value = True
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = True
|
||||||
|
|
||||||
|
filename = "test_document.pdf"
|
||||||
|
file_size = 512 * 1024 # 512 KB
|
||||||
|
metadata = {
|
||||||
|
'document_type': 'Receipt',
|
||||||
|
'tags': []
|
||||||
|
}
|
||||||
|
destinations = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(result)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
# Check that message indicates no destinations
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
self.assertIn("None configured", call_args[1]['message'])
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_formats_file_size_mb(self, mock_send):
|
||||||
|
"""Test that file size is formatted correctly for MB"""
|
||||||
|
# Arrange
|
||||||
|
mock_send.return_value = True
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = True
|
||||||
|
|
||||||
|
filename = "large_document.pdf"
|
||||||
|
file_size = 5 * 1024 * 1024 # 5 MB
|
||||||
|
metadata = {'document_type': 'Contract', 'tags': []}
|
||||||
|
destinations = ['Dropbox']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(result)
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
self.assertIn("5.00 MB", call_args[1]['message'])
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_formats_file_size_kb(self, mock_send):
|
||||||
|
"""Test that file size is formatted correctly for KB"""
|
||||||
|
# Arrange
|
||||||
|
mock_send.return_value = True
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = True
|
||||||
|
|
||||||
|
filename = "small_document.pdf"
|
||||||
|
file_size = 512 * 1024 # 512 KB
|
||||||
|
metadata = {'document_type': 'Note', 'tags': []}
|
||||||
|
destinations = ['Dropbox']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(result)
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
self.assertIn("512.00 KB", call_args[1]['message'])
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
@patch('app.utils.notification.send_notification')
|
||||||
|
def test_notify_file_processed_with_missing_metadata_fields(self, mock_send):
|
||||||
|
"""Test that notification handles missing metadata fields gracefully"""
|
||||||
|
# Arrange
|
||||||
|
mock_send.return_value = True
|
||||||
|
original_value = settings.notify_on_file_processed
|
||||||
|
settings.notify_on_file_processed = True
|
||||||
|
|
||||||
|
filename = "document.pdf"
|
||||||
|
file_size = 1024 * 1024
|
||||||
|
metadata = {} # Empty metadata
|
||||||
|
destinations = ['Dropbox']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = notify_file_processed(filename, file_size, metadata, destinations)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertTrue(result)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
# Check that defaults are used
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
self.assertIn("Unknown", call_args[1]['message']) # Default document type
|
||||||
|
self.assertIn("None", call_args[1]['message']) # No tags
|
||||||
|
finally:
|
||||||
|
settings.notify_on_file_processed = original_value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user