Add models for CRM entities: Activity, Contact, Deal, Organization, OrganizationMember, Task; update User model

This commit is contained in:
k1nq 2025-11-22 14:09:45 +05:00
parent 74330b292f
commit 95a9961549
8 changed files with 375 additions and 4 deletions

View File

@ -1,5 +1,24 @@
"""Model exports for Alembic discovery."""
from app.models.activity import Activity, ActivityType
from app.models.base import Base
from app.models.contact import Contact
from app.models.deal import Deal, DealStage, DealStatus
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember, OrganizationRole
from app.models.task import Task
from app.models.user import User
__all__ = ["Base", "User"]
__all__ = [
"Activity",
"ActivityType",
"Base",
"Contact",
"Deal",
"DealStage",
"DealStatus",
"Organization",
"OrganizationMember",
"OrganizationRole",
"Task",
"User",
]

62
app/models/activity.py Normal file
View File

@ -0,0 +1,62 @@
"""Activity timeline ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, func, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ActivityType(StrEnum):
COMMENT = "comment"
STATUS_CHANGED = "status_changed"
TASK_CREATED = "task_created"
SYSTEM = "system"
class Activity(Base):
"""Represents a timeline event for a deal."""
__tablename__ = "activities"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id", ondelete="CASCADE"))
author_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[ActivityType] = mapped_column(SqlEnum(ActivityType, name="activity_type"), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(
JSONB,
nullable=False,
server_default=text("'{}'::jsonb"),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
deal = relationship("Deal", back_populates="activities")
author = relationship("User", back_populates="activities")
class ActivityBase(BaseModel):
deal_id: int
author_id: int | None = None
type: ActivityType
payload: dict[str, Any] = Field(default_factory=dict)
class ActivityCreate(ActivityBase):
pass
class ActivityRead(ActivityBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

49
app/models/contact.py Normal file
View File

@ -0,0 +1,49 @@
"""Contact ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Contact(Base):
"""Represents a CRM contact belonging to an organization."""
__tablename__ = "contacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"))
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
organization = relationship("Organization", back_populates="contacts")
owner = relationship("User", back_populates="owned_contacts")
deals = relationship("Deal", back_populates="contact")
class ContactBase(BaseModel):
organization_id: int
owner_id: int
name: str
email: EmailStr | None = None
phone: str | None = None
class ContactCreate(ContactBase):
pass
class ContactRead(ContactBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

81
app/models/deal.py Normal file
View File

@ -0,0 +1,81 @@
"""Deal ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class DealStatus(StrEnum):
NEW = "new"
IN_PROGRESS = "in_progress"
WON = "won"
LOST = "lost"
class DealStage(StrEnum):
QUALIFICATION = "qualification"
PROPOSAL = "proposal"
NEGOTIATION = "negotiation"
CLOSED = "closed"
class Deal(Base):
"""Represents a sales opportunity/deal."""
__tablename__ = "deals"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"))
contact_id: Mapped[int] = mapped_column(ForeignKey("contacts.id", ondelete="RESTRICT"))
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
title: Mapped[str] = mapped_column(String(255), nullable=False)
amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
status: Mapped[DealStatus] = mapped_column(
SqlEnum(DealStatus, name="deal_status"), nullable=False, default=DealStatus.NEW
)
stage: Mapped[DealStage] = mapped_column(
SqlEnum(DealStage, name="deal_stage"), nullable=False, default=DealStage.QUALIFICATION
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
organization = relationship("Organization", back_populates="deals")
contact = relationship("Contact", back_populates="deals")
owner = relationship("User", back_populates="owned_deals")
tasks = relationship("Task", back_populates="deal", cascade="all, delete-orphan")
activities = relationship("Activity", back_populates="deal", cascade="all, delete-orphan")
class DealBase(BaseModel):
organization_id: int
contact_id: int
owner_id: int
title: str
amount: Decimal | None = None
currency: str | None = None
status: DealStatus = DealStatus.NEW
stage: DealStage = DealStage.QUALIFICATION
class DealCreate(DealBase):
pass
class DealRead(DealBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,45 @@
"""Organization ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Organization(Base):
"""Represents a CRM organization/workspace."""
__tablename__ = "organizations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
members = relationship(
"OrganizationMember",
back_populates="organization",
cascade="all, delete-orphan",
)
contacts = relationship("Contact", back_populates="organization", cascade="all, delete-orphan")
deals = relationship("Deal", back_populates="organization", cascade="all, delete-orphan")
class OrganizationBase(BaseModel):
name: str
class OrganizationCreate(OrganizationBase):
pass
class OrganizationRead(OrganizationBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,59 @@
"""Organization member ORM model."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
from sqlalchemy import DateTime, Enum as SqlEnum, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class OrganizationRole(StrEnum):
OWNER = "owner"
ADMIN = "admin"
MANAGER = "manager"
MEMBER = "member"
class OrganizationMember(Base):
"""Links users to organizations with role-based access."""
__tablename__ = "organization_members"
__table_args__ = (
UniqueConstraint("organization_id", "user_id", name="uq_organization_member"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
role: Mapped[OrganizationRole] = mapped_column(
SqlEnum(OrganizationRole, name="organization_role"),
nullable=False,
default=OrganizationRole.MEMBER,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
organization = relationship("Organization", back_populates="members")
user = relationship("User", back_populates="memberships")
class OrganizationMemberBase(BaseModel):
organization_id: int
user_id: int
role: OrganizationRole
class OrganizationMemberCreate(OrganizationMemberBase):
pass
class OrganizationMemberRead(OrganizationMemberBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

47
app/models/task.py Normal file
View File

@ -0,0 +1,47 @@
"""Task ORM model and schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Task(Base):
"""Represents a task linked to a deal."""
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
deal = relationship("Deal", back_populates="tasks")
class TaskBase(BaseModel):
deal_id: int
title: str
description: str | None = None
due_date: datetime | None = None
is_done: bool = False
class TaskCreate(TaskBase):
pass
class TaskRead(TaskBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@ -2,13 +2,17 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, EmailStr
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
if TYPE_CHECKING: # pragma: no cover - circular imports only for typing
pass
class User(Base):
"""SQLAlchemy model for application users."""
@ -18,7 +22,7 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@ -27,12 +31,17 @@ class User(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan")
owned_contacts = relationship("Contact", back_populates="owner")
owned_deals = relationship("Deal", back_populates="owner")
activities = relationship("Activity", back_populates="author")
class UserBase(BaseModel):
"""Shared user fields for Pydantic schemas."""
email: EmailStr
full_name: str | None = None
name: str
is_active: bool = True