aboutsummaryrefslogtreecommitdiff
path: root/jb/views
diff options
context:
space:
mode:
authorGreg Stupp2026-09-24 22:00:58 +0000
committerGreg Stupp2026-09-24 22:00:58 +0000
commita5e1f9d5fe8b1d4e66c240a69eff34a8d0e8dff7 (patch)
tree61d38c3796c1e758a344edec7ce52bcb6ee64f36 /jb/views
parent6249139ee928b954e74ac42df81211f1a8094b53 (diff)
parentbe986ab84f7b12c211f7675071d4deae1bf2bd03 (diff)
downloadamt-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.py102
-rw-r--r--jb/views/utils.py20
-rw-r--r--jb/views/wallet.py132
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