Álvaro Valenzuela Valdes commited on
Commit
9c9cca9
·
1 Parent(s): 7f9e1cc

feat: implement local database management system with admin stats and clear functions

Browse files
backend/app/main.py CHANGED
@@ -9,7 +9,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
 
10
  from fastapi import FastAPI
11
  from fastapi.middleware.cors import CORSMiddleware
12
- from app.routers import analysis, company, health, tenders, documents, oc, tender_details
13
  from app.database import engine, Base, SessionLocal, SQLALCHEMY_DATABASE_URL
14
  from app.models.tender import TenderModel
15
  from app.models.analysis import AnalysisHistoryModel
@@ -49,6 +49,7 @@ app.include_router(company.router, prefix="/api", tags=["Company"])
49
  app.include_router(documents.router, prefix="/api", tags=["Documents"])
50
  app.include_router(oc.router, prefix="/api", tags=["Purchase Orders"])
51
  app.include_router(tender_details.router, prefix="/api", tags=["Tender Details"])
 
52
 
53
  @app.on_event("startup")
54
  async def startup_event():
 
9
 
10
  from fastapi import FastAPI
11
  from fastapi.middleware.cors import CORSMiddleware
12
+ from app.routers import analysis, company, health, tenders, documents, oc, tender_details, admin
13
  from app.database import engine, Base, SessionLocal, SQLALCHEMY_DATABASE_URL
14
  from app.models.tender import TenderModel
15
  from app.models.analysis import AnalysisHistoryModel
 
49
  app.include_router(documents.router, prefix="/api", tags=["Documents"])
50
  app.include_router(oc.router, prefix="/api", tags=["Purchase Orders"])
51
  app.include_router(tender_details.router, prefix="/api", tags=["Tender Details"])
52
+ app.include_router(admin.router, prefix="/api", tags=["Admin"])
53
 
54
  @app.on_event("startup")
55
  async def startup_event():
backend/app/routers/admin.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import func
4
+ from app.database import get_db
5
+ from app.models.tender import TenderModel
6
+ from app.models.oc import OCModel
7
+ from app.models.analysis import AnalysisHistoryModel
8
+ from app.services.sync import sync_tenders_to_db, sync_purchase_orders_to_db
9
+ from datetime import datetime
10
+
11
+ router = APIRouter()
12
+
13
+ @router.get("/admin/db-stats")
14
+ def get_detailed_stats(db: Session = Depends(get_db)):
15
+ try:
16
+ tenders_count = db.query(TenderModel).count()
17
+ ocs_count = db.query(OCModel).count()
18
+ analysis_count = db.query(AnalysisHistoryModel).count()
19
+
20
+ # Get top 5 buyers by tender count
21
+ top_buyers = db.query(
22
+ TenderModel.buyer,
23
+ func.count(TenderModel.code).label("count")
24
+ ).group_by(TenderModel.buyer).order_by(func.count(TenderModel.code).desc()).limit(5).all()
25
+
26
+ top_buyers_list = [{"name": b[0], "count": b[1]} for b in top_buyers]
27
+
28
+ # Get last sync date (max of last_updated)
29
+ last_tender = db.query(func.max(TenderModel.last_updated)).scalar()
30
+
31
+ return {
32
+ "total_records": tenders_count,
33
+ "total_ocs": ocs_count,
34
+ "total_analysis": analysis_count,
35
+ "top_buyers": top_buyers_list,
36
+ "last_sync": last_tender.isoformat() if last_tender else None,
37
+ "status": "Healthy"
38
+ }
39
+ except Exception as e:
40
+ raise HTTPException(status_code=500, detail=str(e))
41
+
42
+ @router.delete("/admin/db-clear")
43
+ def clear_database(db: Session = Depends(get_db)):
44
+ try:
45
+ num_tenders = db.query(TenderModel).delete()
46
+ num_ocs = db.query(OCModel).delete()
47
+ db.commit()
48
+ return {
49
+ "message": "Database cleared successfully",
50
+ "deleted": {
51
+ "tenders": num_tenders,
52
+ "purchase_orders": num_ocs
53
+ }
54
+ }
55
+ except Exception as e:
56
+ db.rollback()
57
+ raise HTTPException(status_code=500, detail=str(e))
58
+
59
+ @router.post("/admin/sync-all")
60
+ async def sync_all_data(db: Session = Depends(get_db)):
61
+ try:
62
+ tender_results = await sync_tenders_to_db(db)
63
+ oc_results = await sync_purchase_orders_to_db(db)
64
+ return {
65
+ "tenders": tender_results,
66
+ "purchase_orders": oc_results,
67
+ "timestamp": datetime.utcnow().isoformat()
68
+ }
69
+ except Exception as e:
70
+ raise HTTPException(status_code=500, detail=str(e))
frontend/app/page.tsx CHANGED
@@ -12,6 +12,7 @@ import AnalysisHistory from "../components/AnalysisHistory";
12
  import GlobalSync from "../components/GlobalSync";
