Merge pull request #556 from christianlouis/copilot/feature-universal-dashboard

feat(ui): Universal Sources & Destinations integrations dashboard
This commit is contained in:
Christian Krakau-Louis
2026-03-08 19:08:11 +01:00
committed by GitHub
6 changed files with 1465 additions and 6 deletions
+2
View File
@@ -15,6 +15,7 @@ from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router
from app.views.help import router as help_router # Built-in help / How-To docs
from app.views.imap_accounts import router as imap_accounts_router
from app.views.integrations import router as integrations_router # Unified integrations dashboard
from app.views.license_routes import router as license_router # Add the license router
from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router
@@ -48,4 +49,5 @@ router.include_router(plans_router) # Admin Plan Designer
router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(help_router) # Built-in help / How-To docs
+116
View File
@@ -0,0 +1,116 @@
"""User-facing view for the unified Sources & Destinations dashboard."""
import logging
from fastapi import HTTPException, Request, status
from sqlalchemy.orm import Session
from app.models import IntegrationDirection, IntegrationType, UserIntegration
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
from app.views.base import APIRouter, Depends, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
# Source types that consume the mailbox quota (mirrored from app.api.integrations)
_MAILBOX_SOURCE_TYPES = {IntegrationType.IMAP}
_FREE_TIER_ID = "free"
def _get_max_destinations(tier: dict) -> int | None:
"""Return the maximum number of storage destinations allowed by *tier*.
Returns ``None`` for unlimited, or a positive int for the cap.
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_dest: int = tier.get("max_storage_destinations", 0)
if tier_id == _FREE_TIER_ID:
return max_dest if max_dest > 0 else 1
if max_dest == 0:
return None
return max_dest
def _get_max_sources(tier: dict) -> int | None:
"""Return the maximum number of IMAP sources allowed by *tier*.
Returns ``None`` for unlimited, ``0`` for no access, or a positive int.
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_mb: int = tier.get("max_mailboxes", 0)
if tier_id == _FREE_TIER_ID:
return 0
if max_mb == 0:
return None
return max_mb
@router.get("/integrations")
@require_login
async def integrations_dashboard(request: Request, db: Session = Depends(get_db)):
"""Render the unified Sources & Destinations dashboard."""
try:
owner_id = get_current_owner_id(request)
dest_count = 0
src_count = 0
max_destinations: int | None = 1
max_sources: int | None = 0
can_add_destination = False
can_add_source = False
tier_name = "Free"
tier_id = "free"
if owner_id:
from sqlalchemy import func
dest_count = (
db.query(func.count())
.select_from(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.DESTINATION,
)
.scalar()
) or 0
src_count = (
db.query(func.count())
.select_from(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.SOURCE,
UserIntegration.integration_type.in_(list(_MAILBOX_SOURCE_TYPES)),
)
.scalar()
) or 0
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
tier_name = tier.get("name", tier_id)
max_destinations = _get_max_destinations(tier)
max_sources = _get_max_sources(tier)
can_add_destination = max_destinations is None or dest_count < max_destinations
can_add_source = max_sources is None or (max_sources > 0 and src_count < max_sources)
return templates.TemplateResponse(
"integrations_dashboard.html",
{
"request": request,
"dest_count": dest_count,
"src_count": src_count,
"max_destinations": max_destinations,
"max_sources": max_sources,
"can_add_destination": can_add_destination,
"can_add_source": can_add_source,
"tier_id": tier_id,
"tier_name": tier_name,
},
)
except HTTPException:
raise
except Exception as exc:
logger.error(f"Error loading integrations dashboard: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load integrations dashboard",
)
+33
View File
@@ -140,6 +140,39 @@ In addition to the system-wide IMAP mailboxes configured by the administrator vi
The quota bar on the Email Ingestion page shows your current usage against your plan limit. If you have reached the limit, delete an existing account or upgrade your plan.
#### Integrations Dashboard
The **Integrations** page (`/integrations`) provides a unified view of all your configured ingestion sources and storage destinations. Instead of managing each integration type separately, you can create, edit, test, and delete any integration from a single dashboard.
**Opening the dashboard:** Click **Integrations** in the top navigation bar.
**Quota indicators** at the top of the page show your current usage:
- **Mailbox Sources** — how many IMAP ingestion accounts you have vs. your plan limit
- **Storage Destinations** — how many storage targets you have vs. your plan limit
- An **Upgrade Plan** link appears when you have reached your plan limit
**Adding a new integration:**
1. Click **Add Integration**.
2. Choose a **Direction** — Source (ingestion) or Destination (storage).
3. Choose an **Integration Type** (e.g. IMAP, S3, Dropbox, WebDAV).
4. Fill in the type-specific fields — the form adapts dynamically based on your choice:
- **IMAP** — host, port, username, password, SSL toggle
- **S3** — bucket, region, access key, secret key
- **WebDAV / Nextcloud** — URL, folder, username, password
- **FTP / SFTP** — host, port, remote path, username, password
- **Dropbox / Google Drive / OneDrive** — folder path, with a link to the OAuth setup page
- **Email Forward** — recipient email address
- **Watch Folder** — folder path
- **Paperless NGX** — URL and API token
5. Click **Test Connection** to verify the settings before saving.
6. Click **Save** to persist the integration.
**Managing existing integrations:**
- Click **Test** on any card to re-verify the connection.
- Click **Edit** to update the configuration or credentials.
- Click **Delete** to permanently remove the integration.
### Watch Folders (Automatic Folder Ingestion)
Watch folders allow DocuElevate to automatically monitor directories for new files and ingest them without any manual action.
+6 -6
View File
@@ -106,10 +106,10 @@
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>Pipelines
</a>
<a href="/imap-accounts"
<a href="/integrations"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/imap-accounts' %}aria-current="page"{% endif %}>
<i class="fas fa-envelope-open-text mr-1 text-gray-400" aria-hidden="true"></i>Email Ingestion
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
<i class="fas fa-plug mr-1 text-gray-400" aria-hidden="true"></i>Integrations
</a>
<!-- Admin dropdown shown only for admin users via JS -->
@@ -278,10 +278,10 @@
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>Pipelines
</a>
<a href="/imap-accounts"
<a href="/integrations"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/imap-accounts' %}aria-current="page"{% endif %}>
<i class="fas fa-envelope-open-text mr-2 text-gray-400" aria-hidden="true"></i>Email Ingestion
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
</a>
<!-- Admin section in mobile menu shown only for admin users via JS -->
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
"""Tests for app/views/integrations.py — the unified integrations dashboard.
Covers the /integrations view route, quota rendering, and unit-level
helpers for subscription-tier limits.
"""
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Integration tests — real HTTP round-trips via the TestClient
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestIntegrationsDashboardView:
"""Integration tests for the GET /integrations view route."""
def test_integrations_page_returns_200(self, client):
"""GET /integrations returns HTTP 200."""
response = client.get("/integrations")
assert response.status_code == 200
def test_integrations_page_renders_html(self, client):
"""GET /integrations returns HTML content with a proper html tag."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"<html" in response.content.lower()
def test_integrations_page_contains_title(self, client):
"""GET /integrations response contains the page title."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"Integrations" in response.content
def test_integrations_page_content_type_is_html(self, client):
"""GET /integrations response has HTML content-type."""
response = client.get("/integrations")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
def test_integrations_route_is_registered(self, client):
"""GET /integrations must not return 405 Method Not Allowed."""
response = client.get("/integrations", follow_redirects=False)
assert response.status_code != 405
def test_integrations_page_contains_plug_icon(self, client):
"""GET /integrations page contains the integrations plug icon class."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"fa-plug" in response.content
def test_integrations_page_contains_add_button(self, client):
"""GET /integrations response references an add-integration button."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"Add Integration" in response.content
def test_integrations_page_contains_alpine_app(self, client):
"""GET /integrations response includes the Alpine.js app component."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"integrationsDashboard" in response.content
def test_integrations_page_contains_quota_indicators(self, client):
"""GET /integrations page includes quota labels."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"Mailbox Sources" in response.content
assert b"Storage Destinations" in response.content
def test_integrations_page_contains_upgrade_plan_link(self, client):
"""GET /integrations page includes an upgrade plan CTA."""
response = client.get("/integrations")
assert response.status_code == 200
assert b"Upgrade Plan" in response.content
# ---------------------------------------------------------------------------
# Unit tests — helpers and edge-cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestIntegrationsDashboardUnit:
"""Unit tests for app/views/integrations.py helpers."""
def test_get_max_destinations_free_tier(self):
"""Free tier returns the configured max or safe default 1."""
from app.views.integrations import _get_max_destinations
tier = {"id": "free", "max_storage_destinations": 2}
assert _get_max_destinations(tier) == 2
def test_get_max_destinations_free_tier_zero(self):
"""Free tier with max_storage_destinations=0 returns safe default 1."""
from app.views.integrations import _get_max_destinations
tier = {"id": "free", "max_storage_destinations": 0}
assert _get_max_destinations(tier) == 1
def test_get_max_destinations_paid_unlimited(self):
"""Paid tier with 0 means unlimited (None)."""
from app.views.integrations import _get_max_destinations
tier = {"id": "business", "max_storage_destinations": 0}
assert _get_max_destinations(tier) is None
def test_get_max_destinations_paid_limited(self):
"""Paid tier with positive value returns that limit."""
from app.views.integrations import _get_max_destinations
tier = {"id": "starter", "max_storage_destinations": 5}
assert _get_max_destinations(tier) == 5
def test_get_max_sources_free_tier(self):
"""Free tier always returns 0 (no access)."""
from app.views.integrations import _get_max_sources
tier = {"id": "free", "max_mailboxes": 0}
assert _get_max_sources(tier) == 0
def test_get_max_sources_paid_unlimited(self):
"""Paid tier with max_mailboxes=0 means unlimited (None)."""
from app.views.integrations import _get_max_sources
tier = {"id": "business", "max_mailboxes": 0}
assert _get_max_sources(tier) is None
def test_get_max_sources_paid_limited(self):
"""Paid tier with positive value returns that limit."""
from app.views.integrations import _get_max_sources
tier = {"id": "starter", "max_mailboxes": 3}
assert _get_max_sources(tier) == 3
def test_page_error_returns_500(self, client):
"""When template rendering raises an exception, GET /integrations returns HTTP 500."""
with patch(
"app.views.integrations.templates.TemplateResponse",
side_effect=Exception("render error"),
):
response = client.get("/integrations")
assert response.status_code == 500
def test_page_error_returns_html_500(self, client):
"""HTTP 500 response from the non-API /integrations route returns an HTML page."""
with patch(
"app.views.integrations.templates.TemplateResponse",
side_effect=Exception("render error"),
):
response = client.get("/integrations")
assert response.status_code == 500
assert "text/html" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_page_context_contains_quota_data(self):
"""Verify the view passes quota data to the template context."""
from app.views.integrations import integrations_dashboard
mock_request = MagicMock()
mock_request.session = {"user": {"id": "test-user"}}
mock_db = MagicMock()
with (
patch("app.views.integrations.get_current_owner_id", return_value=None),
patch("app.views.integrations.templates") as mock_templates,
):
mock_templates.TemplateResponse = MagicMock()
await integrations_dashboard(request=mock_request, db=mock_db)
call_args = mock_templates.TemplateResponse.call_args
ctx = call_args[0][1]
assert "dest_count" in ctx
assert "src_count" in ctx
assert "max_destinations" in ctx
assert "max_sources" in ctx
assert "can_add_destination" in ctx
assert "can_add_source" in ctx
assert "tier_name" in ctx