diff --git a/alembic/versions/0117_plugin_config_text_to_jsonb.py b/alembic/versions/0117_plugin_config_text_to_jsonb.py new file mode 100644 index 0000000..65395ad --- /dev/null +++ b/alembic/versions/0117_plugin_config_text_to_jsonb.py @@ -0,0 +1,39 @@ +"""Change plugins.config column from Text to JSONB. + +The config column was stored as a JSON string in a Text column. +This migration converts it to native JSONB for proper querying and validation. + +Revision ID: 0117 +Revises: 0116 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0117" +down_revision = "0116" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Convert Text column to JSONB, casting existing JSON strings + op.alter_column( + "plugins", + "config", + existing_type=sa.Text(), + type_=JSONB, + postgresql_using="config::jsonb", + ) + + +def downgrade() -> None: + # Convert back to Text, casting JSONB to text + op.alter_column( + "plugins", + "config", + existing_type=JSONB, + type_=sa.Text(), + postgresql_using="config::text", + ) diff --git a/app/models/contact.py b/app/models/contact.py index f3edb13..4590ec5 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -254,4 +254,4 @@ class ContactPerson(Base, TenantMixin): # Keep old names for backward compat during migration -CompanyContact = None # deprecated — replaced by ContactPerson 1:N + diff --git a/app/models/plugin.py b/app/models/plugin.py index 5d95da8..81485c8 100644 --- a/app/models/plugin.py +++ b/app/models/plugin.py @@ -5,7 +5,8 @@ from __future__ import annotations import uuid from typing import Any -from sqlalchemy import Boolean, Index, String, Text +from sqlalchemy import Boolean, Index, String +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column @@ -33,8 +34,8 @@ class Plugin(Base, TimestampMixin): active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_core: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) config: Mapped[dict[str, Any] | None] = mapped_column( - Text, - nullable=True, # JSON string for plugin configuration + JSONB, + nullable=True, # JSONB for plugin configuration ) # Transient attribute for response (not persisted) diff --git a/app/models/session.py b/app/models/session.py index 5a6b5e1..92d2f5a 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -2,6 +2,9 @@ ⚠️ Session-Tabelle dient als audit trail. Redis ist der Runtime-Session-Store. Dies ist ein bewusstes Dual-System. + +⚠️ Sessions sind nicht an IP/Device gebunden (Design-Entscheidung). +Bei Bedarf IP-Binding hinzufügen. """ from __future__ import annotations diff --git a/app/models/workspace.py b/app/models/workspace.py index 7051a2a..e42d760 100644 --- a/app/models/workspace.py +++ b/app/models/workspace.py @@ -5,6 +5,9 @@ saved views, and dashboard widgets are visible to a user. They NEVER affect RBAC, ABAC, entity permissions, owner/sharing rights, tenant memberships, RLS policies, or actual data access rights. +⚠️ 4 Workspace-Tabellen sind überdimensioniert für ein Mini-CRM aber funktional +korrekt. Bei Gelegenheit vereinfachen. + See: docs/security_kernel.md for the permission intersection rule. """ diff --git a/app/plugins/builtins/test_sample/__init__.py b/app/plugins/builtins/test_sample/__init__.py deleted file mode 100644 index f744545..0000000 --- a/app/plugins/builtins/test_sample/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test sample plugin for LeoCRM plugin system testing.""" - -from __future__ import annotations - -from typing import Any - -from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest - - -class TestSamplePlugin(BasePlugin): - """A sample plugin for testing the plugin lifecycle.""" - __test__ = False - - manifest = PluginManifest( - name="test_sample", - version="1.0.0", - display_name="Test Sample Plugin", - description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.", - dependencies=[], - routes=[], - events=["contact.created"], - migrations=["0001_test_plugin.sql"], - permissions=[], - - author="LeoCRM Team", - min_app_version="1.0.0", - contract_version="1.0.0") - - def __init__(self) -> None: - super().__init__() - self.install_called = False - self.activate_called = False - self.deactivate_called = False - self.uninstall_called = False - self.event_log: list[dict[str, Any]] = [] - - async def on_install(self, db, service_container) -> None: - self.install_called = True - - async def on_activate(self, db, service_container, event_bus) -> None: - self.activate_called = True - await super().on_activate(db, service_container, event_bus) - - async def on_deactivate(self, db, service_container, event_bus) -> None: - # Contract abmelden - from app.plugins.builtins.contracts import get_contract_registry - get_contract_registry().unregister(self.manifest.name) - self.deactivate_called = True - await super().on_deactivate(db, service_container, event_bus) - - async def on_uninstall(self, db, service_container) -> None: - self.uninstall_called = True - - async def on_contact_created(self, payload: dict[str, Any]) -> None: - self.event_log.append({"event": "contact.created", "payload": payload}) diff --git a/app/plugins/builtins/test_sample/contracts.py b/app/plugins/builtins/test_sample/contracts.py deleted file mode 100644 index 4400664..0000000 --- a/app/plugins/builtins/test_sample/contracts.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Public contract for the test_sample plugin. - -Exposes only the symbols that other builtins plugins need. -Importers should use:: - - from app.plugins.builtins.contracts import get_contract - ts = get_contract("test_sample") - if ts: - # use ts.TestSamplePlugin - -instead of importing from internal modules directly. -""" - -from __future__ import annotations - -from app.plugins.builtins.contracts import get_contract_registry -from app.plugins.builtins.test_sample import TestSamplePlugin - - -class TestSampleContract: - """Public API surface for the test_sample plugin.""" - - contract_name = "test_sample" - - # ─── plugin class ─── - TestSamplePlugin = TestSamplePlugin - - -# ─── self-registration ─── - -_contract = TestSampleContract() -get_contract_registry().register("test_sample", _contract) - - -__all__ = [ - "TestSampleContract", - "TestSamplePlugin", -] diff --git a/app/plugins/builtins/test_sample/migrations/0001_test_plugin.sql b/app/plugins/builtins/test_sample/migrations/0001_test_plugin.sql deleted file mode 100644 index ea60d00..0000000 --- a/app/plugins/builtins/test_sample/migrations/0001_test_plugin.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Test sample plugin migration: creates a test table with tenant_id -CREATE TABLE IF NOT EXISTS test_sample_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - name VARCHAR(100) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS ix_test_sample_items_tenant ON test_sample_items(tenant_id); diff --git a/app/services/plugin_service.py b/app/services/plugin_service.py index edc2c75..acd93ae 100644 --- a/app/services/plugin_service.py +++ b/app/services/plugin_service.py @@ -205,17 +205,12 @@ class PluginService: async def get_plugin_config(self, db: AsyncSession, name: str) -> dict[str, Any]: """Get the configuration for a plugin. - Returns the plugin's config field parsed from JSON string. + Returns the plugin's config field (JSONB, already parsed by SQLAlchemy). """ record = await self._registry._get_plugin_record(db, name) if record is None: raise ValueError(f"Plugin '{name}' is not installed") - import json - config_str = getattr(record, "config", None) or "{}" - try: - return json.loads(config_str) if isinstance(config_str, str) else config_str or {} - except (json.JSONDecodeError, TypeError): - return {} + return getattr(record, "config", None) or {} async def update_plugin_config( self, @@ -227,15 +222,13 @@ class PluginService: ) -> dict[str, Any]: """Update the configuration for a plugin. - Stores the config as a JSON string in the plugin's config field. + Stores the config directly as JSONB in the plugin's config field. """ - import json - record = await self._registry._get_plugin_record(db, name) if record is None: raise ValueError(f"Plugin '{name}' is not installed") - record.config = json.dumps(config) + record.config = config await db.flush() if tenant_id and user_id: diff --git a/docker-compose.yaml b/docker-compose.yaml index 4c19459..ea94755 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,6 +38,8 @@ services: PGDATA: /var/lib/postgresql/data/pgdata volumes: - pgdata:/var/lib/postgresql/data + mem_limit: 512m + cpus: '1.0' healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"] interval: 10s @@ -51,8 +53,10 @@ services: command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required} volumes: - redisdata:/data + mem_limit: 128m + cpus: '0.5' healthcheck: - test: ["CMD-SHELL", "redis-cli ping || exit 1"] + test: ["CMD-SHELL", "redis-cli -a $${REDIS_PASSWORD} ping || exit 1"] interval: 10s timeout: 5s retries: 5 @@ -91,6 +95,8 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin123!} volumes: - storage:/data/storage + mem_limit: 512m + cpus: '1.0' healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"] interval: 30s @@ -130,6 +136,8 @@ services: SMTP_TLS: ${SMTP_TLS:-true} volumes: - storage:/data/storage + mem_limit: 256m + cpus: '0.5' healthcheck: test: ["CMD-SHELL", "python3 -c \"import redis,os; r=redis.from_url(os.environ.get('REDIS_URL','redis://localhost:6379/0')); print('ok' if r.ping() else 'fail')\" || exit 1"] interval: 30s