include archived

This commit is contained in:
2026-07-11 23:11:19 -07:00
parent 576ae85ef0
commit 997e51e944
4 changed files with 92 additions and 26 deletions

39
app.py
View File

@@ -29,22 +29,27 @@ def login_required(view):
return view(*args, **kwargs)
return wrapped
def projects_for(profile, case_email_match, per_page, offset):
def projects_for(profile, case_email_match, per_page, offset, include_archived=False):
"""
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
include_archived (bool): When False (default), archived projects are excluded
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)
def not_archived(ref):
"""Exclude archived projects unless include_archived is set."""
return ref if include_archived else ref.where("is_archived", "==", False)
# Query Firestore for projects where case_email is in viewing_emails array
try:
cnt = 0
@@ -55,7 +60,7 @@ def projects_for(profile, case_email_match, per_page, offset):
# 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).where("is_archived", "==", False)
projects_ref = not_archived(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():
@@ -69,7 +74,7 @@ def projects_for(profile, case_email_match, per_page, offset):
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).where("is_archived", "==", False)
projects_ref = not_archived(db.collection("projects").where("viewing_domains", "array_contains", domain_search))
print("HERE domain", domain_search)
cnt = int(projects_ref.count().get()[0][0].value)
@@ -78,10 +83,10 @@ def projects_for(profile, case_email_match, per_page, offset):
projects.append(doc.to_dict())
return (projects, cnt)
else:
projects_ref = db.collection("projects").where("is_archived", "==", False)
else:
projects_ref = not_archived(db.collection("projects"))
else:
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", "")
@@ -89,10 +94,10 @@ def projects_for(profile, case_email_match, per_page, offset):
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).where("is_archived", "==", False)
projects_ref = not_archived(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()).where("is_archived", "==", False)
projects_ref = not_archived(db.collection("projects").where("viewing_emails", "array_contains", case_email.lower()))
else:
return ([], 0)
@@ -288,7 +293,8 @@ def dashboard(page=1):
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)
include_archived = request.args.get('include_archived') == '1'
paginated_rows, total_projects = projects_for(profile, case_email_match, per_page, offset, include_archived)
# Calculate pagination
total_pages = (total_projects + per_page - 1) // per_page # Ceiling division
@@ -304,6 +310,7 @@ def dashboard(page=1):
total_pages=total_pages,
total_projects=total_projects,
per_page=per_page,
include_archived=include_archived,
is_admin=is_admin)
@@ -323,9 +330,13 @@ def dashboard_export_xls():
if not is_admin and (not profile.get('case_email') and not profile.get('case_domain_email')):
return redirect(url_for("welcome"))
if is_admin and request.args.get('case_email'):
case_email = request.args.get('case_email')
include_archived = request.args.get('include_archived') == '1'
# Get all projects without pagination
try:
all_rows, cnt = projects_for(profile, case_email, 10000, 0)
all_rows, cnt = projects_for(profile, case_email, 10000, 0, include_archived)
# Filter projects where case_email is in viewing_emails array
# Order by matter_description to maintain consistent ordering
print(f"Retrieved {cnt} projects from Firestore for XLS export")

48
sync.py
View File

@@ -441,16 +441,47 @@ def get_oldest_unsynced_projects(db, fraction: float = 0.2) -> List[int]:
return []
import json
def dump_cases_to_json(projects: List[dict], client: FilevineClient, output_dir: str = "sample-cases", limit: int = 10) -> None:
"""Fetch project details and dump them as individual JSON files instead of Firestore.
Args:
projects: List of project data dictionaries from Filevine
client: FilevineClient instance
output_dir: Directory to write JSON files to
limit: Maximum number of cases to dump
"""
os.makedirs(output_dir, exist_ok=True)
projects = projects[:limit]
print(f"[DUMP] Processing {len(projects)} projects to {output_dir}/")
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
written = 0
for row in detailed_rows:
if row.get('ProjectId'):
row['is_archived'] = (row.get('phase_name') == 'Archived')
filepath = os.path.join(output_dir, f"{row['ProjectId']}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(row, f, indent=2, default=str)
written += 1
print(f"[DUMP] Wrote {filepath}")
print(f"[DUMP] Complete - {written} cases written to {output_dir}/")
def main():
"""Main function to fetch and sync projects"""
import argparse
parser = argparse.ArgumentParser(description='Sync Filevine projects to Firestore')
parser.add_argument('--mode', choices=['full', 'last_n', 'oldest_percent', 'hybrid', 'single'],
default='hybrid', help='Sync mode: full=all projects, last_n=recently active, oldest_percent=oldest by last_synced_at, hybrid=last_n+oldest_percent, single=one project')
parser.add_argument('--mode', choices=['full', 'last_n', 'oldest_percent', 'hybrid', 'single', 'dump_json'],
default='hybrid', help='Sync mode: full=all projects, last_n=recently active, oldest_percent=oldest by last_synced_at, hybrid=last_n+oldest_percent, single=one project, dump_json=export to JSON files')
parser.add_argument('--days', type=int, default=14, help='Number of days for last_n mode (default: 14)')
parser.add_argument('--percent', type=float, default=20.0, help='Percentage for oldest_percent mode (default: 20)')
parser.add_argument('--project-id', type=int, help='Project ID for single mode (required when mode=single)')
parser.add_argument('--limit', type=int, default=10, help='Number of cases to dump (default: 10)')
args = parser.parse_args()
if args.mode == 'single' and not args.project_id:
@@ -460,11 +491,14 @@ def main():
try:
client = FilevineClient()
client.get_bearer_token()
from app import db
recent_successes = 0
oldest_successes = 0
total_failures = 0
documents = []
if args.mode != 'dump_json':
from app import db
if args.mode == 'full':
print("[MODE] Full sync - fetching all projects")
@@ -561,7 +595,13 @@ def main():
total_failures = len(detailed_rows) - len(project_ids_synced)
record_sync_stats(db, recent_successes, oldest_successes, total_failures)
print(f"[SYNC] Complete - {len(documents)} projects saved to Firestore")
elif args.mode == 'dump_json':
print(f"[MODE] Dump JSON - fetching {args.limit} cases")
projects = client.list_all_projects()
dump_cases_to_json(projects, client, limit=args.limit)
if args.mode != 'dump_json':
print(f"[SYNC] Complete - {len(documents)} projects saved to Firestore")
except Exception as e:
print(f"Error during sync: {e}")

View File

@@ -1,27 +1,30 @@
{% if total_pages > 1 %}
{% set page_args = {'per_page': per_page} %}
{% if include_archived %}{% set _ = page_args.update({'include_archived': '1'}) %}{% endif %}
{% if case_email %}{% set _ = page_args.update({'case_email': case_email}) %}{% endif %}
<div class="flex justify-center items-center mt-6 space-x-2">
{% if current_page > 1 %}
<a href="{{ url_for(request.endpoint, page=current_page - 1, per_page=per_page) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
<a href="{{ url_for(request.endpoint, page=current_page - 1, **page_args) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
Previous
</a>
{% else %}
<span class="px-3 py-2 text-sm text-slate-400 bg-white border border-slate-300 rounded-md cursor-not-allowed">Previous</span>
{% endif %}
{% for page in range(1, total_pages + 1) %}
{% if page == current_page %}
<span class="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-blue-600 rounded-md">{{ page }}</span>
{% elif page == 1 or page == total_pages or (page >= current_page - 2 and page <= current_page + 2) %}
<a href="{{ url_for(request.endpoint, page=page, per_page=per_page) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
<a href="{{ url_for(request.endpoint, page=page, **page_args) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
{{ page }}
</a>
{% elif page == current_page - 3 or page == current_page + 3 %}
<span class="px-3 py-2 text-sm text-slate-400">...</span>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ url_for(request.endpoint, page=current_page + 1, per_page=per_page) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
<a href="{{ url_for(request.endpoint, page=current_page + 1, **page_args) }}" class="px-3 py-2 text-sm text-slate-600 bg-white border border-slate-300 rounded-md hover:bg-slate-50">
Next
</a>
{% else %}

View File

@@ -26,7 +26,7 @@
<div class="mb-4 flex w-[400px]">
<label for="simulateCaseEmail" class=" text-sm font-medium text-slate-700 mb-1">Simulate case email:</label>
<input type="text" id="simulateCaseEmail" x-model="case_email_sim"
@keyup.debounce.1000ms="window.location.href=`/dashboard/1?case_email=${encodeURIComponent($data.case_email_sim)}`"
@keyup.debounce.1000ms="window.location.href=`/dashboard/1?per_page=${$data.perPage}&include_archived=${$data.includeArchived ? '1' : '0'}&case_email=${encodeURIComponent($data.case_email_sim)}`"
class="w-full px-3 py-2 border w-64 border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter case email to simulate">
</div>
@@ -35,9 +35,19 @@
{% endif %}
<div class="flex gap-2 items-center">
<!-- Include Archived Toggle -->
<div class="mb-4 flex items-center">
<label class="flex items-center cursor-pointer text-sm font-medium text-slate-700">
<input type="checkbox" x-model="includeArchived"
@change="window.location.href = `/dashboard/1?per_page=${$data.perPage}&include_archived=${$data.includeArchived ? '1' : '0'}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`"
class="mr-2 h-4 w-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500">
Include archived
</label>
</div>
<!-- Export Button -->
<div class="mb-4">
<a href="{{ url_for('dashboard_export_xls') }}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<a :href="`{{ url_for('dashboard_export_xls') }}?include_archived=${$data.includeArchived ? '1' : '0'}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="mr-2 -ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
@@ -48,7 +58,7 @@
<!-- Per Page Dropdown -->
<div class="mb-4 flex items-center">
<label for="perPage" class="mr-2 text-sm font-medium text-slate-700">Items per page:</label>
<select id="perPage" x-model="perPage" @change="window.location.href = `/dashboard/1?per_page=${$data.perPage}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`"
<select id="perPage" x-model="perPage" @change="window.location.href = `/dashboard/1?per_page=${$data.perPage}&include_archived=${$data.includeArchived ? '1' : '0'}${$data.case_email_sim ? '&case_email=' + encodeURIComponent($data.case_email_sim) : ''}`"
class="px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="10">10</option>
<option value="25">25</option>
@@ -571,6 +581,7 @@
showColumnModal: false,
case_email_sim: '',
perPage: 25,
includeArchived: false,
columns: [
'Matter Num',
'Matter Description',
@@ -639,6 +650,7 @@
if (perPage) {
this.perPage = parseInt(perPage);
}
this.includeArchived = urlParams.get('include_archived') === '1';
},
isColumnVisible(columnName) {