chore: sync uncommitted working tree changes before clone cleanup

This commit is contained in:
CRM Bot
2026-06-10 20:52:56 +00:00
parent 3f5b7df178
commit 394a6e935d
44 changed files with 246 additions and 220 deletions
+4 -4
View File
@@ -9,18 +9,18 @@ from __future__ import annotations
import asyncio import asyncio
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
# Import models so Base.metadata is populated
import app.models # noqa: F401
from alembic import context
# Import settings + Base + all models # Import settings + Base + all models
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import Base from app.core.db import Base
# Import models so Base.metadata is populated
import app.models # noqa: F401
config = context.config config = context.config
# Override sqlalchemy.url from app settings # Override sqlalchemy.url from app settings
+12 -6
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,9 +12,17 @@ from app.models.user import User
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
from app.services.account_service import ( from app.services.account_service import (
create_account as svc_create_account, create_account as svc_create_account,
)
from app.services.account_service import (
get_account as svc_get_account, get_account as svc_get_account,
)
from app.services.account_service import (
list_accounts as svc_list_accounts, list_accounts as svc_list_accounts,
)
from app.services.account_service import (
soft_delete_account as svc_soft_delete_account, soft_delete_account as svc_soft_delete_account,
)
from app.services.account_service import (
update_account as svc_update_account, update_account as svc_update_account,
) )
@@ -48,10 +54,10 @@ async def create_account(
async def list_accounts( async def list_accounts(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
industry: Optional[Industry] = None, industry: Industry | None = None,
size: Optional[AccountSize] = None, size: AccountSize | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
q: Optional[str] = None, q: str | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[AccountOut]: ) -> list[AccountOut]:
+16 -6
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,11 +17,23 @@ from app.schemas.activity import (
) )
from app.services.activity_service import ( from app.services.activity_service import (
NoParentException, NoParentException,
)
from app.services.activity_service import (
complete_activity as svc_complete_activity, complete_activity as svc_complete_activity,
)
from app.services.activity_service import (
create_activity as svc_create_activity, create_activity as svc_create_activity,
)
from app.services.activity_service import (
get_activity as svc_get_activity, get_activity as svc_get_activity,
)
from app.services.activity_service import (
list_activities as svc_list_activities, list_activities as svc_list_activities,
)
from app.services.activity_service import (
soft_delete_activity as svc_soft_delete_activity, soft_delete_activity as svc_soft_delete_activity,
)
from app.services.activity_service import (
update_activity as svc_update_activity, update_activity as svc_update_activity,
) )
@@ -58,10 +68,10 @@ async def create_activity(
async def list_activities( async def list_activities(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
type: Optional[ActivityType] = None, type: ActivityType | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
overdue: Optional[bool] = None, overdue: bool | None = None,
completed: Optional[bool] = None, completed: bool | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[ActivityOut]: ) -> list[ActivityOut]:
+1 -1
View File
@@ -7,8 +7,8 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings from app.core.config import get_settings
from app.core.deps import get_current_user
from app.core.db import get_db from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
LogoutResponse, LogoutResponse,
+13 -5
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,10 +11,20 @@ from app.models.user import User
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
from app.services.contact_service import ( from app.services.contact_service import (
InvalidAccount, InvalidAccount,
)
from app.services.contact_service import (
create_contact as svc_create_contact, create_contact as svc_create_contact,
)
from app.services.contact_service import (
get_contact as svc_get_contact, get_contact as svc_get_contact,
)
from app.services.contact_service import (
list_contacts as svc_list_contacts, list_contacts as svc_list_contacts,
)
from app.services.contact_service import (
soft_delete_contact as svc_soft_delete_contact, soft_delete_contact as svc_soft_delete_contact,
)
from app.services.contact_service import (
update_contact as svc_update_contact, update_contact as svc_update_contact,
) )
@@ -51,9 +59,9 @@ async def create_contact(
async def list_contacts( async def list_contacts(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
account_id: Optional[int] = None, account_id: int | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
q: Optional[str] = None, q: str | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[ContactOut]: ) -> list[ContactOut]:
+17 -7
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,12 +18,24 @@ from app.schemas.deal import (
) )
from app.services.deal_service import ( from app.services.deal_service import (
InvalidAccount, InvalidAccount,
create_deal as svc_create_deal,
get_deal as svc_get_deal,
get_pipeline, get_pipeline,
)
from app.services.deal_service import (
create_deal as svc_create_deal,
)
from app.services.deal_service import (
get_deal as svc_get_deal,
)
from app.services.deal_service import (
list_deals as svc_list_deals, list_deals as svc_list_deals,
)
from app.services.deal_service import (
soft_delete_deal as svc_soft_delete_deal, soft_delete_deal as svc_soft_delete_deal,
)
from app.services.deal_service import (
update_deal as svc_update_deal, update_deal as svc_update_deal,
)
from app.services.deal_service import (
update_stage as svc_update_stage, update_stage as svc_update_stage,
) )
@@ -60,9 +70,9 @@ async def create_deal(
async def list_deals( async def list_deals(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
stage: Optional[DealStage] = None, stage: DealStage | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
account_id: Optional[int] = None, account_id: int | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[DealOut]: ) -> list[DealOut]:
+12 -4
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,10 +12,20 @@ from app.models.user import User
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
from app.services.note_service import ( from app.services.note_service import (
InvalidParent, InvalidParent,
)
from app.services.note_service import (
create_note as svc_create_note, create_note as svc_create_note,
)
from app.services.note_service import (
get_note as svc_get_note, get_note as svc_get_note,
)
from app.services.note_service import (
list_notes as svc_list_notes, list_notes as svc_list_notes,
)
from app.services.note_service import (
soft_delete_note as svc_soft_delete_note, soft_delete_note as svc_soft_delete_note,
)
from app.services.note_service import (
update_note as svc_update_note, update_note as svc_update_note,
) )
@@ -52,8 +60,8 @@ async def create_note(
async def list_notes( async def list_notes(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
parent_type: Optional[NoteParentType] = None, parent_type: NoteParentType | None = None,
parent_id: Optional[int] = None, parent_id: int | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[NoteOut]: ) -> list[NoteOut]:
+8
View File
@@ -13,9 +13,17 @@ from app.services.tag_service import (
DuplicateTagLink, DuplicateTagLink,
InvalidParent, InvalidParent,
TagNotFound, TagNotFound,
)
from app.services.tag_service import (
create_tag as svc_create_tag, create_tag as svc_create_tag,
)
from app.services.tag_service import (
link_tag as svc_link_tag, link_tag as svc_link_tag,
)
from app.services.tag_service import (
list_tags as svc_list_tags, list_tags as svc_list_tags,
)
from app.services.tag_service import (
unlink_tag as svc_unlink_tag, unlink_tag as svc_unlink_tag,
) )
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request, status
+13 -13
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, ForeignKey, String from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -11,12 +11,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User from app.models.activity import Activity
from app.models.contact import Contact from app.models.contact import Contact
from app.models.deal import Deal from app.models.deal import Deal
from app.models.activity import Activity
from app.models.note import Note from app.models.note import Note
from app.models.tag_link import TagLink from app.models.tag_link import TagLink
from app.models.user import User
class Industry(str, Enum): class Industry(str, Enum):
@@ -43,36 +43,36 @@ class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
website: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) website: Mapped[str | None] = mapped_column(String(512), nullable=True)
industry: Mapped[Optional[Industry]] = mapped_column( industry: Mapped[Industry | None] = mapped_column(
String(32), nullable=True, index=True String(32), nullable=True, index=True
) )
size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True) size: Mapped[AccountSize | None] = mapped_column(String(32), nullable=True)
address: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) address: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
owner_id: Mapped[int] = mapped_column( owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True ForeignKey("users.id"), nullable=False, index=True
) )
# Relationships # Relationships
owner: Mapped["User"] = relationship( owner: Mapped[User] = relationship(
"User", foreign_keys=[owner_id], lazy="joined" "User", foreign_keys=[owner_id], lazy="joined"
) )
contacts: Mapped[list["Contact"]] = relationship( contacts: Mapped[list[Contact]] = relationship(
"Contact", back_populates="account", lazy="selectin" "Contact", back_populates="account", lazy="selectin"
) )
deals: Mapped[list["Deal"]] = relationship( deals: Mapped[list[Deal]] = relationship(
"Deal", back_populates="account", lazy="selectin" "Deal", back_populates="account", lazy="selectin"
) )
activities: Mapped[list["Activity"]] = relationship( activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="account", lazy="selectin" "Activity", back_populates="account", lazy="selectin"
) )
notes: Mapped[list["Note"]] = relationship( notes: Mapped[list[Note]] = relationship(
"Note", "Note",
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')", primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
viewonly=True, viewonly=True,
lazy="selectin", lazy="selectin",
) )
tags: Mapped[list["TagLink"]] = relationship( tags: Mapped[list[TagLink]] = relationship(
"TagLink", "TagLink",
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')", primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
viewonly=True, viewonly=True,
+12 -12
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -12,10 +12,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account from app.models.account import Account
from app.models.contact import Contact from app.models.contact import Contact
from app.models.deal import Deal from app.models.deal import Deal
from app.models.user import User
class ActivityType(str, Enum): class ActivityType(str, Enum):
@@ -36,20 +36,20 @@ class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
String(32), nullable=False, index=True String(32), nullable=False, index=True
) )
subject: Mapped[str] = mapped_column(String(255), nullable=False) subject: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[Optional[str]] = mapped_column(Text, nullable=True) body: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[Optional[datetime]] = mapped_column( due_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True DateTime(timezone=True), nullable=True, index=True
) )
completed_at: Mapped[Optional[datetime]] = mapped_column( completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
account_id: Mapped[Optional[int]] = mapped_column( account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True ForeignKey("accounts.id"), nullable=True, index=True
) )
contact_id: Mapped[Optional[int]] = mapped_column( contact_id: Mapped[int | None] = mapped_column(
ForeignKey("contacts.id"), nullable=True, index=True ForeignKey("contacts.id"), nullable=True, index=True
) )
deal_id: Mapped[Optional[int]] = mapped_column( deal_id: Mapped[int | None] = mapped_column(
ForeignKey("deals.id"), nullable=True, index=True ForeignKey("deals.id"), nullable=True, index=True
) )
owner_id: Mapped[int] = mapped_column( owner_id: Mapped[int] = mapped_column(
@@ -57,16 +57,16 @@ class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
) )
# Relationships # Relationships
account: Mapped[Optional["Account"]] = relationship( account: Mapped[Account | None] = relationship(
"Account", back_populates="activities", lazy="selectin" "Account", back_populates="activities", lazy="selectin"
) )
contact: Mapped[Optional["Contact"]] = relationship( contact: Mapped[Contact | None] = relationship(
"Contact", back_populates="activities", lazy="selectin" "Contact", back_populates="activities", lazy="selectin"
) )
deal: Mapped[Optional["Deal"]] = relationship( deal: Mapped[Deal | None] = relationship(
"Deal", back_populates="activities", lazy="selectin" "Deal", back_populates="activities", lazy="selectin"
) )
owner: Mapped["User"] = relationship( owner: Mapped[User] = relationship(
"User", foreign_keys=[owner_id], lazy="joined" "User", foreign_keys=[owner_id], lazy="joined"
) )
+1 -2
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, func from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -31,7 +30,7 @@ class TimestampMixin:
class SoftDeleteMixin: class SoftDeleteMixin:
"""Adds deleted_at column for soft-delete pattern.""" """Adds deleted_at column for soft-delete pattern."""
deleted_at: Mapped[Optional[datetime]] = mapped_column( deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=True, nullable=True,
default=None, default=None,
+10 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -10,11 +10,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account from app.models.account import Account
from app.models.activity import Activity from app.models.activity import Activity
from app.models.note import Note from app.models.note import Note
from app.models.tag_link import TagLink from app.models.tag_link import TagLink
from app.models.user import User
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
@@ -23,9 +23,9 @@ class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
first_name: Mapped[str] = mapped_column(String(128), nullable=False) first_name: Mapped[str] = mapped_column(String(128), nullable=False)
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True) last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
account_id: Mapped[Optional[int]] = mapped_column( account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True ForeignKey("accounts.id"), nullable=True, index=True
) )
owner_id: Mapped[int] = mapped_column( owner_id: Mapped[int] = mapped_column(
@@ -33,22 +33,22 @@ class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
) )
# Relationships # Relationships
account: Mapped[Optional["Account"]] = relationship( account: Mapped[Account | None] = relationship(
"Account", back_populates="contacts", lazy="selectin" "Account", back_populates="contacts", lazy="selectin"
) )
owner: Mapped["User"] = relationship( owner: Mapped[User] = relationship(
"User", foreign_keys=[owner_id], lazy="joined" "User", foreign_keys=[owner_id], lazy="joined"
) )
activities: Mapped[list["Activity"]] = relationship( activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="contact", lazy="selectin" "Activity", back_populates="contact", lazy="selectin"
) )
notes: Mapped[list["Note"]] = relationship( notes: Mapped[list[Note]] = relationship(
"Note", "Note",
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')", primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
viewonly=True, viewonly=True,
lazy="selectin", lazy="selectin",
) )
tags: Mapped[list["TagLink"]] = relationship( tags: Mapped[list[TagLink]] = relationship(
"TagLink", "TagLink",
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')", primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
viewonly=True, viewonly=True,
+11 -11
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, String from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -13,12 +13,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User
from app.models.account import Account from app.models.account import Account
from app.models.activity import Activity from app.models.activity import Activity
from app.models.deal_stage_history import DealStageHistory
from app.models.note import Note from app.models.note import Note
from app.models.tag_link import TagLink from app.models.tag_link import TagLink
from app.models.deal_stage_history import DealStageHistory from app.models.user import User
class DealStage(str, Enum): class DealStage(str, Enum):
@@ -48,39 +48,39 @@ class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
server_default=DealStage.lead.value, server_default=DealStage.lead.value,
index=True, index=True,
) )
close_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
account_id: Mapped[int] = mapped_column( account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id"), nullable=False, index=True ForeignKey("accounts.id"), nullable=False, index=True
) )
owner_id: Mapped[int] = mapped_column( owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True ForeignKey("users.id"), nullable=False, index=True
) )
won_lost_reason: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) won_lost_reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Relationships # Relationships
account: Mapped["Account"] = relationship( account: Mapped[Account] = relationship(
"Account", back_populates="deals", lazy="selectin" "Account", back_populates="deals", lazy="selectin"
) )
owner: Mapped["User"] = relationship( owner: Mapped[User] = relationship(
"User", foreign_keys=[owner_id], lazy="joined" "User", foreign_keys=[owner_id], lazy="joined"
) )
stage_history: Mapped[list["DealStageHistory"]] = relationship( stage_history: Mapped[list[DealStageHistory]] = relationship(
"DealStageHistory", "DealStageHistory",
back_populates="deal", back_populates="deal",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
order_by="DealStageHistory.created_at", order_by="DealStageHistory.created_at",
) )
activities: Mapped[list["Activity"]] = relationship( activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="deal", lazy="selectin" "Activity", back_populates="deal", lazy="selectin"
) )
notes: Mapped[list["Note"]] = relationship( notes: Mapped[list[Note]] = relationship(
"Note", "Note",
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')", primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
viewonly=True, viewonly=True,
lazy="selectin", lazy="selectin",
) )
tags: Mapped[list["TagLink"]] = relationship( tags: Mapped[list[TagLink]] = relationship(
"TagLink", "TagLink",
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')", primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
viewonly=True, viewonly=True,
+5 -5
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -11,8 +11,8 @@ from app.models.base import Base, OrgScopedMixin, TimestampMixin
from app.models.deal import DealStage from app.models.deal import DealStage
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User
from app.models.deal import Deal from app.models.deal import Deal
from app.models.user import User
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin): class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
@@ -24,16 +24,16 @@ class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
deal_id: Mapped[int] = mapped_column( deal_id: Mapped[int] = mapped_column(
ForeignKey("deals.id"), nullable=False, index=True ForeignKey("deals.id"), nullable=False, index=True
) )
from_stage: Mapped[Optional[DealStage]] = mapped_column(String(32), nullable=True) from_stage: Mapped[DealStage | None] = mapped_column(String(32), nullable=True)
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False) to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
changed_by: Mapped[int] = mapped_column( changed_by: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False ForeignKey("users.id"), nullable=False
) )
deal: Mapped["Deal"] = relationship( deal: Mapped[Deal] = relationship(
"Deal", back_populates="stage_history", lazy="joined" "Deal", back_populates="stage_history", lazy="joined"
) )
changer: Mapped["User"] = relationship( changer: Mapped[User] = relationship(
"User", foreign_keys=[changed_by], lazy="joined" "User", foreign_keys=[changed_by], lazy="joined"
) )
+1 -1
View File
@@ -37,7 +37,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
# Service layer (note_service.create_note) validates existence. # Service layer (note_service.create_note) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
author: Mapped["User"] = relationship( author: Mapped[User] = relationship(
"User", foreign_keys=[author_id], lazy="joined" "User", foreign_keys=[author_id], lazy="joined"
) )
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -18,13 +18,13 @@ class Org(Base, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
logo_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) logo_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
default_currency: Mapped[str] = mapped_column( default_currency: Mapped[str] = mapped_column(
String(3), nullable=False, default="EUR", server_default="EUR" String(3), nullable=False, default="EUR", server_default="EUR"
) )
# Relationship to users (defined here to resolve circular import) # Relationship to users (defined here to resolve circular import)
users: Mapped[list["User"]] = relationship( users: Mapped[list[User]] = relationship(
back_populates="org", back_populates="org",
lazy="selectin", lazy="selectin",
cascade="all, delete-orphan", cascade="all, delete-orphan",
+1 -1
View File
@@ -23,7 +23,7 @@ class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
) )
# Relationships # Relationships
links: Mapped[list["TagLink"]] = relationship( links: Mapped[list[TagLink]] = relationship(
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin" "TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
) )
+1 -1
View File
@@ -41,7 +41,7 @@ class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
# Service layer (tag_service.link_tag) validates existence. # Service layer (tag_service.link_tag) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
tag: Mapped["Tag"] = relationship("Tag", back_populates="links", lazy="joined") tag: Mapped[Tag] = relationship("Tag", back_populates="links", lazy="joined")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>" return f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
+3 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import Boolean, String, UniqueConstraint from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -38,13 +38,13 @@ class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
default=UserRole.sales_rep, default=UserRole.sales_rep,
server_default=UserRole.sales_rep.value, server_default=UserRole.sales_rep.value,
) )
avatar_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
email_notifications: Mapped[bool] = mapped_column( email_notifications: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1" Boolean, nullable=False, default=True, server_default="1"
) )
# Relationship back to org (string reference avoids circular import at runtime) # Relationship back to org (string reference avoids circular import at runtime)
org: Mapped["Org"] = relationship(back_populates="users", lazy="joined") org: Mapped[Org] = relationship(back_populates="users", lazy="joined")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<User id={self.id} email={self.email!r} role={self.role}>" return f"<User id={self.id} email={self.email!r} role={self.role}>"
+11 -12
View File
@@ -3,8 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from decimal import Decimal from typing import Any
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -15,10 +14,10 @@ class AccountBase(BaseModel):
"""Shared fields for create/update.""" """Shared fields for create/update."""
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
website: Optional[str] = Field(None, max_length=512) website: str | None = Field(None, max_length=512)
industry: Optional[Industry] = None industry: Industry | None = None
size: Optional[AccountSize] = None size: AccountSize | None = None
address: Optional[dict[str, Any]] = None address: dict[str, Any] | None = None
class AccountCreate(AccountBase): class AccountCreate(AccountBase):
@@ -30,11 +29,11 @@ class AccountCreate(AccountBase):
class AccountUpdate(BaseModel): class AccountUpdate(BaseModel):
"""Request body for PATCH /api/v1/accounts/{id}. All optional.""" """Request body for PATCH /api/v1/accounts/{id}. All optional."""
name: Optional[str] = Field(None, min_length=1, max_length=255) name: str | None = Field(None, min_length=1, max_length=255)
website: Optional[str] = Field(None, max_length=512) website: str | None = Field(None, max_length=512)
industry: Optional[Industry] = None industry: Industry | None = None
size: Optional[AccountSize] = None size: AccountSize | None = None
address: Optional[dict[str, Any]] = None address: dict[str, Any] | None = None
class AccountOut(AccountBase): class AccountOut(AccountBase):
@@ -47,7 +46,7 @@ class AccountOut(AccountBase):
owner_id: int owner_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
class AccountListItem(AccountOut): class AccountListItem(AccountOut):
+16 -17
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -13,14 +12,14 @@ from app.models.activity import ActivityType
class ActivityBase(BaseModel): class ActivityBase(BaseModel):
type: ActivityType type: ActivityType
subject: str = Field(..., min_length=1, max_length=255) subject: str = Field(..., min_length=1, max_length=255)
body: Optional[str] = None body: str | None = None
due_date: Optional[datetime] = None due_date: datetime | None = None
account_id: Optional[int] = None account_id: int | None = None
contact_id: Optional[int] = None contact_id: int | None = None
deal_id: Optional[int] = None deal_id: int | None = None
@model_validator(mode="after") @model_validator(mode="after")
def _check_at_least_one_parent(self) -> "ActivityBase": def _check_at_least_one_parent(self) -> ActivityBase:
if self.account_id is None and self.contact_id is None and self.deal_id is None: if self.account_id is None and self.contact_id is None and self.deal_id is None:
raise ValueError( raise ValueError(
"At least one of account_id, contact_id, deal_id must be set" "At least one of account_id, contact_id, deal_id must be set"
@@ -33,13 +32,13 @@ class ActivityCreate(ActivityBase):
class ActivityUpdate(BaseModel): class ActivityUpdate(BaseModel):
type: Optional[ActivityType] = None type: ActivityType | None = None
subject: Optional[str] = Field(None, min_length=1, max_length=255) subject: str | None = Field(None, min_length=1, max_length=255)
body: Optional[str] = None body: str | None = None
due_date: Optional[datetime] = None due_date: datetime | None = None
account_id: Optional[int] = None account_id: int | None = None
contact_id: Optional[int] = None contact_id: int | None = None
deal_id: Optional[int] = None deal_id: int | None = None
class ActivityOut(ActivityBase): class ActivityOut(ActivityBase):
@@ -48,16 +47,16 @@ class ActivityOut(ActivityBase):
id: int id: int
org_id: int org_id: int
owner_id: int owner_id: int
completed_at: Optional[datetime] = None completed_at: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
class ActivityCompleteRequest(BaseModel): class ActivityCompleteRequest(BaseModel):
"""Body for PATCH /api/v1/activities/{id}/complete.""" """Body for PATCH /api/v1/activities/{id}/complete."""
outcome: Optional[str] = Field(None, max_length=2048) outcome: str | None = Field(None, max_length=2048)
__all__ = [ __all__ = [
+1 -3
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from app.models.user import UserRole from app.models.user import UserRole
@@ -49,7 +47,7 @@ class RegisterResponse(BaseModel):
Returns the user info (without password) plus an access token. Returns the user info (without password) plus an access token.
""" """
user: "UserOut" user: UserOut
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int expires_in: int
+2 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Generic, List, Optional, TypeVar from typing import Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class PaginatedResponse(BaseModel, Generic[T]):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
items: List[T] # type: ignore[valid-type] items: list[T] # type: ignore[valid-type]
total: int total: int
skip: int skip: int
limit: int limit: int
+9 -10
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
@@ -11,9 +10,9 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class ContactBase(BaseModel): class ContactBase(BaseModel):
first_name: str = Field(..., min_length=1, max_length=128) first_name: str = Field(..., min_length=1, max_length=128)
last_name: str = Field(..., min_length=1, max_length=128) last_name: str = Field(..., min_length=1, max_length=128)
email: Optional[EmailStr] = None email: EmailStr | None = None
phone: Optional[str] = Field(None, max_length=64) phone: str | None = Field(None, max_length=64)
account_id: Optional[int] = None account_id: int | None = None
class ContactCreate(ContactBase): class ContactCreate(ContactBase):
@@ -21,11 +20,11 @@ class ContactCreate(ContactBase):
class ContactUpdate(BaseModel): class ContactUpdate(BaseModel):
first_name: Optional[str] = Field(None, min_length=1, max_length=128) first_name: str | None = Field(None, min_length=1, max_length=128)
last_name: Optional[str] = Field(None, min_length=1, max_length=128) last_name: str | None = Field(None, min_length=1, max_length=128)
email: Optional[EmailStr] = None email: EmailStr | None = None
phone: Optional[str] = Field(None, max_length=64) phone: str | None = Field(None, max_length=64)
account_id: Optional[int] = None account_id: int | None = None
class ContactOut(ContactBase): class ContactOut(ContactBase):
@@ -36,7 +35,7 @@ class ContactOut(ContactBase):
owner_id: int owner_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
class ContactListItem(ContactOut): class ContactListItem(ContactOut):
+6 -7
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -27,13 +26,13 @@ class ActivityFeedItem(BaseModel):
id: int id: int
type: ActivityType type: ActivityType
subject: str subject: str
body: Optional[str] = None body: str | None = None
account_id: Optional[int] = None account_id: int | None = None
contact_id: Optional[int] = None contact_id: int | None = None
deal_id: Optional[int] = None deal_id: int | None = None
owner_id: int owner_id: int
completed_at: Optional[datetime] = None completed_at: datetime | None = None
due_date: Optional[datetime] = None due_date: datetime | None = None
created_at: datetime created_at: datetime
+9 -10
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -16,9 +15,9 @@ class DealBase(BaseModel):
value: Decimal = Field(default=Decimal("0"), max_digits=12, decimal_places=2) value: Decimal = Field(default=Decimal("0"), max_digits=12, decimal_places=2)
currency: str = Field(default="EUR", min_length=3, max_length=3) currency: str = Field(default="EUR", min_length=3, max_length=3)
stage: DealStage = DealStage.lead stage: DealStage = DealStage.lead
close_date: Optional[date] = None close_date: date | None = None
account_id: int account_id: int
won_lost_reason: Optional[str] = Field(None, max_length=512) won_lost_reason: str | None = Field(None, max_length=512)
class DealCreate(DealBase): class DealCreate(DealBase):
@@ -26,11 +25,11 @@ class DealCreate(DealBase):
class DealUpdate(BaseModel): class DealUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255) title: str | None = Field(None, min_length=1, max_length=255)
value: Optional[Decimal] = Field(None, max_digits=12, decimal_places=2) value: Decimal | None = Field(None, max_digits=12, decimal_places=2)
currency: Optional[str] = Field(None, min_length=3, max_length=3) currency: str | None = Field(None, min_length=3, max_length=3)
close_date: Optional[date] = None close_date: date | None = None
won_lost_reason: Optional[str] = Field(None, max_length=512) won_lost_reason: str | None = Field(None, max_length=512)
class DealOut(DealBase): class DealOut(DealBase):
@@ -41,7 +40,7 @@ class DealOut(DealBase):
owner_id: int owner_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
class DealListItem(DealOut): class DealListItem(DealOut):
@@ -52,7 +51,7 @@ class DealStageUpdate(BaseModel):
"""Body for PATCH /api/v1/deals/{id}/stage.""" """Body for PATCH /api/v1/deals/{id}/stage."""
stage: DealStage stage: DealStage
reason: Optional[str] = Field(None, max_length=512) reason: str | None = Field(None, max_length=512)
class DealPipelineOut(BaseModel): class DealPipelineOut(BaseModel):
+2 -3
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +20,7 @@ class NoteCreate(BaseModel):
class NoteUpdate(BaseModel): class NoteUpdate(BaseModel):
"""Body for PATCH /api/v1/notes/{id}.""" """Body for PATCH /api/v1/notes/{id}."""
body: Optional[str] = Field(None, min_length=1) body: str | None = Field(None, min_length=1)
class NoteOut(BaseModel): class NoteOut(BaseModel):
@@ -37,7 +36,7 @@ class NoteOut(BaseModel):
parent_id: int parent_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
__all__ = ["NoteCreate", "NoteOut", "NoteUpdate"] __all__ = ["NoteCreate", "NoteOut", "NoteUpdate"]
+2 -3
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -28,7 +27,7 @@ class TagOut(BaseModel):
color: str color: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
class TagLinkCreate(BaseModel): class TagLinkCreate(BaseModel):
@@ -51,7 +50,7 @@ class TagLinkOut(BaseModel):
parent_id: int parent_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None deleted_at: datetime | None = None
__all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"] __all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"]
+5 -6
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
@@ -20,7 +19,7 @@ class UserOut(BaseModel):
name: str name: str
role: UserRole role: UserRole
org_id: int org_id: int
avatar_url: Optional[str] = None avatar_url: str | None = None
email_notifications: bool = True email_notifications: bool = True
created_at: datetime created_at: datetime
@@ -28,10 +27,10 @@ class UserOut(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
"""Request body for PATCH /api/v1/users/{id} (admin or self).""" """Request body for PATCH /api/v1/users/{id} (admin or self)."""
name: Optional[str] = Field(None, min_length=1, max_length=255) name: str | None = Field(None, min_length=1, max_length=255)
avatar_url: Optional[str] = Field(None, max_length=1024) avatar_url: str | None = Field(None, max_length=1024)
email_notifications: Optional[bool] = None email_notifications: bool | None = None
role: Optional[UserRole] = None # admin-only role: UserRole | None = None # admin-only
class UserCreateRequest(BaseModel): class UserCreateRequest(BaseModel):
+2 -2
View File
@@ -11,7 +11,7 @@ router layer passes `current_user.org_id`.
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -44,7 +44,7 @@ class OrgScopedQuery:
self, self,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
order_by: Optional[Any] = None, order_by: Any | None = None,
**filters: Any, **filters: Any,
) -> list[Any]: ) -> list[Any]:
"""List records scoped to org, with optional filters and pagination.""" """List records scoped to org, with optional filters and pagination."""
+5 -7
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,7 +18,6 @@ async def create_account(
db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int
) -> Account: ) -> Account:
"""Create a new account in the given org.""" """Create a new account in the given org."""
from app.services._base import OrgScopedQuery
account = Account( account = Account(
org_id=org_id, org_id=org_id,
@@ -38,7 +36,7 @@ async def create_account(
async def get_account( async def get_account(
db: AsyncSession, account_id: int, *, org_id: int db: AsyncSession, account_id: int, *, org_id: int
) -> Optional[Account]: ) -> Account | None:
"""Fetch a single account by id (org-scoped, not soft-deleted).""" """Fetch a single account by id (org-scoped, not soft-deleted)."""
from app.services._base import OrgScopedQuery from app.services._base import OrgScopedQuery
@@ -52,10 +50,10 @@ async def list_accounts(
org_id: int, org_id: int,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
industry: Optional[str] = None, industry: str | None = None,
size: Optional[str] = None, size: str | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
q: Optional[str] = None, q: str | None = None,
) -> list[Account]: ) -> list[Account]:
"""List accounts with filters and search.""" """List accounts with filters and search."""
from app.services._base import OrgScopedQuery from app.services._base import OrgScopedQuery
+8 -9
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,9 +23,9 @@ async def _validate_parents(
db: AsyncSession, db: AsyncSession,
*, *,
org_id: int, org_id: int,
account_id: Optional[int], account_id: int | None,
contact_id: Optional[int], contact_id: int | None,
deal_id: Optional[int], deal_id: int | None,
) -> None: ) -> None:
"""Ensure at least one parent is set and each provided id exists in org.""" """Ensure at least one parent is set and each provided id exists in org."""
if account_id is None and contact_id is None and deal_id is None: if account_id is None and contact_id is None and deal_id is None:
@@ -81,7 +80,7 @@ async def create_activity(
async def get_activity( async def get_activity(
db: AsyncSession, activity_id: int, *, org_id: int db: AsyncSession, activity_id: int, *, org_id: int
) -> Optional[Activity]: ) -> Activity | None:
"""Fetch a single activity by id.""" """Fetch a single activity by id."""
q = OrgScopedQuery(Activity, db, org_id=org_id) q = OrgScopedQuery(Activity, db, org_id=org_id)
return await q.get(activity_id) return await q.get(activity_id)
@@ -93,10 +92,10 @@ async def list_activities(
org_id: int, org_id: int,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
type: Optional[ActivityType] = None, type: ActivityType | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
overdue: Optional[bool] = None, overdue: bool | None = None,
completed: Optional[bool] = None, completed: bool | None = None,
) -> list[Activity]: ) -> list[Activity]:
"""List activities with optional filters.""" """List activities with optional filters."""
scoped = OrgScopedQuery(Activity, db, org_id=org_id) scoped = OrgScopedQuery(Activity, db, org_id=org_id)
+1 -3
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -94,7 +92,7 @@ async def register_user(
async def authenticate_user( async def authenticate_user(
db: AsyncSession, email: str, password: str db: AsyncSession, email: str, password: str
) -> Optional[tuple[User, str]]: ) -> tuple[User, str] | None:
"""Verify credentials and return (user, token) on success, None on failure. """Verify credentials and return (user, token) on success, None on failure.
The endpoint wraps this and returns 401 on None — keeping the The endpoint wraps this and returns 401 on None — keeping the
+4 -5
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -64,7 +63,7 @@ async def create_contact(
async def get_contact( async def get_contact(
db: AsyncSession, contact_id: int, *, org_id: int db: AsyncSession, contact_id: int, *, org_id: int
) -> Optional[Contact]: ) -> Contact | None:
"""Fetch a single contact by id.""" """Fetch a single contact by id."""
q = OrgScopedQuery(Contact, db, org_id=org_id) q = OrgScopedQuery(Contact, db, org_id=org_id)
return await q.get(contact_id) return await q.get(contact_id)
@@ -76,9 +75,9 @@ async def list_contacts(
org_id: int, org_id: int,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
account_id: Optional[int] = None, account_id: int | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
q: Optional[str] = None, q: str | None = None,
) -> list[Contact]: ) -> list[Contact]:
"""List contacts with filters and search.""" """List contacts with filters and search."""
scoped = OrgScopedQuery(Contact, db, org_id=org_id) scoped = OrgScopedQuery(Contact, db, org_id=org_id)
+5 -6
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -77,7 +76,7 @@ async def create_deal(
async def get_deal( async def get_deal(
db: AsyncSession, deal_id: int, *, org_id: int db: AsyncSession, deal_id: int, *, org_id: int
) -> Optional[Deal]: ) -> Deal | None:
"""Fetch a single deal by id.""" """Fetch a single deal by id."""
q = OrgScopedQuery(Deal, db, org_id=org_id) q = OrgScopedQuery(Deal, db, org_id=org_id)
return await q.get(deal_id) return await q.get(deal_id)
@@ -89,9 +88,9 @@ async def list_deals(
org_id: int, org_id: int,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
stage: Optional[str] = None, stage: str | None = None,
owner_id: Optional[int] = None, owner_id: int | None = None,
account_id: Optional[int] = None, account_id: int | None = None,
) -> list[Deal]: ) -> list[Deal]:
"""List deals with optional filters.""" """List deals with optional filters."""
scoped = OrgScopedQuery(Deal, db, org_id=org_id) scoped = OrgScopedQuery(Deal, db, org_id=org_id)
@@ -126,7 +125,7 @@ async def update_stage(
new_stage: DealStage, new_stage: DealStage,
*, *,
changed_by: int, changed_by: int,
reason: Optional[str] = None, reason: str | None = None,
) -> Deal: ) -> Deal:
"""Change a deal's stage and append a DealStageHistory record.""" """Change a deal's stage and append a DealStageHistory record."""
from_stage_str = _stage_value(deal.stage) from_stage_str = _stage_value(deal.stage)
+3 -4
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -86,7 +85,7 @@ async def create_note(
async def get_note( async def get_note(
db: AsyncSession, note_id: int, *, org_id: int db: AsyncSession, note_id: int, *, org_id: int
) -> Optional[Note]: ) -> Note | None:
"""Fetch a single note by id.""" """Fetch a single note by id."""
q = OrgScopedQuery(Note, db, org_id=org_id) q = OrgScopedQuery(Note, db, org_id=org_id)
return await q.get(note_id) return await q.get(note_id)
@@ -98,8 +97,8 @@ async def list_notes(
org_id: int, org_id: int,
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
parent_type: Optional[str] = None, parent_type: str | None = None,
parent_id: Optional[int] = None, parent_id: int | None = None,
) -> list[Note]: ) -> list[Note]:
"""List notes with optional parent filters.""" """List notes with optional parent filters."""
scoped = OrgScopedQuery(Note, db, org_id=org_id) scoped = OrgScopedQuery(Note, db, org_id=org_id)
+1 -2
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -75,7 +74,7 @@ async def list_tags(db: AsyncSession, *, org_id: int) -> list[Tag]:
return await scoped.list(skip=0, limit=1000, order_by=Tag.id) return await scoped.list(skip=0, limit=1000, order_by=Tag.id)
async def get_tag(db: AsyncSession, tag_id: int, *, org_id: int) -> Optional[Tag]: async def get_tag(db: AsyncSession, tag_id: int, *, org_id: int) -> Tag | None:
"""Fetch a single tag by id.""" """Fetch a single tag by id."""
return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id) return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id)
+4 -5
View File
@@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +20,7 @@ class EmailAlreadyTaken(Exception):
"""Raised when attempting to create/update a user with an existing email.""" """Raised when attempting to create/update a user with an existing email."""
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
"""Fetch a user by ID (active only, soft-deleted excluded).""" """Fetch a user by ID (active only, soft-deleted excluded)."""
result = await db.execute( result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)) select(User).where(User.id == user_id, User.deleted_at.is_(None))
@@ -30,8 +29,8 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
async def get_user_by_email( async def get_user_by_email(
db: AsyncSession, email: str, org_id: Optional[int] = None db: AsyncSession, email: str, org_id: int | None = None
) -> Optional[User]: ) -> User | None:
"""Fetch a user by email, optionally scoped to an org.""" """Fetch a user by email, optionally scoped to an org."""
stmt = select(User).where( stmt = select(User).where(
User.email == email.lower(), User.email == email.lower(),
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import datetime
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
+3 -3
View File
@@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
from datetime import timedelta
import pytest
from httpx import AsyncClient from httpx import AsyncClient
from jose import jwt from jose import jwt
@@ -203,6 +201,7 @@ async def test_db_user_has_hashed_password(
) -> None: ) -> None:
"""AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext).""" """AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext)."""
from sqlalchemy import select from sqlalchemy import select
from app.models.user import User from app.models.user import User
async with session_factory() as session: async with session_factory() as session:
@@ -228,7 +227,8 @@ async def test_no_default_admin_on_startup(
client: AsyncClient, session_factory client: AsyncClient, session_factory
) -> None: ) -> None:
"""AC: KEIN admin/admin Bootstrap-User beim App-Start (Frisch-DB = leer).""" """AC: KEIN admin/admin Bootstrap-User beim App-Start (Frisch-DB = leer)."""
from sqlalchemy import select, func from sqlalchemy import func, select
from app.models.user import User from app.models.user import User
# Fresh DB → no users # Fresh DB → no users
-1
View File
@@ -14,7 +14,6 @@ from typing import Any
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
# ========================================================= # =========================================================
# Helper # Helper
# ========================================================= # =========================================================
-1
View File
@@ -12,7 +12,6 @@ from typing import Any
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# List of (method, path, payload, auth_required, expected_without_auth, expected_with_auth) # List of (method, path, payload, auth_required, expected_without_auth, expected_with_auth)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -2
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from httpx import AsyncClient from httpx import AsyncClient
# === /users/me === # === /users/me ===
@@ -36,9 +35,10 @@ async def test_get_me_invalid_token_format(client: AsyncClient) -> None:
async def test_get_me_bogus_token(client: AsyncClient) -> None: async def test_get_me_bogus_token(client: AsyncClient) -> None:
"""GET /api/v1/users/me with a token signed with the wrong key returns 401.""" """GET /api/v1/users/me with a token signed with the wrong key returns 401."""
from jose import jwt
import time import time
from jose import jwt
bogus = jwt.encode( bogus = jwt.encode(
{ {
"sub": "1", "sub": "1",