13
  import MarketMonitor from "../components/MarketMonitor";
14
  import SystemInfo from "../components/SystemInfo";
 
15
  import { analyzeTender, fetchAnalysisHistory, fetchCompanyProfile, healthCheck, saveCompanyProfile, searchTenders } from "../lib/api";
16
  import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile as CompanyProfileType, Tender } from "../lib/types";
17
  import { translations, Language } from "../lib/translations";
@@ -25,6 +26,7 @@ const tabs = [
25
  "Agent Analysis",
26
  "Proposal Draft",
27
  "History",
 
28
  "About",
29
  ] as const;
30
 
@@ -350,6 +352,7 @@ export default function HomePage() {
350
  )}
351
  {activeTab === "Proposal Draft" && <ProposalDraft proposal={analysisResult?.proposal_draft ?? ""} />}
352
  {activeTab === "History" && <AnalysisHistory history={analysisHistory} searchHistory={searchHistory} />}
 
353
  {activeTab === "About" && <SystemInfo />}
354
  </div>
355
  </main>
 
12
  import GlobalSync from "../components/GlobalSync";
13
  import MarketMonitor from "../components/MarketMonitor";
14
  import SystemInfo from "../components/SystemInfo";
15
+ import DBManager from "../components/DBManager";
16
  import { analyzeTender, fetchAnalysisHistory, fetchCompanyProfile, healthCheck, saveCompanyProfile, searchTenders } from "../lib/api";
17
  import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile as CompanyProfileType, Tender } from "../lib/types";
18
  import { translations, Language } from "../lib/translations";
 
26
  "Agent Analysis",
27
  "Proposal Draft",
28
  "History",
29
+ "Database",
30
  "About",
31
  ] as const;
32
 
 
352
  )}
353
  {activeTab === "Proposal Draft" && <ProposalDraft proposal={analysisResult?.proposal_draft ?? ""} />}
354
  {activeTab === "History" && <AnalysisHistory history={analysisHistory} searchHistory={searchHistory} />}
355
+ {activeTab === "Database" && <DBManager />}
356
  {activeTab === "About" && <SystemInfo />}
357
  </div>
358
  </main>
