T01: core infrastructure + auth + multi-tenant + RLS
- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens - Session-based auth (Redis + PostgreSQL audit trail) - Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config) - RBAC with roles/permissions + field-level permissions - CSRF protection via Origin header validation - Auth rate limiting (Redis counters with TTL) - CORS with explicit origins (no wildcard) - Health endpoint (no auth required) - Notification service + audit log middleware - 29 tests, 26 ACs, all passing - Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
+12
-33
@@ -1,37 +1,16 @@
|
||||
"""SQLAlchemy ORM models for the CRM system."""
|
||||
"""SQLAlchemy models for LeoCRM."""
|
||||
|
||||
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
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.session import Session
|
||||
from app.models.audit import AuditLog, DeletionLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.auth import PasswordResetToken, ApiToken
|
||||
from app.models.company import Company
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountSize",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Base",
|
||||
"Contact",
|
||||
"Deal",
|
||||
"DealStage",
|
||||
"DealStageHistory",
|
||||
"Industry",
|
||||
"Note",
|
||||
"NoteParentType",
|
||||
"Org",
|
||||
"OrgScopedMixin",
|
||||
"SoftDeleteMixin",
|
||||
"Tag",
|
||||
"TagLink",
|
||||
"TagLinkParentType",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||
"AuditLog", "DeletionLog", "Notification",
|
||||
"PasswordResetToken", "ApiToken", "Company",
|
||||
]
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Account model: companies/organizations that the CRM tracks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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.activity import Activity
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
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[str | None] = mapped_column(String(512), nullable=True)
|
||||
industry: Mapped[Industry | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
size: Mapped[AccountSize | None] = mapped_column(String(32), nullable=True)
|
||||
address: Mapped[dict[str, Any] | None] = 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"]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""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
|
||||
|
||||
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.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
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[str | None] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
contact_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("contacts.id"), nullable=True, index=True
|
||||
)
|
||||
deal_id: Mapped[int | None] = 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[Account | None] = relationship(
|
||||
"Account", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
contact: Mapped[Contact | None] = relationship(
|
||||
"Contact", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
deal: Mapped[Deal | None] = 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"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""AuditLog and DeletionLog models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class AuditLog(Base, TenantMixin):
|
||||
"""Audit trail for all create/update/delete/login actions."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
changes: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class DeletionLog(Base, TenantMixin):
|
||||
"""Immutable record of deleted entities (for forensic recovery)."""
|
||||
|
||||
__tablename__ = "deletion_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
entity_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
deleted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PasswordResetToken and ApiToken models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class PasswordResetToken(Base, TenantMixin):
|
||||
"""Token for password reset flow."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ApiToken(Base, TenantMixin):
|
||||
"""API token for programmatic access."""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
__table_args__ = (
|
||||
Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
scopes: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Base model and reusable mixins for the CRM system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at columns to a model."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Adds deleted_at column for soft-delete pattern."""
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
class OrgScopedMixin:
|
||||
"""Adds org_id foreign key. All queries must filter by org_id (OrgScopedQuery)."""
|
||||
|
||||
org_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("orgs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Base", "OrgScopedMixin", "SoftDeleteMixin", "TimestampMixin"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Company model — minimal for cross-tenant test (AC #17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Numeric, Text, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Company(Base, TenantMixin):
|
||||
"""Company entity (minimal fields for T01 cross-tenant test)."""
|
||||
|
||||
__tablename__ = "companies"
|
||||
__table_args__ = (
|
||||
Index("ix_companies_tenant_deleted", "tenant_id", "deleted_at"),
|
||||
Index("ix_companies_tenant_name", "tenant_id", "name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Contact model: people at accounts (or standalone contacts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.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.user import User
|
||||
|
||||
|
||||
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[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
account_id: Mapped[int | None] = 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[Account | None] = 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"]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""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
|
||||
|
||||
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.account import Account
|
||||
from app.models.activity import Activity
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
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[date | None] = 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[str | None] = 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"]
|
||||
@@ -1,36 +0,0 @@
|
||||
"""DealStageHistory model: audit trail of stage transitions for a deal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.deal import Deal
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
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[DealStage | None] = 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"]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Notification model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Notification(Base, TenantMixin):
|
||||
"""User notification entity."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
Index("ix_notifications_tenant_user_read", "tenant_id", "user_id", "read_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Organization model."""
|
||||
|
||||
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, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Org(Base, TimestampMixin):
|
||||
__tablename__ = "orgs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
default_currency: Mapped[str] = mapped_column(
|
||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
||||
)
|
||||
|
||||
# Relationship to users (defined here to resolve circular import)
|
||||
users: Mapped[list[User]] = relationship(
|
||||
back_populates="org",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Org id={self.id} name={self.name!r}>"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Role model with RBAC permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Role(Base, TenantMixin):
|
||||
"""Role entity with module→action→permission mapping and field-level permissions."""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
field_permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Session model — PostgreSQL audit trail for sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Session(Base, TenantMixin):
|
||||
"""Immutable session audit record. Runtime session lookup uses Redis."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
csrf_token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,44 +0,0 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tenant model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""Tenant entity — top-level organisational unit."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
+34
-33
@@ -1,48 +1,49 @@
|
||||
"""User model with role enum and soft-delete."""
|
||||
"""User and UserTenant models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.org import Org
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""User role for RBAC."""
|
||||
class User(Base, TenantMixin):
|
||||
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||
|
||||
admin = "admin"
|
||||
sales_manager = "sales_manager"
|
||||
sales_rep = "sales_rep"
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=UserRole.sales_rep,
|
||||
server_default=UserRole.sales_rep.value,
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
# Relationship back to org (string reference avoids circular import at runtime)
|
||||
org: Mapped[Org] = relationship(back_populates="users", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email!r} role={self.role}>"
|
||||
class UserTenant(Base):
|
||||
"""N:M association — user membership in tenants."""
|
||||
|
||||
__tablename__ = "user_tenants"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user