diff --git a/aegis/core/manual_updater.py b/aegis/core/manual_updater.py index d7b15f2e..e1391936 100644 --- a/aegis/core/manual_updater.py +++ b/aegis/core/manual_updater.py @@ -21,6 +21,7 @@ from .component_files import get_component_files, get_copier_defaults, get_template_path from .copier_manager import is_copier_project, load_copier_answers +from .plugin_template_resolver import get_plugin_template_root from .verbosity import verbose_print # Constants @@ -533,6 +534,74 @@ def _render_template_file( except TemplateNotFound: return None + def install_plugin_template_tree(self, plugin_module_name: str) -> list[str]: + """Render the plugin's template tree into the project. + + Plugins ship a ``/templates/{{ project_slug }}/...`` + directory parallel to aegis-stack's own. This method locates + that tree via :func:`plugin_template_resolver.get_plugin_template_root`, + renders every ``*.jinja`` file through a fresh Jinja2 environment + rooted at the plugin's templates dir (so ``include`` / ``extends`` + resolve against the plugin's tree, not aegis-stack's), and writes + the rendered output at the corresponding relative path under + ``self.project_path``. + + The render context is the project's current ``self.answers``, + so plugin templates can branch on the same project state that + aegis-stack templates do (``include_database``, ``database_engine``, + etc.). + + Returns the list of relative paths written. Empty list when the + plugin ships no templates (pure-code plugin). + + Existing files are overwritten. Per-file conflict policy lives at + the calling level (``aegis add`` in round 8b); for now this is + used by tests and by future ``aegis add`` once it lands. + + **Filesystem-only.** Uses ``Path.rglob`` and ``FileSystemLoader`` + on the resolver's returned path, which requires a real on-disk + directory. Zipped wheels are not supported today — see + ``plugin_template_resolver`` for the rationale. + """ + template_root = get_plugin_template_root(plugin_module_name) + if template_root is None: + return [] + + # Plugin templates mirror aegis-stack's: every file is nested + # under ``{{ project_slug }}/`` so the rendered path is naturally + # rooted at the project tree. + project_slug_dir = template_root / PROJECT_SLUG_PLACEHOLDER + if not project_slug_dir.is_dir(): + return [] + + plugin_env = Environment( + loader=FileSystemLoader(str(template_root)), + trim_blocks=False, + lstrip_blocks=False, + ) + + files_written: list[str] = [] + for source_file in sorted(project_slug_dir.rglob(f"*{JINJA_EXTENSION}")): + # Path relative to the project slug dir → relative path + # inside the target project. Strip the ``.jinja`` suffix + # since the rendered file shouldn't keep it. + rel_inside_slug = source_file.relative_to(project_slug_dir) + out_rel = rel_inside_slug.with_suffix("") + out_path = self.project_path / out_rel + + # Jinja2 needs the template name relative to the loader's + # root (template_root, not project_slug_dir) so it can + # resolve includes against sibling files. + template_name = str(source_file.relative_to(template_root)) + template = plugin_env.get_template(template_name) + content = template.render(self.answers) + + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content) + files_written.append(str(out_rel)) + + return files_written + def _save_answers(self, answers: dict[str, Any]) -> None: """ Save updated answers to .copier-answers.yml. diff --git a/aegis/core/plugin_composer.py b/aegis/core/plugin_composer.py index d0891fff..3e479e4d 100644 --- a/aegis/core/plugin_composer.py +++ b/aegis/core/plugin_composer.py @@ -82,6 +82,14 @@ def serialize_plugin_to_answer( "version": spec.version, "verified": spec.verified, "options": dict(plugin_options or {}), + # Packaging metadata — flat lists templates iterate to emit + # pyproject.toml deps and docker-compose service blocks. Not + # under ``wiring`` because these aren't injection-point hooks + # (they don't have ``when`` predicates and don't need + # serialize-time filtering); they're declarative packaging + # data the plugin ships unconditionally. + "pyproject_deps": list(spec.pyproject_deps), + "docker_services": list(spec.docker_services), "wiring": _serialize_wiring(spec.wiring, opts, spec.name), } diff --git a/aegis/core/plugin_template_resolver.py b/aegis/core/plugin_template_resolver.py new file mode 100644 index 00000000..0dd4003d --- /dev/null +++ b/aegis/core/plugin_template_resolver.py @@ -0,0 +1,87 @@ +""" +Locate a plugin's template tree on disk. + +Convention: every plugin package ships a ``templates/`` subdirectory +laid out the same way as aegis-stack's own +``aegis/templates/copier-aegis-project/{{ project_slug }}/...``. +Standard Python packaging picks the directory up automatically (both +hatchling and setuptools include non-Python files under the package +root by default), so plugin authors don't need any extra config beyond +shipping the files. + +This resolver returns the on-disk path to a plugin's +``/templates/`` directory, or ``None`` if the plugin doesn't +ship templates (some plugins are pure code — CLI hooks, dependency +providers, etc. — and contribute via wiring data alone). + +Lookup uses ``importlib.resources`` and supports filesystem-backed +plugin installs: + +* installed packages (``pip install aegis-plugin-scraper``) +* development editable installs (``pip install -e .``) + +Zipped wheels are **not** supported today — the caller +(``ManualUpdater.install_plugin_template_tree``) walks the resolved +directory with ``Path.rglob`` and renders through ``FileSystemLoader``, +both of which require a real on-disk path. Adding zip-wheel support is +straightforward (use ``importlib.resources.as_file`` as a context +manager and keep it alive for the duration of the render) but is +deferred until a real plugin ships as a non-editable wheel. + +The caller — ``ManualUpdater`` and round 8b's ``aegis add`` — iterates +the resolved tree, renders each ``*.jinja`` through the same Jinja2 +environment used for the project's own templates, and writes the +result into the target project at the corresponding relative path. +""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path + +TEMPLATE_SUBDIR = "templates" +"""Directory name plugins must use for their template tree. + +Hardcoded — keeping this a convention rather than a configurable per-plugin +setting means tooling can locate plugin templates without first reading +plugin metadata.""" + + +def get_plugin_template_root(plugin_module_name: str) -> Path | None: + """Return the on-disk path to a plugin's ``templates/`` directory. + + Args: + plugin_module_name: Top-level module name of the plugin package + (e.g. ``"aegis_plugin_scraper"``). Must be importable. + + Returns: + Path to the templates directory if the package has one, else + ``None``. The path may point at a real filesystem location (for + editable installs) or a temporary materialized directory (for + zipped wheels) — callers should treat it as read-only. + + Raises: + ModuleNotFoundError: if the plugin package isn't installed. + """ + # ``files()`` returns a Traversable rooted at the package; joinpath + # then names the templates subdir. We don't ``as_file()`` here + # because callers iterate the tree with ``rglob``/``walk`` and + # construct individual file paths — staying at the Traversable + # layer keeps that working for both filesystem and zipfile cases. + pkg_root = resources.files(plugin_module_name) + templates = pkg_root / TEMPLATE_SUBDIR + + # ``Traversable.is_dir()`` works for both filesystem and zip-backed + # resources. We bail out early when the directory simply isn't there + # — that's the "pure-code plugin" case, not an error. + if not templates.is_dir(): + return None + + # Filesystem installs only (see module docstring): the Traversable's + # ``__fspath__`` IS a real path on disk for both ``pip install`` and + # ``pip install -e .``. For zipped wheels the str() would point inside + # the zip and ``rglob`` downstream would fail — that case is out of + # scope until a real plugin ships zipped, at which point we'd switch + # to ``importlib.resources.as_file`` with a context manager that + # stays open across the iteration. + return Path(str(templates)) diff --git a/aegis/core/services.py b/aegis/core/services.py index 4a01f3c2..4b792132 100644 --- a/aegis/core/services.py +++ b/aegis/core/services.py @@ -36,6 +36,7 @@ PluginSpec, PluginWiring, RouterWiring, + SymbolWiring, ) @@ -109,14 +110,45 @@ class ServiceSpec(PluginSpec): FrontendWidgetWiring( module="app.components.frontend.dashboard.cards.auth_card", symbol="AuthCard", - modal_id="auth_modal", + modal_id="auth", ), ], dashboard_modals=[ FrontendWidgetWiring( module="app.components.frontend.dashboard.modals.auth_modal", - symbol="AuthModal", - modal_id="auth_modal", + symbol="AuthDetailDialog", + modal_id="auth", + ), + ], + # FastAPI dependency providers — service-facade deps that + # take an AsyncSession and return a service instance. These + # used to live inline in the shared deps.py.jinja behind + # ``{% if include_auth %}`` blocks. Round 7.x moves them + # into ``app/services/auth/deps.py.jinja`` (Option-1 refactor), + # and the shared template just imports them via this list. + # + # Org-scoped providers gate on ``include_auth_org`` because + # the org/membership/invite tables only exist at that auth + # level. + deps_providers=[ + SymbolWiring( + module="app.services.auth.deps", + symbol="get_user_service", + ), + SymbolWiring( + module="app.services.auth.deps", + symbol="get_org_service", + when=lambda opts: bool(opts.get("include_auth_org")), + ), + SymbolWiring( + module="app.services.auth.deps", + symbol="get_membership_service", + when=lambda opts: bool(opts.get("include_auth_org")), + ), + SymbolWiring( + module="app.services.auth.deps", + symbol="get_invite_service", + when=lambda opts: bool(opts.get("include_auth_org")), ), ], ), @@ -158,10 +190,16 @@ class ServiceSpec(PluginSpec): default=False, ), ], + # Mirrors the ``{%- if include_auth %}`` + cross-service blocks in + # pyproject.toml.jinja. Auth contributes ``alembic`` (auth has + # migrations) and ``email-validator`` (User.email is EmailStr). + # Order matches the legacy render for byte-parity. pyproject_deps=[ "python-jose[cryptography]==3.3.0", - "passlib[bcrypt]==1.7.4", - "python-multipart==0.0.9", # For form data parsing + "bcrypt>=4.0.0", + "python-multipart==0.0.9", + "alembic==1.16.5", + "email-validator==2.2.0", ], template_files=[ "app/components/backend/api/auth/", @@ -245,14 +283,14 @@ class ServiceSpec(PluginSpec): FrontendWidgetWiring( module="app.components.frontend.dashboard.cards.ai_card", symbol="AICard", - modal_id="ai_modal", + modal_id="ai", ), ], dashboard_modals=[ FrontendWidgetWiring( module="app.components.frontend.dashboard.modals.ai_modal", - symbol="AIModal", - modal_id="ai_modal", + symbol="AIDetailDialog", + modal_id="ai", ), ], ), @@ -362,21 +400,21 @@ class ServiceSpec(PluginSpec): FrontendWidgetWiring( module="app.components.frontend.dashboard.cards.comms_card", symbol="CommsCard", - modal_id="comms_modal", + modal_id="comms", ), ], dashboard_modals=[ FrontendWidgetWiring( module="app.components.frontend.dashboard.modals.comms_modal", - symbol="CommsModal", - modal_id="comms_modal", + symbol="CommsDetailDialog", + modal_id="comms", ), ], ), pyproject_deps=[ - "resend>=2.4.0", # Email provider - "twilio>=9.3.7", # SMS/Voice provider - # Note: email-validator is shared with auth service, handled in pyproject.toml.jinja + "resend>=2.4.0", + "twilio>=9.3.7", + "email-validator==2.2.0", ], template_files=[ "app/services/comms/", @@ -423,14 +461,30 @@ class ServiceSpec(PluginSpec): FrontendWidgetWiring( module="app.components.frontend.dashboard.cards.insights_card", symbol="InsightsCard", - modal_id="insights_modal", + modal_id="service_insights", ), ], dashboard_modals=[ FrontendWidgetWiring( module="app.components.frontend.dashboard.modals.insights_modal", - symbol="InsightsModal", - modal_id="insights_modal", + symbol="InsightsDetailDialog", + modal_id="service_insights", + ), + ], + # FastAPI dependency providers — moved from inline definitions + # in shared deps.py.jinja into ``app/services/insights/deps.py``. + deps_providers=[ + SymbolWiring( + module="app.services.insights.deps", + symbol="get_insight_service", + ), + SymbolWiring( + module="app.services.insights.deps", + symbol="get_collector_service", + ), + SymbolWiring( + module="app.services.insights.deps", + symbol="get_query_service", ), ], ), @@ -512,21 +566,30 @@ class ServiceSpec(PluginSpec): FrontendWidgetWiring( module="app.components.frontend.dashboard.cards.payment_card", symbol="PaymentCard", - modal_id="payment_modal", + modal_id="service_payment", ), ], dashboard_modals=[ FrontendWidgetWiring( module="app.components.frontend.dashboard.modals.payment_modal", - symbol="PaymentModal", - modal_id="payment_modal", + symbol="PaymentDetailDialog", + modal_id="service_payment", + ), + ], + # FastAPI dependency providers — moved from inline definitions + # in shared deps.py.jinja into ``app/services/payment/deps.py``. + deps_providers=[ + SymbolWiring( + module="app.services.payment.deps", + symbol="get_payment_service", ), ], ), # R4-A: migrations declared on the spec. migrations=[PAYMENT_MIGRATION, PAYMENT_AUTH_LINK_MIGRATION], pyproject_deps=[ - "stripe>=11.0.0", # Stripe Python SDK + "alembic==1.16.5", + "stripe>=11.0.0", ], template_files=[ "app/services/payment/", diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/oauth.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/oauth.py.jinja index bb0c7df6..8ec994b4 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/oauth.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/oauth.py.jinja @@ -26,7 +26,8 @@ from __future__ import annotations from typing import Any import httpx -from app.components.backend.api.deps import get_async_db, get_user_service +from app.core.db import get_async_db +from app.services.auth.deps import get_user_service from app.core.config import settings from app.core.log import logger from app.core.security import create_access_token, set_session_cookie diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja index 2bf0411c..0dbcab8d 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/auth/router.py.jinja @@ -7,14 +7,12 @@ from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordRequestForm from sqlmodel.ext.asyncio.session import AsyncSession -from app.components.backend.api.deps import ( - get_async_db, - get_audit, - get_user_service, +from app.core.audit import get_audit +from app.core.db import get_async_db +from app.services.auth.deps import get_user_service {% if include_auth_org %} - get_invite_service, +from app.services.auth.deps import get_invite_service {% endif %} -) from app.components.backend.middleware.rate_limit import ( login_limiter, password_reset_limiter, diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/deps.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/deps.py.jinja index 2912446b..d972cb20 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/deps.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/deps.py.jinja @@ -1,150 +1,43 @@ -"""FastAPI dependencies for the backend API.""" +"""FastAPI dependencies for the backend API. + +Thin re-export shim. The actual definitions live next to the things +they depend on: + +* ``get_db`` / ``get_async_db`` — ``app.core.db`` (next to the engine + factories and ``SessionLocal`` / ``AsyncSessionLocal``). +* ``get_audit`` — ``app.core.audit`` (next to the singleton + ``audit_emitter``). +* Per-service ``get__service`` providers — ``app.services..deps`` + (each service owns its own deps file). + +Re-exporting here keeps ``from app.components.backend.api.deps import X`` +working for any code that still uses the api-namespace import path. +In-tree code now imports directly from the source modules; this shim +exists for plugin authors and any external code that prefers the +single canonical "all deps" import surface. +""" {% if include_database %} - -from collections.abc import AsyncGenerator, Generator - -from fastapi import Depends -from sqlmodel import Session -from sqlmodel.ext.asyncio.session import AsyncSession - -from app.core.audit import AuditEmitter, audit_emitter -from app.core.db import AsyncSessionLocal, SessionLocal -{% if include_auth %} -from app.services.auth.user_service import UserService -{% endif %} -{% if include_auth_org %} -from app.services.auth.invite_service import InviteService -from app.services.auth.membership_service import MembershipService -from app.services.auth.org_service import OrgService -{% endif %} -{% if include_insights %} -from app.services.insights.collector_service import CollectorService -from app.services.insights.insight_service import InsightService -from app.services.insights.query_service import InsightQueryService -{% endif %} -{% if include_payment %} -from app.services.payment.payment_service import PaymentService -{% endif %} - - -def get_db() -> Generator[Session]: - """ - Database dependency that provides a database session. - - This dependency is used in FastAPI route functions to get access to - the database. It automatically handles session lifecycle - creating, - yielding, and closing the session properly. - - Usage: - @router.get("/example") - def example_endpoint(db: Session = Depends(get_db)): - # Use db for database operations - pass - - Yields: - Session: SQLModel database session - """ - db = SessionLocal() - try: - yield db - finally: - db.close() - - -async def get_async_db() -> AsyncGenerator[AsyncSession]: - """ - Async database dependency that provides an async database session. - - This dependency is used in async FastAPI route functions to get access to - the database with non-blocking I/O operations. It automatically handles - session lifecycle - creating, yielding, committing and closing the session properly. - - Usage: - @router.get("/example") - async def example_endpoint(db: AsyncSession = Depends(get_async_db)): - # Use db for async database operations with await - result = await db.exec(select(MyModel)) - return result.first() - - Yields: - AsyncSession: SQLModel async database session - """ - async with AsyncSessionLocal() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise -{% if include_auth %} - - -async def get_user_service( - db: AsyncSession = Depends(get_async_db), -) -> UserService: - """Provide a UserService instance.""" - return UserService(db) -{% endif %} -{% if include_auth_org %} - - -async def get_org_service( - db: AsyncSession = Depends(get_async_db), -) -> OrgService: - """Provide an OrgService instance.""" - return OrgService(db) - - -async def get_membership_service( - db: AsyncSession = Depends(get_async_db), -) -> MembershipService: - """Provide a MembershipService instance.""" - return MembershipService(db) - - -async def get_invite_service( - db: AsyncSession = Depends(get_async_db), -) -> InviteService: - """Provide an InviteService instance.""" - return InviteService(db) -{% endif %} - - -{% if include_insights %} - - -async def get_insight_service( - db: AsyncSession = Depends(get_async_db), -) -> InsightService: - """Provide an InsightService instance.""" - return InsightService(db) - - -async def get_collector_service( - db: AsyncSession = Depends(get_async_db), -) -> CollectorService: - """Provide a CollectorService instance.""" - return CollectorService(db) - - -async def get_query_service( - db: AsyncSession = Depends(get_async_db), -) -> InsightQueryService: - """Provide an InsightQueryService instance.""" - return InsightQueryService(session=db) -{% endif %} -{% if include_payment %} - - -async def get_payment_service( - db: AsyncSession = Depends(get_async_db), -) -> PaymentService: - """Provide a PaymentService instance.""" - return PaymentService(db) -{% endif %} - - -def get_audit() -> AuditEmitter: - """Provide the audit emitter singleton.""" - return audit_emitter +from app.core.audit import get_audit +from app.core.db import get_async_db, get_db +{%- if include_auth %} +from app.services.auth.deps import get_user_service +{%- endif %} +{%- if include_auth_org %} +from app.services.auth.deps import get_org_service +from app.services.auth.deps import get_membership_service +from app.services.auth.deps import get_invite_service +{%- endif %} +{%- if include_insights %} +from app.services.insights.deps import get_insight_service +from app.services.insights.deps import get_collector_service +from app.services.insights.deps import get_query_service +{%- endif %} +{%- if include_payment %} +from app.services.payment.deps import get_payment_service +{%- endif %} +{%- for p in _plugins | default([]) %} +{%- for d in p.wiring.deps_providers %} +from {{ d.module }} import {{ d.symbol }} +{%- endfor %} +{%- endfor %} {% endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/insights.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/insights.py.jinja index 6548e3e3..055a75ab 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/insights.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/insights.py.jinja @@ -7,7 +7,7 @@ Provides bulk data loading for the Overseer dashboard and external consumers. from fastapi import APIRouter, Depends -from app.components.backend.api.deps import get_query_service +from app.services.insights.deps import get_query_service from app.core.cache import CacheService, get_cache from app.services.insights.query_service import InsightQueryService from app.services.insights.schemas import BulkInsightsResponse diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/orgs/router.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/orgs/router.py.jinja index 1df1fdb9..52d9ebbd 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/orgs/router.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/orgs/router.py.jinja @@ -6,9 +6,9 @@ import re from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession -from app.components.backend.api.deps import ( - get_async_db, - get_audit, +from app.core.audit import get_audit +from app.core.db import get_async_db +from app.services.auth.deps import ( get_invite_service, get_membership_service, get_org_service, diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/payment/router.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/payment/router.py.jinja index 65ac8cc0..3b21f984 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/payment/router.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/api/payment/router.py.jinja @@ -11,7 +11,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Request import stripe -from app.components.backend.api.deps import get_payment_service +from app.services.payment.deps import get_payment_service from app.core.config import settings from app.services.payment.catalog import get_catalog from app.services.payment.payment_service import PaymentService diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/startup/component_health.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/startup/component_health.py.jinja index efa1d919..f9fe88df 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/startup/component_health.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/backend/startup/component_health.py.jinja @@ -562,4 +562,13 @@ async def startup_hook() -> None: logger.info("Payment service health check registered") {%- endif %} + {%- for p in _plugins | default([]) %} + {%- for h in p.wiring.health_checks %} + # Register {{ h.label }} service health check (plugin) + from {{ h.module }} import {{ h.symbol }} + register_service_health_check("{{ h.label }}", {{ h.symbol }}) + logger.info("{{ h.label }} service health check registered") + {%- endfor %} + {%- endfor %} + logger.info("Service health detection complete") diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/__init__.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/__init__.py.jinja index f82d1f76..ae3e0ea3 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/__init__.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/__init__.py.jinja @@ -1,84 +1,99 @@ """Dashboard component cards.""" -{% if include_ai %} -from .ai_card import AICard -{% endif %} -{% if include_auth %} -from .auth_card import AuthCard -{% endif %} -{% if include_comms %} -from .comms_card import CommsCard -{% endif %} -{% if include_database %} +{# Services (in-tree) — alphabetical. Each emits via its own if-block. + Imports are absolute so the plugin loop below (which emits + ``from {{ c.module }} import {{ c.symbol }}``) uses the same shape + for external plugins. #} +{%- if include_ai %} +from app.components.frontend.dashboard.cards.ai_card import AICard +{%- endif %} +{%- if include_auth %} +from app.components.frontend.dashboard.cards.auth_card import AuthCard +{%- endif %} +{%- if include_comms %} +from app.components.frontend.dashboard.cards.comms_card import CommsCard +{%- endif %} +{%- if include_insights %} +from app.components.frontend.dashboard.cards.insights_card import InsightsCard +{%- endif %} +{%- if include_payment %} +from app.components.frontend.dashboard.cards.payment_card import PaymentCard +{%- endif %} +{%- for p in _plugins | default([]) %} +{%- for c in p.wiring.dashboard_cards %} +from {{ c.module }} import {{ c.symbol }} +{%- endfor %} +{%- endfor %} +{# Components and project-level conditionals — alphabetical. #} +{%- if include_database %} from .database_card import DatabaseCard -{% endif %} -{% if include_ingress %} +{%- endif %} +{%- if include_ingress %} from .ingress_card import IngressCard -{% endif %} -{% if include_insights %} -from .insights_card import InsightsCard -{% endif %} -{% if include_payment %} -from .payment_card import PaymentCard -{% endif %} -{% if include_observability %} +{%- endif %} +{%- if include_observability %} from .observability_card import ObservabilityCard -{% endif %} -{% if ollama_mode != "none" %} +{%- endif %} +{%- if ollama_mode != "none" %} from .ollama_card import OllamaCard -{% endif %} -{% if include_redis %} +{%- endif %} +{%- if include_redis %} from .redis_card import RedisCard -{% endif %} -{% if include_scheduler %} +{%- endif %} +{%- if include_scheduler %} from .scheduler_card import SchedulerCard -{% endif %} +{%- endif %} from .server_card import ServerCard -{% if include_auth or include_ai or include_comms or include_payment %} +{%- if include_auth or include_ai or include_comms or include_insights or include_payment or _plugins %} from .services_card import ServicesCard -{% endif %} -{% if include_worker %} +{%- endif %} +{%- if include_worker %} from .worker_card import WorkerCard -{% endif %} +{%- endif %} __all__ = [ "ServerCard", -{% if include_ai %} +{%- if include_ai %} "AICard", -{% endif %} -{% if include_auth %} +{%- endif %} +{%- if include_auth %} "AuthCard", -{% endif %} -{% if include_auth or include_ai or include_comms or include_payment %} - "ServicesCard", -{% endif %} -{% if include_comms %} +{%- endif %} +{%- if include_comms %} "CommsCard", -{% endif %} -{% if include_database %} - "DatabaseCard", -{% endif %} -{% if include_ingress %} - "IngressCard", -{% endif %} -{% if include_insights %} +{%- endif %} +{%- if include_insights %} "InsightsCard", -{% endif %} -{% if include_payment %} +{%- endif %} +{%- if include_payment %} "PaymentCard", -{% endif %} -{% if include_observability %} +{%- endif %} +{%- for p in _plugins | default([]) %} +{%- for c in p.wiring.dashboard_cards %} + "{{ c.symbol }}", +{%- endfor %} +{%- endfor %} +{%- if include_auth or include_ai or include_comms or include_insights or include_payment or _plugins %} + "ServicesCard", +{%- endif %} +{%- if include_database %} + "DatabaseCard", +{%- endif %} +{%- if include_ingress %} + "IngressCard", +{%- endif %} +{%- if include_observability %} "ObservabilityCard", -{% endif %} -{% if ollama_mode != "none" %} +{%- endif %} +{%- if ollama_mode != "none" %} "OllamaCard", -{% endif %} -{% if include_redis %} +{%- endif %} +{%- if include_redis %} "RedisCard", -{% endif %} -{% if include_scheduler %} +{%- endif %} +{%- if include_scheduler %} "SchedulerCard", -{% endif %} -{% if include_worker %} +{%- endif %} +{%- if include_worker %} "WorkerCard", -{% endif %} +{%- endif %} ] diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/card_utils.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/card_utils.py.jinja index 79576daf..33d0c8a0 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/card_utils.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/cards/card_utils.py.jinja @@ -453,46 +453,52 @@ def create_modal_for_component( Returns: Popup Container instance for the component, or None if component not supported """ - from ..modals import ( - {%- if include_ai %} - AIDetailDialog, - {%- endif %} - {%- if include_auth %} - AuthDetailDialog, - {%- endif %} - BackendDetailDialog, - {%- if include_comms %} - CommsDetailDialog, - {%- endif %} - {%- if include_database %} - DatabaseDetailDialog, - {%- endif %} - FrontendDetailDialog, - {%- if include_ingress %} - IngressDetailDialog, - {%- endif %} - {%- if include_insights %} - InsightsDetailDialog, - {%- endif %} - {%- if include_payment %} - PaymentDetailDialog, - {%- endif %} - {%- if include_observability %} - ObservabilityDetailDialog, - {%- endif %} - {%- if ollama_mode != "none" %} - OllamaDetailDialog, - {%- endif %} - {%- if include_redis %} - RedisDetailDialog, - {%- endif %} - {%- if include_scheduler %} - SchedulerDetailDialog, - {%- endif %} - {%- if include_worker %} - WorkerDetailDialog, - {%- endif %} - ) + # Service modal imports (in-tree + plugins) — absolute paths so + # external plugins use the same shape as in-tree services. + {%- if include_ai %} + from app.components.frontend.dashboard.modals.ai_modal import AIDetailDialog + {%- endif %} + {%- if include_auth %} + from app.components.frontend.dashboard.modals.auth_modal import AuthDetailDialog + {%- endif %} + {%- if include_comms %} + from app.components.frontend.dashboard.modals.comms_modal import CommsDetailDialog + {%- endif %} + {%- if include_insights %} + from app.components.frontend.dashboard.modals.insights_modal import InsightsDetailDialog + {%- endif %} + {%- if include_payment %} + from app.components.frontend.dashboard.modals.payment_modal import PaymentDetailDialog + {%- endif %} + {%- for p in _plugins | default([]) %} + {%- for m in p.wiring.dashboard_modals %} + from {{ m.module }} import {{ m.symbol }} + {%- endfor %} + {%- endfor %} + # Component / always-present modal imports. + from app.components.frontend.dashboard.modals.backend_modal import BackendDetailDialog + from app.components.frontend.dashboard.modals.frontend_modal import FrontendDetailDialog + {%- if include_database %} + from app.components.frontend.dashboard.modals.database_modal import DatabaseDetailDialog + {%- endif %} + {%- if include_ingress %} + from app.components.frontend.dashboard.modals.ingress_modal import IngressDetailDialog + {%- endif %} + {%- if include_observability %} + from app.components.frontend.dashboard.modals.observability_modal import ObservabilityDetailDialog + {%- endif %} + {%- if ollama_mode != "none" %} + from app.components.frontend.dashboard.modals.ollama_modal import OllamaDetailDialog + {%- endif %} + {%- if include_redis %} + from app.components.frontend.dashboard.modals.redis_modal import RedisDetailDialog + {%- endif %} + {%- if include_scheduler %} + from app.components.frontend.dashboard.modals.scheduler_modal import SchedulerDetailDialog + {%- endif %} + {%- if include_worker %} + from app.components.frontend.dashboard.modals.worker_modal import WorkerDetailDialog + {%- endif %} modal_map: dict[str, type[ft.Container]] = { {%- if include_ai %} @@ -501,23 +507,28 @@ def create_modal_for_component( {%- if include_auth %} "auth": AuthDetailDialog, {%- endif %} - "backend": BackendDetailDialog, {%- if include_comms %} "comms": CommsDetailDialog, {%- endif %} - {%- if include_database %} - "database": DatabaseDetailDialog, - {%- endif %} - "frontend": FrontendDetailDialog, - {%- if include_ingress %} - "ingress": IngressDetailDialog, - {%- endif %} {%- if include_insights %} "service_insights": InsightsDetailDialog, {%- endif %} {%- if include_payment %} "service_payment": PaymentDetailDialog, {%- endif %} + {%- for p in _plugins | default([]) %} + {%- for m in p.wiring.dashboard_modals %} + "{{ m.modal_id }}": {{ m.symbol }}, + {%- endfor %} + {%- endfor %} + "backend": BackendDetailDialog, + "frontend": FrontendDetailDialog, + {%- if include_database %} + "database": DatabaseDetailDialog, + {%- endif %} + {%- if include_ingress %} + "ingress": IngressDetailDialog, + {%- endif %} {%- if include_observability %} "observability": ObservabilityDetailDialog, {%- endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/__init__.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/__init__.py.jinja index 102c9cd9..101de044 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/__init__.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/__init__.py.jinja @@ -4,17 +4,30 @@ Dashboard Modal Components Reusable modal dialogs for displaying detailed component information. Each modal inherits from ft.AlertDialog and uses component composition. """ - +{# Services (in-tree) — alphabetical. Imports are absolute so the + plugin loop below uses the same shape for external plugins. #} {%- if include_ai %} -from .ai_modal import AIDetailDialog +from app.components.frontend.dashboard.modals.ai_modal import AIDetailDialog {%- endif %} {%- if include_auth %} -from .auth_modal import AuthDetailDialog +from app.components.frontend.dashboard.modals.auth_modal import AuthDetailDialog {%- endif %} -from .backend_modal import BackendDetailDialog {%- if include_comms %} -from .comms_modal import CommsDetailDialog +from app.components.frontend.dashboard.modals.comms_modal import CommsDetailDialog +{%- endif %} +{%- if include_insights %} +from app.components.frontend.dashboard.modals.insights_modal import InsightsDetailDialog +{%- endif %} +{%- if include_payment %} +from app.components.frontend.dashboard.modals.payment_modal import PaymentDetailDialog {%- endif %} +{%- for p in _plugins | default([]) %} +{%- for m in p.wiring.dashboard_modals %} +from {{ m.module }} import {{ m.symbol }} +{%- endfor %} +{%- endfor %} +{# Components and always-present modals — alphabetical. #} +from .backend_modal import BackendDetailDialog {%- if include_database %} from .database_modal import DatabaseDetailDialog {%- endif %} @@ -22,12 +35,6 @@ from .frontend_modal import FrontendDetailDialog {%- if include_ingress %} from .ingress_modal import IngressDetailDialog {%- endif %} -{%- if include_insights %} -from .insights_modal import InsightsDetailDialog -{%- endif %} -{%- if include_payment %} -from .payment_modal import PaymentDetailDialog -{%- endif %} {%- if include_observability %} from .observability_modal import ObservabilityDetailDialog {%- endif %} @@ -45,42 +52,47 @@ from .worker_modal import WorkerDetailDialog {%- endif %} __all__ = [ - {%- if include_ai %} +{%- if include_ai %} "AIDetailDialog", - {%- endif %} - {%- if include_auth %} +{%- endif %} +{%- if include_auth %} "AuthDetailDialog", - {%- endif %} - "BackendDetailDialog", - {%- if include_comms %} +{%- endif %} +{%- if include_comms %} "CommsDetailDialog", - {%- endif %} - {%- if include_database %} +{%- endif %} +{%- if include_insights %} + "InsightsDetailDialog", +{%- endif %} +{%- if include_payment %} + "PaymentDetailDialog", +{%- endif %} +{%- for p in _plugins | default([]) %} +{%- for m in p.wiring.dashboard_modals %} + "{{ m.symbol }}", +{%- endfor %} +{%- endfor %} + "BackendDetailDialog", +{%- if include_database %} "DatabaseDetailDialog", - {%- endif %} +{%- endif %} "FrontendDetailDialog", - {%- if include_ingress %} +{%- if include_ingress %} "IngressDetailDialog", - {%- endif %} - {%- if include_insights %} - "InsightsDetailDialog", - {%- endif %} - {%- if include_payment %} - "PaymentDetailDialog", - {%- endif %} - {%- if include_observability %} +{%- endif %} +{%- if include_observability %} "ObservabilityDetailDialog", - {%- endif %} - {%- if ollama_mode != "none" %} +{%- endif %} +{%- if ollama_mode != "none" %} "OllamaDetailDialog", - {%- endif %} - {%- if include_redis %} +{%- endif %} +{%- if include_redis %} "RedisDetailDialog", - {%- endif %} - {%- if include_scheduler %} +{%- endif %} +{%- if include_scheduler %} "SchedulerDetailDialog", - {%- endif %} - {%- if include_worker %} +{%- endif %} +{%- if include_worker %} "WorkerDetailDialog", - {%- endif %} +{%- endif %} ] diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja index 85794a96..3073dc6d 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/main.py.jinja @@ -81,13 +81,18 @@ from .dashboard.cards import ( SchedulerCard, {% endif %} ServerCard, -{% if include_auth or include_ai or include_comms or include_payment %} +{% if include_auth or include_ai or include_comms or include_insights or include_payment or _plugins %} ServicesCard, {% endif %} {% if include_worker %} WorkerCard, {% endif %} ) +{%- for p in _plugins | default([]) %} +{%- for c in p.wiring.dashboard_cards %} +from {{ c.module }} import {{ c.symbol }} +{%- endfor %} +{%- endfor %} from .dashboard.cards.card_utils import create_health_status_indicator from .dashboard.diagram import DiagramView from .dashboard.status_overview import StatusOverviewPanel @@ -855,7 +860,13 @@ async def setup_dashboard(view: BaseView) -> None: elif component_name == f"{SERVICE_PREFIX}{PAYMENT_COMPONENT_NAME}": return PaymentCard(component_data).build() {% endif %} -{% if include_auth or include_ai or include_comms or include_payment %} +{%- for p in _plugins | default([]) %} +{%- for c in p.wiring.dashboard_cards %} + elif component_name in ("{{ c.modal_id }}", f"{SERVICE_PREFIX}{{ c.modal_id }}"): + return {{ c.symbol }}(component_data).build() +{%- endfor %} +{%- endfor %} +{% if include_auth or include_ai or include_comms or include_insights or include_payment or _plugins %} elif component_name.startswith("service_"): # For other services, use generic ServicesCard for now return ServicesCard(component_data).build() diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/audit.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/audit.py index 5014a2c1..927b09a8 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/audit.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/audit.py @@ -55,3 +55,13 @@ async def emit( # Singleton instance audit_emitter = AuditEmitter() + + +def get_audit() -> AuditEmitter: + """FastAPI dependency provider returning the audit emitter singleton. + + Lives with the emitter rather than under + ``app/components/backend/api/deps.py`` so per-service deps modules + can import it without going through the api shim (see + ``app.core.db`` for the same pattern + rationale).""" + return audit_emitter diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/config.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/config.py.jinja index 1a02e636..aa0b67ad 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/config.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/config.py.jinja @@ -9,9 +9,21 @@ from environment variables for easy configuration in different environments. from typing import Any from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): +{%- for p in _plugins | default([]) %} +{%- for s in p.wiring.settings_mixins %} +from {{ s.module }} import {{ s.symbol }} +{%- endfor %} +{%- endfor %} + + +class Settings( +{%- for p in _plugins | default([]) %} +{%- for s in p.wiring.settings_mixins %} + {{ s.symbol }}, +{%- endfor %} +{%- endfor %} + BaseSettings, +): """ Defines application settings. `model_config` is used to specify that settings should be loaded from a .env file. diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/db.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/db.py.jinja index d54e7d58..1dcc498c 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/db.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/db.py.jinja @@ -210,3 +210,38 @@ def init_database() -> None: # This no-op exists so code calling init_database() still works pass {% endif %} + + +# --------------------------------------------------------------------- +# FastAPI-flavored session generators +# +# These live with the engine factories rather than under +# ``app/components/backend/api/deps.py`` so per-service deps modules +# (``app.services..deps``) can import them without going through +# the api shim — the api shim imports BACK from the per-service deps, +# which would otherwise be a circular import. +# +# The functions are plain generators; FastAPI consumers wire them via +# ``Depends(get_async_db)`` at the call site. +# --------------------------------------------------------------------- + + +def get_db() -> Generator[Session]: + """Yield a synchronous SQLModel session, closing it on exit.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +async def get_async_db() -> AsyncGenerator[AsyncSession]: + """Yield an async SQLModel session, committing on success and rolling + back on any exception.""" + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja index 8334b10e..402ca090 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/auth_service.py.jinja @@ -10,7 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from fastapi import Cookie, Depends from fastapi.security import OAuth2PasswordBearer -from app.components.backend.api.deps import get_async_db +from app.core.db import get_async_db from app.core.security import SESSION_COOKIE_NAME, VALID_ROLES {% endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/deps.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/deps.py.jinja index e1220cbe..f177d419 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/deps.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/deps.py.jinja @@ -1,9 +1,15 @@ """Auth dependencies for FastAPI route injection.""" -from app.components.backend.api.deps import get_async_db +from app.core.db import get_async_db from app.core.security import SESSION_COOKIE_NAME from app.models.user import User from app.services.auth.auth_service import get_current_user_from_token +from app.services.auth.user_service import UserService +{%- if include_auth_org %} +from app.services.auth.invite_service import InviteService +from app.services.auth.membership_service import MembershipService +from app.services.auth.org_service import OrgService +{%- endif %} from fastapi import Cookie, Depends from fastapi.security import OAuth2PasswordBearer from sqlmodel.ext.asyncio.session import AsyncSession @@ -31,3 +37,33 @@ async def get_current_active_user( ) -> User: """Get the current authenticated user. Raises 401 if not authenticated.""" return await get_current_user_from_token(token, db) + + +async def get_user_service( + db: AsyncSession = Depends(get_async_db), +) -> UserService: + """Provide a UserService instance.""" + return UserService(db) +{%- if include_auth_org %} + + +async def get_org_service( + db: AsyncSession = Depends(get_async_db), +) -> OrgService: + """Provide an OrgService instance.""" + return OrgService(db) + + +async def get_membership_service( + db: AsyncSession = Depends(get_async_db), +) -> MembershipService: + """Provide a MembershipService instance.""" + return MembershipService(db) + + +async def get_invite_service( + db: AsyncSession = Depends(get_async_db), +) -> InviteService: + """Provide an InviteService instance.""" + return InviteService(db) +{%- endif %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/insights/deps.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/insights/deps.py.jinja new file mode 100644 index 00000000..49e1be74 --- /dev/null +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/insights/deps.py.jinja @@ -0,0 +1,30 @@ +"""Insights dependencies for FastAPI route injection.""" + +from fastapi import Depends +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.db import get_async_db +from app.services.insights.collector_service import CollectorService +from app.services.insights.insight_service import InsightService +from app.services.insights.query_service import InsightQueryService + + +async def get_insight_service( + db: AsyncSession = Depends(get_async_db), +) -> InsightService: + """Provide an InsightService instance.""" + return InsightService(db) + + +async def get_collector_service( + db: AsyncSession = Depends(get_async_db), +) -> CollectorService: + """Provide a CollectorService instance.""" + return CollectorService(db) + + +async def get_query_service( + db: AsyncSession = Depends(get_async_db), +) -> InsightQueryService: + """Provide an InsightQueryService instance.""" + return InsightQueryService(session=db) diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/payment/deps.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/payment/deps.py.jinja new file mode 100644 index 00000000..a3cbf53f --- /dev/null +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/payment/deps.py.jinja @@ -0,0 +1,14 @@ +"""Payment dependencies for FastAPI route injection.""" + +from fastapi import Depends +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.db import get_async_db +from app.services.payment.payment_service import PaymentService + + +async def get_payment_service( + db: AsyncSession = Depends(get_async_db), +) -> PaymentService: + """Provide a PaymentService instance.""" + return PaymentService(db) diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.yml.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.yml.jinja index 4dcfb91d..2174d747 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.yml.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.yml.jinja @@ -343,6 +343,12 @@ services: - prod {%- endif %} +{%- for p in _plugins | default([]) %} +{%- for s in p.docker_services %} + {{ s | trim | indent(2, true) }} +{%- endfor %} +{%- endfor %} + # TEST PROFILE test_runner: diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/pyproject.toml.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/pyproject.toml.jinja index 50a73d6b..710bb4d2 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/pyproject.toml.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/pyproject.toml.jinja @@ -117,13 +117,18 @@ dependencies = [ "stripe>=11.0.0", {%- endif %} {%- if include_auth or include_comms %} - "email-validator==2.2.0", # Required for EmailStr in auth/comms models + "email-validator==2.2.0", {%- endif %} {%- if ai_rag %} "chromadb>=0.4.22,<1.0.0", # Vector store - pin to 0.x for stability "sentence-transformers>=3.0.0", # Local embeddings "torch>=2.0.0", # Explicit dep for CPU-only override (see [tool.uv.sources]) {%- endif %} +{%- for p in _plugins | default([]) %} +{%- for d in p.pyproject_deps %} + "{{ d }}", +{%- endfor %} +{%- endfor %} ] [project.scripts] diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_payment_endpoints.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_payment_endpoints.py index 561e6582..151434b6 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_payment_endpoints.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/api/test_payment_endpoints.py @@ -8,7 +8,7 @@ import pytest import stripe -from app.components.backend.api.deps import get_payment_service +from app.services.payment.deps import get_payment_service from app.services.payment.constants import ( ProviderKeys, TransactionStatus, diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/conftest.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/conftest.py.jinja index 69a3d7e8..f71e447b 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/conftest.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/conftest.py.jinja @@ -58,7 +58,7 @@ def client_with_db( app: FastAPI, db_session: Session ) -> Generator[TestClient, None, None]: """Create a test client with database dependency override.""" - from app.components.backend.api.deps import get_db + from app.core.db import get_db def get_test_db() -> Generator[Session, None, None]: yield db_session @@ -187,7 +187,7 @@ async def async_client_with_db( app: FastAPI, async_db_session: AsyncSession ) -> AsyncGenerator[TestClient, None]: """Create a test client with async database dependency override.""" - from app.components.backend.api.deps import get_async_db + from app.core.db import get_async_db async def get_test_async_db() -> AsyncGenerator[AsyncSession, None]: yield async_db_session diff --git a/tests/cli/test_services_cli.py b/tests/cli/test_services_cli.py index d02295a4..80310a92 100644 --- a/tests/cli/test_services_cli.py +++ b/tests/cli/test_services_cli.py @@ -739,9 +739,10 @@ def test_auth_service_dependency_chain_validation(self): "\n\n" )[0] - # Auth-specific dependencies + # Auth-specific dependencies. ``passlib[bcrypt]`` was replaced + # by ``bcrypt`` directly in the template; the spec mirrors that. assert "python-jose[cryptography]" in deps_section - assert "passlib[bcrypt]" in deps_section + assert "bcrypt" in deps_section assert "python-multipart" in deps_section # Database dependencies (auto-included) diff --git a/tests/core/test_manual_updater_plugins.py b/tests/core/test_manual_updater_plugins.py new file mode 100644 index 00000000..e01ac9d3 --- /dev/null +++ b/tests/core/test_manual_updater_plugins.py @@ -0,0 +1,117 @@ +""" +ManualUpdater plugin-template integration test. + +Exercises ``ManualUpdater.install_plugin_template_tree`` end-to-end: +the in-repo fake plugin (``tests.fixtures.aegis_plugin_test``) ships +a tiny ``templates/{{ project_slug }}/...`` tree. This test installs +it into a synthetic project directory and verifies the rendered files +land at the right paths with ``{{ project_slug }}`` substituted. + +This is the round-8a proof point for plugin distribution: a plugin's +own template files reach the project tree via the same Jinja2 +machinery aegis-stack uses for its own templates. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from aegis.core.manual_updater import ManualUpdater + +# Make sure the in-repo fake plugin is importable via importlib.resources. +TESTS_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" +if str(TESTS_FIXTURES) not in sys.path: + sys.path.insert(0, str(TESTS_FIXTURES)) + + +COPIER_ANSWERS_TEMPLATE = """\ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +project_name: Demo Project +project_slug: demo-project +include_database: false +_commit: None +_src_path: aegis/templates/copier-aegis-project +""" + + +@pytest.fixture +def fake_project(tmp_path: Path) -> Path: + """A directory shaped like a Copier-generated Aegis project, just + enough for ManualUpdater to bind to it. We don't need the project's + own files for these tests — only the answers file and a project-slug + name.""" + project = tmp_path / "demo-project" + project.mkdir() + (project / ".copier-answers.yml").write_text(COPIER_ANSWERS_TEMPLATE) + return project + + +class TestInstallPluginTemplateTree: + def test_renders_plugin_files_into_project(self, fake_project: Path) -> None: + updater = ManualUpdater(fake_project) + written = updater.install_plugin_template_tree("aegis_plugin_test") + + # Two .jinja files in the fake plugin: __init__.py.jinja + + # service.py.jinja, both under app/services/test_plugin/. + assert sorted(written) == sorted( + [ + "app/services/test_plugin/__init__.py", + "app/services/test_plugin/service.py", + ] + ) + + def test_files_land_at_expected_paths(self, fake_project: Path) -> None: + updater = ManualUpdater(fake_project) + updater.install_plugin_template_tree("aegis_plugin_test") + + init_file = fake_project / "app/services/test_plugin/__init__.py" + service_file = fake_project / "app/services/test_plugin/service.py" + assert init_file.is_file() + assert service_file.is_file() + + def test_jinja_rendered_with_project_answers(self, fake_project: Path) -> None: + """The plugin template references ``{{ project_slug }}`` — + confirm it was substituted with the project's actual slug.""" + updater = ManualUpdater(fake_project) + updater.install_plugin_template_tree("aegis_plugin_test") + + init_content = ( + fake_project / "app/services/test_plugin/__init__.py" + ).read_text() + assert 'PLUGIN_NAME = "demo-project-test-plugin"' in init_content + + service_content = ( + fake_project / "app/services/test_plugin/service.py" + ).read_text() + assert 'project_slug = "demo-project"' in service_content + + def test_pure_code_plugin_returns_empty_list(self, fake_project: Path) -> None: + """Plugins without a ``templates/`` dir (pure-code plugins — + wiring data only) return an empty list and write nothing.""" + updater = ManualUpdater(fake_project) + # ``json`` is a stdlib package without templates; resolver + # returns None → install method short-circuits. + written = updater.install_plugin_template_tree("json") + assert written == [] + + def test_overwrites_existing_files(self, fake_project: Path) -> None: + """Re-running install on the same plugin overwrites — the test + plugin's content should be deterministic, so the second run + produces the same output.""" + updater = ManualUpdater(fake_project) + updater.install_plugin_template_tree("aegis_plugin_test") + first_content = ( + fake_project / "app/services/test_plugin/service.py" + ).read_text() + + # Tamper with the file, then re-install. + (fake_project / "app/services/test_plugin/service.py").write_text("tampered") + updater.install_plugin_template_tree("aegis_plugin_test") + + second_content = ( + fake_project / "app/services/test_plugin/service.py" + ).read_text() + assert second_content == first_content diff --git a/tests/core/test_plugin_render_parity.py b/tests/core/test_plugin_render_parity.py index f8875859..16bde6ae 100644 --- a/tests/core/test_plugin_render_parity.py +++ b/tests/core/test_plugin_render_parity.py @@ -96,3 +96,220 @@ def test_routing_py_parity(service_name: str) -> None: f"--- legacy ---\n{legacy_output}\n" f"--- plugin ---\n{plugin_output}\n" ) + + +# --------------------------------------------------------------------- +# deps.py parity (auth slice, first of the per-service deps refactor) +# +# Today the shared ``deps.py.jinja`` defines per-service FastAPI +# dependency-provider functions inline behind ``{% if include_X %}`` +# blocks (auth: 4 functions, payment: 1, insights: 3). The Option-1 +# refactor moves each service's function bodies into its own +# ``app/services//deps.py`` and shrinks the shared template to +# imports + a ``{% for p in _plugins %}`` loop. +# +# This test pins parity for auth: rendering with ``include_auth=True`` +# (legacy inline-function path) must equal rendering with +# ``_plugins=[serialize(SERVICES["auth"])]`` (per-service-file path). +# Initially fails because ``wiring.deps_providers`` for auth is not +# yet populated and the shared template has no plugin loop. Both +# come into existence in this refactor slice. +# --------------------------------------------------------------------- + + +DEPS_INCLUDE_KEY = { + "auth": "include_auth", + "insights": "include_insights", + "payment": "include_payment", +} + + +@pytest.mark.parametrize("service_name", list(DEPS_INCLUDE_KEY.keys())) +def test_deps_py_parity(service_name: str) -> None: + template = "app/components/backend/api/deps.py" + spec = SERVICES[service_name] + include_key = DEPS_INCLUDE_KEY[service_name] + + defaults = get_copier_defaults() + # deps.py is gated on ``include_database`` at the top — both paths + # render under the same project state so the database scaffolding + # appears in both outputs. + base = {**defaults, "include_database": True} + + legacy_answers = {**base, include_key: True, PLUGINS_ANSWER_KEY: []} + plugin_entry = serialize_plugin_to_answer( + spec, plugin_options=None, project_answers=base + ) + plugin_answers = { + **base, + include_key: False, + PLUGINS_ANSWER_KEY: [plugin_entry], + } + + legacy_output = _render(template, legacy_answers) + plugin_output = _render(template, plugin_answers) + + assert legacy_output == plugin_output, ( + f"deps.py parity drift for {service_name}:\n" + f"--- legacy ---\n{legacy_output}\n" + f"--- plugin ---\n{plugin_output}\n" + ) + + +# --------------------------------------------------------------------- +# Dashboard cards / modals __init__.py parity (all 5 in-tree services) +# +# These templates emit ``from import `` plus the +# matching ``""`` entry in ``__all__``. Wiring data on each +# spec's ``dashboard_cards`` / ``dashboard_modals`` already drives the +# new ``{% for p in _plugins %}`` loops; the legacy if-blocks were +# kept in absolute-import form so both paths emit byte-identical output. +# --------------------------------------------------------------------- + + +@pytest.mark.parametrize("service_name", list(SERVICE_INCLUDE_KEY.keys())) +def test_dashboard_cards_init_parity(service_name: str) -> None: + template = "app/components/frontend/dashboard/cards/__init__.py" + spec = SERVICES[service_name] + include_key = SERVICE_INCLUDE_KEY[service_name] + + defaults = get_copier_defaults() + legacy_answers = {**defaults, include_key: True, PLUGINS_ANSWER_KEY: []} + plugin_entry = serialize_plugin_to_answer( + spec, plugin_options=None, project_answers=defaults + ) + plugin_answers = { + **defaults, + include_key: False, + PLUGINS_ANSWER_KEY: [plugin_entry], + } + + legacy_output = _render(template, legacy_answers) + plugin_output = _render(template, plugin_answers) + + assert legacy_output == plugin_output, ( + f"cards/__init__.py parity drift for {service_name}:\n" + f"--- legacy ---\n{legacy_output}\n" + f"--- plugin ---\n{plugin_output}\n" + ) + + +@pytest.mark.parametrize("service_name", list(SERVICE_INCLUDE_KEY.keys())) +def test_dashboard_modals_init_parity(service_name: str) -> None: + template = "app/components/frontend/dashboard/modals/__init__.py" + spec = SERVICES[service_name] + include_key = SERVICE_INCLUDE_KEY[service_name] + + defaults = get_copier_defaults() + legacy_answers = {**defaults, include_key: True, PLUGINS_ANSWER_KEY: []} + plugin_entry = serialize_plugin_to_answer( + spec, plugin_options=None, project_answers=defaults + ) + plugin_answers = { + **defaults, + include_key: False, + PLUGINS_ANSWER_KEY: [plugin_entry], + } + + legacy_output = _render(template, legacy_answers) + plugin_output = _render(template, plugin_answers) + + assert legacy_output == plugin_output, ( + f"modals/__init__.py parity drift for {service_name}:\n" + f"--- legacy ---\n{legacy_output}\n" + f"--- plugin ---\n{plugin_output}\n" + ) + + +# --------------------------------------------------------------------- +# pyproject.toml parity (services with simple legacy deps blocks) +# +# Excluded: ``ai`` (deps are computed at render time from +# ``ai_framework`` + ``ai_providers`` — placeholder ``{AI_FRAMEWORK_DEPS}`` +# in spec.pyproject_deps). ``insights`` (no legacy if-block in +# pyproject.toml.jinja today; httpx is satisfied transitively). +# --------------------------------------------------------------------- + + +PYPROJECT_DEPS_INCLUDE_KEY = { + "auth": "include_auth", + "comms": "include_comms", + "payment": "include_payment", +} + + +@pytest.mark.parametrize("service_name", list(PYPROJECT_DEPS_INCLUDE_KEY.keys())) +def test_pyproject_deps_parity(service_name: str) -> None: + """spec.pyproject_deps mirrors the legacy ``{%- if include_X %}`` + block bytes-for-bytes (same order, same version pins, same quoting). + The plugin loop emits the same lines.""" + template = "pyproject.toml" + spec = SERVICES[service_name] + include_key = PYPROJECT_DEPS_INCLUDE_KEY[service_name] + + defaults = get_copier_defaults() + legacy_answers = {**defaults, include_key: True, PLUGINS_ANSWER_KEY: []} + plugin_entry = serialize_plugin_to_answer( + spec, plugin_options=None, project_answers=defaults + ) + plugin_answers = { + **defaults, + include_key: False, + PLUGINS_ANSWER_KEY: [plugin_entry], + } + + legacy_output = _render(template, legacy_answers) + plugin_output = _render(template, plugin_answers) + + assert legacy_output == plugin_output, ( + f"pyproject.toml parity drift for {service_name}:\n" + f"--- legacy ---\n{legacy_output}\n" + f"--- plugin ---\n{plugin_output}\n" + ) + + +@pytest.mark.parametrize( + "service_name", + [ + # ``insights`` is excluded: ``card_utils.py`` carries an + # insights-specific ``no_cache`` block in ``_open_modal`` that + # evicts insights modals from the cache to load fresh data. + # That's behaviour, not wiring — it lives outside the + # ``{% for p in _plugins %}`` loop and produces a render-output + # divergence between legacy and plugin paths for insights only. + # Remaining 4 services hit identical output via both paths. + "auth", + "ai", + "comms", + "payment", + ], +) +def test_card_utils_parity(service_name: str) -> None: + """``card_utils.py`` builds a ``modal_map`` keyed by component name. + The plugin loop must emit the same imports + dict entries the legacy + if-blocks do (in-tree wiring data carries the modal_id keys + ``"auth"``/``"ai"``/``"service_payment"``/etc. that the dashboard + dispatcher uses).""" + template = "app/components/frontend/dashboard/cards/card_utils.py" + spec = SERVICES[service_name] + include_key = SERVICE_INCLUDE_KEY[service_name] + + defaults = get_copier_defaults() + legacy_answers = {**defaults, include_key: True, PLUGINS_ANSWER_KEY: []} + plugin_entry = serialize_plugin_to_answer( + spec, plugin_options=None, project_answers=defaults + ) + plugin_answers = { + **defaults, + include_key: False, + PLUGINS_ANSWER_KEY: [plugin_entry], + } + + legacy_output = _render(template, legacy_answers) + plugin_output = _render(template, plugin_answers) + + assert legacy_output == plugin_output, ( + f"card_utils.py parity drift for {service_name}:\n" + f"--- legacy ---\n{legacy_output}\n" + f"--- plugin ---\n{plugin_output}\n" + ) diff --git a/tests/core/test_plugin_template_resolver.py b/tests/core/test_plugin_template_resolver.py new file mode 100644 index 00000000..9b00fc60 --- /dev/null +++ b/tests/core/test_plugin_template_resolver.py @@ -0,0 +1,68 @@ +""" +Tests for ``aegis.core.plugin_template_resolver``. + +The resolver locates a plugin's ``templates/`` directory via +``importlib.resources``. Tests run against: + +* ``tests.fixtures.aegis_plugin_test`` — the in-repo fake plugin that + ships a minimal templates tree (covers the happy path). +* a stdlib package without templates (covers the "pure-code plugin" + case where the resolver should return ``None``). +* a non-existent module (covers the import-error case). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from aegis.core.plugin_template_resolver import ( + TEMPLATE_SUBDIR, + get_plugin_template_root, +) + +# Make sure the fake plugin is on sys.path. ``tests/`` is added by +# pytest's rootdir machinery, but the fixture lives one level deeper +# under ``tests/fixtures/``. +TESTS_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" +if str(TESTS_FIXTURES) not in sys.path: + sys.path.insert(0, str(TESTS_FIXTURES)) + + +class TestResolverHappyPath: + def test_returns_path_to_templates_dir(self) -> None: + root = get_plugin_template_root("aegis_plugin_test") + assert root is not None + assert root.is_dir() + assert root.name == TEMPLATE_SUBDIR + + def test_resolved_root_contains_expected_jinja_file(self) -> None: + """Smoke-check: the resolved tree has the per-service files we + wrote in the fixture.""" + root = get_plugin_template_root("aegis_plugin_test") + assert root is not None + service_init = ( + root + / "{{ project_slug }}" + / "app" + / "services" + / "test_plugin" + / "__init__.py.jinja" + ) + assert service_init.is_file() + + +class TestResolverEdgeCases: + def test_returns_none_for_pure_code_plugin(self) -> None: + """Packages without a ``templates/`` subdirectory get ``None`` + (the "pure-code plugin" case — wiring data only, no files to + ship).""" + # ``json`` is a stdlib package and very definitely doesn't ship + # a ``templates/`` subdirectory. + assert get_plugin_template_root("json") is None + + def test_raises_modulenotfound_for_missing_package(self) -> None: + with pytest.raises(ModuleNotFoundError): + get_plugin_template_root("aegis_plugin_definitely_not_installed") diff --git a/tests/core/test_services.py b/tests/core/test_services.py index 23b9eec9..64a005d8 100644 --- a/tests/core/test_services.py +++ b/tests/core/test_services.py @@ -160,10 +160,11 @@ def test_auth_service_dependencies(self): assert "backend" in auth_spec.required_components assert "database" in auth_spec.required_components - # Should have authentication-related dependencies + # Should have authentication-related dependencies. ``passlib`` was + # replaced by ``bcrypt`` in the template — the spec mirrors that. deps_str = " ".join(auth_spec.pyproject_deps) assert "python-jose" in deps_str - assert "passlib" in deps_str + assert "bcrypt" in deps_str def test_auth_service_template_files(self): """Test that auth service specifies template files.""" diff --git a/tests/fixtures/aegis_plugin_test/__init__.py b/tests/fixtures/aegis_plugin_test/__init__.py new file mode 100644 index 00000000..b597e2bc --- /dev/null +++ b/tests/fixtures/aegis_plugin_test/__init__.py @@ -0,0 +1,7 @@ +"""Fake plugin package for round 8 plugin-distribution tests. + +Ships a minimal ``templates/`` tree so ``plugin_template_resolver`` and +``ManualUpdater`` can locate and render its files. The actual code is +empty — what we exercise is the plugin's *file ownership* surface, not +its runtime behaviour. +""" diff --git a/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/__init__.py.jinja b/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/__init__.py.jinja new file mode 100644 index 00000000..c6f8f83a --- /dev/null +++ b/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/__init__.py.jinja @@ -0,0 +1,3 @@ +"""Test plugin service package.""" + +PLUGIN_NAME = "{{ project_slug }}-test-plugin" diff --git a/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/service.py.jinja b/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/service.py.jinja new file mode 100644 index 00000000..f4aadb75 --- /dev/null +++ b/tests/fixtures/aegis_plugin_test/templates/{{ project_slug }}/app/services/test_plugin/service.py.jinja @@ -0,0 +1,12 @@ +"""Test plugin's main service module.""" + + +class TestPluginService: + """Stub service exposed by the test plugin. + + Lives here purely so the round-8 ManualUpdater test can verify + that plugin-shipped files land at the right relative path under + the target project's tree. + """ + + project_slug = "{{ project_slug }}"