Compare commits

..

No commits in common. "1039fba57118962f1fb86e37df618cbc67ecd899" and "5fcb574aca1a0c348e5bca2ecc4160457bc4713d" have entirely different histories.

24 changed files with 58 additions and 58 deletions

View File

@ -95,5 +95,5 @@ async def deals_funnel(
breakdowns: list[StageBreakdown] = await service.get_deal_funnel(context.organization_id) breakdowns: list[StageBreakdown] = await service.get_deal_funnel(context.organization_id)
return DealFunnelResponse( return DealFunnelResponse(
stages=[StageBreakdownModel.model_validate(item) for item in breakdowns], stages=[StageBreakdownModel.model_validate(item) for item in breakdowns]
) )

View File

@ -83,7 +83,7 @@ async def create_contact(
service: ContactService = Depends(get_contact_service), service: ContactService = Depends(get_contact_service),
) -> ContactRead: ) -> ContactRead:
data = payload.to_domain( 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: try:
contact = await service.create_contact(data, context=context) contact = await service.create_contact(data, context=context)

View File

@ -68,7 +68,7 @@ async def list_deals(
stage_value = DealStage(stage) if stage else None stage_value = DealStage(stage) if stage else None
except ValueError as exc: except ValueError as exc:
raise HTTPException( 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 ) from exc
params = DealQueryParams( params = DealQueryParams(
@ -100,7 +100,7 @@ async def create_deal(
"""Create a new deal within the current organization.""" """Create a new deal within the current organization."""
data = payload.to_domain( 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: try:
deal = await service.create_deal(data, context=context) deal = await service.create_deal(data, context=context)

View File

@ -46,7 +46,7 @@ class RedisCacheManager:
if self._client is not None: if self._client is not None:
return return
self._client = redis.from_url( 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() await self._refresh_availability()
@ -63,7 +63,7 @@ class RedisCacheManager:
async with self._lock: async with self._lock:
if self._client is None: if self._client is None:
self._client = redis.from_url( 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() await self._refresh_availability()
@ -127,7 +127,7 @@ async def read_json(client: Redis, key: str) -> Any | None:
async def write_json( 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: ) -> None:
"""Serialize data to JSON and store it with TTL using retry/backoff.""" """Serialize data to JSON and store it with TTL using retry/backoff."""
payload = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8") payload = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8")

View File

@ -17,7 +17,7 @@ class Settings(BaseSettings):
db_name: str = Field(default="test_task_crm", description="Database name") db_name: str = Field(default="test_task_crm", description="Database name")
db_user: str = Field(default="postgres", description="Database user") db_user: str = Field(default="postgres", description="Database user")
db_password: SecretStr = Field( db_password: SecretStr = Field(
default=SecretStr("postgres"), description="Database user password", default=SecretStr("postgres"), description="Database user password"
) )
database_url_override: str | None = Field( database_url_override: str | None = Field(
default=None, default=None,
@ -32,7 +32,7 @@ class Settings(BaseSettings):
redis_enabled: bool = Field(default=False, description="Toggle Redis-backed cache usage") 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") redis_url: str = Field(default="redis://localhost:6379/0", description="Redis connection URL")
analytics_cache_ttl_seconds: int = Field( 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( analytics_cache_backoff_ms: int = Field(
default=200, default=200,

View File

@ -38,12 +38,12 @@ def create_app() -> FastAPI:
application.add_middleware( application.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
"https://kitchen-crm.k1nq.tech", # "https://kitchen-crm.k1nq.tech",
"http://192.168.31.51", # "http://192.168.31.51",
"http://localhost:8000", # "http://localhost:8000",
"http://0.0.0.0:8000", # "http://0.0.0.0:8000",
"http://127.0.0.1:8000", # "http://127.0.0.1:8000",
# "*", "*", # ! TODO: Убрать
], ],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], # Разрешить все HTTP-методы allow_methods=["*"], # Разрешить все HTTP-методы

View File

@ -38,7 +38,7 @@ class User(Base):
) )
memberships = relationship( 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_contacts = relationship("Contact", back_populates="owner")
owned_deals = relationship("Deal", back_populates="owner") owned_deals = relationship("Deal", back_populates="owner")

View File

@ -41,7 +41,7 @@ class ActivityRepository:
select(Activity) select(Activity)
.join(Deal, Deal.id == Activity.deal_id) .join(Deal, Deal.id == Activity.deal_id)
.where( .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) .order_by(Activity.created_at)
) )

View File

@ -46,7 +46,7 @@ class ContactRepository:
user_id: int, user_id: int,
) -> Sequence[Contact]: ) -> Sequence[Contact]:
stmt: Select[tuple[Contact]] = select(Contact).where( stmt: Select[tuple[Contact]] = select(Contact).where(
Contact.organization_id == params.organization_id, Contact.organization_id == params.organization_id
) )
stmt = self._apply_filters(stmt, params, role, user_id) stmt = self._apply_filters(stmt, params, role, user_id)
offset = (max(params.page, 1) - 1) * params.page_size offset = (max(params.page, 1) - 1) * params.page_size
@ -63,7 +63,7 @@ class ContactRepository:
user_id: int, user_id: int,
) -> Contact | None: ) -> Contact | None:
stmt = select(Contact).where( 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) result = await self._session.scalars(stmt)
return result.first() return result.first()

View File

@ -148,7 +148,7 @@ class DealRepository:
return stmt return stmt
def _apply_ordering( def _apply_ordering(
self, stmt: Select[tuple[Deal]], params: DealQueryParams, self, stmt: Select[tuple[Deal]], params: DealQueryParams
) -> Select[tuple[Deal]]: ) -> Select[tuple[Deal]]:
column = ORDERABLE_COLUMNS.get(params.order_by or "created_at", Deal.created_at) column = ORDERABLE_COLUMNS.get(params.order_by or "created_at", Deal.created_at)
order_func = desc if params.order_desc else asc order_func = desc if params.order_desc else asc

View File

@ -107,7 +107,7 @@ class TaskRepository:
return task return task
def _apply_filters( def _apply_filters(
self, stmt: Select[tuple[Task]], params: TaskQueryParams, self, stmt: Select[tuple[Task]], params: TaskQueryParams
) -> Select[tuple[Task]]: ) -> Select[tuple[Task]]:
if params.deal_id is not None: if params.deal_id is not None:
stmt = stmt.where(Task.deal_id == params.deal_id) stmt = stmt.where(Task.deal_id == params.deal_id)

View File

@ -169,7 +169,7 @@ class AnalyticsService:
return _deserialize_summary(payload) return _deserialize_summary(payload)
async def _store_summary_cache( async def _store_summary_cache(
self, organization_id: int, days: int, summary: DealSummary, self, organization_id: int, days: int, summary: DealSummary
) -> None: ) -> None:
if not self._is_cache_enabled() or self._cache is None: if not self._is_cache_enabled() or self._cache is None:
return return
@ -187,7 +187,7 @@ class AnalyticsService:
return _deserialize_funnel(payload) return _deserialize_funnel(payload)
async def _store_funnel_cache( async def _store_funnel_cache(
self, organization_id: int, breakdowns: list[StageBreakdown], self, organization_id: int, breakdowns: list[StageBreakdown]
) -> None: ) -> None:
if not self._is_cache_enabled() or self._cache is None: if not self._is_cache_enabled() or self._cache is None:
return return
@ -321,7 +321,7 @@ def _deserialize_funnel(payload: Any) -> list[StageBreakdown] | None:
async def invalidate_analytics_cache( async def invalidate_analytics_cache(
cache: Redis | None, organization_id: int, backoff_ms: int, cache: Redis | None, organization_id: int, backoff_ms: int
) -> None: ) -> None:
"""Remove cached analytics payloads for the organization.""" """Remove cached analytics payloads for the organization."""

View File

@ -80,7 +80,7 @@ class ContactService:
) )
try: try:
return await self._repository.list( 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: except ContactAccessError as exc:
raise ContactForbiddenError(str(exc)) from exc raise ContactForbiddenError(str(exc)) from exc
@ -126,7 +126,7 @@ class ContactService:
return contact return contact
try: try:
return await self._repository.update( 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: except ContactAccessError as exc:
raise ContactForbiddenError(str(exc)) from exc raise ContactForbiddenError(str(exc)) from exc

View File

@ -79,7 +79,7 @@ class DealService:
await self._ensure_contact_in_organization(data.contact_id, context.organization_id) 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) deal = await self._repository.create(data=data, role=context.role, user_id=context.user_id)
await invalidate_analytics_cache( await invalidate_analytics_cache(
self._cache, context.organization_id, self._cache_backoff_ms, self._cache, context.organization_id, self._cache_backoff_ms
) )
return deal return deal
@ -120,7 +120,7 @@ class DealService:
return deal return deal
updated = await self._repository.update( 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( await self._log_activities(
deal_id=deal.id, deal_id=deal.id,
@ -128,7 +128,7 @@ class DealService:
activities=[activity for activity in [stage_activity, status_activity] if activity], activities=[activity for activity in [stage_activity, status_activity] if activity],
) )
await invalidate_analytics_cache( await invalidate_analytics_cache(
self._cache, context.organization_id, self._cache_backoff_ms, self._cache, context.organization_id, self._cache_backoff_ms
) )
return updated return updated
@ -150,7 +150,7 @@ class DealService:
return return
for activity_type, payload in entries: for activity_type, payload in entries:
activity = Activity( 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) self._repository.session.add(activity)
await self._repository.session.flush() await self._repository.session.flush()
@ -160,7 +160,7 @@ class DealService:
raise DealOrganizationMismatchError("Operation targets a different organization") raise DealOrganizationMismatchError("Operation targets a different organization")
async def _ensure_contact_in_organization( async def _ensure_contact_in_organization(
self, contact_id: int, organization_id: int, self, contact_id: int, organization_id: int
) -> Contact: ) -> Contact:
contact = await self._repository.session.get(Contact, contact_id) contact = await self._repository.session.get(Contact, contact_id)
if contact is None or contact.organization_id != organization_id: if contact is None or contact.organization_id != organization_id:
@ -185,5 +185,5 @@ class DealService:
effective_amount = updates.amount if updates.amount is not None else deal.amount effective_amount = updates.amount if updates.amount is not None else deal.amount
if effective_amount is None or Decimal(effective_amount) <= Decimal("0"): if effective_amount is None or Decimal(effective_amount) <= Decimal("0"):
raise DealStatusValidationError( raise DealStatusValidationError(
"Amount must be greater than zero to mark a deal as won", "Amount must be greater than zero to mark a deal as won"
) )

View File

@ -56,7 +56,7 @@ class OrganizationService:
self._repository = repository self._repository = repository
async def get_context( async def get_context(
self, *, user_id: int, organization_id: int | None, self, *, user_id: int, organization_id: int | None
) -> OrganizationContext: ) -> OrganizationContext:
"""Resolve request context ensuring the user belongs to the given organization.""" """Resolve request context ensuring the user belongs to the given organization."""
@ -70,7 +70,7 @@ class OrganizationService:
return OrganizationContext(organization=membership.organization, membership=membership) return OrganizationContext(organization=membership.organization, membership=membership)
def ensure_entity_in_context( def ensure_entity_in_context(
self, *, entity_organization_id: int, context: OrganizationContext, self, *, entity_organization_id: int, context: OrganizationContext
) -> None: ) -> None:
"""Make sure a resource belongs to the current organization.""" """Make sure a resource belongs to the current organization."""

View File

@ -28,7 +28,7 @@ class Scenario:
async def prepare_scenario(session_factory: async_sessionmaker[AsyncSession]) -> Scenario: async def prepare_scenario(session_factory: async_sessionmaker[AsyncSession]) -> Scenario:
async with session_factory() as session: async with session_factory() as session:
user = User( 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") org = Organization(name="Acme LLC")
session.add_all([user, org]) session.add_all([user, org])

View File

@ -32,7 +32,7 @@ async def prepare_analytics_scenario(
async with session_factory() as session: async with session_factory() as session:
org = Organization(name="Analytics Org") org = Organization(name="Analytics Org")
user = User( 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]) session.add_all([org, user])
await session.flush() await session.flush()

View File

@ -61,7 +61,7 @@ async def test_list_user_organizations_returns_memberships(
) -> None: ) -> None:
async with session_factory() as session: async with session_factory() as session:
user = User( 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) session.add(user)
await session.flush() await session.flush()
@ -115,10 +115,10 @@ async def test_owner_can_add_member_to_organization(
) -> None: ) -> None:
async with session_factory() as session: async with session_factory() as session:
owner = User( 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( 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]) session.add_all([owner, invitee])
await session.flush() await session.flush()
@ -220,10 +220,10 @@ async def test_member_role_cannot_add_users(
) -> None: ) -> None:
async with session_factory() as session: async with session_factory() as session:
member_user = User( 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( 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]) session.add_all([member_user, invitee])
await session.flush() await session.flush()
@ -266,10 +266,10 @@ async def test_cannot_add_duplicate_member(
) -> None: ) -> None:
async with session_factory() as session: async with session_factory() as session:
owner = User( 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( 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]) session.add_all([owner, invitee])
await session.flush() await session.flush()

