diff --git a/app/api/v1/accounts.py b/app/api/v1/accounts.py index 905dab8..72857e2 100644 --- a/app/api/v1/accounts.py +++ b/app/api/v1/accounts.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, Response, status 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.services.account_service import ( create_account as svc_create_account, +) +from app.services.account_service import ( get_account as svc_get_account, +) +from app.services.account_service import ( list_accounts as svc_list_accounts, +) +from app.services.account_service import ( soft_delete_account as svc_soft_delete_account, +) +from app.services.account_service import ( update_account as svc_update_account, ) @@ -48,10 +54,10 @@ async def create_account( async def list_accounts( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - industry: Optional[Industry] = None, - size: Optional[AccountSize] = None, - owner_id: Optional[int] = None, - q: Optional[str] = None, + industry: Industry | None = None, + size: AccountSize | None = None, + owner_id: int | None = None, + q: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[AccountOut]: diff --git a/app/api/v1/activities.py b/app/api/v1/activities.py index b456618..264a4fa 100644 --- a/app/api/v1/activities.py +++ b/app/api/v1/activities.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.ext.asyncio import AsyncSession @@ -19,11 +17,23 @@ from app.schemas.activity import ( ) from app.services.activity_service import ( NoParentException, +) +from app.services.activity_service import ( complete_activity as svc_complete_activity, +) +from app.services.activity_service import ( create_activity as svc_create_activity, +) +from app.services.activity_service import ( get_activity as svc_get_activity, +) +from app.services.activity_service import ( list_activities as svc_list_activities, +) +from app.services.activity_service import ( soft_delete_activity as svc_soft_delete_activity, +) +from app.services.activity_service import ( update_activity as svc_update_activity, ) @@ -58,10 +68,10 @@ async def create_activity( async def list_activities( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - type: Optional[ActivityType] = None, - owner_id: Optional[int] = None, - overdue: Optional[bool] = None, - completed: Optional[bool] = None, + type: ActivityType | None = None, + owner_id: int | None = None, + overdue: bool | None = None, + completed: bool | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[ActivityOut]: @@ -88,9 +98,7 @@ async def get_activity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ActivityOut: - activity = await svc_get_activity( - db, activity_id, org_id=current_user.org_id - ) + activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id) if activity is None: raise HTTPException(status_code=404, detail="Activity not found") return ActivityOut.model_validate(activity) @@ -107,9 +115,7 @@ async def update_activity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ActivityOut: - activity = await svc_get_activity( - db, activity_id, org_id=current_user.org_id - ) + activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id) if activity is None: raise HTTPException(status_code=404, detail="Activity not found") try: @@ -130,9 +136,7 @@ async def complete_activity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ActivityOut: - activity = await svc_get_activity( - db, activity_id, org_id=current_user.org_id - ) + activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id) if activity is None: raise HTTPException(status_code=404, detail="Activity not found") updated = await svc_complete_activity(db, activity, payload) @@ -150,9 +154,7 @@ async def delete_activity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: - activity = await svc_get_activity( - db, activity_id, org_id=current_user.org_id - ) + activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id) if activity is None: raise HTTPException(status_code=404, detail="Activity not found") await svc_soft_delete_activity(db, activity) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 8989901..a63c7bf 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -7,8 +7,8 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession 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.deps import get_current_user from app.models.user import User from app.schemas.auth import ( LogoutResponse, @@ -127,9 +127,7 @@ async def refresh( from app.core.security import create_access_token role_str = ( - current_user.role.value - if hasattr(current_user.role, "value") - else str(current_user.role) + current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) ) token = create_access_token(current_user.id, current_user.org_id, role_str) return TokenResponse( diff --git a/app/api/v1/contacts.py b/app/api/v1/contacts.py index 907e12c..30a75fe 100644 --- a/app/api/v1/contacts.py +++ b/app/api/v1/contacts.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, Response, status 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.services.contact_service import ( InvalidAccount, +) +from app.services.contact_service import ( create_contact as svc_create_contact, +) +from app.services.contact_service import ( get_contact as svc_get_contact, +) +from app.services.contact_service import ( list_contacts as svc_list_contacts, +) +from app.services.contact_service import ( soft_delete_contact as svc_soft_delete_contact, +) +from app.services.contact_service import ( update_contact as svc_update_contact, ) @@ -51,9 +59,9 @@ async def create_contact( async def list_contacts( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - account_id: Optional[int] = None, - owner_id: Optional[int] = None, - q: Optional[str] = None, + account_id: int | None = None, + owner_id: int | None = None, + q: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[ContactOut]: diff --git a/app/api/v1/dashboard.py b/app/api/v1/dashboard.py index 4796f36..2b8c408 100644 --- a/app/api/v1/dashboard.py +++ b/app/api/v1/dashboard.py @@ -40,9 +40,7 @@ async def get_activity_feed_endpoint( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[ActivityFeedItem]: - activities = await get_activity_feed( - db, org_id=current_user.org_id, limit=limit - ) + activities = await get_activity_feed(db, org_id=current_user.org_id, limit=limit) return [ActivityFeedItem.model_validate(a) for a in activities] diff --git a/app/api/v1/deals.py b/app/api/v1/deals.py index 6a2d63a..a17f837 100644 --- a/app/api/v1/deals.py +++ b/app/api/v1/deals.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.ext.asyncio import AsyncSession @@ -20,12 +18,24 @@ from app.schemas.deal import ( ) from app.services.deal_service import ( InvalidAccount, - create_deal as svc_create_deal, - get_deal as svc_get_deal, 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, +) +from app.services.deal_service import ( soft_delete_deal as svc_soft_delete_deal, +) +from app.services.deal_service import ( update_deal as svc_update_deal, +) +from app.services.deal_service import ( update_stage as svc_update_stage, ) @@ -60,9 +70,9 @@ async def create_deal( async def list_deals( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - stage: Optional[DealStage] = None, - owner_id: Optional[int] = None, - account_id: Optional[int] = None, + stage: DealStage | None = None, + owner_id: int | None = None, + account_id: int | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[DealOut]: diff --git a/app/api/v1/notes.py b/app/api/v1/notes.py index 3a75040..dd663be 100644 --- a/app/api/v1/notes.py +++ b/app/api/v1/notes.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, Response, status 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.services.note_service import ( InvalidParent, +) +from app.services.note_service import ( create_note as svc_create_note, +) +from app.services.note_service import ( get_note as svc_get_note, +) +from app.services.note_service import ( list_notes as svc_list_notes, +) +from app.services.note_service import ( soft_delete_note as svc_soft_delete_note, +) +from app.services.note_service import ( update_note as svc_update_note, ) @@ -52,8 +60,8 @@ async def create_note( async def list_notes( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - parent_type: Optional[NoteParentType] = None, - parent_id: Optional[int] = None, + parent_type: NoteParentType | None = None, + parent_id: int | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[NoteOut]: diff --git a/app/api/v1/tags.py b/app/api/v1/tags.py index 0ef5753..82e68fc 100644 --- a/app/api/v1/tags.py +++ b/app/api/v1/tags.py @@ -13,9 +13,17 @@ from app.services.tag_service import ( DuplicateTagLink, InvalidParent, TagNotFound, +) +from app.services.tag_service import ( create_tag as svc_create_tag, +) +from app.services.tag_service import ( link_tag as svc_link_tag, +) +from app.services.tag_service import ( list_tags as svc_list_tags, +) +from app.services.tag_service import ( unlink_tag as svc_unlink_tag, ) diff --git a/app/api/v1/users.py b/app/api/v1/users.py index 1fc3f06..b5c69dc 100644 --- a/app/api/v1/users.py +++ b/app/api/v1/users.py @@ -42,9 +42,7 @@ async def update_me( db: AsyncSession = Depends(get_db), ) -> UserOut: """A user can update their own profile, but not their role.""" - updated = await user_service.update_user_profile( - db, current_user, payload, is_admin=False - ) + updated = await user_service.update_user_profile(db, current_user, payload, is_admin=False) return UserOut.model_validate(updated) @@ -86,9 +84,7 @@ async def create_user( ) -> UserOut: """Admin creates a new user in the same org.""" try: - new_user = await user_service.create_user_as_admin( - db, payload, org_id=current_user.org_id - ) + new_user = await user_service.create_user_as_admin(db, payload, org_id=current_user.org_id) except user_service.EmailAlreadyTaken as e: raise HTTPException(status_code=409, detail=str(e)) from e return UserOut.model_validate(new_user) @@ -124,9 +120,7 @@ async def update_user( if target.org_id != current_user.org_id: raise HTTPException(status_code=404, detail="User not found") - updated = await user_service.update_user_profile( - db, target, payload, is_admin=is_admin - ) + updated = await user_service.update_user_profile(db, target, payload, is_admin=is_admin) return UserOut.model_validate(updated) @@ -143,9 +137,7 @@ async def delete_user( ) -> Response: """Sets deleted_at timestamp. The record stays in the DB for audit purposes.""" if current_user.id == user_id: - raise HTTPException( - status_code=400, detail="You cannot delete your own account" - ) + raise HTTPException(status_code=400, detail="You cannot delete your own account") target = await user_service.get_user_by_id(db, user_id) if target is None or target.org_id != current_user.org_id: diff --git a/app/core/deps.py b/app/core/deps.py index 51d504b..1b457b1 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -42,9 +42,7 @@ async def get_current_user( raise credentials_exc from None # Load user fresh from DB to honor soft-delete and role changes - result = await db.execute( - select(User).where(User.id == user_id, User.deleted_at.is_(None)) - ) + result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None))) user = result.scalar_one_or_none() if user is None: @@ -57,9 +55,7 @@ async def get_current_admin_user( user: User = Depends(get_current_user), ) -> User: """Require the current user to have the admin role.""" - user_role = ( - user.role.value if hasattr(user.role, "value") else str(user.role) - ) + user_role = user.role.value if hasattr(user.role, "value") else str(user.role) if user_role != UserRole.admin.value: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/app/core/security.py b/app/core/security.py index 6177dfd..e624013 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -91,9 +91,7 @@ def is_token_expired(token: str) -> bool: try: jwt.get_unverified_claims(token) # If decode succeeds, it's not expired. - jwt.decode( - token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM] - ) + jwt.decode(token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM]) return False except JWTError as e: return "expired" in str(e).lower() or "exp" in str(e).lower() diff --git a/app/main.py b/app/main.py index 8931c24..bbf6c93 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,6 @@ from __future__ import annotations import logging from contextlib import asynccontextmanager - from pathlib import Path from fastapi import FastAPI, Request, status @@ -129,17 +128,13 @@ def create_app() -> FastAPI: response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" if settings_local.is_production: - response.headers["Strict-Transport-Security"] = ( - "max-age=31536000; includeSubDomains" - ) + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response # === Exception Handlers === @app.exception_handler(StarletteHTTPException) - async def http_exception_handler( - request: Request, exc: StarletteHTTPException - ) -> JSONResponse: + async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: """Format HTTPException responses consistently.""" # Distinguish token-expired for FR-1.7 acceptance criterion if exc.status_code == 401: @@ -163,15 +158,14 @@ def create_app() -> FastAPI: ) -> JSONResponse: """Format Pydantic validation errors consistently.""" from fastapi.encoders import jsonable_encoder + return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": jsonable_encoder(exc.errors())}, ) @app.exception_handler(SQLAlchemyError) - async def sqlalchemy_exception_handler( - request: Request, exc: SQLAlchemyError - ) -> JSONResponse: + async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse: """Log DB errors and return a 500 without leaking internals.""" logger.exception("Database error on %s %s", request.method, request.url) return JSONResponse( diff --git a/app/models/account.py b/app/models/account.py index 0e3315c..20c8d41 100644 --- a/app/models/account.py +++ b/app/models/account.py @@ -3,7 +3,7 @@ from __future__ import annotations 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.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 if TYPE_CHECKING: - from app.models.user import User + from app.models.activity import Activity from app.models.contact import Contact from app.models.deal import Deal - 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 Industry(str, Enum): @@ -43,36 +43,28 @@ class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) - website: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - industry: Mapped[Optional[Industry]] = mapped_column( - String(32), nullable=True, index=True - ) - size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True) - address: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) - owner_id: Mapped[int] = mapped_column( - ForeignKey("users.id"), 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( + 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( + 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( + 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( + tags: Mapped[list[TagLink]] = relationship( "TagLink", primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')", viewonly=True, diff --git a/app/models/activity.py b/app/models/activity.py index b013841..feae350 100644 --- a/app/models/activity.py +++ b/app/models/activity.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import DateTime, ForeignKey, String, Text 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 if TYPE_CHECKING: - from app.models.user import User 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): @@ -32,43 +32,31 @@ 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 - ) + type: Mapped[ActivityType] = mapped_column(String(32), nullable=False, index=True) subject: Mapped[str] = mapped_column(String(255), nullable=False) - body: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - due_date: Mapped[Optional[datetime]] = mapped_column( + 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[Optional[datetime]] = mapped_column( - DateTime(timezone=True), nullable=True - ) - account_id: Mapped[Optional[int]] = mapped_column( + 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[Optional[int]] = mapped_column( + contact_id: Mapped[int | None] = mapped_column( ForeignKey("contacts.id"), nullable=True, index=True ) - deal_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("deals.id"), nullable=True, index=True - ) - owner_id: Mapped[int] = mapped_column( - ForeignKey("users.id"), nullable=False, 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[Optional["Account"]] = relationship( + account: Mapped[Account | None] = relationship( "Account", back_populates="activities", lazy="selectin" ) - contact: Mapped[Optional["Contact"]] = relationship( + contact: Mapped[Contact | None] = relationship( "Contact", back_populates="activities", lazy="selectin" ) - deal: Mapped[Optional["Deal"]] = relationship( - "Deal", back_populates="activities", lazy="selectin" - ) - owner: Mapped["User"] = relationship( - "User", foreign_keys=[owner_id], lazy="joined" - ) + 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"" diff --git a/app/models/base.py b/app/models/base.py index dd6656b..e4518db 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from sqlalchemy import DateTime, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column @@ -31,7 +30,7 @@ class TimestampMixin: class SoftDeleteMixin: """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), nullable=True, default=None, diff --git a/app/models/contact.py b/app/models/contact.py index f3d081f..9cc6953 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, String 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 if TYPE_CHECKING: - from app.models.user import User 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): @@ -23,32 +23,28 @@ class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): 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[Optional[str]] = mapped_column(String(255), nullable=True, index=True) - phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) - account_id: Mapped[Optional[int]] = mapped_column( + 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 - ) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) # Relationships - account: Mapped[Optional["Account"]] = relationship( + 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( + 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( + 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( + tags: Mapped[list[TagLink]] = relationship( "TagLink", primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')", viewonly=True, diff --git a/app/models/deal.py b/app/models/deal.py index dd5ae4d..eea93ca 100644 --- a/app/models/deal.py +++ b/app/models/deal.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date from decimal import Decimal from enum import Enum -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import Date, ForeignKey, Numeric, String 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 if TYPE_CHECKING: - from app.models.user import User 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.deal_stage_history import DealStageHistory + from app.models.user import User class DealStage(str, Enum): @@ -48,39 +48,31 @@ class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): server_default=DealStage.lead.value, index=True, ) - close_date: Mapped[Optional[date]] = 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[Optional[str]] = mapped_column(String(512), nullable=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( + 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( + activities: Mapped[list[Activity]] = relationship( "Activity", back_populates="deal", lazy="selectin" ) - notes: Mapped[list["Note"]] = relationship( + 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( + tags: Mapped[list[TagLink]] = relationship( "TagLink", primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')", viewonly=True, diff --git a/app/models/deal_stage_history.py b/app/models/deal_stage_history.py index 86de67c..d0ed6c3 100644 --- a/app/models/deal_stage_history.py +++ b/app/models/deal_stage_history.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, String 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 if TYPE_CHECKING: - from app.models.user import User from app.models.deal import Deal + from app.models.user import User class DealStageHistory(Base, TimestampMixin, OrgScopedMixin): @@ -21,21 +21,13 @@ class DealStageHistory(Base, TimestampMixin, OrgScopedMixin): __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[Optional[DealStage]] = mapped_column(String(32), nullable=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 - ) + 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" - ) + 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"{self.to_stage}>" diff --git a/app/models/note.py b/app/models/note.py index 8bab9cd..4c2a612 100644 --- a/app/models/note.py +++ b/app/models/note.py @@ -27,19 +27,13 @@ class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): 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 - ) + 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" - ) + author: Mapped[User] = relationship("User", foreign_keys=[author_id], lazy="joined") def __repr__(self) -> str: return f"" diff --git a/app/models/org.py b/app/models/org.py index ac97e67..08d2d70 100644 --- a/app/models/org.py +++ b/app/models/org.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import String 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) 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( String(3), nullable=False, default="EUR", server_default="EUR" ) # Relationship to users (defined here to resolve circular import) - users: Mapped[list["User"]] = relationship( + users: Mapped[list[User]] = relationship( back_populates="org", lazy="selectin", cascade="all, delete-orphan", diff --git a/app/models/tag.py b/app/models/tag.py index 168f550..f2bfe7a 100644 --- a/app/models/tag.py +++ b/app/models/tag.py @@ -23,7 +23,7 @@ class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): ) # Relationships - links: Mapped[list["TagLink"]] = relationship( + links: Mapped[list[TagLink]] = relationship( "TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin" ) diff --git a/app/models/tag_link.py b/app/models/tag_link.py index 1332f36..60cf813 100644 --- a/app/models/tag_link.py +++ b/app/models/tag_link.py @@ -24,27 +24,21 @@ class TagLinkParentType(str, Enum): class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): __tablename__ = "tag_links" - __table_args__ = ( - UniqueConstraint( - "tag_id", "parent_type", "parent_id", name="uq_tag_link" - ), - ) + __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 - ) + 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") + tag: Mapped[Tag] = relationship("Tag", back_populates="links", lazy="joined") def __repr__(self) -> str: - return f"" + return ( + f"" + ) __all__ = ["TagLink", "TagLinkParentType"] diff --git a/app/models/user.py b/app/models/user.py index 3fcdc35..d9bcb10 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlalchemy import Boolean, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -24,9 +24,7 @@ class UserRole(str, Enum): class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): __tablename__ = "users" - __table_args__ = ( - UniqueConstraint("org_id", "email", name="uq_users_org_email"), - ) + __table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) @@ -38,13 +36,13 @@ class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): default=UserRole.sales_rep, 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( Boolean, nullable=False, default=True, server_default="1" ) # 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: return f"" diff --git a/app/schemas/account.py b/app/schemas/account.py index f4f0d21..5e43621 100644 --- a/app/schemas/account.py +++ b/app/schemas/account.py @@ -3,8 +3,7 @@ from __future__ import annotations from datetime import datetime -from decimal import Decimal -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -15,10 +14,10 @@ class AccountBase(BaseModel): """Shared fields for create/update.""" name: str = Field(..., min_length=1, max_length=255) - website: Optional[str] = Field(None, max_length=512) - industry: Optional[Industry] = None - size: Optional[AccountSize] = None - address: Optional[dict[str, Any]] = None + website: str | None = Field(None, max_length=512) + industry: Industry | None = None + size: AccountSize | None = None + address: dict[str, Any] | None = None class AccountCreate(AccountBase): @@ -30,11 +29,11 @@ class AccountCreate(AccountBase): class AccountUpdate(BaseModel): """Request body for PATCH /api/v1/accounts/{id}. All optional.""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - website: Optional[str] = Field(None, max_length=512) - industry: Optional[Industry] = None - size: Optional[AccountSize] = None - address: Optional[dict[str, Any]] = None + name: str | None = Field(None, min_length=1, max_length=255) + website: str | None = Field(None, max_length=512) + industry: Industry | None = None + size: AccountSize | None = None + address: dict[str, Any] | None = None class AccountOut(AccountBase): @@ -47,7 +46,7 @@ class AccountOut(AccountBase): owner_id: int created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None class AccountListItem(AccountOut): diff --git a/app/schemas/activity.py b/app/schemas/activity.py index 4976d0c..52f7c71 100644 --- a/app/schemas/activity.py +++ b/app/schemas/activity.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -13,18 +12,16 @@ from app.models.activity import ActivityType class ActivityBase(BaseModel): type: ActivityType subject: str = Field(..., min_length=1, max_length=255) - body: Optional[str] = None - due_date: Optional[datetime] = None - account_id: Optional[int] = None - contact_id: Optional[int] = None - deal_id: Optional[int] = None + body: str | None = None + due_date: datetime | None = None + account_id: int | None = None + contact_id: int | None = None + deal_id: int | None = None @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: - raise ValueError( - "At least one of account_id, contact_id, deal_id must be set" - ) + raise ValueError("At least one of account_id, contact_id, deal_id must be set") return self @@ -33,13 +30,13 @@ class ActivityCreate(ActivityBase): class ActivityUpdate(BaseModel): - type: Optional[ActivityType] = None - subject: Optional[str] = Field(None, min_length=1, max_length=255) - body: Optional[str] = None - due_date: Optional[datetime] = None - account_id: Optional[int] = None - contact_id: Optional[int] = None - deal_id: Optional[int] = None + type: ActivityType | None = None + subject: str | None = Field(None, min_length=1, max_length=255) + body: str | None = None + due_date: datetime | None = None + account_id: int | None = None + contact_id: int | None = None + deal_id: int | None = None class ActivityOut(ActivityBase): @@ -48,16 +45,16 @@ class ActivityOut(ActivityBase): id: int org_id: int owner_id: int - completed_at: Optional[datetime] = None + completed_at: datetime | None = None created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None class ActivityCompleteRequest(BaseModel): """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__ = [ diff --git a/app/schemas/auth.py b/app/schemas/auth.py index fd414f7..f82094c 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from pydantic import BaseModel, EmailStr, Field from app.models.user import UserRole @@ -49,7 +47,7 @@ class RegisterResponse(BaseModel): Returns the user info (without password) plus an access token. """ - user: "UserOut" + user: UserOut access_token: str token_type: str = "bearer" expires_in: int diff --git a/app/schemas/common.py b/app/schemas/common.py index dad3865..f40a9e2 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Generic, List, Optional, TypeVar +from typing import Generic, TypeVar from pydantic import BaseModel, ConfigDict, Field @@ -21,7 +21,7 @@ class PaginatedResponse(BaseModel, Generic[T]): model_config = ConfigDict(from_attributes=True) - items: List[T] # type: ignore[valid-type] + items: list[T] # type: ignore[valid-type] total: int skip: int limit: int diff --git a/app/schemas/contact.py b/app/schemas/contact.py index da19df6..843d4d5 100644 --- a/app/schemas/contact.py +++ b/app/schemas/contact.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -11,9 +10,9 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field class ContactBase(BaseModel): first_name: str = Field(..., min_length=1, max_length=128) last_name: str = Field(..., min_length=1, max_length=128) - email: Optional[EmailStr] = None - phone: Optional[str] = Field(None, max_length=64) - account_id: Optional[int] = None + email: EmailStr | None = None + phone: str | None = Field(None, max_length=64) + account_id: int | None = None class ContactCreate(ContactBase): @@ -21,11 +20,11 @@ class ContactCreate(ContactBase): class ContactUpdate(BaseModel): - first_name: Optional[str] = Field(None, min_length=1, max_length=128) - last_name: Optional[str] = Field(None, min_length=1, max_length=128) - email: Optional[EmailStr] = None - phone: Optional[str] = Field(None, max_length=64) - account_id: Optional[int] = None + first_name: str | None = Field(None, min_length=1, max_length=128) + last_name: str | None = Field(None, min_length=1, max_length=128) + email: EmailStr | None = None + phone: str | None = Field(None, max_length=64) + account_id: int | None = None class ContactOut(ContactBase): @@ -36,7 +35,7 @@ class ContactOut(ContactBase): owner_id: int created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None class ContactListItem(ContactOut): diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index 0fe22f2..ffb4279 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict @@ -27,13 +26,13 @@ class ActivityFeedItem(BaseModel): id: int type: ActivityType subject: str - body: Optional[str] = None - account_id: Optional[int] = None - contact_id: Optional[int] = None - deal_id: Optional[int] = None + body: str | None = None + account_id: int | None = None + contact_id: int | None = None + deal_id: int | None = None owner_id: int - completed_at: Optional[datetime] = None - due_date: Optional[datetime] = None + completed_at: datetime | None = None + due_date: datetime | None = None created_at: datetime diff --git a/app/schemas/deal.py b/app/schemas/deal.py index 2a2ff0c..cda50e7 100644 --- a/app/schemas/deal.py +++ b/app/schemas/deal.py @@ -4,7 +4,6 @@ from __future__ import annotations from datetime import date, datetime from decimal import Decimal -from typing import Optional 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) currency: str = Field(default="EUR", min_length=3, max_length=3) stage: DealStage = DealStage.lead - close_date: Optional[date] = None + close_date: date | None = None 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): @@ -26,11 +25,11 @@ class DealCreate(DealBase): class DealUpdate(BaseModel): - title: Optional[str] = Field(None, min_length=1, max_length=255) - value: Optional[Decimal] = Field(None, max_digits=12, decimal_places=2) - currency: Optional[str] = Field(None, min_length=3, max_length=3) - close_date: Optional[date] = None - won_lost_reason: Optional[str] = Field(None, max_length=512) + title: str | None = Field(None, min_length=1, max_length=255) + value: Decimal | None = Field(None, max_digits=12, decimal_places=2) + currency: str | None = Field(None, min_length=3, max_length=3) + close_date: date | None = None + won_lost_reason: str | None = Field(None, max_length=512) class DealOut(DealBase): @@ -41,7 +40,7 @@ class DealOut(DealBase): owner_id: int created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None class DealListItem(DealOut): @@ -52,7 +51,7 @@ class DealStageUpdate(BaseModel): """Body for PATCH /api/v1/deals/{id}/stage.""" stage: DealStage - reason: Optional[str] = Field(None, max_length=512) + reason: str | None = Field(None, max_length=512) class DealPipelineOut(BaseModel): diff --git a/app/schemas/note.py b/app/schemas/note.py index c414ed2..7a813d0 100644 --- a/app/schemas/note.py +++ b/app/schemas/note.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -21,7 +20,7 @@ class NoteCreate(BaseModel): class NoteUpdate(BaseModel): """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): @@ -37,7 +36,7 @@ class NoteOut(BaseModel): parent_id: int created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None __all__ = ["NoteCreate", "NoteOut", "NoteUpdate"] diff --git a/app/schemas/tag.py b/app/schemas/tag.py index 63d4cd9..d7bfc86 100644 --- a/app/schemas/tag.py +++ b/app/schemas/tag.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -28,7 +27,7 @@ class TagOut(BaseModel): color: str created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None class TagLinkCreate(BaseModel): @@ -51,7 +50,7 @@ class TagLinkOut(BaseModel): parent_id: int created_at: datetime updated_at: datetime - deleted_at: Optional[datetime] = None + deleted_at: datetime | None = None __all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"] diff --git a/app/schemas/user.py b/app/schemas/user.py index 56bd139..6d9b9ae 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -20,7 +19,7 @@ class UserOut(BaseModel): name: str role: UserRole org_id: int - avatar_url: Optional[str] = None + avatar_url: str | None = None email_notifications: bool = True created_at: datetime @@ -28,10 +27,10 @@ class UserOut(BaseModel): class UserUpdate(BaseModel): """Request body for PATCH /api/v1/users/{id} (admin or self).""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - avatar_url: Optional[str] = Field(None, max_length=1024) - email_notifications: Optional[bool] = None - role: Optional[UserRole] = None # admin-only + name: str | None = Field(None, min_length=1, max_length=255) + avatar_url: str | None = Field(None, max_length=1024) + email_notifications: bool | None = None + role: UserRole | None = None # admin-only class UserCreateRequest(BaseModel): diff --git a/app/services/_base.py b/app/services/_base.py index 47e7416..a64d56e 100644 --- a/app/services/_base.py +++ b/app/services/_base.py @@ -11,7 +11,7 @@ router layer passes `current_user.org_id`. from __future__ import annotations -from typing import Any, Optional +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -44,7 +44,7 @@ class OrgScopedQuery: self, skip: int = 0, limit: int = 20, - order_by: Optional[Any] = None, + order_by: Any | None = None, **filters: Any, ) -> list[Any]: """List records scoped to org, with optional filters and pagination.""" @@ -65,9 +65,13 @@ class OrgScopedQuery: """Count records scoped to org, with optional filters.""" from sqlalchemy import func as sa_func - stmt = select(sa_func.count()).select_from(self.model).where( - self.model.org_id == self.org_id, - self.model.deleted_at.is_(None), + stmt = ( + select(sa_func.count()) + .select_from(self.model) + .where( + self.model.org_id == self.org_id, + self.model.deleted_at.is_(None), + ) ) for field, value in filters.items(): if value is not None: diff --git a/app/services/account_service.py b/app/services/account_service.py index 400e22a..f3a56bb 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +18,6 @@ async def create_account( db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int ) -> Account: """Create a new account in the given org.""" - from app.services._base import OrgScopedQuery account = Account( org_id=org_id, @@ -36,9 +34,7 @@ async def create_account( return account -async def get_account( - db: AsyncSession, account_id: int, *, org_id: int -) -> Optional[Account]: +async def get_account(db: AsyncSession, account_id: int, *, org_id: int) -> Account | None: """Fetch a single account by id (org-scoped, not soft-deleted).""" from app.services._base import OrgScopedQuery @@ -52,10 +48,10 @@ async def list_accounts( org_id: int, skip: int = 0, limit: int = 20, - industry: Optional[str] = None, - size: Optional[str] = None, - owner_id: Optional[int] = None, - q: Optional[str] = None, + industry: str | None = None, + size: str | None = None, + owner_id: int | None = None, + q: str | None = None, ) -> list[Account]: """List accounts with filters and search.""" from app.services._base import OrgScopedQuery @@ -76,9 +72,7 @@ async def list_accounts( return base -async def update_account( - db: AsyncSession, account: Account, payload: AccountUpdate -) -> Account: +async def update_account(db: AsyncSession, account: Account, payload: AccountUpdate) -> Account: """Apply partial updates to an account.""" data = payload.model_dump(exclude_unset=True) for field, value in data.items(): diff --git a/app/services/activity_service.py b/app/services/activity_service.py index bf13693..78cb698 100644 --- a/app/services/activity_service.py +++ b/app/services/activity_service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -24,9 +23,9 @@ async def _validate_parents( db: AsyncSession, *, org_id: int, - account_id: Optional[int], - contact_id: Optional[int], - deal_id: Optional[int], + account_id: int | None, + contact_id: int | None, + deal_id: int | None, ) -> None: """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: @@ -35,14 +34,17 @@ async def _validate_parents( ) if account_id is not None: from app.models.account import Account + if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None: raise NoParentException(f"Account {account_id} not found in this org") if contact_id is not None: from app.models.contact import Contact + if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None: raise NoParentException(f"Contact {contact_id} not found in this org") if deal_id is not None: from app.models.deal import Deal + if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None: raise NoParentException(f"Deal {deal_id} not found in this org") @@ -79,9 +81,7 @@ async def create_activity( return activity -async def get_activity( - db: AsyncSession, activity_id: int, *, org_id: int -) -> Optional[Activity]: +async def get_activity(db: AsyncSession, activity_id: int, *, org_id: int) -> Activity | None: """Fetch a single activity by id.""" q = OrgScopedQuery(Activity, db, org_id=org_id) return await q.get(activity_id) @@ -93,10 +93,10 @@ async def list_activities( org_id: int, skip: int = 0, limit: int = 20, - type: Optional[ActivityType] = None, - owner_id: Optional[int] = None, - overdue: Optional[bool] = None, - completed: Optional[bool] = None, + type: ActivityType | None = None, + owner_id: int | None = None, + overdue: bool | None = None, + completed: bool | None = None, ) -> list[Activity]: """List activities with optional filters.""" scoped = OrgScopedQuery(Activity, db, org_id=org_id) @@ -110,7 +110,8 @@ async def list_activities( now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes if overdue is True: base = [ - a for a in base + a + for a in base if a.due_date is not None and a.due_date < now and a.completed_at is None ] if completed is True: @@ -162,9 +163,7 @@ async def complete_activity( return activity -async def soft_delete_activity( - db: AsyncSession, activity: Activity -) -> Activity: +async def soft_delete_activity(db: AsyncSession, activity: Activity) -> Activity: """Soft-delete an activity.""" activity.deleted_at = datetime.now(UTC) await db.commit() diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 6e4aa0e..93ca952 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -35,15 +33,11 @@ class InvalidCredentials(AuthError): async def count_users(db: AsyncSession) -> int: """Count active (non-soft-deleted) users. Used to gate bootstrap registration.""" - result = await db.execute( - select(User).where(User.deleted_at.is_(None)) - ) + result = await db.execute(select(User).where(User.deleted_at.is_(None))) return len(result.scalars().all()) -async def register_user( - db: AsyncSession, payload: UserRegisterRequest -) -> tuple[User, str]: +async def register_user(db: AsyncSession, payload: UserRegisterRequest) -> tuple[User, str]: """Bootstrap registration. Creates a new Org and the first user (or a new user in the existing org @@ -81,9 +75,7 @@ async def register_user( await db.commit() except IntegrityError as e: await db.rollback() - raise EmailAlreadyExists( - f"A user with email {payload.email!r} already exists." - ) from e + raise EmailAlreadyExists(f"A user with email {payload.email!r} already exists.") from e await db.refresh(user) @@ -92,9 +84,7 @@ async def register_user( return user, token -async def authenticate_user( - db: AsyncSession, email: str, password: str -) -> Optional[tuple[User, str]]: +async def authenticate_user(db: AsyncSession, email: str, password: str) -> tuple[User, str] | None: """Verify credentials and return (user, token) on success, None on failure. The endpoint wraps this and returns 401 on None — keeping the diff --git a/app/services/contact_service.py b/app/services/contact_service.py index 112a26f..a6204b5 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Optional from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -22,9 +21,7 @@ class InvalidAccount(Exception): """Raised when account_id points to a non-existent account.""" -async def _validate_account( - db: AsyncSession, account_id: int, *, org_id: int -) -> None: +async def _validate_account(db: AsyncSession, account_id: int, *, org_id: int) -> None: """Raise if account_id does not exist within the org.""" if account_id is None: return @@ -62,9 +59,7 @@ async def create_contact( return contact -async def get_contact( - db: AsyncSession, contact_id: int, *, org_id: int -) -> Optional[Contact]: +async def get_contact(db: AsyncSession, contact_id: int, *, org_id: int) -> Contact | None: """Fetch a single contact by id.""" q = OrgScopedQuery(Contact, db, org_id=org_id) return await q.get(contact_id) @@ -76,9 +71,9 @@ async def list_contacts( org_id: int, skip: int = 0, limit: int = 20, - account_id: Optional[int] = None, - owner_id: Optional[int] = None, - q: Optional[str] = None, + account_id: int | None = None, + owner_id: int | None = None, + q: str | None = None, ) -> list[Contact]: """List contacts with filters and search.""" scoped = OrgScopedQuery(Contact, db, org_id=org_id) @@ -101,9 +96,7 @@ async def list_contacts( return base -async def update_contact( - db: AsyncSession, contact: Contact, payload: ContactUpdate -) -> Contact: +async def update_contact(db: AsyncSession, contact: Contact, payload: ContactUpdate) -> Contact: """Apply partial updates to a contact.""" data = payload.model_dump(exclude_unset=True) if "account_id" in data and data["account_id"] is not None: diff --git a/app/services/dashboard_service.py b/app/services/dashboard_service.py index 6ffb7b2..b4870b3 100644 --- a/app/services/dashboard_service.py +++ b/app/services/dashboard_service.py @@ -22,16 +22,13 @@ async def get_kpis(db: AsyncSession, *, org_id: int) -> dict[str, object]: open_deals = [d for d in all_deals if d.stage in open_stages] open_deals_count = len(open_deals) - pipeline_value = float( - sum((d.value for d in open_deals), Decimal("0")) - ) + pipeline_value = float(sum((d.value for d in open_deals), Decimal("0"))) now = datetime.now(UTC) # SQLite returns naive datetimes; strip tz for comparison month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0) won_this_month = [ - d for d in all_deals - if d.stage == DealStage.won and d.created_at >= month_start + d for d in all_deals if d.stage == DealStage.won and d.created_at >= month_start ] won_count = len(won_this_month) @@ -50,9 +47,7 @@ async def get_kpis(db: AsyncSession, *, org_id: int) -> dict[str, object]: } -async def get_activity_feed( - db: AsyncSession, *, org_id: int, limit: int = 20 -) -> list[Activity]: +async def get_activity_feed(db: AsyncSession, *, org_id: int, limit: int = 20) -> list[Activity]: """Return the most recent activities, ordered by created_at desc.""" result = await db.execute( select(Activity) diff --git a/app/services/deal_service.py b/app/services/deal_service.py index 2e577d3..09c2afb 100644 --- a/app/services/deal_service.py +++ b/app/services/deal_service.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections import defaultdict from datetime import UTC, datetime from decimal import Decimal -from typing import Optional from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -75,9 +74,7 @@ async def create_deal( return deal -async def get_deal( - db: AsyncSession, deal_id: int, *, org_id: int -) -> Optional[Deal]: +async def get_deal(db: AsyncSession, deal_id: int, *, org_id: int) -> Deal | None: """Fetch a single deal by id.""" q = OrgScopedQuery(Deal, db, org_id=org_id) return await q.get(deal_id) @@ -89,9 +86,9 @@ async def list_deals( org_id: int, skip: int = 0, limit: int = 20, - stage: Optional[str] = None, - owner_id: Optional[int] = None, - account_id: Optional[int] = None, + stage: str | None = None, + owner_id: int | None = None, + account_id: int | None = None, ) -> list[Deal]: """List deals with optional filters.""" scoped = OrgScopedQuery(Deal, db, org_id=org_id) @@ -105,9 +102,7 @@ async def list_deals( ) -async def update_deal( - db: AsyncSession, deal: Deal, payload: DealUpdate -) -> Deal: +async def update_deal(db: AsyncSession, deal: Deal, payload: DealUpdate) -> Deal: """Apply partial updates to a deal. Does NOT change stage (use update_stage).""" data = payload.model_dump(exclude_unset=True) # Disallow direct stage changes via update endpoint @@ -126,7 +121,7 @@ async def update_stage( new_stage: DealStage, *, changed_by: int, - reason: Optional[str] = None, + reason: str | None = None, ) -> Deal: """Change a deal's stage and append a DealStageHistory record.""" from_stage_str = _stage_value(deal.stage) @@ -151,9 +146,7 @@ async def update_stage( return deal -async def get_pipeline( - db: AsyncSession, *, org_id: int -) -> list[dict[str, object]]: +async def get_pipeline(db: AsyncSession, *, org_id: int) -> list[dict[str, object]]: """Return deals grouped by stage for the pipeline view.""" scoped = OrgScopedQuery(Deal, db, org_id=org_id) all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id) diff --git a/app/services/note_service.py b/app/services/note_service.py index 597fac4..ac726e7 100644 --- a/app/services/note_service.py +++ b/app/services/note_service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -37,19 +36,13 @@ async def _validate_parent( """Verify the (parent_type, parent_id) tuple points to an existing entity in org.""" if parent_type == NoteParentType.account: if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Account {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)") elif parent_type == NoteParentType.contact: if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Contact {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)") elif parent_type == NoteParentType.deal: if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Deal {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)") async def create_note( @@ -84,9 +77,7 @@ async def create_note( return note -async def get_note( - db: AsyncSession, note_id: int, *, org_id: int -) -> Optional[Note]: +async def get_note(db: AsyncSession, note_id: int, *, org_id: int) -> Note | None: """Fetch a single note by id.""" q = OrgScopedQuery(Note, db, org_id=org_id) return await q.get(note_id) @@ -98,8 +89,8 @@ async def list_notes( org_id: int, skip: int = 0, limit: int = 20, - parent_type: Optional[str] = None, - parent_id: Optional[int] = None, + parent_type: str | None = None, + parent_id: int | None = None, ) -> list[Note]: """List notes with optional parent filters.""" scoped = OrgScopedQuery(Note, db, org_id=org_id) @@ -112,9 +103,7 @@ async def list_notes( ) -async def update_note( - db: AsyncSession, note: Note, payload: NoteUpdate -) -> Note: +async def update_note(db: AsyncSession, note: Note, payload: NoteUpdate) -> Note: """Apply partial updates to a note. Does NOT change parent (notes are pinned).""" data = payload.model_dump(exclude_unset=True) data.pop("parent_type", None) diff --git a/app/services/tag_service.py b/app/services/tag_service.py index 377bafc..9feaa15 100644 --- a/app/services/tag_service.py +++ b/app/services/tag_service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Optional from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -39,24 +38,16 @@ async def _validate_parent( """Verify (parent_type, parent_id) points to an existing entity in org.""" if parent_type == TagLinkParentType.account: if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Account {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)") elif parent_type == TagLinkParentType.contact: if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Contact {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)") elif parent_type == TagLinkParentType.deal: if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None: - raise InvalidParent( - f"Deal {parent_id} not found in this org (R-2 validation)" - ) + raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)") -async def create_tag( - db: AsyncSession, payload: TagCreate, *, org_id: int -) -> Tag: +async def create_tag(db: AsyncSession, payload: TagCreate, *, org_id: int) -> Tag: """Create a new tag in the given org.""" tag = Tag( org_id=org_id, @@ -75,14 +66,12 @@ async def list_tags(db: AsyncSession, *, org_id: int) -> list[Tag]: 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.""" return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id) -async def link_tag( - db: AsyncSession, payload: TagLinkCreate, *, org_id: int -) -> TagLink: +async def link_tag(db: AsyncSession, payload: TagLinkCreate, *, org_id: int) -> TagLink: """Link a tag to an entity. R-2: validates parent exists.""" parent_type = ( payload.parent_type diff --git a/app/services/user_service.py b/app/services/user_service.py index 132d550..c821150 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import datetime, UTC -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -21,17 +20,13 @@ class EmailAlreadyTaken(Exception): """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).""" - result = await db.execute( - select(User).where(User.id == user_id, User.deleted_at.is_(None)) - ) + result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None))) return result.scalar_one_or_none() -async def get_user_by_email( - db: AsyncSession, email: str, org_id: Optional[int] = None -) -> Optional[User]: +async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None: """Fetch a user by email, optionally scoped to an org.""" stmt = select(User).where( User.email == email.lower(), @@ -73,15 +68,11 @@ async def soft_delete_user(db: AsyncSession, user: User) -> User: return user -async def create_user_as_admin( - db: AsyncSession, payload: UserCreateRequest, org_id: int -) -> User: +async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User: """Create a new user in the given org (admin-only flow).""" existing = await get_user_by_email(db, payload.email, org_id=org_id) if existing is not None: - raise EmailAlreadyTaken( - f"A user with email {payload.email!r} already exists in this org." - ) + raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.") user = User( org_id=org_id, @@ -96,9 +87,7 @@ async def create_user_as_admin( return user -async def list_users( - db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50 -) -> list[User]: +async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]: """List active users in an org, paginated.""" result = await db.execute( select(User) diff --git a/tests/conftest.py b/tests/conftest.py index 8ec8857..5e561de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,9 +44,7 @@ async def session_factory( engine: AsyncEngine, ) -> async_sessionmaker[AsyncSession]: """Session factory bound to the test engine.""" - return async_sessionmaker( - bind=engine, expire_on_commit=False, class_=AsyncSession - ) + return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) @pytest_asyncio.fixture @@ -131,7 +129,9 @@ async def second_user( "/api/v1/auth/login", data={"username": payload["email"], "password": payload["password"]}, ) - assert login_resp.status_code == 200, f"login failed: {login_resp.status_code} {login_resp.text}" + assert login_resp.status_code == 200, ( + f"login failed: {login_resp.status_code} {login_resp.text}" + ) token = login_resp.json()["access_token"] return { @@ -167,7 +167,12 @@ async def seed_data( # 2 accounts acc1 = await client.post( "/api/v1/accounts/", - json={"name": "Acme Corp", "industry": "sme", "size": "sme", "website": "https://acme.test"}, + json={ + "name": "Acme Corp", + "industry": "sme", + "size": "sme", + "website": "https://acme.test", + }, headers=headers, ) assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}" @@ -184,7 +189,12 @@ async def seed_data( # 3 contacts (2 with account, 1 standalone) c1 = await client.post( "/api/v1/contacts/", - json={"first_name": "Anna", "last_name": "Schmidt", "email": "anna@acme.example", "account_id": acc1_id}, + json={ + "first_name": "Anna", + "last_name": "Schmidt", + "email": "anna@acme.example", + "account_id": acc1_id, + }, headers=headers, ) assert c1.status_code == 201, c1.text @@ -192,7 +202,12 @@ async def seed_data( c2 = await client.post( "/api/v1/contacts/", - json={"first_name": "Bob", "last_name": "Mueller", "email": "bob@globex.example", "account_id": acc2_id}, + json={ + "first_name": "Bob", + "last_name": "Mueller", + "email": "bob@globex.example", + "account_id": acc2_id, + }, headers=headers, ) assert c2.status_code == 201, c2.text @@ -208,13 +223,15 @@ async def seed_data( # 5 deals (different stages) deal_ids: list[int] = [] - for i, (title, stage) in enumerate([ - ("Deal A", "lead"), - ("Deal B", "qualified"), - ("Deal C", "proposal"), - ("Deal D", "negotiation"), - ("Deal E", "won"), - ]): + for i, (title, stage) in enumerate( + [ + ("Deal A", "lead"), + ("Deal B", "qualified"), + ("Deal C", "proposal"), + ("Deal D", "negotiation"), + ("Deal E", "won"), + ] + ): d = await client.post( "/api/v1/deals/", json={ @@ -230,6 +247,7 @@ async def seed_data( # 10 activities (4 overdue, 4 future, 2 completed) from datetime import UTC, datetime, timedelta + past = (datetime.now(UTC) - timedelta(days=2)).isoformat() future = (datetime.now(UTC) + timedelta(days=2)).isoformat() future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat() @@ -237,10 +255,20 @@ async def seed_data( for i in range(10): if i < 4: due = past - body_data: dict[str, object] = {"type": "task", "subject": f"Overdue task {i}", "due_date": due, "deal_id": deal_ids[i % 5]} + body_data: dict[str, object] = { + "type": "task", + "subject": f"Overdue task {i}", + "due_date": due, + "deal_id": deal_ids[i % 5], + } elif i < 8: due = future if i % 2 == 0 else future2 - body_data = {"type": "call", "subject": f"Upcoming call {i}", "due_date": due, "account_id": acc1_id} + body_data = { + "type": "call", + "subject": f"Upcoming call {i}", + "due_date": due, + "account_id": acc1_id, + } else: body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id} a = await client.post("/api/v1/activities/", json=body_data, headers=headers) @@ -250,18 +278,36 @@ async def seed_data( # 3 tags tag_ids: list[int] = [] for name in ["VIP", "Strategic", "Enterprise"]: - t = await client.post("/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers) + t = await client.post( + "/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers + ) assert t.status_code == 201, t.text tag_ids.append(t.json()["id"]) # 4 notes (mixed parents: 2 account, 1 contact, 1 deal) - n1 = await client.post("/api/v1/notes/", json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, headers=headers) + n1 = await client.post( + "/api/v1/notes/", + json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, + headers=headers, + ) assert n1.status_code == 201, n1.text - n2 = await client.post("/api/v1/notes/", json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, headers=headers) + n2 = await client.post( + "/api/v1/notes/", + json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, + headers=headers, + ) assert n2.status_code == 201, n2.text - n3 = await client.post("/api/v1/notes/", json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, headers=headers) + n3 = await client.post( + "/api/v1/notes/", + json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, + headers=headers, + ) assert n3.status_code == 201, n3.text - n4 = await client.post("/api/v1/notes/", json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, headers=headers) + n4 = await client.post( + "/api/v1/notes/", + json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, + headers=headers, + ) assert n4.status_code == 201, n4.text return { diff --git a/tests/test_accounts.py b/tests/test_accounts.py index c241093..b3dd481 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -21,7 +21,9 @@ async def test_create_account(client: AsyncClient, auth_headers: dict[str, str]) @pytest.mark.asyncio -async def test_create_account_missing_required(client: AsyncClient, auth_headers: dict[str, str]) -> None: +async def test_create_account_missing_required( + client: AsyncClient, auth_headers: dict[str, str] +) -> None: resp = await client.post( "/api/v1/accounts/", json={"industry": "sme"}, # name missing @@ -97,7 +99,9 @@ async def test_db_write_account_no_password_field( assert resp.status_code == 201 async with session_factory() as session: - result = await session.execute(text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'")) + result = await session.execute( + text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'") + ) row = result.fetchone() assert row is not None assert row[0] == "Schema Test Co" diff --git a/tests/test_activities.py b/tests/test_activities.py index 0c58f32..7ce9f42 100644 --- a/tests/test_activities.py +++ b/tests/test_activities.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import datetime import pytest from httpx import AsyncClient diff --git a/tests/test_auth.py b/tests/test_auth.py index 34addff..2791a66 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,9 +3,7 @@ from __future__ import annotations import time -from datetime import timedelta -import pytest from httpx import AsyncClient from jose import jwt @@ -43,9 +41,7 @@ async def test_register_success(client: AsyncClient) -> None: # === FR-1.1 / Akzeptanzkriterium 2: register duplicate email === -async def test_register_duplicate_email( - client: AsyncClient, registered_user: dict -) -> None: +async def test_register_duplicate_email(client: AsyncClient, registered_user: dict) -> None: """AC #2: POST /api/v1/auth/register mit existierender Email → 409.""" # registered_user already exists; trying again with same email (and 2nd user # would also be blocked by bootstrap). 409 is correct because of the email conflict. @@ -93,9 +89,7 @@ async def test_register_weak_password(client: AsyncClient) -> None: # === FR-1.2 / Akzeptanzkriterium 4: login success === -async def test_login_success( - client: AsyncClient, registered_user: dict -) -> None: +async def test_login_success(client: AsyncClient, registered_user: dict) -> None: """AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT.""" resp = await client.post( "/api/v1/auth/login", @@ -114,9 +108,7 @@ async def test_login_success( # === FR-1.2 / Akzeptanzkriterium 5: login wrong password === -async def test_login_wrong_password( - client: AsyncClient, registered_user: dict -) -> None: +async def test_login_wrong_password(client: AsyncClient, registered_user: dict) -> None: """AC #5: POST /api/v1/auth/login mit falschem Passwort → 401.""" resp = await client.post( "/api/v1/auth/login", @@ -203,12 +195,11 @@ async def test_db_user_has_hashed_password( ) -> None: """AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext).""" from sqlalchemy import select + from app.models.user import User async with session_factory() as session: - result = await session.execute( - select(User).where(User.email == registered_user["email"]) - ) + result = await session.execute(select(User).where(User.email == registered_user["email"])) user = result.scalar_one() # bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text assert user.password_hash.startswith("$"), ( @@ -216,19 +207,17 @@ async def test_db_user_has_hashed_password( ) assert user.password_hash != registered_user["password"] assert len(user.password_hash) > 50, ( - "Bcrypt hash should be ~60 chars long, got " - f"{len(user.password_hash)}" + f"Bcrypt hash should be ~60 chars long, got {len(user.password_hash)}" ) # === Bonus: no default admin bootstrap on startup === -async def test_no_default_admin_on_startup( - client: AsyncClient, session_factory -) -> None: +async def test_no_default_admin_on_startup(client: AsyncClient, session_factory) -> None: """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 # Fresh DB → no users @@ -238,10 +227,6 @@ async def test_no_default_admin_on_startup( assert count == 0, f"Fresh DB should have 0 users, found {count}" # Also check: no user with role=admin and well-known email - result = await session.execute( - select(User).where(User.role == "admin") - ) + result = await session.execute(select(User).where(User.role == "admin")) admins = result.scalars().all() - assert len(admins) == 0, ( - f"Fresh DB should have no admin users, found {len(admins)}" - ) + assert len(admins) == 0, f"Fresh DB should have no admin users, found {len(admins)}" diff --git a/tests/test_contacts.py b/tests/test_contacts.py index f23f401..8c32d13 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -13,7 +13,12 @@ async def test_create_contact_with_account( acc_id = seed_data["account_ids"][0] resp = await client.post( "/api/v1/contacts/", - json={"first_name": "Diana", "last_name": "Prince", "email": "diana@x.example", "account_id": acc_id}, + json={ + "first_name": "Diana", + "last_name": "Prince", + "email": "diana@x.example", + "account_id": acc_id, + }, headers=auth_headers, ) assert resp.status_code == 201, resp.text @@ -34,9 +39,7 @@ async def test_create_contact_invalid_account( @pytest.mark.asyncio -async def test_list_contacts_filter_account( - client: AsyncClient, seed_data: dict -) -> None: +async def test_list_contacts_filter_account(client: AsyncClient, seed_data: dict) -> None: acc_id = seed_data["account_ids"][0] resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"]) assert resp.status_code == 200 @@ -45,9 +48,7 @@ async def test_list_contacts_filter_account( @pytest.mark.asyncio -async def test_search_contacts_by_email( - client: AsyncClient, seed_data: dict -) -> None: +async def test_search_contacts_by_email(client: AsyncClient, seed_data: dict) -> None: # seed_data creates contact with email 'anna@acme.example' on account 0 resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"]) assert resp.status_code == 200 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 6f276a2..52b373f 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -11,7 +11,9 @@ async def test_kpis(client: AsyncClient, seed_data: dict) -> None: resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"]) assert resp.status_code == 200, resp.text body = resp.json() - assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set(body.keys()) + assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set( + body.keys() + ) assert isinstance(body["open_deals_count"], int) assert isinstance(body["pipeline_value"], (int, float)) assert isinstance(body["won_this_month"], int) diff --git a/tests/test_deals.py b/tests/test_deals.py index d7ef509..7f7c594 100644 --- a/tests/test_deals.py +++ b/tests/test_deals.py @@ -37,6 +37,7 @@ async def test_update_deal_stage_creates_history( # Verify DealStageHistory row exists in DB from sqlalchemy import text + async with session_factory() as session: result = await session.execute( text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"), @@ -74,6 +75,7 @@ async def test_db_write_deal( ) -> None: """DB roundtrip: read back a seeded deal directly via SQL.""" from sqlalchemy import text + deal_id = seed_data["deal_ids"][2] async with session_factory() as session: result = await session.execute( diff --git a/tests/test_frontend_assets.py b/tests/test_frontend_assets.py index 292d819..9be29fd 100644 --- a/tests/test_frontend_assets.py +++ b/tests/test_frontend_assets.py @@ -27,6 +27,7 @@ def _read(rel: str) -> str: # api.js # --------------------------------------------------------------------------- + def test_api_js_exports_api_and_ApiError(): content = _read("js/api.js") assert "export class ApiError" in content @@ -56,6 +57,7 @@ def test_api_js_handles_204_no_content(): # store.js # --------------------------------------------------------------------------- + def test_store_js_defines_auth_and_notifications(): content = _read("js/store.js") assert "Alpine.store('auth'" in content @@ -81,6 +83,7 @@ def test_store_js_notifications_has_push_and_dismiss(): # app.css # --------------------------------------------------------------------------- + def test_app_css_contains_tailwind_overrides(): content = _read("css/app.css") # The file defines custom CSS variables (--crm-primary, etc.) @@ -107,10 +110,10 @@ def test_app_css_has_toast_styles(): # --------------------------------------------------------------------------- COMPONENTS = [ - ("account-list.js", "accountList"), - ("deal-kanban.js", "dealKanban"), - ("activity-list.js", "activityList"), - ("dashboard-kpis.js", "dashboardKpis"), + ("account-list.js", "accountList"), + ("deal-kanban.js", "dealKanban"), + ("activity-list.js", "activityList"), + ("dashboard-kpis.js", "dashboardKpis"), ] @@ -120,11 +123,13 @@ def test_alpine_component_defined(filename, factory): globally with Alpine.data(name, factory).""" content = _read(f"components/{filename}") # factory function definition - assert f"export function {factory}" in content, \ + assert f"export function {factory}" in content, ( f"{filename} missing `export function {factory}()`" + ) # Alpine.data() registration - assert f"Alpine.data('{factory}'" in content, \ + assert f"Alpine.data('{factory}'" in content, ( f"{filename} missing `Alpine.data('{factory}', …)` registration" + ) def test_deal_kanban_handles_drag_and_drop(): @@ -159,6 +164,7 @@ def test_account_list_uses_pagination(): # notifications.js (toast component) # --------------------------------------------------------------------------- + def test_notifications_js_registers_toast_container(): content = _read("js/components/notifications.js") assert "toastContainer" in content @@ -169,6 +175,7 @@ def test_notifications_js_registers_toast_container(): # auth.js # --------------------------------------------------------------------------- + def test_auth_js_exports_login_logout_register(): content = _read("js/auth.js") assert "export async function login" in content diff --git a/tests/test_frontend_pages.py b/tests/test_frontend_pages.py index 04a8716..16fe45b 100644 --- a/tests/test_frontend_pages.py +++ b/tests/test_frontend_pages.py @@ -33,18 +33,18 @@ def _read(name: str) -> str: # Each page: (filename, [required substrings]) # --------------------------------------------------------------------------- PAGES = [ - ("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]), - ("app.html", ["Dashboard", "Logout", "Pipeline"]), - ("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]), - ("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]), - ("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]), - ("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]), - ("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]), - ("activities.html", ["Activities", "+ New Activity", "Fällig"]), + ("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]), + ("app.html", ["Dashboard", "Logout", "Pipeline"]), + ("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]), + ("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]), + ("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]), + ("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]), + ("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]), + ("activities.html", ["Activities", "+ New Activity", "Fällig"]), ("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]), - ("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]), - ("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]), - ("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]), + ("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]), + ("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]), + ("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]), # Dashboard: ships inside app.html. We test the marker text directly there. ] diff --git a/tests/test_frontend_security.py b/tests/test_frontend_security.py index 2532a7b..c3abbd6 100644 --- a/tests/test_frontend_security.py +++ b/tests/test_frontend_security.py @@ -43,9 +43,8 @@ def test_no_x_html_in_alpine_templates(): stripped = re.sub(r"", "", line) if XHTML_PATTERN.search(stripped): offenders.append((path.name, lineno, line.strip()[:120])) - assert not offenders, ( - "x-html usage is forbidden (R-5); offenders:\n" - + "\n".join(f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders) + assert not offenders, "x-html usage is forbidden (R-5); offenders:\n" + "\n".join( + f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders ) @@ -54,21 +53,23 @@ def test_no_innerHTML_in_alpine_pages(): for path in _all_html_files(): content = path.read_text(encoding="utf-8") # allow within