Files
simbarag/blueprints/beli/__init__.py
T
ryan bc3e3d2797 Accept access tokens on protected routes; persist OIDC state
- Add transitional @jwt_access_required (accepts access tokens, and for now
  also refresh tokens) and use it on every protected route and inside
  admin_required. /api/user/refresh stays refresh-only.
- Move pending OIDC state/PKCE verifiers from an in-process dict to an
  oidc_states table (10 min TTL, single-use) so logins survive a blue/green
  flip. Drop the unused redirect_after_login.
- Unit tests for the decorator and the state store.

Deploy this commit on its own, before the access-token frontend: the old
backend answers access tokens with 422, so the new frontend only works once
this backend is live on both colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:34 -04:00

188 lines
5.9 KiB
Python

import hmac
import logging
import os
from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity
from blueprints.users.decorators import jwt_access_required
from .logic import (
MAX_PROFILES_PER_USER,
_get_access_token,
_refresh_friend_sample,
refresh_profile,
schedule_nightly_refresh,
)
from .models import BeliProfile, BeliRestaurant, BeliStatus
beli_blueprint = Blueprint("beli", __name__, url_prefix="/api/beli")
logger = logging.getLogger(__name__)
def _check_cron_secret() -> bool:
expected = os.environ.get("CRON_SECRET", "")
if not expected:
return False
provided = request.headers.get("X-Cron-Secret", "")
return hmac.compare_digest(provided.encode(), expected.encode())
def _serialize_profile(profile: BeliProfile) -> dict:
return {
"id": str(profile.id),
"beli_email": profile.beli_email,
"beli_user_id": profile.beli_user_id,
"enabled": profile.enabled,
"last_refreshed_at": (
profile.last_refreshed_at.isoformat() if profile.last_refreshed_at else None
),
"last_error": profile.last_error,
"created_at": profile.created_at.isoformat(),
}
def _serialize_restaurant(r: BeliRestaurant) -> dict:
return {
"id": str(r.id),
"profile_id": str(r.profile_id),
"beli_business_id": r.beli_business_id,
"name": r.name,
"city": r.city,
"neighborhood": r.neighborhood,
"cuisine": r.cuisine,
"price_range": r.price_range,
"status": r.status.value,
"rank_index": r.rank_index,
"score": float(r.score) if r.score is not None else None,
"notes": r.notes,
"tags": r.tags,
"fetched_at": r.fetched_at.isoformat(),
}
@beli_blueprint.route("/profiles", methods=["GET"])
@jwt_access_required
async def list_profiles():
user_id = get_jwt_identity()
profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all()
return jsonify([_serialize_profile(p) for p in profiles])
@beli_blueprint.route("/profiles", methods=["POST"])
@jwt_access_required
async def create_profile():
user_id = get_jwt_identity()
count = await BeliProfile.filter(user_id=user_id).count()
if count >= MAX_PROFILES_PER_USER:
return (
jsonify(
{"error": f"Maximum {MAX_PROFILES_PER_USER} Beli profiles per user"}
),
400,
)
data = await request.get_json()
if not data:
return jsonify({"error": "Invalid payload"}), 400
email = (data.get("beli_email") or "").strip()
password = (data.get("beli_password") or "").strip()
if not email or not password:
return jsonify({"error": "beli_email and beli_password are required"}), 400
if await BeliProfile.exists(user_id=user_id, beli_email=email):
return jsonify({"error": "A profile with this email already exists"}), 409
from utils.beli_service import authenticate
try:
session = await authenticate(email, password)
except Exception as e:
return jsonify({"error": f"Beli authentication failed: {e}"}), 400
profile = await BeliProfile.create(
user_id=user_id,
beli_email=email,
beli_password=password,
beli_user_id=session["user_id"],
beli_refresh_token=session["refresh_token"],
enabled=True,
)
return jsonify(_serialize_profile(profile)), 201
@beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"])
@jwt_access_required
async def delete_profile(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
if not profile:
return jsonify({"error": "Not found"}), 404
await BeliRestaurant.filter(profile=profile).delete()
await profile.delete()
return jsonify({"status": "deleted"})
@beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"])
@jwt_access_required
async def refresh_profile_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
if not profile:
return jsonify({"error": "Not found"}), 404
result = await refresh_profile(str(profile.id))
return jsonify(result)
@beli_blueprint.route("/restaurants", methods=["GET"])
@jwt_access_required
async def list_restaurants():
user_id = get_jwt_identity()
profile_ids = [
p.id for p in await BeliProfile.filter(user_id=user_id).only("id").all()
]
qs = BeliRestaurant.filter(profile_id__in=profile_ids)
status_filter = request.args.get("status")
city_filter = request.args.get("city")
if status_filter:
try:
qs = qs.filter(status=BeliStatus(status_filter))
except ValueError:
return jsonify({"error": f"Invalid status: {status_filter}"}), 400
if city_filter:
qs = qs.filter(city__icontains=city_filter)
restaurants = await qs.order_by("rank_index", "name").all()
return jsonify([_serialize_restaurant(r) for r in restaurants])
@beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"])
@jwt_access_required
async def refresh_friends_endpoint(profile_id: str):
user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
if not profile:
return jsonify({"error": "Not found"}), 404
try:
access_token = await _get_access_token(profile)
upserted = await _refresh_friend_sample(profile, access_token)
return jsonify({"ok": True, "upserted": upserted})
except Exception as e:
return jsonify({"error": str(e)}), 500
@beli_blueprint.route("/cron/refresh", methods=["POST"])
async def cron_nightly_refresh():
if not _check_cron_secret():
return jsonify({"error": "Unauthorized"}), 401
result = await schedule_nightly_refresh()
return jsonify(result)