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 --- # No longer using cache - projects are stored in Firestore PHASES = { 209436: "Nonpayment File Review", 209437: "Attorney File Review", 209438: "Notice Preparation", 209439: "Notice Pending", 209440: "Notice Expired", 209442: "Preparing and Filing UD", 209443: "Waiting for Answer", 209444: "Archived", 210761: "Service of Process", 211435: "Default", 211436: "Pre-Answer Motion", 211437: "Request for Trial", 211438: "Trial Prep and Trial", 211439: "Writ and Sheriff", 211440: "Lockout Pending", 211441: "Stipulation Preparation", 211442: "Stipulation Pending", 211443: "Stipulation Expired", 211446: "On Hold", 211466: "Request for Monetary Judgment", 211467: "Appeals and Post-Poss. Motions", 211957: "Migrated", 213691: "Close Out/ Invoicing", 213774: "Judgment After Stip & Order", } # --- 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 store them in Firestore""" print("Fetching projects....") # Get bearer token bearer = get_filevine_bearer() # List projects (all pages) projects = list_all_projects(bearer) projects = projects[:250] # 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")) cs = fetch_contacts(bearer, pid) 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 {} defendants = fetch_collection(bearer, pid, "defendants") or [] service_info = fetch_collection(bearer, pid, "serviceInfo") or [] property_info = fetch_form(bearer, pid, "propertyInfo") project_overview = fetch_form(bearer, pid, "projectOverview") matter_overview = fetch_form(bearer, pid, "matterOverview") fees_and_costs = fetch_form(bearer, pid, "feesAndCosts") or {} lease_info_np = fetch_form(bearer, pid, "leaseInfoNP") or {} completed_tasks = [{"description": x.get("body") , "completed": x.get("completedDate")} for x in fetch_project_tasks(bearer, pid).get("items") if x.get("isCompleted")] pending_tasks = [{"description": x.get("body") , "completed": x.get("completedDate")} for x in fetch_project_tasks(bearer, pid).get("items") if not x.get("isCompleted")] team = fetch_project_team(bearer, pid) assigned_attorney = next((m.get('fullname') for m in team if ('Assigned Attorney' in [r.get('name') for r in m.get('teamOrgRoles')]) ), '') primary_contact = next((m.get('fullname') for m in team if ('Primary' in [r.get('name') for r in m.get('teamOrgRoles')]) ), '') secondary_paralegal = next((m.get('fullname') for m in team if ('Secondary Paralegal' in [r.get('name') for r in m.get('teamOrgRoles')]) ), '') # Extract notice service and expiration dates notice_service_date = new_file_review.get("noticeServiceDate") or '' notice_expiration_date = new_file_review.get("noticeExpirationDate") or '' # Extract daily rent damages daily_rent_damages = lease_info_np.get("dailyRentDamages") or dates_and_deadlines.get("dailyRentDamages") or '' # Extract default date default_date = dates_and_deadlines.get("defaultDate") or '' case_filed_date = dates_and_deadlines.get("dateCaseFiled") or '' # Extract motion hearing dates demurrer_hearing_date = dates_and_deadlines.get("demurrerHearingDate") or '' motion_to_strike_hearing_date = dates_and_deadlines.get("mTSHearingDate") or '' motion_to_quash_hearing_date = dates_and_deadlines.get("mTQHearingDate") or '' other_motion_hearing_date = dates_and_deadlines.get("otherMotion1HearingDate") or '' # Extract MSC details msc_date = dates_and_deadlines.get("mSCDate") or '' msc_time = dates_and_deadlines.get("mSCTime") or '' msc_address = dates_and_deadlines.get("mSCAddress") or '' msc_div_dept_room = dates_and_deadlines.get("mSCDeptDiv") or '' # Extract trial details trial_date = dates_and_deadlines.get("trialDate") or '' trial_time = dates_and_deadlines.get("trialTime") or '' trial_address = dates_and_deadlines.get("trialAddress") or '' trial_div_dept_room = dates_and_deadlines.get("trialDeptDivRoom") or '' # Extract final result of trial/MSC final_result = dates_and_deadlines.get("finalResultOfTrialMSCCa") or '' # Extract settlement details date_of_settlement = dates_and_deadlines.get("dateOfStipulation") or '' final_obligation = dates_and_deadlines.get("finalObligationUnderTheStip") or '' def_comply_stip = dates_and_deadlines.get("defendantsComplyWithStip") or '' # Extract judgment and writ details judgment_date = dates_and_deadlines.get("dateOfJudgment") or '' writ_issued_date = dates_and_deadlines.get("writIssuedDate") or '' # Extract lockout and stay details scheduled_lockout = dates_and_deadlines.get("sheriffScheduledDate") or '' oppose_stays = dates_and_deadlines.get("opposeStays") or '' # Extract premises safety and entry code premises_safety = new_file_review.get("lockoutSafetyIssuesOrSpecialCareIssues") or '' matter_gate_code = property_info.get("propertyEntryCodeOrInstructions") or '' # Extract possession recovered date date_possession_recovered = dates_and_deadlines.get("datePossessionRecovered") or '' # Extract attorney fees and costs attorney_fees = fees_and_costs.get("attorneyFeesTotal") or '' costs = fees_and_costs.get("costs") or '' row = { "client": c.get("firstName"), "matter_description": p.get("projectName"), "defendant_1": defendant_one.get('fullName', 'Unknown'), "matter_open": dates_and_deadlines.get("dateCaseFiled") or p.get("createdDate"), "notice_type": new_file_review.get("noticeType", '') or '', "case_number": dates_and_deadlines.get('caseNumber', '') or '', "premises_address": property_info.get("premisesAddressWithUnit") or '', "premises_city": property_info.get("premisesCity") or '', "responsible_attorney": assigned_attorney, "staff_person": primary_contact, "staff_person_2": secondary_paralegal, "phase_name": p.get("phaseName"), "completed_tasks": completed_tasks, "pending_tasks": pending_tasks, "notice_service_date": notice_service_date, "notice_expiration_date": notice_expiration_date, "case_field_date": case_filed_date, "daily_rent_damages": daily_rent_damages, "default_date": default_date, "demurrer_hearing_date": demurrer_hearing_date, "motion_to_strike_hearing_date": motion_to_strike_hearing_date, "motion_to_quash_hearing_date": motion_to_quash_hearing_date, "other_motion_hearing_date": other_motion_hearing_date, "msc_date": msc_date, "msc_time": msc_time, "msc_address": msc_address, "msc_div_dept_room": msc_div_dept_room, "trial_date": trial_date, "trial_time": trial_time, "trial_address": trial_address, "trial_div_dept_room": trial_div_dept_room, "final_result": final_result, "date_of_settlement": date_of_settlement, "final_obligation": final_obligation, "def_comply_stip": def_comply_stip, "judgment_date": judgment_date, "writ_issued_date": writ_issued_date, "scheduled_lockout": scheduled_lockout, "oppose_stays": oppose_stays, "premises_safety": premises_safety, "matter_gate_code": matter_gate_code, "date_possession_recovered": date_possession_recovered, "attorney_fees": attorney_fees, "costs": costs, "documents_url": matter_overview.get('documentShareFolderURL') or '', "service_attempt_date_1": next(iter(service_info), {}).get('serviceDate'), "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) # Store the results in Firestore projects_ref = db.collection("projects") # Clear existing projects projects_ref.stream() for doc in projects_ref.stream(): doc.reference.delete() # Add new projects for row in detailed_rows: project_id = str(row.get("ProjectId")) if project_id: projects_ref.document(project_id).set(row) print(f"Stored {len(detailed_rows)} projects in Firestore") return detailed_rows # No longer using cache - projects are stored in Firestore # --- 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() 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_count = None tries = 0 offset = 0 # TODO we probably need to sync the data with fierbase cnt = 0 while len(results) < 250: cnt = len(results) print(f"list try {tries}, starting at {offset}, previous count {last_count}, currently at {cnt}") tries += 1 url = base params = {} if last_count is not None: # Some deployments use LastID/Offset pagination; adapt if needed offset = offset + last_count print(f"OFFSET f{offset}") params["offset"] = offset r = requests.get(url, headers=headers, params=params, timeout=30) r.raise_for_status() page = r.json() from pprint import pprint items = page.get("items", []) results.extend(items) has_more = page.get("hasMore") last_count = page.get("count") from pprint import pprint pprint(page) 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_project_team(bearer: str, project_id_native: int): url = f"https://api.filevineapp.com/fv-app/v2/Projects/{project_id_native}/team?limit=1000" 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() from pprint import pprint pprint( r.json().get('items') or []) return r.json().get('items') or [] def fetch_project_tasks(bearer: str, project_id_native: int): url = f"https://api.filevineapp.com/fv-app/v2/Projects/{project_id_native}/tasks" 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 {} def fetch_collection(bearer: str, project_id_native: int, collection: str): try: url = f"https://api.filevineapp.com/fv-app/v2/Projects/{project_id_native}/Collections/{collection}" 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 [x.get('dataObject') for x in r.json().get("items")] 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")) # Read projects directly from Firestore projects_ref = db.collection("projects") docs = projects_ref.stream() detailed_rows = [] for doc in docs: detailed_rows.append(doc.to_dict()) print(f"Retrieved {len(detailed_rows)} projects from Firestore") # Render table return render_template("dashboard.html", rows=detailed_rows, case_email=case_email) # GAE compatibility if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", "5004")))