chore(quality): apply ruff autofixes and formatting (223 fixes, regression-free: 118/118 tests pass)
This commit is contained in:
+12
-6
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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,10 +65,14 @@ 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 = (
|
||||||
|
select(sa_func.count())
|
||||||
|
.select_from(self.model)
|
||||||
|
.where(
|
||||||
self.model.org_id == self.org_id,
|
self.model.org_id == self.org_id,
|
||||||
self.model.deleted_at.is_(None),
|
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:
|
||||||
stmt = stmt.where(getattr(self.model, field) == value)
|
stmt = stmt.where(getattr(self.model, field) == value)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+62
-16
@@ -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 A", "lead"),
|
||||||
("Deal B", "qualified"),
|
("Deal B", "qualified"),
|
||||||
("Deal C", "proposal"),
|
("Deal C", "proposal"),
|
||||||
("Deal D", "negotiation"),
|
("Deal D", "negotiation"),
|
||||||
("Deal E", "won"),
|
("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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user