View File

@ -98,7 +98,7 @@ async def test_list_activities_returns_only_current_deal(session: AsyncSession)
payload={"text": "hi"}, payload={"text": "hi"},
), ),
Activity( 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 +120,7 @@ async def test_add_comment_rejects_empty_text(session: AsyncSession) -> None:
with pytest.raises(ActivityValidationError): with pytest.raises(ActivityValidationError):
await service.add_comment( 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
) )

View File

@ -39,16 +39,16 @@ async def session() -> AsyncGenerator[AsyncSession, None]:
async def _seed_data(session: AsyncSession) -> tuple[int, int, int]: async def _seed_data(session: AsyncSession) -> tuple[int, int, int]:
org = Organization(name="Analytics Org") org = Organization(name="Analytics Org")
user = User( 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]) session.add_all([org, user])
await session.flush() await session.flush()
member = OrganizationMember( 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( 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]) session.add_all([member, contact])
await session.flush() await session.flush()

View File

@ -50,7 +50,7 @@ def jwt_service() -> JWTService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_success( async def test_authenticate_success(
password_hasher: PasswordHasher, jwt_service: JWTService, password_hasher: PasswordHasher, jwt_service: JWTService
) -> None: ) -> None:
hashed = password_hasher.hash("StrongPass123") hashed = password_hasher.hash("StrongPass123")
user = User(email="user@example.com", hashed_password=hashed, name="Alice", is_active=True) user = User(email="user@example.com", hashed_password=hashed, name="Alice", is_active=True)
@ -103,7 +103,7 @@ async def test_refresh_tokens_returns_new_pair(
jwt_service: JWTService, jwt_service: JWTService,
) -> None: ) -> None:
user = User( 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 user.id = 7
service = AuthService(StubUserRepository(user), password_hasher, jwt_service) service = AuthService(StubUserRepository(user), password_hasher, jwt_service)
@ -121,7 +121,7 @@ async def test_refresh_tokens_rejects_access_token(
jwt_service: JWTService, jwt_service: JWTService,
) -> None: ) -> None:
user = User( 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 user.id = 9
service = AuthService(StubUserRepository(user), password_hasher, jwt_service) service = AuthService(StubUserRepository(user), password_hasher, jwt_service)

View File

@ -65,7 +65,7 @@ def _make_context(org: Organization, user: User, role: OrganizationRole) -> Orga
async def _persist_base( async def _persist_base(
session: AsyncSession, *, role: OrganizationRole = OrganizationRole.MANAGER, session: AsyncSession, *, role: OrganizationRole = OrganizationRole.MANAGER
) -> tuple[ ) -> tuple[
OrganizationContext, OrganizationContext,
Contact, Contact,

View File

@ -28,7 +28,7 @@ class StubOrganizationRepository(OrganizationRepository):
self._membership = membership self._membership = membership
async def get_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 ) -> OrganizationMember | None: # pragma: no cover - helper
if ( if (
self._membership self._membership
@ -40,7 +40,7 @@ class StubOrganizationRepository(OrganizationRepository):
def make_membership( def make_membership(
role: OrganizationRole, *, organization_id: int = 1, user_id: int = 10, role: OrganizationRole, *, organization_id: int = 1, user_id: int = 10
) -> OrganizationMember: ) -> OrganizationMember:
organization = Organization(name="Acme Inc") organization = Organization(name="Acme Inc")
organization.id = organization_id organization.id = organization_id
@ -75,7 +75,7 @@ class MembershipRepositoryStub(OrganizationRepository):
"""Repository stub that can emulate duplicate checks for add_member.""" """Repository stub that can emulate duplicate checks for add_member."""
def __init__( def __init__(
self, memberships: dict[tuple[int, int], OrganizationMember] | None = None, self, memberships: dict[tuple[int, int], OrganizationMember] | None = None
) -> None: ) -> None:
self._session_stub = SessionStub() self._session_stub = SessionStub()
super().__init__(session=cast(AsyncSession, self._session_stub)) super().__init__(session=cast(AsyncSession, self._session_stub))
@ -95,7 +95,7 @@ async def test_get_context_success() -> None:
service = OrganizationService(StubOrganizationRepository(membership)) service = OrganizationService(StubOrganizationRepository(membership))
context = await service.get_context( 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 assert context.organization_id == membership.organization_id
@ -183,7 +183,7 @@ async def test_add_member_rejects_duplicate_membership() -> None:
with pytest.raises(OrganizationMemberAlreadyExistsError): with pytest.raises(OrganizationMemberAlreadyExistsError):
await service.add_member( await service.add_member(
context=context, user_id=duplicate_user_id, role=OrganizationRole.MANAGER, context=context, user_id=duplicate_user_id, role=OrganizationRole.MANAGER
) )

View File

@ -190,7 +190,7 @@ async def test_member_cannot_update_foreign_task(session: AsyncSession) -> None:
role=OrganizationRole.MEMBER, role=OrganizationRole.MEMBER,
) )
member_context = OrganizationContext( member_context = OrganizationContext(
organization=context_owner.organization, membership=membership, organization=context_owner.organization, membership=membership
) )
with pytest.raises(TaskForbiddenError): with pytest.raises(TaskForbiddenError):