Spaces:
Running
Running
admin login
Browse files- frontend/src/App.tsx +10 -5
- frontend/src/components/HeaderNav.tsx +72 -36
- frontend/src/contexts/AdminContext.tsx +110 -0
- frontend/src/pages/{DevPage.tsx β AdminPage/AdminPage.tsx} +259 -82
- frontend/src/pages/UploadPage/UploadPage.tsx +7 -7
- py_backend/ADMIN_SETUP.md +108 -0
- py_backend/alembic/versions/{0002_drone_pose_fields_and_schema.py β 0002_drone_fields.py} +1 -1
- py_backend/alembic/versions/0003_fix_json_schemas.py +175 -0
- py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py +1 -1
- py_backend/app/crud.py +12 -0
- py_backend/app/main.py +7 -0
- py_backend/app/routers/admin.py +132 -0
- py_backend/app/routers/caption.py +27 -3
- py_backend/app/routers/models.py +11 -1
- py_backend/app/routers/schemas.py +179 -0
- py_backend/app/services/huggingface_service.py +1 -1
- py_backend/app/services/schema_validator.py +217 -0
- py_backend/app/services/vlm_service.py +51 -4
- py_backend/browser_test.js +58 -0
- py_backend/requirements.txt +2 -0
- py_backend/test_admin_endpoints.py +94 -0
- py_backend/test_schema_integration.py +232 -0
- py_backend/test_schema_validation.py +140 -0
- py_backend/test_simple_validation.py +143 -0
frontend/src/App.tsx
CHANGED
|
@@ -9,8 +9,10 @@ import ExplorePage from './pages/ExplorePage';
|
|
| 9 |
import HelpPage from './pages/HelpPage';
|
| 10 |
import MapDetailPage from './pages/MapDetailsPage';
|
| 11 |
import DemoPage from './pages/DemoPage';
|
| 12 |
-
|
|
|
|
| 13 |
import { FilterProvider } from './contexts/FilterContext';
|
|
|
|
| 14 |
|
| 15 |
const router = createHashRouter([
|
| 16 |
{
|
|
@@ -22,7 +24,8 @@ const router = createHashRouter([
|
|
| 22 |
{ path: '/explore', element: <ExplorePage /> },
|
| 23 |
{ path: '/help', element: <HelpPage /> },
|
| 24 |
{ path: '/demo', element: <DemoPage /> },
|
| 25 |
-
|
|
|
|
| 26 |
{ path: '/map/:mapId', element: <MapDetailPage /> },
|
| 27 |
],
|
| 28 |
},
|
|
@@ -95,9 +98,11 @@ function Application() {
|
|
| 95 |
return (
|
| 96 |
<AlertContext.Provider value={alertContextValue}>
|
| 97 |
<LanguageContext.Provider value={languageContextValue}>
|
| 98 |
-
<
|
| 99 |
-
<
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
</LanguageContext.Provider>
|
| 102 |
</AlertContext.Provider>
|
| 103 |
);
|
|
|
|
| 9 |
import HelpPage from './pages/HelpPage';
|
| 10 |
import MapDetailPage from './pages/MapDetailsPage';
|
| 11 |
import DemoPage from './pages/DemoPage';
|
| 12 |
+
|
| 13 |
+
import AdminPage from './pages/AdminPage/AdminPage';
|
| 14 |
import { FilterProvider } from './contexts/FilterContext';
|
| 15 |
+
import { AdminProvider } from './contexts/AdminContext';
|
| 16 |
|
| 17 |
const router = createHashRouter([
|
| 18 |
{
|
|
|
|
| 24 |
{ path: '/explore', element: <ExplorePage /> },
|
| 25 |
{ path: '/help', element: <HelpPage /> },
|
| 26 |
{ path: '/demo', element: <DemoPage /> },
|
| 27 |
+
|
| 28 |
+
{ path: '/admin', element: <AdminPage /> },
|
| 29 |
{ path: '/map/:mapId', element: <MapDetailPage /> },
|
| 30 |
],
|
| 31 |
},
|
|
|
|
| 98 |
return (
|
| 99 |
<AlertContext.Provider value={alertContextValue}>
|
| 100 |
<LanguageContext.Provider value={languageContextValue}>
|
| 101 |
+
<AdminProvider>
|
| 102 |
+
<FilterProvider>
|
| 103 |
+
<RouterProvider router={router} />
|
| 104 |
+
</FilterProvider>
|
| 105 |
+
</AdminProvider>
|
| 106 |
</LanguageContext.Provider>
|
| 107 |
</AlertContext.Provider>
|
| 108 |
);
|
frontend/src/components/HeaderNav.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
| 8 |
GoMainIcon,
|
| 9 |
SettingsIcon,
|
| 10 |
} from "@ifrc-go/icons";
|
|
|
|
| 11 |
|
| 12 |
declare global {
|
| 13 |
interface Window {
|
|
@@ -19,12 +20,12 @@ const navItems = [
|
|
| 19 |
{ to: "/upload", label: "Upload", Icon: UploadCloudLineIcon },
|
| 20 |
{ to: "/explore", label: "Explore", Icon: SearchLineIcon },
|
| 21 |
{ to: "/analytics", label: "Analytics", Icon: AnalysisIcon },
|
| 22 |
-
{ to: "/dev", label: "Dev", Icon: SettingsIcon },
|
| 23 |
];
|
| 24 |
|
| 25 |
export default function HeaderNav() {
|
| 26 |
const location = useLocation();
|
| 27 |
const navigate = useNavigate();
|
|
|
|
| 28 |
|
| 29 |
return (
|
| 30 |
<nav className="border-b border-gray-200 bg-white shadow-sm sticky top-0 z-50 backdrop-blur-sm bg-white/95">
|
|
@@ -72,18 +73,18 @@ export default function HeaderNav() {
|
|
| 72 |
? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
|
| 73 |
: 'hover:bg-white hover:shadow-md hover:scale-105'
|
| 74 |
}`}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
navigate(to);
|
| 86 |
-
}}
|
| 87 |
>
|
| 88 |
<Icon className={`w-4 h-4 transition-transform duration-200 ${
|
| 89 |
isActive ? 'scale-110' : 'group-hover:scale-110'
|
|
@@ -99,31 +100,66 @@ export default function HeaderNav() {
|
|
| 99 |
})}
|
| 100 |
</nav>
|
| 101 |
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if (
|
| 114 |
-
window.confirmNavigationIfNeeded
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</PageContainer>
|
| 128 |
</nav>
|
| 129 |
);
|
|
|
|
| 8 |
GoMainIcon,
|
| 9 |
SettingsIcon,
|
| 10 |
} from "@ifrc-go/icons";
|
| 11 |
+
import { useAdmin } from "../contexts/AdminContext";
|
| 12 |
|
| 13 |
declare global {
|
| 14 |
interface Window {
|
|
|
|
| 20 |
{ to: "/upload", label: "Upload", Icon: UploadCloudLineIcon },
|
| 21 |
{ to: "/explore", label: "Explore", Icon: SearchLineIcon },
|
| 22 |
{ to: "/analytics", label: "Analytics", Icon: AnalysisIcon },
|
|
|
|
| 23 |
];
|
| 24 |
|
| 25 |
export default function HeaderNav() {
|
| 26 |
const location = useLocation();
|
| 27 |
const navigate = useNavigate();
|
| 28 |
+
const { isAuthenticated } = useAdmin();
|
| 29 |
|
| 30 |
return (
|
| 31 |
<nav className="border-b border-gray-200 bg-white shadow-sm sticky top-0 z-50 backdrop-blur-sm bg-white/95">
|
|
|
|
| 73 |
? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
|
| 74 |
: 'hover:bg-white hover:shadow-md hover:scale-105'
|
| 75 |
}`}
|
| 76 |
+
onClick={() => {
|
| 77 |
+
if (location.pathname === "/upload") {
|
| 78 |
+
if (window.confirmNavigationIfNeeded) {
|
| 79 |
+
window.confirmNavigationIfNeeded(to);
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
}
|
| 86 |
+
navigate(to);
|
| 87 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
>
|
| 89 |
<Icon className={`w-4 h-4 transition-transform duration-200 ${
|
| 90 |
isActive ? 'scale-110' : 'group-hover:scale-110'
|
|
|
|
| 100 |
})}
|
| 101 |
</nav>
|
| 102 |
|
| 103 |
+
<div className="flex items-center space-x-2">
|
| 104 |
+
<Button
|
| 105 |
+
name="help"
|
| 106 |
+
variant={location.pathname === '/help' ? "primary" : "tertiary"}
|
| 107 |
+
size={1}
|
| 108 |
+
className={`transition-all duration-200 ${
|
| 109 |
+
location.pathname === '/help'
|
| 110 |
+
? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
|
| 111 |
+
: 'hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105'
|
| 112 |
+
}`}
|
| 113 |
+
onClick={() => {
|
| 114 |
+
if (location.pathname === "/upload") {
|
| 115 |
+
if (window.confirmNavigationIfNeeded) {
|
| 116 |
+
window.confirmNavigationIfNeeded('/help');
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
}
|
| 123 |
+
navigate('/help');
|
| 124 |
+
}}
|
| 125 |
+
>
|
| 126 |
+
<QuestionLineIcon className="w-4 h-4" />
|
| 127 |
+
<span className="inline ml-2 font-semibold">Help & Support</span>
|
| 128 |
+
</Button>
|
| 129 |
+
|
| 130 |
+
<div className="relative">
|
| 131 |
+
<Container withInternalPadding className="p-2">
|
| 132 |
+
<Button
|
| 133 |
+
name="dev"
|
| 134 |
+
variant={location.pathname === '/admin' ? "primary" : "tertiary"}
|
| 135 |
+
size={1}
|
| 136 |
+
className={`transition-all duration-200 ${
|
| 137 |
+
location.pathname === '/admin'
|
| 138 |
+
? 'shadow-lg shadow-purple-500/20 transform scale-105'
|
| 139 |
+
: 'hover:bg-purple-50 hover:text-purple-600 hover:shadow-md hover:scale-105'
|
| 140 |
+
}`}
|
| 141 |
+
onClick={() => {
|
| 142 |
+
if (location.pathname === "/upload") {
|
| 143 |
+
if (window.confirmNavigationIfNeeded) {
|
| 144 |
+
window.confirmNavigationIfNeeded('/admin');
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
navigate('/admin');
|
| 152 |
+
}}
|
| 153 |
+
>
|
| 154 |
+
<SettingsIcon className="w-4 h-4" />
|
| 155 |
+
<span className="inline ml-2 font-semibold">Dev</span>
|
| 156 |
+
</Button>
|
| 157 |
+
</Container>
|
| 158 |
+
{location.pathname === '/admin' && (
|
| 159 |
+
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-purple-500 rounded-full animate-pulse"></div>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
</PageContainer>
|
| 164 |
</nav>
|
| 165 |
);
|
frontend/src/contexts/AdminContext.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
| 2 |
+
import type { ReactNode } from 'react';
|
| 3 |
+
|
| 4 |
+
interface AdminContextType {
|
| 5 |
+
isAuthenticated: boolean;
|
| 6 |
+
isLoading: boolean;
|
| 7 |
+
login: (password: string) => Promise<boolean>;
|
| 8 |
+
logout: () => void;
|
| 9 |
+
verifyToken: () => Promise<void>;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const AdminContext = createContext<AdminContextType | undefined>(undefined);
|
| 13 |
+
|
| 14 |
+
export const useAdmin = () => {
|
| 15 |
+
const context = useContext(AdminContext);
|
| 16 |
+
if (context === undefined) {
|
| 17 |
+
throw new Error('useAdmin must be used within an AdminProvider');
|
| 18 |
+
}
|
| 19 |
+
return context;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
interface AdminProviderProps {
|
| 23 |
+
children: ReactNode;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
|
| 27 |
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 28 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 29 |
+
|
| 30 |
+
const verifyToken = async () => {
|
| 31 |
+
const adminToken = localStorage.getItem('adminToken');
|
| 32 |
+
if (!adminToken) {
|
| 33 |
+
setIsAuthenticated(false);
|
| 34 |
+
setIsLoading(false);
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
const response = await fetch('/api/admin/verify', {
|
| 40 |
+
method: 'POST',
|
| 41 |
+
headers: {
|
| 42 |
+
'Content-Type': 'application/json',
|
| 43 |
+
'Authorization': `Bearer ${adminToken}`
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
if (response.ok) {
|
| 48 |
+
setIsAuthenticated(true);
|
| 49 |
+
} else {
|
| 50 |
+
// Token is invalid, remove it
|
| 51 |
+
localStorage.removeItem('adminToken');
|
| 52 |
+
setIsAuthenticated(false);
|
| 53 |
+
}
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error('Error verifying admin token:', error);
|
| 56 |
+
localStorage.removeItem('adminToken');
|
| 57 |
+
setIsAuthenticated(false);
|
| 58 |
+
} finally {
|
| 59 |
+
setIsLoading(false);
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const login = async (password: string): Promise<boolean> => {
|
| 64 |
+
try {
|
| 65 |
+
const response = await fetch('/api/admin/login', {
|
| 66 |
+
method: 'POST',
|
| 67 |
+
headers: {
|
| 68 |
+
'Content-Type': 'application/json',
|
| 69 |
+
},
|
| 70 |
+
body: JSON.stringify({ password })
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
if (response.ok) {
|
| 74 |
+
const data = await response.json();
|
| 75 |
+
localStorage.setItem('adminToken', data.access_token);
|
| 76 |
+
setIsAuthenticated(true);
|
| 77 |
+
return true;
|
| 78 |
+
} else {
|
| 79 |
+
return false;
|
| 80 |
+
}
|
| 81 |
+
} catch (error) {
|
| 82 |
+
console.error('Login error:', error);
|
| 83 |
+
return false;
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const logout = () => {
|
| 88 |
+
localStorage.removeItem('adminToken');
|
| 89 |
+
setIsAuthenticated(false);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
// Check authentication status on mount
|
| 93 |
+
useEffect(() => {
|
| 94 |
+
verifyToken();
|
| 95 |
+
}, []);
|
| 96 |
+
|
| 97 |
+
const value: AdminContextType = {
|
| 98 |
+
isAuthenticated,
|
| 99 |
+
isLoading,
|
| 100 |
+
login,
|
| 101 |
+
logout,
|
| 102 |
+
verifyToken
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<AdminContext.Provider value={value}>
|
| 107 |
+
{children}
|
| 108 |
+
</AdminContext.Provider>
|
| 109 |
+
);
|
| 110 |
+
};
|
frontend/src/pages/{DevPage.tsx β AdminPage/AdminPage.tsx}
RENAMED
|
@@ -1,11 +1,17 @@
|
|
| 1 |
-
import { useState, useEffect } from 'react';
|
| 2 |
-
import {
|
| 3 |
-
|
| 4 |
-
} from '@ifrc-go/ui';
|
| 5 |
|
| 6 |
const SELECTED_MODEL_KEY = 'selectedVlmModel';
|
| 7 |
|
| 8 |
-
export default function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const [availableModels, setAvailableModels] = useState<Array<{
|
| 10 |
m_code: string;
|
| 11 |
label: string;
|
|
@@ -15,8 +21,10 @@ export default function DevPage() {
|
|
| 15 |
const [selectedModel, setSelectedModel] = useState<string>('');
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const fetchModels = () => {
|
| 22 |
fetch('/api/models')
|
|
@@ -26,7 +34,10 @@ export default function DevPage() {
|
|
| 26 |
|
| 27 |
const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
|
| 28 |
if (modelsData.models && modelsData.models.length > 0) {
|
| 29 |
-
if (persistedModel
|
|
|
|
|
|
|
|
|
|
| 30 |
setSelectedModel(persistedModel);
|
| 31 |
} else {
|
| 32 |
const firstAvailableModel = modelsData.models.find((m: { is_available: boolean }) => m.is_available) || modelsData.models[0];
|
|
@@ -36,7 +47,7 @@ export default function DevPage() {
|
|
| 36 |
}
|
| 37 |
})
|
| 38 |
.catch(() => {
|
| 39 |
-
|
| 40 |
});
|
| 41 |
};
|
| 42 |
|
|
@@ -71,13 +82,117 @@ export default function DevPage() {
|
|
| 71 |
|
| 72 |
const handleModelChange = (modelCode: string) => {
|
| 73 |
setSelectedModel(modelCode);
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
};
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return (
|
| 78 |
<PageContainer>
|
| 79 |
<div className="mx-auto max-w-screen-lg px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
|
| 80 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
<div className="mt-8 space-y-8">
|
| 83 |
{/* Model Selection Section */}
|
|
@@ -99,6 +214,7 @@ export default function DevPage() {
|
|
| 99 |
onChange={(e) => handleModelChange(e.target.value)}
|
| 100 |
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ifrcRed focus:border-transparent min-w-[200px]"
|
| 101 |
>
|
|
|
|
| 102 |
{availableModels
|
| 103 |
.filter(model => model.is_available)
|
| 104 |
.map(model => (
|
|
@@ -109,12 +225,13 @@ export default function DevPage() {
|
|
| 109 |
</select>
|
| 110 |
{selectedModel && (
|
| 111 |
<span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded">
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
</span>
|
| 114 |
)}
|
| 115 |
</div>
|
| 116 |
-
|
| 117 |
-
|
| 118 |
</div>
|
| 119 |
</Container>
|
| 120 |
|
|
@@ -146,16 +263,16 @@ export default function DevPage() {
|
|
| 146 |
<td className="px-4 py-2 text-sm font-mono">{model.m_code}</td>
|
| 147 |
<td className="px-4 py-2 text-sm">{model.label}</td>
|
| 148 |
<td className="px-4 py-2 text-sm">{model.model_type}</td>
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
</tr>
|
| 160 |
))}
|
| 161 |
</tbody>
|
|
@@ -164,65 +281,125 @@ export default function DevPage() {
|
|
| 164 |
</div>
|
| 165 |
</Container>
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
</div>
|
| 227 |
</div>
|
| 228 |
</PageContainer>
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { useAdmin } from '../../contexts/AdminContext';
|
| 4 |
+
import { PageContainer, Heading, Button, Container, TextInput } from '@ifrc-go/ui';
|
| 5 |
|
| 6 |
const SELECTED_MODEL_KEY = 'selectedVlmModel';
|
| 7 |
|
| 8 |
+
export default function AdminPage() {
|
| 9 |
+
const navigate = useNavigate();
|
| 10 |
+
const { isAuthenticated, isLoading, login, logout } = useAdmin();
|
| 11 |
+
const [password, setPassword] = useState('');
|
| 12 |
+
const [error, setError] = useState('');
|
| 13 |
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
| 14 |
+
|
| 15 |
const [availableModels, setAvailableModels] = useState<Array<{
|
| 16 |
m_code: string;
|
| 17 |
label: string;
|
|
|
|
| 21 |
const [selectedModel, setSelectedModel] = useState<string>('');
|
| 22 |
|
| 23 |
useEffect(() => {
|
| 24 |
+
if (isAuthenticated) {
|
| 25 |
+
fetchModels();
|
| 26 |
+
}
|
| 27 |
+
}, [isAuthenticated]);
|
| 28 |
|
| 29 |
const fetchModels = () => {
|
| 30 |
fetch('/api/models')
|
|
|
|
| 34 |
|
| 35 |
const persistedModel = localStorage.getItem(SELECTED_MODEL_KEY);
|
| 36 |
if (modelsData.models && modelsData.models.length > 0) {
|
| 37 |
+
if (persistedModel === 'random') {
|
| 38 |
+
// Keep random selection
|
| 39 |
+
setSelectedModel('random');
|
| 40 |
+
} else if (persistedModel && modelsData.models.find((m: { m_code: string; is_available: boolean }) => m.m_code === persistedModel && m.is_available)) {
|
| 41 |
setSelectedModel(persistedModel);
|
| 42 |
} else {
|
| 43 |
const firstAvailableModel = modelsData.models.find((m: { is_available: boolean }) => m.is_available) || modelsData.models[0];
|
|
|
|
| 47 |
}
|
| 48 |
})
|
| 49 |
.catch(() => {
|
| 50 |
+
// Handle error silently
|
| 51 |
});
|
| 52 |
};
|
| 53 |
|
|
|
|
| 82 |
|
| 83 |
const handleModelChange = (modelCode: string) => {
|
| 84 |
setSelectedModel(modelCode);
|
| 85 |
+
if (modelCode === 'random') {
|
| 86 |
+
// For random selection, we'll select a random available model when needed
|
| 87 |
+
localStorage.setItem(SELECTED_MODEL_KEY, 'random');
|
| 88 |
+
} else {
|
| 89 |
+
localStorage.setItem(SELECTED_MODEL_KEY, modelCode);
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const handleLogin = async (e: React.FormEvent) => {
|
| 94 |
+
e.preventDefault();
|
| 95 |
+
if (!password.trim()) {
|
| 96 |
+
setError('Please enter a password');
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
setIsLoggingIn(true);
|
| 101 |
+
setError('');
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
const success = await login(password);
|
| 105 |
+
if (!success) {
|
| 106 |
+
setError('Invalid password');
|
| 107 |
+
}
|
| 108 |
+
} catch (err) {
|
| 109 |
+
setError('Login failed. Please try again.');
|
| 110 |
+
} finally {
|
| 111 |
+
setIsLoggingIn(false);
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleLogout = () => {
|
| 116 |
+
logout();
|
| 117 |
+
setPassword('');
|
| 118 |
+
setError('');
|
| 119 |
};
|
| 120 |
|
| 121 |
+
if (isLoading) {
|
| 122 |
+
return (
|
| 123 |
+
<PageContainer>
|
| 124 |
+
<div className="flex items-center justify-center min-h-[400px]">
|
| 125 |
+
<div className="text-center">
|
| 126 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ifrcRed mx-auto mb-4"></div>
|
| 127 |
+
<p className="text-gray-600">Loading...</p>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</PageContainer>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if (!isAuthenticated) {
|
| 135 |
+
return (
|
| 136 |
+
<PageContainer>
|
| 137 |
+
<div className="mx-auto max-w-md px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
|
| 138 |
+
<div className="text-center mb-8">
|
| 139 |
+
<Heading level={1}>Admin Login</Heading>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<form onSubmit={handleLogin} className="space-y-6">
|
| 143 |
+
<div>
|
| 144 |
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
| 145 |
+
Password
|
| 146 |
+
</label>
|
| 147 |
+
<TextInput
|
| 148 |
+
id="password"
|
| 149 |
+
name="password"
|
| 150 |
+
type="password"
|
| 151 |
+
value={password}
|
| 152 |
+
onChange={(value) => setPassword(value || '')}
|
| 153 |
+
placeholder="Enter admin password"
|
| 154 |
+
required
|
| 155 |
+
className="w-full"
|
| 156 |
+
/>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
{error && (
|
| 160 |
+
<div className="bg-ifrcRed/10 border border-ifrcRed/20 rounded-md p-3">
|
| 161 |
+
<p className="text-sm text-ifrcRed font-medium">{error}</p>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
|
| 165 |
+
<div className="flex justify-center">
|
| 166 |
+
<Container withInternalPadding className="p-2">
|
| 167 |
+
<Button
|
| 168 |
+
name="login"
|
| 169 |
+
type="submit"
|
| 170 |
+
variant="primary"
|
| 171 |
+
size={2}
|
| 172 |
+
disabled={isLoggingIn}
|
| 173 |
+
>
|
| 174 |
+
{isLoggingIn ? 'Logging in...' : 'Login'}
|
| 175 |
+
</Button>
|
| 176 |
+
</Container>
|
| 177 |
+
</div>
|
| 178 |
+
</form>
|
| 179 |
+
</div>
|
| 180 |
+
</PageContainer>
|
| 181 |
+
);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
return (
|
| 185 |
<PageContainer>
|
| 186 |
<div className="mx-auto max-w-screen-lg px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
|
| 187 |
+
<div className="flex justify-end mb-8">
|
| 188 |
+
<Button
|
| 189 |
+
name="logout"
|
| 190 |
+
variant="secondary"
|
| 191 |
+
onClick={handleLogout}
|
| 192 |
+
>
|
| 193 |
+
Logout
|
| 194 |
+
</Button>
|
| 195 |
+
</div>
|
| 196 |
|
| 197 |
<div className="mt-8 space-y-8">
|
| 198 |
{/* Model Selection Section */}
|
|
|
|
| 214 |
onChange={(e) => handleModelChange(e.target.value)}
|
| 215 |
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ifrcRed focus:border-transparent min-w-[200px]"
|
| 216 |
>
|
| 217 |
+
<option value="random">Random</option>
|
| 218 |
{availableModels
|
| 219 |
.filter(model => model.is_available)
|
| 220 |
.map(model => (
|
|
|
|
| 225 |
</select>
|
| 226 |
{selectedModel && (
|
| 227 |
<span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded">
|
| 228 |
+
{selectedModel === 'random'
|
| 229 |
+
? 'β Random model selection active'
|
| 230 |
+
: 'β Active for caption generation'
|
| 231 |
+
}
|
| 232 |
</span>
|
| 233 |
)}
|
| 234 |
</div>
|
|
|
|
|
|
|
| 235 |
</div>
|
| 236 |
</Container>
|
| 237 |
|
|
|
|
| 263 |
<td className="px-4 py-2 text-sm font-mono">{model.m_code}</td>
|
| 264 |
<td className="px-4 py-2 text-sm">{model.label}</td>
|
| 265 |
<td className="px-4 py-2 text-sm">{model.model_type}</td>
|
| 266 |
+
<td className="px-4 py-2 text-sm">
|
| 267 |
+
<Button
|
| 268 |
+
name={`toggle-${model.m_code}`}
|
| 269 |
+
variant={model.is_available ? "primary" : "secondary"}
|
| 270 |
+
size={1}
|
| 271 |
+
onClick={() => toggleModelAvailability(model.m_code, model.is_available)}
|
| 272 |
+
>
|
| 273 |
+
{model.is_available ? 'Enabled' : 'Disabled'}
|
| 274 |
+
</Button>
|
| 275 |
+
</td>
|
| 276 |
</tr>
|
| 277 |
))}
|
| 278 |
</tbody>
|
|
|
|
| 281 |
</div>
|
| 282 |
</Container>
|
| 283 |
|
| 284 |
+
{/* API Testing Section */}
|
| 285 |
+
<Container
|
| 286 |
+
heading="API Testing"
|
| 287 |
+
headingLevel={2}
|
| 288 |
+
withHeaderBorder
|
| 289 |
+
withInternalPadding
|
| 290 |
+
>
|
| 291 |
+
<div className="space-y-4">
|
| 292 |
+
<p className="text-gray-700">
|
| 293 |
+
Test API endpoints and model functionality.
|
| 294 |
+
</p>
|
| 295 |
+
|
| 296 |
+
<div className="flex flex-wrap gap-4">
|
| 297 |
+
<Button
|
| 298 |
+
name="test-models-api"
|
| 299 |
+
variant="secondary"
|
| 300 |
+
onClick={() => {
|
| 301 |
+
fetch('/api/models')
|
| 302 |
+
.then(r => r.json())
|
| 303 |
+
.then(() => {
|
| 304 |
+
alert('Models API response received successfully');
|
| 305 |
+
})
|
| 306 |
+
.catch(() => {
|
| 307 |
+
alert('Models API error occurred');
|
| 308 |
+
});
|
| 309 |
+
}}
|
| 310 |
+
>
|
| 311 |
+
Test Models API
|
| 312 |
+
</Button>
|
| 313 |
+
|
| 314 |
+
<Button
|
| 315 |
+
name="test-selected-model"
|
| 316 |
+
variant="secondary"
|
| 317 |
+
disabled={!selectedModel}
|
| 318 |
+
onClick={() => {
|
| 319 |
+
if (!selectedModel) return;
|
| 320 |
+
fetch(`/api/models/${selectedModel}/test`)
|
| 321 |
+
.then(r => r.json())
|
| 322 |
+
.then(() => {
|
| 323 |
+
alert('Model test completed successfully');
|
| 324 |
+
})
|
| 325 |
+
.catch(() => {
|
| 326 |
+
alert('Model test failed');
|
| 327 |
+
});
|
| 328 |
+
}}
|
| 329 |
+
>
|
| 330 |
+
Test Selected Model
|
| 331 |
+
</Button>
|
| 332 |
+
|
| 333 |
+
<Button
|
| 334 |
+
name="refresh-models"
|
| 335 |
+
variant="secondary"
|
| 336 |
+
onClick={fetchModels}
|
| 337 |
+
>
|
| 338 |
+
Refresh Models
|
| 339 |
+
</Button>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
</Container>
|
| 343 |
|
| 344 |
+
{/* Schema Validation Section */}
|
| 345 |
+
<Container
|
| 346 |
+
heading="Schema Validation"
|
| 347 |
+
headingLevel={2}
|
| 348 |
+
withHeaderBorder
|
| 349 |
+
withInternalPadding
|
| 350 |
+
>
|
| 351 |
+
<div className="space-y-4">
|
| 352 |
+
<p className="text-gray-700">
|
| 353 |
+
Monitor and test JSON schema validation for VLM responses.
|
| 354 |
+
</p>
|
| 355 |
+
|
| 356 |
+
<div className="flex flex-wrap gap-4">
|
| 357 |
+
<Button
|
| 358 |
+
name="view-schemas"
|
| 359 |
+
variant="secondary"
|
| 360 |
+
onClick={() => {
|
| 361 |
+
fetch('/api/schemas', {
|
| 362 |
+
headers: {
|
| 363 |
+
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
|
| 364 |
+
}
|
| 365 |
+
})
|
| 366 |
+
.then(r => r.json())
|
| 367 |
+
.then(data => {
|
| 368 |
+
console.log('Schemas:', data);
|
| 369 |
+
alert(`Found ${data.length} schemas. Check console for details.`);
|
| 370 |
+
})
|
| 371 |
+
.catch(() => {
|
| 372 |
+
alert('Failed to fetch schemas');
|
| 373 |
+
});
|
| 374 |
+
}}
|
| 375 |
+
>
|
| 376 |
+
View Schemas
|
| 377 |
+
</Button>
|
| 378 |
+
|
| 379 |
+
<Button
|
| 380 |
+
name="validation-stats"
|
| 381 |
+
variant="secondary"
|
| 382 |
+
onClick={() => {
|
| 383 |
+
fetch('/api/schemas/validation-stats', {
|
| 384 |
+
headers: {
|
| 385 |
+
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
|
| 386 |
+
}
|
| 387 |
+
})
|
| 388 |
+
.then(r => r.json())
|
| 389 |
+
.then(data => {
|
| 390 |
+
console.log('Validation Stats:', data);
|
| 391 |
+
alert(`Validation: ${data.validation_passed} passed, ${data.validation_failed} failed. Check console for details.`);
|
| 392 |
+
})
|
| 393 |
+
.catch(() => {
|
| 394 |
+
alert('Failed to fetch validation stats');
|
| 395 |
+
});
|
| 396 |
+
}}
|
| 397 |
+
>
|
| 398 |
+
Validation Stats
|
| 399 |
+
</Button>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
</Container>
|
| 403 |
</div>
|
| 404 |
</div>
|
| 405 |
</PageContainer>
|
frontend/src/pages/UploadPage/UploadPage.tsx
CHANGED
|
@@ -342,7 +342,7 @@ export default function UploadPage() {
|
|
| 342 |
|
| 343 |
const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
|
| 344 |
if (modelName) {
|
| 345 |
-
|
| 346 |
}
|
| 347 |
|
| 348 |
try {
|
|
@@ -365,7 +365,7 @@ export default function UploadPage() {
|
|
| 365 |
body: new URLSearchParams({
|
| 366 |
title: title || 'Generated Caption',
|
| 367 |
prompt: imageType === 'drone_image' ? 'DEFAULT_DRONE_IMAGE' : 'DEFAULT_CRISIS_MAP',
|
| 368 |
-
...(modelName
|
| 369 |
})
|
| 370 |
},
|
| 371 |
);
|
|
@@ -449,11 +449,11 @@ export default function UploadPage() {
|
|
| 449 |
const capRes = await fetch(`/api/images/${newId}/caption`, {
|
| 450 |
method: 'POST',
|
| 451 |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
});
|
| 458 |
const capJson = await readJsonSafely(capRes);
|
| 459 |
if (!capRes.ok) throw new Error((capJson.error as string) || 'Caption failed');
|
|
|
|
| 342 |
|
| 343 |
const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
|
| 344 |
if (modelName) {
|
| 345 |
+
fd.append('model_name', modelName);
|
| 346 |
}
|
| 347 |
|
| 348 |
try {
|
|
|
|
| 365 |
body: new URLSearchParams({
|
| 366 |
title: title || 'Generated Caption',
|
| 367 |
prompt: imageType === 'drone_image' ? 'DEFAULT_DRONE_IMAGE' : 'DEFAULT_CRISIS_MAP',
|
| 368 |
+
...(modelName && { model_name: modelName })
|
| 369 |
})
|
| 370 |
},
|
| 371 |
);
|
|
|
|
| 449 |
const capRes = await fetch(`/api/images/${newId}/caption`, {
|
| 450 |
method: 'POST',
|
| 451 |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 452 |
+
body: new URLSearchParams({
|
| 453 |
+
title: 'Generated Caption',
|
| 454 |
+
prompt: imageType === 'drone_image' ? 'DEFAULT_DRONE_IMAGE' : 'DEFAULT_CRISIS_MAP',
|
| 455 |
+
...(modelName && { model_name: modelName }),
|
| 456 |
+
}),
|
| 457 |
});
|
| 458 |
const capJson = await readJsonSafely(capRes);
|
| 459 |
if (!capRes.ok) throw new Error((capJson.error as string) || 'Caption failed');
|
py_backend/ADMIN_SETUP.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Admin Authentication Setup
|
| 2 |
+
|
| 3 |
+
This document explains how to set up admin authentication for the PromptAid Vision application.
|
| 4 |
+
|
| 5 |
+
## Environment Variables
|
| 6 |
+
|
| 7 |
+
Add these environment variables to your `.env` file or Hugging Face Space secrets:
|
| 8 |
+
|
| 9 |
+
### Required Variables
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# Admin password for authentication
|
| 13 |
+
ADMIN_PASSWORD=your-secure-admin-password-here
|
| 14 |
+
|
| 15 |
+
# JWT secret key for token signing (use a strong, random key)
|
| 16 |
+
JWT_SECRET_KEY=your-secure-jwt-secret-key-here
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
### Optional Variables
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
# Database connection
|
| 23 |
+
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
| 24 |
+
|
| 25 |
+
# Storage configuration
|
| 26 |
+
STORAGE_PROVIDER=local
|
| 27 |
+
STORAGE_DIR=./uploads
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## How It Works
|
| 31 |
+
|
| 32 |
+
### 1. Admin Login
|
| 33 |
+
- Users click "Admin Login" in the header navigation
|
| 34 |
+
- They enter the admin password
|
| 35 |
+
- If correct, they receive a JWT token valid for 24 hours
|
| 36 |
+
|
| 37 |
+
### 2. Authentication Flow
|
| 38 |
+
- Frontend stores the JWT token in localStorage
|
| 39 |
+
- Token is sent with each admin API request in Authorization header
|
| 40 |
+
- Backend verifies token validity and role
|
| 41 |
+
|
| 42 |
+
### 3. Security Features
|
| 43 |
+
- JWT tokens expire after 24 hours
|
| 44 |
+
- Tokens are verified on each admin request
|
| 45 |
+
- Password is stored in environment variables (not in code)
|
| 46 |
+
|
| 47 |
+
## API Endpoints
|
| 48 |
+
|
| 49 |
+
### POST `/api/admin/login`
|
| 50 |
+
- **Purpose**: Authenticate admin user
|
| 51 |
+
- **Body**: `{"password": "admin_password"}`
|
| 52 |
+
- **Response**: `{"token": "jwt_token", "expires_at": "timestamp"}`
|
| 53 |
+
|
| 54 |
+
### POST `/api/admin/verify`
|
| 55 |
+
- **Purpose**: Verify admin token
|
| 56 |
+
- **Headers**: `Authorization: Bearer <token>`
|
| 57 |
+
- **Response**: `{"valid": true/false, "message": "..."}`
|
| 58 |
+
|
| 59 |
+
### GET `/api/admin/status`
|
| 60 |
+
- **Purpose**: Get admin status (protected endpoint)
|
| 61 |
+
- **Headers**: `Authorization: Bearer <token>`
|
| 62 |
+
- **Response**: `{"status": "authenticated", "role": "admin", "timestamp": "..."}`
|
| 63 |
+
|
| 64 |
+
## Development vs Production
|
| 65 |
+
|
| 66 |
+
### Development
|
| 67 |
+
- Default password: `admin123`
|
| 68 |
+
- Default JWT secret: `your-secret-key-change-in-production`
|
| 69 |
+
- **β οΈ Change these in production!**
|
| 70 |
+
|
| 71 |
+
### Production
|
| 72 |
+
- Use strong, random passwords
|
| 73 |
+
- Use secure JWT secret keys
|
| 74 |
+
- Store secrets in environment variables or Hugging Face Space secrets
|
| 75 |
+
- Consider implementing password hashing for additional security
|
| 76 |
+
|
| 77 |
+
## Future Enhancements
|
| 78 |
+
|
| 79 |
+
- User-specific accounts and permissions
|
| 80 |
+
- Role-based access control
|
| 81 |
+
- Password hashing with bcrypt
|
| 82 |
+
- Session management
|
| 83 |
+
- Audit logging
|
| 84 |
+
- Two-factor authentication
|
| 85 |
+
|
| 86 |
+
## Troubleshooting
|
| 87 |
+
|
| 88 |
+
### Common Issues
|
| 89 |
+
|
| 90 |
+
1. **"Invalid admin password"**
|
| 91 |
+
- Check that `ADMIN_PASSWORD` environment variable is set correctly
|
| 92 |
+
- Ensure no extra spaces or characters
|
| 93 |
+
|
| 94 |
+
2. **"Token is invalid or expired"**
|
| 95 |
+
- Token may have expired (24-hour limit)
|
| 96 |
+
- Try logging in again
|
| 97 |
+
- Check `JWT_SECRET_KEY` is consistent
|
| 98 |
+
|
| 99 |
+
3. **"Method Not Allowed"**
|
| 100 |
+
- Ensure admin router is properly included in main.py
|
| 101 |
+
- Check API endpoint URLs are correct
|
| 102 |
+
|
| 103 |
+
### Debug Steps
|
| 104 |
+
|
| 105 |
+
1. Verify environment variables are loaded
|
| 106 |
+
2. Check backend logs for authentication errors
|
| 107 |
+
3. Verify JWT token format in browser localStorage
|
| 108 |
+
4. Test API endpoints directly with tools like curl or Postman
|
py_backend/alembic/versions/{0002_drone_pose_fields_and_schema.py β 0002_drone_fields.py}
RENAMED
|
@@ -10,7 +10,7 @@ import json
|
|
| 10 |
|
| 11 |
# Alembic identifiers
|
| 12 |
revision = "0002_drone_fields"
|
| 13 |
-
down_revision = "
|
| 14 |
branch_labels = None
|
| 15 |
depends_on = None
|
| 16 |
|
|
|
|
| 10 |
|
| 11 |
# Alembic identifiers
|
| 12 |
revision = "0002_drone_fields"
|
| 13 |
+
down_revision = "b8fc40bfe3c7"
|
| 14 |
branch_labels = None
|
| 15 |
depends_on = None
|
| 16 |
|
py_backend/alembic/versions/0003_fix_json_schemas.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fix JSON schemas to match validation requirements
|
| 2 |
+
|
| 3 |
+
Revision ID: 0003_fix_json_schemas
|
| 4 |
+
Revises: 0002_drone_pose_fields_and_schema
|
| 5 |
+
Create Date: 2025-01-20 10:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '0003_fix_json_schemas'
|
| 14 |
+
down_revision = '0002_drone_fields'
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade():
|
| 20 |
+
"""Fix the JSON schemas to match validation requirements"""
|
| 21 |
+
|
| 22 |
+
# Fix the default crisis map schema
|
| 23 |
+
crisis_schema = {
|
| 24 |
+
"type": "object",
|
| 25 |
+
"properties": {
|
| 26 |
+
"analysis": {"type": "string"},
|
| 27 |
+
"metadata": {
|
| 28 |
+
"type": "object",
|
| 29 |
+
"properties": {
|
| 30 |
+
"title": {"type": "string"},
|
| 31 |
+
"source": {"type": "string"},
|
| 32 |
+
"type": {"type": "string"},
|
| 33 |
+
"countries": {"type": "array", "items": {"type": "string"}},
|
| 34 |
+
"epsg": {"type": "string"}
|
| 35 |
+
},
|
| 36 |
+
"required": ["title", "source", "type", "countries", "epsg"]
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"required": ["analysis", "metadata"]
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
op.execute(
|
| 43 |
+
sa.text(
|
| 44 |
+
"""
|
| 45 |
+
UPDATE json_schemas
|
| 46 |
+
SET schema = CAST(:schema AS JSONB)
|
| 47 |
+
WHERE schema_id = '[email protected]'
|
| 48 |
+
"""
|
| 49 |
+
).bindparams(
|
| 50 |
+
schema=json.dumps(crisis_schema, separators=(",", ":"))
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Fix the drone schema (the current one is mostly correct, but let's ensure it's perfect)
|
| 55 |
+
drone_schema = {
|
| 56 |
+
"type": "object",
|
| 57 |
+
"properties": {
|
| 58 |
+
"analysis": {"type": "string"},
|
| 59 |
+
"metadata": {
|
| 60 |
+
"type": "object",
|
| 61 |
+
"properties": {
|
| 62 |
+
"title": {"type": ["string", "null"]},
|
| 63 |
+
"source": {"type": ["string", "null"]},
|
| 64 |
+
"type": {"type": ["string", "null"]},
|
| 65 |
+
"countries": {"type": ["array", "null"], "items": {"type": "string"}},
|
| 66 |
+
"epsg": {"type": ["string", "null"]},
|
| 67 |
+
"center_lat": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 68 |
+
"center_lon": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 69 |
+
"amsl_m": {"type": ["number", "null"]},
|
| 70 |
+
"agl_m": {"type": ["number", "null"]},
|
| 71 |
+
"heading_deg": {"type": ["number", "null"], "minimum": 0, "maximum": 360},
|
| 72 |
+
"yaw_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 73 |
+
"pitch_deg": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 74 |
+
"roll_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 75 |
+
"rtk_fix": {"type": ["boolean", "null"]},
|
| 76 |
+
"std_h_m": {"type": ["number", "null"], "minimum": 0},
|
| 77 |
+
"std_v_m": {"type": ["number", "null"], "minimum": 0}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"required": ["analysis", "metadata"]
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
op.execute(
|
| 85 |
+
sa.text(
|
| 86 |
+
"""
|
| 87 |
+
UPDATE json_schemas
|
| 88 |
+
SET schema = CAST(:schema AS JSONB)
|
| 89 |
+
WHERE schema_id = '[email protected]'
|
| 90 |
+
"""
|
| 91 |
+
).bindparams(
|
| 92 |
+
schema=json.dumps(drone_schema, separators=(",", ":"))
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
print("β Updated JSON schemas to match validation requirements")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def downgrade():
|
| 100 |
+
"""Revert to previous schema versions"""
|
| 101 |
+
|
| 102 |
+
# Revert crisis map schema to original
|
| 103 |
+
original_crisis_schema = {
|
| 104 |
+
"type": "object",
|
| 105 |
+
"properties": {
|
| 106 |
+
"analysis": {"type": "string"},
|
| 107 |
+
"metadata": {
|
| 108 |
+
"type": "object",
|
| 109 |
+
"properties": {
|
| 110 |
+
"title": {"type": "string"},
|
| 111 |
+
"source": {"type": "string"},
|
| 112 |
+
"type": {"type": "string"},
|
| 113 |
+
"countries": {"type": "array", "items": {"type": "string"}},
|
| 114 |
+
"epsg": {"type": "string"}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
},
|
| 118 |
+
"required": ["analysis", "metadata"]
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
op.execute(
|
| 122 |
+
sa.text(
|
| 123 |
+
"""
|
| 124 |
+
UPDATE json_schemas
|
| 125 |
+
SET schema = CAST(:schema AS JSONB)
|
| 126 |
+
WHERE schema_id = '[email protected]'
|
| 127 |
+
"""
|
| 128 |
+
).bindparams(
|
| 129 |
+
schema=json.dumps(original_crisis_schema, separators=(",", ":"))
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Revert drone schema to original
|
| 134 |
+
original_drone_schema = {
|
| 135 |
+
"type": "object",
|
| 136 |
+
"properties": {
|
| 137 |
+
"analysis": {"type": "string"},
|
| 138 |
+
"metadata": {
|
| 139 |
+
"type": "object",
|
| 140 |
+
"properties": {
|
| 141 |
+
"title": {"type": ["string", "null"]},
|
| 142 |
+
"source": {"type": ["string", "null"]},
|
| 143 |
+
"type": {"type": ["string", "null"]},
|
| 144 |
+
"countries": {"type": ["array", "null"], "items": {"type": "string"}},
|
| 145 |
+
"epsg": {"type": ["string", "null"]},
|
| 146 |
+
"center_lat": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 147 |
+
"center_lon": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 148 |
+
"amsl_m": {"type": ["number", "null"]},
|
| 149 |
+
"agl_m": {"type": ["number", "null"]},
|
| 150 |
+
"heading_deg": {"type": ["number", "null"], "minimum": 0, "maximum": 360},
|
| 151 |
+
"yaw_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 152 |
+
"pitch_deg": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 153 |
+
"roll_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 154 |
+
"rtk_fix": {"type": ["boolean", "null"]},
|
| 155 |
+
"std_h_m": {"type": ["number", "null"], "minimum": 0},
|
| 156 |
+
"std_v_m": {"type": ["number", "null"], "minimum": 0}
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
},
|
| 160 |
+
"required": ["analysis", "metadata"]
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
op.execute(
|
| 164 |
+
sa.text(
|
| 165 |
+
"""
|
| 166 |
+
UPDATE json_schemas
|
| 167 |
+
SET schema = CAST(:schema AS JSONB)
|
| 168 |
+
WHERE schema_id = '[email protected]'
|
| 169 |
+
"""
|
| 170 |
+
).bindparams(
|
| 171 |
+
schema=json.dumps(original_drone_schema, separators=(",", ":"))
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
print("β Reverted JSON schemas to previous versions")
|
py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py
CHANGED
|
@@ -9,7 +9,7 @@ import sqlalchemy as sa
|
|
| 9 |
from sqlalchemy.dialects import postgresql
|
| 10 |
import pycountry
|
| 11 |
|
| 12 |
-
revision = '
|
| 13 |
down_revision = None
|
| 14 |
branch_labels = None
|
| 15 |
depends_on = None
|
|
|
|
| 9 |
from sqlalchemy.dialects import postgresql
|
| 10 |
import pycountry
|
| 11 |
|
| 12 |
+
revision = 'b8fc40bfe3c7'
|
| 13 |
down_revision = None
|
| 14 |
branch_labels = None
|
| 15 |
depends_on = None
|
py_backend/app/crud.py
CHANGED
|
@@ -211,3 +211,15 @@ def get_models(db: Session):
|
|
| 211 |
def get_model(db: Session, m_code: str):
|
| 212 |
"""Get a specific model by code"""
|
| 213 |
return db.get(models.Models, m_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def get_model(db: Session, m_code: str):
|
| 212 |
"""Get a specific model by code"""
|
| 213 |
return db.get(models.Models, m_code)
|
| 214 |
+
|
| 215 |
+
def get_all_schemas(db: Session):
|
| 216 |
+
"""Get all JSON schemas"""
|
| 217 |
+
return db.query(models.JSONSchema).all()
|
| 218 |
+
|
| 219 |
+
def get_schema(db: Session, schema_id: str):
|
| 220 |
+
"""Get a specific JSON schema by ID"""
|
| 221 |
+
return db.query(models.JSONSchema).filter(models.JSONSchema.schema_id == schema_id).first()
|
| 222 |
+
|
| 223 |
+
def get_recent_images_with_validation(db: Session, limit: int = 100):
|
| 224 |
+
"""Get recent images with validation info"""
|
| 225 |
+
return db.query(models.Images).order_by(models.Images.created_at.desc()).limit(limit).all()
|
py_backend/app/main.py
CHANGED
|
@@ -7,10 +7,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.config import settings
|
| 11 |
from app.routers import upload, caption, metadata, models
|
| 12 |
from app.routers.images import router as images_router
|
| 13 |
from app.routers.prompts import router as prompts_router
|
|
|
|
|
|
|
| 14 |
|
| 15 |
app = FastAPI(title="PromptAid Vision")
|
| 16 |
|
|
@@ -29,6 +34,8 @@ app.include_router(models.router, prefix="/api", tags=["models"]
|
|
| 29 |
app.include_router(upload.router, prefix="/api/images", tags=["images"])
|
| 30 |
app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
|
| 31 |
app.include_router(prompts_router, prefix="/api/prompts", tags=["prompts"])
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@app.get("/health", include_in_schema=False, response_class=JSONResponse)
|
| 34 |
async def health():
|
|
|
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
|
| 9 |
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
from app.config import settings
|
| 14 |
from app.routers import upload, caption, metadata, models
|
| 15 |
from app.routers.images import router as images_router
|
| 16 |
from app.routers.prompts import router as prompts_router
|
| 17 |
+
from app.routers.admin import router as admin_router
|
| 18 |
+
from app.routers.schemas import router as schemas_router
|
| 19 |
|
| 20 |
app = FastAPI(title="PromptAid Vision")
|
| 21 |
|
|
|
|
| 34 |
app.include_router(upload.router, prefix="/api/images", tags=["images"])
|
| 35 |
app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
|
| 36 |
app.include_router(prompts_router, prefix="/api/prompts", tags=["prompts"])
|
| 37 |
+
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
| 38 |
+
app.include_router(schemas_router, prefix="/api", tags=["schemas"])
|
| 39 |
|
| 40 |
@app.get("/health", include_in_schema=False, response_class=JSONResponse)
|
| 41 |
async def health():
|
py_backend/app/routers/admin.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import jwt
|
| 3 |
+
import hashlib
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Depends, Header
|
| 6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from ..database import SessionLocal
|
| 11 |
+
from ..config import settings
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
security = HTTPBearer()
|
| 15 |
+
|
| 16 |
+
# Models
|
| 17 |
+
class AdminLoginRequest(BaseModel):
|
| 18 |
+
password: str
|
| 19 |
+
|
| 20 |
+
class AdminLoginResponse(BaseModel):
|
| 21 |
+
access_token: str
|
| 22 |
+
token_type: str = "bearer"
|
| 23 |
+
expires_at: str
|
| 24 |
+
|
| 25 |
+
class AdminVerifyResponse(BaseModel):
|
| 26 |
+
valid: bool
|
| 27 |
+
message: str
|
| 28 |
+
|
| 29 |
+
def get_db():
|
| 30 |
+
db = SessionLocal()
|
| 31 |
+
try:
|
| 32 |
+
yield db
|
| 33 |
+
finally:
|
| 34 |
+
db.close()
|
| 35 |
+
|
| 36 |
+
def get_admin_password():
|
| 37 |
+
"""Get admin password from environment variable"""
|
| 38 |
+
password = os.getenv('ADMIN_PASSWORD')
|
| 39 |
+
if not password:
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=500,
|
| 42 |
+
detail="ADMIN_PASSWORD environment variable not set"
|
| 43 |
+
)
|
| 44 |
+
return password
|
| 45 |
+
|
| 46 |
+
def create_admin_token():
|
| 47 |
+
"""Create a JWT token for admin authentication"""
|
| 48 |
+
# In production, use a proper secret key
|
| 49 |
+
secret_key = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-in-production')
|
| 50 |
+
|
| 51 |
+
payload = {
|
| 52 |
+
'role': 'admin',
|
| 53 |
+
'exp': datetime.utcnow() + timedelta(hours=24), # 24 hour expiry
|
| 54 |
+
'iat': datetime.utcnow()
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
token = jwt.encode(payload, secret_key, algorithm='HS256')
|
| 58 |
+
return token
|
| 59 |
+
|
| 60 |
+
def verify_admin_token(token: str):
|
| 61 |
+
"""Verify the admin JWT token"""
|
| 62 |
+
try:
|
| 63 |
+
secret_key = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-in-production')
|
| 64 |
+
payload = jwt.decode(token, secret_key, algorithms=['HS256'])
|
| 65 |
+
|
| 66 |
+
if payload.get('role') != 'admin':
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# Check if token is expired
|
| 70 |
+
exp = payload.get('exp')
|
| 71 |
+
if exp and datetime.utcnow() > datetime.fromtimestamp(exp):
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
return True
|
| 75 |
+
except jwt.ExpiredSignatureError:
|
| 76 |
+
return False
|
| 77 |
+
except jwt.InvalidTokenError:
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
@router.post("/login", response_model=AdminLoginResponse)
|
| 81 |
+
async def admin_login(request: AdminLoginRequest):
|
| 82 |
+
"""Admin login endpoint"""
|
| 83 |
+
admin_password = get_admin_password()
|
| 84 |
+
|
| 85 |
+
# Hash the provided password and compare with stored hash
|
| 86 |
+
# For now, using simple comparison (in production, use proper hashing)
|
| 87 |
+
if request.password == admin_password:
|
| 88 |
+
token = create_admin_token()
|
| 89 |
+
expires_at = datetime.utcnow() + timedelta(hours=24)
|
| 90 |
+
|
| 91 |
+
return AdminLoginResponse(
|
| 92 |
+
access_token=token,
|
| 93 |
+
expires_at=expires_at.isoformat()
|
| 94 |
+
)
|
| 95 |
+
else:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=401,
|
| 98 |
+
detail="Invalid admin password"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
@router.post("/verify", response_model=AdminVerifyResponse)
|
| 102 |
+
async def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 103 |
+
"""Verify admin token endpoint"""
|
| 104 |
+
token = credentials.credentials
|
| 105 |
+
|
| 106 |
+
if verify_admin_token(token):
|
| 107 |
+
return AdminVerifyResponse(
|
| 108 |
+
valid=True,
|
| 109 |
+
message="Token is valid"
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
return AdminVerifyResponse(
|
| 113 |
+
valid=False,
|
| 114 |
+
message="Token is invalid or expired"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
@router.get("/status")
|
| 118 |
+
async def admin_status(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 119 |
+
"""Get admin status (protected endpoint)"""
|
| 120 |
+
token = credentials.credentials
|
| 121 |
+
|
| 122 |
+
if not verify_admin_token(token):
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=401,
|
| 125 |
+
detail="Invalid or expired token"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"status": "authenticated",
|
| 130 |
+
"role": "admin",
|
| 131 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 132 |
+
}
|
py_backend/app/routers/caption.py
CHANGED
|
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
|
| 3 |
from typing import List
|
| 4 |
from .. import crud, database, schemas, storage
|
| 5 |
from ..services.vlm_service import vlm_manager
|
|
|
|
| 6 |
from ..config import settings
|
| 7 |
|
| 8 |
from ..services.stub_vlm_service import StubVLMService
|
|
@@ -132,11 +133,34 @@ async def create_caption(
|
|
| 132 |
prompt=prompt_text,
|
| 133 |
metadata_instructions=metadata_instructions,
|
| 134 |
model_name=model_name,
|
|
|
|
| 135 |
)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
raw = result.get("raw_response", {})
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
except Exception as e:
|
| 141 |
print(f"VLM error, using fallback: {e}")
|
| 142 |
text = "This is a fallback caption due to VLM service error."
|
|
|
|
| 3 |
from typing import List
|
| 4 |
from .. import crud, database, schemas, storage
|
| 5 |
from ..services.vlm_service import vlm_manager
|
| 6 |
+
from ..services.schema_validator import schema_validator
|
| 7 |
from ..config import settings
|
| 8 |
|
| 9 |
from ..services.stub_vlm_service import StubVLMService
|
|
|
|
| 133 |
prompt=prompt_text,
|
| 134 |
metadata_instructions=metadata_instructions,
|
| 135 |
model_name=model_name,
|
| 136 |
+
db_session=db,
|
| 137 |
)
|
| 138 |
+
|
| 139 |
+
# Get the raw response for validation
|
| 140 |
raw = result.get("raw_response", {})
|
| 141 |
+
|
| 142 |
+
# Validate and clean the data using schema validation
|
| 143 |
+
image_type = img.image_type
|
| 144 |
+
print(f"DEBUG: Validating data for image type: {image_type}")
|
| 145 |
+
print(f"DEBUG: Raw data structure: {list(raw.keys()) if isinstance(raw, dict) else 'Not a dict'}")
|
| 146 |
+
|
| 147 |
+
cleaned_data, is_valid, validation_error = schema_validator.clean_and_validate_data(raw, image_type)
|
| 148 |
+
|
| 149 |
+
if is_valid:
|
| 150 |
+
print(f"β Schema validation passed for {image_type}")
|
| 151 |
+
text = cleaned_data.get("analysis", "")
|
| 152 |
+
metadata = cleaned_data.get("metadata", {})
|
| 153 |
+
else:
|
| 154 |
+
print(f"β Schema validation failed for {image_type}: {validation_error}")
|
| 155 |
+
# Use fallback but log the validation error
|
| 156 |
+
text = result.get("caption", "This is a fallback caption due to schema validation error.")
|
| 157 |
+
metadata = result.get("metadata", {})
|
| 158 |
+
raw["validation_error"] = validation_error
|
| 159 |
+
raw["validation_failed"] = True
|
| 160 |
+
|
| 161 |
+
# Use the actual model that was used, not the requested model_name
|
| 162 |
+
used_model = result.get("model", model_name) or "STUB_MODEL"
|
| 163 |
+
|
| 164 |
except Exception as e:
|
| 165 |
print(f"VLM error, using fallback: {e}")
|
| 166 |
text = "This is a fallback caption due to VLM service error."
|
py_backend/app/routers/models.py
CHANGED
|
@@ -29,7 +29,17 @@ def get_available_models(db: Session = Depends(get_db)):
|
|
| 29 |
"config": model.config
|
| 30 |
})
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
except Exception as e:
|
| 34 |
raise HTTPException(500, f"Failed to get models: {str(e)}")
|
| 35 |
|
|
|
|
| 29 |
"config": model.config
|
| 30 |
})
|
| 31 |
|
| 32 |
+
# Add debug info about registered services
|
| 33 |
+
registered_services = list(vlm_manager.services.keys())
|
| 34 |
+
|
| 35 |
+
return {
|
| 36 |
+
"models": models_info,
|
| 37 |
+
"debug": {
|
| 38 |
+
"registered_services": registered_services,
|
| 39 |
+
"total_services": len(registered_services),
|
| 40 |
+
"available_db_models": [m.m_code for m in db_models if m.is_available]
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
except Exception as e:
|
| 44 |
raise HTTPException(500, f"Failed to get models: {str(e)}")
|
| 45 |
|
py_backend/app/routers/schemas.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from .. import crud, database, schemas
|
| 5 |
+
from ..services.schema_validator import schema_validator
|
| 6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 7 |
+
from ..routers.admin import verify_admin_token
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
security = HTTPBearer()
|
| 11 |
+
|
| 12 |
+
def get_db():
|
| 13 |
+
db = database.SessionLocal()
|
| 14 |
+
try:
|
| 15 |
+
yield db
|
| 16 |
+
finally:
|
| 17 |
+
db.close()
|
| 18 |
+
|
| 19 |
+
def verify_admin_access(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 20 |
+
"""Verify admin token for schema endpoints"""
|
| 21 |
+
token = credentials.credentials
|
| 22 |
+
|
| 23 |
+
if not verify_admin_token(token):
|
| 24 |
+
raise HTTPException(
|
| 25 |
+
status_code=401,
|
| 26 |
+
detail="Invalid or expired admin token"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
return token
|
| 30 |
+
|
| 31 |
+
@router.get("/schemas", response_model=List[Dict[str, Any]])
|
| 32 |
+
async def get_schemas(
|
| 33 |
+
db: Session = Depends(get_db),
|
| 34 |
+
token: str = Depends(verify_admin_access)
|
| 35 |
+
):
|
| 36 |
+
"""Get all JSON schemas (admin only)"""
|
| 37 |
+
try:
|
| 38 |
+
# Get schemas from database
|
| 39 |
+
db_schemas = crud.get_all_schemas(db)
|
| 40 |
+
|
| 41 |
+
schemas_list = []
|
| 42 |
+
for schema in db_schemas:
|
| 43 |
+
schemas_list.append({
|
| 44 |
+
"schema_id": schema.schema_id,
|
| 45 |
+
"title": schema.title,
|
| 46 |
+
"version": schema.version,
|
| 47 |
+
"created_at": schema.created_at.isoformat() if schema.created_at else None,
|
| 48 |
+
"schema": schema.schema
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
return schemas_list
|
| 52 |
+
except Exception as e:
|
| 53 |
+
raise HTTPException(500, f"Failed to get schemas: {str(e)}")
|
| 54 |
+
|
| 55 |
+
@router.get("/schemas/{schema_id}", response_model=Dict[str, Any])
|
| 56 |
+
async def get_schema(
|
| 57 |
+
schema_id: str,
|
| 58 |
+
db: Session = Depends(get_db),
|
| 59 |
+
token: str = Depends(verify_admin_access)
|
| 60 |
+
):
|
| 61 |
+
"""Get a specific JSON schema (admin only)"""
|
| 62 |
+
try:
|
| 63 |
+
schema = crud.get_schema(db, schema_id)
|
| 64 |
+
if not schema:
|
| 65 |
+
raise HTTPException(404, f"Schema {schema_id} not found")
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"schema_id": schema.schema_id,
|
| 69 |
+
"title": schema.title,
|
| 70 |
+
"version": schema.version,
|
| 71 |
+
"created_at": schema.created_at.isoformat() if schema.created_at else None,
|
| 72 |
+
"schema": schema.schema
|
| 73 |
+
}
|
| 74 |
+
except HTTPException:
|
| 75 |
+
raise
|
| 76 |
+
except Exception as e:
|
| 77 |
+
raise HTTPException(500, f"Failed to get schema: {str(e)}")
|
| 78 |
+
|
| 79 |
+
@router.post("/schemas/validate")
|
| 80 |
+
async def validate_data_against_schema(
|
| 81 |
+
data: Dict[str, Any],
|
| 82 |
+
schema_id: str,
|
| 83 |
+
db: Session = Depends(get_db),
|
| 84 |
+
token: str = Depends(verify_admin_access)
|
| 85 |
+
):
|
| 86 |
+
"""Validate JSON data against a specific schema (admin only)"""
|
| 87 |
+
try:
|
| 88 |
+
# Get the schema from database
|
| 89 |
+
schema = crud.get_schema(db, schema_id)
|
| 90 |
+
if not schema:
|
| 91 |
+
raise HTTPException(404, f"Schema {schema_id} not found")
|
| 92 |
+
|
| 93 |
+
# Validate the data
|
| 94 |
+
is_valid, error_msg = schema_validator.validate_against_schema(
|
| 95 |
+
data, schema.schema, schema_id
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"is_valid": is_valid,
|
| 100 |
+
"error_message": error_msg,
|
| 101 |
+
"schema_id": schema_id,
|
| 102 |
+
"data": data
|
| 103 |
+
}
|
| 104 |
+
except HTTPException:
|
| 105 |
+
raise
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise HTTPException(500, f"Validation failed: {str(e)}")
|
| 108 |
+
|
| 109 |
+
@router.post("/schemas/test-validation")
|
| 110 |
+
async def test_schema_validation(
|
| 111 |
+
image_type: str,
|
| 112 |
+
data: Dict[str, Any],
|
| 113 |
+
token: str = Depends(verify_admin_access)
|
| 114 |
+
):
|
| 115 |
+
"""Test data validation against image type schemas (admin only)"""
|
| 116 |
+
try:
|
| 117 |
+
# Validate data against the appropriate schema for the image type
|
| 118 |
+
cleaned_data, is_valid, error_msg = schema_validator.clean_and_validate_data(
|
| 119 |
+
data, image_type
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"is_valid": is_valid,
|
| 124 |
+
"error_message": error_msg,
|
| 125 |
+
"image_type": image_type,
|
| 126 |
+
"original_data": data,
|
| 127 |
+
"cleaned_data": cleaned_data if is_valid else None
|
| 128 |
+
}
|
| 129 |
+
except Exception as e:
|
| 130 |
+
raise HTTPException(500, f"Test validation failed: {str(e)}")
|
| 131 |
+
|
| 132 |
+
@router.get("/schemas/validation-stats")
|
| 133 |
+
async def get_validation_stats(
|
| 134 |
+
db: Session = Depends(get_db),
|
| 135 |
+
token: str = Depends(verify_admin_access)
|
| 136 |
+
):
|
| 137 |
+
"""Get validation statistics (admin only)"""
|
| 138 |
+
try:
|
| 139 |
+
# Get recent images with validation info
|
| 140 |
+
recent_images = crud.get_recent_images_with_validation(db, limit=100)
|
| 141 |
+
|
| 142 |
+
stats = {
|
| 143 |
+
"total_images": len(recent_images),
|
| 144 |
+
"validation_passed": 0,
|
| 145 |
+
"validation_failed": 0,
|
| 146 |
+
"validation_errors": [],
|
| 147 |
+
"by_image_type": {
|
| 148 |
+
"crisis_map": {"total": 0, "passed": 0, "failed": 0},
|
| 149 |
+
"drone_image": {"total": 0, "passed": 0, "failed": 0}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
for img in recent_images:
|
| 154 |
+
if hasattr(img, 'raw_json') and img.raw_json:
|
| 155 |
+
image_type = img.image_type
|
| 156 |
+
if image_type in stats["by_image_type"]:
|
| 157 |
+
stats["by_image_type"][image_type]["total"] += 1
|
| 158 |
+
|
| 159 |
+
# Check if validation failed
|
| 160 |
+
if img.raw_json.get("validation_failed"):
|
| 161 |
+
stats["validation_failed"] += 1
|
| 162 |
+
if image_type in stats["by_image_type"]:
|
| 163 |
+
stats["by_image_type"][image_type]["failed"] += 1
|
| 164 |
+
|
| 165 |
+
error = img.raw_json.get("validation_error", "Unknown error")
|
| 166 |
+
stats["validation_errors"].append({
|
| 167 |
+
"image_id": str(img.image_id),
|
| 168 |
+
"image_type": image_type,
|
| 169 |
+
"error": error,
|
| 170 |
+
"created_at": img.created_at.isoformat() if img.created_at else None
|
| 171 |
+
})
|
| 172 |
+
else:
|
| 173 |
+
stats["validation_passed"] += 1
|
| 174 |
+
if image_type in stats["by_image_type"]:
|
| 175 |
+
stats["by_image_type"][image_type]["passed"] += 1
|
| 176 |
+
|
| 177 |
+
return stats
|
| 178 |
+
except Exception as e:
|
| 179 |
+
raise HTTPException(500, f"Failed to get validation stats: {str(e)}")
|
py_backend/app/services/huggingface_service.py
CHANGED
|
@@ -152,6 +152,6 @@ class InstructBLIPService(HuggingFaceService):
|
|
| 152 |
"""InstructBLIP model service using Hugging Face"""
|
| 153 |
|
| 154 |
def __init__(self, api_key: str):
|
| 155 |
-
super().__init__(api_key, "
|
| 156 |
self.model_name = "INSTRUCTBLIP_VICUNA_7B"
|
| 157 |
self.model_type = ModelType.CUSTOM
|
|
|
|
| 152 |
"""InstructBLIP model service using Hugging Face"""
|
| 153 |
|
| 154 |
def __init__(self, api_key: str):
|
| 155 |
+
super().__init__(api_key, "Salesforce/instructblip-vicuna-7b")
|
| 156 |
self.model_name = "INSTRUCTBLIP_VICUNA_7B"
|
| 157 |
self.model_type = ModelType.CUSTOM
|
py_backend/app/services/schema_validator.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Dict, Any, Optional, Tuple
|
| 3 |
+
from jsonschema import validate, ValidationError
|
| 4 |
+
from jsonschema.validators import Draft7Validator
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class SchemaValidator:
|
| 10 |
+
"""Service for validating JSON data against stored schemas"""
|
| 11 |
+
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.validators = {}
|
| 14 |
+
|
| 15 |
+
def validate_against_schema(self, data: Dict[str, Any], schema: Dict[str, Any], schema_id: str) -> Tuple[bool, Optional[str]]:
|
| 16 |
+
"""
|
| 17 |
+
Validate JSON data against a schema
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
data: The JSON data to validate
|
| 21 |
+
schema: The JSON schema to validate against
|
| 22 |
+
schema_id: Identifier for the schema (for logging)
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
Tuple of (is_valid, error_message)
|
| 26 |
+
"""
|
| 27 |
+
try:
|
| 28 |
+
# Use Draft7Validator for better error messages
|
| 29 |
+
validator = Draft7Validator(schema)
|
| 30 |
+
errors = list(validator.iter_errors(data))
|
| 31 |
+
|
| 32 |
+
if errors:
|
| 33 |
+
error_messages = []
|
| 34 |
+
for error in errors:
|
| 35 |
+
path = " -> ".join(str(p) for p in error.path) if error.path else "root"
|
| 36 |
+
error_messages.append(f"{path}: {error.message}")
|
| 37 |
+
|
| 38 |
+
error_msg = f"Schema validation failed for {schema_id}: {'; '.join(error_messages)}"
|
| 39 |
+
logger.warning(error_msg)
|
| 40 |
+
return False, error_msg
|
| 41 |
+
|
| 42 |
+
logger.info(f"Schema validation passed for {schema_id}")
|
| 43 |
+
return True, None
|
| 44 |
+
|
| 45 |
+
except Exception as e:
|
| 46 |
+
error_msg = f"Schema validation error for {schema_id}: {str(e)}"
|
| 47 |
+
logger.error(error_msg)
|
| 48 |
+
return False, error_msg
|
| 49 |
+
|
| 50 |
+
def validate_crisis_map_data(self, data: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
| 51 |
+
"""
|
| 52 |
+
Validate crisis map data against the default schema
|
| 53 |
+
"""
|
| 54 |
+
# Define the expected crisis map schema
|
| 55 |
+
crisis_schema = {
|
| 56 |
+
"type": "object",
|
| 57 |
+
"properties": {
|
| 58 |
+
"analysis": {"type": "string"},
|
| 59 |
+
"metadata": {
|
| 60 |
+
"type": "object",
|
| 61 |
+
"properties": {
|
| 62 |
+
"title": {"type": "string"},
|
| 63 |
+
"source": {"type": "string"},
|
| 64 |
+
"type": {"type": "string"},
|
| 65 |
+
"countries": {"type": "array", "items": {"type": "string"}},
|
| 66 |
+
"epsg": {"type": "string"}
|
| 67 |
+
},
|
| 68 |
+
"required": ["title", "source", "type", "countries", "epsg"]
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
"required": ["analysis", "metadata"]
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return self.validate_against_schema(data, crisis_schema, "crisis_map")
|
| 75 |
+
|
| 76 |
+
def validate_drone_data(self, data: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
| 77 |
+
"""
|
| 78 |
+
Validate drone data against the drone schema
|
| 79 |
+
"""
|
| 80 |
+
# Define the expected drone schema
|
| 81 |
+
drone_schema = {
|
| 82 |
+
"type": "object",
|
| 83 |
+
"properties": {
|
| 84 |
+
"analysis": {"type": "string"},
|
| 85 |
+
"metadata": {
|
| 86 |
+
"type": "object",
|
| 87 |
+
"properties": {
|
| 88 |
+
"title": {"type": ["string", "null"]},
|
| 89 |
+
"source": {"type": ["string", "null"]},
|
| 90 |
+
"type": {"type": ["string", "null"]},
|
| 91 |
+
"countries": {"type": ["array", "null"], "items": {"type": "string"}},
|
| 92 |
+
"epsg": {"type": ["string", "null"]},
|
| 93 |
+
"center_lat": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 94 |
+
"center_lon": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 95 |
+
"amsl_m": {"type": ["number", "null"]},
|
| 96 |
+
"agl_m": {"type": ["number", "null"]},
|
| 97 |
+
"heading_deg": {"type": ["number", "null"], "minimum": 0, "maximum": 360},
|
| 98 |
+
"yaw_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 99 |
+
"pitch_deg": {"type": ["number", "null"], "minimum": -90, "maximum": 90},
|
| 100 |
+
"roll_deg": {"type": ["number", "null"], "minimum": -180, "maximum": 180},
|
| 101 |
+
"rtk_fix": {"type": ["boolean", "null"]},
|
| 102 |
+
"std_h_m": {"type": ["number", "null"], "minimum": 0},
|
| 103 |
+
"std_v_m": {"type": ["number", "null"], "minimum": 0}
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
"required": ["analysis", "metadata"]
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return self.validate_against_schema(data, drone_schema, "drone")
|
| 111 |
+
|
| 112 |
+
def validate_data_by_type(self, data: Dict[str, Any], image_type: str) -> Tuple[bool, Optional[str]]:
|
| 113 |
+
"""
|
| 114 |
+
Validate data based on image type
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
data: The JSON data to validate
|
| 118 |
+
image_type: Either 'crisis_map' or 'drone_image'
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
Tuple of (is_valid, error_message)
|
| 122 |
+
"""
|
| 123 |
+
if image_type == 'drone_image':
|
| 124 |
+
return self.validate_drone_data(data)
|
| 125 |
+
elif image_type == 'crisis_map':
|
| 126 |
+
return self.validate_crisis_map_data(data)
|
| 127 |
+
else:
|
| 128 |
+
return False, f"Unknown image type: {image_type}"
|
| 129 |
+
|
| 130 |
+
def clean_and_validate_data(self, raw_data: Dict[str, Any], image_type: str) -> Tuple[Dict[str, Any], bool, Optional[str]]:
|
| 131 |
+
"""
|
| 132 |
+
Clean and validate data, returning cleaned data, validation status, and any errors
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
raw_data: Raw data from VLM
|
| 136 |
+
image_type: Type of image being processed
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
Tuple of (cleaned_data, is_valid, error_message)
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
# Extract the main content (handle different VLM response formats)
|
| 143 |
+
if "content" in raw_data:
|
| 144 |
+
# Some VLM models wrap content in a "content" field
|
| 145 |
+
content = raw_data["content"]
|
| 146 |
+
if isinstance(content, str):
|
| 147 |
+
# Try to parse JSON from string content
|
| 148 |
+
try:
|
| 149 |
+
parsed_content = json.loads(content)
|
| 150 |
+
data = parsed_content
|
| 151 |
+
except json.JSONDecodeError:
|
| 152 |
+
# If it's not JSON, treat as analysis
|
| 153 |
+
data = {"analysis": content, "metadata": {}}
|
| 154 |
+
else:
|
| 155 |
+
data = content
|
| 156 |
+
else:
|
| 157 |
+
data = raw_data
|
| 158 |
+
|
| 159 |
+
# Validate the data
|
| 160 |
+
is_valid, error_msg = self.validate_data_by_type(data, image_type)
|
| 161 |
+
|
| 162 |
+
if is_valid:
|
| 163 |
+
# Clean the data (remove any extra fields, normalize)
|
| 164 |
+
cleaned_data = self._clean_data(data, image_type)
|
| 165 |
+
return cleaned_data, True, None
|
| 166 |
+
else:
|
| 167 |
+
return data, False, error_msg
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
error_msg = f"Data processing error: {str(e)}"
|
| 171 |
+
logger.error(error_msg)
|
| 172 |
+
return raw_data, False, error_msg
|
| 173 |
+
|
| 174 |
+
def _clean_data(self, data: Dict[str, Any], image_type: str) -> Dict[str, Any]:
|
| 175 |
+
"""
|
| 176 |
+
Clean and normalize the data structure
|
| 177 |
+
"""
|
| 178 |
+
cleaned = {
|
| 179 |
+
"analysis": data.get("analysis", ""),
|
| 180 |
+
"metadata": {}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
metadata = data.get("metadata", {})
|
| 184 |
+
|
| 185 |
+
# Clean metadata based on image type
|
| 186 |
+
if image_type == 'crisis_map':
|
| 187 |
+
cleaned["metadata"] = {
|
| 188 |
+
"title": metadata.get("title", ""),
|
| 189 |
+
"source": metadata.get("source", "OTHER"),
|
| 190 |
+
"type": metadata.get("type", "OTHER"),
|
| 191 |
+
"countries": metadata.get("countries", []),
|
| 192 |
+
"epsg": metadata.get("epsg", "OTHER")
|
| 193 |
+
}
|
| 194 |
+
elif image_type == 'drone_image':
|
| 195 |
+
cleaned["metadata"] = {
|
| 196 |
+
"title": metadata.get("title"),
|
| 197 |
+
"source": metadata.get("source"),
|
| 198 |
+
"type": metadata.get("type"),
|
| 199 |
+
"countries": metadata.get("countries"),
|
| 200 |
+
"epsg": metadata.get("epsg"),
|
| 201 |
+
"center_lat": metadata.get("center_lat"),
|
| 202 |
+
"center_lon": metadata.get("center_lon"),
|
| 203 |
+
"amsl_m": metadata.get("amsl_m"),
|
| 204 |
+
"agl_m": metadata.get("agl_m"),
|
| 205 |
+
"heading_deg": metadata.get("heading_deg"),
|
| 206 |
+
"yaw_deg": metadata.get("yaw_deg"),
|
| 207 |
+
"pitch_deg": metadata.get("pitch_deg"),
|
| 208 |
+
"roll_deg": metadata.get("roll_deg"),
|
| 209 |
+
"rtk_fix": metadata.get("rtk_fix"),
|
| 210 |
+
"std_h_m": metadata.get("std_h_m"),
|
| 211 |
+
"std_v_m": metadata.get("std_v_m")
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
return cleaned
|
| 215 |
+
|
| 216 |
+
# Global instance
|
| 217 |
+
schema_validator = SchemaValidator()
|
py_backend/app/services/vlm_service.py
CHANGED
|
@@ -62,18 +62,65 @@ class VLMServiceManager:
|
|
| 62 |
"""Get list of available model names"""
|
| 63 |
return list(self.services.keys())
|
| 64 |
|
| 65 |
-
async def generate_caption(self, image_bytes: bytes, prompt: str, metadata_instructions: str = "", model_name: str | None = None) -> dict:
|
| 66 |
"""Generate caption using the specified model or fallback to available service."""
|
| 67 |
|
| 68 |
service = None
|
| 69 |
-
if model_name:
|
| 70 |
service = self.services.get(model_name)
|
| 71 |
if not service:
|
| 72 |
print(f"Model '{model_name}' not found, using fallback")
|
| 73 |
|
| 74 |
if not service and self.services:
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
if not service:
|
| 79 |
raise ValueError("No VLM services available")
|
|
|
|
| 62 |
"""Get list of available model names"""
|
| 63 |
return list(self.services.keys())
|
| 64 |
|
| 65 |
+
async def generate_caption(self, image_bytes: bytes, prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
|
| 66 |
"""Generate caption using the specified model or fallback to available service."""
|
| 67 |
|
| 68 |
service = None
|
| 69 |
+
if model_name and model_name != "random":
|
| 70 |
service = self.services.get(model_name)
|
| 71 |
if not service:
|
| 72 |
print(f"Model '{model_name}' not found, using fallback")
|
| 73 |
|
| 74 |
if not service and self.services:
|
| 75 |
+
# If random is selected or no specific model, choose a random available service
|
| 76 |
+
if db_session:
|
| 77 |
+
# Check database availability for random selection
|
| 78 |
+
try:
|
| 79 |
+
from .. import crud
|
| 80 |
+
available_models = crud.get_models(db_session)
|
| 81 |
+
available_model_codes = [m.m_code for m in available_models if m.is_available]
|
| 82 |
+
|
| 83 |
+
print(f"DEBUG: Available models in database: {available_model_codes}")
|
| 84 |
+
print(f"DEBUG: Registered services: {list(self.services.keys())}")
|
| 85 |
+
|
| 86 |
+
# Filter services to only those marked as available in database
|
| 87 |
+
available_services = [s for s in self.services.values() if s.model_name in available_model_codes]
|
| 88 |
+
|
| 89 |
+
print(f"DEBUG: Available services after filtering: {[s.model_name for s in available_services]}")
|
| 90 |
+
|
| 91 |
+
if available_services:
|
| 92 |
+
import random
|
| 93 |
+
import time
|
| 94 |
+
# Use current time as seed for better randomness
|
| 95 |
+
random.seed(int(time.time() * 1000000) % 1000000)
|
| 96 |
+
|
| 97 |
+
# Shuffle the list first for better randomization
|
| 98 |
+
shuffled_services = available_services.copy()
|
| 99 |
+
random.shuffle(shuffled_services)
|
| 100 |
+
|
| 101 |
+
service = shuffled_services[0]
|
| 102 |
+
print(f"Randomly selected service: {service.model_name} (from {len(available_services)} available)")
|
| 103 |
+
print(f"DEBUG: All available services were: {[s.model_name for s in available_services]}")
|
| 104 |
+
print(f"DEBUG: Shuffled order: {[s.model_name for s in shuffled_services]}")
|
| 105 |
+
else:
|
| 106 |
+
# Fallback to any service
|
| 107 |
+
service = next(iter(self.services.values()))
|
| 108 |
+
print(f"Using fallback service: {service.model_name}")
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"Error checking database availability: {e}, using fallback")
|
| 111 |
+
service = next(iter(self.services.values()))
|
| 112 |
+
print(f"Using fallback service: {service.model_name}")
|
| 113 |
+
else:
|
| 114 |
+
# No database session, use service property
|
| 115 |
+
available_services = [s for s in self.services.values() if s.is_available]
|
| 116 |
+
if available_services:
|
| 117 |
+
import random
|
| 118 |
+
service = random.choice(available_services)
|
| 119 |
+
print(f"Randomly selected service: {service.model_name}")
|
| 120 |
+
else:
|
| 121 |
+
# Fallback to any service
|
| 122 |
+
service = next(iter(self.services.values()))
|
| 123 |
+
print(f"Using fallback service: {service.model_name}")
|
| 124 |
|
| 125 |
if not service:
|
| 126 |
raise ValueError("No VLM services available")
|
py_backend/browser_test.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Test schema endpoints in browser console
|
| 2 |
+
// Copy and paste this into your browser's developer console
|
| 3 |
+
|
| 4 |
+
async function testSchemaEndpoints() {
|
| 5 |
+
console.log("π§ͺ Testing Schema Endpoints...");
|
| 6 |
+
|
| 7 |
+
// Get the admin token from localStorage
|
| 8 |
+
const token = localStorage.getItem('adminToken');
|
| 9 |
+
|
| 10 |
+
if (!token) {
|
| 11 |
+
console.log("β No admin token found. Please login first.");
|
| 12 |
+
return;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
console.log("β
Admin token found:", token.substring(0, 20) + "...");
|
| 16 |
+
|
| 17 |
+
const headers = {
|
| 18 |
+
'Authorization': `Bearer ${token}`,
|
| 19 |
+
'Content-Type': 'application/json'
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
// Test get schemas
|
| 24 |
+
console.log("\n1. Testing GET /api/schemas...");
|
| 25 |
+
const schemasResponse = await fetch('/api/schemas', { headers });
|
| 26 |
+
console.log("Response status:", schemasResponse.status);
|
| 27 |
+
|
| 28 |
+
if (schemasResponse.ok) {
|
| 29 |
+
const schemas = await schemasResponse.json();
|
| 30 |
+
console.log("β
Schemas found:", schemas.length);
|
| 31 |
+
schemas.forEach(schema => {
|
| 32 |
+
console.log(` - ${schema.schema_id}: ${schema.title}`);
|
| 33 |
+
});
|
| 34 |
+
} else {
|
| 35 |
+
const errorText = await schemasResponse.text();
|
| 36 |
+
console.log("β Error:", errorText);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Test validation stats
|
| 40 |
+
console.log("\n2. Testing GET /api/schemas/validation-stats...");
|
| 41 |
+
const statsResponse = await fetch('/api/schemas/validation-stats', { headers });
|
| 42 |
+
console.log("Response status:", statsResponse.status);
|
| 43 |
+
|
| 44 |
+
if (statsResponse.ok) {
|
| 45 |
+
const stats = await statsResponse.json();
|
| 46 |
+
console.log("β
Validation stats:", stats);
|
| 47 |
+
} else {
|
| 48 |
+
const errorText = await statsResponse.text();
|
| 49 |
+
console.log("β Error:", errorText);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.log("β Network error:", error);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Run the test
|
| 58 |
+
testSchemaEndpoints();
|
py_backend/requirements.txt
CHANGED
|
@@ -20,3 +20,5 @@ requests
|
|
| 20 |
pycountry>=22.3.5
|
| 21 |
pycountry-convert>=0.7.2
|
| 22 |
python-multipart
|
|
|
|
|
|
|
|
|
| 20 |
pycountry>=22.3.5
|
| 21 |
pycountry-convert>=0.7.2
|
| 22 |
python-multipart
|
| 23 |
+
PyJWT
|
| 24 |
+
jsonschema
|
py_backend/test_admin_endpoints.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test admin endpoints and schema validation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# Update this to your backend URL
|
| 11 |
+
BASE_URL = "http://localhost:8000" # Change if different
|
| 12 |
+
|
| 13 |
+
def test_admin_login():
|
| 14 |
+
"""Test admin login"""
|
| 15 |
+
print("π§ͺ Testing Admin Login...")
|
| 16 |
+
|
| 17 |
+
# You'll need to set your admin password here or in environment
|
| 18 |
+
password = os.getenv("ADMIN_PASSWORD", "your_password_here")
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
response = requests.post(f"{BASE_URL}/api/admin/login",
|
| 22 |
+
json={"password": password})
|
| 23 |
+
|
| 24 |
+
if response.status_code == 200:
|
| 25 |
+
data = response.json()
|
| 26 |
+
print(f"β
Admin login successful")
|
| 27 |
+
print(f" Token type: {data.get('token_type', 'N/A')}")
|
| 28 |
+
print(f" Expires at: {data.get('expires_at', 'N/A')}")
|
| 29 |
+
return data.get('access_token')
|
| 30 |
+
else:
|
| 31 |
+
print(f"β Admin login failed: {response.status_code}")
|
| 32 |
+
print(f" Response: {response.text}")
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"β Admin login error: {e}")
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
def test_schema_endpoints(token):
|
| 40 |
+
"""Test schema endpoints with token"""
|
| 41 |
+
if not token:
|
| 42 |
+
print("β No token available, skipping schema tests")
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
print("\nπ§ͺ Testing Schema Endpoints...")
|
| 46 |
+
|
| 47 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 48 |
+
|
| 49 |
+
# Test get schemas
|
| 50 |
+
try:
|
| 51 |
+
response = requests.get(f"{BASE_URL}/api/schemas", headers=headers)
|
| 52 |
+
if response.status_code == 200:
|
| 53 |
+
schemas = response.json()
|
| 54 |
+
print(f"β
Get schemas successful: found {len(schemas)} schemas")
|
| 55 |
+
for schema in schemas:
|
| 56 |
+
print(f" - {schema['schema_id']}: {schema['title']}")
|
| 57 |
+
else:
|
| 58 |
+
print(f"β Get schemas failed: {response.status_code}")
|
| 59 |
+
print(f" Response: {response.text}")
|
| 60 |
+
except Exception as e:
|
| 61 |
+
print(f"β Get schemas error: {e}")
|
| 62 |
+
|
| 63 |
+
# Test validation stats
|
| 64 |
+
try:
|
| 65 |
+
response = requests.get(f"{BASE_URL}/api/schemas/validation-stats", headers=headers)
|
| 66 |
+
if response.status_code == 200:
|
| 67 |
+
stats = response.json()
|
| 68 |
+
print(f"β
Validation stats successful:")
|
| 69 |
+
print(f" Total images: {stats.get('total_images', 0)}")
|
| 70 |
+
print(f" Validation passed: {stats.get('validation_passed', 0)}")
|
| 71 |
+
print(f" Validation failed: {stats.get('validation_failed', 0)}")
|
| 72 |
+
else:
|
| 73 |
+
print(f"β Validation stats failed: {response.status_code}")
|
| 74 |
+
print(f" Response: {response.text}")
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"β Validation stats error: {e}")
|
| 77 |
+
|
| 78 |
+
def main():
|
| 79 |
+
print("π Testing Admin and Schema Endpoints")
|
| 80 |
+
print("=" * 50)
|
| 81 |
+
|
| 82 |
+
# Test admin login
|
| 83 |
+
token = test_admin_login()
|
| 84 |
+
|
| 85 |
+
# Test schema endpoints
|
| 86 |
+
test_schema_endpoints(token)
|
| 87 |
+
|
| 88 |
+
print(f"\nπ Instructions:")
|
| 89 |
+
print(f"1. Make sure your backend is running")
|
| 90 |
+
print(f"2. Set ADMIN_PASSWORD environment variable or update the password in this script")
|
| 91 |
+
print(f"3. Update BASE_URL if your backend is not on localhost:8000")
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
main()
|
py_backend/test_schema_integration.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Comprehensive test script for schema validation integration
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
BASE_URL = "http://localhost:8000"
|
| 12 |
+
|
| 13 |
+
def test_schema_endpoints():
|
| 14 |
+
"""Test admin schema management endpoints"""
|
| 15 |
+
print("π§ͺ Testing Schema Management Endpoints...")
|
| 16 |
+
|
| 17 |
+
# First, login to get admin token
|
| 18 |
+
login_data = {"password": os.getenv("ADMIN_PASSWORD", "your_password_here")}
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
login_response = requests.post(f"{BASE_URL}/api/admin/login", json=login_data)
|
| 22 |
+
if login_response.status_code != 200:
|
| 23 |
+
print(f"β Admin login failed: {login_response.status_code}")
|
| 24 |
+
print("Please set ADMIN_PASSWORD environment variable or update the password in the script")
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
token = login_response.json().get("access_token")
|
| 28 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 29 |
+
|
| 30 |
+
# Test getting all schemas
|
| 31 |
+
schemas_response = requests.get(f"{BASE_URL}/api/schemas", headers=headers)
|
| 32 |
+
if schemas_response.status_code == 200:
|
| 33 |
+
schemas = schemas_response.json()
|
| 34 |
+
print(f"β
Found {len(schemas)} schemas in database:")
|
| 35 |
+
for schema in schemas:
|
| 36 |
+
print(f" - {schema['schema_id']}: {schema['title']}")
|
| 37 |
+
else:
|
| 38 |
+
print(f"β Failed to fetch schemas: {schemas_response.status_code}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
# Test validation stats
|
| 42 |
+
stats_response = requests.get(f"{BASE_URL}/api/schemas/validation-stats", headers=headers)
|
| 43 |
+
if stats_response.status_code == 200:
|
| 44 |
+
stats = stats_response.json()
|
| 45 |
+
print(f"β
Validation stats: {stats['validation_passed']} passed, {stats['validation_failed']} failed")
|
| 46 |
+
else:
|
| 47 |
+
print(f"β Failed to fetch validation stats: {stats_response.status_code}")
|
| 48 |
+
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"β Schema endpoint test failed: {e}")
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
def test_schema_validation():
|
| 56 |
+
"""Test schema validation directly"""
|
| 57 |
+
print("\nπ§ͺ Testing Schema Validation Logic...")
|
| 58 |
+
|
| 59 |
+
# Test crisis map validation
|
| 60 |
+
crisis_data = {
|
| 61 |
+
"analysis": "A major earthquake occurred in Panama with magnitude 6.6",
|
| 62 |
+
"metadata": {
|
| 63 |
+
"title": "Panama Earthquake July 2025",
|
| 64 |
+
"source": "WFP",
|
| 65 |
+
"type": "EARTHQUAKE",
|
| 66 |
+
"countries": ["PA"],
|
| 67 |
+
"epsg": "32617"
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
from app.services.schema_validator import schema_validator
|
| 73 |
+
|
| 74 |
+
# Test crisis map validation
|
| 75 |
+
is_valid, error = schema_validator.validate_crisis_map_data(crisis_data)
|
| 76 |
+
if is_valid:
|
| 77 |
+
print("β
Crisis map validation: PASSED")
|
| 78 |
+
else:
|
| 79 |
+
print(f"β Crisis map validation: FAILED - {error}")
|
| 80 |
+
|
| 81 |
+
# Test drone validation
|
| 82 |
+
drone_data = {
|
| 83 |
+
"analysis": "Drone image shows damaged infrastructure after earthquake",
|
| 84 |
+
"metadata": {
|
| 85 |
+
"title": "Damaged Infrastructure",
|
| 86 |
+
"source": "WFP",
|
| 87 |
+
"type": "EARTHQUAKE",
|
| 88 |
+
"countries": ["PA"],
|
| 89 |
+
"epsg": "4326",
|
| 90 |
+
"center_lat": 8.5,
|
| 91 |
+
"center_lon": -80.0,
|
| 92 |
+
"amsl_m": 100.0,
|
| 93 |
+
"agl_m": 50.0,
|
| 94 |
+
"heading_deg": 180.0,
|
| 95 |
+
"yaw_deg": 0.0,
|
| 96 |
+
"pitch_deg": 0.0,
|
| 97 |
+
"roll_deg": 0.0,
|
| 98 |
+
"rtk_fix": True,
|
| 99 |
+
"std_h_m": 0.5,
|
| 100 |
+
"std_v_m": 0.3
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
is_valid, error = schema_validator.validate_drone_data(drone_data)
|
| 105 |
+
if is_valid:
|
| 106 |
+
print("β
Drone validation: PASSED")
|
| 107 |
+
else:
|
| 108 |
+
print(f"β Drone validation: FAILED - {error}")
|
| 109 |
+
|
| 110 |
+
# Test invalid data
|
| 111 |
+
invalid_data = {
|
| 112 |
+
"analysis": "Test analysis",
|
| 113 |
+
"metadata": {
|
| 114 |
+
"center_lat": 95.0, # Invalid: > 90
|
| 115 |
+
"center_lon": 200.0, # Invalid: > 180
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
is_valid, error = schema_validator.validate_drone_data(invalid_data)
|
| 120 |
+
if not is_valid:
|
| 121 |
+
print("β
Invalid data rejection: PASSED")
|
| 122 |
+
print(f" Expected error: {error}")
|
| 123 |
+
else:
|
| 124 |
+
print("β Invalid data rejection: FAILED - Should have rejected invalid coordinates")
|
| 125 |
+
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"β Schema validation test failed: {e}")
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
def test_models_api():
|
| 133 |
+
"""Test models API to see debug info"""
|
| 134 |
+
print("\nπ§ͺ Testing Models API (Debug Info)...")
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
response = requests.get(f"{BASE_URL}/api/models")
|
| 138 |
+
if response.status_code == 200:
|
| 139 |
+
data = response.json()
|
| 140 |
+
models = data.get("models", [])
|
| 141 |
+
debug = data.get("debug", {})
|
| 142 |
+
|
| 143 |
+
print(f"β
Found {len(models)} models:")
|
| 144 |
+
for model in models[:3]: # Show first 3
|
| 145 |
+
print(f" - {model['m_code']}: {model['label']} ({'Available' if model['is_available'] else 'Disabled'})")
|
| 146 |
+
|
| 147 |
+
if debug:
|
| 148 |
+
print(f"β
Debug info:")
|
| 149 |
+
print(f" - Registered services: {debug.get('registered_services', [])}")
|
| 150 |
+
print(f" - Total services: {debug.get('total_services', 0)}")
|
| 151 |
+
print(f" - Available DB models: {debug.get('available_db_models', [])}")
|
| 152 |
+
|
| 153 |
+
return True
|
| 154 |
+
else:
|
| 155 |
+
print(f"β Models API failed: {response.status_code}")
|
| 156 |
+
return False
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
print(f"β Models API test failed: {e}")
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
def test_caption_generation():
|
| 163 |
+
"""Test caption generation with a sample image to see if validation works"""
|
| 164 |
+
print("\nπ§ͺ Testing Caption Generation with Schema Validation...")
|
| 165 |
+
|
| 166 |
+
# This would require an actual image upload, which is complex
|
| 167 |
+
# For now, we'll just check if the endpoint exists
|
| 168 |
+
try:
|
| 169 |
+
# Test if caption endpoint exists (should return 404 for non-existent image)
|
| 170 |
+
response = requests.post(f"{BASE_URL}/api/images/test-id/caption",
|
| 171 |
+
data={"title": "Test", "prompt": "DEFAULT_CRISIS_MAP"})
|
| 172 |
+
|
| 173 |
+
# We expect 404 since the image doesn't exist, but that means the endpoint works
|
| 174 |
+
if response.status_code == 404:
|
| 175 |
+
print("β
Caption generation endpoint: ACCESSIBLE")
|
| 176 |
+
print(" (404 expected for non-existent image)")
|
| 177 |
+
return True
|
| 178 |
+
else:
|
| 179 |
+
print(f"β
Caption generation endpoint: ACCESSIBLE (status: {response.status_code})")
|
| 180 |
+
return True
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
print(f"β Caption generation test failed: {e}")
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
def main():
|
| 187 |
+
print("π Starting Schema Validation Integration Tests")
|
| 188 |
+
print("=" * 60)
|
| 189 |
+
|
| 190 |
+
tests = [
|
| 191 |
+
("Schema Validation Logic", test_schema_validation),
|
| 192 |
+
("Models API", test_models_api),
|
| 193 |
+
("Caption Generation Endpoint", test_caption_generation),
|
| 194 |
+
("Admin Schema Endpoints", test_schema_endpoints),
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
results = []
|
| 198 |
+
for test_name, test_func in tests:
|
| 199 |
+
try:
|
| 200 |
+
result = test_func()
|
| 201 |
+
results.append((test_name, result))
|
| 202 |
+
except Exception as e:
|
| 203 |
+
print(f"β {test_name}: EXCEPTION - {e}")
|
| 204 |
+
results.append((test_name, False))
|
| 205 |
+
|
| 206 |
+
print("\n" + "=" * 60)
|
| 207 |
+
print("π Test Results Summary:")
|
| 208 |
+
|
| 209 |
+
passed = 0
|
| 210 |
+
for test_name, result in results:
|
| 211 |
+
status = "β
PASSED" if result else "β FAILED"
|
| 212 |
+
print(f" {test_name}: {status}")
|
| 213 |
+
if result:
|
| 214 |
+
passed += 1
|
| 215 |
+
|
| 216 |
+
print(f"\nπ― Overall: {passed}/{len(results)} tests passed")
|
| 217 |
+
|
| 218 |
+
if passed == len(results):
|
| 219 |
+
print("\nπ All tests passed! Schema validation is working correctly.")
|
| 220 |
+
print("\nNext steps:")
|
| 221 |
+
print("1. Try uploading an image through the frontend")
|
| 222 |
+
print("2. Check the admin panel for schema validation stats")
|
| 223 |
+
print("3. Look at the backend logs for validation debug messages")
|
| 224 |
+
else:
|
| 225 |
+
print("\nβ οΈ Some tests failed. Check the error messages above.")
|
| 226 |
+
print("Common issues:")
|
| 227 |
+
print("- ADMIN_PASSWORD not set correctly")
|
| 228 |
+
print("- Backend not running on localhost:8000")
|
| 229 |
+
print("- Database connection issues")
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
main()
|
py_backend/test_schema_validation.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test script for schema validation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 9 |
+
|
| 10 |
+
from app.services.schema_validator import schema_validator
|
| 11 |
+
|
| 12 |
+
def test_crisis_map_validation():
|
| 13 |
+
"""Test crisis map data validation"""
|
| 14 |
+
print("Testing Crisis Map Validation...")
|
| 15 |
+
|
| 16 |
+
# Valid crisis map data
|
| 17 |
+
valid_data = {
|
| 18 |
+
"analysis": "A major earthquake occurred in Panama with magnitude 6.6",
|
| 19 |
+
"metadata": {
|
| 20 |
+
"title": "Panama Earthquake July 2025",
|
| 21 |
+
"source": "WFP",
|
| 22 |
+
"type": "EARTHQUAKE",
|
| 23 |
+
"countries": ["PA"],
|
| 24 |
+
"epsg": "32617"
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
is_valid, error = schema_validator.validate_crisis_map_data(valid_data)
|
| 29 |
+
print(f"β Valid data: {is_valid}, Error: {error}")
|
| 30 |
+
|
| 31 |
+
# Invalid crisis map data (missing required fields)
|
| 32 |
+
invalid_data = {
|
| 33 |
+
"analysis": "A major earthquake occurred in Panama",
|
| 34 |
+
"metadata": {
|
| 35 |
+
"title": "Panama Earthquake",
|
| 36 |
+
# Missing source, type, countries, epsg
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
is_valid, error = schema_validator.validate_crisis_map_data(invalid_data)
|
| 41 |
+
print(f"β Invalid data: {is_valid}, Error: {error}")
|
| 42 |
+
|
| 43 |
+
# Test data cleaning
|
| 44 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(valid_data, "crisis_map")
|
| 45 |
+
print(f"β Cleaned data: {is_valid}, Error: {error}")
|
| 46 |
+
if is_valid:
|
| 47 |
+
print(f" Cleaned metadata: {cleaned_data['metadata']}")
|
| 48 |
+
|
| 49 |
+
def test_drone_validation():
|
| 50 |
+
"""Test drone data validation"""
|
| 51 |
+
print("\nTesting Drone Validation...")
|
| 52 |
+
|
| 53 |
+
# Valid drone data
|
| 54 |
+
valid_data = {
|
| 55 |
+
"analysis": "Drone image shows damaged infrastructure after earthquake",
|
| 56 |
+
"metadata": {
|
| 57 |
+
"title": "Damaged Infrastructure",
|
| 58 |
+
"source": "WFP",
|
| 59 |
+
"type": "EARTHQUAKE",
|
| 60 |
+
"countries": ["PA"],
|
| 61 |
+
"epsg": "4326",
|
| 62 |
+
"center_lat": 8.5,
|
| 63 |
+
"center_lon": -80.0,
|
| 64 |
+
"amsl_m": 100.0,
|
| 65 |
+
"agl_m": 50.0,
|
| 66 |
+
"heading_deg": 180.0,
|
| 67 |
+
"yaw_deg": 0.0,
|
| 68 |
+
"pitch_deg": 0.0,
|
| 69 |
+
"roll_deg": 0.0,
|
| 70 |
+
"rtk_fix": True,
|
| 71 |
+
"std_h_m": 0.5,
|
| 72 |
+
"std_v_m": 0.3
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
is_valid, error = schema_validator.validate_drone_data(valid_data)
|
| 77 |
+
print(f"β Valid drone data: {is_valid}, Error: {error}")
|
| 78 |
+
|
| 79 |
+
# Invalid drone data (invalid coordinate values)
|
| 80 |
+
invalid_data = {
|
| 81 |
+
"analysis": "Drone image shows damaged infrastructure",
|
| 82 |
+
"metadata": {
|
| 83 |
+
"title": "Damaged Infrastructure",
|
| 84 |
+
"center_lat": 95.0, # Invalid: > 90
|
| 85 |
+
"center_lon": 200.0, # Invalid: > 180
|
| 86 |
+
"heading_deg": 400.0, # Invalid: > 360
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
is_valid, error = schema_validator.validate_drone_data(invalid_data)
|
| 91 |
+
print(f"β Invalid drone data: {is_valid}, Error: {error}")
|
| 92 |
+
|
| 93 |
+
# Test data cleaning
|
| 94 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(valid_data, "drone_image")
|
| 95 |
+
print(f"β Cleaned drone data: {is_valid}, Error: {error}")
|
| 96 |
+
if is_valid:
|
| 97 |
+
print(f" Cleaned metadata keys: {list(cleaned_data['metadata'].keys())}")
|
| 98 |
+
|
| 99 |
+
def test_vlm_response_format():
|
| 100 |
+
"""Test handling of different VLM response formats"""
|
| 101 |
+
print("\nTesting VLM Response Format Handling...")
|
| 102 |
+
|
| 103 |
+
# Test content-wrapped format
|
| 104 |
+
content_wrapped = {
|
| 105 |
+
"content": {
|
| 106 |
+
"analysis": "Test analysis",
|
| 107 |
+
"metadata": {
|
| 108 |
+
"title": "Test Title",
|
| 109 |
+
"source": "WFP",
|
| 110 |
+
"type": "EARTHQUAKE",
|
| 111 |
+
"countries": ["PA"],
|
| 112 |
+
"epsg": "4326"
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(content_wrapped, "crisis_map")
|
| 118 |
+
print(f"β Content-wrapped format: {is_valid}, Error: {error}")
|
| 119 |
+
|
| 120 |
+
# Test string content format
|
| 121 |
+
string_content = {
|
| 122 |
+
"content": '{"analysis": "Test", "metadata": {"title": "Test", "source": "WFP", "type": "EARTHQUAKE", "countries": ["PA"], "epsg": "4326"}}'
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(string_content, "crisis_map")
|
| 126 |
+
print(f"β String content format: {is_valid}, Error: {error}")
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
print("Schema Validation Test Suite")
|
| 130 |
+
print("=" * 40)
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
test_crisis_map_validation()
|
| 134 |
+
test_drone_validation()
|
| 135 |
+
test_vlm_response_format()
|
| 136 |
+
print("\nβ
All tests completed!")
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f"\nβ Test failed with error: {e}")
|
| 139 |
+
import traceback
|
| 140 |
+
traceback.print_exc()
|
py_backend/test_simple_validation.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Simple test to verify schema validation is working in your running backend
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
def test_validation_system():
|
| 7 |
+
"""Test the validation system directly"""
|
| 8 |
+
print("π§ͺ Testing Schema Validation System...")
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
# Import the validator
|
| 12 |
+
from app.services.schema_validator import schema_validator
|
| 13 |
+
|
| 14 |
+
print("\n1. Testing Crisis Map Validation:")
|
| 15 |
+
crisis_data = {
|
| 16 |
+
"analysis": "A major earthquake occurred in Panama with magnitude 6.6",
|
| 17 |
+
"metadata": {
|
| 18 |
+
"title": "Panama Earthquake July 2025",
|
| 19 |
+
"source": "WFP",
|
| 20 |
+
"type": "EARTHQUAKE",
|
| 21 |
+
"countries": ["PA"],
|
| 22 |
+
"epsg": "32617"
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(crisis_data, "crisis_map")
|
| 27 |
+
print(f" β
Valid crisis map data: {is_valid}")
|
| 28 |
+
if is_valid:
|
| 29 |
+
print(f" π Cleaned metadata keys: {list(cleaned_data['metadata'].keys())}")
|
| 30 |
+
|
| 31 |
+
print("\n2. Testing Drone Validation:")
|
| 32 |
+
drone_data = {
|
| 33 |
+
"analysis": "Drone shows damaged building",
|
| 34 |
+
"metadata": {
|
| 35 |
+
"title": "Damaged Building",
|
| 36 |
+
"center_lat": 8.5,
|
| 37 |
+
"center_lon": -80.0,
|
| 38 |
+
"amsl_m": 100.0,
|
| 39 |
+
"heading_deg": 180.0
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(drone_data, "drone_image")
|
| 44 |
+
print(f" β
Valid drone data: {is_valid}")
|
| 45 |
+
if is_valid:
|
| 46 |
+
print(f" π Cleaned metadata has {len([k for k, v in cleaned_data['metadata'].items() if v is not None])} non-null fields")
|
| 47 |
+
|
| 48 |
+
print("\n3. Testing Invalid Data Rejection:")
|
| 49 |
+
invalid_data = {
|
| 50 |
+
"analysis": "Test",
|
| 51 |
+
"metadata": {
|
| 52 |
+
"center_lat": 95.0, # Invalid: > 90
|
| 53 |
+
"center_lon": 200.0, # Invalid: > 180
|
| 54 |
+
"heading_deg": 400.0 # Invalid: > 360
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(invalid_data, "drone_image")
|
| 59 |
+
print(f" β
Invalid data correctly rejected: {not is_valid}")
|
| 60 |
+
if not is_valid:
|
| 61 |
+
print(f" π Error message: {error[:100]}...")
|
| 62 |
+
|
| 63 |
+
print("\n4. Testing VLM Response Format Handling:")
|
| 64 |
+
wrapped_data = {
|
| 65 |
+
"content": {
|
| 66 |
+
"analysis": "Test analysis",
|
| 67 |
+
"metadata": {
|
| 68 |
+
"title": "Test Title",
|
| 69 |
+
"source": "WFP",
|
| 70 |
+
"type": "EARTHQUAKE",
|
| 71 |
+
"countries": ["PA"],
|
| 72 |
+
"epsg": "4326"
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
cleaned_data, is_valid, error = schema_validator.clean_and_validate_data(wrapped_data, "crisis_map")
|
| 78 |
+
print(f" β
Content-wrapped format handled: {is_valid}")
|
| 79 |
+
|
| 80 |
+
print("\nπ Schema validation system is working correctly!")
|
| 81 |
+
print("\nπ What this means:")
|
| 82 |
+
print(" β’ VLM responses will be validated against schemas")
|
| 83 |
+
print(" β’ Invalid data will be caught and logged")
|
| 84 |
+
print(" β’ Clean, structured data will be stored")
|
| 85 |
+
print(" β’ Different schemas for crisis maps vs drone images")
|
| 86 |
+
|
| 87 |
+
return True
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"β Test failed: {e}")
|
| 91 |
+
import traceback
|
| 92 |
+
traceback.print_exc()
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
def test_database_schemas():
|
| 96 |
+
"""Test database schema access"""
|
| 97 |
+
print("\nπ§ͺ Testing Database Schema Access...")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
from app import crud, database
|
| 101 |
+
from app.models import JSONSchema
|
| 102 |
+
|
| 103 |
+
# Create a database session
|
| 104 |
+
db = database.SessionLocal()
|
| 105 |
+
|
| 106 |
+
# Get schemas from database
|
| 107 |
+
schemas = crud.get_all_schemas(db)
|
| 108 |
+
print(f" β
Found {len(schemas)} schemas in database:")
|
| 109 |
+
|
| 110 |
+
for schema in schemas:
|
| 111 |
+
print(f" - {schema.schema_id}: {schema.title}")
|
| 112 |
+
|
| 113 |
+
# Test specific schema retrieval
|
| 114 |
+
crisis_schema = crud.get_schema(db, "[email protected]")
|
| 115 |
+
drone_schema = crud.get_schema(db, "[email protected]")
|
| 116 |
+
|
| 117 |
+
if crisis_schema:
|
| 118 |
+
print(f" β
Crisis schema found: {crisis_schema.title}")
|
| 119 |
+
if drone_schema:
|
| 120 |
+
print(f" β
Drone schema found: {drone_schema.title}")
|
| 121 |
+
|
| 122 |
+
db.close()
|
| 123 |
+
return True
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
print(f"β Database schema test failed: {e}")
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
print("π Testing Schema Validation Integration")
|
| 131 |
+
print("=" * 50)
|
| 132 |
+
|
| 133 |
+
success1 = test_validation_system()
|
| 134 |
+
success2 = test_database_schemas()
|
| 135 |
+
|
| 136 |
+
if success1 and success2:
|
| 137 |
+
print(f"\nβ
All tests passed! Your schema validation is ready to use.")
|
| 138 |
+
print(f"\nπ§ To see it in action:")
|
| 139 |
+
print(f" 1. Upload an image through your frontend")
|
| 140 |
+
print(f" 2. Check backend logs for validation messages")
|
| 141 |
+
print(f" 3. Use admin panel to view validation stats")
|
| 142 |
+
else:
|
| 143 |
+
print(f"\nβ Some tests failed. Check the errors above.")
|