Add models for CRM entities: Activity, Contact, Deal, Organization, OrganizationMember, Task; update User model
This commit is contained in:
parent
74330b292f
commit
95a9961549
|
|
@ -1,5 +1,24 @@
|
||||||
"""Model exports for Alembic discovery."""
|
"""Model exports for Alembic discovery."""
|
||||||
|
from app.models.activity import Activity, ActivityType
|
||||||
from app.models.base import Base
|
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
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = ["Base", "User"]
|
__all__ = [
|
||||||
|
"Activity",
|
||||||
|
"ActivityType",
|
||||||
|
"Base",
|
||||||
|
"Contact",
|
||||||
|
"Deal",
|
||||||
|
"DealStage",
|
||||||
|
"DealStatus",
|
||||||
|
"Organization",
|
||||||
|
"OrganizationMember",
|
||||||
|
"OrganizationRole",
|
||||||
|
"Task",
|
||||||
|
"User",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
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
|
from app.models.base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - circular imports only for typing
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""SQLAlchemy model for application users."""
|
"""SQLAlchemy model for application users."""
|
||||||
|
|
@ -18,7 +22,7 @@ class User(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), 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)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
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
|
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):
|
class UserBase(BaseModel):
|
||||||
"""Shared user fields for Pydantic schemas."""
|
"""Shared user fields for Pydantic schemas."""
|
||||||
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
full_name: str | None = None
|
name: str
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue