fix(cleanup): resolve 9 low-priority issues (P34-P42)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
P34: Remove test_sample plugin from production code P35: Remove CompanyContact=None dead code from contact.py P36: Change Plugin.config from Text to JSONB (model + migration 0117 + service) P37: Add AI comment about workspace overengineering in workspace.py P38: Add container resource limits to docker-compose.yaml P39: Guest TTL 1800 not found — already migrated to regular users P40: Add AI comment about missing IP/Device binding in session.py P41: Fix Redis healthcheck to use auth password P42: RLS migration history comment already present in alembic/env.py
This commit is contained in:
@@ -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",
|
||||||
|
)
|
||||||
@@ -254,4 +254,4 @@ class ContactPerson(Base, TenantMixin):
|
|||||||
|
|
||||||
|
|
||||||
# Keep old names for backward compat during migration
|
# Keep old names for backward compat during migration
|
||||||
CompanyContact = None # deprecated — replaced by ContactPerson 1:N
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
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.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_core: 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(
|
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
Text,
|
JSONB,
|
||||||
nullable=True, # JSON string for plugin configuration
|
nullable=True, # JSONB for plugin configuration
|
||||||
)
|
)
|
||||||
|
|
||||||
# Transient attribute for response (not persisted)
|
# Transient attribute for response (not persisted)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
⚠️ Session-Tabelle dient als audit trail. Redis ist der Runtime-Session-Store.
|
⚠️ Session-Tabelle dient als audit trail. Redis ist der Runtime-Session-Store.
|
||||||
Dies ist ein bewusstes Dual-System.
|
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
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -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,
|
RBAC, ABAC, entity permissions, owner/sharing rights, tenant memberships,
|
||||||
RLS policies, or actual data access rights.
|
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.
|
See: docs/security_kernel.md for the permission intersection rule.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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);
|
|
||||||
@@ -205,17 +205,12 @@ class PluginService:
|
|||||||
async def get_plugin_config(self, db: AsyncSession, name: str) -> dict[str, Any]:
|
async def get_plugin_config(self, db: AsyncSession, name: str) -> dict[str, Any]:
|
||||||
"""Get the configuration for a plugin.
|
"""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)
|
record = await self._registry._get_plugin_record(db, name)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise ValueError(f"Plugin '{name}' is not installed")
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||||||
import json
|
return getattr(record, "config", None) or {}
|
||||||
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 {}
|
|
||||||
|
|
||||||
async def update_plugin_config(
|
async def update_plugin_config(
|
||||||
self,
|
self,
|
||||||
@@ -227,15 +222,13 @@ class PluginService:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Update the configuration for a plugin.
|
"""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)
|
record = await self._registry._get_plugin_record(db, name)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise ValueError(f"Plugin '{name}' is not installed")
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||||||
|
|
||||||
record.config = json.dumps(config)
|
record.config = config
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
if tenant_id and user_id:
|
if tenant_id and user_id:
|
||||||
|
|||||||
+9
-1
@@ -38,6 +38,8 @@ services:
|
|||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: '1.0'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -51,8 +53,10 @@ services:
|
|||||||
command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
|
command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
mem_limit: 128m
|
||||||
|
cpus: '0.5'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli ping || exit 1"]
|
test: ["CMD-SHELL", "redis-cli -a $${REDIS_PASSWORD} ping || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -91,6 +95,8 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin123!}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin123!}
|
||||||
volumes:
|
volumes:
|
||||||
- storage:/data/storage
|
- storage:/data/storage
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: '1.0'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -130,6 +136,8 @@ services:
|
|||||||
SMTP_TLS: ${SMTP_TLS:-true}
|
SMTP_TLS: ${SMTP_TLS:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- storage:/data/storage
|
- storage:/data/storage
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: '0.5'
|
||||||
healthcheck:
|
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"]
|
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
|
interval: 30s
|
||||||
|
|||||||
Reference in New Issue
Block a user