RoyAalekh commited on
Commit
0d6cca3
·
1 Parent(s): 54404f6

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

Files changed (2) hide show
  1. app_supabase.py +601 -0
  2. 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