frontend/components/DBManager.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { fetchDetailedDbStats, syncDatabase, clearDatabase } from "../lib/api";
5
+ import BrandLoader from "./BrandLoader";
6
+
7
+ export default function DBManager() {
8
+ const [stats, setStats] = useState<any>(null);
9
+ const [isLoading, setIsLoading] = useState(true);
10
+ const [isActionInProgress, setIsActionInProgress] = useState(false);
11
+ const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
12
+
13
+ const loadStats = async () => {
14
+ setIsLoading(true);
15
+ const data = await fetchDetailedDbStats();
16
+ setStats(data);
17
+ setIsLoading(false);
18
+ };
19
+
20
+ useEffect(() => {
21
+ loadStats();
22
+ }, []);
23
+
24
+ const handleSync = async () => {
25
+ setIsActionInProgress(true);
26
+ setMessage(null);
27
+ try {
28
+ const result = await syncDatabase();
29
+ setMessage({
30
+ type: 'success',
31
+ text: `Sync complete! New: ${result.tenders?.new || 0} tenders, ${result.purchase_orders?.new || 0} OCs.`
32
+ });
33
+ await loadStats();
34
+ } catch (e) {
35
+ setMessage({ type: 'error', text: 'Synchronization failed.' });
36
+ } finally {
37
+ setIsActionInProgress(false);
38
+ }
39
+ };
40
+
41
+ const handleClear = async () => {
42
+ if (!confirm("Are you sure you want to delete ALL local tenders and purchase orders? This cannot be undone.")) return;
43
+
44
+ setIsActionInProgress(true);
45
+ setMessage(null);
46
+ try {
47
+ await clearDatabase();
48
+ setMessage({ type: 'success', text: 'Local database cleared successfully.' });
49
+ await loadStats();
50
+ } catch (e) {
51
+ setMessage({ type: 'error', text: 'Failed to clear database.' });
52
+ } finally {
53
+ setIsActionInProgress(false);
54
+ }
55
+ };
56
+
57
+ if (isLoading) return (
58
+ <div className="flex items-center justify-center min-h-[400px]">
59
+ <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-cyan"></div>
60
+ </div>
61
+ );
62
+
63
+ return (
64
+ <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
65
+ {isActionInProgress && <BrandLoader />}
66
+
67
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
68
+ <div>
69
+ <h2 className="text-3xl font-black text-white tracking-tight">Database Intelligence</h2>
70
+ <p className="text-slate-400 mt-1">Manage local persistence and synchronization pipeline.</p>
71
+ </div>
72
+
73
+ <div className="flex items-center gap-3">
74
+ <button
75
+ onClick={handleSync}
76
+ disabled={isActionInProgress}
77
+ className="px-6 py-3 rounded-2xl bg-cyan text-slate-950 font-bold hover:bg-sky transition-all active:scale-95 disabled:opacity-50 flex items-center gap-2"
78
+ >
79
+ <span>🔄 Sync Everything</span>
80
+ </button>
81
+ <button
82
+ onClick={handleClear}
83
+ disabled={isActionInProgress}
84
+ className="px-6 py-3 rounded-2xl bg-red-500/10 border border-red-500/30 text-red-400 font-bold hover:bg-red-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-2"
85
+ >
86
+ <span>🗑️ Clear Local Data</span>
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ {message && (
92
+ <div className={`p-4 rounded-2xl border ${message.type === 'success' ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-red-500/10 border-red-500/30 text-red-400'} animate-in zoom-in-95 duration-300`}>
93
+ {message.text}
94
+ </div>
95
+ )}
96
+
97
+ {/* Stats Grid */}
98
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
99
+ <div className="glass-card p-8 border border-white/5 bg-white/[0.02] rounded-3xl relative overflow-hidden group">
100
+ <div className="absolute top-0 right-0 p-4 text-4xl opacity-10 group-hover:scale-110 transition-transform">📄</div>
101
+ <p className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-2">Total Tenders</p>
102
+ <h3 className="text-5xl font-black text-white">{stats?.total_records || 0}</h3>
103
+ <p className="text-[10px] text-cyan mt-4 font-mono">Last Sync: {stats?.last_sync ? new Date(stats.last_sync).toLocaleString() : 'Never'}</p>
104
+ </div>
105
+
106
+ <div className="glass-card p-8 border border-white/5 bg-white/[0.02] rounded-3xl relative overflow-hidden group">
107
+ <div className="absolute top-0 right-0 p-4 text-4xl opacity-10 group-hover:scale-110 transition-transform">🛒</div>
108
+ <p className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-2">Purchase Orders</p>
109
+ <h3 className="text-5xl font-black text-white">{stats?.total_ocs || 0}</h3>
110
+ <p className="text-[10px] text-sky mt-4 font-mono">Real-time local tracking</p>
111
+ </div>
112
+
113
+ <div className="glass-card p-8 border border-white/5 bg-white/[0.02] rounded-3xl relative overflow-hidden group">
114
+ <div className="absolute top-0 right-0 p-4 text-4xl opacity-10 group-hover:scale-110 transition-transform">🧠</div>
115
+ <p className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-2">Analyses Generated</p>
116
+ <h3 className="text-5xl font-black text-white">{stats?.total_analysis || 0}</h3>
117
+ <p className="text-[10px] text-purple-400 mt-4 font-mono">AI Intelligence persistence</p>
118
+ </div>
119
+ </div>
120
+
121
+ {/* Top Buyers List */}
122
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
123
+ <div className="glass-card p-8 border border-white/5 bg-white/[0.02] rounded-3xl">
124
+ <h3 className="text-sm font-black uppercase tracking-widest text-slate-400 mb-6 flex items-center gap-2">
125
+ <span>🏛️</span> Top Local Institutions
126
+ </h3>
127
+ <div className="space-y-4">
128
+ {stats?.top_buyers?.map((buyer: any, idx: number) => (
129
+ <div key={idx} className="flex items-center justify-between p-4 rounded-2xl bg-white/[0.03] border border-white/5">
130
+ <span className="text-sm text-slate-300 truncate max-w-[250px] font-medium">{buyer.name}</span>
131
+ <span className="text-lg font-black text-cyan font-mono">{buyer.count}</span>
132
+ </div>
133
+ ))}
134
+ {(!stats?.top_buyers || stats.top_buyers.length === 0) && (
135
+ <p className="text-slate-600 italic text-sm py-4">No institutions found in local database.</p>
136
+ )}
137
+ </div>
138
+ </div>
139
+
140
+ <div className="glass-card p-8 border border-white/5 bg-white/[0.02] rounded-3xl">
141
+ <h3 className="text-sm font-black uppercase tracking-widest text-slate-400 mb-6 flex items-center gap-2">
142
+ <span>💡</span> Persistence Insights
143
+ </h3>
144
+ <div className="space-y-6">
145
+ <div className="p-4 rounded-2xl bg-blue-500/5 border border-blue-500/10">
146
+ <p className="text-xs text-blue-400 font-bold mb-1">Local Mode Active</p>
147
+ <p className="text-xs text-slate-400 leading-relaxed">System is prioritizing local database for faster search. Global sync updates the local cache with the latest Mercado Público data.</p>
148
+ </div>
149
+ <div className="p-4 rounded-2xl bg-purple-500/5 border border-purple-500/10">
150
+ <p className="text-xs text-purple-400 font-bold mb-1">Integrity Check</p>
151
+ <p className="text-xs text-slate-400 leading-relaxed">All nested data (attachments, items, criteria) is successfully serialized as JSON in the SQLite storage.</p>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
frontend/components/Sidebar.tsx CHANGED
@@ -12,6 +12,7 @@ type SidebarTab =
12
  | "Agent Analysis"
13
  | "Proposal Draft"
14
  | "History"
 
15
  | "About";
16
 
17
  type Props = {
@@ -38,6 +39,7 @@ export default function Sidebar({ tabs, activeTab, onTabSelect, status, lang, fo
38
  case "Agent Analysis": return { label: t.agentAnalysis, icon: "🤖" };
39
  case "Proposal Draft": return { label: t.proposalDraft, icon: "✍️" };
40
  case "History": return { label: t.history, icon: "🕒" };
 
41
  case "About": return { label: t.about, icon: "ℹ️" };
42
  default: return { label: tab, icon: "•" };
43
  }
 
12
  | "Agent Analysis"
13
  | "Proposal Draft"
14
  | "History"
15
+ | "Database"
16
  | "About";
17
 
18
  type Props = {
 
39
  case "Agent Analysis": return { label: t.agentAnalysis, icon: "🤖" };
40
  case "Proposal Draft": return { label: t.proposalDraft, icon: "✍️" };
41
  case "History": return { label: t.history, icon: "🕒" };
42
+ case "Database": return { label: "Local DB", icon: "🗄️" };
43
  case "About": return { label: t.about, icon: "ℹ️" };
44
  default: return { label: tab, icon: "•" };
45
  }
frontend/lib/api.ts CHANGED
@@ -168,13 +168,27 @@ export async function fetchSearchHistory(): Promise<any[]> {
168
  }
169
 
170
  export async function syncDatabase() {
171
- const res = await fetch(`${API_BASE}/api/tenders/sync`, { method: "POST" });
172
  if (!res.ok) {
173
  throw new Error("Error syncing database");
174
  }
175
  return res.json();
176
  }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  export async function scrapeTenders(keyword: string): Promise<Tender[]> {
179
  const res = await fetch(`${API_BASE}/api/tenders/scrape?keyword=${encodeURIComponent(keyword)}`);
180
  if (!res.ok) {
 
168
  }
169
 
170
  export async function syncDatabase() {
171
+ const res = await fetch(`${API_BASE}/api/admin/sync-all`, { method: "POST" });
172
  if (!res.ok) {
173
  throw new Error("Error syncing database");
174
  }
175
  return res.json();
176
  }
177
 
178
+ export async function clearDatabase() {
179
+ const res = await fetch(`${API_BASE}/api/admin/db-clear`, { method: "DELETE" });
180
+ if (!res.ok) {
181
+ throw new Error("Error clearing database");
182
+ }
183
+ return res.json();
184
+ }
185
+
186
+ export async function fetchDetailedDbStats() {
187
+ const res = await fetch(`${API_BASE}/api/admin/db-stats`);
188
+ if (!res.ok) return null;
189
+ return res.json();
190
+ }
191
+
192
  export async function scrapeTenders(keyword: string): Promise<Tender[]> {
193
  const res = await fetch(`${API_BASE}/api/tenders/scrape?keyword=${encodeURIComponent(keyword)}`);
194
  if (!res.ok) {