Phase 1C: Frontend unified contact UI

This commit is contained in:
Agent Zero
2026-07-23 17:17:32 +02:00
parent a8331fbc2b
commit 879106c4eb
62 changed files with 552 additions and 1276 deletions
+5 -5
View File
@@ -29,8 +29,8 @@ from app.core.db import Base, close_engine, reset_engine_for_testing
from app.core.service_container import get_container # noqa: F401
from app.main import create_app
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
from app.models.company import Company
from app.models.contact import CompanyContact, Contact # noqa: F401
from app.models.contact import Contact
from app.models.contact import Contact, ContactPerson # noqa: F401
from app.models.plugin import Plugin, PluginMigration # noqa: F401
from app.models.role import Role
from app.models.tenant import Tenant
@@ -131,7 +131,7 @@ def clean_tables(db_setup):
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(
text(
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
)
)
conn.commit()
@@ -268,7 +268,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
await db.flush()
# Create a company in tenant A
company_a = Company(
company_a = Contact(
tenant_id=tenant_a.id,
name="Company Alpha",
industry="IT",
@@ -276,7 +276,7 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
updated_by=admin_a.id,
)
# Create a company in tenant B
company_b = Company(
company_b = Contact(
tenant_id=tenant_b.id,
name="Company Beta",
industry="Finance",
+4 -4
View File
@@ -1012,7 +1012,7 @@ async def test_gather_context_mail(db_session: AsyncSession):
@pytest.mark.asyncio
async def test_gather_context_company(db_session: AsyncSession):
"""gather_context for company collects data."""
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1041,8 +1041,8 @@ async def test_gather_context_company(db_session: AsyncSession):
db_session.add(company)
await db_session.commit()
context = await gather_context(db_session, "company", company.id, tenant.id)
assert context["entity_type"] == "company"
context = await gather_context(db_session, "contact", company.id, tenant.id)
assert context["entity_type"] == "contact"
assert context["entity_id"] == str(company.id)
assert "company" in context
assert "contacts" in context
@@ -1463,7 +1463,7 @@ async def test_suggestions_filter_by_entity_type(
s_company = ProactiveSuggestion(
tenant_id=seed["tenant_a"].id,
user_id=seed["admin_a"].id,
entity_type="company",
entity_type="contact",
entity_id=uuid.uuid4(),
suggestion_type="info",
title="Company Suggestion",
+2 -2
View File
@@ -428,12 +428,12 @@ async def test_ac14_link_entry(calendar_authed_client):
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/calendar/entries/{entry_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["entity_type"] == "company"
assert data["entity_type"] == "contact"
assert data["entity_id"] == company_id
+13 -13
View File
@@ -66,13 +66,13 @@ async def test_link_file_to_company(authed_client: AsyncClient):
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == file_id
assert data["entity_type"] == "company"
assert data["entity_type"] == "contact"
assert data["entity_id"] == company_id
assert data["already_linked"] is False
@@ -106,7 +106,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
# Link first
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -115,7 +115,7 @@ async def test_unlink_file_from_entity(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 204
@@ -137,7 +137,7 @@ async def test_list_file_links(authed_client: AsyncClient):
# Link to company
await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
# Link to contact
@@ -164,7 +164,7 @@ async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
entity_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": entity_id},
json={"entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -228,7 +228,7 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
file_id = str(uuid.uuid4())
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": str(company_id)},
json={"entity_type": "contact", "entity_id": str(company_id)},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -309,7 +309,7 @@ async def test_link_invalid_file_id(authed_client: AsyncClient):
client, seed = authed_client
resp = await client.post(
"/api/v1/dms/files/bad-uuid/link",
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -321,7 +321,7 @@ async def test_link_invalid_entity_id(authed_client: AsyncClient):
client, seed = authed_client
resp = await client.post(
f"/api/v1/dms/files/{uuid.uuid4()}/link",
json={"entity_type": "company", "entity_id": "bad-uuid"},
json={"entity_type": "contact", "entity_id": "bad-uuid"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -347,14 +347,14 @@ async def test_link_already_linked(authed_client: AsyncClient):
company_id = str(seed["company_a"].id)
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_linked"] is False
resp = await client.post(
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -370,7 +370,7 @@ async def test_unlink_not_found(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
f"/api/v1/dms/files/{file_id}/link",
json={"entity_type": "company", "entity_id": company_id},
json={"entity_type": "contact", "entity_id": company_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@@ -383,7 +383,7 @@ async def test_unlink_invalid_file_id(authed_client: AsyncClient):
resp = await client.request(
"DELETE",
"/api/v1/dms/files/bad-uuid/link",
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
json={"entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
+7 -7
View File
@@ -164,13 +164,13 @@ async def test_assign_tag(authed_client: AsyncClient):
resp = await authed_client.post(
"/api/v1/tags/assign",
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tag_id"] == tag_id
assert data["entity_type"] == "company"
assert data["entity_type"] == "contact"
assert data["entity_id"] == entity_id
assert data["already_assigned"] is False
@@ -377,7 +377,7 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
"/api/v1/tags/assign",
json={
"tag_id": str(uuid.uuid4()),
"entity_type": "company",
"entity_type": "contact",
"entity_id": str(uuid.uuid4()),
},
headers=ORIGIN_HEADER,
@@ -397,14 +397,14 @@ async def test_assign_tag_already_assigned(authed_client: AsyncClient):
entity_id = str(uuid.uuid4())
resp = await authed_client.post(
"/api/v1/tags/assign",
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert resp.json()["already_assigned"] is False
resp = await authed_client.post(
"/api/v1/tags/assign",
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -416,7 +416,7 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
resp = await authed_client.post(
"/api/v1/tags/assign",
json={"tag_id": "bad", "entity_type": "company", "entity_id": str(uuid.uuid4())},
json={"tag_id": "bad", "entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
@@ -434,7 +434,7 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient):
resp = await authed_client.request(
"DELETE",
"/api/v1/tags/assign",
json={"tag_id": tag_id, "entity_type": "company", "entity_id": str(uuid.uuid4())},
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": str(uuid.uuid4())},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
+23 -25
View File
@@ -167,7 +167,7 @@ async def test_ac1_search_returns_results(
client, _ = search_authed_client
mock_hybrid_search.return_value = [
{
"entity_type": "company",
"entity_type": "contact",
"entity_id": str(uuid.uuid4()),
"title": "Company Alpha",
"snippet": "IT company",
@@ -186,7 +186,7 @@ async def test_ac1_search_returns_results(
assert "results" in data
assert isinstance(data["results"], list)
assert len(data["results"]) > 0
assert data["results"][0]["entity_type"] == "company"
assert data["results"][0]["entity_type"] == "contact"
assert data["results"][0]["title"] == "Company Alpha"
assert "score" in data["results"][0]
assert "query" in data
@@ -209,13 +209,13 @@ async def test_ac2_search_entity_types_filter(
resp = await client.post(
"/api/v1/search",
json={"query": "test", "entity_types": ["company"]},
json={"query": "test", "entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
# Verify hybrid_search was called with entity_types=["company"]
# Verify hybrid_search was called with entity_types=["contact"]
call_kwargs = mock_hybrid_search.call_args
assert call_kwargs.kwargs.get("entity_types") == ["company"]
assert call_kwargs.kwargs.get("entity_types") == ["contact"]
# ─── AC3: No auth → 401 ───
@@ -322,7 +322,7 @@ async def test_ac8_similar_returns_results(
resp = await client.post(
"/api/v1/search/similar",
json={
"entity_type": "company",
"entity_type": "contact",
"entity_id": str(seed["company_a"].id),
"limit": 5,
},
@@ -347,7 +347,7 @@ async def test_ac9_similar_tenant_isolation(
resp = await client.post(
"/api/v1/search/similar",
json={
"entity_type": "company",
"entity_type": "contact",
"entity_id": str(seed["company_b"].id),
"limit": 5,
},
@@ -398,7 +398,7 @@ async def test_ac11_reindex_admin_200(
client, _ = search_authed_client
resp = await client.post(
"/api/v1/search/reindex",
json={"entity_types": ["company"]},
json={"entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
@@ -420,7 +420,7 @@ async def test_ac12_reindex_viewer_403(
resp = await search_client.post(
"/api/v1/search/reindex",
json={"entity_types": ["company"]},
json={"entity_types": ["contact"]},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
@@ -440,7 +440,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
{"id": "3", "title": "Result C", "score": 0.7},
]
fused = rrf_fusion(fts_results, vec_results, "company")
fused = rrf_fusion(fts_results, vec_results, "contact")
# All 3 unique IDs should be present
ids = [str(r.get("id", "")) for r in fused]
@@ -457,7 +457,7 @@ def test_rrf_fusion_combines_fts_and_vector_scores():
def test_rrf_fusion_empty_inputs():
"""RRF fusion with empty inputs returns empty list."""
fused = rrf_fusion([], [], "company")
fused = rrf_fusion([], [], "contact")
assert fused == []
@@ -467,7 +467,7 @@ def test_rrf_fusion_fts_only():
{"id": "1", "title": "A"},
{"id": "2", "title": "B"},
]
fused = rrf_fusion(fts_results, [], "company")
fused = rrf_fusion(fts_results, [], "contact")
assert len(fused) == 2
# First result (rank 0) should have higher score
assert fused[0]["_score"] > fused[1]["_score"]
@@ -547,7 +547,7 @@ def test_provider_get_all():
p1 = MagicMock()
p1.entity_type = "contact"
p2 = MagicMock()
p2.entity_type = "company"
p2.entity_type = "contact"
registry.register(p1)
registry.register(p2)
@@ -556,7 +556,6 @@ def test_provider_get_all():
assert len(all_providers) == 2
entity_types = [p.entity_type for p in all_providers]
assert "contact" in entity_types
assert "company" in entity_types
def test_provider_clear():
@@ -577,13 +576,12 @@ def test_provider_get_entity_types():
p1 = MagicMock()
p1.entity_type = "contact"
p2 = MagicMock()
p2.entity_type = "company"
p2.entity_type = "contact"
registry.register(p1)
registry.register(p2)
types = registry.get_entity_types()
assert "contact" in types
assert "company" in types
assert len(types) == 2
@@ -745,12 +743,12 @@ async def test_llm_aggregate_results_success():
mock_agg_resp.choices = [MagicMock()]
mock_agg_resp.choices[0].message.content = json.dumps({
"summary": "2 Ergebnisse gefunden",
"facets": {"types": {"company": 1, "contact": 1}},
"suggestions": ["Filter by company"],
"facets": {"types": {"contact": 2}},
"suggestions": ["Filter by contact"],
})
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_agg_resp):
results = [
{"entity_type": "company", "title": "Company Alpha"},
{"entity_type": "contact", "title": "Company Alpha"},
{"entity_type": "contact", "title": "John Doe"},
]
result = await llm_aggregate_results(results, "test")
@@ -997,7 +995,7 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_company calls index_entity."""
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1038,7 +1036,7 @@ async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncS
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession):
"""reindex iterates over all entities of a type."""
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1070,7 +1068,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await reindex({}, "company")
await reindex({}, "contact")
mock_index_entity.assert_called()
@@ -1079,7 +1077,7 @@ async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_embedding_batch(mock_index_entity, mock_factory, db_session: AsyncSession):
"""embedding_batch finds entities without embedding."""
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
@@ -1169,7 +1167,7 @@ def test_company_provider_get_embedding_text():
"""CompanyProvider get_embedding_text is a callable method."""
provider = CompanySearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "company"
assert provider.entity_type == "contact"
def test_mail_provider_get_embedding_text():
@@ -1210,7 +1208,7 @@ def test_company_provider_to_search_result():
provider = CompanySearchProvider()
entity = {"id": "456", "name": "Acme Corp", "description": "IT company"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "company"
assert result["entity_type"] == "contact"
assert result["entity_id"] == "456"
assert result["title"] == "Acme Corp"
assert "IT company" in result["snippet"]