Coverage for polar/organization/endpoints.py: 30%

136 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 16:17 +0000

1from typing import cast 1a

2 

3from fastapi import Depends, Query, Response, status 1a

4from sqlalchemy.orm import joinedload 1a

5 

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) 

36 

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

51 

52router = APIRouter(prefix="/organizations", tags=["organizations"]) 1a

53 

54OrganizationNotFound = { 1a

55 "description": "Organization not found.", 

56 "model": ResourceNotFound.schema(), 

57} 

58 

59 

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 ) 

81 

82 return ListResource.from_paginated_results( 

83 [OrganizationSchema.model_validate(result) for result in results], 

84 count, 

85 pagination, 

86 ) 

87 

88 

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) 

103 

104 if organization is None: 

105 raise ResourceNotFound() 

106 

107 return organization 

108 

109 

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) 

125 

126 

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) 

149 

150 if organization is None: 

151 raise ResourceNotFound() 

152 

153 return await organization_service.update(session, organization, organization_update) 

154 

155 

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. 

176 

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. 

180 

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

185 

186 if organization is None: 

187 raise ResourceNotFound() 

188 

189 result = await organization_service.request_deletion( 

190 session, auth_subject, organization 

191 ) 

192 

193 return OrganizationDeletionResponse( 

194 deleted=result.can_delete_immediately, 

195 requires_support=not result.can_delete_immediately, 

196 blocked_reasons=result.blocked_reasons, 

197 ) 

198 

199 

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) 

223 

224 if organization is None: 

225 raise ResourceNotFound() 

226 

227 if organization.account_id is None: 

228 raise ResourceNotFound() 

229 

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

236 

237 account = await account_service.get(session, auth_subject, organization.account_id) 

238 if account is None: 

239 raise ResourceNotFound() 

240 

241 return account 

242 

243 

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 ) 

286 

287 if organization is None: 

288 raise ResourceNotFound() 

289 

290 payment_status = await organization_service.get_payment_status( 

291 session, organization, account_verification_only=account_verification_only 

292 ) 

293 

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 ) 

302 

303 

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) 

316 

317 if organization is None: 

318 raise ResourceNotFound() 

319 

320 members = await user_organization_service.list_by_org(session, id) 

321 

322 return ListResource( 

323 items=[OrganizationMember.model_validate(m) for m in members], 

324 pagination=Pagination(total_count=len(members), max_page=1), 

325 ) 

326 

327 

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) 

342 

343 if organization is None: 

344 raise ResourceNotFound() 

345 

346 # Get or create user by email 

347 user, _ = await user_service.get_by_email_or_create(session, invite_body.email) 

348 

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) 

356 

357 # Add user to organization 

358 await organization_service.add_user(session, organization, user) 

359 

360 # Get the inviter's email (from auth subject) 

361 inviter_email = auth_subject.subject.email 

362 

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 ) 

377 

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 ) 

383 

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 ) 

388 

389 if user_org is None: 

390 raise ResourceNotFound() 

391 

392 response.status_code = status.HTTP_201_CREATED 

393 return OrganizationMember.model_validate(user_org) 

394 

395 

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) 

413 

414 if organization is None: 

415 raise ResourceNotFound() 

416 

417 # Run AI validation and store results 

418 result = await organization_service.validate_with_ai(session, organization) 

419 

420 return OrganizationReviewStatus( 

421 verdict=result.verdict, # type: ignore[arg-type] 

422 reason=result.reason, 

423 ) 

424 

425 

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) 

445 

446 if organization is None: 

447 raise ResourceNotFound() 

448 

449 try: 

450 result = await organization_service.submit_appeal( 

451 session, organization, appeal_request.reason 

452 ) 

453 

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 ) 

470 

471 

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) 

489 

490 if organization is None: 

491 raise ResourceNotFound() 

492 

493 review_repository = OrganizationReviewRepository.from_session(session) 

494 review = await review_repository.get_by_organization(organization.id) 

495 

496 if review is None: 

497 return OrganizationReviewStatus() 

498 

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 )