feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests

This commit is contained in:
CRM Bot
2026-06-03 21:40:26 +00:00
parent 955607f730
commit 53cbcde729
45 changed files with 3902 additions and 4 deletions
+22
View File
@@ -1,14 +1,36 @@
"""SQLAlchemy ORM models for the CRM system."""
from app.models.account import Account, AccountSize, Industry
from app.models.activity import Activity, ActivityType
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
from app.models.contact import Contact
from app.models.deal import Deal, DealStage
from app.models.deal_stage_history import DealStageHistory
from app.models.note import Note, NoteParentType
from app.models.org import Org
from app.models.tag import Tag
from app.models.tag_link import TagLink, TagLinkParentType
from app.models.user import User, UserRole
__all__ = [
"Account",
"AccountSize",
"Activity",
"ActivityType",
"Base",
"Contact",
"Deal",
"DealStage",
"DealStageHistory",
"Industry",
"Note",
"NoteParentType",
"Org",
"OrgScopedMixin",
"SoftDeleteMixin",
"Tag",
"TagLink",
"TagLinkParentType",
"TimestampMixin",
"User",
"UserRole",
+86
View File
@@ -0,0 +1,86 @@
"""Account model: companies/organizations that the CRM tracks."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional
from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
from app.models.contact import Contact
from app.models.deal import Deal
from app.models.activity import Activity
from app.models.note import Note
from app.models.tag_link import TagLink
class Industry(str, Enum):
"""Industry classification for accounts."""
startup = "startup"
sme = "sme"
midmarket = "midmarket"
enterprise = "enterprise"
other = "other"
class AccountSize(str, Enum):
"""Company size category."""
startup = "startup"
sme = "sme"
midmarket = "midmarket"
enterprise = "enterprise"
class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
website: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
industry: Mapped[Optional[Industry]] = mapped_column(
String(32), nullable=True, index=True
)
size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True)
address: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
# Relationships
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
contacts: Mapped[list["Contact"]] = relationship(
"Contact", back_populates="account", lazy="selectin"
)
deals: Mapped[list["Deal"]] = relationship(
"Deal", back_populates="account", lazy="selectin"
)
activities: Mapped[list["Activity"]] = relationship(
"Activity", back_populates="account", lazy="selectin"
)
notes: Mapped[list["Note"]] = relationship(
"Note",
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list["TagLink"]] = relationship(
"TagLink",
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Account id={self.id} name={self.name!r}>"
__all__ = ["Account", "AccountSize", "Industry"]
+77
View File
@@ -0,0 +1,77 @@
"""Activity model: calls, meetings, emails, tasks, notes tied to any entity."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account
from app.models.contact import Contact
from app.models.deal import Deal
class ActivityType(str, Enum):
"""Type of activity."""
call = "call"
meeting = "meeting"
email = "email"
task = "task"
note = "note"
class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "activities"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[ActivityType] = mapped_column(
String(32), nullable=False, index=True
)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
due_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
account_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True
)
contact_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("contacts.id"), nullable=True, index=True
)
deal_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("deals.id"), nullable=True, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
# Relationships
account: Mapped[Optional["Account"]] = relationship(
"Account", back_populates="activities", lazy="selectin"
)
contact: Mapped[Optional["Contact"]] = relationship(
"Contact", back_populates="activities", lazy="selectin"
)
deal: Mapped[Optional["Deal"]] = relationship(
"Deal", back_populates="activities", lazy="selectin"
)
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
def __repr__(self) -> str:
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
__all__ = ["Activity", "ActivityType"]
+62
View File
@@ -0,0 +1,62 @@
"""Contact model: people at accounts (or standalone contacts)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account
from app.models.activity import Activity
from app.models.note import Note
from app.models.tag_link import TagLink
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "contacts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
first_name: Mapped[str] = mapped_column(String(128), nullable=False)
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
account_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
# Relationships
account: Mapped[Optional["Account"]] = relationship(
"Account", back_populates="contacts", lazy="selectin"
)
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
activities: Mapped[list["Activity"]] = relationship(
"Activity", back_populates="contact", lazy="selectin"
)
notes: Mapped[list["Note"]] = relationship(
"Note",
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list["TagLink"]] = relationship(
"TagLink",
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Contact id={self.id} {self.first_name} {self.last_name!r}>"
__all__ = ["Contact"]
+94
View File
@@ -0,0 +1,94 @@
"""Deal model: sales opportunities tied to an account."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account
from app.models.activity import Activity
from app.models.note import Note
from app.models.tag_link import TagLink
from app.models.deal_stage_history import DealStageHistory
class DealStage(str, Enum):
"""Sales pipeline stage for a deal."""
lead = "lead"
qualified = "qualified"
proposal = "proposal"
negotiation = "negotiation"
won = "won"
lost = "lost"
class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "deals"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
currency: Mapped[str] = mapped_column(
String(3), nullable=False, default="EUR", server_default="EUR"
)
stage: Mapped[DealStage] = mapped_column(
String(32),
nullable=False,
default=DealStage.lead,
server_default=DealStage.lead.value,
index=True,
)
close_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id"), nullable=False, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
won_lost_reason: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
# Relationships
account: Mapped["Account"] = relationship(
"Account", back_populates="deals", lazy="selectin"
)
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
stage_history: Mapped[list["DealStageHistory"]] = relationship(
"DealStageHistory",
back_populates="deal",
cascade="all, delete-orphan",
lazy="selectin",
order_by="DealStageHistory.created_at",
)
activities: Mapped[list["Activity"]] = relationship(
"Activity", back_populates="deal", lazy="selectin"
)
notes: Mapped[list["Note"]] = relationship(
"Note",
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list["TagLink"]] = relationship(
"TagLink",
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Deal id={self.id} title={self.title!r} stage={self.stage}>"
__all__ = ["Deal", "DealStage"]
+44
View File
@@ -0,0 +1,44 @@
"""DealStageHistory model: audit trail of stage transitions for a deal."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, TimestampMixin
from app.models.deal import DealStage
if TYPE_CHECKING:
from app.models.user import User
from app.models.deal import Deal
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
"""Records each deal stage transition. Append-only — no soft-delete."""
__tablename__ = "deal_stage_history"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
deal_id: Mapped[int] = mapped_column(
ForeignKey("deals.id"), nullable=False, index=True
)
from_stage: Mapped[Optional[DealStage]] = mapped_column(String(32), nullable=True)
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
changed_by: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False
)
deal: Mapped["Deal"] = relationship(
"Deal", back_populates="stage_history", lazy="joined"
)
changer: Mapped["User"] = relationship(
"User", foreign_keys=[changed_by], lazy="joined"
)
def __repr__(self) -> str:
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
__all__ = ["DealStageHistory"]
+48
View File
@@ -0,0 +1,48 @@
"""Note model: free-text notes attached polymorphically to accounts/contacts/deals."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
class NoteParentType(str, Enum):
"""Polymorphic parent type for notes (no DB FK, validated in service layer)."""
account = "account"
contact = "contact"
deal = "deal"
class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
body: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
parent_type: Mapped[NoteParentType] = mapped_column(
String(32), nullable=False, index=True
)
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
# Service layer (note_service.create_note) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
author: Mapped["User"] = relationship(
"User", foreign_keys=[author_id], lazy="joined"
)
def __repr__(self) -> str:
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
__all__ = ["Note", "NoteParentType"]
+34
View File
@@ -0,0 +1,34 @@
"""Tag model: labels that can be linked to accounts/contacts/deals."""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.tag_link import TagLink
class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
color: Mapped[str] = mapped_column(
String(7), nullable=False, default="#3B82F6", server_default="#3B82F6"
)
# Relationships
links: Mapped[list["TagLink"]] = relationship(
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
)
def __repr__(self) -> str:
return f"<Tag id={self.id} name={self.name!r}>"
__all__ = ["Tag"]
+50
View File
@@ -0,0 +1,50 @@
"""TagLink model: polymorphic many-to-many between tags and accounts/contacts/deals."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.tag import Tag
class TagLinkParentType(str, Enum):
"""Polymorphic parent type for tag links (no DB FK, validated in service layer)."""
account = "account"
contact = "contact"
deal = "deal"
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "tag_links"
__table_args__ = (
UniqueConstraint(
"tag_id", "parent_type", "parent_id", name="uq_tag_link"
),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id"), nullable=False, index=True
)
parent_type: Mapped[TagLinkParentType] = mapped_column(
String(32), nullable=False, index=True
)
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
# Service layer (tag_service.link_tag) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
tag: Mapped["Tag"] = relationship("Tag", back_populates="links", lazy="joined")
def __repr__(self) -> str:
return f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
__all__ = ["TagLink", "TagLinkParentType"]