Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation) - Custom Fields Backend: model, schema, service, routes, migration 0041 - Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering) - Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions - Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem - NotificationBell integrated into TopBar - Routes: /tags, /settings/custom-fields registered - Settings nav: Custom Fields entry added - Menu items: Tags added to automation plugin manifest
This commit is contained in:
@@ -49,6 +49,7 @@ from app.routes import (
|
||||
sequences,
|
||||
system_settings,
|
||||
attachments,
|
||||
custom_field_definitions,
|
||||
custom_fields,
|
||||
saved_filters,
|
||||
)
|
||||
@@ -330,6 +331,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(addresses.router)
|
||||
app.include_router(bank_accounts.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(custom_field_definitions.router)
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models.system_settings import SystemSettings
|
||||
from app.models.tax import TaxRate
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
|
||||
__all__ = [
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"PluginMigration",
|
||||
"AIConversation",
|
||||
"AIMessage",
|
||||
"CustomFieldDefinition",
|
||||
"Workflow",
|
||||
"WorkflowInstance",
|
||||
"WorkflowStepHistory",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""CustomFieldDefinition model — user-defined custom fields stored in DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class CustomFieldDefinition(Base, TenantMixin):
|
||||
"""User-defined custom field definition stored in the database.
|
||||
|
||||
These definitions are merged with plugin-provided custom fields
|
||||
at query time. Each definition is scoped to a tenant and entity type.
|
||||
"""
|
||||
|
||||
__tablename__ = "custom_field_definitions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "entity", "name",
|
||||
name="uq_custom_field_def_tenant_entity_name",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
# ── Identity ──
|
||||
entity: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
|
||||
# ── Type & Options ──
|
||||
field_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="text"
|
||||
) # text, number, date, select, multiselect, boolean
|
||||
options: Mapped[list[str] | None] = mapped_column(JSON, nullable=True, default=list)
|
||||
default_value: Mapped[Any | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
# ── Behaviour ──
|
||||
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# ── Audit ──
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
@@ -92,6 +92,13 @@ class AutomationPlugin(BasePlugin):
|
||||
icon="Copy",
|
||||
order=54,
|
||||
),
|
||||
FrontendMenuItem(
|
||||
label_key="nav.tags",
|
||||
label="Tags",
|
||||
path="/tags",
|
||||
icon="Tag",
|
||||
order=55,
|
||||
),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""API routes for CustomFieldDefinition CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.schemas.custom_field_definition import (
|
||||
CustomFieldDefinitionCreate,
|
||||
CustomFieldDefinitionResponse,
|
||||
CustomFieldDefinitionUpdate,
|
||||
)
|
||||
from app.services import custom_field_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/custom-fields", tags=["custom-fields-definitions"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/definitions",
|
||||
response_model=list[CustomFieldDefinitionResponse],
|
||||
dependencies=[Depends(require_permission("contacts:read"))],
|
||||
)
|
||||
async def list_definitions(
|
||||
entity: str | None = Query(None, description="Filter by entity type (e.g. 'contact', 'company')"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all active custom field definitions for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
definitions = await custom_field_service.list_definitions(db, tenant_id, entity=entity)
|
||||
return definitions
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitions",
|
||||
response_model=CustomFieldDefinitionResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_permission("contacts:write"))],
|
||||
)
|
||||
async def create_definition(
|
||||
body: CustomFieldDefinitionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new custom field definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
definition = await custom_field_service.create_definition(
|
||||
db, tenant_id, user_id, body.model_dump()
|
||||
)
|
||||
return definition
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/definitions/{definition_id}",
|
||||
response_model=CustomFieldDefinitionResponse,
|
||||
dependencies=[Depends(require_permission("contacts:write"))],
|
||||
)
|
||||
async def update_definition(
|
||||
definition_id: str,
|
||||
body: CustomFieldDefinitionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing custom field definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
def_id = uuid.UUID(definition_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None
|
||||
|
||||
# Filter out None values from the update body
|
||||
update_data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not update_data:
|
||||
raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"})
|
||||
|
||||
definition = await custom_field_service.update_definition(
|
||||
db, tenant_id, def_id, update_data, user_id=user_id
|
||||
)
|
||||
if definition is None:
|
||||
raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"})
|
||||
return definition
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/definitions/{definition_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_permission("contacts:write"))],
|
||||
)
|
||||
async def delete_definition(
|
||||
definition_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a custom field definition."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
def_id = uuid.UUID(definition_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None
|
||||
|
||||
deleted = await custom_field_service.delete_definition(db, tenant_id, def_id)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"})
|
||||
return None
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.contact import Contact
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
|
||||
@@ -24,10 +25,19 @@ class CustomFieldUpdateRequest(BaseModel):
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]:
|
||||
"""Collect all custom field definitions from active plugin manifests."""
|
||||
async def _collect_custom_field_definitions(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity: str = "contact",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect all custom field definitions from plugin manifests and DB.
|
||||
|
||||
DB-stored definitions override plugin definitions with the same name.
|
||||
"""
|
||||
definitions: list[dict[str, Any]] = []
|
||||
seen_names: set[str] = set()
|
||||
|
||||
# 1. Collect from active plugin manifests
|
||||
registry = get_registry()
|
||||
for plugin in registry._plugins.values():
|
||||
manifest = plugin.manifest
|
||||
@@ -50,10 +60,40 @@ def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str,
|
||||
"plugin": manifest.name,
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Collect from DB (user-defined custom field definitions)
|
||||
stmt = select(CustomFieldDefinition).where(
|
||||
CustomFieldDefinition.tenant_id == tenant_id,
|
||||
CustomFieldDefinition.entity == entity,
|
||||
CustomFieldDefinition.is_active == True, # noqa: E712
|
||||
).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
|
||||
result = await db.execute(stmt)
|
||||
db_definitions = result.scalars().all()
|
||||
|
||||
for d in db_definitions:
|
||||
if d.name in seen_names:
|
||||
# DB definition overrides plugin definition — replace it
|
||||
definitions = [x for x in definitions if x["name"] != d.name]
|
||||
else:
|
||||
seen_names.add(d.name)
|
||||
definitions.append(
|
||||
{
|
||||
"name": d.name,
|
||||
"label": d.label,
|
||||
"label_key": "",
|
||||
"field_type": d.field_type,
|
||||
"options": d.options or [],
|
||||
"default_value": d.default_value,
|
||||
"required": d.required,
|
||||
"entity": d.entity,
|
||||
"plugin": "user_defined",
|
||||
}
|
||||
)
|
||||
|
||||
return definitions
|
||||
|
||||
|
||||
def _merge_definitions_with_values(
|
||||
async def _merge_definitions_with_values(
|
||||
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge field definitions with stored values, applying defaults."""
|
||||
@@ -87,8 +127,8 @@ async def get_custom_fields(
|
||||
if contact is None:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
|
||||
definitions = _collect_custom_field_definitions("contact")
|
||||
merged = _merge_definitions_with_values(definitions, contact.custom)
|
||||
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
|
||||
merged = await _merge_definitions_with_values(definitions, contact.custom)
|
||||
return {"fields": merged}
|
||||
|
||||
|
||||
@@ -114,7 +154,7 @@ async def update_custom_fields(
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
|
||||
# Validate against definitions
|
||||
definitions = _collect_custom_field_definitions("contact")
|
||||
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
|
||||
def_map = {d["name"]: d for d in definitions}
|
||||
|
||||
current_custom = dict(contact.custom or {})
|
||||
@@ -154,5 +194,5 @@ async def update_custom_fields(
|
||||
|
||||
contact.custom = current_custom
|
||||
await db.flush()
|
||||
merged = _merge_definitions_with_values(definitions, contact.custom)
|
||||
merged = await _merge_definitions_with_values(definitions, contact.custom)
|
||||
return {"fields": merged}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic schemas for CustomFieldDefinition CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CustomFieldDefinitionCreate(BaseModel):
|
||||
"""Schema for creating a new custom field definition."""
|
||||
|
||||
entity: str = Field(..., description="Entity type (e.g. 'contact', 'company')")
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Unique field name (snake_case)")
|
||||
label: str = Field(..., min_length=1, max_length=200, description="Human-readable label")
|
||||
field_type: str = Field(
|
||||
...,
|
||||
pattern=r"^(text|number|date|select|multiselect|boolean)$",
|
||||
description="Field type",
|
||||
)
|
||||
options: list[str] | None = Field(default=None, description="Options for select/multiselect types")
|
||||
default_value: Any = Field(default=None, description="Default value")
|
||||
required: bool = Field(default=False, description="Whether the field is required")
|
||||
is_active: bool = Field(default=True, description="Whether the field is active")
|
||||
sort_order: int = Field(default=0, description="Sort order")
|
||||
|
||||
|
||||
class CustomFieldDefinitionUpdate(BaseModel):
|
||||
"""Schema for updating an existing custom field definition."""
|
||||
|
||||
label: str | None = Field(default=None, max_length=200)
|
||||
field_type: str | None = Field(
|
||||
default=None,
|
||||
pattern=r"^(text|number|date|select|multiselect|boolean)$",
|
||||
)
|
||||
options: list[str] | None = Field(default=None)
|
||||
default_value: Any = Field(default=None)
|
||||
required: bool | None = Field(default=None)
|
||||
is_active: bool | None = Field(default=None)
|
||||
sort_order: int | None = Field(default=None)
|
||||
|
||||
|
||||
class CustomFieldDefinitionResponse(BaseModel):
|
||||
"""Schema for returning a custom field definition."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
entity: str
|
||||
name: str
|
||||
label: str
|
||||
field_type: str
|
||||
options: list[str] | None = None
|
||||
default_value: Any = None
|
||||
required: bool = False
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
created_by: uuid.UUID | None = None
|
||||
updated_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,113 @@
|
||||
"""CRUD service for CustomFieldDefinition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
|
||||
|
||||
async def list_definitions(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity: str | None = None,
|
||||
) -> list[CustomFieldDefinition]:
|
||||
"""List custom field definitions for a tenant, optionally filtered by entity."""
|
||||
stmt = select(CustomFieldDefinition).where(
|
||||
CustomFieldDefinition.tenant_id == tenant_id,
|
||||
CustomFieldDefinition.is_active == True, # noqa: E712
|
||||
)
|
||||
if entity:
|
||||
stmt = stmt.where(CustomFieldDefinition.entity == entity)
|
||||
stmt = stmt.order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_definition(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
definition_id: uuid.UUID,
|
||||
) -> CustomFieldDefinition | None:
|
||||
"""Get a single custom field definition by ID."""
|
||||
stmt = select(CustomFieldDefinition).where(
|
||||
CustomFieldDefinition.id == definition_id,
|
||||
CustomFieldDefinition.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_definition(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
) -> CustomFieldDefinition:
|
||||
"""Create a new custom field definition."""
|
||||
definition = CustomFieldDefinition(
|
||||
tenant_id=tenant_id,
|
||||
entity=data["entity"],
|
||||
name=data["name"],
|
||||
label=data["label"],
|
||||
field_type=data["field_type"],
|
||||
options=data.get("options"),
|
||||
default_value=data.get("default_value"),
|
||||
required=data.get("required", False),
|
||||
is_active=data.get("is_active", True),
|
||||
sort_order=data.get("sort_order", 0),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(definition)
|
||||
await db.flush()
|
||||
await db.refresh(definition)
|
||||
return definition
|
||||
|
||||
|
||||
async def update_definition(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
definition_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> CustomFieldDefinition | None:
|
||||
"""Update an existing custom field definition."""
|
||||
definition = await get_definition(db, tenant_id, definition_id)
|
||||
if definition is None:
|
||||
return None
|
||||
|
||||
update_fields = ["label", "field_type", "options", "default_value", "required", "is_active", "sort_order"]
|
||||
for field in update_fields:
|
||||
if field in data:
|
||||
setattr(definition, field, data[field])
|
||||
|
||||
if user_id is not None:
|
||||
definition.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(definition)
|
||||
return definition
|
||||
|
||||
|
||||
async def delete_definition(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
definition_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Delete a custom field definition."""
|
||||
stmt = select(CustomFieldDefinition).where(
|
||||
CustomFieldDefinition.id == definition_id,
|
||||
CustomFieldDefinition.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
definition = result.scalar_one_or_none()
|
||||
if definition is None:
|
||||
return False
|
||||
await db.delete(definition)
|
||||
await db.flush()
|
||||
return True
|
||||
Reference in New Issue
Block a user