bc3e3d2797
- 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>
188 lines
5.9 KiB
Python
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)
|