Coverage for polar/organization/endpoints.py: 30%
136 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
1from typing import cast 1a
3from fastapi import Depends, Query, Response, status 1a
4from sqlalchemy.orm import joinedload 1a
6from polar.account.schemas import Account as AccountSchema 1a
7from polar.account.service import account as account_service 1a
8from polar.auth.models import is_anonymous, is_user 1a
9from polar.auth.scope import Scope 1a
10from polar.config import settings 1a
11from polar.email.react import render_email_template 1a
12from polar.email.schemas import OrganizationInviteEmail, OrganizationInviteProps 1a
13from polar.email.sender import enqueue_email 1a
14from polar.exceptions import ( 1a
15 NotPermitted,
16 PolarRequestValidationError,
17 ResourceNotFound,
18 Unauthorized,
19)
20from polar.kit.pagination import ListResource, Pagination, PaginationParamsQuery 1a
21from polar.models import Account, Organization 1a
22from polar.openapi import APITag 1a
23from polar.organization.repository import OrganizationReviewRepository 1a
24from polar.postgres import ( 1a
25 AsyncReadSession,
26 AsyncSession,
27 get_db_read_session,
28 get_db_session,
29)
30from polar.routing import APIRouter 1a
31from polar.user.service import user as user_service 1a
32from polar.user_organization.schemas import OrganizationMember, OrganizationMemberInvite 1a
33from polar.user_organization.service import ( 1a
34 user_organization as user_organization_service,
35)
37from . import auth, sorting 1a
38from .schemas import Organization as OrganizationSchema 1a
39from .schemas import ( 1a
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 1a
52router = APIRouter(prefix="/organizations", tags=["organizations"]) 1a
54OrganizationNotFound = { 1a
55 "description": "Organization not found.",
56 "model": ResourceNotFound.schema(),
57}
60@router.get( 1a
61 "/",
62 summary="List Organizations",
63 response_model=ListResource[OrganizationSchema],
64 tags=[APITag.public],
65)
66async def list( 1a
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( 1a
90 "/{id}",
91 summary="Get Organization",
92 response_model=OrganizationSchema,
93 responses={404: OrganizationNotFound},
94 tags=[APITag.public],
95)
96async def get( 1a
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:
105 raise ResourceNotFound()
107 return organization
110@router.post( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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) 1b
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( 1a
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( 1a
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:
225 raise ResourceNotFound()
227 if organization.account_id is None:
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( 1a
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( 1a
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):
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:
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( 1a
305 "/{id}/members",
306 response_model=ListResource[OrganizationMember],
307 tags=[APITag.private],
308)
309async def members( 1a
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( 1a
329 "/{id}/members/invite",
330 response_model=OrganizationMember,
331 tags=[APITag.private],
332)
333async def invite_member( 1a
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:
390 raise ResourceNotFound()
392 response.status_code = status.HTTP_201_CREATED
393 return OrganizationMember.model_validate(user_org)
396@router.post( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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( 1a
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:
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 )