Compare commits
10 Commits
f915e33dab
...
dc81c8e2a7
| Author | SHA1 | Date | |
|---|---|---|---|
| dc81c8e2a7 | |||
| c3263a0eaf | |||
| 607e65560c | |||
| 86a09225e7 | |||
| dc6c24ca6d | |||
| 6cde9ab75f | |||
| a1b836b392 | |||
| 234578b646 | |||
| c3108ff68c | |||
| c3e943f135 |
166
.claude/skills/filevine-api.md
Normal file
166
.claude/skills/filevine-api.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Filevine API Curl Skill
|
||||
|
||||
This skill enables intelligent exploration of the Filevine API by making curl requests and analyzing data patterns.
|
||||
|
||||
## Capabilities
|
||||
|
||||
1. **Discover relevant data within a project** - Search across all endpoints used in sync.py
|
||||
2. **Make educated guesses** - Use patterns from sample data to infer where data might exist
|
||||
3. **Query specific fields** - Search for fields like "matter number", "case number", dates, etc.
|
||||
|
||||
## API Endpoints Discovered
|
||||
|
||||
Based on analysis of `sync.py`, the following endpoints are used:
|
||||
|
||||
| Endpoint | Purpose | Related Code |
|
||||
|----------|---------|--------------|
|
||||
| `Projects/{id}` | Project details (name, URLs, dates, phase) | `fetch_project_detail()` |
|
||||
| `Projects/{id}/team` | Team members and roles | `fetch_project_team()` |
|
||||
| `Projects/{id}/tasks` | Project tasks | `fetch_project_tasks()` |
|
||||
| `Projects/{id}/Forms/{form}` | Specific forms (datesAndDeadlines, newFileReview, etc.) | `fetch_form()` |
|
||||
| `Projects/{id}/Contacts` | Project contacts | `fetch_contacts()` |
|
||||
| `Contacts/{id}` | Client information | `fetch_client()` |
|
||||
| `Projects/{id}/Collections/{collection}` | Collections (serviceInfo) | `fetch_collection()` |
|
||||
|
||||
## Sample Data Patterns
|
||||
|
||||
From examining `examples/*.json`, here are common patterns:
|
||||
|
||||
**Project-level fields (Projects/{id}):**
|
||||
- `projectName` - Full project name
|
||||
- `number` - Project number
|
||||
- `incidentDate` - Incident date
|
||||
- `projectEmailAddress` - Email address
|
||||
- `projectUrl` - Link to Filevine
|
||||
- `phaseName` - Current phase
|
||||
- `createdDate` - Creation date
|
||||
|
||||
**DatesAndDeadlines form:**
|
||||
- `caseNumber` - Case number
|
||||
- `dateCaseFiled` - Case filed date
|
||||
- `defaultDate` - Default date
|
||||
- `trialDate` - Trial date
|
||||
- `mSCDate` - MSC date
|
||||
- `noticeExpirationDate` - Notice expiration
|
||||
- `judgmentDate` - Judgment date
|
||||
|
||||
**NewFileReview form:**
|
||||
- `noticeServiceDate` - When notice was served
|
||||
- `noticeExpirationDate` - Notice expiration
|
||||
- `noticeType` - Type of notice (NP, PPQ, etc.)
|
||||
- `amountDemandedInNotice` - Amount demanded
|
||||
|
||||
**ComplaintInfo form:**
|
||||
- `totalDamagesSought` - Total damages
|
||||
- `complaintVerificationBy` - Verification method
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks about finding data, follow this workflow:
|
||||
|
||||
### 1. Parse the Query
|
||||
|
||||
Identify what the user wants to find:
|
||||
- Field name (e.g., "matter number", "case number", "trial date")
|
||||
- Context (e.g., "in a project", "in the datesAndDeadlines form")
|
||||
|
||||
### 2. Generate Endpoint Candidates
|
||||
|
||||
Based on the query, suggest multiple endpoints to check:
|
||||
|
||||
**For case/project identifiers:**
|
||||
- `Projects/{id}` (check `number`, `caseNumber` fields)
|
||||
- `Projects/{id}/Forms/datesAndDeadlines` (check `caseNumber` field)
|
||||
- `Projects/{id}/Forms/matterOverview` (check `matterNumber` field)
|
||||
|
||||
**For dates:**
|
||||
- `Projects/{id}` (check `incidentDate`, `createdDate`)
|
||||
- `Projects/{id}/Forms/datesAndDeadlines` (check `trialDate`, `mSCDate`, `defaultDate`, etc.)
|
||||
- `Projects/{id}/Forms/newFileReview` (check `noticeServiceDate`)
|
||||
|
||||
**For contact/defendant info:**
|
||||
- `Projects/{id}/Contacts` (check contact list for defendant)
|
||||
- `Contacts/{client_id}` (check full contact info)
|
||||
|
||||
**For attorney/team:**
|
||||
- `Projects/{id}/team` (check `teamOrgRoles` for "Assigned Attorney", "Primary", etc.)
|
||||
|
||||
**For notices:**
|
||||
- `Projects/{id}/Forms/newFileReview` (check `noticeType`, `noticeExpirationDate`, etc.)
|
||||
- `Projects/{id}/Collections/serviceInfo` (check service dates)
|
||||
|
||||
### 3. Execute curl Commands
|
||||
|
||||
For each candidate endpoint, construct and execute curl commands using the auth headers from the codebase:
|
||||
|
||||
```bash
|
||||
curl -X GET "https://api.filevineapp.com/fv-app/v2/Projects/{PROJECT_ID}/{ENDPOINT}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: Bearer {BEARER_TOKEN}" \
|
||||
-H "x-fv-orgid: {ORG_ID}" \
|
||||
-H "x-fv-userid: {USER_ID}"
|
||||
```
|
||||
|
||||
Use the project ID from sample data (e.g., `15974631` or `15914808`) as a placeholder.
|
||||
|
||||
### 4. Analyze Results
|
||||
|
||||
Look for the queried field in the JSON response:
|
||||
- Search field names (not values)
|
||||
- Check nested objects and arrays
|
||||
- Note which endpoints actually contain the data
|
||||
|
||||
### 5. Report Findings
|
||||
|
||||
Provide:
|
||||
1. The field value(s) found
|
||||
2. The endpoint(s) that contain it
|
||||
3. Any related fields discovered in the same endpoint
|
||||
4. Context about how this data is used in the application
|
||||
|
||||
## Common Patterns to Guess From
|
||||
|
||||
When the user asks "What endpoint should I hit to find [field]?", consider:
|
||||
|
||||
**Field Mapping:**
|
||||
| Common Field Names | Likely Endpoints |
|
||||
|-------------------|------------------|
|
||||
| `matterNumber`, `matter number` | `Projects/{id}/Forms/matterOverview` |
|
||||
| `caseNumber`, `case number` | `Projects/{id}/Forms/datesAndDeadlines` |
|
||||
| `incident date`, `incidentDate` | `Projects/{id}`, `Projects/{id}/Forms/datesAndDeadlines` |
|
||||
| `trial date`, `trialDate` | `Projects/{id}/Forms/datesAndDeadlines` |
|
||||
| `notice service date`, `noticeServiceDate` | `Projects/{id}/Forms/newFileReview` |
|
||||
| `notice expiration date`, `noticeExpirationDate` | `Projects/{id}/Forms/newFileReview` |
|
||||
| `notice type` | `Projects/{id}/Forms/newFileReview` |
|
||||
| `assigned attorney` | `Projects/{id}/team` (role: "Assigned Attorney") |
|
||||
| `primary contact`, `staff person` | `Projects/{id}/team` (role: "Primary") |
|
||||
| `defendant` | `Projects/{id}/Contacts` (filter for "Defendant" role) |
|
||||
| `client` | `Contacts/{client_id}` or `Projects/{id}` (clientId field) |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
When executing curl commands, use these placeholder values (user should provide actual values):
|
||||
|
||||
- `PROJECT_ID` - Use sample IDs like 15974631 or 15914808
|
||||
- `ORG_ID` - From FILEVINE_ORG_ID env var
|
||||
- `USER_ID` - From FILEVINE_USER_ID env var
|
||||
- `BEARER_TOKEN` - From FilevineClient.get_bearer_token()
|
||||
|
||||
## Tips
|
||||
|
||||
1. Start with the most likely endpoints first
|
||||
2. The sample data shows that many fields are duplicated across endpoints - check multiple places
|
||||
3. Date fields are often in ISO format in API responses
|
||||
4. Some forms (like `newFileReview`) contain multiple related date fields
|
||||
5. Team roles use specific names - "Assigned Attorney", "Primary", "Secondary Paralegal"
|
||||
6. Contact objects in arrays may have `personTypes` to identify their role
|
||||
|
||||
## Example Workflow
|
||||
|
||||
User: "What endpoint should I hit to find the matter number?"
|
||||
|
||||
1. Identify: User wants "matter number" field
|
||||
2. Guess: Matter number might be in `Projects/{id}/Forms/matterOverview` based on sample data patterns
|
||||
3. Check: `Projects/{id}` also has `number` field
|
||||
4. Execute: curl to both endpoints with project 15974631
|
||||
5. Report: Found `number` in project root and `matterNumber` in matterOverview form
|
||||
5
.env
5
.env
@@ -9,8 +9,9 @@ GOOGLE_APPLICATION_CREDENTIALS=./rothbard-staging2-12345-firebase-adminsdk-fbsvc
|
||||
|
||||
# Filevine auth
|
||||
FILEVINE_CLIENT_ID=4F18738C-107A-4B82-BFAC-308F1B6A626A
|
||||
FILEVINE_CLIENT_SECRET=*2{aXWvYN(9!BiYUXC_tXj^n8
|
||||
FILEVINE_PERSONAL_ACCESS_TOKEN=C8F5C606B834D4EE0CBF0793969496F6210037EB934523756BA80BF2D8EC1880
|
||||
FILEVINE_CLIENT_SECRET=q<}QzfD^3t_atF-7+U8(gJCgj
|
||||
FILEVINE_PERSONAL_ACCESS_TOKEN=68BEBFB8437B9668642BD71EDEAF09593ACF075CE35AE4079FC7588410094210
|
||||
|
||||
FILEVINE_ORG_ID=9227
|
||||
FILEVINE_USER_ID=100510
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ __pycache__/**
|
||||
|
||||
|
||||
**/*.pyc
|
||||
node_modules/**
|
||||
|
||||
139
admin.py
139
admin.py
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from functools import wraps
|
||||
from flask import render_template, request, redirect, url_for, session, abort, jsonify
|
||||
from flask import render_template, request, redirect, url_for, session, abort, jsonify, flash
|
||||
from firebase_init import db
|
||||
from firebase_admin import auth as fb_auth
|
||||
from utils import get_user_profile
|
||||
@@ -45,8 +47,10 @@ def register_admin_routes(app):
|
||||
"uid": doc.id,
|
||||
"user_email": user_data.get("user_email", ""),
|
||||
"case_email": user_data.get("case_email", ""),
|
||||
"case_domain_email": user_data.get("case_domain_email", ""),
|
||||
"enabled": bool(user_data.get("enabled", False)),
|
||||
"is_admin": bool(user_data.get("is_admin", False))
|
||||
"is_admin": bool(user_data.get("is_admin", False)),
|
||||
"password_reset_required": bool(user_data.get("password_reset_required", False))
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -78,6 +82,7 @@ def register_admin_routes(app):
|
||||
"uid": uid,
|
||||
"user_email": user_data.get("user_email", ""),
|
||||
"case_email": user_data.get("case_email", ""),
|
||||
"case_domain_email": user_data.get("case_domain_email", ""),
|
||||
"enabled": bool(user_data.get("enabled", False)),
|
||||
"is_admin": bool(user_data.get("is_admin", False))
|
||||
}
|
||||
@@ -92,17 +97,23 @@ def register_admin_routes(app):
|
||||
# Get the user from Firebase Auth
|
||||
user = fb_auth.get_user(uid)
|
||||
|
||||
# Generate password reset link using Firebase Auth
|
||||
password_reset_link = fb_auth.generate_password_reset_link(user.email)
|
||||
|
||||
# Send password reset email using Firebase's built-in template
|
||||
# This will send an email to the user with a link to reset their password
|
||||
# Firebase automatically handles the email template and delivery
|
||||
print(f"[INFO] Password reset link generated for {user.email}: {password_reset_link}")
|
||||
# Generate temporary password (random word + 3 digits)
|
||||
words = ["sun", "moon", "star", "cloud", "rain", "wind", "fire", "water", "snow", "stone",
|
||||
"tree", "leaf", "flower", "bird", "wolf", "tiger", "bear", "fish", "dragon",
|
||||
"magic", "quest", "light", "dark", "gold", "silver", "ruby", "pearl", "diamond"]
|
||||
random_word = random.choice(words)
|
||||
random_digits = ''.join(random.choices(string.digits, k=3))
|
||||
temp_password = f"{random_word}{random_digits}"
|
||||
|
||||
# Store the password reset link in the session for display in the banner
|
||||
session['password_reset_link'] = password_reset_link
|
||||
session['reset_user_email'] = user.email
|
||||
# Create user profile in Firestore with password reset required flag
|
||||
user_ref = db.collection("users").document(user.uid)
|
||||
user_ref.set({
|
||||
"password_reset_required": True
|
||||
},merge=True)
|
||||
|
||||
flash(f"User now has a temporary password {temp_password}.", "success")
|
||||
fb_auth.update_user(uid, password=temp_password)
|
||||
|
||||
# Redirect back to the admin users table
|
||||
return redirect(url_for('admin_users'))
|
||||
@@ -126,11 +137,14 @@ def register_admin_routes(app):
|
||||
|
||||
# Update user in Firestore
|
||||
user_ref = db.collection("users").document(target_uid)
|
||||
user_ref.update({
|
||||
# Only update fields that can be changed, excluding is_admin
|
||||
update_data = {
|
||||
"enabled": data.get("enabled", False),
|
||||
"is_admin": data.get("is_admin", False),
|
||||
"case_email": data.get("case_email", "")
|
||||
})
|
||||
"case_email": data.get("case_email", ""),
|
||||
"case_domain_email": data.get("case_domain_email", "")
|
||||
}
|
||||
# Never allow changing is_admin field during updates - admin status can only be set during creation
|
||||
user_ref.update(update_data)
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
@@ -147,7 +161,7 @@ def register_admin_routes(app):
|
||||
@app.route("/admin/users/create", methods=["POST"])
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""Create a new user"""
|
||||
"""Create a new user with temporary password"""
|
||||
try:
|
||||
# Get form data
|
||||
user_email = request.form.get("user_email")
|
||||
@@ -158,22 +172,37 @@ def register_admin_routes(app):
|
||||
if "@" not in user_email:
|
||||
abort(400, "Invalid email format")
|
||||
|
||||
# Create user in Firebase Authentication
|
||||
# Generate temporary password (random word + 3 digits)
|
||||
words = ["sun", "moon", "star", "cloud", "rain", "wind", "fire", "water", "snow", "stone",
|
||||
"tree", "leaf", "flower", "bird", "wolf", "tiger", "bear", "fish", "dragon",
|
||||
"magic", "quest", "light", "dark", "gold", "silver", "ruby", "pearl", "diamond"]
|
||||
random_word = random.choice(words)
|
||||
random_digits = ''.join(random.choices(string.digits, k=3))
|
||||
temp_password = f"{random_word}{random_digits}"
|
||||
|
||||
# Create user in Firebase Authentication with temporary password
|
||||
user_record = fb_auth.create_user(
|
||||
email=user_email,
|
||||
email_verified=False,
|
||||
disabled=not request.form.get("enabled", False)
|
||||
disabled=not request.form.get("enabled", False),
|
||||
password=temp_password
|
||||
)
|
||||
|
||||
# Create user profile in Firestore
|
||||
# Create user profile in Firestore with password reset required flag
|
||||
user_ref = db.collection("users").document(user_record.uid)
|
||||
user_ref.set({
|
||||
"user_email": user_email,
|
||||
"case_email": request.form.get("case_email", ""),
|
||||
"case_domain_email": request.form.get("case_domain_email", ""),
|
||||
"enabled": bool(request.form.get("enabled", False)),
|
||||
"is_admin": bool(request.form.get("is_admin", False))
|
||||
"is_admin": bool(request.form.get("is_admin", False)),
|
||||
"password_reset_required": True
|
||||
})
|
||||
|
||||
# Display success message with temporary password
|
||||
flash(f"User created successfully. Temporary password: {temp_password}", "success")
|
||||
print(f"[INFO] Created user {user_email} with temp password: {temp_password}")
|
||||
|
||||
# Redirect to admin users page
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
@@ -183,3 +212,73 @@ def register_admin_routes(app):
|
||||
except Exception as e:
|
||||
print(f"[ERR] Failed to create user: {e}")
|
||||
abort(500, "Failed to create user")
|
||||
|
||||
@app.route("/admin/users/<uid>/become-user", methods=["POST"])
|
||||
@admin_required
|
||||
def become_user(uid):
|
||||
"""Allow admin to impersonate another user by replacing session UID"""
|
||||
try:
|
||||
# Verify the target user exists
|
||||
target_user_doc = db.collection("users").document(uid).get()
|
||||
if not target_user_doc.exists:
|
||||
abort(404, "User not found")
|
||||
|
||||
# Store original admin UID and set impersonation flags
|
||||
session['original_uid'] = session.get('uid')
|
||||
session['impersonating'] = True
|
||||
|
||||
# Replace session UID with target user's UID
|
||||
session['uid'] = uid
|
||||
|
||||
print(f"[INFO] Admin {session['original_uid']} is now impersonating user {uid}")
|
||||
|
||||
# Redirect to dashboard
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERR] Failed to become user {uid}: {e}")
|
||||
abort(500, "Failed to impersonate user")
|
||||
|
||||
@app.route("/admin/users/revert")
|
||||
def revert_user():
|
||||
"""Revert back to the original admin session.
|
||||
|
||||
Only accessible if the original session owner (before impersonation) was an admin.
|
||||
This check must happen before @admin_required decorator to verify the original UID,
|
||||
not the currently impersonated user's UID.
|
||||
"""
|
||||
try:
|
||||
# Check if user is currently impersonating
|
||||
if 'original_uid' not in session:
|
||||
# Not impersonating - check if current user is admin
|
||||
uid = session.get('uid')
|
||||
if not uid:
|
||||
return redirect(url_for('login'))
|
||||
profile = get_user_profile(uid)
|
||||
if profile.get('is_admin'):
|
||||
return redirect(url_for('admin_users'))
|
||||
# Not an admin, deny access
|
||||
abort(403, "Access denied. Admin privileges required.")
|
||||
|
||||
# Verify the original session owner was an admin (not the impersonated user)
|
||||
original_uid = session.get('original_uid')
|
||||
if not original_uid:
|
||||
abort(403, "Access denied. Invalid session state.")
|
||||
|
||||
original_profile = get_user_profile(original_uid)
|
||||
if not original_profile.get('is_admin'):
|
||||
abort(403, "Access denied. Only admins can revert from impersonation.")
|
||||
|
||||
# Restore original admin UID
|
||||
session['uid'] = original_uid
|
||||
session.pop('impersonating', None)
|
||||
session.pop('original_uid', None)
|
||||
|
||||
print(f"[INFO] Reverted from impersonation back to admin")
|
||||
|
||||
# Redirect to admin users page
|
||||
return redirect(url_for('admin_users'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERR] Failed to revert user: {e}")
|
||||
abort(500, "Failed to revert session")
|
||||
218
app.py
218
app.py
@@ -29,6 +29,82 @@ def login_required(view):
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
def projects_for(profile, case_email_match, per_page, offset):
|
||||
"""
|
||||
Filter projects based on user profile and case_email query string argument.
|
||||
|
||||
Args:
|
||||
profile (dict): User profile containing 'enabled', 'is_admin', 'case_email', and 'case_domain_email' fields
|
||||
case_email_match (str): Case email from query string argument, or None
|
||||
|
||||
Returns:
|
||||
list: List of project dictionaries that match the filtering criteria
|
||||
"""
|
||||
is_admin = profile.get("is_admin", False)
|
||||
|
||||
if not profile.get("enabled"):
|
||||
return ([], 0)
|
||||
|
||||
# Query Firestore for projects where case_email is in viewing_emails array
|
||||
try:
|
||||
cnt = 0
|
||||
if is_admin:
|
||||
if case_email_match:
|
||||
case_email_match_lower = case_email_match.lower().strip()
|
||||
|
||||
# Check if case_email_match is a valid email address (contains @)
|
||||
if '@' in case_email_match_lower and not case_email_match_lower.startswith('@'):
|
||||
# If it's a complete email address, filter by exact match in viewing_emails
|
||||
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email_match_lower)
|
||||
cnt = int(projects_ref.count().get()[0][0].value)
|
||||
projects = []
|
||||
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
|
||||
projects.append(doc.to_dict())
|
||||
return (projects, cnt)
|
||||
else:
|
||||
# If no @ sign, treat as domain search
|
||||
# Also handle cases like "@gmail.com" by extracting the domain
|
||||
domain_search = case_email_match_lower
|
||||
if domain_search.startswith('@'):
|
||||
domain_search = domain_search[1:] # Remove the @ sign
|
||||
|
||||
# Filter by domain match in viewing_emails
|
||||
projects_ref = db.collection("projects").where("viewing_domains", "array_contains", domain_search)
|
||||
print("HERE domain", domain_search)
|
||||
cnt = int(projects_ref.count().get()[0][0].value)
|
||||
|
||||
projects = []
|
||||
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
|
||||
projects.append(doc.to_dict())
|
||||
return (projects, cnt)
|
||||
|
||||
else:
|
||||
projects_ref = db.collection("projects")
|
||||
|
||||
else:
|
||||
# For non-admin users, check if they have domain email or specific case email
|
||||
case_domain_email = profile.get("case_domain_email", "")
|
||||
case_email = profile.get("case_email", "")
|
||||
|
||||
if case_domain_email:
|
||||
# Use exact match on viewing_domains field
|
||||
domain_lower = case_domain_email.lower()
|
||||
projects_ref = db.collection("projects").where("viewing_domains", "array_contains", domain_lower)
|
||||
elif case_email:
|
||||
# Use the original logic for specific case email match
|
||||
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email.lower())
|
||||
else:
|
||||
return ([], 0)
|
||||
|
||||
cnt = int(projects_ref.count().get()[0][0].value)
|
||||
projects = []
|
||||
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
|
||||
projects.append(doc.to_dict())
|
||||
return (projects, cnt)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to query projects: {e}")
|
||||
return ([], 0)
|
||||
|
||||
|
||||
|
||||
@app.context_processor
|
||||
@@ -63,7 +139,7 @@ def index():
|
||||
if not uid:
|
||||
return redirect(url_for("login"))
|
||||
profile = get_user_profile(uid)
|
||||
if profile.get("enabled") and profile.get("case_email"):
|
||||
if profile.get("enabled") and (profile.get("case_email") or profile.get("case_domain_email")):
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("welcome"))
|
||||
|
||||
@@ -94,6 +170,12 @@ def session_login():
|
||||
# Optional: short session
|
||||
session["expires_at"] = (datetime.utcnow() + timedelta(hours=8)).isoformat()
|
||||
|
||||
# Check if user needs password reset
|
||||
user_profile = get_user_profile(uid)
|
||||
if user_profile.get("password_reset_required"):
|
||||
return jsonify({"requires_password_reset": True})
|
||||
|
||||
print(f"logged in as {uid} - user_profile.email")
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
print("[ERR] session_login:", e)
|
||||
@@ -106,6 +188,52 @@ def logout():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.route("/require-password-reset")
|
||||
@login_required
|
||||
def require_password_reset():
|
||||
"""Show password reset page for users who need to reset their password"""
|
||||
return render_template("require_password_reset.html")
|
||||
|
||||
|
||||
@app.route("/reset-password-submit", methods=["POST"])
|
||||
@login_required
|
||||
def reset_password_submit():
|
||||
"""Handle password reset form submission"""
|
||||
uid = session.get("uid")
|
||||
profile = get_user_profile(uid)
|
||||
|
||||
new_password = request.form.get("new_password")
|
||||
confirm_password = request.form.get("confirm_password")
|
||||
|
||||
# Validate passwords match
|
||||
if new_password != confirm_password:
|
||||
flash("Passwords do not match", "error")
|
||||
return redirect(url_for("require_password_reset"))
|
||||
|
||||
# Validate password length
|
||||
if len(new_password) < 6:
|
||||
flash("Password must be at least 6 characters", "error")
|
||||
return redirect(url_for("require_password_reset"))
|
||||
|
||||
# Update password in Firebase Auth
|
||||
try:
|
||||
fb_auth.update_user(uid, password=new_password)
|
||||
print(db.collection("users").document(uid))
|
||||
|
||||
# Clear the password reset required flag in Firestore
|
||||
db.collection("users").document(uid).set({"password_reset_required": False},merge=True)
|
||||
print(db.collection("users").document(uid))
|
||||
|
||||
print(f"[INFO] Password reset successful for user {uid}")
|
||||
except Exception as e:
|
||||
print(f"[ERR] Failed to reset password for {uid}: {e}")
|
||||
flash("Failed to reset password. Please try again.", "error")
|
||||
return redirect(url_for("require_password_reset"))
|
||||
|
||||
# Allow user to login now
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.route("/welcome")
|
||||
@login_required
|
||||
def welcome():
|
||||
@@ -127,74 +255,34 @@ def dashboard(page=1):
|
||||
return redirect(url_for("welcome"))
|
||||
|
||||
is_admin = profile.get("is_admin")
|
||||
case_email = None
|
||||
if not is_admin:
|
||||
case_email = profile.get("case_email")
|
||||
if not case_email:
|
||||
return redirect(url_for("welcome"))
|
||||
if is_admin and request.args.get('case_email'):
|
||||
case_email = request.args.get('case_email').lower()
|
||||
# Validate email format
|
||||
if '@' not in case_email:
|
||||
return abort(400, "Invalid email format")
|
||||
|
||||
# Pagination settings
|
||||
per_page = int(request.args.get('per_page', 25))
|
||||
offset = (page - 1) * per_page
|
||||
query = None
|
||||
|
||||
# Get total count efficiently using a count aggregation query
|
||||
try:
|
||||
# Firestore doesn't have a direct count() method, so we need to count documents
|
||||
import time
|
||||
start_time = time.time()
|
||||
projects_ref = db.collection("projects")
|
||||
# Filter projects where case_email is in viewing_emails array
|
||||
if case_email:
|
||||
query = projects_ref.where("viewing_emails", "array_contains", case_email.lower())
|
||||
else:
|
||||
query = projects_ref
|
||||
|
||||
total_projects = int(query.count().get()[0][0].value)
|
||||
end_time = time.time()
|
||||
print(f"Filtered projects count: {total_projects} (took {end_time - start_time:.2f}s)")
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to get filtered count: {e}")
|
||||
total_projects = 0
|
||||
case_email_match = None
|
||||
if is_admin and request.args.get('case_email'):
|
||||
case_email_match = request.args.get('case_email')
|
||||
if not is_admin and (not profile.get('case_email') and not profile.get('case_domain_email')):
|
||||
return redirect(url_for("welcome"))
|
||||
paginated_rows, total_projects = projects_for(profile, case_email_match, per_page, offset)
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (total_projects + per_page - 1) // per_page # Ceiling division
|
||||
|
||||
# Read only the current page from Firestore using limit() and offset()
|
||||
import time
|
||||
start_time = time.time()
|
||||
# Filter projects where case_email is in viewing_emails array
|
||||
if case_email:
|
||||
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email.lower()).order_by("matter_description").limit(per_page).offset(offset)
|
||||
else:
|
||||
projects_ref = db.collection("projects").order_by("matter_description").limit(per_page).offset(offset)
|
||||
|
||||
docs = projects_ref.stream()
|
||||
paginated_rows = []
|
||||
|
||||
for doc in docs:
|
||||
paginated_rows.append(doc.to_dict())
|
||||
|
||||
end_time = time.time()
|
||||
print(f"Retrieved {len(paginated_rows)} projects from Firestore (page {page} of {total_pages}) in {end_time - start_time:.2f}s")
|
||||
from pprint import pprint
|
||||
pprint([p['property_contacts'] for p in paginated_rows if p['property_contacts'].get('propertyManager1', None)])
|
||||
pprint([p['ProjectId'] for p in paginated_rows ])
|
||||
print(f"Retrieved {len(paginated_rows)} projects from Firestore")
|
||||
# Render table with pagination data
|
||||
return render_template("dashboard.html",
|
||||
rows=paginated_rows,
|
||||
case_email=case_email,
|
||||
case_email=case_email_match,
|
||||
current_page=page,
|
||||
total_pages=total_pages,
|
||||
total_projects=total_projects,
|
||||
per_page=per_page)
|
||||
|
||||
|
||||
|
||||
|
||||
@app.route("/dashboard/export_xls")
|
||||
@login_required
|
||||
def dashboard_export_xls():
|
||||
@@ -206,33 +294,15 @@ def dashboard_export_xls():
|
||||
|
||||
is_admin = profile.get("is_admin")
|
||||
case_email = None
|
||||
if not is_admin:
|
||||
case_email = profile.get("case_email")
|
||||
if not case_email:
|
||||
return redirect(url_for("welcome"))
|
||||
if is_admin and request.args.get('case_email'):
|
||||
case_email = request.args.get('case_email').lower()
|
||||
# Validate email format
|
||||
if '@' not in case_email:
|
||||
return abort(400, "Invalid email format")
|
||||
if not is_admin and (not profile.get('case_email') and not profile.get('case_domain_email')):
|
||||
return redirect(url_for("welcome"))
|
||||
|
||||
# Get all projects without pagination
|
||||
try:
|
||||
projects_ref = db.collection("projects")
|
||||
all_rows, cnt = projects_for(profile, case_email, 10000, 0)
|
||||
# Filter projects where case_email is in viewing_emails array
|
||||
if case_email:
|
||||
projects_ref = projects_ref.where("viewing_emails", "array_contains", case_email.lower())
|
||||
|
||||
# Order by matter_description to maintain consistent ordering
|
||||
projects_ref = projects_ref.order_by("matter_description")
|
||||
|
||||
docs = projects_ref.stream()
|
||||
all_rows = []
|
||||
|
||||
for doc in docs:
|
||||
all_rows.append(doc.to_dict())
|
||||
|
||||
print(f"Retrieved {len(all_rows)} projects from Firestore for XLS export")
|
||||
print(f"Retrieved {cnt} projects from Firestore for XLS export")
|
||||
|
||||
# Create workbook and worksheet
|
||||
wb = Workbook()
|
||||
@@ -362,7 +432,7 @@ def dashboard_export_xls():
|
||||
elif header == 'Notice Expir. Date':
|
||||
field_name = 'notice_expiration_date'
|
||||
elif header == 'Date Case Filed':
|
||||
field_name = 'case_field_date'
|
||||
field_name = 'case_filed_date'
|
||||
elif header == 'Daily Rent Damages':
|
||||
field_name = 'daily_rent_damages'
|
||||
elif header == 'Default Date':
|
||||
|
||||
@@ -11,14 +11,15 @@ FV_USER_ID = os.environ.get("FILEVINE_USER_ID")
|
||||
|
||||
class FilevineClient:
|
||||
def __init__(self, bearer_token: str = None):
|
||||
self.bearer_token = bearer_token
|
||||
self.base_url = "https://api.filevineapp.com/fv-app/v2"
|
||||
self.bearer_token = bearer_token
|
||||
self.headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {self.bearer_token}",
|
||||
"x-fv-orgid": str(FV_ORG_ID),
|
||||
"x-fv-userid": str(FV_USER_ID),
|
||||
}
|
||||
self.get_bearer_token()
|
||||
|
||||
def get_bearer_token(self) -> str:
|
||||
"""Get a new bearer token using Filevine credentials"""
|
||||
@@ -32,6 +33,7 @@ class FilevineClient:
|
||||
}
|
||||
|
||||
headers = {"Accept": "application/json"}
|
||||
print("data is", data)
|
||||
print(data)
|
||||
resp = requests.post(url, data=data, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectModel:
|
||||
pending_tasks: List[Dict[str, Any]] = None,
|
||||
notice_service_date: str = "",
|
||||
notice_expiration_date: str = "",
|
||||
case_filed_date: str = "",
|
||||
case_field_date: str = "",
|
||||
daily_rent_damages: str = "",
|
||||
default_date: str = "",
|
||||
@@ -69,7 +70,8 @@ class ProjectModel:
|
||||
project_name: str = "",
|
||||
project_url: str = "",
|
||||
property_contacts: Dict[str, Any] = None,
|
||||
viewing_emails: List[str] = None
|
||||
viewing_emails: List[str] = None,
|
||||
viewing_domains: List[str] = None
|
||||
):
|
||||
|
||||
self.client = client
|
||||
@@ -88,6 +90,7 @@ class ProjectModel:
|
||||
self.pending_tasks = pending_tasks or []
|
||||
self.notice_service_date = notice_service_date
|
||||
self.notice_expiration_date = notice_expiration_date
|
||||
self.case_filed_date = case_filed_date or case_field_date
|
||||
self.case_field_date = case_field_date
|
||||
self.daily_rent_damages = daily_rent_damages
|
||||
self.default_date = default_date
|
||||
@@ -127,6 +130,7 @@ class ProjectModel:
|
||||
self.project_url = project_url
|
||||
self.property_contacts = property_contacts or {}
|
||||
self.viewing_emails = viewing_emails or []
|
||||
self.viewing_domains = viewing_domains or []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert the ProjectModel to a dictionary for Firestore storage."""
|
||||
@@ -147,6 +151,7 @@ class ProjectModel:
|
||||
"pending_tasks": self.pending_tasks,
|
||||
"notice_service_date": self.notice_service_date,
|
||||
"notice_expiration_date": self.notice_expiration_date,
|
||||
"case_filed_date": self.case_filed_date,
|
||||
"case_field_date": self.case_field_date,
|
||||
"daily_rent_damages": self.daily_rent_damages,
|
||||
"default_date": self.default_date,
|
||||
@@ -185,7 +190,8 @@ class ProjectModel:
|
||||
"ProjectName": self.project_name,
|
||||
"ProjectUrl": self.project_url,
|
||||
"property_contacts": self.property_contacts,
|
||||
"viewing_emails": self.viewing_emails
|
||||
"viewing_emails": self.viewing_emails,
|
||||
"viewing_domains": self.viewing_domains
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -208,6 +214,7 @@ class ProjectModel:
|
||||
pending_tasks=data.get("pending_tasks", []),
|
||||
notice_service_date=data.get("notice_service_date", ""),
|
||||
notice_expiration_date=data.get("notice_expiration_date", ""),
|
||||
case_filed_date=data.get("case_filed_date", ""),
|
||||
case_field_date=data.get("case_field_date", ""),
|
||||
daily_rent_damages=data.get("daily_rent_damages", ""),
|
||||
default_date=data.get("default_date", ""),
|
||||
@@ -246,5 +253,6 @@ class ProjectModel:
|
||||
project_name=data.get("ProjectName", ""),
|
||||
project_url=data.get("ProjectUrl", ""),
|
||||
property_contacts=data.get("property_contacts", {}),
|
||||
viewing_emails=data.get("viewing_emails", [])
|
||||
viewing_emails=data.get("viewing_emails", []),
|
||||
viewing_domains=data.get("viewing_domains", [])
|
||||
)
|
||||
|
||||
71
package-lock.json
generated
Normal file
71
package-lock.json
generated
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "rothbard",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.58.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.58.0"
|
||||
}
|
||||
}
|
||||
209
query_projects.py
Executable file
209
query_projects.py
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI script to query Firebase for projects associated with a user email.
|
||||
|
||||
Usage:
|
||||
python scripts/query_projects.py --email user@example.com
|
||||
python scripts/query_projects.py --email user@example.com --limit 10
|
||||
python scripts/query_projects.py --email @gmail.com # Domain search
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, "..")
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from firebase_init import db
|
||||
from firebase_admin import auth as fb_auth
|
||||
from utils import get_user_profile
|
||||
|
||||
|
||||
def get_uid_from_email(email: str) -> str | None:
|
||||
"""Get Firebase Auth UID from user email."""
|
||||
try:
|
||||
user = fb_auth.get_user_by_email(email)
|
||||
return user.uid
|
||||
except fb_auth.UserNotFound:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error looking up user: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def query_projects_for_user(
|
||||
uid: str,
|
||||
case_email: str | None = None,
|
||||
case_domain_email: str | None = None,
|
||||
limit: int = 100
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
Query Firestore for projects associated with a user.
|
||||
|
||||
Args:
|
||||
uid: Firebase user UID
|
||||
case_email: Specific case email to filter by (optional)
|
||||
case_domain_email: Domain to filter by (optional)
|
||||
limit: Maximum number of projects to return
|
||||
|
||||
Returns:
|
||||
Tuple of (projects list, total count)
|
||||
"""
|
||||
profile = get_user_profile(uid)
|
||||
|
||||
if not profile.get("enabled"):
|
||||
print(f"Warning: User is not enabled")
|
||||
|
||||
# Determine which filter to use
|
||||
filter_email = case_email or profile.get("case_email")
|
||||
filter_domain = case_domain_email or profile.get("case_domain_email")
|
||||
|
||||
if not filter_email and not filter_domain:
|
||||
print("Error: No case_email or case_domain_email configured for this user")
|
||||
return ([], 0)
|
||||
|
||||
try:
|
||||
if filter_domain:
|
||||
# Domain-based search
|
||||
domain_lower = filter_domain.lower()
|
||||
projects_ref = db.collection("projects").where(
|
||||
"viewing_domains", "array_contains", domain_lower
|
||||
)
|
||||
else:
|
||||
# Email-based search
|
||||
email_lower = filter_email.lower()
|
||||
projects_ref = db.collection("projects").where(
|
||||
"viewing_emails", "array_contains", email_lower
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total_count = int(projects_ref.count().get()[0][0].value)
|
||||
|
||||
# Get paginated results
|
||||
projects = []
|
||||
for doc in projects_ref.order_by("matter_description").limit(limit).stream():
|
||||
projects.append(doc.to_dict())
|
||||
|
||||
return (projects, total_count)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying projects: {e}")
|
||||
return ([], 0)
|
||||
|
||||
|
||||
def format_project(project: Dict[str, Any]) -> str:
|
||||
"""Format a project dictionary for display."""
|
||||
lines = [
|
||||
f"Matter #: {project.get('number', 'N/A')}",
|
||||
f"Description: {project.get('matter_description', 'N/A')}",
|
||||
f"Client: {project.get('client', 'N/A')}",
|
||||
f"Defendant 1: {project.get('defendant_1', 'N/A')}",
|
||||
f"Case #: {project.get('case_number', 'N/A')}",
|
||||
f"Premises: {project.get('premises_address', 'N/A')}, {project.get('premises_city', 'N/A')}",
|
||||
f"Assigned Attorney: {project.get('responsible_attorney', 'N/A')}",
|
||||
f"Primary Contact: {project.get('staff_person', 'N/A')}",
|
||||
f"Matter Stage: {project.get('phase_name', 'N/A')}",
|
||||
f"Documents: {project.get('documents_url', 'N/A')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Query Firebase for projects associated with a user email"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email", "-e",
|
||||
required=True,
|
||||
help="User email address (e.g., user@example.com or @domain.com for domain search)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", "-l",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Maximum number of projects to return (default: 100)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", "-j",
|
||||
action="store_true",
|
||||
help="Output results as JSON"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show detailed project information"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--case-email",
|
||||
help="Override case email filter (use user's case_email by default)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--case-domain",
|
||||
help="Override case domain filter (use user's case_domain_email by default)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get UID from email
|
||||
uid = get_uid_from_email(args.email)
|
||||
if not uid:
|
||||
print(f"Error: User not found with email '{args.email}'")
|
||||
sys.exit(1)
|
||||
|
||||
# Get user profile
|
||||
profile = get_user_profile(uid)
|
||||
|
||||
print(f"\nUser: {profile.get('user_email', 'N/A')}")
|
||||
print(f"UID: {uid}")
|
||||
print(f"Enabled: {profile.get('enabled', False)}")
|
||||
print(f"Is Admin: {profile.get('is_admin', False)}")
|
||||
print(f"Case Email: {profile.get('case_email', 'N/A')}")
|
||||
print(f"Case Domain Email: {profile.get('case_domain_email', 'N/A')}")
|
||||
|
||||
# Query projects
|
||||
projects, total_count = query_projects_for_user(
|
||||
uid=uid,
|
||||
case_email=args.case_email,
|
||||
case_domain_email=args.case_domain,
|
||||
limit=args.limit
|
||||
)
|
||||
|
||||
print(f"\nFound {total_count} projects (showing {min(len(projects), args.limit)})")
|
||||
|
||||
if args.json:
|
||||
# Output as JSON
|
||||
result = {
|
||||
"user": {
|
||||
"email": profile.get("user_email"),
|
||||
"uid": uid,
|
||||
"enabled": profile.get("enabled"),
|
||||
"is_admin": profile.get("is_admin"),
|
||||
"case_email": profile.get("case_email"),
|
||||
"case_domain_email": profile.get("case_domain_email")
|
||||
},
|
||||
"total_count": total_count,
|
||||
"projects": projects
|
||||
}
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
# Human-readable output
|
||||
for i, project in enumerate(projects, 1):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Project {i}:")
|
||||
print('='*60)
|
||||
if args.verbose:
|
||||
print(format_project(project))
|
||||
else:
|
||||
print(f" Matter #: {project.get('number', 'N/A')}")
|
||||
print(f" Description: {project.get('matter_description', 'N/A')}")
|
||||
print(f" Client: {project.get('client', 'N/A')}")
|
||||
print(f" Case #: {project.get('case_number', 'N/A')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
59
sync.py
59
sync.py
@@ -29,6 +29,44 @@ def convert_to_pacific_time(date_str):
|
||||
if not date_str:
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Parse the UTC datetime
|
||||
utc_time = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
|
||||
# Set timezone to UTC
|
||||
utc_time = utc_time.replace(tzinfo=pytz.UTC)
|
||||
|
||||
# Convert to Pacific Time
|
||||
pacific_time = utc_time.astimezone(pytz.timezone('America/Los_Angeles'))
|
||||
|
||||
# Format as YYYY-MM-DD
|
||||
return pacific_time.strftime('%Y-%m-%d')
|
||||
except (ValueError, AttributeError) as e:
|
||||
print(f"[WARN] Date conversion failed for '{date_str}': {e}")
|
||||
return ''
|
||||
|
||||
|
||||
def extract_domains_from_emails(emails: List[str]) -> List[str]:
|
||||
"""Extract unique domains from a list of email addresses.
|
||||
|
||||
Args:
|
||||
emails (List[str]): List of email addresses
|
||||
|
||||
Returns:
|
||||
List[str]: List of unique domains extracted from the emails
|
||||
"""
|
||||
if not emails:
|
||||
return []
|
||||
|
||||
domains = set()
|
||||
for email in emails:
|
||||
if email and '@' in email:
|
||||
# Extract domain part after @
|
||||
domain = email.split('@')[1].lower()
|
||||
domains.add(domain)
|
||||
|
||||
return sorted(list(domains))
|
||||
|
||||
try:
|
||||
# Parse the UTC datetime
|
||||
utc_time = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
@@ -133,6 +171,9 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
|
||||
# Extract default date
|
||||
default_date = convert_to_pacific_time(dates_and_deadlines.get("defaultDate")) or ''
|
||||
case_filed_date = convert_to_pacific_time(dates_and_deadlines.get("dateCaseFiled")) or ''
|
||||
cf = dates_and_deadlines.get("dateCaseFiled")
|
||||
from pprint import pprint
|
||||
print(f"CASE FILED {case_filed_date} {cf}")
|
||||
|
||||
# Extract motion hearing dates
|
||||
demurrer_hearing_date = convert_to_pacific_time(dates_and_deadlines.get("demurrerHearingDate")) or ''
|
||||
@@ -183,7 +224,7 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
|
||||
import itertools
|
||||
# valid_property_managers = list(itertools.chain(*))
|
||||
valid_property_managers = [e.get('address').lower() for pm in property_managers if pm and pm.get('emails') for e in pm.get('emails') if e and e.get('address')]
|
||||
pprint(valid_property_managers)
|
||||
print(valid_property_managers)
|
||||
|
||||
|
||||
row = ProjectModel(
|
||||
@@ -204,6 +245,7 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
|
||||
notice_service_date=notice_service_date,
|
||||
notice_expiration_date=notice_expiration_date,
|
||||
case_field_date=case_filed_date,
|
||||
case_filed_date=case_filed_date,
|
||||
daily_rent_damages=daily_rent_damages,
|
||||
default_date=default_date,
|
||||
demurrer_hearing_date=demurrer_hearing_date,
|
||||
@@ -235,13 +277,14 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
|
||||
service_attempt_date_1=convert_to_pacific_time(next(iter(service_info), {}).get('serviceDate')),
|
||||
contacts=cs,
|
||||
project_email_address=p.get("projectEmailAddress", ""),
|
||||
number=p.get("number", ""),
|
||||
number=p.get("number", "") or matter_overview.get('matterNumber', ''),
|
||||
incident_date=convert_to_pacific_time(p.get("incidentDate") or detail.get("incidentDate")),
|
||||
project_id=pid,
|
||||
project_name=p.get("projectName") or detail.get("projectName"),
|
||||
project_url=p.get("projectUrl") or detail.get("projectUrl"),
|
||||
#property_contacts=property_contacts
|
||||
viewing_emails = valid_property_managers
|
||||
viewing_emails = valid_property_managers,
|
||||
viewing_domains = extract_domains_from_emails(valid_property_managers)
|
||||
)
|
||||
# Store the results in Firestore
|
||||
from app import db # Import db from app
|
||||
@@ -303,7 +346,7 @@ def main():
|
||||
|
||||
# List projects (all pages) with filter for projects updated in the last 7 days
|
||||
from datetime import datetime, timedelta
|
||||
seven_days_ago = (datetime.now() - timedelta(days=14)).strftime('%Y-%m-%d')
|
||||
seven_days_ago = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||
projects = client.list_all_projects(latest_activity_since=seven_days_ago)
|
||||
|
||||
#projects = [p for p in projects if (p.get("projectId") or {}).get("native") == 15914808]
|
||||
@@ -321,5 +364,13 @@ def main():
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
def sync_single(x):
|
||||
client = FilevineClient()
|
||||
z = process_project(0, 1, client.fetch_project_detail(x), client)
|
||||
from pprint import pprint
|
||||
|
||||
#pprint(z)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
<p class="mt-1 text-sm text-slate-500">The email address used for project access.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="case_domain_email" class="block text-sm font-medium text-slate-700">Case Domain Email</label>
|
||||
<input type="text" id="case_domain_email" name="case_domain_email"
|
||||
value=""
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-slate-500">All cases with property contacts in this domain will be viewable to the user</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="window.location.href='/admin/users'"
|
||||
class="px-4 py-2 text-sm font-medium text-slate-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors">
|
||||
|
||||
@@ -28,9 +28,13 @@
|
||||
<div class="mt-1 flex items-center">
|
||||
<input type="checkbox" id="is_admin" name="is_admin"
|
||||
{% if user.is_admin %}checked{% endif %}
|
||||
{% if not user.is_admin %}disabled{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded">
|
||||
<label for="is_admin" class="ml-2 block text-sm text-slate-700">Check to make this user an admin</label>
|
||||
</div>
|
||||
{% if not user.is_admin %}
|
||||
<p class="mt-1 text-sm text-slate-500">Admin status can only be set during user creation.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -38,7 +42,15 @@
|
||||
<input type="email" id="case_email" name="case_email"
|
||||
value="{{ user.case_email }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-slate-500">The email address used for project access.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">All cases with this email will be viewable by the user</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="case_domain_email" class="block text-sm font-medium text-slate-700">Case Domain Email</label>
|
||||
<input type="text" id="case_domain_email" name="case_domain_email"
|
||||
value="{{ user.case_domain_email }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-slate-500">All cases with property contacts in this domain will be viewable to the user</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
@@ -62,8 +74,8 @@ document.getElementById('userForm').addEventListener('submit', function(e) {
|
||||
const userData = {
|
||||
uid: '{{ user.uid }}',
|
||||
enabled: formData.get('enabled') === 'on',
|
||||
is_admin: formData.get('is_admin') === 'on',
|
||||
case_email: formData.get('case_email')
|
||||
case_email: formData.get('case_email'),
|
||||
case_domain_email: formData.get('case_domain_email')
|
||||
};
|
||||
|
||||
fetch('/admin/users/update', {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="h-full flex flex-col">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="bg-{% if category == 'success' %}green{% else %}blue{% endif %}-50 border border-{% if category == 'success' %}green{% else %}blue{% endif %}-200 text-{% if category == 'success' %}green{% else %}blue{% endif %}-800 px-4 py-3 rounded-md mb-4">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if session.get('password_reset_link') %}
|
||||
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-md mb-4">
|
||||
<p class="font-medium">Please send an email to {{ session.get('reset_user_email') }}</p>
|
||||
@@ -67,13 +77,22 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-800">{{ user.case_email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-800">
|
||||
<form method="POST" action="/admin/users/{{ user.uid }}/reset-password" style="display: inline;">
|
||||
<button type="submit"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
|
||||
onclick="return confirm('Are you sure you want to reset the password for {{ user.user_email }}? This will send a password reset email to their account.')">
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<form method="POST" action="/admin/users/{{ user.uid }}/become-user" style="display: inline;">
|
||||
<button type="submit"
|
||||
class="text-green-600 hover:text-green-800 text-sm font-medium underline"
|
||||
onclick="return confirm('Are you sure you want to view as {{ user.user_email }}? This will replace your current session.')">
|
||||
Become User
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/users/{{ user.uid }}/reset-password" style="display: inline;">
|
||||
<button type="submit"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
|
||||
onclick="return confirm('Are you sure you want to reset the password for {{ user.user_email }}? This will send a password reset email to their account.')">
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
<a href="/" class="font-semibold">Rothbard Law Group - Cases</a>
|
||||
<nav class="space-x-4">
|
||||
{% if session.uid %}
|
||||
{% if session.impersonating %}
|
||||
<a href="/admin/users/revert" class="text-sm text-orange-600 hover:text-orange-900 font-medium">Revert to Admin</a>
|
||||
{% else %}
|
||||
<a href="/dashboard" class="text-sm text-slate-600 hover:text-slate-900">Dashboard</a>
|
||||
{% set profile = get_user_profile(session.uid) %}
|
||||
{% if profile.is_admin %}
|
||||
<a href="/admin/users" class="text-sm text-slate-600 hover:text-slate-900">Admin Users</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="/logout" class="text-sm text-slate-600 hover:text-slate-900">Logout</a>
|
||||
{% else %}
|
||||
<a href="/login" class="text-sm text-slate-600 hover:text-slate-900">Login</a>
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="h-full flex flex-col" x-data="columnConfig()">
|
||||
{% if session.impersonating %}
|
||||
{% set impersonated_profile = get_user_profile(session.uid) %}
|
||||
<div class="bg-orange-50 border border-orange-200 text-orange-800 px-4 py-3 rounded-md mb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p class="font-medium">Viewing as: {{ impersonated_profile.user_email }}</p>
|
||||
<p class="text-sm mt-1">You are impersonating this user. Click "Revert to Admin" in the navigation to return to your admin account.</p>
|
||||
</div>
|
||||
<a href="/admin/users/revert" class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-orange-700 bg-orange-100 hover:bg-orange-200 rounded-md transition-colors">
|
||||
Revert to Admin
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case_email %}
|
||||
<h1 class="text-xl font-semibold mb-4">Projects for {{ case_email }}</h1>
|
||||
{% else %}
|
||||
<h1 class="text-xl font-semibold mb-4">All projects</h1>
|
||||
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
|
||||
{% set profile = get_user_profile(session.uid) %}
|
||||
@@ -375,9 +392,8 @@
|
||||
{% endcall %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="{'hidden': !isColumnVisible('Date Case Filed')}">
|
||||
|
||||
{% call expander() %}
|
||||
{{ r.case_field_date }}
|
||||
{% if r.case_filed_date %}{{ r.case_filed_date }}{% else %}{{ r.case_field_date }}{% endif %}
|
||||
{% endcall %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" :class="{'hidden': !isColumnVisible('Daily Rent Damages')}">
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="w-full max-w-md p-8 space-y-6 bg-white rounded-xl shadow-lg">
|
||||
<h1 class="text-2xl font-bold text-center text-gray-800">Secure Access</h1>
|
||||
<div class="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded-lg mb-4">
|
||||
<p>If you don't have a user account, or need to reset your password, send an email to <a href="mailto:office@rothbardlawgroup.com" class="underline">office@rothbardlawgroup.com</a>.</p>
|
||||
</div>
|
||||
<form id="login-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
|
||||
@@ -28,18 +31,14 @@
|
||||
<script>
|
||||
// Initialize Firebase configuration from template
|
||||
window.FIREBASE_CONFIG = {{ firebase_config|tojson }};
|
||||
|
||||
// Initialize Firebase app and auth
|
||||
const app = firebase.initializeApp(window.FIREBASE_CONFIG || {});
|
||||
const auth = firebase.auth();
|
||||
|
||||
// Get form and input elements
|
||||
const form = document.getElementById('login-form');
|
||||
const email = document.getElementById('email');
|
||||
const password = document.getElementById('password');
|
||||
const err = document.getElementById('error');
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.classList.add('hidden');
|
||||
@@ -51,10 +50,17 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idToken })
|
||||
});
|
||||
if(!res.ok){
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Session exchange failed');
|
||||
}
|
||||
window.location.href = '/';
|
||||
|
||||
const data = await res.json();
|
||||
if (data.requires_password_reset) {
|
||||
window.location.href = '/require-password-reset';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = e.message || 'Authentication failed';
|
||||
err.classList.remove('hidden');
|
||||
|
||||
47
templates/require_password_reset.html
Normal file
47
templates/require_password_reset.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="h-full flex flex-col max-w-md mx-auto">
|
||||
<h1 class="text-xl font-semibold mb-6">Password Reset Required</h1>
|
||||
|
||||
<form method="POST" action="/reset-password-submit" class="space-y-6">
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password"
|
||||
value=""
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
minlength="6">
|
||||
<p class="mt-1 text-sm text-slate-500">Password must be at least 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
value=""
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<a href="/login"
|
||||
class="px-4 py-2 text-sm font-medium text-slate-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors text-center inline-block">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
152
terraform/main.tf
Normal file
152
terraform/main.tf
Normal file
@@ -0,0 +1,152 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
google-beta = {
|
||||
source = "hashicorp/google-beta"
|
||||
version = "~> 6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.gcp_project_id
|
||||
region = var.gcp_region
|
||||
}
|
||||
|
||||
|
||||
# Firebase Project Setup
|
||||
resource "google_firebase_project" "default" {
|
||||
provider = google-beta
|
||||
project = var.gcp_project_id
|
||||
}
|
||||
|
||||
# Firebase Web App
|
||||
resource "google_firebase_web_app" "rothbard_portal" {
|
||||
provider = google-beta
|
||||
project = google_firebase_project.default.project
|
||||
display_name = "Rothbard Client Portal"
|
||||
|
||||
app_urls = ["https://${var.domain_name}"]
|
||||
}
|
||||
|
||||
# Firestore Database
|
||||
resource "google_firestore_database" "default" {
|
||||
provider = google-beta
|
||||
project = var.gcp_project_id
|
||||
name = "(default)"
|
||||
location_id = var.firestore_location
|
||||
type = "FIRESTORE_NATIVE"
|
||||
|
||||
delete_protection_state = "DELETE_PROTECTION_DISABLED"
|
||||
}
|
||||
|
||||
# Firebase Authentication - Complete Configuration
|
||||
resource "google_firebase_auth_config" "default" {
|
||||
provider = google-beta
|
||||
project = var.gcp_project_id
|
||||
|
||||
sign_in_options {
|
||||
email {
|
||||
enabled = true
|
||||
password_required = true
|
||||
}
|
||||
|
||||
# Disable other providers for security
|
||||
phone {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
google {
|
||||
enabled = var.enable_google_signin
|
||||
}
|
||||
|
||||
facebook {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
apple {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
# Email configuration
|
||||
email {
|
||||
reset_password_template {
|
||||
from_email_address = var.auth_from_email
|
||||
from_display_name = var.auth_from_name
|
||||
reply_to = var.auth_reply_to
|
||||
subject = "Reset your Rothbard Law Group password"
|
||||
html = file("${path.module}/templates/reset_password.html")
|
||||
text = file("${path.module}/templates/reset_password.txt")
|
||||
}
|
||||
|
||||
email_verification_template {
|
||||
from_email_address = var.auth_from_email
|
||||
from_display_name = var.auth_from_name
|
||||
reply_to = var.auth_reply_to
|
||||
subject = "Verify your Rothbard Law Group account"
|
||||
html = file("${path.module}/templates/email_verification.html")
|
||||
text = file("${path.module}/templates/email_verification.txt")
|
||||
}
|
||||
}
|
||||
|
||||
# Security settings
|
||||
sign_in {
|
||||
allow_duplicate_emails = false
|
||||
}
|
||||
|
||||
# Multi-factor authentication (disabled for simplicity)
|
||||
multi_factor_auth {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
# Anonymous user access (disabled)
|
||||
anonymous {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
# Service Account for the Flask App
|
||||
resource "google_service_account" "flask_app" {
|
||||
account_id = "rothbard-flask-app"
|
||||
display_name = "Rothbard Flask App Service Account"
|
||||
}
|
||||
|
||||
# IAM permissions for the Flask App
|
||||
resource "google_project_iam_member" "firestore_access" {
|
||||
project = var.gcp_project_id
|
||||
role = "roles/datastore.user"
|
||||
member = "serviceAccount:${google_service_account.flask_app.email}"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "firebase_admin" {
|
||||
project = var.gcp_project_id
|
||||
role = "roles/firebase.admin"
|
||||
member = "serviceAccount:${google_service_account.flask_app.email}"
|
||||
}
|
||||
|
||||
# Firestore Security Rules - Note: Firestore security policies are managed through Firestore rules
|
||||
# This section is commented out as google_firestore_security_policy is not supported
|
||||
# Security rules should be managed through firestore.rules file or Firebase console
|
||||
|
||||
# Firebase Hosting (optional - for static assets)
|
||||
resource "google_firebase_hosting_site" "default" {
|
||||
provider = google-beta
|
||||
project = var.gcp_project_id
|
||||
site_id = "rothbard-portal"
|
||||
}
|
||||
|
||||
# Output important values
|
||||
output "firebase_web_app_id" {
|
||||
description = "Firebase Web App ID"
|
||||
value = google_firebase_web_app.rothbard_portal.app_id
|
||||
}
|
||||
|
||||
output "firebase_project_id" {
|
||||
description = "Firebase Project ID"
|
||||
value = google_firebase_project.default.project
|
||||
}
|
||||
|
||||
output "service_account_email" {
|
||||
description = "Service account email for Flask app"
|
||||
value = google_service_account.flask_app.email
|
||||
}
|
||||
144
terraform/modules/cloud_run/main.tf
Normal file
144
terraform/modules/cloud_run/main.tf
Normal file
@@ -0,0 +1,144 @@
|
||||
# Cloud Run Service for Flask App
|
||||
resource "google_cloud_run_service" "flask_app" {
|
||||
name = "${var.app_name}-service"
|
||||
location = var.gcp_region
|
||||
|
||||
template {
|
||||
spec {
|
||||
containers {
|
||||
image = var.container_image
|
||||
|
||||
# Environment variables for the Flask app
|
||||
env {
|
||||
name = "FLASK_SECRET_KEY"
|
||||
value = var.flask_secret_key
|
||||
}
|
||||
|
||||
env {
|
||||
name = "FIREBASE_PROJECT_ID"
|
||||
value = var.firebase_project_id
|
||||
}
|
||||
|
||||
env {
|
||||
name = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
value = "/etc/secrets/service-account.json"
|
||||
}
|
||||
|
||||
# Filevine API credentials
|
||||
env {
|
||||
name = "FILEVINE_CLIENT_ID"
|
||||
value = var.filevine_client_id
|
||||
}
|
||||
|
||||
env {
|
||||
name = "FILEVINE_CLIENT_SECRET"
|
||||
value = var.filevine_client_secret
|
||||
}
|
||||
|
||||
env {
|
||||
name = "FILEVINE_PERSONAL_ACCESS_TOKEN"
|
||||
value = var.filevine_pat
|
||||
}
|
||||
|
||||
env {
|
||||
name = "FILEVINE_ORG_ID"
|
||||
value = var.filevine_org_id
|
||||
}
|
||||
|
||||
env {
|
||||
name = "FILEVINE_USER_ID"
|
||||
value = var.filevine_user_id
|
||||
}
|
||||
|
||||
# Memory and CPU limits
|
||||
resources {
|
||||
limits = {
|
||||
cpu = "1000m"
|
||||
memory = "512Mi"
|
||||
}
|
||||
}
|
||||
|
||||
# Mount service account key
|
||||
volume_mount {
|
||||
name = "service-account-key"
|
||||
mount_path = "/etc/secrets"
|
||||
read_only = true
|
||||
}
|
||||
}
|
||||
|
||||
# Service account for the container
|
||||
service_account_name = var.service_account_email
|
||||
|
||||
# Volumes
|
||||
volumes {
|
||||
name = "service-account-key"
|
||||
secret {
|
||||
secret_name = google_secret_manager_secret.service_account_key.secret_id
|
||||
items {
|
||||
key = "latest"
|
||||
path = "service-account.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Allow unauthenticated access
|
||||
container_concurrency = 100
|
||||
timeout_seconds = 300
|
||||
}
|
||||
|
||||
# Traffic settings
|
||||
metadata {
|
||||
annotations = {
|
||||
"autoscaling.knative.dev/maxScale" = "10"
|
||||
"autoscaling.knative.dev/minScale" = "1"
|
||||
"run.googleapis.com/ingress" = "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traffic {
|
||||
percent = 100
|
||||
latest_revision = true
|
||||
}
|
||||
|
||||
depends_on = [google_secret_manager_secret_version.service_account_key]
|
||||
}
|
||||
|
||||
# Make Cloud Run service publicly accessible
|
||||
resource "google_cloud_run_service_iam_member" "public" {
|
||||
location = google_cloud_run_service.flask_app.location
|
||||
project = google_cloud_run_service.flask_app.project
|
||||
service = google_cloud_run_service.flask_app.name
|
||||
role = "roles/run.invoker"
|
||||
member = "allUsers"
|
||||
}
|
||||
|
||||
# Store service account key in Secret Manager
|
||||
resource "google_secret_manager_secret" "service_account_key" {
|
||||
project = var.gcp_project_id
|
||||
secret_id = "${var.app_name}-service-account-key"
|
||||
|
||||
replication {
|
||||
automatic {}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "service_account_key" {
|
||||
secret = google_secret_manager_secret.service_account_key.id
|
||||
secret_data = var.service_account_key_data
|
||||
}
|
||||
|
||||
# Cloud Storage bucket for container storage (if needed)
|
||||
resource "google_storage_bucket" "app_storage" {
|
||||
name = "${var.app_name}-storage-${var.gcp_project_id}"
|
||||
location = var.gcp_region
|
||||
force_destroy = true
|
||||
|
||||
uniform_bucket_level_access = true
|
||||
}
|
||||
|
||||
# Output the service URL
|
||||
output "service_url" {
|
||||
description = "Cloud Run service URL"
|
||||
value = google_cloud_run_service.flask_app.status[0].url
|
||||
}
|
||||
14
utils.py
14
utils.py
@@ -3,7 +3,7 @@ from firebase_init import db
|
||||
from firebase_admin import auth as fb_auth
|
||||
|
||||
def get_user_profile(uid: str):
|
||||
"""Fetch user's Firestore profile: users/{uid} => { enabled, case_email, is_admin, user_email }"""
|
||||
"""Fetch user's Firestore profile: users/{uid} => { enabled, case_email, is_admin, user_email, password_reset_required }"""
|
||||
doc_ref = db.collection("users").document(uid)
|
||||
snap = doc_ref.get()
|
||||
if not snap.exists:
|
||||
@@ -19,18 +19,24 @@ def get_user_profile(uid: str):
|
||||
"enabled": False,
|
||||
"is_admin": False,
|
||||
"user_email": user_email,
|
||||
"case_email": user_email
|
||||
"case_email": user_email,
|
||||
"case_domain_email": "",
|
||||
"password_reset_required": True
|
||||
}, merge=True)
|
||||
return {
|
||||
"enabled": False,
|
||||
"is_admin": False,
|
||||
"user_email": user_email,
|
||||
"case_email": user_email
|
||||
"case_email": user_email,
|
||||
"case_domain_email": "",
|
||||
"password_reset_required": True
|
||||
}
|
||||
data = snap.to_dict() or {}
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"is_admin": bool(data.get("is_admin", False)),
|
||||
"user_email": data.get("user_email"),
|
||||
"case_email": data.get("case_email")
|
||||
"case_email": data.get("case_email"),
|
||||
"case_domain_email": data.get("case_domain_email", ""),
|
||||
"password_reset_required": bool(data.get("password_reset_required", False))
|
||||
}
|
||||
Reference in New Issue
Block a user