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:
Agent Zero
2026-07-26 03:02:25 +02:00
parent 444c7fdb88
commit a7e3890634
22 changed files with 2684 additions and 8 deletions
+110
View File
@@ -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
+47 -7
View File
@@ -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}