Coverage for polar/organization/endpoints.py: 81%
136 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1from typing import cast
3from fastapi import Depends, Query, Response, status
4from sqlalchemy.orm import joinedload
6from polar.account.schemas import Account as AccountSchema
7from polar.account.service import account as account_service
8from polar.auth.models import is_anonymous, is_user
9from polar.auth.scope import Scope
10from polar.config import settings
11from polar.email.react import render_email_template
12from polar.email.schemas import OrganizationInviteEmail, OrganizationInviteProps
13from polar.email.sender import enqueue_email
14from polar.exceptions import (
15 NotPermitted,
16 PolarRequestValidationError,
17 ResourceNotFound,
18 Unauthorized,
19)
20from polar.kit.pagination import ListResource, Pagination, PaginationParamsQuery
21from polar.models import Account, Organization
22from polar.openapi import APITag
23from polar.organization.repository import OrganizationReviewRepository
24from polar.postgres import (
25 AsyncReadSession,
26 AsyncSession,
27 get_db_read_session,
28 get_db_session,
29)
30from polar.routing import APIRouter
31from polar.user.service import user as user_service
32from polar.user_organization.schemas import OrganizationMember, OrganizationMemberInvite
33from polar.user_organization.service import (
34 user_organization as user_organization_service,
35)
37from . import auth, sorting
38from .schemas import Organization as OrganizationSchema
39from .schemas import (
40 OrganizationAppealRequest,
41 OrganizationAppealResponse,
42 OrganizationCreate,
43 OrganizationDeletionResponse,
44 OrganizationID,
45 OrganizationPaymentStatus,
46 OrganizationPaymentStep,
47 OrganizationReviewStatus,
48 OrganizationUpdate,
49)
50from .service import organization as organization_service
52router = APIRouter(prefix="/organizations", tags=["organizations"])
54OrganizationNotFound = {
55 "description": "Organization not found.",
56 "model": ResourceNotFound.schema(),
57}
60@router.get(
61 "/",
62 summary="List Organizations",
63 response_model=ListResource[OrganizationSchema],
64 tags=[APITag.public],
65)
66async def list(
67 auth_subject: auth.OrganizationsRead,
68 pagination: PaginationParamsQuery,
69 sorting: sorting.ListSorting,
70 slug: str | None = Query(None, description="Filter by slug."),
71 session: AsyncReadSession = Depends(get_db_read_session),
72) -> ListResource[OrganizationSchema]:
73 """List organizations."""
74 results, count = await organization_service.list(
75 session,
76 auth_subject,
77 slug=slug,
78 pagination=pagination,
79 sorting=sorting,
80 )
82 return ListResource.from_paginated_results(
83 [OrganizationSchema.model_validate(result) for result in results],
84 count,
85 pagination,
86 )
89@router.get(
90 "/{id}",
91 summary="Get Organization",
92 response_model=OrganizationSchema,
93 responses={404: OrganizationNotFound},
94 tags=[APITag.public],
95)
96async def get(
97 id: OrganizationID,
98 auth_subject: auth.OrganizationsRead,
99 session: AsyncReadSession = Depends(get_db_read_session),
100) -> Organization:
101 """Get an organization by ID."""
102 organization = await organization_service.get(session, auth_subject, id)
104 if organization is None: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 raise ResourceNotFound()
107 return organization
110@router.post(
111 "/",
112 response_model=OrganizationSchema,
113 status_code=201,
114 summary="Create Organization",
115 responses={201: {"description": "Organization created."}},
116 tags=[APITag.public],
117)
118async def create(
119 organization_create: OrganizationCreate,
120 auth_subject: auth.OrganizationsCreate,
121 session: AsyncSession = Depends(get_db_session),
122) -> Organization:
123 """Create an organization."""
124 return await organization_service.create(session, organization_create, auth_subject)
127@router.patch(
128 "/{id}",
129 response_model=OrganizationSchema,
130 summary="Update Organization",
131 responses={
132 200: {"description": "Organization updated."},
133 403: {
134 "description": "You don't have the permission to update this organization.",
135 "model": NotPermitted.schema(),
136 },
137 404: OrganizationNotFound,
138 },
139 tags=[APITag.public],
140)
141async def update(
142 id: OrganizationID,
143 organization_update: OrganizationUpdate,
144 auth_subject: auth.OrganizationsWrite,
145 session: AsyncSession = Depends(get_db_session),
146) -> Organization:
147 """Update an organization."""
148 organization = await organization_service.get(session, auth_subject, id)
150 if organization is None:
151 raise ResourceNotFound()
153 return await organization_service.update(session, organization, organization_update)
156@router.delete(
157 "/{id}",
158 response_model=OrganizationDeletionResponse,
159 summary="Delete Organization",
160 responses={
161 200: {"description": "Organization deleted or deletion request submitted."},
162 403: {
163 "description": "You don't have the permission to delete this organization.",
164 "model": NotPermitted.schema(),
165 },
166 404: OrganizationNotFound,
167 },
168 tags=[APITag.private],
169)
170async def delete(
171 id: OrganizationID,
172 auth_subject: auth.OrganizationsWriteUser,
173 session: AsyncSession = Depends(get_db_session),
174) -> OrganizationDeletionResponse:
175 """Request deletion of an organization.
177 If the organization has no orders or active subscriptions, it will be
178 immediately soft-deleted. If it has an account, the Stripe account will
179 be deleted first.
181 If deletion cannot proceed immediately (has orders, subscriptions, or
182 Stripe deletion fails), a support ticket will be created for manual handling.
183 """
184 organization = await organization_service.get(session, auth_subject, id)
186 if organization is None:
187 raise ResourceNotFound()
189 result = await organization_service.request_deletion(
190 session, auth_subject, organization
191 )
193 return OrganizationDeletionResponse(
194 deleted=result.can_delete_immediately,
195 requires_support=not result.can_delete_immediately,
196 blocked_reasons=result.blocked_reasons,
197 )
200@router.get(
201 "/{id}/account",
202 response_model=AccountSchema,
203 summary="Get Organization Account",
204 responses={
205 403: {
206 "description": "User is not the admin of the account.",
207 "model": NotPermitted.schema(),
208 },
209 404: {
210 "description": "Organization not found or account not set.",
211 "model": ResourceNotFound.schema(),
212 },
213 },
214 tags=[APITag.private],
215)
216async def get_account(
217 id: OrganizationID,
218 auth_subject: auth.OrganizationsRead,
219 session: AsyncReadSession = Depends(get_db_read_session),
220) -> Account:
221 """Get the account for an organization."""
222 organization = await organization_service.get(session, auth_subject, id)
224 if organization is None: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 raise ResourceNotFound()
227 if organization.account_id is None: 227 ↛ 230line 227 didn't jump to line 230 because the condition on line 227 was always true
228 raise ResourceNotFound()
230 if is_user(auth_subject):
231 user = auth_subject.subject
232 if not await account_service.is_user_admin(
233 session, organization.account_id, user
234 ):
235 raise NotPermitted("You are not the admin of this account")
237 account = await account_service.get(session, auth_subject, organization.account_id)
238 if account is None:
239 raise ResourceNotFound()
241 return account
244@router.get(
245 "/{id}/payment-status",
246 response_model=OrganizationPaymentStatus,
247 tags=[APITag.private],
248 summary="Get Organization Payment Status",
249 responses={404: OrganizationNotFound},
250)
251async def get_payment_status(
252 id: OrganizationID,
253 auth_subject: auth.OrganizationsReadOrAnonymous,
254 session: AsyncReadSession = Depends(get_db_read_session),
255 account_verification_only: bool = Query(
256 False,
257 description="Only perform account verification checks, skip product and integration checks",
258 ),
259) -> OrganizationPaymentStatus:
260 """Get payment status and onboarding steps for an organization."""
261 # Handle authentication based on account_verification_only flag
262 if is_anonymous(auth_subject) and not account_verification_only:
263 raise Unauthorized()
264 elif is_anonymous(auth_subject):
265 organization = await organization_service.get_anonymous(
266 session,
267 id,
268 options=(joinedload(Organization.account).joinedload(Account.admin),),
269 )
270 else:
271 # For authenticated users, check proper scopes (need at least one of these)
272 required_scopes = {
273 Scope.web_read,
274 Scope.web_write,
275 Scope.organizations_read,
276 Scope.organizations_write,
277 }
278 if not (auth_subject.scopes & required_scopes): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise ResourceNotFound()
280 organization = await organization_service.get(
281 session,
282 cast(auth.OrganizationsRead, auth_subject),
283 id,
284 options=(joinedload(Organization.account).joinedload(Account.admin),),
285 )
287 if organization is None: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 raise ResourceNotFound()
290 payment_status = await organization_service.get_payment_status(
291 session, organization, account_verification_only=account_verification_only
292 )
294 return OrganizationPaymentStatus(
295 payment_ready=payment_status.payment_ready,
296 steps=[
297 OrganizationPaymentStep(**step.model_dump())
298 for step in payment_status.steps
299 ],
300 organization_status=payment_status.organization_status,
301 )
304@router.get(
305 "/{id}/members",
306 response_model=ListResource[OrganizationMember],
307 tags=[APITag.private],
308)
309async def members(
310 id: OrganizationID,
311 auth_subject: auth.OrganizationsRead,
312 session: AsyncReadSession = Depends(get_db_read_session),
313) -> ListResource[OrganizationMember]:
314 """List members in an organization."""
315 organization = await organization_service.get(session, auth_subject, id)
317 if organization is None:
318 raise ResourceNotFound()
320 members = await user_organization_service.list_by_org(session, id)
322 return ListResource(
323 items=[OrganizationMember.model_validate(m) for m in members],
324 pagination=Pagination(total_count=len(members), max_page=1),
325 )
328@router.post(
329 "/{id}/members/invite",
330 response_model=OrganizationMember,
331 tags=[APITag.private],
332)
333async def invite_member(
334 id: OrganizationID,
335 invite_body: OrganizationMemberInvite,
336 auth_subject: auth.OrganizationsWrite,
337 response: Response,
338 session: AsyncSession = Depends(get_db_session),
339) -> OrganizationMember:
340 """Invite a user to join an organization."""
341 organization = await organization_service.get(session, auth_subject, id)
343 if organization is None:
344 raise ResourceNotFound()
346 # Get or create user by email
347 user, _ = await user_service.get_by_email_or_create(session, invite_body.email)
349 # Check if user is already member of organization
350 user_org = await user_organization_service.get_by_user_and_org(
351 session, user.id, organization.id
352 )
353 if user_org is not None:
354 response.status_code = status.HTTP_200_OK
355 return OrganizationMember.model_validate(user_org)
357 # Add user to organization
358 await organization_service.add_user(session, organization, user)
360 # Get the inviter's email (from auth subject)
361 inviter_email = auth_subject.subject.email
363 # Send invitation email
364 email = invite_body.email
365 body = render_email_template(
366 OrganizationInviteEmail(
367 props=OrganizationInviteProps(
368 email=email,
369 organization_name=organization.name,
370 inviter_email=inviter_email or "",
371 invite_url=settings.generate_frontend_url(
372 f"/dashboard/{organization.slug}"
373 ),
374 )
375 )
376 )
378 enqueue_email(
379 to_email_addr=email,
380 subject=f"You've been invited to {organization.name} on Polar",
381 html_content=body,
382 )
384 # Get the user organization relationship to return
385 user_org = await user_organization_service.get_by_user_and_org(
386 session, user.id, organization.id
387 )
389 if user_org is None: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 raise ResourceNotFound()
392 response.status_code = status.HTTP_201_CREATED
393 return OrganizationMember.model_validate(user_org)
396@router.post(
397 "/{id}/ai-validation",
398 response_model=OrganizationReviewStatus,
399 summary="Validate Organization Details with AI",
400 responses={
401 200: {"description": "Organization validated with AI."},
402 404: OrganizationNotFound,
403 },
404 tags=[APITag.private],
405)
406async def validate_with_ai(
407 id: OrganizationID,
408 auth_subject: auth.OrganizationsWrite,
409 session: AsyncSession = Depends(get_db_session),
410) -> OrganizationReviewStatus:
411 """Validate organization details using AI compliance check."""
412 organization = await organization_service.get(session, auth_subject, id)
414 if organization is None:
415 raise ResourceNotFound()
417 # Run AI validation and store results
418 result = await organization_service.validate_with_ai(session, organization)
420 return OrganizationReviewStatus(
421 verdict=result.verdict, # type: ignore[arg-type]
422 reason=result.reason,
423 )
426@router.post(
427 "/{id}/appeal",
428 response_model=OrganizationAppealResponse,
429 summary="Submit Appeal for Organization Review",
430 responses={
431 200: {"description": "Appeal submitted successfully."},
432 404: OrganizationNotFound,
433 400: {"description": "Invalid appeal request."},
434 },
435 tags=[APITag.private],
436)
437async def submit_appeal(
438 id: OrganizationID,
439 appeal_request: OrganizationAppealRequest,
440 auth_subject: auth.OrganizationsWrite,
441 session: AsyncSession = Depends(get_db_session),
442) -> OrganizationAppealResponse:
443 """Submit an appeal for organization review after AI validation failure."""
444 organization = await organization_service.get(session, auth_subject, id)
446 if organization is None:
447 raise ResourceNotFound()
449 try:
450 result = await organization_service.submit_appeal(
451 session, organization, appeal_request.reason
452 )
454 return OrganizationAppealResponse(
455 success=True,
456 message="Appeal submitted successfully. Our team will review your case.",
457 appeal_submitted_at=result.appeal_submitted_at, # type: ignore[arg-type]
458 )
459 except ValueError as e:
460 raise PolarRequestValidationError(
461 [
462 {
463 "type": "value_error",
464 "loc": ("body", "reason"),
465 "msg": e.args[0],
466 "input": appeal_request.reason,
467 }
468 ]
469 )
472@router.get(
473 "/{id}/review-status",
474 response_model=OrganizationReviewStatus,
475 summary="Get Organization Review Status",
476 responses={
477 200: {"description": "Organization review status retrieved."},
478 404: OrganizationNotFound,
479 },
480 tags=[APITag.private],
481)
482async def get_review_status(
483 id: OrganizationID,
484 auth_subject: auth.OrganizationsRead,
485 session: AsyncReadSession = Depends(get_db_read_session),
486) -> OrganizationReviewStatus:
487 """Get the current review status and appeal information for an organization."""
488 organization = await organization_service.get(session, auth_subject, id)
490 if organization is None: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true
491 raise ResourceNotFound()
493 review_repository = OrganizationReviewRepository.from_session(session)
494 review = await review_repository.get_by_organization(organization.id)
496 if review is None:
497 return OrganizationReviewStatus()
499 return OrganizationReviewStatus(
500 verdict=review.verdict, # type: ignore[arg-type]
501 reason=review.reason,
502 appeal_submitted_at=review.appeal_submitted_at,
503 appeal_reason=review.appeal_reason,
504 appeal_decision=review.appeal_decision,
505 appeal_reviewed_at=review.appeal_reviewed_at,
506 )