diff options
| author | Greg Stupp | 2026-09-24 22:00:58 +0000 |
|---|---|---|
| committer | Greg Stupp | 2026-09-24 22:00:58 +0000 |
| commit | a5e1f9d5fe8b1d4e66c240a69eff34a8d0e8dff7 (patch) | |
| tree | 61d38c3796c1e758a344edec7ce52bcb6ee64f36 /jb/views | |
| parent | 6249139ee928b954e74ac42df81211f1a8094b53 (diff) | |
| parent | be986ab84f7b12c211f7675071d4deae1bf2bd03 (diff) | |
| download | amt-jb-a5e1f9d5fe8b1d4e66c240a69eff34a8d0e8dff7.tar.gz amt-jb-a5e1f9d5fe8b1d4e66c240a69eff34a8d0e8dff7.zip | |
Merges pull request #5
wallet views
Diffstat (limited to 'jb/views')
| -rw-r--r-- | jb/views/auth.py | 102 | ||||
| -rw-r--r-- | jb/views/utils.py | 20 | ||||
| -rw-r--r-- | jb/views/wallet.py | 132 |
3 files changed, 233 insertions, 21 deletions
diff --git a/jb/views/auth.py b/jb/views/auth.py index cc1224f..ba1a6f8 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -2,7 +2,16 @@ import secrets from typing import Annotated from urllib.parse import urlencode -from fastapi import APIRouter, Depends, Header, HTTPException, Response, status +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + Header, + HTTPException, + Request, + Response, + status, +) from fastapi.responses import HTMLResponse, RedirectResponse from jb.api.auth import ( @@ -26,6 +35,7 @@ from jb.managers.email_manager import ( send_login_email, ) from jb.managers.gr_api import GRApiManager +from jb.managers.thl import create_paypal_cashout_method_if_not_exists from jb.models.auth import ( AccountLogin, AmtAccountLink, @@ -33,10 +43,24 @@ from jb.models.auth import ( User, ) from jb.settings import render_base_html +from jb.views.utils import get_client_ip auth_router = APIRouter(prefix="/auth", tags=["Auth"]) +def try_create_paypal_cashout_method_if_not_exists( + product_user_id: str, email: str, client_ip: str +) -> None: + try: + create_paypal_cashout_method_if_not_exists( + product_user_id=product_user_id, + email=email, + client_ip=client_ip, + ) + except Exception: + LOG.exception("Failed to create PayPal cashout method for %s", product_user_id) + + def authenticate_invite_amt_account_link( authorization: Annotated[str | None, Header()] = None, ) -> None: @@ -76,6 +100,8 @@ def request_magic_link(body: AccountLogin) -> dict[str, str]: @auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) def magic_link_landing_page( + request: Request, + background_tasks: BackgroundTasks, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> Response: @@ -87,7 +113,14 @@ def magic_link_landing_page( detail="token is required", ) response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - _exchange_magic_link(token, response, gr_api) + client_ip = get_client_ip(request) + _exchange_magic_link( + token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) return response return HTMLResponse( render_base_html(), @@ -101,17 +134,41 @@ def magic_link_landing_page( @auth_router.post("/magic-link/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_magic_link( + request: Request, + background_tasks: BackgroundTasks, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" - _exchange_magic_link(body.token, response, gr_api) + client_ip = get_client_ip(request) + _exchange_magic_link( + body.token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) -def _exchange_magic_link(token: str, response: Response, gr_api: GRApiManager) -> None: +def _exchange_magic_link( + token: str, + response: Response, + gr_api: GRApiManager, + client_ip: str, + background_tasks: BackgroundTasks, +) -> None: user_email = consume_magic_token(token) user = gr_api.ensure_user_exists(User.model_validate({"email": user_email})) + + # Cashout setup is not required for login and should not delay the response. + background_tasks.add_task( + try_create_paypal_cashout_method_if_not_exists, + product_user_id=user.product_user_id, + email=str(user.email), + client_ip=client_ip, + ) + response.set_cookie( key=SESSION_COOKIE_NAME, value=create_session(user.product_user_id), @@ -161,6 +218,8 @@ def invite_amt_account_link( @auth_router.get("/debug/", response_class=HTMLResponse, include_in_schema=False) def link_amt_account_landing_page( + request: Request, + background_tasks: BackgroundTasks, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, ) -> HTMLResponse: @@ -175,9 +234,15 @@ def link_amt_account_landing_page( status_code=status.HTTP_400_BAD_REQUEST, detail="token is required", ) - + client_ip = get_client_ip(request) _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - _exchange_amt_account_link(token=token, response=_response, gr_api=gr_api) + _exchange_amt_account_link( + token=token, + response=_response, + gr_api=gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) return HTMLResponse( render_base_html(), @@ -191,25 +256,46 @@ def link_amt_account_landing_page( @auth_router.post("/link-amt/exchange/", status_code=status.HTTP_204_NO_CONTENT) def exchange_amt_account_link( + request: Request, + background_tasks: BackgroundTasks, body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Validate the email link, then transition the bound AMT account.""" + client_ip = get_client_ip(request) try: - _exchange_amt_account_link(body.token, response, gr_api) + _exchange_amt_account_link( + body.token, + response, + gr_api, + client_ip=client_ip, + background_tasks=background_tasks, + ) except ValueError as e: LOG.error(f"Failed to exchange AMT account link: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -def _exchange_amt_account_link(token: str, response: Response, gr_api: GRApiManager): +def _exchange_amt_account_link( + token: str, + response: Response, + gr_api: GRApiManager, + client_ip: str, + background_tasks: BackgroundTasks, +): token_data = consume_amt_account_link_token(token) email = token_data.email amt_worker_id = token_data.amt_worker_id user = User(email=email) user = gr_api.transition_user_from_amt(user=user, amt_worker_id=amt_worker_id) + background_tasks.add_task( + try_create_paypal_cashout_method_if_not_exists, + product_user_id=user.product_user_id, + email=str(user.email), + client_ip=client_ip, + ) # In Mautic, associate the email with the worker ID (AFTER the user has transitioned) get_or_create_contact(email=email, amt_worker_id=amt_worker_id) diff --git a/jb/views/utils.py b/jb/views/utils.py index 0d08e9b..a133263 100644 --- a/jb/views/utils.py +++ b/jb/views/utils.py @@ -2,17 +2,11 @@ from fastapi import Request def get_client_ip(request: Request) -> str: - """ - Using a testclient, the ip returned is 'testclient'. If so, instead, grab - the ip from the headers - """ - ip = request.headers.get("X-Forwarded-For") - if not ip: - ip = request.client.host # type: ignore - elif ip == "testclient" or ip.startswith("10."): - forwarded = request.headers.get("X-Forwarded-For") - ip = ( - forwarded.split(",")[0].strip() if forwarded else request.client.host # type: ignore - ) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",", 1)[0].strip() - return ip + if request.client is None: + raise ValueError("Client IP is unavailable") + + return request.client.host diff --git a/jb/views/wallet.py b/jb/views/wallet.py new file mode 100644 index 0000000..681cc21 --- /dev/null +++ b/jb/views/wallet.py @@ -0,0 +1,132 @@ +from datetime import timedelta +from typing import Annotated +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import HTMLResponse +from generalresearch.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.wallet.cashout_method import CashoutRequestInfo +from generalresearch.redis_helper import RedisConfig + +from jb.api.auth import get_authenticated_user +from jb.api.cashout_token import consume_cashout_token, create_cashout_token +from jb.config import settings +from jb.decorators import get_redis_config +from jb.dependencies import get_gr_api_manager +from jb.managers.email_manager import ( + send_cashout_confirmation_email, + send_cashout_status_email, +) +from jb.managers.gr_api import GRApiManager +from jb.managers.thl import ( + get_cashout_detail, + get_cashout_method, + user_cashout_request, +) +from jb.models.auth import User +from jb.models.wallet import ( + CashoutConfirmation, + CashoutPostback, + CashoutRequest, + PendingCashout, +) +from jb.settings import render_base_html + +wallet_router = APIRouter(prefix="/wallet", tags=["Wallet"]) + + +@wallet_router.post("/cashout/request/") +def request_cashout( + body: CashoutRequest, + user: Annotated[User, Depends(get_authenticated_user)], +) -> dict[str, str]: + """Email the authenticated user a link confirming the requested amount.""" + token = create_cashout_token( + PendingCashout( + product_user_id=user.product_user_id, + amount=body.amount, + cashout_method_id=body.cashout_method_id, + ) + ) + query = urlencode({"token": token}) + confirmation_link = f"{settings.base_url}wallet/cashout/confirm/?{query}" + + cm = get_cashout_method(cashout_method_id=body.cashout_method_id) + + if settings.debug: + return {"confirmation_link": confirmation_link, "cashout_method": cm.id} + + send_cashout_confirmation_email( + email=str(user.email), token=token, cashout_method=cm, amount=body.amount + ) + return {"detail": "Confirmation sent. Check your inbox to finish the cashout."} + + +@wallet_router.get( + "/cashout/confirm/", response_class=HTMLResponse, include_in_schema=False +) +def cashout_confirmation_page() -> HTMLResponse: + """Serve the SPA without consuming the token; email prefetches are harmless.""" + return HTMLResponse( + render_base_html(), + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex, nofollow", + }, + ) + + +@wallet_router.post("/cashout/confirm/", response_model=CashoutRequestInfo) +def confirm_cashout( + body: CashoutConfirmation, + user: Annotated[User, Depends(get_authenticated_user)], +) -> CashoutRequestInfo: + cashout = consume_cashout_token(body.token) + + return user_cashout_request( + product_user_id=cashout.product_user_id, + amount=cashout.amount, + cashout_method_id=cashout.cashout_method_id, + ) + + +@wallet_router.post("/cashout/postback/", status_code=status.HTTP_204_NO_CONTENT) +def cashout_postback( + body: CashoutPostback, + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], + redis_config: Annotated[RedisConfig, Depends(get_redis_config)], +) -> None: + # Treat the posted ID only as a lookup key; THL supplies the trusted details. + try: + cashout = get_cashout_detail(body.cashout_id) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cashout not found: {e}", + ) + if cashout.product_id != settings.product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cashout not found", + ) + if cashout.status != PayoutStatus.COMPLETE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Cashout is not complete", + ) + # In case THL retries, send at most one email for each + dedupe_key = f"wallet:cashout-email-sent:{cashout.id}:{cashout.status.value}" + redis_client = redis_config.create_redis_client() + claimed = redis_client.set(dedupe_key, "sending", nx=True, ex=timedelta(minutes=5)) + if not claimed: + return + + try: + user = gr_api.get_user(product_user_id=cashout.product_user_id) + send_cashout_status_email(email=str(user.email), cashout=cashout) + redis_client.set(dedupe_key, "sent", ex=timedelta(minutes=30)) + except Exception: + # Allow a later retry when user lookup or email delivery fails. + redis_client.delete(dedupe_key) + raise |
