import json import os from functools import wraps from datetime import datetime, timedelta from flask import Flask, render_template, request, redirect, url_for, session, abort, jsonify from dotenv import load_dotenv import firebase_admin from firebase_admin import credentials, auth as fb_auth, firestore import requests load_dotenv() app = Flask(__name__) app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(32)) # --- Firebase Admin init --- _creds = None json_inline = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON") file_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") if json_inline: _creds = credentials.Certificate(json.loads(json_inline)) elif file_path and os.path.exists(file_path): _creds = credentials.Certificate(file_path) else: raise RuntimeError("Firebase credentials not configured. Set GOOGLE_APPLICATION_CREDENTIALS or FIREBASE_SERVICE_ACCOUNT_JSON.") firebase_admin.initialize_app(_creds) db = firestore.client() # --- Filevine env --- FV_CLIENT_ID = os.environ.get("FILEVINE_CLIENT_ID") FV_CLIENT_SECRET = os.environ.get("FILEVINE_CLIENT_SECRET") FV_PAT = os.environ.get("FILEVINE_PERSONAL_ACCESS_TOKEN") FV_ORG_ID = os.environ.get("FILEVINE_ORG_ID") FV_USER_ID = os.environ.get("FILEVINE_USER_ID") if not all([FV_CLIENT_ID, FV_CLIENT_SECRET, FV_PAT, FV_ORG_ID, FV_USER_ID]): print("[WARN] Missing one or more Filevine env vars — dashboard will fail until set.") # --- Cache --- from cache import project_cache # --- Helpers --- def login_required(view): @wraps(view) def wrapped(*args, **kwargs): uid = session.get("uid") if not uid: return redirect(url_for("login")) return view(*args, **kwargs) return wrapped def get_user_profile(uid: str): """Fetch user's Firestore profile: users/{uid} => { enabled, caseEmail }""" doc_ref = db.collection("users").document(uid) snap = doc_ref.get() if not snap.exists: # bootstrap a placeholder doc so admins can fill it in doc_ref.set({"enabled": False}, merge=True) return {"enabled": False, "caseEmail": None} data = snap.to_dict() or {} return {"enabled": bool(data.get("enabled", False)), "caseEmail": data.get("caseEmail")} def fetch_all_projects(): """Fetch all projects for a user and cache them""" print("Fetching projects....") # Get bearer token bearer = get_filevine_bearer() # List projects (all pages) projects = list_all_projects(bearer) # todo, only 10 projects projects = projects[:10] # Fetch details for each detailed_rows = [] for p in projects: pid = (p.get("projectId") or {}).get("native") c = fetch_client(bearer, (p.get("clientId") or {}).get("native")) print("fetched client") cs = fetch_contacts(bearer, pid) print("fetched contacts") if pid is None: continue try: detail = fetch_project_detail(bearer, pid) except Exception as e: print(f"[WARN] detail fetch failed for {pid}: {e}") detail = {} from pprint import pprint defendant_one = next((c.get('orgContact', {}) for c in cs if "Defendant" in c.get('orgContact', {}).get('personTypes', [])), {}) new_file_review = fetch_form(bearer, pid, "newFileReview") or {} dates_and_deadlines = fetch_form(bearer, pid, "datesAndDeadlines") or {} row = { "client": c.get("firstName"), "matter_description": p.get("projectName"), "defendant_1": defendant_one.get('fullName', 'Unknown'), "matter_open": p.get("createdDate"), "notice_type": new_file_review.get("noticeType", '') or '', "case_number": dates_and_deadlines.get('caseNumber', '') or '', "contacts": cs, "ProjectEmailAddress": p.get("projectEmailAddress"), "Number": p.get("number"), "IncidentDate": (p.get("incidentDate") or detail.get("incidentDate")), "ProjectId": pid, "ProjectName": p.get("projectName") or detail.get("projectName"), "ProjectUrl": p.get("projectUrl") or detail.get("projectUrl"), } detailed_rows.append(row) # Cache the results project_cache.set_projects(detailed_rows) return detailed_rows import time import threading def async_cache_projects(): from threading import Thread def cache_loop(): while True: try: # Check if cache is already being updated to avoid concurrent updates if not project_cache.is_updating(): project_cache.set_updating(True) fetch_all_projects() project_cache.set_updating(False) else: print("Cache update already in progress, skipping this cycle") except Exception as e: print(f"Error in cache loop: {e}") project_cache.set_updating(False) # Wait for 15 minutes before next update time.sleep(15 * 60) # 15 minutes in seconds thread = Thread(target=cache_loop, args=()) thread.daemon = True thread.start() # --- Routes --- @app.route("/") def index(): uid = session.get("uid") if not uid: return redirect(url_for("login")) profile = get_user_profile(uid) if profile.get("enabled") and profile.get("caseEmail"): return redirect(url_for("dashboard")) return redirect(url_for("welcome")) @app.route("/login") def login(): # Pass public Firebase config to template fb_public = { "apiKey": os.environ.get("FIREBASE_API_KEY", ""), "authDomain": os.environ.get("FIREBASE_AUTH_DOMAIN", ""), "projectId": os.environ.get("FIREBASE_PROJECT_ID", ""), "appId": os.environ.get("FIREBASE_APP_ID", ""), } return render_template("login.html", firebase_config=fb_public) @app.route("/session_login", methods=["POST"]) def session_login(): """Exchanges Firebase ID token for a server session.""" id_token = request.json.get("idToken") if request.is_json else request.form.get("idToken") if not id_token: abort(400, "Missing idToken") try: decoded = fb_auth.verify_id_token(id_token) uid = decoded["uid"] session.clear() session["uid"] = uid # Optional: short session session["expires_at"] = (datetime.utcnow() + timedelta(hours=8)).isoformat() async_cache_projects() return jsonify({"ok": True}) except Exception as e: print("[ERR] session_login:", e) abort(401, "Invalid ID token") @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.route("/welcome") @login_required def welcome(): uid = session.get("uid") profile = get_user_profile(uid) return render_template("welcome.html", profile=profile) # --- Filevine API --- def get_filevine_bearer(): url = "https://identity.filevine.com/connect/token" data = { "client_id": FV_CLIENT_ID, "client_secret": FV_CLIENT_SECRET, "grant_type": "personal_access_token", "scope": "fv.api.gateway.access tenant filevine.v2.api.* email openid fv.auth.tenant.read", "token": FV_PAT, } headers = {"Accept": "application/json"} resp = requests.post(url, data=data, headers=headers, timeout=30) resp.raise_for_status() js = resp.json() token = js.get("access_token") print(f"Got bearer {token}") return token def list_all_projects(bearer: str): base = "https://api.filevineapp.com/fv-app/v2/Projects" headers = { "Accept": "application/json", "Authorization": f"Bearer {bearer}", "x-fv-orgid": str(FV_ORG_ID), "x-fv-userid": str(FV_USER_ID), } results = [] last_id = None tries = 0 # TODO we probably need to sync the data with fierbase while len(results) < 200: cnt = len(results) print(f"list try {tries}, last_id {last_id}, count {cnt}") tries += 1 url = base params = {} if last_id is not None: # Some deployments use LastID/Offset pagination; adapt if needed params["lastID"] = last_id r = requests.get(url, headers=headers, params=params, timeout=30) r.raise_for_status() page = r.json() items = page.get("items", []) results.extend(items) has_more = page.get("hasMore") last_id = page.get("lastID") if not has_more: break # Safety valve if tries > 200: break return results def fetch_project_detail(bearer: str, project_id_native: int): url = f"https://api.filevineapp.com/fv-app/v2/Projects/{project_id_native}" headers = { "Accept": "application/json", "Authorization": f"Bearer {bearer}", "x-fv-orgid": str(FV_ORG_ID), "x-fv-userid": str(FV_USER_ID), } r = requests.get(url, headers=headers, timeout=30) r.raise_for_status() return r.json() def fetch_client(bearer: str, client_id_native: int): url = f"https://api.filevineapp.com/fv-app/v2/contacts/{client_id_native}" headers = { "Accept": "application/json", "Authorization": f"Bearer {bearer}", "x-fv-orgid": str(FV_ORG_ID), "x-fv-userid": str(FV_USER_ID), } r = requests.get(url, headers=headers, timeout=30) r.raise_for_status() return r.json() def fetch_contacts(bearer: str, project_id_native: int): url = f"https://api.filevineapp.com/fv-app/v2/projects/{project_id_native}/contacts" headers = { "Accept": "application/json", "Authorization": f"Bearer {bearer}", "x-fv-orgid": str(FV_ORG_ID), "x-fv-userid": str(FV_USER_ID), } r = requests.get(url, headers=headers, timeout=30) r.raise_for_status() return r.json().get("items") def fetch_form(bearer: str, project_id_native: int, form: str): try: url = f"https://api.filevineapp.com/fv-app/v2/Projects/{project_id_native}/Forms/{form}" headers = { "Accept": "application/json", "Authorization": f"Bearer {bearer}", "x-fv-orgid": str(FV_ORG_ID), "x-fv-userid": str(FV_USER_ID), } r = requests.get(url, headers=headers, timeout=30) r.raise_for_status() return r.json() except Exception as e: print(e) return {} @app.route("/dashboard") @login_required def dashboard(): uid = session.get("uid") profile = get_user_profile(uid) if not profile.get("enabled"): return redirect(url_for("welcome")) case_email = profile.get("caseEmail") if not case_email: return redirect(url_for("welcome")) # Check cache first cached_projects = project_cache.get_projects() if cached_projects is not None: detailed_rows = cached_projects print("USING CACHE") else: # Fetch and cache projects detailed_rows = fetch_all_projects() print("FETCHING") print("HI", len(detailed_rows)) # 5) Render table return render_template("dashboard.html", rows=detailed_rows, case_email=case_email) # GAE compatibility if __name__ == "__main__": async_cache_projects() app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", "5004")))