From 95a9961549b88b4341c63aa4e6c9fdb90772b8c2 Mon Sep 17 00:00:00 2001 From: k1nq Date: Sat, 22 Nov 2025 14:09:45 +0500 Subject: [PATCH] Add models for CRM entities: Activity, Contact, Deal, Organization, OrganizationMember, Task; update User model --- app/models/__init__.py | 21 +++++++- app/models/activity.py | 62 +++++++++++++++++++++++ app/models/contact.py | 49 +++++++++++++++++++ app/models/deal.py | 81 +++++++++++++++++++++++++++++++ app/models/organization.py | 45 +++++++++++++++++ app/models/organization_member.py | 59 ++++++++++++++++++++++ app/models/task.py | 47 ++++++++++++++++++ app/models/user.py | 15 ++++-- 8 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 app/models/activity.py create mode 100644 app/models/contact.py create mode 100644 app/models/deal.py create mode 100644 app/models/organization.py create mode 100644 app/models/organization_member.py create mode 100644 app/models/task.py diff --git a/app/models/__init__.py b/app/models/__init__.py index a251586..8129f0a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", +] diff --git a/app/models/activity.py b/app/models/activity.py new file mode 100644 index 0000000..4c004a7 --- /dev/null +++ b/app/models/activity.py @@ -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) diff --git a/app/models/contact.py b/app/models/contact.py new file mode 100644 index 0000000..5c971ef --- /dev/null +++ b/app/models/contact.py @@ -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) diff --git a/app/models/deal.py b/app/models/deal.py new file mode 100644 index 0000000..628ea8e --- /dev/null +++ b/app/models/deal.py @@ -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) diff --git a/app/models/organization.py b/app/models/organization.py new file mode 100644 index 0000000..ddd2399 --- /dev/null +++ b/app/models/organization.py @@ -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) diff --git a/app/models/organization_member.py b/app/models/organization_member.py new file mode 100644 index 0000000..ec434d3 --- /dev/null +++ b/app/models/organization_member.py @@ -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) diff --git a/app/models/task.py b/app/models/task.py new file mode 100644 index 0000000..26fc957 --- /dev/null +++ b/app/models/task.py @@ -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) diff --git a/app/models/user.py b/app/models/user.py index 4014049..e0323fe 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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