Spaces:
Running
Running
Create clean Supabase-only TreeTrack implementation
Browse files- Add SupabaseFileStorage for image/audio uploads with private buckets
- Create app_supabase.py - clean FastAPI app using only Supabase
- No SQLite complexity, no backup/restore systems
- Direct Postgres + Storage integration
- Signed URLs for private file access
- All functionality preserved with cleaner codebase
- app_supabase.py +601 -0
- supabase_storage.py +153 -0
app_supabase.py
ADDED
@@ -0,0 +1,601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
TreeTrack FastAPI Application - Supabase Edition
|
3 |
+
Clean implementation using Supabase Postgres + Storage
|
4 |
+
"""
|
5 |
+
|
6 |
+
import json
|
7 |
+
import logging
|
8 |
+
import time
|
9 |
+
from datetime import datetime
|
10 |
+
from typing import Any, Optional, List, Dict
|
11 |
+
|
12 |
+
import uvicorn
|
13 |
+
from fastapi import FastAPI, HTTPException, Request, status, File, UploadFile, Form
|
14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
15 |
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
16 |
+
from fastapi.staticfiles import StaticFiles
|
17 |
+
from pydantic import BaseModel, Field, field_validator
|
18 |
+
import uuid
|
19 |
+
import os
|
20 |
+
|
21 |
+
# Import our Supabase components
|
22 |
+
from supabase_database import SupabaseDatabase
|
23 |
+
from supabase_storage import SupabaseFileStorage
|
24 |
+
from config import get_settings
|
25 |
+
from master_tree_database import create_master_tree_database, get_tree_suggestions, get_all_tree_codes
|
26 |
+
|
27 |
+
# Configure logging
|
28 |
+
logging.basicConfig(
|
29 |
+
level=logging.INFO,
|
30 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
31 |
+
handlers=[logging.FileHandler("app.log"), logging.StreamHandler()],
|
32 |
+
)
|
33 |
+
logger = logging.getLogger(__name__)
|
34 |
+
|
35 |
+
# Log startup
|
36 |
+
build_time = os.environ.get('BUILD_TIME', 'unknown')
|
37 |
+
logger.info(f"TreeTrack Supabase Edition starting - Build time: {build_time}")
|
38 |
+
|
39 |
+
# Get configuration settings
|
40 |
+
settings = get_settings()
|
41 |
+
|
42 |
+
# Initialize FastAPI app
|
43 |
+
app = FastAPI(
|
44 |
+
title="TreeTrack - Supabase Edition",
|
45 |
+
description="Tree mapping and tracking with persistent cloud storage",
|
46 |
+
version="3.0.0",
|
47 |
+
docs_url="/docs",
|
48 |
+
redoc_url="/redoc",
|
49 |
+
)
|
50 |
+
|
51 |
+
# CORS middleware
|
52 |
+
app.add_middleware(
|
53 |
+
CORSMiddleware,
|
54 |
+
allow_origins=settings.security.cors_origins,
|
55 |
+
allow_credentials=True,
|
56 |
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
57 |
+
allow_headers=["*"],
|
58 |
+
)
|
59 |
+
|
60 |
+
# Serve static files
|
61 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
62 |
+
|
63 |
+
# Initialize Supabase components
|
64 |
+
db = SupabaseDatabase()
|
65 |
+
storage = SupabaseFileStorage()
|
66 |
+
|
67 |
+
# Pydantic models (same as before)
|
68 |
+
class Tree(BaseModel):
|
69 |
+
"""Complete tree model with all 12 fields"""
|
70 |
+
id: int
|
71 |
+
latitude: float
|
72 |
+
longitude: float
|
73 |
+
local_name: Optional[str] = None
|
74 |
+
scientific_name: Optional[str] = None
|
75 |
+
common_name: Optional[str] = None
|
76 |
+
tree_code: Optional[str] = None
|
77 |
+
height: Optional[float] = None
|
78 |
+
width: Optional[float] = None
|
79 |
+
utility: Optional[List[str]] = None
|
80 |
+
storytelling_text: Optional[str] = None
|
81 |
+
storytelling_audio: Optional[str] = None
|
82 |
+
phenology_stages: Optional[List[str]] = None
|
83 |
+
photographs: Optional[Dict[str, str]] = None
|
84 |
+
notes: Optional[str] = None
|
85 |
+
created_at: str
|
86 |
+
updated_at: Optional[str] = None
|
87 |
+
created_by: str = "system"
|
88 |
+
|
89 |
+
|
90 |
+
class TreeCreate(BaseModel):
|
91 |
+
"""Model for creating new tree records"""
|
92 |
+
latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
|
93 |
+
longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
|
94 |
+
local_name: Optional[str] = Field(None, max_length=200, description="Local Assamese name")
|
95 |
+
scientific_name: Optional[str] = Field(None, max_length=200, description="Scientific name")
|
96 |
+
common_name: Optional[str] = Field(None, max_length=200, description="Common name")
|
97 |
+
tree_code: Optional[str] = Field(None, max_length=20, description="Tree reference code")
|
98 |
+
height: Optional[float] = Field(None, gt=0, le=200, description="Height in meters")
|
99 |
+
width: Optional[float] = Field(None, gt=0, le=2000, description="Width/girth in centimeters")
|
100 |
+
utility: Optional[List[str]] = Field(None, description="Ecological/cultural utilities")
|
101 |
+
storytelling_text: Optional[str] = Field(None, max_length=5000, description="Stories and narratives")
|
102 |
+
storytelling_audio: Optional[str] = Field(None, description="Audio file path")
|
103 |
+
phenology_stages: Optional[List[str]] = Field(None, description="Current development stages")
|
104 |
+
photographs: Optional[Dict[str, str]] = Field(None, description="Photo categories and paths")
|
105 |
+
notes: Optional[str] = Field(None, max_length=2000, description="Additional observations")
|
106 |
+
|
107 |
+
@field_validator("utility", mode='before')
|
108 |
+
@classmethod
|
109 |
+
def validate_utility(cls, v):
|
110 |
+
if isinstance(v, str):
|
111 |
+
try:
|
112 |
+
v = json.loads(v)
|
113 |
+
except json.JSONDecodeError:
|
114 |
+
raise ValueError(f"Invalid JSON string for utility: {v}")
|
115 |
+
|
116 |
+
if v is not None:
|
117 |
+
valid_utilities = [
|
118 |
+
"Religious", "Timber", "Biodiversity", "Hydrological benefit",
|
119 |
+
"Faunal interaction", "Food", "Medicine", "Shelter", "Cultural"
|
120 |
+
]
|
121 |
+
for item in v:
|
122 |
+
if item not in valid_utilities:
|
123 |
+
raise ValueError(f"Invalid utility: {item}")
|
124 |
+
return v
|
125 |
+
|
126 |
+
@field_validator("phenology_stages", mode='before')
|
127 |
+
@classmethod
|
128 |
+
def validate_phenology(cls, v):
|
129 |
+
if isinstance(v, str):
|
130 |
+
try:
|
131 |
+
v = json.loads(v)
|
132 |
+
except json.JSONDecodeError:
|
133 |
+
raise ValueError(f"Invalid JSON string for phenology_stages: {v}")
|
134 |
+
|
135 |
+
if v is not None:
|
136 |
+
valid_stages = [
|
137 |
+
"New leaves", "Old leaves", "Open flowers", "Fruiting",
|
138 |
+
"Ripe fruit", "Recent fruit drop", "Other"
|
139 |
+
]
|
140 |
+
for stage in v:
|
141 |
+
if stage not in valid_stages:
|
142 |
+
raise ValueError(f"Invalid phenology stage: {stage}")
|
143 |
+
return v
|
144 |
+
|
145 |
+
@field_validator("photographs", mode='before')
|
146 |
+
@classmethod
|
147 |
+
def validate_photographs(cls, v):
|
148 |
+
if isinstance(v, str):
|
149 |
+
try:
|
150 |
+
v = json.loads(v)
|
151 |
+
except json.JSONDecodeError:
|
152 |
+
raise ValueError(f"Invalid JSON string for photographs: {v}")
|
153 |
+
|
154 |
+
if v is not None:
|
155 |
+
valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
|
156 |
+
for category in v.keys():
|
157 |
+
if category not in valid_categories:
|
158 |
+
raise ValueError(f"Invalid photo category: {category}")
|
159 |
+
return v
|
160 |
+
|
161 |
+
|
162 |
+
class TreeUpdate(BaseModel):
|
163 |
+
"""Model for updating tree records"""
|
164 |
+
latitude: Optional[float] = Field(None, ge=-90, le=90)
|
165 |
+
longitude: Optional[float] = Field(None, ge=-180, le=180)
|
166 |
+
local_name: Optional[str] = Field(None, max_length=200)
|
167 |
+
scientific_name: Optional[str] = Field(None, max_length=200)
|
168 |
+
common_name: Optional[str] = Field(None, max_length=200)
|
169 |
+
tree_code: Optional[str] = Field(None, max_length=20)
|
170 |
+
height: Optional[float] = Field(None, gt=0, le=200)
|
171 |
+
width: Optional[float] = Field(None, gt=0, le=2000)
|
172 |
+
utility: Optional[List[str]] = None
|
173 |
+
storytelling_text: Optional[str] = Field(None, max_length=5000)
|
174 |
+
storytelling_audio: Optional[str] = None
|
175 |
+
phenology_stages: Optional[List[str]] = None
|
176 |
+
photographs: Optional[Dict[str, str]] = None
|
177 |
+
notes: Optional[str] = Field(None, max_length=2000)
|
178 |
+
|
179 |
+
|
180 |
+
# Application startup
|
181 |
+
@app.on_event("startup")
|
182 |
+
async def startup_event():
|
183 |
+
"""Initialize application"""
|
184 |
+
try:
|
185 |
+
# Test Supabase connection
|
186 |
+
if not db.test_connection():
|
187 |
+
logger.error("Failed to connect to Supabase database")
|
188 |
+
raise Exception("Database connection failed")
|
189 |
+
|
190 |
+
# Initialize database schema
|
191 |
+
db.initialize_database()
|
192 |
+
|
193 |
+
# Initialize master tree database
|
194 |
+
create_master_tree_database()
|
195 |
+
|
196 |
+
# Log success
|
197 |
+
tree_count = db.get_tree_count()
|
198 |
+
logger.info(f"TreeTrack Supabase Edition initialized - {tree_count} trees in database")
|
199 |
+
|
200 |
+
except Exception as e:
|
201 |
+
logger.error(f"Application startup failed: {e}")
|
202 |
+
raise
|
203 |
+
|
204 |
+
|
205 |
+
# Health check
|
206 |
+
@app.get("/health", tags=["Health"])
|
207 |
+
async def health_check():
|
208 |
+
"""Health check endpoint"""
|
209 |
+
try:
|
210 |
+
connection_ok = db.test_connection()
|
211 |
+
tree_count = db.get_tree_count() if connection_ok else 0
|
212 |
+
|
213 |
+
return {
|
214 |
+
"status": "healthy" if connection_ok else "unhealthy",
|
215 |
+
"database": "connected" if connection_ok else "disconnected",
|
216 |
+
"trees": tree_count,
|
217 |
+
"timestamp": datetime.now().isoformat(),
|
218 |
+
"version": "3.0.0",
|
219 |
+
}
|
220 |
+
except Exception as e:
|
221 |
+
logger.error(f"Health check failed: {e}")
|
222 |
+
return {
|
223 |
+
"status": "unhealthy",
|
224 |
+
"error": str(e),
|
225 |
+
"timestamp": datetime.now().isoformat(),
|
226 |
+
}
|
227 |
+
|
228 |
+
|
229 |
+
# Frontend routes
|
230 |
+
@app.get("/", response_class=HTMLResponse, tags=["Frontend"])
|
231 |
+
async def read_root():
|
232 |
+
"""Serve the main application page"""
|
233 |
+
try:
|
234 |
+
with open("static/index.html", encoding="utf-8") as f:
|
235 |
+
content = f.read()
|
236 |
+
return HTMLResponse(content=content)
|
237 |
+
except FileNotFoundError:
|
238 |
+
logger.error("index.html not found")
|
239 |
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
240 |
+
|
241 |
+
|
242 |
+
@app.get("/map", response_class=HTMLResponse, tags=["Frontend"])
|
243 |
+
async def serve_map():
|
244 |
+
"""Serve the map page"""
|
245 |
+
return RedirectResponse(url="/static/map.html")
|
246 |
+
|
247 |
+
|
248 |
+
# Tree CRUD Operations
|
249 |
+
@app.get("/api/trees", response_model=List[Tree], tags=["Trees"])
|
250 |
+
async def get_trees(
|
251 |
+
limit: int = 100,
|
252 |
+
offset: int = 0,
|
253 |
+
species: str = None,
|
254 |
+
health_status: str = None,
|
255 |
+
):
|
256 |
+
"""Get trees with pagination and filters"""
|
257 |
+
if limit < 1 or limit > settings.server.max_trees_per_request:
|
258 |
+
raise HTTPException(
|
259 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
260 |
+
detail=f"Limit must be between 1 and {settings.server.max_trees_per_request}",
|
261 |
+
)
|
262 |
+
if offset < 0:
|
263 |
+
raise HTTPException(
|
264 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
265 |
+
detail="Offset must be non-negative",
|
266 |
+
)
|
267 |
+
|
268 |
+
try:
|
269 |
+
trees = db.get_trees(limit=limit, offset=offset, species=species, health_status=health_status)
|
270 |
+
|
271 |
+
# Add signed URLs for files
|
272 |
+
processed_trees = []
|
273 |
+
for tree in trees:
|
274 |
+
processed_tree = storage.process_tree_files(tree)
|
275 |
+
processed_trees.append(processed_tree)
|
276 |
+
|
277 |
+
return processed_trees
|
278 |
+
|
279 |
+
except Exception as e:
|
280 |
+
logger.error(f"Error retrieving trees: {e}")
|
281 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve trees")
|
282 |
+
|
283 |
+
|
284 |
+
@app.post("/api/trees", response_model=Tree, status_code=status.HTTP_201_CREATED, tags=["Trees"])
|
285 |
+
async def create_tree(tree: TreeCreate):
|
286 |
+
"""Create a new tree record"""
|
287 |
+
try:
|
288 |
+
# Convert to dict for database insertion
|
289 |
+
tree_data = tree.model_dump(exclude_unset=True)
|
290 |
+
|
291 |
+
# Create tree in database
|
292 |
+
created_tree = db.create_tree(tree_data)
|
293 |
+
|
294 |
+
# Process files and return with URLs
|
295 |
+
processed_tree = storage.process_tree_files(created_tree)
|
296 |
+
|
297 |
+
logger.info(f"Created tree with ID: {created_tree.get('id')}")
|
298 |
+
return processed_tree
|
299 |
+
|
300 |
+
except Exception as e:
|
301 |
+
logger.error(f"Error creating tree: {e}")
|
302 |
+
raise HTTPException(status_code=500, detail="Failed to create tree")
|
303 |
+
|
304 |
+
|
305 |
+
@app.get("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
|
306 |
+
async def get_tree(tree_id: int):
|
307 |
+
"""Get a specific tree by ID"""
|
308 |
+
try:
|
309 |
+
tree = db.get_tree(tree_id)
|
310 |
+
|
311 |
+
if tree is None:
|
312 |
+
raise HTTPException(
|
313 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
314 |
+
detail=f"Tree with ID {tree_id} not found",
|
315 |
+
)
|
316 |
+
|
317 |
+
# Process files and return with URLs
|
318 |
+
processed_tree = storage.process_tree_files(tree)
|
319 |
+
return processed_tree
|
320 |
+
|
321 |
+
except HTTPException:
|
322 |
+
raise
|
323 |
+
except Exception as e:
|
324 |
+
logger.error(f"Error retrieving tree {tree_id}: {e}")
|
325 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve tree")
|
326 |
+
|
327 |
+
|
328 |
+
@app.put("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
|
329 |
+
async def update_tree(tree_id: int, tree_update: TreeUpdate):
|
330 |
+
"""Update a tree record"""
|
331 |
+
try:
|
332 |
+
# Convert to dict for database update
|
333 |
+
update_data = tree_update.model_dump(exclude_unset=True)
|
334 |
+
|
335 |
+
if not update_data:
|
336 |
+
raise HTTPException(
|
337 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
338 |
+
detail="No update data provided",
|
339 |
+
)
|
340 |
+
|
341 |
+
# Update tree in database
|
342 |
+
updated_tree = db.update_tree(tree_id, update_data)
|
343 |
+
|
344 |
+
# Process files and return with URLs
|
345 |
+
processed_tree = storage.process_tree_files(updated_tree)
|
346 |
+
|
347 |
+
logger.info(f"Updated tree with ID: {tree_id}")
|
348 |
+
return processed_tree
|
349 |
+
|
350 |
+
except HTTPException:
|
351 |
+
raise
|
352 |
+
except Exception as e:
|
353 |
+
logger.error(f"Error updating tree {tree_id}: {e}")
|
354 |
+
raise HTTPException(status_code=500, detail="Failed to update tree")
|
355 |
+
|
356 |
+
|
357 |
+
@app.delete("/api/trees/{tree_id}", tags=["Trees"])
|
358 |
+
async def delete_tree(tree_id: int):
|
359 |
+
"""Delete a tree record"""
|
360 |
+
try:
|
361 |
+
# Get tree data first to clean up files
|
362 |
+
tree = db.get_tree(tree_id)
|
363 |
+
|
364 |
+
if tree is None:
|
365 |
+
raise HTTPException(
|
366 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
367 |
+
detail=f"Tree with ID {tree_id} not found",
|
368 |
+
)
|
369 |
+
|
370 |
+
# Delete tree from database
|
371 |
+
db.delete_tree(tree_id)
|
372 |
+
|
373 |
+
# Clean up associated files
|
374 |
+
try:
|
375 |
+
if tree.get('photographs'):
|
376 |
+
for file_path in tree['photographs'].values():
|
377 |
+
if file_path:
|
378 |
+
storage.delete_image(file_path)
|
379 |
+
|
380 |
+
if tree.get('storytelling_audio'):
|
381 |
+
storage.delete_audio(tree['storytelling_audio'])
|
382 |
+
except Exception as e:
|
383 |
+
logger.warning(f"Failed to clean up files for tree {tree_id}: {e}")
|
384 |
+
|
385 |
+
logger.info(f"Deleted tree with ID: {tree_id}")
|
386 |
+
return {"message": f"Tree {tree_id} deleted successfully"}
|
387 |
+
|
388 |
+
except HTTPException:
|
389 |
+
raise
|
390 |
+
except Exception as e:
|
391 |
+
logger.error(f"Error deleting tree {tree_id}: {e}")
|
392 |
+
raise HTTPException(status_code=500, detail="Failed to delete tree")
|
393 |
+
|
394 |
+
|
395 |
+
# File Upload Endpoints
|
396 |
+
@app.post("/api/upload/image", tags=["Files"])
|
397 |
+
async def upload_image(
|
398 |
+
file: UploadFile = File(...),
|
399 |
+
category: str = Form(...)
|
400 |
+
):
|
401 |
+
"""Upload an image file with cloud persistence"""
|
402 |
+
# Validate file type
|
403 |
+
if not file.content_type or not file.content_type.startswith('image/'):
|
404 |
+
raise HTTPException(status_code=400, detail="File must be an image")
|
405 |
+
|
406 |
+
# Validate category
|
407 |
+
valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
|
408 |
+
if category not in valid_categories:
|
409 |
+
raise HTTPException(
|
410 |
+
status_code=400,
|
411 |
+
detail=f"Category must be one of: {valid_categories}"
|
412 |
+
)
|
413 |
+
|
414 |
+
try:
|
415 |
+
# Read file content
|
416 |
+
content = await file.read()
|
417 |
+
|
418 |
+
# Upload to Supabase Storage
|
419 |
+
result = storage.upload_image(content, file.filename, category)
|
420 |
+
|
421 |
+
logger.info(f"Image uploaded successfully: {result['filename']}")
|
422 |
+
|
423 |
+
return {
|
424 |
+
"filename": result['filename'],
|
425 |
+
"category": category,
|
426 |
+
"size": result['size'],
|
427 |
+
"content_type": file.content_type,
|
428 |
+
"bucket": result['bucket'],
|
429 |
+
"success": True
|
430 |
+
}
|
431 |
+
|
432 |
+
except Exception as e:
|
433 |
+
logger.error(f"Error uploading image: {e}")
|
434 |
+
raise HTTPException(status_code=500, detail=str(e))
|
435 |
+
|
436 |
+
|
437 |
+
@app.post("/api/upload/audio", tags=["Files"])
|
438 |
+
async def upload_audio(file: UploadFile = File(...)):
|
439 |
+
"""Upload an audio file with cloud persistence"""
|
440 |
+
# Validate file type
|
441 |
+
if not file.content_type or not file.content_type.startswith('audio/'):
|
442 |
+
raise HTTPException(status_code=400, detail="File must be an audio file")
|
443 |
+
|
444 |
+
try:
|
445 |
+
# Read file content
|
446 |
+
content = await file.read()
|
447 |
+
|
448 |
+
# Upload to Supabase Storage
|
449 |
+
result = storage.upload_audio(content, file.filename)
|
450 |
+
|
451 |
+
logger.info(f"Audio uploaded successfully: {result['filename']}")
|
452 |
+
|
453 |
+
return {
|
454 |
+
"filename": result['filename'],
|
455 |
+
"size": result['size'],
|
456 |
+
"content_type": file.content_type,
|
457 |
+
"bucket": result['bucket'],
|
458 |
+
"success": True
|
459 |
+
}
|
460 |
+
|
461 |
+
except Exception as e:
|
462 |
+
logger.error(f"Error uploading audio: {e}")
|
463 |
+
raise HTTPException(status_code=500, detail=str(e))
|
464 |
+
|
465 |
+
|
466 |
+
# File serving - generate signed URLs on demand
|
467 |
+
@app.get("/api/files/image/{file_path:path}", tags=["Files"])
|
468 |
+
async def get_image(file_path: str):
|
469 |
+
"""Get signed URL for image file"""
|
470 |
+
try:
|
471 |
+
signed_url = storage.get_image_url(file_path, expires_in=3600) # 1 hour
|
472 |
+
return RedirectResponse(url=signed_url)
|
473 |
+
except Exception as e:
|
474 |
+
logger.error(f"Error getting image URL: {e}")
|
475 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
476 |
+
|
477 |
+
|
478 |
+
@app.get("/api/files/audio/{file_path:path}", tags=["Files"])
|
479 |
+
async def get_audio(file_path: str):
|
480 |
+
"""Get signed URL for audio file"""
|
481 |
+
try:
|
482 |
+
signed_url = storage.get_audio_url(file_path, expires_in=3600) # 1 hour
|
483 |
+
return RedirectResponse(url=signed_url)
|
484 |
+
except Exception as e:
|
485 |
+
logger.error(f"Error getting audio URL: {e}")
|
486 |
+
raise HTTPException(status_code=404, detail="Audio not found")
|
487 |
+
|
488 |
+
|
489 |
+
# Utility endpoints
|
490 |
+
@app.get("/api/utilities", tags=["Data"])
|
491 |
+
async def get_utilities():
|
492 |
+
"""Get list of valid utility options"""
|
493 |
+
return {
|
494 |
+
"utilities": [
|
495 |
+
"Religious", "Timber", "Biodiversity", "Hydrological benefit",
|
496 |
+
"Faunal interaction", "Food", "Medicine", "Shelter", "Cultural"
|
497 |
+
]
|
498 |
+
}
|
499 |
+
|
500 |
+
|
501 |
+
@app.get("/api/phenology-stages", tags=["Data"])
|
502 |
+
async def get_phenology_stages():
|
503 |
+
"""Get list of valid phenology stages"""
|
504 |
+
return {
|
505 |
+
"stages": [
|
506 |
+
"New leaves", "Old leaves", "Open flowers", "Fruiting",
|
507 |
+
"Ripe fruit", "Recent fruit drop", "Other"
|
508 |
+
]
|
509 |
+
}
|
510 |
+
|
511 |
+
|
512 |
+
@app.get("/api/photo-categories", tags=["Data"])
|
513 |
+
async def get_photo_categories():
|
514 |
+
"""Get list of valid photo categories"""
|
515 |
+
return {
|
516 |
+
"categories": ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
|
517 |
+
}
|
518 |
+
|
519 |
+
|
520 |
+
# Statistics
|
521 |
+
@app.get("/api/stats", tags=["Statistics"])
|
522 |
+
async def get_stats():
|
523 |
+
"""Get comprehensive tree statistics"""
|
524 |
+
try:
|
525 |
+
total_trees = db.get_tree_count()
|
526 |
+
species_distribution = db.get_species_distribution(limit=20)
|
527 |
+
health_distribution = db.get_health_distribution() # Will be empty for now
|
528 |
+
measurements = db.get_average_measurements()
|
529 |
+
|
530 |
+
return {
|
531 |
+
"total_trees": total_trees,
|
532 |
+
"species_distribution": species_distribution,
|
533 |
+
"health_distribution": health_distribution,
|
534 |
+
"average_height": measurements["average_height"],
|
535 |
+
"average_diameter": measurements["average_diameter"],
|
536 |
+
"last_updated": datetime.now().isoformat(),
|
537 |
+
}
|
538 |
+
|
539 |
+
except Exception as e:
|
540 |
+
logger.error(f"Error retrieving statistics: {e}")
|
541 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve statistics")
|
542 |
+
|
543 |
+
|
544 |
+
# Master tree database suggestions
|
545 |
+
@app.get("/api/tree-suggestions", tags=["Data"])
|
546 |
+
async def get_tree_suggestions_api(query: str = "", limit: int = 10):
|
547 |
+
"""Get auto-suggestions for tree names from master database"""
|
548 |
+
if not query or len(query.strip()) == 0:
|
549 |
+
return {"suggestions": []}
|
550 |
+
|
551 |
+
try:
|
552 |
+
create_master_tree_database()
|
553 |
+
suggestions = get_tree_suggestions(query.strip(), limit)
|
554 |
+
|
555 |
+
return {
|
556 |
+
"query": query,
|
557 |
+
"suggestions": suggestions,
|
558 |
+
"count": len(suggestions)
|
559 |
+
}
|
560 |
+
|
561 |
+
except Exception as e:
|
562 |
+
logger.error(f"Error getting tree suggestions: {e}")
|
563 |
+
return {"suggestions": [], "error": str(e)}
|
564 |
+
|
565 |
+
|
566 |
+
@app.get("/api/tree-codes", tags=["Data"])
|
567 |
+
async def get_tree_codes_api():
|
568 |
+
"""Get all available tree codes from master database"""
|
569 |
+
try:
|
570 |
+
create_master_tree_database()
|
571 |
+
tree_codes = get_all_tree_codes()
|
572 |
+
|
573 |
+
return {
|
574 |
+
"tree_codes": tree_codes,
|
575 |
+
"count": len(tree_codes)
|
576 |
+
}
|
577 |
+
|
578 |
+
except Exception as e:
|
579 |
+
logger.error(f"Error getting tree codes: {e}")
|
580 |
+
return {"tree_codes": [], "error": str(e)}
|
581 |
+
|
582 |
+
|
583 |
+
# Version info
|
584 |
+
@app.get("/api/version", tags=["System"])
|
585 |
+
async def get_version():
|
586 |
+
"""Get current application version"""
|
587 |
+
return {
|
588 |
+
"version": "3.0.0",
|
589 |
+
"backend": "supabase",
|
590 |
+
"database": "postgres",
|
591 |
+
"storage": "supabase-storage",
|
592 |
+
"timestamp": int(time.time()),
|
593 |
+
"build": build_time,
|
594 |
+
"server_time": datetime.now().isoformat()
|
595 |
+
}
|
596 |
+
|
597 |
+
|
598 |
+
if __name__ == "__main__":
|
599 |
+
uvicorn.run(
|
600 |
+
"app_supabase:app", host="127.0.0.1", port=8000, reload=True, log_level="info"
|
601 |
+
)
|
supabase_storage.py
ADDED
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Supabase file storage implementation for TreeTrack
|
3 |
+
Handles images and audio uploads to private Supabase Storage buckets
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import uuid
|
8 |
+
import logging
|
9 |
+
from typing import Dict, Any, Optional
|
10 |
+
from pathlib import Path
|
11 |
+
from supabase_client import get_supabase_client
|
12 |
+
|
13 |
+
logger = logging.getLogger(__name__)
|
14 |
+
|
15 |
+
class SupabaseFileStorage:
|
16 |
+
"""Supabase Storage implementation for file uploads"""
|
17 |
+
|
18 |
+
def __init__(self):
|
19 |
+
self.client = get_supabase_client()
|
20 |
+
self.image_bucket = "tree-images"
|
21 |
+
self.audio_bucket = "tree-audios"
|
22 |
+
logger.info("SupabaseFileStorage initialized")
|
23 |
+
|
24 |
+
def upload_image(self, file_data: bytes, filename: str, category: str) -> Dict[str, Any]:
|
25 |
+
"""Upload image to Supabase Storage"""
|
26 |
+
try:
|
27 |
+
# Generate unique filename
|
28 |
+
file_id = str(uuid.uuid4())
|
29 |
+
file_extension = Path(filename).suffix.lower()
|
30 |
+
unique_filename = f"{category.lower()}/{file_id}{file_extension}"
|
31 |
+
|
32 |
+
# Upload to Supabase Storage
|
33 |
+
result = self.client.storage.from_(self.image_bucket).upload(
|
34 |
+
unique_filename, file_data
|
35 |
+
)
|
36 |
+
|
37 |
+
if result:
|
38 |
+
logger.info(f"Image uploaded successfully: {unique_filename}")
|
39 |
+
return {
|
40 |
+
"success": True,
|
41 |
+
"filename": unique_filename,
|
42 |
+
"bucket": self.image_bucket,
|
43 |
+
"category": category,
|
44 |
+
"size": len(file_data),
|
45 |
+
"file_id": file_id
|
46 |
+
}
|
47 |
+
else:
|
48 |
+
raise Exception("Upload failed")
|
49 |
+
|
50 |
+
except Exception as e:
|
51 |
+
logger.error(f"Error uploading image {filename}: {e}")
|
52 |
+
raise Exception(f"Failed to upload image: {str(e)}")
|
53 |
+
|
54 |
+
def upload_audio(self, file_data: bytes, filename: str) -> Dict[str, Any]:
|
55 |
+
"""Upload audio to Supabase Storage"""
|
56 |
+
try:
|
57 |
+
# Generate unique filename
|
58 |
+
file_id = str(uuid.uuid4())
|
59 |
+
file_extension = Path(filename).suffix.lower()
|
60 |
+
unique_filename = f"audio/{file_id}{file_extension}"
|
61 |
+
|
62 |
+
# Upload to Supabase Storage
|
63 |
+
result = self.client.storage.from_(self.audio_bucket).upload(
|
64 |
+
unique_filename, file_data
|
65 |
+
)
|
66 |
+
|
67 |
+
if result:
|
68 |
+
logger.info(f"Audio uploaded successfully: {unique_filename}")
|
69 |
+
return {
|
70 |
+
"success": True,
|
71 |
+
"filename": unique_filename,
|
72 |
+
"bucket": self.audio_bucket,
|
73 |
+
"size": len(file_data),
|
74 |
+
"file_id": file_id
|
75 |
+
}
|
76 |
+
else:
|
77 |
+
raise Exception("Upload failed")
|
78 |
+
|
79 |
+
except Exception as e:
|
80 |
+
logger.error(f"Error uploading audio {filename}: {e}")
|
81 |
+
raise Exception(f"Failed to upload audio: {str(e)}")
|
82 |
+
|
83 |
+
def get_signed_url(self, bucket_name: str, file_path: str, expires_in: int = 3600) -> str:
|
84 |
+
"""Generate signed URL for private file access"""
|
85 |
+
try:
|
86 |
+
result = self.client.storage.from_(bucket_name).create_signed_url(
|
87 |
+
file_path, expires_in
|
88 |
+
)
|
89 |
+
|
90 |
+
if result and 'signedURL' in result:
|
91 |
+
return result['signedURL']
|
92 |
+
else:
|
93 |
+
raise Exception("Failed to generate signed URL")
|
94 |
+
|
95 |
+
except Exception as e:
|
96 |
+
logger.error(f"Error generating signed URL for {file_path}: {e}")
|
97 |
+
raise Exception(f"Failed to generate file URL: {str(e)}")
|
98 |
+
|
99 |
+
def get_image_url(self, image_path: str, expires_in: int = 3600) -> str:
|
100 |
+
"""Get signed URL for image"""
|
101 |
+
return self.get_signed_url(self.image_bucket, image_path, expires_in)
|
102 |
+
|
103 |
+
def get_audio_url(self, audio_path: str, expires_in: int = 3600) -> str:
|
104 |
+
"""Get signed URL for audio"""
|
105 |
+
return self.get_signed_url(self.audio_bucket, audio_path, expires_in)
|
106 |
+
|
107 |
+
def delete_file(self, bucket_name: str, file_path: str) -> bool:
|
108 |
+
"""Delete file from Supabase Storage"""
|
109 |
+
try:
|
110 |
+
result = self.client.storage.from_(bucket_name).remove([file_path])
|
111 |
+
logger.info(f"File deleted: {bucket_name}/{file_path}")
|
112 |
+
return True
|
113 |
+
except Exception as e:
|
114 |
+
logger.error(f"Error deleting file {file_path}: {e}")
|
115 |
+
return False
|
116 |
+
|
117 |
+
def delete_image(self, image_path: str) -> bool:
|
118 |
+
"""Delete image from storage"""
|
119 |
+
return self.delete_file(self.image_bucket, image_path)
|
120 |
+
|
121 |
+
def delete_audio(self, audio_path: str) -> bool:
|
122 |
+
"""Delete audio from storage"""
|
123 |
+
return self.delete_file(self.audio_bucket, audio_path)
|
124 |
+
|
125 |
+
def process_tree_files(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
|
126 |
+
"""Process tree data to add signed URLs for files"""
|
127 |
+
processed_data = tree_data.copy()
|
128 |
+
|
129 |
+
try:
|
130 |
+
# Process photographs
|
131 |
+
if processed_data.get('photographs'):
|
132 |
+
photos = processed_data['photographs']
|
133 |
+
if isinstance(photos, dict):
|
134 |
+
for category, file_path in photos.items():
|
135 |
+
if file_path:
|
136 |
+
try:
|
137 |
+
photos[f"{category}_url"] = self.get_image_url(file_path)
|
138 |
+
except Exception as e:
|
139 |
+
logger.warning(f"Failed to generate URL for photo {file_path}: {e}")
|
140 |
+
|
141 |
+
# Process storytelling audio
|
142 |
+
if processed_data.get('storytelling_audio'):
|
143 |
+
audio_path = processed_data['storytelling_audio']
|
144 |
+
try:
|
145 |
+
processed_data['storytelling_audio_url'] = self.get_audio_url(audio_path)
|
146 |
+
except Exception as e:
|
147 |
+
logger.warning(f"Failed to generate URL for audio {audio_path}: {e}")
|
148 |
+
|
149 |
+
return processed_data
|
150 |
+
|
151 |
+
except Exception as e:
|
152 |
+
logger.error(f"Error processing tree files: {e}")
|
153 |
+
return processed_data
|