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)
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),
) -> 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)

View File

@ -68,7 +68,7 @@ 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 +100,7 @@ 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)

View File

@ -46,7 +46,7 @@ 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 +63,7 @@ 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 +127,7 @@ 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")

View File

@ -17,7 +17,7 @@ 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 +32,7 @@ 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,

View File

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

View File

@ -38,7 +38,7 @@ 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")

View File

@ -41,7 +41,7 @@ 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)
)

View File

@ -46,7 +46,7 @@ class ContactRepository:
user_id: int,
) -> Sequence[Contact]:
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)
offset = (max(params.page, 1) - 1) * params.page_size
@ -63,7 +63,7 @@ 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()

View File

@ -148,7 +148,7 @@ 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

View File

@ -107,7 +107,7 @@ 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)

View File

@ -169,7 +169,7 @@ 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 +187,7 @@ 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 +321,7 @@ 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."""

View File

@ -80,7 +80,7 @@ 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 +126,7 @@ 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

View File

@ -79,7 +79,7 @@ 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 +120,7 @@ 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 +128,7 @@ 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 +150,7 @@ 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 +160,7 @@ 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:
@ -185,5 +185,5 @@ class DealService:
effective_amount = updates.amount if updates.amount is not None else deal.amount
if effective_amount is None or Decimal(effective_amount) <= Decimal("0"):
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
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 +70,7 @@ 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."""

View File

@ -28,7 +28,7 @@ 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])

View File

@ -32,7 +32,7 @@ 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()

View File

@ -61,7 +61,7 @@ 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 +115,10 @@ 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 +220,10 @@ 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 +266,10 @@ 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()

View File

@ -98,7 +98,7 @@ 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 +120,7 @@ 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
)

View File

@ -39,16 +39,16 @@ 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()

View File

@ -50,7 +50,7 @@ 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 +103,7 @@ 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 +121,7 @@ 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)

View File

@ -65,7 +65,7 @@ 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,

View File

@ -28,7 +28,7 @@ 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 +40,7 @@ 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 +75,7 @@ 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 +95,7 @@ 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 +183,7 @@ 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
)

View File

@ -190,7 +190,7 @@ 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):