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