feat: adds Sync statistics.
This commit is contained in:
35
app.py
35
app.py
@@ -195,12 +195,37 @@ def require_password_reset():
|
|||||||
return render_template("require_password_reset.html")
|
return render_template("require_password_reset.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/reset-password-submit", methods=["POST"])
|
@app.route("/sync-stats")
|
||||||
@login_required
|
@login_required
|
||||||
def reset_password_submit():
|
def sync_stats():
|
||||||
"""Handle password reset form submission"""
|
"""Display sync statistics as a stacked bar chart"""
|
||||||
uid = session.get("uid")
|
from datetime import datetime, timedelta
|
||||||
profile = get_user_profile(uid)
|
import pytz
|
||||||
|
|
||||||
|
profile = get_user_profile(session['uid'])
|
||||||
|
if not profile.get("is_admin"):
|
||||||
|
abort(403, "Access denied. Admin privileges required.")
|
||||||
|
|
||||||
|
pacific = pytz.timezone('America/Los_Angeles')
|
||||||
|
today = datetime.now(pacific)
|
||||||
|
dates = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(13, -1, -1)]
|
||||||
|
|
||||||
|
stats = []
|
||||||
|
for date in dates:
|
||||||
|
doc_id = f"sync_{date}"
|
||||||
|
doc = db.collection("sync_stats").document(doc_id).get()
|
||||||
|
if doc.exists:
|
||||||
|
d = doc.to_dict()
|
||||||
|
stats.append({
|
||||||
|
"date": date,
|
||||||
|
"recent_successes": d.get("recent_successes", 0),
|
||||||
|
"oldest_successes": d.get("oldest_successes", 0),
|
||||||
|
"failures": d.get("failures", 0)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
stats.append({"date": date, "recent_successes": 0, "oldest_successes": 0, "failures": 0})
|
||||||
|
|
||||||
|
return render_template("sync_stats.html", stats=stats)
|
||||||
|
|
||||||
new_password = request.form.get("new_password")
|
new_password = request.form.get("new_password")
|
||||||
confirm_password = request.form.get("confirm_password")
|
confirm_password = request.form.get("confirm_password")
|
||||||
|
|||||||
128
sync.py
128
sync.py
@@ -76,25 +76,62 @@ def convert_to_pacific_time(date_str):
|
|||||||
|
|
||||||
def extract_domains_from_emails(emails: List[str]) -> List[str]:
|
def extract_domains_from_emails(emails: List[str]) -> List[str]:
|
||||||
"""Extract unique domains from a list of email addresses.
|
"""Extract unique domains from a list of email addresses.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
emails (List[str]): List of email addresses
|
emails (List[str]): List of email addresses
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[str]: List of unique domains extracted from the emails
|
List[str]: List of unique domains extracted from the emails
|
||||||
"""
|
"""
|
||||||
if not emails:
|
if not emails:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
domains = set()
|
domains = set()
|
||||||
for email in emails:
|
for email in emails:
|
||||||
if email and '@' in email:
|
if email and '@' in email:
|
||||||
# Extract domain part after @
|
|
||||||
domain = email.split('@')[1].lower()
|
domain = email.split('@')[1].lower()
|
||||||
domains.add(domain)
|
domains.add(domain)
|
||||||
|
|
||||||
return sorted(list(domains))
|
return sorted(list(domains))
|
||||||
|
|
||||||
|
|
||||||
|
def record_sync_stats(db, recent_successes: int, oldest_successes: int, failures: int):
|
||||||
|
"""Record sync statistics for today in Firestore.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Firestore client
|
||||||
|
recent_successes: Number of recently active projects updated
|
||||||
|
oldest_successes: Number of oldest projects updated
|
||||||
|
failures: Number of failed updates
|
||||||
|
"""
|
||||||
|
from datetime import datetime as dt
|
||||||
|
pacific = pytz.timezone('America/Los_Angeles')
|
||||||
|
today = dt.now(pacific).strftime('%Y-%m-%d')
|
||||||
|
doc_id = f"sync_{today}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_ref = db.collection("sync_stats").document(doc_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if doc.exists:
|
||||||
|
current = doc.to_dict()
|
||||||
|
doc_ref.update({
|
||||||
|
"recent_successes": current.get("recent_successes", 0) + recent_successes,
|
||||||
|
"oldest_successes": current.get("oldest_successes", 0) + oldest_successes,
|
||||||
|
"failures": current.get("failures", 0) + failures,
|
||||||
|
"updated_at": dt.now(pytz.UTC).isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
doc_ref.set({
|
||||||
|
"date": today,
|
||||||
|
"recent_successes": recent_successes,
|
||||||
|
"oldest_successes": oldest_successes,
|
||||||
|
"failures": failures,
|
||||||
|
"created_at": dt.now(pytz.UTC).isoformat()
|
||||||
|
})
|
||||||
|
print(f"[STATS] Recorded sync stats: recent={recent_successes}, oldest={oldest_successes}, failures={failures}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to record sync stats: {e}")
|
||||||
|
|
||||||
from models.project_model import ProjectModel
|
from models.project_model import ProjectModel
|
||||||
from filevine_client import FilevineClient
|
from filevine_client import FilevineClient
|
||||||
|
|
||||||
@@ -383,9 +420,9 @@ def get_oldest_unsynced_projects(db, fraction: float = 0.2) -> List[int]:
|
|||||||
def main():
|
def main():
|
||||||
"""Main function to fetch and sync projects"""
|
"""Main function to fetch and sync projects"""
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Sync Filevine projects to Firestore')
|
parser = argparse.ArgumentParser(description='Sync Filevine projects to Firestore')
|
||||||
parser.add_argument('--mode', choices=['full', 'last_n', 'oldest_percent', 'hybrid', 'single'],
|
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')
|
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('--days', type=int, default=14, help='Number of days for last_n mode (default: 14)')
|
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('--percent', type=float, default=20.0, help='Percentage for oldest_percent mode (default: 20)')
|
||||||
@@ -401,28 +438,72 @@ def main():
|
|||||||
client.get_bearer_token()
|
client.get_bearer_token()
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
|
recent_successes = 0
|
||||||
|
oldest_successes = 0
|
||||||
|
total_failures = 0
|
||||||
|
|
||||||
if args.mode == 'full':
|
if args.mode == 'full':
|
||||||
print("[MODE] Full sync - fetching all projects")
|
print("[MODE] Full sync - fetching all projects")
|
||||||
projects = client.list_all_projects()
|
projects = client.list_all_projects()
|
||||||
|
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
||||||
|
documents = []
|
||||||
|
for row in detailed_rows:
|
||||||
|
if row.get('ProjectId'):
|
||||||
|
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
||||||
|
documents.append((row.get('ProjectId'), row))
|
||||||
|
batch_write_to_firestore(db, "projects", documents)
|
||||||
|
success_count = sum(1 for r in detailed_rows if r.get('ProjectId'))
|
||||||
|
fail_count = len(detailed_rows) - success_count
|
||||||
|
record_sync_stats(db, success_count, 0, fail_count)
|
||||||
|
|
||||||
elif args.mode == 'last_n':
|
elif args.mode == 'last_n':
|
||||||
days_ago = (datetime.now() - timedelta(days=args.days)).strftime('%Y-%m-%d')
|
days_ago = (datetime.now() - timedelta(days=args.days)).strftime('%Y-%m-%d')
|
||||||
print(f"[MODE] Last {args.days} days - fetching active since {days_ago}")
|
print(f"[MODE] Last {args.days} days - fetching active since {days_ago}")
|
||||||
projects = client.list_all_projects(latest_activity_since=days_ago)
|
projects = client.list_all_projects(latest_activity_since=days_ago)
|
||||||
|
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
||||||
|
documents = []
|
||||||
|
for row in detailed_rows:
|
||||||
|
if row.get('ProjectId'):
|
||||||
|
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
||||||
|
documents.append((row.get('ProjectId'), row))
|
||||||
|
batch_write_to_firestore(db, "projects", documents)
|
||||||
|
success_count = sum(1 for r in detailed_rows if r.get('ProjectId'))
|
||||||
|
fail_count = len(detailed_rows) - success_count
|
||||||
|
record_sync_stats(db, success_count, 0, fail_count)
|
||||||
|
|
||||||
elif args.mode == 'oldest_percent':
|
elif args.mode == 'oldest_percent':
|
||||||
fraction = args.percent / 100.0
|
fraction = args.percent / 100.0
|
||||||
oldest_ids = get_oldest_unsynced_projects(db, fraction=fraction)
|
oldest_ids = get_oldest_unsynced_projects(db, fraction=fraction)
|
||||||
print(f"[MODE] Oldest {args.percent}% - fetching {len(oldest_ids)} projects")
|
print(f"[MODE] Oldest {args.percent}% - fetching {len(oldest_ids)} projects")
|
||||||
|
|
||||||
all_projects = client.list_all_projects()
|
all_projects = client.list_all_projects()
|
||||||
projects = [p for p in all_projects if p.get("projectId", {}).get("native") in set(oldest_ids)]
|
projects = [p for p in all_projects if p.get("projectId", {}).get("native") in set(oldest_ids)]
|
||||||
|
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
||||||
|
documents = []
|
||||||
|
for row in detailed_rows:
|
||||||
|
if row.get('ProjectId'):
|
||||||
|
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
||||||
|
documents.append((row.get('ProjectId'), row))
|
||||||
|
batch_write_to_firestore(db, "projects", documents)
|
||||||
|
success_count = sum(1 for r in detailed_rows if r.get('ProjectId'))
|
||||||
|
fail_count = len(detailed_rows) - success_count
|
||||||
|
record_sync_stats(db, 0, success_count, fail_count)
|
||||||
|
|
||||||
elif args.mode == 'single':
|
elif args.mode == 'single':
|
||||||
print(f"[MODE] Single project - fetching project {args.project_id}")
|
print(f"[MODE] Single project - fetching project {args.project_id}")
|
||||||
project_detail = client.fetch_project_detail(args.project_id)
|
project_detail = client.fetch_project_detail(args.project_id)
|
||||||
projects = [project_detail] if project_detail else []
|
projects = [project_detail] if project_detail else []
|
||||||
|
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
||||||
|
documents = []
|
||||||
|
for row in detailed_rows:
|
||||||
|
if row.get('ProjectId'):
|
||||||
|
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
||||||
|
documents.append((row.get('ProjectId'), row))
|
||||||
|
batch_write_to_firestore(db, "projects", documents)
|
||||||
|
success_count = sum(1 for r in detailed_rows if r.get('ProjectId'))
|
||||||
|
fail_count = len(detailed_rows) - success_count
|
||||||
|
record_sync_stats(db, success_count, 0, fail_count)
|
||||||
|
|
||||||
elif args.mode == 'hybrid':
|
elif args.mode == 'hybrid':
|
||||||
print("[MODE] Hybrid - active + oldest")
|
print("[MODE] Hybrid - active + oldest")
|
||||||
|
|
||||||
@@ -439,17 +520,22 @@ def main():
|
|||||||
|
|
||||||
all_projects = client.list_all_projects()
|
all_projects = client.list_all_projects()
|
||||||
projects = [p for p in all_projects if p.get("projectId", {}).get("native") in all_ids_to_sync]
|
projects = [p for p in all_projects if p.get("projectId", {}).get("native") in all_ids_to_sync]
|
||||||
|
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
||||||
|
|
||||||
# Process projects in parallel
|
# Classify successes by source
|
||||||
detailed_rows = process_projects_parallel(projects, client, max_workers=10)
|
project_ids_synced = {r.get('ProjectId') for r in detailed_rows if r.get('ProjectId')}
|
||||||
|
recent_successes = len([pid for pid in project_ids_synced if pid in active_ids])
|
||||||
|
oldest_successes = len([pid for pid in project_ids_synced if pid in oldest_ids])
|
||||||
|
|
||||||
# Batch write all results to Firestore
|
documents = []
|
||||||
documents = []
|
for row in detailed_rows:
|
||||||
for row in detailed_rows:
|
if row.get('ProjectId'):
|
||||||
if row.get('ProjectId'):
|
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
||||||
row['is_archived'] = (row.get('phase_name') == 'Archived')
|
documents.append((row.get('ProjectId'), row))
|
||||||
documents.append((row.get('ProjectId'), row))
|
batch_write_to_firestore(db, "projects", documents)
|
||||||
batch_write_to_firestore(db, "projects", documents)
|
|
||||||
|
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")
|
print(f"[SYNC] Complete - {len(documents)} projects saved to Firestore")
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
{% set profile = get_user_profile(session.uid) %}
|
{% set profile = get_user_profile(session.uid) %}
|
||||||
{% if profile.is_admin %}
|
{% if profile.is_admin %}
|
||||||
<a href="/admin/users" class="text-sm text-slate-600 hover:text-slate-900">Admin Users</a>
|
<a href="/admin/users" class="text-sm text-slate-600 hover:text-slate-900">Admin Users</a>
|
||||||
|
<a href="/sync-stats" class="text-sm text-slate-600 hover:text-slate-900">Sync Stats</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/logout" class="text-sm text-slate-600 hover:text-slate-900">Logout</a>
|
<a href="/logout" class="text-sm text-slate-600 hover:text-slate-900">Logout</a>
|
||||||
|
|||||||
110
templates/sync_stats.html
Normal file
110
templates/sync_stats.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-800">Sync Statistics</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Last 14 days of sync activity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="h-80">
|
||||||
|
<canvas id="syncChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center gap-6 mt-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded bg-emerald-500"></span>
|
||||||
|
<span class="text-sm text-slate-600">Recent Updates</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded bg-sky-500"></span>
|
||||||
|
<span class="text-sm text-slate-600">Oldest Updates</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded bg-rose-500"></span>
|
||||||
|
<span class="text-sm text-slate-600">Failures</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Date</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Recent Updates</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Oldest Updates</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Failures</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3 text-slate-800">{{ stat.date }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-emerald-600">{{ stat.recent_successes }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sky-600">{{ stat.oldest_successes }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-rose-600">{{ stat.failures }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium text-slate-800">
|
||||||
|
{{ stat.recent_successes + stat.oldest_successes + stat.failures }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const ctx = document.getElementById('syncChart').getContext('2d');
|
||||||
|
const labels = {{ stats | map(attribute='date') | list | tojson }};
|
||||||
|
const recentSuccesses = {{ stats | map(attribute='recent_successes') | list | tojson }};
|
||||||
|
const oldestSuccesses = {{ stats | map(attribute='oldest_successes') | list | tojson }};
|
||||||
|
const failures = {{ stats | map(attribute='failures') | list | tojson }};
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Recent Updates',
|
||||||
|
data: recentSuccesses,
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
stack: 'stack0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Oldest Updates',
|
||||||
|
data: oldestSuccesses,
|
||||||
|
backgroundColor: '#0ea5e9',
|
||||||
|
stack: 'stack0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Failures',
|
||||||
|
data: failures,
|
||||||
|
backgroundColor: '#f43f5e',
|
||||||
|
stack: 'stack0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { stepSize: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user