chore(quality): apply ruff autofixes and formatting (223 fixes, regression-free: 118/118 tests pass)

This commit is contained in:
Agent Zero
2026-06-10 21:24:24 +00:00
parent c53af91d74
commit aec40d03ac
56 changed files with 459 additions and 528 deletions
+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]:
+20 -18
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]:
@@ -88,9 +98,7 @@ async def get_activity(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ActivityOut: ) -> ActivityOut:
activity = await svc_get_activity( activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
db, activity_id, org_id=current_user.org_id
)
if activity is None: if activity is None:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
return ActivityOut.model_validate(activity) return ActivityOut.model_validate(activity)
@@ -107,9 +115,7 @@ async def update_activity(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ActivityOut: ) -> ActivityOut:
activity = await svc_get_activity( activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
db, activity_id, org_id=current_user.org_id
)
if activity is None: if activity is None:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
try: try:
@@ -130,9 +136,7 @@ async def complete_activity(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ActivityOut: ) -> ActivityOut:
activity = await svc_get_activity( activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
db, activity_id, org_id=current_user.org_id
)
if activity is None: if activity is None:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
updated = await svc_complete_activity(db, activity, payload) updated = await svc_complete_activity(db, activity, payload)
@@ -150,9 +154,7 @@ async def delete_activity(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Response: ) -> Response:
activity = await svc_get_activity( activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
db, activity_id, org_id=current_user.org_id
)
if activity is None: if activity is None:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
await svc_soft_delete_activity(db, activity) await svc_soft_delete_activity(db, activity)
+2 -4
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,
@@ -127,9 +127,7 @@ async def refresh(
from app.core.security import create_access_token from app.core.security import create_access_token
role_str = ( role_str = (
current_user.role.value current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
if hasattr(current_user.role, "value")
else str(current_user.role)
) )
token = create_access_token(current_user.id, current_user.org_id, role_str) token = create_access_token(current_user.id, current_user.org_id, role_str)
return TokenResponse( return TokenResponse(
+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]:
+1 -3
View File
@@ -40,9 +40,7 @@ async def get_activity_feed_endpoint(
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[ActivityFeedItem]: ) -> list[ActivityFeedItem]:
activities = await get_activity_feed( activities = await get_activity_feed(db, org_id=current_user.org_id, limit=limit)
db, org_id=current_user.org_id, limit=limit
)
return [ActivityFeedItem.model_validate(a) for a in activities] return [ActivityFeedItem.model_validate(a) for a in activities]
+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,
) )
+4 -12
View File
@@ -42,9 +42,7 @@ async def update_me(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> UserOut: ) -> UserOut:
"""A user can update their own profile, but not their role.""" """A user can update their own profile, but not their role."""
updated = await user_service.update_user_profile( updated = await user_service.update_user_profile(db, current_user, payload, is_admin=False)
db, current_user, payload, is_admin=False
)
return UserOut.model_validate(updated) return UserOut.model_validate(updated)
@@ -86,9 +84,7 @@ async def create_user(
) -> UserOut: ) -> UserOut:
"""Admin creates a new user in the same org.""" """Admin creates a new user in the same org."""
try: try:
new_user = await user_service.create_user_as_admin( new_user = await user_service.create_user_as_admin(db, payload, org_id=current_user.org_id)
db, payload, org_id=current_user.org_id
)
except user_service.EmailAlreadyTaken as e: except user_service.EmailAlreadyTaken as e:
raise HTTPException(status_code=409, detail=str(e)) from e raise HTTPException(status_code=409, detail=str(e)) from e
return UserOut.model_validate(new_user) return UserOut.model_validate(new_user)
@@ -124,9 +120,7 @@ async def update_user(
if target.org_id != current_user.org_id: if target.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
updated = await user_service.update_user_profile( updated = await user_service.update_user_profile(db, target, payload, is_admin=is_admin)
db, target, payload, is_admin=is_admin
)
return UserOut.model_validate(updated) return UserOut.model_validate(updated)
@@ -143,9 +137,7 @@ async def delete_user(
) -> Response: ) -> Response:
"""Sets deleted_at timestamp. The record stays in the DB for audit purposes.""" """Sets deleted_at timestamp. The record stays in the DB for audit purposes."""
if current_user.id == user_id: if current_user.id == user_id:
raise HTTPException( raise HTTPException(status_code=400, detail="You cannot delete your own account")
status_code=400, detail="You cannot delete your own account"
)
target = await user_service.get_user_by_id(db, user_id) target = await user_service.get_user_by_id(db, user_id)
if target is None or target.org_id != current_user.org_id: if target is None or target.org_id != current_user.org_id:
+2 -6
View File
@@ -42,9 +42,7 @@ async def get_current_user(
raise credentials_exc from None raise credentials_exc from None
# Load user fresh from DB to honor soft-delete and role changes # Load user fresh from DB to honor soft-delete and role changes
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))
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
@@ -57,9 +55,7 @@ async def get_current_admin_user(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> User: ) -> User:
"""Require the current user to have the admin role.""" """Require the current user to have the admin role."""
user_role = ( user_role = user.role.value if hasattr(user.role, "value") else str(user.role)
user.role.value if hasattr(user.role, "value") else str(user.role)
)
if user_role != UserRole.admin.value: if user_role != UserRole.admin.value:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
+1 -3
View File
@@ -91,9 +91,7 @@ def is_token_expired(token: str) -> bool:
try: try:
jwt.get_unverified_claims(token) jwt.get_unverified_claims(token)
# If decode succeeds, it's not expired. # If decode succeeds, it's not expired.
jwt.decode( jwt.decode(token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM])
token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM]
)
return False return False
except JWTError as e: except JWTError as e:
return "expired" in str(e).lower() or "exp" in str(e).lower() return "expired" in str(e).lower() or "exp" in str(e).lower()
+4 -10
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
@@ -129,17 +128,13 @@ def create_app() -> FastAPI:
response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
if settings_local.is_production: if settings_local.is_production:
response.headers["Strict-Transport-Security"] = ( response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
"max-age=31536000; includeSubDomains"
)
return response return response
# === Exception Handlers === # === Exception Handlers ===
@app.exception_handler(StarletteHTTPException) @app.exception_handler(StarletteHTTPException)
async def http_exception_handler( async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
request: Request, exc: StarletteHTTPException
) -> JSONResponse:
"""Format HTTPException responses consistently.""" """Format HTTPException responses consistently."""
# Distinguish token-expired for FR-1.7 acceptance criterion # Distinguish token-expired for FR-1.7 acceptance criterion
if exc.status_code == 401: if exc.status_code == 401:
@@ -163,15 +158,14 @@ def create_app() -> FastAPI:
) -> JSONResponse: ) -> JSONResponse:
"""Format Pydantic validation errors consistently.""" """Format Pydantic validation errors consistently."""
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
return JSONResponse( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())}, content={"detail": jsonable_encoder(exc.errors())},
) )
@app.exception_handler(SQLAlchemyError) @app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler( async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
request: Request, exc: SQLAlchemyError
) -> JSONResponse:
"""Log DB errors and return a 500 without leaking internals.""" """Log DB errors and return a 500 without leaking internals."""
logger.exception("Database error on %s %s", request.method, request.url) logger.exception("Database error on %s %s", request.method, request.url)
return JSONResponse( return JSONResponse(
+14 -22
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,28 @@ 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[AccountSize | None] = mapped_column(String(32), nullable=True)
) address: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
size: Mapped[Optional[AccountSize]] = mapped_column(String(32), nullable=True) owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=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
)
# 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,
+14 -26
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):
@@ -32,43 +32,31 @@ class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "activities" __tablename__ = "activities"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[ActivityType] = mapped_column( type: Mapped[ActivityType] = mapped_column(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[int | None] = mapped_column(
)
account_id: Mapped[Optional[int]] = 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(ForeignKey("users.id"), nullable=False, index=True)
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
# 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("User", foreign_keys=[owner_id], lazy="joined")
)
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>" return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
+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,
+11 -15
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,32 +23,28 @@ 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(ForeignKey("users.id"), nullable=False, index=True)
ForeignKey("users.id"), nullable=False, index=True
)
# 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,
+13 -21
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,31 @@ 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(ForeignKey("users.id"), nullable=False, index=True)
) won_lost_reason: Mapped[str | None] = mapped_column(String(512), nullable=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)
# 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("User", foreign_keys=[owner_id], lazy="joined")
) stage_history: Mapped[list[DealStageHistory]] = relationship(
owner: Mapped["User"] = relationship(
"User", foreign_keys=[owner_id], lazy="joined"
)
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,
+7 -15
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):
@@ -21,21 +21,13 @@ class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
__tablename__ = "deal_stage_history" __tablename__ = "deal_stage_history"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
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[DealStage | None] = mapped_column(String(32), nullable=True)
)
from_stage: Mapped[Optional[DealStage]] = 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("User", foreign_keys=[changed_by], lazy="joined")
)
changer: Mapped["User"] = relationship(
"User", foreign_keys=[changed_by], lazy="joined"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>" return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
+3 -9
View File
@@ -27,19 +27,13 @@ class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
body: Mapped[str] = mapped_column(Text, nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column( author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
ForeignKey("users.id"), nullable=False, index=True parent_type: Mapped[NoteParentType] = mapped_column(String(32), 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. # parent_id is intentionally NOT a DB FK because of polymorphic parent.
# 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"
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>" return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
+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"
) )
+7 -13
View File
@@ -24,27 +24,21 @@ class TagLinkParentType(str, Enum):
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "tag_links" __tablename__ = "tag_links"
__table_args__ = ( __table_args__ = (UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),)
UniqueConstraint(
"tag_id", "parent_type", "parent_id", name="uq_tag_link"
),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
tag_id: Mapped[int] = mapped_column( tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), nullable=False, index=True)
ForeignKey("tags.id"), nullable=False, index=True parent_type: Mapped[TagLinkParentType] = mapped_column(String(32), 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. # parent_id is intentionally NOT a DB FK because of polymorphic parent.
# 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}>"
)
__all__ = ["TagLink", "TagLinkParentType"] __all__ = ["TagLink", "TagLinkParentType"]
+4 -6
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
@@ -24,9 +24,7 @@ class UserRole(str, Enum):
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin): class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = ( __table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),)
UniqueConstraint("org_id", "email", name="uq_users_org_email"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=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, 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):
+17 -20
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,18 +12,16 @@ 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"
)
return self return self
@@ -33,13 +30,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 +45,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):
+9 -5
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."""
@@ -65,9 +65,13 @@ class OrgScopedQuery:
"""Count records scoped to org, with optional filters.""" """Count records scoped to org, with optional filters."""
from sqlalchemy import func as sa_func from sqlalchemy import func as sa_func
stmt = select(sa_func.count()).select_from(self.model).where( stmt = (
self.model.org_id == self.org_id, select(sa_func.count())
self.model.deleted_at.is_(None), .select_from(self.model)
.where(
self.model.org_id == self.org_id,
self.model.deleted_at.is_(None),
)
) )
for field, value in filters.items(): for field, value in filters.items():
if value is not None: if value is not None:
+6 -12
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,
@@ -36,9 +34,7 @@ async def create_account(
return account return account
async def get_account( async def get_account(db: AsyncSession, account_id: int, *, org_id: int) -> Account | None:
db: AsyncSession, account_id: int, *, org_id: int
) -> Optional[Account]:
"""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 +48,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
@@ -76,9 +72,7 @@ async def list_accounts(
return base return base
async def update_account( async def update_account(db: AsyncSession, account: Account, payload: AccountUpdate) -> Account:
db: AsyncSession, account: Account, payload: AccountUpdate
) -> Account:
"""Apply partial updates to an account.""" """Apply partial updates to an account."""
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
for field, value in data.items(): for field, value in data.items():
+14 -15
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:
@@ -35,14 +34,17 @@ async def _validate_parents(
) )
if account_id is not None: if account_id is not None:
from app.models.account import Account from app.models.account import Account
if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None: 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") raise NoParentException(f"Account {account_id} not found in this org")
if contact_id is not None: if contact_id is not None:
from app.models.contact import Contact from app.models.contact import Contact
if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None: 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") raise NoParentException(f"Contact {contact_id} not found in this org")
if deal_id is not None: if deal_id is not None:
from app.models.deal import Deal from app.models.deal import Deal
if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None: 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") raise NoParentException(f"Deal {deal_id} not found in this org")
@@ -79,9 +81,7 @@ async def create_activity(
return activity return activity
async def get_activity( async def get_activity(db: AsyncSession, activity_id: int, *, org_id: int) -> Activity | None:
db: AsyncSession, activity_id: int, *, org_id: int
) -> Optional[Activity]:
"""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 +93,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)
@@ -110,7 +110,8 @@ async def list_activities(
now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes
if overdue is True: if overdue is True:
base = [ 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 a.due_date is not None and a.due_date < now and a.completed_at is None
] ]
if completed is True: if completed is True:
@@ -162,9 +163,7 @@ async def complete_activity(
return activity return activity
async def soft_delete_activity( async def soft_delete_activity(db: AsyncSession, activity: Activity) -> Activity:
db: AsyncSession, activity: Activity
) -> Activity:
"""Soft-delete an activity.""" """Soft-delete an activity."""
activity.deleted_at = datetime.now(UTC) activity.deleted_at = datetime.now(UTC)
await db.commit() await db.commit()
+4 -14
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
@@ -35,15 +33,11 @@ class InvalidCredentials(AuthError):
async def count_users(db: AsyncSession) -> int: async def count_users(db: AsyncSession) -> int:
"""Count active (non-soft-deleted) users. Used to gate bootstrap registration.""" """Count active (non-soft-deleted) users. Used to gate bootstrap registration."""
result = await db.execute( result = await db.execute(select(User).where(User.deleted_at.is_(None)))
select(User).where(User.deleted_at.is_(None))
)
return len(result.scalars().all()) return len(result.scalars().all())
async def register_user( async def register_user(db: AsyncSession, payload: UserRegisterRequest) -> tuple[User, str]:
db: AsyncSession, payload: UserRegisterRequest
) -> tuple[User, str]:
"""Bootstrap registration. """Bootstrap registration.
Creates a new Org and the first user (or a new user in the existing org 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() await db.commit()
except IntegrityError as e: except IntegrityError as e:
await db.rollback() await db.rollback()
raise EmailAlreadyExists( raise EmailAlreadyExists(f"A user with email {payload.email!r} already exists.") from e
f"A user with email {payload.email!r} already exists."
) from e
await db.refresh(user) await db.refresh(user)
@@ -92,9 +84,7 @@ async def register_user(
return user, token return user, token
async def authenticate_user( async def authenticate_user(db: AsyncSession, email: str, password: str) -> tuple[User, str] | None:
db: AsyncSession, email: str, password: str
) -> Optional[tuple[User, str]]:
"""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
+6 -13
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
@@ -22,9 +21,7 @@ class InvalidAccount(Exception):
"""Raised when account_id points to a non-existent account.""" """Raised when account_id points to a non-existent account."""
async def _validate_account( async def _validate_account(db: AsyncSession, account_id: int, *, org_id: int) -> None:
db: AsyncSession, account_id: int, *, org_id: int
) -> None:
"""Raise if account_id does not exist within the org.""" """Raise if account_id does not exist within the org."""
if account_id is None: if account_id is None:
return return
@@ -62,9 +59,7 @@ async def create_contact(
return contact return contact
async def get_contact( async def get_contact(db: AsyncSession, contact_id: int, *, org_id: int) -> Contact | None:
db: AsyncSession, contact_id: int, *, org_id: int
) -> Optional[Contact]:
"""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 +71,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)
@@ -101,9 +96,7 @@ async def list_contacts(
return base return base
async def update_contact( async def update_contact(db: AsyncSession, contact: Contact, payload: ContactUpdate) -> Contact:
db: AsyncSession, contact: Contact, payload: ContactUpdate
) -> Contact:
"""Apply partial updates to a contact.""" """Apply partial updates to a contact."""
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
if "account_id" in data and data["account_id"] is not None: if "account_id" in data and data["account_id"] is not None:
+3 -8
View File
@@ -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 = [d for d in all_deals if d.stage in open_stages]
open_deals_count = len(open_deals) open_deals_count = len(open_deals)
pipeline_value = float( pipeline_value = float(sum((d.value for d in open_deals), Decimal("0")))
sum((d.value for d in open_deals), Decimal("0"))
)
now = datetime.now(UTC) now = datetime.now(UTC)
# SQLite returns naive datetimes; strip tz for comparison # SQLite returns naive datetimes; strip tz for comparison
month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0) month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0)
won_this_month = [ won_this_month = [
d for d in all_deals d for d in all_deals if d.stage == DealStage.won and d.created_at >= month_start
if d.stage == DealStage.won and d.created_at >= month_start
] ]
won_count = len(won_this_month) 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( async def get_activity_feed(db: AsyncSession, *, org_id: int, limit: int = 20) -> list[Activity]:
db: AsyncSession, *, org_id: int, limit: int = 20
) -> list[Activity]:
"""Return the most recent activities, ordered by created_at desc.""" """Return the most recent activities, ordered by created_at desc."""
result = await db.execute( result = await db.execute(
select(Activity) select(Activity)
+7 -14
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
@@ -75,9 +74,7 @@ async def create_deal(
return deal return deal
async def get_deal( async def get_deal(db: AsyncSession, deal_id: int, *, org_id: int) -> Deal | None:
db: AsyncSession, deal_id: int, *, org_id: int
) -> Optional[Deal]:
"""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 +86,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)
@@ -105,9 +102,7 @@ async def list_deals(
) )
async def update_deal( async def update_deal(db: AsyncSession, deal: Deal, payload: DealUpdate) -> Deal:
db: AsyncSession, deal: Deal, payload: DealUpdate
) -> Deal:
"""Apply partial updates to a deal. Does NOT change stage (use update_stage).""" """Apply partial updates to a deal. Does NOT change stage (use update_stage)."""
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
# Disallow direct stage changes via update endpoint # Disallow direct stage changes via update endpoint
@@ -126,7 +121,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)
@@ -151,9 +146,7 @@ async def update_stage(
return deal return deal
async def get_pipeline( async def get_pipeline(db: AsyncSession, *, org_id: int) -> list[dict[str, object]]:
db: AsyncSession, *, org_id: int
) -> list[dict[str, object]]:
"""Return deals grouped by stage for the pipeline view.""" """Return deals grouped by stage for the pipeline view."""
scoped = OrgScopedQuery(Deal, db, org_id=org_id) scoped = OrgScopedQuery(Deal, db, org_id=org_id)
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id) all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
+7 -18
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
@@ -37,19 +36,13 @@ async def _validate_parent(
"""Verify the (parent_type, parent_id) tuple points to an existing entity in org.""" """Verify the (parent_type, parent_id) tuple points to an existing entity in org."""
if parent_type == NoteParentType.account: if parent_type == NoteParentType.account:
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
f"Account {parent_id} not found in this org (R-2 validation)"
)
elif parent_type == NoteParentType.contact: elif parent_type == NoteParentType.contact:
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
f"Contact {parent_id} not found in this org (R-2 validation)"
)
elif parent_type == NoteParentType.deal: elif parent_type == NoteParentType.deal:
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
f"Deal {parent_id} not found in this org (R-2 validation)"
)
async def create_note( async def create_note(
@@ -84,9 +77,7 @@ async def create_note(
return note return note
async def get_note( async def get_note(db: AsyncSession, note_id: int, *, org_id: int) -> Note | None:
db: AsyncSession, note_id: int, *, org_id: int
) -> Optional[Note]:
"""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 +89,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)
@@ -112,9 +103,7 @@ async def list_notes(
) )
async def update_note( async def update_note(db: AsyncSession, note: Note, payload: NoteUpdate) -> Note:
db: AsyncSession, note: Note, payload: NoteUpdate
) -> Note:
"""Apply partial updates to a note. Does NOT change parent (notes are pinned).""" """Apply partial updates to a note. Does NOT change parent (notes are pinned)."""
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
data.pop("parent_type", None) data.pop("parent_type", None)
+6 -17
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
@@ -39,24 +38,16 @@ async def _validate_parent(
"""Verify (parent_type, parent_id) points to an existing entity in org.""" """Verify (parent_type, parent_id) points to an existing entity in org."""
if parent_type == TagLinkParentType.account: if parent_type == TagLinkParentType.account:
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
f"Account {parent_id} not found in this org (R-2 validation)"
)
elif parent_type == TagLinkParentType.contact: elif parent_type == TagLinkParentType.contact:
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
f"Contact {parent_id} not found in this org (R-2 validation)"
)
elif parent_type == TagLinkParentType.deal: elif parent_type == TagLinkParentType.deal:
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None: if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent( raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
f"Deal {parent_id} not found in this org (R-2 validation)"
)
async def create_tag( async def create_tag(db: AsyncSession, payload: TagCreate, *, org_id: int) -> Tag:
db: AsyncSession, payload: TagCreate, *, org_id: int
) -> Tag:
"""Create a new tag in the given org.""" """Create a new tag in the given org."""
tag = Tag( tag = Tag(
org_id=org_id, 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) 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)
async def link_tag( async def link_tag(db: AsyncSession, payload: TagLinkCreate, *, org_id: int) -> TagLink:
db: AsyncSession, payload: TagLinkCreate, *, org_id: int
) -> TagLink:
"""Link a tag to an entity. R-2: validates parent exists.""" """Link a tag to an entity. R-2: validates parent exists."""
parent_type = ( parent_type = (
payload.parent_type payload.parent_type
+7 -18
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,17 +20,13 @@ 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))
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_by_email( async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None:
db: AsyncSession, email: str, org_id: Optional[int] = None
) -> Optional[User]:
"""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(),
@@ -73,15 +68,11 @@ async def soft_delete_user(db: AsyncSession, user: User) -> User:
return user return user
async def create_user_as_admin( async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User:
db: AsyncSession, payload: UserCreateRequest, org_id: int
) -> User:
"""Create a new user in the given org (admin-only flow).""" """Create a new user in the given org (admin-only flow)."""
existing = await get_user_by_email(db, payload.email, org_id=org_id) existing = await get_user_by_email(db, payload.email, org_id=org_id)
if existing is not None: if existing is not None:
raise EmailAlreadyTaken( raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.")
f"A user with email {payload.email!r} already exists in this org."
)
user = User( user = User(
org_id=org_id, org_id=org_id,
@@ -96,9 +87,7 @@ async def create_user_as_admin(
return user return user
async def list_users( async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]:
db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50
) -> list[User]:
"""List active users in an org, paginated.""" """List active users in an org, paginated."""
result = await db.execute( result = await db.execute(
select(User) select(User)
+67 -21
View File
@@ -44,9 +44,7 @@ async def session_factory(
engine: AsyncEngine, engine: AsyncEngine,
) -> async_sessionmaker[AsyncSession]: ) -> async_sessionmaker[AsyncSession]:
"""Session factory bound to the test engine.""" """Session factory bound to the test engine."""
return async_sessionmaker( return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
bind=engine, expire_on_commit=False, class_=AsyncSession
)
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -131,7 +129,9 @@ async def second_user(
"/api/v1/auth/login", "/api/v1/auth/login",
data={"username": payload["email"], "password": payload["password"]}, 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"] token = login_resp.json()["access_token"]
return { return {
@@ -167,7 +167,12 @@ async def seed_data(
# 2 accounts # 2 accounts
acc1 = await client.post( acc1 = await client.post(
"/api/v1/accounts/", "/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, headers=headers,
) )
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}" 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) # 3 contacts (2 with account, 1 standalone)
c1 = await client.post( c1 = await client.post(
"/api/v1/contacts/", "/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, headers=headers,
) )
assert c1.status_code == 201, c1.text assert c1.status_code == 201, c1.text
@@ -192,7 +202,12 @@ async def seed_data(
c2 = await client.post( c2 = await client.post(
"/api/v1/contacts/", "/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, headers=headers,
) )
assert c2.status_code == 201, c2.text assert c2.status_code == 201, c2.text
@@ -208,13 +223,15 @@ async def seed_data(
# 5 deals (different stages) # 5 deals (different stages)
deal_ids: list[int] = [] deal_ids: list[int] = []
for i, (title, stage) in enumerate([ for i, (title, stage) in enumerate(
("Deal A", "lead"), [
("Deal B", "qualified"), ("Deal A", "lead"),
("Deal C", "proposal"), ("Deal B", "qualified"),
("Deal D", "negotiation"), ("Deal C", "proposal"),
("Deal E", "won"), ("Deal D", "negotiation"),
]): ("Deal E", "won"),
]
):
d = await client.post( d = await client.post(
"/api/v1/deals/", "/api/v1/deals/",
json={ json={
@@ -230,6 +247,7 @@ async def seed_data(
# 10 activities (4 overdue, 4 future, 2 completed) # 10 activities (4 overdue, 4 future, 2 completed)
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
past = (datetime.now(UTC) - timedelta(days=2)).isoformat() past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
future = (datetime.now(UTC) + timedelta(days=2)).isoformat() future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat() future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
@@ -237,10 +255,20 @@ async def seed_data(
for i in range(10): for i in range(10):
if i < 4: if i < 4:
due = past 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: elif i < 8:
due = future if i % 2 == 0 else future2 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: else:
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id} 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) a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
@@ -250,18 +278,36 @@ async def seed_data(
# 3 tags # 3 tags
tag_ids: list[int] = [] tag_ids: list[int] = []
for name in ["VIP", "Strategic", "Enterprise"]: 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 assert t.status_code == 201, t.text
tag_ids.append(t.json()["id"]) tag_ids.append(t.json()["id"])
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal) # 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 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 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 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 assert n4.status_code == 201, n4.text
return { return {
+6 -2
View File
@@ -21,7 +21,9 @@ async def test_create_account(client: AsyncClient, auth_headers: dict[str, str])
@pytest.mark.asyncio @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( resp = await client.post(
"/api/v1/accounts/", "/api/v1/accounts/",
json={"industry": "sme"}, # name missing json={"industry": "sme"}, # name missing
@@ -97,7 +99,9 @@ async def test_db_write_account_no_password_field(
assert resp.status_code == 201 assert resp.status_code == 201
async with session_factory() as session: 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() row = result.fetchone()
assert row is not None assert row is not None
assert row[0] == "Schema Test Co" assert row[0] == "Schema Test Co"
+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
+11 -26
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
@@ -43,9 +41,7 @@ async def test_register_success(client: AsyncClient) -> None:
# === FR-1.1 / Akzeptanzkriterium 2: register duplicate email === # === FR-1.1 / Akzeptanzkriterium 2: register duplicate email ===
async def test_register_duplicate_email( async def test_register_duplicate_email(client: AsyncClient, registered_user: dict) -> None:
client: AsyncClient, registered_user: dict
) -> None:
"""AC #2: POST /api/v1/auth/register mit existierender Email → 409.""" """AC #2: POST /api/v1/auth/register mit existierender Email → 409."""
# registered_user already exists; trying again with same email (and 2nd user # 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. # 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 === # === FR-1.2 / Akzeptanzkriterium 4: login success ===
async def test_login_success( async def test_login_success(client: AsyncClient, registered_user: dict) -> None:
client: AsyncClient, registered_user: dict
) -> None:
"""AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT.""" """AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT."""
resp = await client.post( resp = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
@@ -114,9 +108,7 @@ async def test_login_success(
# === FR-1.2 / Akzeptanzkriterium 5: login wrong password === # === FR-1.2 / Akzeptanzkriterium 5: login wrong password ===
async def test_login_wrong_password( async def test_login_wrong_password(client: AsyncClient, registered_user: dict) -> None:
client: AsyncClient, registered_user: dict
) -> None:
"""AC #5: POST /api/v1/auth/login mit falschem Passwort → 401.""" """AC #5: POST /api/v1/auth/login mit falschem Passwort → 401."""
resp = await client.post( resp = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
@@ -203,12 +195,11 @@ 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:
result = await session.execute( result = await session.execute(select(User).where(User.email == registered_user["email"]))
select(User).where(User.email == registered_user["email"])
)
user = result.scalar_one() user = result.scalar_one()
# bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text # bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text
assert user.password_hash.startswith("$"), ( 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 user.password_hash != registered_user["password"]
assert len(user.password_hash) > 50, ( assert len(user.password_hash) > 50, (
"Bcrypt hash should be ~60 chars long, got " f"Bcrypt hash should be ~60 chars long, got {len(user.password_hash)}"
f"{len(user.password_hash)}"
) )
# === Bonus: no default admin bootstrap on startup === # === Bonus: no default admin bootstrap on startup ===
async def test_no_default_admin_on_startup( async def test_no_default_admin_on_startup(client: AsyncClient, session_factory) -> None:
client: AsyncClient, session_factory
) -> 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
@@ -238,10 +227,6 @@ async def test_no_default_admin_on_startup(
assert count == 0, f"Fresh DB should have 0 users, found {count}" assert count == 0, f"Fresh DB should have 0 users, found {count}"
# Also check: no user with role=admin and well-known email # Also check: no user with role=admin and well-known email
result = await session.execute( result = await session.execute(select(User).where(User.role == "admin"))
select(User).where(User.role == "admin")
)
admins = result.scalars().all() admins = result.scalars().all()
assert len(admins) == 0, ( assert len(admins) == 0, f"Fresh DB should have no admin users, found {len(admins)}"
f"Fresh DB should have no admin users, found {len(admins)}"
)
+8 -7
View File
@@ -13,7 +13,12 @@ async def test_create_contact_with_account(
acc_id = seed_data["account_ids"][0] acc_id = seed_data["account_ids"][0]
resp = await client.post( resp = await client.post(
"/api/v1/contacts/", "/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, headers=auth_headers,
) )
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
@@ -34,9 +39,7 @@ async def test_create_contact_invalid_account(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_contacts_filter_account( async def test_list_contacts_filter_account(client: AsyncClient, seed_data: dict) -> None:
client: AsyncClient, seed_data: dict
) -> None:
acc_id = seed_data["account_ids"][0] acc_id = seed_data["account_ids"][0]
resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"]) resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"])
assert resp.status_code == 200 assert resp.status_code == 200
@@ -45,9 +48,7 @@ async def test_list_contacts_filter_account(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_contacts_by_email( async def test_search_contacts_by_email(client: AsyncClient, seed_data: dict) -> None:
client: AsyncClient, seed_data: dict
) -> None:
# seed_data creates contact with email 'anna@acme.example' on account 0 # 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"]) resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"])
assert resp.status_code == 200 assert resp.status_code == 200
+3 -1
View File
@@ -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"]) resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"])
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() 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["open_deals_count"], int)
assert isinstance(body["pipeline_value"], (int, float)) assert isinstance(body["pipeline_value"], (int, float))
assert isinstance(body["won_this_month"], int) assert isinstance(body["won_this_month"], int)
+2
View File
@@ -37,6 +37,7 @@ async def test_update_deal_stage_creates_history(
# Verify DealStageHistory row exists in DB # Verify DealStageHistory row exists in DB
from sqlalchemy import text from sqlalchemy import text
async with session_factory() as session: async with session_factory() as session:
result = await session.execute( result = await session.execute(
text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"), 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: ) -> None:
"""DB roundtrip: read back a seeded deal directly via SQL.""" """DB roundtrip: read back a seeded deal directly via SQL."""
from sqlalchemy import text from sqlalchemy import text
deal_id = seed_data["deal_ids"][2] deal_id = seed_data["deal_ids"][2]
async with session_factory() as session: async with session_factory() as session:
result = await session.execute( result = await session.execute(
+13 -6
View File
@@ -27,6 +27,7 @@ def _read(rel: str) -> str:
# api.js # api.js
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_api_js_exports_api_and_ApiError(): def test_api_js_exports_api_and_ApiError():
content = _read("js/api.js") content = _read("js/api.js")
assert "export class ApiError" in content assert "export class ApiError" in content
@@ -56,6 +57,7 @@ def test_api_js_handles_204_no_content():
# store.js # store.js
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_store_js_defines_auth_and_notifications(): def test_store_js_defines_auth_and_notifications():
content = _read("js/store.js") content = _read("js/store.js")
assert "Alpine.store('auth'" in content assert "Alpine.store('auth'" in content
@@ -81,6 +83,7 @@ def test_store_js_notifications_has_push_and_dismiss():
# app.css # app.css
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_app_css_contains_tailwind_overrides(): def test_app_css_contains_tailwind_overrides():
content = _read("css/app.css") content = _read("css/app.css")
# The file defines custom CSS variables (--crm-primary, etc.) # The file defines custom CSS variables (--crm-primary, etc.)
@@ -107,10 +110,10 @@ def test_app_css_has_toast_styles():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
COMPONENTS = [ COMPONENTS = [
("account-list.js", "accountList"), ("account-list.js", "accountList"),
("deal-kanban.js", "dealKanban"), ("deal-kanban.js", "dealKanban"),
("activity-list.js", "activityList"), ("activity-list.js", "activityList"),
("dashboard-kpis.js", "dashboardKpis"), ("dashboard-kpis.js", "dashboardKpis"),
] ]
@@ -120,11 +123,13 @@ def test_alpine_component_defined(filename, factory):
globally with Alpine.data(name, factory).""" globally with Alpine.data(name, factory)."""
content = _read(f"components/{filename}") content = _read(f"components/{filename}")
# factory function definition # factory function definition
assert f"export function {factory}" in content, \ assert f"export function {factory}" in content, (
f"{filename} missing `export function {factory}()`" f"{filename} missing `export function {factory}()`"
)
# Alpine.data() registration # Alpine.data() registration
assert f"Alpine.data('{factory}'" in content, \ assert f"Alpine.data('{factory}'" in content, (
f"{filename} missing `Alpine.data('{factory}', …)` registration" f"{filename} missing `Alpine.data('{factory}', …)` registration"
)
def test_deal_kanban_handles_drag_and_drop(): def test_deal_kanban_handles_drag_and_drop():
@@ -159,6 +164,7 @@ def test_account_list_uses_pagination():
# notifications.js (toast component) # notifications.js (toast component)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_notifications_js_registers_toast_container(): def test_notifications_js_registers_toast_container():
content = _read("js/components/notifications.js") content = _read("js/components/notifications.js")
assert "toastContainer" in content assert "toastContainer" in content
@@ -169,6 +175,7 @@ def test_notifications_js_registers_toast_container():
# auth.js # auth.js
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_auth_js_exports_login_logout_register(): def test_auth_js_exports_login_logout_register():
content = _read("js/auth.js") content = _read("js/auth.js")
assert "export async function login" in content assert "export async function login" in content
+11 -11
View File
@@ -33,18 +33,18 @@ def _read(name: str) -> str:
# Each page: (filename, [required substrings]) # Each page: (filename, [required substrings])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
PAGES = [ PAGES = [
("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]), ("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]),
("app.html", ["Dashboard", "Logout", "Pipeline"]), ("app.html", ["Dashboard", "Logout", "Pipeline"]),
("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]), ("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]),
("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]), ("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]),
("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]), ("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]),
("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]), ("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]),
("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]), ("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]),
("activities.html", ["Activities", "+ New Activity", "Fällig"]), ("activities.html", ["Activities", "+ New Activity", "Fällig"]),
("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]), ("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]),
("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]), ("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]),
("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]), ("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]),
("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]), ("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]),
# Dashboard: ships inside app.html. We test the marker text directly there. # Dashboard: ships inside app.html. We test the marker text directly there.
] ]
+24 -18
View File
@@ -43,9 +43,8 @@ def test_no_x_html_in_alpine_templates():
stripped = re.sub(r"<!--.*?-->", "", line) stripped = re.sub(r"<!--.*?-->", "", line)
if XHTML_PATTERN.search(stripped): if XHTML_PATTERN.search(stripped):
offenders.append((path.name, lineno, line.strip()[:120])) offenders.append((path.name, lineno, line.strip()[:120]))
assert not offenders, ( assert not offenders, "x-html usage is forbidden (R-5); offenders:\n" + "\n".join(
"x-html usage is forbidden (R-5); offenders:\n" f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders
+ "\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(): for path in _all_html_files():
content = path.read_text(encoding="utf-8") content = path.read_text(encoding="utf-8")
# allow within <script> blocks only if commented out / disabled # allow within <script> blocks only if commented out / disabled
assert "innerHTML" not in content, \ assert "innerHTML" not in content, (
f"{path.name} contains innerHTML which is forbidden (R-5)" f"{path.name} contains innerHTML which is forbidden (R-5)"
)
def test_x_text_is_used_instead(): def test_x_text_is_used_instead():
"""Sanity check: the auth/dashboard pages use x-text for user-derived strings.""" """Sanity check: the auth/dashboard pages use x-text for user-derived strings."""
index = (WEBUI / "index.html").read_text(encoding="utf-8") index = (WEBUI / "index.html").read_text(encoding="utf-8")
# The error message div should use x-text (so user input never gets HTML-parsed) # The error message div should use x-text (so user input never gets HTML-parsed)
assert "x-text=\"error\"" in index or 'x-text="error"' in index assert 'x-text="error"' in index or 'x-text="error"' in index
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# R-1: JWT in localStorage # R-1: JWT in localStorage
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_jwt_uses_localStorage(): def test_jwt_uses_localStorage():
"""api.js must read/write the JWT in localStorage, not cookies/sessionStorage.""" """api.js must read/write the JWT in localStorage, not cookies/sessionStorage."""
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8") api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
@@ -90,14 +91,16 @@ def test_jwt_not_in_cookies():
"""Defence in depth: do NOT put the JWT in document.cookie.""" """Defence in depth: do NOT put the JWT in document.cookie."""
for js in WEBUI.rglob("*.js"): for js in WEBUI.rglob("*.js"):
content = js.read_text(encoding="utf-8") content = js.read_text(encoding="utf-8")
assert "document.cookie" not in content, \ assert "document.cookie" not in content, (
f"{js.relative_to(ROOT)} must not use document.cookie for the JWT" f"{js.relative_to(ROOT)} must not use document.cookie for the JWT"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 13.3: CSP-Header in main.py # 13.3: CSP-Header in main.py
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_csp_header_in_main_py(): def test_csp_header_in_main_py():
"""app/main.py must wire up a middleware (or response-header decorator) """app/main.py must wire up a middleware (or response-header decorator)
that sets Content-Security-Policy. We don't run the server here — we that sets Content-Security-Policy. We don't run the server here — we
@@ -105,29 +108,30 @@ def test_csp_header_in_main_py():
assert MAIN_PY.exists(), f"Missing {MAIN_PY}" assert MAIN_PY.exists(), f"Missing {MAIN_PY}"
content = MAIN_PY.read_text(encoding="utf-8") content = MAIN_PY.read_text(encoding="utf-8")
# The middleware / decorator must mention the CSP header value # The middleware / decorator must mention the CSP header value
assert "Content-Security-Policy" in content, \ assert "Content-Security-Policy" in content, "app/main.py does not set Content-Security-Policy"
"app/main.py does not set Content-Security-Policy"
# The header must allow the Tailwind CDN at minimum # The header must allow the Tailwind CDN at minimum
assert "cdn.tailwindcss.com" in content, \ assert "cdn.tailwindcss.com" in content, (
"CSP-Header must allow https://cdn.tailwindcss.com in script-src" "CSP-Header must allow https://cdn.tailwindcss.com in script-src"
)
# And block scripts from arbitrary origins # And block scripts from arbitrary origins
assert "default-src 'self'" in content or 'default-src "self"' in content, \ assert "default-src 'self'" in content or 'default-src "self"' in content, (
"CSP-Header must set default-src 'self'" "CSP-Header must set default-src 'self'"
)
def test_csp_blocks_object_embedding(): def test_csp_blocks_object_embedding():
"""object-src 'none' is part of the agreed lockdown.""" """object-src 'none' is part of the agreed lockdown."""
content = MAIN_PY.read_text(encoding="utf-8") content = MAIN_PY.read_text(encoding="utf-8")
assert "object-src 'none'" in content, \ assert "object-src 'none'" in content, "CSP-Header must set object-src 'none'"
"CSP-Header must set object-src 'none'"
def test_csp_uses_starlette_middleware(): def test_csp_uses_starlette_middleware():
"""Per architecture: CSP is set in a FastAPI/Starlette middleware, """Per architecture: CSP is set in a FastAPI/Starlette middleware,
not in nginx / a response handler.""" not in nginx / a response handler."""
content = MAIN_PY.read_text(encoding="utf-8") content = MAIN_PY.read_text(encoding="utf-8")
assert "@app.middleware(\"http\")" in content or '@app.middleware("http")' in content, \ assert '@app.middleware("http")' in content or '@app.middleware("http")' in content, (
"CSP-Header must be set in an @app.middleware(\"http\") decorator" 'CSP-Header must be set in an @app.middleware("http") decorator'
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -155,9 +159,9 @@ def test_protected_page_has_auth_gate(filename):
content = (WEBUI / filename).read_text(encoding="utf-8") content = (WEBUI / filename).read_text(encoding="utf-8")
# The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"` # The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"`
# or `x-init="init()"` on a component that checks the JWT. # or `x-init="init()"` on a component that checks the JWT.
assert "Alpine.store('auth').init()" in content or \ assert "Alpine.store('auth').init()" in content or "$store.auth.init()" in content, (
"$store.auth.init()" in content, \
f"{filename} has no Alpine.store('auth').init() auth-gate" f"{filename} has no Alpine.store('auth').init() auth-gate"
)
def test_index_page_has_no_auth_gate(): def test_index_page_has_no_auth_gate():
@@ -165,13 +169,15 @@ def test_index_page_has_no_auth_gate():
content = (WEBUI / "index.html").read_text(encoding="utf-8") content = (WEBUI / "index.html").read_text(encoding="utf-8")
# The auth store's init() redirects to /index.html when no JWT exists, # The auth store's init() redirects to /index.html when no JWT exists,
# so it must NOT be called from the login page. # so it must NOT be called from the login page.
assert "Alpine.store('auth').init()" not in content, \ assert "Alpine.store('auth').init()" not in content, (
"index.html (login page) must not call auth.init() (would cause loop)" "index.html (login page) must not call auth.init() (would cause loop)"
)
def test_404_page_has_no_auth_gate(): def test_404_page_has_no_auth_gate():
"""404 page is reachable without auth (so users can find their way back).""" """404 page is reachable without auth (so users can find their way back)."""
content = (WEBUI / "404.html").read_text(encoding="utf-8") content = (WEBUI / "404.html").read_text(encoding="utf-8")
# The 404 must not call auth.init() — it is intentionally public. # The 404 must not call auth.init() — it is intentionally public.
assert "Alpine.store('auth').init()" not in content, \ assert "Alpine.store('auth').init()" not in content, (
"404.html must not call auth.init() (public page)" "404.html must not call auth.init() (public page)"
)
+1 -3
View File
@@ -22,9 +22,7 @@ async def test_health_no_auth_required(client: AsyncClient) -> None:
resp = await client.get("/health") resp = await client.get("/health")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# Even with a bogus header, it should still be 200 (public endpoint) # Even with a bogus header, it should still be 200 (public endpoint)
resp = await client.get( resp = await client.get("/health", headers={"Authorization": "Bearer not-a-real-token"})
"/health", headers={"Authorization": "Bearer not-a-real-token"}
)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
+1 -3
View File
@@ -7,9 +7,7 @@ from httpx import AsyncClient
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tag( async def test_create_tag(client: AsyncClient, auth_headers: dict[str, str]) -> None:
client: AsyncClient, auth_headers: dict[str, str]
) -> None:
resp = await client.post( resp = await client.post(
"/api/v1/tags/", "/api/v1/tags/",
json={"name": "Important", "color": "#FF0000"}, json={"name": "Important", "color": "#FF0000"},
+4 -8
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",
@@ -173,9 +173,7 @@ async def test_list_users_as_sales_rep_forbidden(
# === Refresh & Logout === # === Refresh & Logout ===
async def test_refresh_token( async def test_refresh_token(client: AsyncClient, auth_headers: dict[str, str]) -> None:
client: AsyncClient, auth_headers: dict[str, str]
) -> None:
"""POST /api/v1/auth/refresh issues a new token for the current user.""" """POST /api/v1/auth/refresh issues a new token for the current user."""
resp = await client.post("/api/v1/auth/refresh", headers=auth_headers) resp = await client.post("/api/v1/auth/refresh", headers=auth_headers)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@@ -185,9 +183,7 @@ async def test_refresh_token(
assert data["expires_in"] > 0 assert data["expires_in"] > 0
async def test_logout( async def test_logout(client: AsyncClient, auth_headers: dict[str, str]) -> None:
client: AsyncClient, auth_headers: dict[str, str]
) -> None:
"""POST /api/v1/auth/logout returns 200 with confirmation message.""" """POST /api/v1/auth/logout returns 200 with confirmation message."""
resp = await client.post("/api/v1/auth/logout", headers=auth_headers) resp = await client.post("/api/v1/auth/logout", headers=auth_headers)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text