From f3627581ab98b6902c1042834b81da78ba4bd82f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:12:22 +0000 Subject: [PATCH 1/4] Initial plan From 2c652dcc3e3b7bf5b959de8d0a1fe3cd6635763c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:34:22 +0000 Subject: [PATCH 2/4] feat(ui): add unified Sources & Destinations integrations dashboard Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/views/__init__.py | 2 + app/views/integrations.py | 107 ++ frontend/templates/base.html | 10 + .../templates/integrations_dashboard.html | 1116 +++++++++++++++++ tests/test_views_integrations.py | 180 +++ 5 files changed, 1415 insertions(+) create mode 100644 app/views/integrations.py create mode 100644 frontend/templates/integrations_dashboard.html create mode 100644 tests/test_views_integrations.py diff --git a/app/views/__init__.py b/app/views/__init__.py index c57c027a..ca2e9952 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/integrations.py b/app/views/integrations.py new file mode 100644 index 00000000..3c65daa3 --- /dev/null +++ b/app/views/integrations.py @@ -0,0 +1,107 @@ +"""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: + integrations = ( + db.query(UserIntegration) + .filter(UserIntegration.owner_id == owner_id) + .order_by(UserIntegration.id) + .all() + ) + dest_count = sum(1 for i in integrations if i.direction == IntegrationDirection.DESTINATION) + src_count = sum( + 1 + for i in integrations + if i.direction == IntegrationDirection.SOURCE and i.integration_type in _MAILBOX_SOURCE_TYPES + ) + 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", + ) diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 7b0d9d14..2e61ae37 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -111,6 +111,11 @@ {% if request and request.url.path == '/imap-accounts' %}aria-current="page"{% endif %}> Email Ingestion + + Integrations +