Merge campaign reporting and custom fields

Advertising spend and cost per lead reported next to the leads they
produced, and fields that can be added without a migration — including
from a MaskanX conversation over MCP.
This commit is contained in:
AFFAANh
2026-08-04 11:11:34 +05:30
13 changed files with 1551 additions and 2 deletions
@@ -0,0 +1,94 @@
"""add crm_campaigns
Mirrors advertising campaigns pushed from MaskanX so the CRM can report
spend and cost per lead alongside the leads those campaigns produced.
Money columns are integers in minor currency units (paise, cents), matching
what MaskanX sends and what Meta's budget fields use. A Numeric here would
add a second convention and a rounding step between two systems that
currently agree exactly.
Revision ID: b1c4d7e29a03
Revises: eca4681c9f66
Create Date: 2026-08-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b1c4d7e29a03'
down_revision: Union[str, None] = 'eca4681c9f66'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_campaigns',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('provider', sa.String(length=80), nullable=False),
sa.Column('external_id', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=240), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('objective', sa.String(length=80), nullable=True),
sa.Column('channel', sa.String(length=80), nullable=True),
sa.Column('currency', sa.String(length=8), nullable=True),
sa.Column('daily_budget', sa.Integer(), nullable=True),
sa.Column('spend', sa.Integer(), nullable=False),
sa.Column('impressions', sa.Integer(), nullable=False),
sa.Column('clicks', sa.Integer(), nullable=False),
sa.Column('leads', sa.Integer(), nullable=False),
sa.Column('cost_per_lead', sa.Integer(), nullable=True),
sa.Column('metrics_from', sa.String(length=20), nullable=True),
sa.Column('metrics_to', sa.String(length=20), nullable=True),
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('metadata_json', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
# MaskanX re-pushes the same campaign as its figures change, so the
# provider's own id is what identifies it, not our surrogate key.
sa.UniqueConstraint(
'tenant_id', 'provider', 'external_id',
name='uq_crm_campaigns_provider_external',
),
)
op.create_index(
'ix_crm_campaigns_tenant_status',
'crm_campaigns',
['tenant_id', 'status'],
)
# Ad attribution promoted out of crm_leads.attributes into real columns,
# so counting leads per campaign is an indexed join rather than a JSON
# scan. Nullable: most leads do not come from an ad.
op.add_column(
'crm_leads',
sa.Column('campaign_external_id', sa.String(length=255), nullable=True),
)
op.add_column(
'crm_leads',
sa.Column('adset_external_id', sa.String(length=255), nullable=True),
)
op.add_column(
'crm_leads',
sa.Column('ad_external_id', sa.String(length=255), nullable=True),
)
op.create_index(
'ix_crm_leads_campaign_external_id',
'crm_leads',
['campaign_external_id'],
)
def downgrade() -> None:
op.drop_index('ix_crm_leads_campaign_external_id', table_name='crm_leads')
op.drop_column('crm_leads', 'ad_external_id')
op.drop_column('crm_leads', 'adset_external_id')
op.drop_column('crm_leads', 'campaign_external_id')
op.drop_index('ix_crm_campaigns_tenant_status', table_name='crm_campaigns')
op.drop_table('crm_campaigns')
@@ -0,0 +1,62 @@
"""add crm_custom_properties
Fields an operator (or an agent, through MCP) adds without a migration.
The definition lives here; the values live in each record's existing
`attributes` JSON. That split is the whole point: adding a field is an
INSERT, so it can happen mid-conversation and be usable immediately.
Revision ID: c2e5f8a41b76
Revises: b1c4d7e29a03
Create Date: 2026-08-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c2e5f8a41b76'
down_revision: Union[str, None] = 'b1c4d7e29a03'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_custom_properties',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('object_type', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('label', sa.String(length=160), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data_type', sa.String(length=24), nullable=False),
sa.Column('options', sa.JSON(), nullable=False),
sa.Column('is_required', sa.Boolean(), nullable=False),
sa.Column('created_by', sa.String(length=120), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
# `name` is the key written into each record's attributes, so it has
# to be unique per object type — two definitions sharing a key would
# fight over the same stored value.
sa.UniqueConstraint(
'tenant_id', 'object_type', 'name',
name='uq_crm_custom_properties_object_name',
),
)
op.create_index(
'ix_crm_custom_properties_tenant_object',
'crm_custom_properties',
['tenant_id', 'object_type'],
)
def downgrade() -> None:
op.drop_index(
'ix_crm_custom_properties_tenant_object',
table_name='crm_custom_properties',
)
op.drop_table('crm_custom_properties')
+72
View File
@@ -0,0 +1,72 @@
"""Advertising campaigns, as mirrored from MaskanX.
Read-only. The CRM does not run campaigns — MaskanX does, and pushes their
figures here through `POST /integrations/campaigns`. This exists so that
"what did this campaign cost us per lead" can be answered next to the leads
themselves, without leaving the CRM.
Each campaign carries two lead counts: `leads` as Meta attributes them, and
`crm_leads` as this database actually holds them. They routinely differ —
Meta attributes late, and a lead can be entered here by hand — and the gap
is worth seeing rather than hiding behind one number.
"""
from fastapi import APIRouter
from sqlalchemy import func, select
from app.core.security import CurrentUser, Database
from app.models import Campaign, Lead
from app.schemas import CampaignOut
router = APIRouter(prefix="/campaigns", tags=["Campaigns"])
@router.get("", response_model=list[CampaignOut])
def list_campaigns(user: CurrentUser, db: Database) -> list[CampaignOut]:
campaigns = list(
db.scalars(
select(Campaign)
.where(Campaign.tenant_id == user.tenant_id)
.order_by(Campaign.spend.desc()),
),
)
# One grouped count rather than a query per campaign: a workspace with
# a hundred campaigns should still be one round trip.
counts = dict(
db.execute(
select(
Lead.campaign_external_id,
func.count(Lead.id),
)
.where(
Lead.tenant_id == user.tenant_id,
Lead.campaign_external_id.is_not(None),
)
.group_by(Lead.campaign_external_id),
).all(),
)
return [
CampaignOut(
id=campaign.id,
provider=campaign.provider,
external_id=campaign.external_id,
name=campaign.name,
status=campaign.status,
objective=campaign.objective,
channel=campaign.channel,
currency=campaign.currency,
daily_budget=campaign.daily_budget,
spend=campaign.spend,
impressions=campaign.impressions,
clicks=campaign.clicks,
leads=campaign.leads,
cost_per_lead=campaign.cost_per_lead,
metrics_from=campaign.metrics_from,
metrics_to=campaign.metrics_to,
synced_at=campaign.synced_at,
crm_leads=counts.get(campaign.external_id, 0),
)
for campaign in campaigns
]
+11 -1
View File
@@ -18,6 +18,7 @@ from app.services import (
add_audit,
add_event,
apply_updates,
validated_attributes,
contact_to_out,
model_or_404,
verify_optional_reference,
@@ -83,7 +84,11 @@ def create_contact(payload: ContactCreate, user: Writer, db: Database) -> Contac
user.tenant_id,
)
verify_optional_reference(db, User, payload.owner_id, user.tenant_id)
contact = Contact(tenant_id=user.tenant_id, **payload.model_dump())
values = payload.model_dump()
values["attributes"] = validated_attributes(
db, user.tenant_id, "contact", {}, values.get("attributes"),
)
contact = Contact(tenant_id=user.tenant_id, **values)
db.add(contact)
try:
db.flush()
@@ -145,6 +150,11 @@ def update_contact(
user.tenant_id,
)
verify_optional_reference(db, User, values.get("owner_id"), user.tenant_id)
if "attributes" in values:
values["attributes"] = validated_attributes(
db, user.tenant_id, "contact", contact.attributes,
values["attributes"],
)
apply_updates(contact, values)
add_audit(
db,
+86
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload
from app.core.config import get_settings
from app.core.security import Admin, Database, hash_service_key
from app.models import (
Campaign,
Contact,
ExternalLink,
IdempotencyRecord,
@@ -21,6 +22,8 @@ from app.models import (
Tenant,
)
from app.schemas import (
IntegrationCampaignRequest,
IntegrationCampaignResponse,
IntegrationCredentialOut,
IntegrationKeyCreate,
IntegrationKeyResponse,
@@ -155,6 +158,21 @@ def _authenticate_integration(db: Database, raw_key: str | None) -> IntegrationC
return credential
def _attribution(campaign: dict, key: str) -> str | None:
"""Read one ad id out of a lead's campaign block.
`campaign` is free-form JSON from the sender, so a value may be absent,
null, or a number. Anything not usable as an id becomes None rather
than being coerced — a lead attributed to the string "None" would be
worse than one attributed to nothing.
"""
value = campaign.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
@router.get("/status", response_model=IntegrationStatusResponse)
def integration_status(
db: Database,
@@ -333,6 +351,12 @@ def ingest_lead(
stage_id=first_stage.id,
source_id=source.id,
position=next_lead_position(db, credential.tenant_id, first_stage.id),
# Kept in `attributes` as well, so nothing the sender included is
# lost, but the three ids that identify the ad are promoted to
# columns — that is what makes "leads for this campaign" a join.
campaign_external_id=_attribution(payload.campaign, "campaign_id"),
adset_external_id=_attribution(payload.campaign, "adset_id"),
ad_external_id=_attribution(payload.campaign, "ad_id"),
attributes={"campaign": payload.campaign, **payload.metadata},
)
db.add(lead)
@@ -378,3 +402,65 @@ def ingest_lead(
)
db.commit()
return result
@router.post("/campaigns", response_model=IntegrationCampaignResponse)
def ingest_campaign(
payload: IntegrationCampaignRequest,
db: Database,
integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None,
) -> IntegrationCampaignResponse:
"""Create or update one advertising campaign's figures.
Deliberately an upsert keyed on `(provider, external_id)` rather than an
idempotent create like `/leads`. A lead is an event that happened once;
a campaign's figures change every time they are read, and MaskanX pushes
the same campaign repeatedly as its spend grows. An Idempotency-Key here
would either reject the update or return a stale answer.
"""
credential = _authenticate_integration(db, integration_key)
campaign = db.scalar(
select(Campaign).where(
Campaign.tenant_id == credential.tenant_id,
Campaign.provider == payload.provider,
Campaign.external_id == payload.external_id,
),
)
created = campaign is None
if campaign is None:
campaign = Campaign(
tenant_id=credential.tenant_id,
provider=payload.provider,
external_id=payload.external_id,
)
db.add(campaign)
campaign.name = payload.name
campaign.status = payload.status
campaign.objective = payload.objective
campaign.channel = payload.channel
campaign.currency = payload.currency
campaign.daily_budget = payload.daily_budget
campaign.spend = payload.spend
campaign.impressions = payload.impressions
campaign.clicks = payload.clicks
campaign.leads = payload.leads
campaign.cost_per_lead = payload.cost_per_lead
campaign.metrics_from = payload.metrics_from
campaign.metrics_to = payload.metrics_to
campaign.metadata_json = payload.metadata
campaign.synced_at = datetime.now(UTC)
add_event(
db,
tenant_id=credential.tenant_id,
topic="campaign.synced",
payload={
"provider": payload.provider,
"external_id": payload.external_id,
"spend": payload.spend,
},
)
db.commit()
return IntegrationCampaignResponse(campaign_id=campaign.id, created=created)
+205
View File
@@ -0,0 +1,205 @@
"""Custom property definitions — fields added without a migration.
Two ways in, on purpose:
- `/properties` for a logged-in person, from the CRM's own settings screen.
- `/integrations/properties` for MaskanX, authenticated by an integration
key. That is what an agent reaches through MCP when a conversation asks
for a new field.
Both go through the same validation. An agent gets no shortcut around the
rules a person is held to — if anything it needs them more, since it will
happily invent a property name from a half-sentence.
"""
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.core.security import CurrentUser, Database
from app.models import CustomProperty
from app.properties import PropertyError, validate_definition
from app.schemas import (
CustomPropertyCreate,
CustomPropertyOut,
CustomPropertyUpdate,
)
router = APIRouter(prefix="/properties", tags=["Properties"])
integration_router = APIRouter(prefix="/integrations", tags=["Integrations"])
def _list_for(db, tenant_id: str, object_type: str | None) -> list[CustomProperty]:
query = select(CustomProperty).where(CustomProperty.tenant_id == tenant_id)
if object_type:
query = query.where(CustomProperty.object_type == object_type)
return list(db.scalars(query.order_by(CustomProperty.label)))
def _create(
db,
tenant_id: str,
payload: CustomPropertyCreate,
created_by: str,
) -> CustomProperty:
try:
validate_definition(
name=payload.name,
object_type=payload.object_type,
data_type=payload.data_type,
options=payload.options,
)
except PropertyError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
prop = CustomProperty(
tenant_id=tenant_id,
object_type=payload.object_type,
name=payload.name,
label=payload.label,
description=payload.description,
data_type=payload.data_type,
options=payload.options,
is_required=payload.is_required,
created_by=created_by,
)
db.add(prop)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
# Told plainly rather than as a constraint name: an agent that
# re-runs a request needs to know the field already exists, not
# that a unique index fired.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"A property named {payload.name!r} already exists on "
f"{payload.object_type}."
),
) from exc
return prop
@router.get("", response_model=list[CustomPropertyOut])
def list_properties(
user: CurrentUser,
db: Database,
object_type: str | None = None,
) -> list[CustomProperty]:
return _list_for(db, user.tenant_id, object_type)
@router.post("", response_model=CustomPropertyOut, status_code=status.HTTP_201_CREATED)
def create_property(
payload: CustomPropertyCreate,
user: CurrentUser,
db: Database,
) -> CustomProperty:
return _create(db, user.tenant_id, payload, created_by=user.email)
@router.patch("/{property_id}", response_model=CustomPropertyOut)
def update_property(
property_id: str,
payload: CustomPropertyUpdate,
user: CurrentUser,
db: Database,
) -> CustomProperty:
"""Update a property's presentation, never its identity or type.
`name` and `data_type` are not updatable — see `CustomPropertyUpdate`.
Narrowing an enumeration is allowed even though records may already
hold a value that is no longer offered: those values stay readable and
simply cannot be chosen again, which is the behaviour an operator
retiring an option expects.
"""
prop = db.scalar(
select(CustomProperty).where(
CustomProperty.id == property_id,
CustomProperty.tenant_id == user.tenant_id,
),
)
if prop is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if payload.options is not None and prop.data_type != "enumeration":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Options only apply to an enumeration, not to a {prop.data_type}.",
)
if payload.options is not None and not payload.options:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="An enumeration needs at least one option.",
)
for field, value in payload.model_dump(exclude_unset=True).items():
if value is not None:
setattr(prop, field, value)
db.commit()
return prop
@router.delete("/{property_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_property(property_id: str, user: CurrentUser, db: Database) -> None:
"""Remove a definition.
Values already written into records are left where they are. Rewriting
every record to strip a key would be a large, silent, irreversible
write in response to a small request; leaving them means undoing a
mistaken delete is just recreating the property.
"""
prop = db.scalar(
select(CustomProperty).where(
CustomProperty.id == property_id,
CustomProperty.tenant_id == user.tenant_id,
),
)
if prop is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
db.delete(prop)
db.commit()
# --- integration key access, for MaskanX and its MCP tools ---
@integration_router.get("/properties", response_model=list[CustomPropertyOut])
def list_properties_via_integration(
db: Database,
object_type: str | None = None,
integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None,
) -> list[CustomProperty]:
from app.api.integrations import _authenticate_integration
credential = _authenticate_integration(db, integration_key)
properties = _list_for(db, credential.tenant_id, object_type)
db.commit()
return properties
@integration_router.post(
"/properties",
response_model=CustomPropertyOut,
status_code=status.HTTP_201_CREATED,
)
def create_property_via_integration(
payload: CustomPropertyCreate,
db: Database,
integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None,
) -> CustomProperty:
from app.api.integrations import _authenticate_integration
credential = _authenticate_integration(db, integration_key)
return _create(
db,
credential.tenant_id,
payload,
created_by=f"integration:{credential.name}",
)
+14 -1
View File
@@ -1,6 +1,16 @@
from fastapi import APIRouter
from app.api import activities, auth, catalog, contacts, dashboard, integrations, leads
from app.api import (
activities,
auth,
campaigns,
catalog,
contacts,
dashboard,
integrations,
properties,
leads,
)
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
@@ -9,5 +19,8 @@ api_router.include_router(contacts.router)
api_router.include_router(leads.router)
api_router.include_router(activities.router)
api_router.include_router(catalog.router)
api_router.include_router(campaigns.router)
api_router.include_router(properties.router)
api_router.include_router(properties.integration_router)
api_router.include_router(integrations.router)
+121
View File
@@ -279,6 +279,14 @@ class Lead(Base, TimestampMixin):
type_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_lead_types.id", ondelete="SET NULL"),
)
# Ad attribution, promoted out of `attributes` into real columns.
# Meta stamps every lead-ad submission with the campaign, ad set and ad
# that produced it, so "spend and leads for this ad" is a join rather
# than a JSON scan — which also keeps it working on SQLite and
# PostgreSQL alike. Null for leads that did not come from an ad.
campaign_external_id: Mapped[str | None] = mapped_column(String(255), index=True)
adset_external_id: Mapped[str | None] = mapped_column(String(255))
ad_external_id: Mapped[str | None] = mapped_column(String(255))
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
contact: Mapped[Contact | None] = relationship()
@@ -558,3 +566,116 @@ class IdempotencyRecord(Base):
nullable=False,
)
class Campaign(Base, TimestampMixin):
"""An advertising campaign mirrored from MaskanX.
The CRM does not run campaigns; it reports on them next to the leads
they produced. MaskanX pushes each campaign's figures here so that
"what did this campaign cost us per lead" is answerable without leaving
the CRM.
Money is stored in **minor** currency units (paise, cents), matching
what MaskanX sends and what Meta's own budget fields use. Storing a
decimal here would introduce a second convention and a rounding step
between two systems that currently agree exactly.
"""
__tablename__ = "crm_campaigns"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"provider",
"external_id",
name="uq_crm_campaigns_provider_external",
),
Index("ix_crm_campaigns_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
# "maskanx" for now; kept general so a second ad platform does not need
# a second table.
provider: Mapped[str] = mapped_column(String(80), nullable=False)
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(240), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="unknown")
objective: Mapped[str | None] = mapped_column(String(80), nullable=True)
channel: Mapped[str | None] = mapped_column(String(80), nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
daily_budget: Mapped[int | None] = mapped_column(Integer, nullable=True)
spend: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
clicks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Leads as Meta counts them. The CRM's own lead count can differ — a
# lead may arrive here that Meta has not attributed yet, or vice versa —
# so both are kept rather than one being derived from the other.
leads: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_per_lead: Mapped[int | None] = mapped_column(Integer, nullable=True)
metrics_from: Mapped[str | None] = mapped_column(String(20), nullable=True)
metrics_to: Mapped[str | None] = mapped_column(String(20), nullable=True)
synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
metadata_json: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False,
)
class CustomProperty(Base, TimestampMixin):
"""A field an operator added, without a schema migration.
Modelled on how HubSpot treats properties: the definition lives in a
table, and the values live in the record's existing `attributes` JSON.
That is the whole point — adding a field is an INSERT, not a migration,
so an agent can create one mid-conversation and the next record can use
it immediately.
The cost of that choice is that values are not typed by the database,
so `data_type` is enforced in application code on write. Anything that
writes a custom property value has to go through that validation; a
direct UPDATE to `attributes` bypasses it.
`name` is the stable key stored inside `attributes`; `label` is what a
person reads. They are kept separate for the same reason HubSpot keeps
them separate: renaming a label must not orphan every value already
written under the old key.
"""
__tablename__ = "crm_custom_properties"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"object_type",
"name",
name="uq_crm_custom_properties_object_name",
),
Index("ix_crm_custom_properties_tenant_object", "tenant_id", "object_type"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
# Which kind of record carries it: contact, lead, organization, campaign.
object_type: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(String(80), nullable=False)
label: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
data_type: Mapped[str] = mapped_column(String(24), nullable=False, default="string")
# Allowed values for an enumeration. Empty for every other type.
options: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Where it came from. An agent-created field is marked so a human can
# tell at a glance which fields they defined and which one a
# conversation produced.
created_by: Mapped[str | None] = mapped_column(String(120))
+173
View File
@@ -0,0 +1,173 @@
"""Validation for custom property definitions and their values.
Custom property values live in a JSON column, so the database enforces
nothing about them. Everything that writes one has to come through here, or
the `data_type` on the definition is decoration.
Pure functions: no session, no I/O, so the rules can be tested directly.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
# A property name becomes a key inside a record's `attributes` JSON and is
# referenced by agents and API callers, so it is restricted to something
# that survives being a JSON key, a query parameter and a column header.
NAME_PATTERN = r"^[a-z][a-z0-9_]{1,63}$"
DATA_TYPES = ("string", "number", "boolean", "date", "enumeration")
OBJECT_TYPES = ("contact", "lead", "organization", "campaign")
class PropertyError(ValueError):
"""Raised when a definition or a value is not usable."""
def validate_definition(
*,
name: str,
object_type: str,
data_type: str,
options: list[str] | None,
) -> None:
"""Check a property definition before it is stored.
Rejects an enumeration with no options: a field whose only valid values
are none of them cannot ever be filled in, and an agent creating one by
mistake would produce a form nobody can submit.
"""
import re
if object_type not in OBJECT_TYPES:
raise PropertyError(
f"Unknown object type {object_type!r}. "
f"Available: {', '.join(OBJECT_TYPES)}.",
)
if data_type not in DATA_TYPES:
raise PropertyError(
f"Unknown data type {data_type!r}. Available: {', '.join(DATA_TYPES)}.",
)
if not re.match(NAME_PATTERN, name or ""):
raise PropertyError(
f"{name!r} is not a usable property name. Use lowercase letters, "
f"digits and underscores, starting with a letter, e.g. "
f"'budget_range'.",
)
if data_type == "enumeration" and not options:
raise PropertyError(
"An enumeration needs at least one option; otherwise no value "
"could ever be valid for it.",
)
if data_type != "enumeration" and options:
raise PropertyError(
f"Options only apply to an enumeration, not to a {data_type}.",
)
def coerce_value(definition: Any, value: Any) -> Any:
"""Return `value` in the shape its definition calls for.
Coerces rather than merely checking, because callers are agents and
HTTP clients: a number arriving as the string "42" is a well-formed
intention expressed loosely, and rejecting it would be pedantry. What
is rejected is anything genuinely ambiguous — "quite large" is not a
number by any reading.
`None` clears the value and is always allowed here; whether a property
may be empty is `is_required`'s business, checked separately, because
the two questions have different answers on a partial update.
"""
if value is None:
return None
data_type = definition.data_type
if data_type == "string":
return str(value)
if data_type == "number":
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise PropertyError(
f"{definition.label!r} expects a number, not {value!r}.",
) from exc
# Keep whole numbers whole: 3.0 stored for a count reads oddly and
# round-trips into JSON as 3.0 forever.
return int(number) if number.is_integer() else number
if data_type == "boolean":
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in ("true", "yes", "1"):
return True
if text in ("false", "no", "0"):
return False
raise PropertyError(
f"{definition.label!r} expects true or false, not {value!r}.",
)
if data_type == "date":
if isinstance(value, (date, datetime)):
return value.isoformat()
try:
return date.fromisoformat(str(value)[:10]).isoformat()
except ValueError as exc:
raise PropertyError(
f"{definition.label!r} expects a date as YYYY-MM-DD, "
f"not {value!r}.",
) from exc
if data_type == "enumeration":
text = str(value)
if text not in (definition.options or []):
raise PropertyError(
f"{text!r} is not one of the allowed values for "
f"{definition.label!r}: "
f"{', '.join(definition.options or []) or 'none'}.",
)
return text
raise PropertyError(f"Unknown data type {data_type!r}.")
def apply_values(
definitions: list[Any],
current: dict[str, Any],
incoming: dict[str, Any],
) -> dict[str, Any]:
"""Merge validated custom property values into a record's attributes.
Only keys that have a definition are touched. An unknown key is
rejected rather than stored: silently accepting one would let a typo
("budjet_range") sit in the database looking like data, and the whole
reason for a registry is that the set of fields is knowable.
Required properties are enforced only against what is being written,
not against the merged result — a partial update that does not mention
a required field is not an attempt to clear it.
"""
by_name = {definition.name: definition for definition in definitions}
unknown = sorted(set(incoming) - set(by_name))
if unknown:
known = ", ".join(sorted(by_name)) or "none"
raise PropertyError(
f"No such property: {', '.join(unknown)}. Defined here: {known}.",
)
merged = dict(current)
for name, raw in incoming.items():
definition = by_name[name]
coerced = coerce_value(definition, raw)
if coerced is None and definition.is_required:
raise PropertyError(f"{definition.label!r} is required and cannot be empty.")
if coerced is None:
merged.pop(name, None)
else:
merged[name] = coerced
return merged
+100
View File
@@ -409,3 +409,103 @@ class IntegrationStatusResponse(ApiModel):
credential_id: str
credential_name: str
key_prefix: str
class IntegrationCampaignRequest(ApiModel):
"""One advertising campaign's current figures, pushed from MaskanX.
Money is in **minor** currency units (paise, cents), matching what Meta
and MaskanX both use. Sending a decimal would introduce a second
convention and a rounding step between systems that agree exactly.
"""
provider: str = Field(default="maskanx", min_length=2, max_length=80)
external_id: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=240)
status: str = Field(default="unknown", max_length=40)
objective: str | None = Field(default=None, max_length=80)
channel: str | None = Field(default=None, max_length=80)
currency: str | None = Field(default=None, max_length=8)
daily_budget: int | None = Field(default=None, ge=0)
spend: int = Field(default=0, ge=0)
impressions: int = Field(default=0, ge=0)
clicks: int = Field(default=0, ge=0)
leads: int = Field(default=0, ge=0)
# Sent rather than derived so the CRM shows exactly the figure MaskanX
# shows. Recomputing it here from spend and leads would drift whenever
# the two systems rounded differently.
cost_per_lead: int | None = Field(default=None, ge=0)
metrics_from: str | None = Field(default=None, max_length=20)
metrics_to: str | None = Field(default=None, max_length=20)
metadata: dict[str, Any] = Field(default_factory=dict)
class IntegrationCampaignResponse(ApiModel):
campaign_id: str
created: bool
class CampaignOut(ApiModel):
id: str
provider: str
external_id: str
name: str
status: str
objective: str | None
channel: str | None
currency: str | None
daily_budget: int | None
spend: int
impressions: int
clicks: int
leads: int
cost_per_lead: int | None
metrics_from: str | None
metrics_to: str | None
synced_at: datetime
# Leads the CRM itself holds for this campaign. Kept alongside `leads`
# (Meta's count) rather than replacing it: a lead can exist here that
# Meta has not attributed yet, and the gap between the two is worth
# seeing rather than hiding.
crm_leads: int = 0
class CustomPropertyCreate(ApiModel):
object_type: str = Field(min_length=2, max_length=40)
name: str = Field(min_length=2, max_length=80)
label: str = Field(min_length=1, max_length=160)
description: str | None = None
data_type: str = Field(default="string", max_length=24)
options: list[str] = Field(default_factory=list)
is_required: bool = False
class CustomPropertyUpdate(ApiModel):
"""A partial update.
`name`, `object_type` and `data_type` are absent on purpose. They are
the identity and meaning of the field: changing a name orphans every
value already stored under it, and changing a type leaves stored values
that no longer satisfy it. Delete and recreate instead — deliberately
more effort, because it loses data.
"""
label: str | None = Field(default=None, min_length=1, max_length=160)
description: str | None = None
options: list[str] | None = None
is_required: bool | None = None
class CustomPropertyOut(ApiModel):
id: str
object_type: str
name: str
label: str
description: str | None
data_type: str
options: list[str]
is_required: bool
created_by: str | None
created_at: datetime
+49
View File
@@ -247,3 +247,52 @@ def next_lead_position(db: Session, tenant_id: str, stage_id: str) -> int:
def decimal_or_zero(value: Decimal | None) -> Decimal:
return value or Decimal("0")
def validated_attributes(
db: Session,
tenant_id: str,
object_type: str,
current: dict[str, Any],
incoming: dict[str, Any] | None,
) -> dict[str, Any]:
"""Merge incoming custom property values after validating them.
Custom property values live in a JSON column, so the database enforces
nothing about them — every write path has to come through here or the
`data_type` on a definition is decoration.
Values whose property has no definition are rejected rather than
stored. Silently accepting one would let a typo sit in the database
looking like data, and a registry whose set of fields is not actually
the set of fields is worse than none.
"""
from sqlalchemy import select
from app.models import CustomProperty
from app.properties import PropertyError, apply_values
if incoming is None:
return current
definitions = list(
db.scalars(
select(CustomProperty).where(
CustomProperty.tenant_id == tenant_id,
CustomProperty.object_type == object_type,
),
),
)
# Nothing defined yet: keep the previous behaviour of storing whatever
# arrives, so adding this validation does not break workspaces that
# already use `attributes` as a free-form bag.
if not definitions:
return incoming
try:
return apply_values(definitions, current, incoming)
except PropertyError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
+192
View File
@@ -0,0 +1,192 @@
"""Advertising campaigns mirrored from MaskanX.
The CRM does not run campaigns; MaskanX pushes their figures here so that
spend and cost per lead can be read next to the leads they produced.
The behaviour that matters is that pushing the same campaign again updates
it rather than duplicating it — MaskanX re-pushes every few minutes as
spend grows — and that lead attribution actually links the two.
"""
from fastapi.testclient import TestClient
import pytest
@pytest.fixture()
def service_key(client: TestClient, auth_headers: dict[str, str]) -> str:
created = client.post(
"/api/v1/integrations/credentials",
headers=auth_headers,
json={"name": "MaskanX"},
)
assert created.status_code == 201, created.text
return created.json()["key"]
def _campaign(**overrides) -> dict:
payload = {
"provider": "maskanx",
"external_id": "camp_1",
"name": "Q3 lead gen",
"status": "live",
"objective": "OUTCOME_LEADS",
"channel": "facebook",
"currency": "INR",
"daily_budget": 50000,
"spend": 46000,
"impressions": 12000,
"clicks": 300,
"leads": 4,
"cost_per_lead": 11500,
"metrics_from": "2026-07-05",
"metrics_to": "2026-08-03",
}
payload.update(overrides)
return payload
def _push(client, service_key, **overrides):
return client.post(
"/api/v1/integrations/campaigns",
headers={"X-Integration-Key": service_key},
json=_campaign(**overrides),
)
def test_a_campaign_can_be_pushed_and_read_back(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
pushed = _push(client, service_key)
assert pushed.status_code == 200, pushed.text
assert pushed.json()["created"] is True
listed = client.get("/api/v1/campaigns", headers=auth_headers)
assert listed.status_code == 200, listed.text
body = listed.json()
assert len(body) == 1
assert body[0]["name"] == "Q3 lead gen"
assert body[0]["spend"] == 46000
assert body[0]["cost_per_lead"] == 11500
def test_pushing_the_same_campaign_again_updates_it(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
"""MaskanX re-pushes every few minutes as spend grows."""
_push(client, service_key, spend=46000)
again = _push(client, service_key, spend=90000, leads=9, cost_per_lead=10000)
assert again.json()["created"] is False
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
assert len(body) == 1
assert body[0]["spend"] == 90000
assert body[0]["leads"] == 9
def test_two_campaigns_are_kept_apart(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
_push(client, service_key, external_id="camp_1", name="First")
_push(client, service_key, external_id="camp_2", name="Second")
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
assert {row["name"] for row in body} == {"First", "Second"}
def test_campaigns_are_ordered_by_spend(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
_push(client, service_key, external_id="small", name="Small", spend=1000)
_push(client, service_key, external_id="big", name="Big", spend=99000)
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
assert [row["name"] for row in body] == ["Big", "Small"]
def test_pushing_a_campaign_needs_an_integration_key(client: TestClient) -> None:
response = client.post("/api/v1/integrations/campaigns", json=_campaign())
assert response.status_code == 401
def test_reading_campaigns_needs_a_logged_in_user(client: TestClient) -> None:
assert client.get("/api/v1/campaigns").status_code == 401
def test_negative_spend_is_rejected(client: TestClient, service_key: str) -> None:
"""Money that went backwards is a bug upstream, not a figure to store."""
assert _push(client, service_key, spend=-1).status_code == 422
# --- attribution ---
def _lead(client, service_key, external_id, campaign_id):
return client.post(
"/api/v1/integrations/leads",
headers={
"X-Integration-Key": service_key,
"Idempotency-Key": f"key-{external_id}",
},
json={
"provider": "maskanx",
"external_id": external_id,
"first_name": "Asha",
"last_name": "Menon",
"email": f"{external_id}@example.com",
"campaign": {
"campaign_id": campaign_id,
"adset_id": "meta_set_1",
"ad_id": "meta_ad_1",
},
},
)
def test_leads_are_counted_against_the_campaign_that_produced_them(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
_push(client, service_key, external_id="camp_1", leads=9)
assert _lead(client, service_key, "lead_1", "camp_1").status_code == 200
assert _lead(client, service_key, "lead_2", "camp_1").status_code == 200
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
# Meta's count and the CRM's own count are kept separately: they
# routinely differ, and the gap is worth seeing.
assert body[0]["leads"] == 9
assert body[0]["crm_leads"] == 2
def test_a_lead_from_another_campaign_is_not_counted(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
_push(client, service_key, external_id="camp_1")
_lead(client, service_key, "lead_1", "camp_2")
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
assert body[0]["crm_leads"] == 0
def test_a_lead_with_no_campaign_does_not_break_the_count(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
"""Most leads do not come from an ad."""
_push(client, service_key, external_id="camp_1")
response = client.post(
"/api/v1/integrations/leads",
headers={"X-Integration-Key": service_key, "Idempotency-Key": "manual-1"},
json={
"provider": "maskanx",
"external_id": "walk_in_1",
"first_name": "Walk",
"last_name": "In",
},
)
assert response.status_code == 200
body = client.get("/api/v1/campaigns", headers=auth_headers).json()
assert body[0]["crm_leads"] == 0
+372
View File
@@ -0,0 +1,372 @@
"""Custom properties: fields added without a migration.
The definitions live in a table and the values in each record's JSON
`attributes`, so nothing in the database enforces a type. These tests are
mostly about the validation that stands in for that, and about the two ways
in — a logged-in person, and an integration key, which is what an agent
reaches through MCP.
"""
import pytest
from fastapi.testclient import TestClient
from app.properties import PropertyError, coerce_value, validate_definition
class _Definition:
"""Stands in for a stored definition in the pure-function tests."""
def __init__(self, data_type, options=None, label="Field", is_required=False):
self.data_type = data_type
self.options = options or []
self.label = label
self.is_required = is_required
self.name = "field"
# --- defining ---
def test_a_name_must_be_a_usable_key():
"""It becomes a JSON key and a query parameter, not just a label."""
for bad in ("Budget Range", "1st_field", "budget-range", "", "A"):
with pytest.raises(PropertyError):
validate_definition(
name=bad, object_type="contact", data_type="string", options=None,
)
def test_a_good_name_is_accepted():
validate_definition(
name="budget_range", object_type="contact", data_type="string", options=None,
)
def test_an_enumeration_with_no_options_is_refused():
"""No value could ever be valid for it."""
with pytest.raises(PropertyError, match="at least one option"):
validate_definition(
name="stage", object_type="lead", data_type="enumeration", options=[],
)
def test_options_on_a_non_enumeration_are_refused():
with pytest.raises(PropertyError, match="only apply to an enumeration"):
validate_definition(
name="notes", object_type="lead", data_type="string", options=["a"],
)
def test_an_unknown_object_type_lists_the_real_ones():
with pytest.raises(PropertyError, match="contact"):
validate_definition(
name="x", object_type="spaceship", data_type="string", options=None,
)
# --- values ---
def test_a_number_arriving_as_a_string_is_accepted():
"""Callers are agents and HTTP clients; "42" is a number loosely typed."""
assert coerce_value(_Definition("number"), "42") == 42
def test_a_whole_number_stays_whole():
assert coerce_value(_Definition("number"), "3.0") == 3
def test_a_decimal_survives():
assert coerce_value(_Definition("number"), "3.5") == 3.5
def test_something_genuinely_unnumeric_is_refused():
with pytest.raises(PropertyError, match="expects a number"):
coerce_value(_Definition("number"), "quite large")
@pytest.mark.parametrize("value", ["true", "Yes", "1", True])
def test_truthy_spellings_of_boolean(value):
assert coerce_value(_Definition("boolean"), value) is True
@pytest.mark.parametrize("value", ["false", "No", "0", False])
def test_falsy_spellings_of_boolean(value):
assert coerce_value(_Definition("boolean"), value) is False
def test_an_ambiguous_boolean_is_refused():
with pytest.raises(PropertyError, match="true or false"):
coerce_value(_Definition("boolean"), "maybe")
def test_a_date_is_normalised_to_iso():
assert coerce_value(_Definition("date"), "2026-08-04") == "2026-08-04"
def test_a_datetime_string_keeps_only_the_date():
assert coerce_value(_Definition("date"), "2026-08-04T10:30:00Z") == "2026-08-04"
def test_an_unparseable_date_says_what_it_wanted():
with pytest.raises(PropertyError, match="YYYY-MM-DD"):
coerce_value(_Definition("date"), "next tuesday")
def test_an_enumeration_value_must_be_offered():
definition = _Definition("enumeration", options=["hot", "warm"], label="Temp")
assert coerce_value(definition, "hot") == "hot"
with pytest.raises(PropertyError, match="hot, warm"):
coerce_value(definition, "lukewarm")
def test_none_always_clears_a_value():
"""Whether it may be empty is is_required's business, checked elsewhere."""
assert coerce_value(_Definition("number", is_required=True), None) is None
# --- the API ---
@pytest.fixture()
def service_key(client: TestClient, auth_headers: dict[str, str]) -> str:
created = client.post(
"/api/v1/integrations/credentials",
headers=auth_headers,
json={"name": "MaskanX"},
)
return created.json()["key"]
def _definition(**overrides) -> dict:
payload = {
"object_type": "contact",
"name": "budget_range",
"label": "Budget range",
"data_type": "enumeration",
"options": ["50-70 lakh", "70-90 lakh", "1 crore+"],
}
payload.update(overrides)
return payload
def test_a_property_can_be_created_and_listed(
client: TestClient, auth_headers: dict,
) -> None:
created = client.post(
"/api/v1/properties", headers=auth_headers, json=_definition(),
)
assert created.status_code == 201, created.text
listed = client.get("/api/v1/properties", headers=auth_headers).json()
assert [p["name"] for p in listed] == ["budget_range"]
assert listed[0]["options"] == ["50-70 lakh", "70-90 lakh", "1 crore+"]
def test_properties_can_be_filtered_by_object_type(
client: TestClient, auth_headers: dict,
) -> None:
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
client.post(
"/api/v1/properties",
headers=auth_headers,
json=_definition(
object_type="lead", name="site_visit_done",
label="Site visit done", data_type="boolean", options=[],
),
)
contacts = client.get(
"/api/v1/properties?object_type=contact", headers=auth_headers,
).json()
assert [p["name"] for p in contacts] == ["budget_range"]
def test_the_same_property_twice_is_a_readable_conflict(
client: TestClient, auth_headers: dict,
) -> None:
"""An agent re-running a request needs to know it already exists."""
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
again = client.post(
"/api/v1/properties", headers=auth_headers, json=_definition(),
)
assert again.status_code == 409
assert "already exists" in again.json()["error"]["message"]
def test_an_invalid_definition_is_refused_with_a_reason(
client: TestClient, auth_headers: dict,
) -> None:
response = client.post(
"/api/v1/properties",
headers=auth_headers,
json=_definition(name="Budget Range"),
)
assert response.status_code == 422
assert "budget_range" in response.json()["error"]["message"]
def test_a_label_can_be_changed_without_orphaning_values(
client: TestClient, auth_headers: dict,
) -> None:
created = client.post(
"/api/v1/properties", headers=auth_headers, json=_definition(),
).json()
updated = client.patch(
f"/api/v1/properties/{created['id']}",
headers=auth_headers,
json={"label": "Buying budget"},
)
assert updated.status_code == 200
assert updated.json()["label"] == "Buying budget"
# The key values are stored under is untouched.
assert updated.json()["name"] == "budget_range"
def test_a_property_can_be_deleted(client: TestClient, auth_headers: dict) -> None:
created = client.post(
"/api/v1/properties", headers=auth_headers, json=_definition(),
).json()
assert (
client.delete(
f"/api/v1/properties/{created['id']}", headers=auth_headers,
).status_code
== 204
)
assert client.get("/api/v1/properties", headers=auth_headers).json() == []
def test_reading_properties_needs_a_login(client: TestClient) -> None:
assert client.get("/api/v1/properties").status_code == 401
# --- through an integration key, which is what MCP uses ---
def test_an_integration_can_create_a_property(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
created = client.post(
"/api/v1/integrations/properties",
headers={"X-Integration-Key": service_key},
json=_definition(),
)
assert created.status_code == 201, created.text
# Marked as machine-made, so a human can tell which fields a
# conversation produced.
assert created.json()["created_by"].startswith("integration:")
def test_an_integration_gets_no_shortcut_around_validation(
client: TestClient, service_key: str,
) -> None:
response = client.post(
"/api/v1/integrations/properties",
headers={"X-Integration-Key": service_key},
json=_definition(data_type="enumeration", options=[]),
)
assert response.status_code == 422
def test_an_integration_property_is_visible_to_people(
client: TestClient, auth_headers: dict, service_key: str,
) -> None:
"""A field created in chat has to show up in the CRM's own screens."""
client.post(
"/api/v1/integrations/properties",
headers={"X-Integration-Key": service_key},
json=_definition(),
)
listed = client.get("/api/v1/properties", headers=auth_headers).json()
assert [p["name"] for p in listed] == ["budget_range"]
def test_creating_a_property_needs_a_valid_key(client: TestClient) -> None:
response = client.post("/api/v1/integrations/properties", json=_definition())
assert response.status_code == 401
# --- values on records ---
def _contact(client, auth_headers, attributes):
return client.post(
"/api/v1/contacts",
headers=auth_headers,
json={
"first_name": "Asha",
"last_name": "Menon",
"primary_email": "asha@example.com",
"attributes": attributes,
},
)
def test_a_value_is_validated_against_its_definition(
client: TestClient, auth_headers: dict,
) -> None:
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
good = _contact(client, auth_headers, {"budget_range": "70-90 lakh"})
assert good.status_code == 201, good.text
assert good.json()["attributes"]["budget_range"] == "70-90 lakh"
def test_a_value_outside_the_enumeration_is_refused(
client: TestClient, auth_headers: dict,
) -> None:
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
response = _contact(client, auth_headers, {"budget_range": "a few rupees"})
assert response.status_code == 422
assert "70-90 lakh" in response.json()["error"]["message"]
def test_an_undefined_property_is_refused_rather_than_stored(
client: TestClient, auth_headers: dict,
) -> None:
"""A typo left in the database looks exactly like data."""
client.post("/api/v1/properties", headers=auth_headers, json=_definition())
response = _contact(client, auth_headers, {"budjet_range": "70-90 lakh"})
assert response.status_code == 422
assert "budjet_range" in response.json()["error"]["message"]
def test_attributes_stay_free_form_when_nothing_is_defined(
client: TestClient, auth_headers: dict,
) -> None:
"""Adding this validation must not break workspaces already using them."""
response = _contact(client, auth_headers, {"anything": "at all"})
assert response.status_code == 201
assert response.json()["attributes"] == {"anything": "at all"}
def test_a_number_property_is_coerced_on_the_way_in(
client: TestClient, auth_headers: dict,
) -> None:
client.post(
"/api/v1/properties",
headers=auth_headers,
json=_definition(
name="bedrooms", label="Bedrooms", data_type="number", options=[],
),
)
response = _contact(client, auth_headers, {"bedrooms": "3"})
assert response.json()["attributes"]["bedrooms"] == 3