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:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 6520e88d53
commit 3ab4925783
137 changed files with 3866 additions and 10195 deletions
+12 -33
View File
@@ -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",
]
-78
View File
@@ -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"]
-65
View File
@@ -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"]
+52
View File
@@ -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()
)
+53
View File
@@ -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)
-51
View File
@@ -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"]
+40
View File
@@ -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
)
-58
View File
@@ -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"]
-86
View File
@@ -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"]
-36
View File
@@ -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"]
-42
View File
@@ -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"]
+35
View File
@@ -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()
)
-34
View File
@@ -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}>"
+29
View File
@@ -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()
)
+30
View File
@@ -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()
)
-34
View File
@@ -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"]
-44
View File
@@ -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"]
+30
View File
@@ -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
View File
@@ -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()
)