Compare commits
2 Commits
5fcb574aca
...
1039fba571
| Author | SHA1 | Date |
|---|---|---|
|
|
1039fba571 | |
|
|
ed6c656963 |
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
12
app/main.py
12
app/main.py
|
|
@ -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",
|
||||
"*", # ! TODO: Убрать
|
||||
"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",
|
||||
# "*",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"], # Разрешить все HTTP-методы
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue