diff --git a/app/api/v1/contacts.py b/app/api/v1/contacts.py index db899e7..66f3ec2 100644 --- a/app/api/v1/contacts.py +++ b/app/api/v1/contacts.py @@ -83,7 +83,8 @@ async def create_contact( service: ContactService = Depends(get_contact_service), ) -> ContactRead: data = payload.to_domain( - organization_id=context.organization_id, fallback_owner=context.user_id, + organization_id=context.organization_id, + fallback_owner=context.user_id, ) try: contact = await service.create_contact(data, context=context) diff --git a/app/api/v1/deals.py b/app/api/v1/deals.py index 65de8a2..7baac8f 100644 --- a/app/api/v1/deals.py +++ b/app/api/v1/deals.py @@ -68,7 +68,8 @@ async def list_deals( stage_value = DealStage(stage) if stage else None except ValueError as exc: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid deal filter", + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid deal filter", ) from exc params = DealQueryParams( @@ -100,7 +101,8 @@ async def create_deal( """Create a new deal within the current organization.""" data = payload.to_domain( - organization_id=context.organization_id, fallback_owner=context.user_id, + organization_id=context.organization_id, + fallback_owner=context.user_id, ) try: deal = await service.create_deal(data, context=context) diff --git a/app/core/cache.py b/app/core/cache.py index d76f109..8dd004b 100644 --- a/app/core/cache.py +++ b/app/core/cache.py @@ -46,7 +46,9 @@ class RedisCacheManager: if self._client is not None: return self._client = redis.from_url( - settings.redis_url, encoding="utf-8", decode_responses=False, + settings.redis_url, + encoding="utf-8", + decode_responses=False, ) await self._refresh_availability() @@ -63,7 +65,9 @@ class RedisCacheManager: async with self._lock: if self._client is None: self._client = redis.from_url( - settings.redis_url, encoding="utf-8", decode_responses=False, + settings.redis_url, + encoding="utf-8", + decode_responses=False, ) await self._refresh_availability() @@ -127,7 +131,11 @@ async def read_json(client: Redis, key: str) -> Any | None: async def write_json( - client: Redis, key: str, value: Any, ttl_seconds: int, backoff_ms: int, + client: Redis, + key: str, + value: Any, + ttl_seconds: int, + backoff_ms: int, ) -> None: """Serialize data to JSON and store it with TTL using retry/backoff.""" payload = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8") diff --git a/app/core/config.py b/app/core/config.py index 3842fef..d153432 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -17,7 +17,8 @@ class Settings(BaseSettings): db_name: str = Field(default="test_task_crm", description="Database name") db_user: str = Field(default="postgres", description="Database user") db_password: SecretStr = Field( - default=SecretStr("postgres"), description="Database user password", + default=SecretStr("postgres"), + description="Database user password", ) database_url_override: str | None = Field( default=None, @@ -32,7 +33,9 @@ class Settings(BaseSettings): redis_enabled: bool = Field(default=False, description="Toggle Redis-backed cache usage") redis_url: str = Field(default="redis://localhost:6379/0", description="Redis connection URL") analytics_cache_ttl_seconds: int = Field( - default=120, ge=1, description="TTL for cached analytics responses", + default=120, + ge=1, + description="TTL for cached analytics responses", ) analytics_cache_backoff_ms: int = Field( default=200, diff --git a/app/models/user.py b/app/models/user.py index 4b69860..b9a50bd 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -38,7 +38,9 @@ class User(Base): ) memberships = relationship( - "OrganizationMember", back_populates="user", cascade="all, delete-orphan", + "OrganizationMember", + back_populates="user", + cascade="all, delete-orphan", ) owned_contacts = relationship("Contact", back_populates="owner") owned_deals = relationship("Deal", back_populates="owner") diff --git a/app/repositories/activity_repo.py b/app/repositories/activity_repo.py index f980370..a652799 100644 --- a/app/repositories/activity_repo.py +++ b/app/repositories/activity_repo.py @@ -41,7 +41,8 @@ class ActivityRepository: select(Activity) .join(Deal, Deal.id == Activity.deal_id) .where( - Activity.deal_id == params.deal_id, Deal.organization_id == params.organization_id, + Activity.deal_id == params.deal_id, + Deal.organization_id == params.organization_id, ) .order_by(Activity.created_at) ) diff --git a/app/repositories/contact_repo.py b/app/repositories/contact_repo.py index bf245bb..476cc5f 100644 --- a/app/repositories/contact_repo.py +++ b/app/repositories/contact_repo.py @@ -63,7 +63,8 @@ class ContactRepository: user_id: int, ) -> Contact | None: stmt = select(Contact).where( - Contact.id == contact_id, Contact.organization_id == organization_id, + Contact.id == contact_id, + Contact.organization_id == organization_id, ) result = await self._session.scalars(stmt) return result.first() diff --git a/app/repositories/deal_repo.py b/app/repositories/deal_repo.py index 6d6c482..e5edfe2 100644 --- a/app/repositories/deal_repo.py +++ b/app/repositories/deal_repo.py @@ -148,7 +148,9 @@ class DealRepository: return stmt def _apply_ordering( - self, stmt: Select[tuple[Deal]], params: DealQueryParams, + self, + stmt: Select[tuple[Deal]], + params: DealQueryParams, ) -> Select[tuple[Deal]]: column = ORDERABLE_COLUMNS.get(params.order_by or "created_at", Deal.created_at) order_func = desc if params.order_desc else asc diff --git a/app/repositories/task_repo.py b/app/repositories/task_repo.py index 4a4fa73..af26968 100644 --- a/app/repositories/task_repo.py +++ b/app/repositories/task_repo.py @@ -107,7 +107,9 @@ class TaskRepository: return task def _apply_filters( - self, stmt: Select[tuple[Task]], params: TaskQueryParams, + self, + stmt: Select[tuple[Task]], + params: TaskQueryParams, ) -> Select[tuple[Task]]: if params.deal_id is not None: stmt = stmt.where(Task.deal_id == params.deal_id) diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py index c63a1de..fac25ac 100644 --- a/app/services/analytics_service.py +++ b/app/services/analytics_service.py @@ -169,7 +169,10 @@ class AnalyticsService: return _deserialize_summary(payload) async def _store_summary_cache( - self, organization_id: int, days: int, summary: DealSummary, + self, + organization_id: int, + days: int, + summary: DealSummary, ) -> None: if not self._is_cache_enabled() or self._cache is None: return @@ -187,7 +190,9 @@ class AnalyticsService: return _deserialize_funnel(payload) async def _store_funnel_cache( - self, organization_id: int, breakdowns: list[StageBreakdown], + self, + organization_id: int, + breakdowns: list[StageBreakdown], ) -> None: if not self._is_cache_enabled() or self._cache is None: return @@ -321,7 +326,9 @@ def _deserialize_funnel(payload: Any) -> list[StageBreakdown] | None: async def invalidate_analytics_cache( - cache: Redis | None, organization_id: int, backoff_ms: int, + cache: Redis | None, + organization_id: int, + backoff_ms: int, ) -> None: """Remove cached analytics payloads for the organization.""" diff --git a/app/services/contact_service.py b/app/services/contact_service.py index fc4465f..79f7554 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -80,7 +80,9 @@ class ContactService: ) try: return await self._repository.list( - params=params, role=context.role, user_id=context.user_id, + params=params, + role=context.role, + user_id=context.user_id, ) except ContactAccessError as exc: raise ContactForbiddenError(str(exc)) from exc @@ -126,7 +128,10 @@ class ContactService: return contact try: return await self._repository.update( - contact, payload, role=context.role, user_id=context.user_id, + contact, + payload, + role=context.role, + user_id=context.user_id, ) except ContactAccessError as exc: raise ContactForbiddenError(str(exc)) from exc diff --git a/app/services/deal_service.py b/app/services/deal_service.py index 72bc87a..df6b28f 100644 --- a/app/services/deal_service.py +++ b/app/services/deal_service.py @@ -79,7 +79,9 @@ class DealService: await self._ensure_contact_in_organization(data.contact_id, context.organization_id) deal = await self._repository.create(data=data, role=context.role, user_id=context.user_id) await invalidate_analytics_cache( - self._cache, context.organization_id, self._cache_backoff_ms, + self._cache, + context.organization_id, + self._cache_backoff_ms, ) return deal @@ -120,7 +122,10 @@ class DealService: return deal updated = await self._repository.update( - deal, changes, role=context.role, user_id=context.user_id, + deal, + changes, + role=context.role, + user_id=context.user_id, ) await self._log_activities( deal_id=deal.id, @@ -128,7 +133,9 @@ class DealService: activities=[activity for activity in [stage_activity, status_activity] if activity], ) await invalidate_analytics_cache( - self._cache, context.organization_id, self._cache_backoff_ms, + self._cache, + context.organization_id, + self._cache_backoff_ms, ) return updated @@ -150,7 +157,10 @@ class DealService: return for activity_type, payload in entries: activity = Activity( - deal_id=deal_id, author_id=author_id, type=activity_type, payload=payload, + deal_id=deal_id, + author_id=author_id, + type=activity_type, + payload=payload, ) self._repository.session.add(activity) await self._repository.session.flush() @@ -160,7 +170,9 @@ class DealService: raise DealOrganizationMismatchError("Operation targets a different organization") async def _ensure_contact_in_organization( - self, contact_id: int, organization_id: int, + self, + contact_id: int, + organization_id: int, ) -> Contact: contact = await self._repository.session.get(Contact, contact_id) if contact is None or contact.organization_id != organization_id: diff --git a/app/services/organization_service.py b/app/services/organization_service.py index 85e840c..0578597 100644 --- a/app/services/organization_service.py +++ b/app/services/organization_service.py @@ -56,7 +56,10 @@ class OrganizationService: self._repository = repository async def get_context( - self, *, user_id: int, organization_id: int | None, + self, + *, + user_id: int, + organization_id: int | None, ) -> OrganizationContext: """Resolve request context ensuring the user belongs to the given organization.""" @@ -70,7 +73,10 @@ class OrganizationService: return OrganizationContext(organization=membership.organization, membership=membership) def ensure_entity_in_context( - self, *, entity_organization_id: int, context: OrganizationContext, + self, + *, + entity_organization_id: int, + context: OrganizationContext, ) -> None: """Make sure a resource belongs to the current organization.""" diff --git a/tests/api/v1/task_activity_shared.py b/tests/api/v1/task_activity_shared.py index c17b939..d7896c5 100644 --- a/tests/api/v1/task_activity_shared.py +++ b/tests/api/v1/task_activity_shared.py @@ -28,7 +28,10 @@ class Scenario: async def prepare_scenario(session_factory: async_sessionmaker[AsyncSession]) -> Scenario: async with session_factory() as session: user = User( - email="owner@example.com", hashed_password="hashed", name="Owner", is_active=True, + email="owner@example.com", + hashed_password="hashed", + name="Owner", + is_active=True, ) org = Organization(name="Acme LLC") session.add_all([user, org]) diff --git a/tests/api/v1/test_analytics.py b/tests/api/v1/test_analytics.py index 5857e0d..f3f3078 100644 --- a/tests/api/v1/test_analytics.py +++ b/tests/api/v1/test_analytics.py @@ -32,7 +32,10 @@ async def prepare_analytics_scenario( async with session_factory() as session: org = Organization(name="Analytics Org") user = User( - email="analytics@example.com", hashed_password="hashed", name="Analyst", is_active=True, + email="analytics@example.com", + hashed_password="hashed", + name="Analyst", + is_active=True, ) session.add_all([org, user]) await session.flush() diff --git a/tests/api/v1/test_organizations.py b/tests/api/v1/test_organizations.py index b4bf83a..ec27e8d 100644 --- a/tests/api/v1/test_organizations.py +++ b/tests/api/v1/test_organizations.py @@ -61,7 +61,10 @@ async def test_list_user_organizations_returns_memberships( ) -> None: async with session_factory() as session: user = User( - email="owner@example.com", hashed_password="hashed", name="Owner", is_active=True, + email="owner@example.com", + hashed_password="hashed", + name="Owner", + is_active=True, ) session.add(user) await session.flush() @@ -115,10 +118,16 @@ async def test_owner_can_add_member_to_organization( ) -> None: async with session_factory() as session: owner = User( - email="owner-add@example.com", hashed_password="hashed", name="Owner", is_active=True, + email="owner-add@example.com", + hashed_password="hashed", + name="Owner", + is_active=True, ) invitee = User( - email="new-member@example.com", hashed_password="hashed", name="Member", is_active=True, + email="new-member@example.com", + hashed_password="hashed", + name="Member", + is_active=True, ) session.add_all([owner, invitee]) await session.flush() @@ -220,10 +229,16 @@ async def test_member_role_cannot_add_users( ) -> None: async with session_factory() as session: member_user = User( - email="member@example.com", hashed_password="hashed", name="Member", is_active=True, + email="member@example.com", + hashed_password="hashed", + name="Member", + is_active=True, ) invitee = User( - email="invitee@example.com", hashed_password="hashed", name="Invitee", is_active=True, + email="invitee@example.com", + hashed_password="hashed", + name="Invitee", + is_active=True, ) session.add_all([member_user, invitee]) await session.flush() @@ -266,10 +281,16 @@ async def test_cannot_add_duplicate_member( ) -> None: async with session_factory() as session: owner = User( - email="dup-owner@example.com", hashed_password="hashed", name="Owner", is_active=True, + email="dup-owner@example.com", + hashed_password="hashed", + name="Owner", + is_active=True, ) invitee = User( - email="dup-member@example.com", hashed_password="hashed", name="Invitee", is_active=True, + email="dup-member@example.com", + hashed_password="hashed", + name="Invitee", + is_active=True, ) session.add_all([owner, invitee]) await session.flush() diff --git a/tests/services/test_activity_service.py b/tests/services/test_activity_service.py index 32bbc3c..107e2fa 100644 --- a/tests/services/test_activity_service.py +++ b/tests/services/test_activity_service.py @@ -98,7 +98,10 @@ async def test_list_activities_returns_only_current_deal(session: AsyncSession) payload={"text": "hi"}, ), Activity( - deal_id=deal_id + 1, author_id=context.user_id, type=ActivityType.SYSTEM, payload={}, + deal_id=deal_id + 1, + author_id=context.user_id, + type=ActivityType.SYSTEM, + payload={}, ), ], ) @@ -120,7 +123,10 @@ async def test_add_comment_rejects_empty_text(session: AsyncSession) -> None: with pytest.raises(ActivityValidationError): await service.add_comment( - deal_id=deal_id, author_id=context.user_id, text=" ", context=context, + deal_id=deal_id, + author_id=context.user_id, + text=" ", + context=context, ) diff --git a/tests/services/test_analytics_service.py b/tests/services/test_analytics_service.py index 9fd3c53..93512ba 100644 --- a/tests/services/test_analytics_service.py +++ b/tests/services/test_analytics_service.py @@ -39,16 +39,24 @@ async def session() -> AsyncGenerator[AsyncSession, None]: async def _seed_data(session: AsyncSession) -> tuple[int, int, int]: org = Organization(name="Analytics Org") user = User( - email="analytics@example.com", hashed_password="hashed", name="Analyst", is_active=True, + email="analytics@example.com", + hashed_password="hashed", + name="Analyst", + is_active=True, ) session.add_all([org, user]) await session.flush() member = OrganizationMember( - organization_id=org.id, user_id=user.id, role=OrganizationRole.OWNER, + organization_id=org.id, + user_id=user.id, + role=OrganizationRole.OWNER, ) contact = Contact( - organization_id=org.id, owner_id=user.id, name="Client", email="client@example.com", + organization_id=org.id, + owner_id=user.id, + name="Client", + email="client@example.com", ) session.add_all([member, contact]) await session.flush() diff --git a/tests/services/test_auth_service.py b/tests/services/test_auth_service.py index a13fa7e..5a23a58 100644 --- a/tests/services/test_auth_service.py +++ b/tests/services/test_auth_service.py @@ -50,7 +50,8 @@ def jwt_service() -> JWTService: @pytest.mark.asyncio async def test_authenticate_success( - password_hasher: PasswordHasher, jwt_service: JWTService, + password_hasher: PasswordHasher, + jwt_service: JWTService, ) -> None: hashed = password_hasher.hash("StrongPass123") user = User(email="user@example.com", hashed_password=hashed, name="Alice", is_active=True) @@ -103,7 +104,10 @@ async def test_refresh_tokens_returns_new_pair( jwt_service: JWTService, ) -> None: user = User( - email="refresh@example.com", hashed_password="hashed", name="Refresh", is_active=True, + email="refresh@example.com", + hashed_password="hashed", + name="Refresh", + is_active=True, ) user.id = 7 service = AuthService(StubUserRepository(user), password_hasher, jwt_service) @@ -121,7 +125,10 @@ async def test_refresh_tokens_rejects_access_token( jwt_service: JWTService, ) -> None: user = User( - email="refresh@example.com", hashed_password="hashed", name="Refresh", is_active=True, + email="refresh@example.com", + hashed_password="hashed", + name="Refresh", + is_active=True, ) user.id = 9 service = AuthService(StubUserRepository(user), password_hasher, jwt_service) diff --git a/tests/services/test_deal_service.py b/tests/services/test_deal_service.py index ac16a37..11d525d 100644 --- a/tests/services/test_deal_service.py +++ b/tests/services/test_deal_service.py @@ -65,7 +65,9 @@ def _make_context(org: Organization, user: User, role: OrganizationRole) -> Orga async def _persist_base( - session: AsyncSession, *, role: OrganizationRole = OrganizationRole.MANAGER, + session: AsyncSession, + *, + role: OrganizationRole = OrganizationRole.MANAGER, ) -> tuple[ OrganizationContext, Contact, diff --git a/tests/services/test_organization_service.py b/tests/services/test_organization_service.py index 55f5ffd..73bf21c 100644 --- a/tests/services/test_organization_service.py +++ b/tests/services/test_organization_service.py @@ -28,7 +28,9 @@ class StubOrganizationRepository(OrganizationRepository): self._membership = membership async def get_membership( - self, organization_id: int, user_id: int, + self, + organization_id: int, + user_id: int, ) -> OrganizationMember | None: # pragma: no cover - helper if ( self._membership @@ -40,7 +42,10 @@ class StubOrganizationRepository(OrganizationRepository): def make_membership( - role: OrganizationRole, *, organization_id: int = 1, user_id: int = 10, + role: OrganizationRole, + *, + organization_id: int = 1, + user_id: int = 10, ) -> OrganizationMember: organization = Organization(name="Acme Inc") organization.id = organization_id @@ -75,7 +80,8 @@ class MembershipRepositoryStub(OrganizationRepository): """Repository stub that can emulate duplicate checks for add_member.""" def __init__( - self, memberships: dict[tuple[int, int], OrganizationMember] | None = None, + self, + memberships: dict[tuple[int, int], OrganizationMember] | None = None, ) -> None: self._session_stub = SessionStub() super().__init__(session=cast(AsyncSession, self._session_stub)) @@ -95,7 +101,8 @@ async def test_get_context_success() -> None: service = OrganizationService(StubOrganizationRepository(membership)) context = await service.get_context( - user_id=membership.user_id, organization_id=membership.organization_id, + user_id=membership.user_id, + organization_id=membership.organization_id, ) assert context.organization_id == membership.organization_id @@ -183,7 +190,9 @@ async def test_add_member_rejects_duplicate_membership() -> None: with pytest.raises(OrganizationMemberAlreadyExistsError): await service.add_member( - context=context, user_id=duplicate_user_id, role=OrganizationRole.MANAGER, + context=context, + user_id=duplicate_user_id, + role=OrganizationRole.MANAGER, ) diff --git a/tests/services/test_task_service.py b/tests/services/test_task_service.py index 58e67a5..e667519 100644 --- a/tests/services/test_task_service.py +++ b/tests/services/test_task_service.py @@ -190,7 +190,8 @@ async def test_member_cannot_update_foreign_task(session: AsyncSession) -> None: role=OrganizationRole.MEMBER, ) member_context = OrganizationContext( - organization=context_owner.organization, membership=membership, + organization=context_owner.organization, + membership=membership, ) with pytest.raises(TaskForbiddenError):