RoyAalekh commited on
Commit
ef3133a
Β·
1 Parent(s): 2fcae50

πŸš€ Complete Supabase Migration - Clean Architecture Ready for Deployment

Browse files

βœ… CRITICAL FIXES APPLIED:
- Remove duplicate database classes from supabase_client.py
- Fix storage bucket naming mismatch (tree-audio consistency)
- Remove hardcoded URLs, enforce environment variables
- Configure private storage buckets with signed URLs
- Clean up obsolete files (app_supabase.py, app_sqlite_backup.py)
- Optimize config for HuggingFace Spaces (port 7860, CORS *)
- Make environment validation deployment-friendly

🌳 MASTER DATABASE: 146 species preserved & functional
πŸ—οΈ ARCHITECTURE: Clean Supabase-only implementation
πŸ“ FILES: Minimal, focused structure
πŸ”§ FEATURES: All 12 tree fields + file uploads + autocomplete

Ready for HuggingFace Spaces deployment! 🎯

DEPLOYMENT_READY.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TreeTrack - Deployment Ready βœ…
2
+
3
+ ## Migration Status: COMPLETE
4
+ **Date**: January 8, 2025
5
+ **Status**: βœ… Ready for deployment
6
+ **Backend**: Supabase Postgres + Storage
7
+ **Master Database**: βœ… 146 species preserved
8
+
9
+ ---
10
+
11
+ ## πŸ—‚οΈ File Structure (Clean)
12
+ ```
13
+ TreeTrack/
14
+ β”œβ”€β”€ app.py # Main FastAPI application
15
+ β”œβ”€β”€ config.py # Configuration management
16
+ β”œβ”€β”€ supabase_client.py # Supabase client setup
17
+ β”œβ”€β”€ supabase_database.py # Database operations
18
+ β”œβ”€β”€ supabase_storage.py # File storage operations
19
+ β”œβ”€β”€ master_tree_database.py # Autocomplete species database
20
+ β”œβ”€β”€ requirements.txt # Python dependencies
21
+ β”œβ”€β”€ static/ # Frontend assets
22
+ β”œβ”€β”€ data/ # Local SQLite for master species
23
+ └── DEPLOYMENT_READY.md # This file
24
+ ```
25
+
26
+ ## 🌳 Master Tree Database
27
+ - **Status**: βœ… Fully preserved and functional
28
+ - **Entries**: 146 tree species (including 6 local-name-only entries)
29
+ - **Storage**: Local SQLite database (`data/master_trees.db`)
30
+ - **Purpose**: Fast autocomplete and suggestions
31
+ - **API Endpoints**:
32
+ - `/api/tree-suggestions?query=<search>`
33
+ - `/api/tree-codes`
34
+
35
+ ### Sample Species Available:
36
+ - **Sal** (Shorea robusta) - Tree Code: SR
37
+ - **Neem** (Azadirachta indica) - Tree Code: AI2
38
+ - **Borgos** (Ficus benghalensis) - Tree Code: FB
39
+ - **And 143 more...**
40
+
41
+ ## πŸ—οΈ Architecture
42
+ - **Database**: Supabase Postgres (cloud-persistent)
43
+ - **Storage**: Supabase Storage with signed URLs
44
+ - **Autocomplete**: Local SQLite for performance
45
+ - **API**: FastAPI with comprehensive REST endpoints
46
+ - **Frontend**: Static HTML/JS (unchanged)
47
+
48
+ ## πŸ”§ Key Features Preserved
49
+ βœ… All 12 tree fields (location, names, measurements, etc.)
50
+ βœ… File uploads (images, audio) with cloud storage
51
+ βœ… Tree CRUD operations
52
+ βœ… Statistics and analytics
53
+ βœ… Master species autocomplete
54
+ βœ… Photo categories and phenology stages
55
+ βœ… Search and filtering
56
+
57
+ ## 🌐 API Endpoints
58
+ ```
59
+ GET / # Frontend
60
+ GET /health # Health check
61
+ GET /api/trees # List trees
62
+ POST /api/trees # Create tree
63
+ GET /api/trees/{id} # Get tree
64
+ PUT /api/trees/{id} # Update tree
65
+ DELETE /api/trees/{id} # Delete tree
66
+ POST /api/upload/image # Upload image
67
+ POST /api/upload/audio # Upload audio
68
+ GET /api/tree-suggestions # Autocomplete ⭐
69
+ GET /api/tree-codes # Tree codes ⭐
70
+ GET /api/stats # Statistics
71
+ ```
72
+
73
+ ## πŸ“¦ Dependencies
74
+ All required packages in `requirements.txt`:
75
+ - `fastapi>=0.115.0`
76
+ - `uvicorn[standard]>=0.32.0`
77
+ - `supabase>=2.3.4`
78
+ - `psycopg2-binary>=2.9.9`
79
+ - Plus supporting libraries
80
+
81
+ ## πŸš€ Deployment Notes
82
+ 1. **Environment Variables Required**:
83
+ - `SUPABASE_URL`
84
+ - `SUPABASE_ANON_KEY`
85
+ - `SUPABASE_SERVICE_ROLE_KEY`
86
+
87
+ 2. **Supabase Setup Required**:
88
+ - Trees table with 12 fields
89
+ - Storage buckets: `tree-images`, `tree-audio`
90
+ - Row Level Security policies
91
+
92
+ 3. **Master Database**:
93
+ - Auto-initializes on startup
94
+ - Creates local `data/master_trees.db`
95
+ - No manual setup required
96
+
97
+ ## βœ… Pre-Deploy Checklist
98
+ - [x] SQLite dependencies removed
99
+ - [x] Database interface abstracted
100
+ - [x] Supabase modules standalone
101
+ - [x] Master tree database preserved
102
+ - [x] All syntax validated
103
+ - [x] File structure cleaned
104
+ - [x] Requirements updated
105
+ - [x] **CRITICAL FIXES APPLIED**:
106
+ - [x] Removed duplicate database classes
107
+ - [x] Fixed bucket naming mismatch (tree-audio vs tree-audios)
108
+ - [x] Removed hardcoded Supabase URL
109
+ - [x] Fixed storage bucket configuration (private with signed URLs)
110
+ - [x] Made environment validation deployment-friendly
111
+ - [x] Optimized config for HuggingFace Spaces (port 7860, CORS *)
112
+
113
+ ## 🎯 Next Steps
114
+ 1. **Deploy to HuggingFace Spaces**
115
+ 2. **Monitor deployment logs**
116
+ 3. **Test master database autocomplete**
117
+ 4. **Verify file uploads work**
118
+ 5. **Test end-to-end functionality**
119
+
120
+ ---
121
+ **Ready for deployment!** πŸš€
app.py CHANGED
@@ -597,5 +597,5 @@ async def get_version():
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
  )
 
597
 
598
  if __name__ == "__main__":
599
  uvicorn.run(
600
+ "app:app", host="127.0.0.1", port=8000, reload=True, log_level="info"
601
  )
app_sqlite_backup.py DELETED
@@ -1,1614 +0,0 @@
1
- """
2
- Enhanced Tree Mapping FastAPI Application
3
- Implements security, robustness, performance, and best practices improvements
4
- """
5
-
6
-
7
- import json
8
- import logging
9
- import re
10
- import shutil
11
- import sqlite3
12
- import time
13
- from contextlib import contextmanager
14
- from datetime import datetime
15
- from pathlib import Path
16
- from typing import Any, Optional
17
-
18
- import uvicorn
19
- from fastapi import FastAPI, HTTPException, Request, status, File, UploadFile, Form
20
- from fastapi.middleware.cors import CORSMiddleware
21
- from fastapi.middleware.trustedhost import TrustedHostMiddleware
22
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
23
- from fastapi.staticfiles import StaticFiles
24
- from pydantic import BaseModel, Field, field_validator, model_validator
25
- import uuid
26
- import aiofiles
27
- import os
28
-
29
- from config import get_settings
30
- from master_tree_database import create_master_tree_database, get_tree_suggestions, get_all_tree_codes
31
- # Simple file-based persistence - no git/tokens needed
32
-
33
- # Configure logging
34
- logging.basicConfig(
35
- level=logging.INFO,
36
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
37
- handlers=[logging.FileHandler("app.log"), logging.StreamHandler()],
38
- )
39
- logger = logging.getLogger(__name__)
40
-
41
- # Log build info to help with cache debugging
42
-
43
- build_time = os.environ.get('BUILD_TIME', 'unknown')
44
- logger.info(f"TreeTrack starting - Build time: {build_time}")
45
-
46
-
47
- # Import configuration
48
-
49
-
50
- # Get configuration settings
51
- settings = get_settings()
52
-
53
- # Initialize FastAPI app with security headers
54
- app = FastAPI(
55
- title="Tree Mapping API",
56
- description="Secure API for mapping and tracking trees",
57
- version="2.0.0",
58
- docs_url="/docs",
59
- redoc_url="/redoc",
60
- )
61
-
62
- # CORS middleware - Essential for frontend-backend communication
63
- app.add_middleware(
64
- CORSMiddleware,
65
- allow_origins=settings.security.cors_origins,
66
- allow_credentials=True,
67
- allow_methods=["GET", "POST", "PUT", "DELETE"],
68
- allow_headers=["*"],
69
- )
70
-
71
- # TrustedHost middleware - Enable for production deployment
72
- # app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.security.allowed_hosts)
73
-
74
-
75
- # Security headers middleware - TEMPORARILY DISABLED FOR DEBUGGING
76
- # @app.middleware("http")
77
- # async def add_security_headers(request: Request, call_next):
78
- # start_time = time.time()
79
-
80
- # try:
81
- # response = await call_next(request)
82
-
83
- # # Add security headers
84
- # response.headers["X-Content-Type-Options"] = "nosniff"
85
- # response.headers["X-Frame-Options"] = "DENY"
86
- # response.headers["X-XSS-Protection"] = "1; mode=block"
87
- # response.headers["Strict-Transport-Security"] = (
88
- # "max-age=31536000; includeSubDomains"
89
- # )
90
- # response.headers["Content-Security-Policy"] = (
91
- # "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.plot.ly; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'
92
- # )
93
-
94
- # # Log request
95
- # process_time = time.time() - start_time
96
- # logger.info(
97
- # f"{request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s"
98
- # )
99
-
100
- # return response
101
- # except Exception as e:
102
- # logger.error(f"Request failed: {request.method} {request.url.path} - {e!s}")
103
- # raise
104
-
105
-
106
- # Serve static files
107
- app.mount("/static", StaticFiles(directory="static"), name="static")
108
-
109
- # Add cache-busting middleware for development
110
- @app.middleware("http")
111
- async def add_cache_headers(request: Request, call_next):
112
- response = await call_next(request)
113
-
114
- # Add cache-control headers for static files to prevent caching issues
115
- if request.url.path.startswith("/static/"):
116
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
117
- response.headers["Pragma"] = "no-cache"
118
- response.headers["Expires"] = "0"
119
-
120
- return response
121
-
122
-
123
- # Database setup with proper error handling
124
- def ensure_data_directory():
125
- """Ensure data and upload directories exist with proper permissions"""
126
- # For HF Spaces, try to use persistent storage, fall back to local
127
- base_dirs = ["data"]
128
-
129
- # Check if we can access persistent storage
130
- persistent_available = False
131
- try:
132
- persistent_test = Path("/tmp/hf_space_test")
133
- persistent_test.mkdir(exist_ok=True)
134
- persistent_test.rmdir()
135
- # If we can write to /tmp, we might have persistent storage
136
- logger.info("Persistent storage might be available")
137
- persistent_available = True
138
- except Exception:
139
- logger.info("Using local storage only")
140
-
141
- # Create local directories
142
- directories = ["data", "uploads", "uploads/images", "uploads/audio"]
143
-
144
- for dir_name in directories:
145
- dir_path = Path(dir_name)
146
- try:
147
- dir_path.mkdir(exist_ok=True, parents=True)
148
- # Don't set restrictive permissions in HF Spaces
149
- logger.info(f"Directory {dir_name} initialized")
150
- except Exception as e:
151
- logger.error(f"Failed to create {dir_name} directory: {e}")
152
- raise
153
-
154
-
155
- @contextmanager
156
- def get_db_connection():
157
- """Context manager for database connections with proper error handling"""
158
- conn = None
159
- try:
160
- conn = sqlite3.connect(
161
- settings.database.db_path,
162
- timeout=settings.server.request_timeout,
163
- check_same_thread=False,
164
- )
165
- conn.row_factory = sqlite3.Row
166
- # Enable foreign key constraints
167
- conn.execute("PRAGMA foreign_keys = ON")
168
-
169
- # Optimize for concurrent writes (perfect for 2 users)
170
- conn.execute("PRAGMA journal_mode = WAL")
171
- conn.execute("PRAGMA synchronous = NORMAL") # Good balance of safety/speed
172
- conn.execute("PRAGMA cache_size = 10000") # 10MB cache for better performance
173
- conn.execute("PRAGMA temp_store = MEMORY") # Use memory for temp tables
174
- conn.execute("PRAGMA wal_autocheckpoint = 1000") # Prevent WAL from growing too large
175
- yield conn
176
- except sqlite3.Error as e:
177
- logger.error(f"Database error: {e}")
178
- if conn:
179
- conn.rollback()
180
- raise
181
- except Exception as e:
182
- logger.error(f"Unexpected database error: {e}")
183
- if conn:
184
- conn.rollback()
185
- raise
186
- finally:
187
- if conn:
188
- conn.close()
189
-
190
-
191
- def init_db():
192
- """Initialize database with enhanced schema and constraints"""
193
- ensure_data_directory()
194
-
195
- try:
196
- with get_db_connection() as conn:
197
- cursor = conn.cursor()
198
-
199
- # Create enhanced trees table with all 12 fields
200
- cursor.execute("""
201
- CREATE TABLE IF NOT EXISTS trees (
202
- id INTEGER PRIMARY KEY AUTOINCREMENT,
203
- -- 1. Geolocation
204
- latitude REAL NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
205
- longitude REAL NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
206
-
207
- -- 2. Local Name (Assamese)
208
- local_name TEXT CHECK (local_name IS NULL OR length(local_name) <= 200),
209
-
210
- -- 3. Scientific Name
211
- scientific_name TEXT CHECK (scientific_name IS NULL OR length(scientific_name) <= 200),
212
-
213
- -- 4. Common Name
214
- common_name TEXT CHECK (common_name IS NULL OR length(common_name) <= 200),
215
-
216
- -- 5. Tree Code
217
- tree_code TEXT CHECK (tree_code IS NULL OR length(tree_code) <= 20),
218
-
219
- -- 6. Height (in meters)
220
- height REAL CHECK (height IS NULL OR (height > 0 AND height <= 200)),
221
-
222
- -- 7. Width/Girth (in cm)
223
- width REAL CHECK (width IS NULL OR (width > 0 AND width <= 2000)),
224
-
225
- -- 8. Utility (JSON array of selected utilities)
226
- utility TEXT, -- JSON array: ["Religious", "Timber", "Biodiversity", etc.]
227
-
228
- -- 9. Storytelling
229
- storytelling_text TEXT CHECK (storytelling_text IS NULL OR length(storytelling_text) <= 5000),
230
- storytelling_audio TEXT, -- File path to audio recording
231
-
232
- -- 10. Phenology Tracker (JSON array of current stages)
233
- phenology_stages TEXT, -- JSON array: ["New leaves", "Open flowers", etc.]
234
-
235
- -- 11. Photographs (JSON object with categories and file paths)
236
- photographs TEXT, -- JSON: {"leaf": "path1.jpg", "bark": "path2.jpg", etc.}
237
-
238
- -- 12. Notes
239
- notes TEXT CHECK (notes IS NULL OR length(notes) <= 2000),
240
-
241
- -- System fields
242
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
243
- created_by TEXT DEFAULT 'system',
244
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
245
- )
246
- """)
247
-
248
- # Create indexes for better performance
249
- cursor.execute(
250
- "CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude)"
251
- )
252
- cursor.execute(
253
- "CREATE INDEX IF NOT EXISTS idx_trees_scientific_name ON trees(scientific_name)"
254
- )
255
- cursor.execute(
256
- "CREATE INDEX IF NOT EXISTS idx_trees_local_name ON trees(local_name)"
257
- )
258
- cursor.execute(
259
- "CREATE INDEX IF NOT EXISTS idx_trees_tree_code ON trees(tree_code)"
260
- )
261
- cursor.execute(
262
- "CREATE INDEX IF NOT EXISTS idx_trees_timestamp ON trees(timestamp)"
263
- )
264
-
265
- # Create trigger to update updated_at timestamp
266
- cursor.execute("""
267
- CREATE TRIGGER IF NOT EXISTS update_trees_timestamp
268
- AFTER UPDATE ON trees
269
- BEGIN
270
- UPDATE trees SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
271
- END
272
- """)
273
-
274
- conn.commit()
275
- logger.info("Database initialized successfully")
276
-
277
- except Exception as e:
278
- logger.error(f"Failed to initialize database: {e}")
279
- raise
280
-
281
-
282
- import json
283
- from typing import List, Dict
284
-
285
- # Enhanced Pydantic models for comprehensive tree tracking
286
- class Tree(BaseModel):
287
- """Complete tree model with all 12 fields"""
288
- id: int
289
- # 1. Geolocation
290
- latitude: float
291
- longitude: float
292
- # 2. Local Name (Assamese)
293
- local_name: Optional[str] = None
294
- # 3. Scientific Name
295
- scientific_name: Optional[str] = None
296
- # 4. Common Name
297
- common_name: Optional[str] = None
298
- # 5. Tree Code
299
- tree_code: Optional[str] = None
300
- # 6. Height (meters)
301
- height: Optional[float] = None
302
- # 7. Width/Girth (cm)
303
- width: Optional[float] = None
304
- # 8. Utility (JSON array)
305
- utility: Optional[List[str]] = None
306
- # 9. Storytelling
307
- storytelling_text: Optional[str] = None
308
- storytelling_audio: Optional[str] = None
309
- # 10. Phenology Tracker (JSON array)
310
- phenology_stages: Optional[List[str]] = None
311
- # 11. Photographs (JSON object)
312
- photographs: Optional[Dict[str, str]] = None
313
- # 12. Notes
314
- notes: Optional[str] = None
315
- # System fields
316
- timestamp: str
317
- created_by: str = "system"
318
- updated_at: Optional[str] = None
319
-
320
-
321
- class TreeCreate(BaseModel):
322
- """Model for creating new tree records"""
323
- # 1. Geolocation (Required)
324
- latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
325
- longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
326
- # 2. Local Name (Assamese)
327
- local_name: Optional[str] = Field(None, max_length=200, description="Local Assamese name")
328
- # 3. Scientific Name
329
- scientific_name: Optional[str] = Field(None, max_length=200, description="Scientific name (genus species)")
330
- # 4. Common Name
331
- common_name: Optional[str] = Field(None, max_length=200, description="Common or colloquial name")
332
- # 5. Tree Code
333
- tree_code: Optional[str] = Field(None, max_length=20, description="Tree reference code (e.g. C.A, A-G1)")
334
- # 6. Height
335
- height: Optional[float] = Field(None, gt=0, le=200, description="Height in meters")
336
- # 7. Width/Girth
337
- width: Optional[float] = Field(None, gt=0, le=2000, description="Width/girth in centimeters")
338
- # 8. Utility
339
- utility: Optional[List[str]] = Field(None, description="Ecological/cultural utilities")
340
- # 9. Storytelling
341
- storytelling_text: Optional[str] = Field(None, max_length=5000, description="Stories, histories, narratives")
342
- storytelling_audio: Optional[str] = Field(None, description="Audio recording file path")
343
- # 10. Phenology Tracker
344
- phenology_stages: Optional[List[str]] = Field(None, description="Current development stages")
345
- # 11. Photographs
346
- photographs: Optional[Dict[str, str]] = Field(None, description="Photo categories and file paths")
347
- # 12. Notes
348
- notes: Optional[str] = Field(None, max_length=2000, description="Additional observations")
349
-
350
- @field_validator("utility", mode='before')
351
- @classmethod
352
- def validate_utility(cls, v):
353
- # Handle JSON string input
354
- if isinstance(v, str):
355
- try:
356
- v = json.loads(v)
357
- except json.JSONDecodeError:
358
- raise ValueError(f"Invalid JSON string for utility: {v}")
359
-
360
- if v is not None:
361
- valid_utilities = [
362
- "Religious", "Timber", "Biodiversity", "Hydrological benefit",
363
- "Faunal interaction", "Food", "Medicine", "Shelter", "Cultural"
364
- ]
365
- for item in v:
366
- if item not in valid_utilities:
367
- raise ValueError(f"Invalid utility: {item}. Must be one of: {valid_utilities}")
368
- return v
369
-
370
- @field_validator("phenology_stages", mode='before')
371
- @classmethod
372
- def validate_phenology(cls, v):
373
- # Handle JSON string input
374
- if isinstance(v, str):
375
- try:
376
- v = json.loads(v)
377
- except json.JSONDecodeError:
378
- raise ValueError(f"Invalid JSON string for phenology_stages: {v}")
379
-
380
- if v is not None:
381
- valid_stages = [
382
- "New leaves", "Old leaves", "Open flowers", "Fruiting",
383
- "Ripe fruit", "Recent fruit drop", "Other"
384
- ]
385
- for stage in v:
386
- if stage not in valid_stages:
387
- raise ValueError(f"Invalid phenology stage: {stage}. Must be one of: {valid_stages}")
388
- return v
389
-
390
- @field_validator("photographs", mode='before')
391
- @classmethod
392
- def validate_photographs(cls, v):
393
- # Handle JSON string input
394
- if isinstance(v, str):
395
- try:
396
- v = json.loads(v)
397
- except json.JSONDecodeError:
398
- raise ValueError(f"Invalid JSON string for photographs: {v}")
399
-
400
- if v is not None:
401
- valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
402
- for category in v.keys():
403
- if category not in valid_categories:
404
- raise ValueError(f"Invalid photo category: {category}. Must be one of: {valid_categories}")
405
- return v
406
-
407
-
408
- class TreeUpdate(BaseModel):
409
- """Model for updating tree records"""
410
- latitude: Optional[float] = Field(None, ge=-90, le=90)
411
- longitude: Optional[float] = Field(None, ge=-180, le=180)
412
- local_name: Optional[str] = Field(None, max_length=200)
413
- scientific_name: Optional[str] = Field(None, max_length=200)
414
- common_name: Optional[str] = Field(None, max_length=200)
415
- tree_code: Optional[str] = Field(None, max_length=20)
416
- height: Optional[float] = Field(None, gt=0, le=200)
417
- width: Optional[float] = Field(None, gt=0, le=2000)
418
- utility: Optional[List[str]] = None
419
- storytelling_text: Optional[str] = Field(None, max_length=5000)
420
- storytelling_audio: Optional[str] = None
421
- phenology_stages: Optional[List[str]] = None
422
- photographs: Optional[Dict[str, str]] = None
423
- notes: Optional[str] = Field(None, max_length=2000)
424
-
425
-
426
- class StatsResponse(BaseModel):
427
- total_trees: int
428
- species_distribution: list[dict[str, Any]]
429
- health_distribution: list[dict[str, Any]]
430
- average_height: float
431
- average_diameter: float
432
- last_updated: str
433
-
434
-
435
- # Initialize database on startup and restore if needed
436
- def initialize_app():
437
- """Initialize application with database restoration for persistent storage"""
438
- try:
439
- # HF Spaces provides /data as a persistent volume that survives container restarts
440
- persistent_db_path = Path("/data/trees.db")
441
- local_db_path = Path("data/trees.db")
442
-
443
- # Ensure local directory exists
444
- local_db_path.parent.mkdir(parents=True, exist_ok=True)
445
-
446
- # Try to ensure persistent directory exists (may fail on some systems)
447
- try:
448
- persistent_db_path.parent.mkdir(parents=True, exist_ok=True)
449
- logger.info("Persistent storage directory available at /data")
450
- except (PermissionError, OSError) as e:
451
- logger.warning(f"Cannot create /data directory: {e}. Using local backup strategy.")
452
- # Fallback to local backup strategy only
453
- persistent_db_path = None
454
-
455
- # Check if we have a persistent database in /data (if available)
456
- if persistent_db_path and persistent_db_path.exists():
457
- logger.info("Found persistent database, copying to local directory...")
458
- shutil.copy2(persistent_db_path, local_db_path)
459
- with sqlite3.connect(local_db_path) as conn:
460
- cursor = conn.cursor()
461
- cursor.execute("SELECT COUNT(*) FROM trees")
462
- tree_count = cursor.fetchone()[0]
463
- logger.info(f"Database restored from persistent storage: {tree_count} trees")
464
- else:
465
- # Check for backup files in multiple locations
466
- backup_locations = [
467
- Path("trees_database.db"), # Root level backup
468
- Path("static/trees_database.db"), # Static backup
469
- ]
470
-
471
- backup_restored = False
472
- for backup_path in backup_locations:
473
- if backup_path.exists():
474
- logger.info(f"Found backup at {backup_path}, restoring...")
475
- shutil.copy2(backup_path, local_db_path)
476
- # Try to save to persistent storage if available
477
- if persistent_db_path:
478
- try:
479
- shutil.copy2(backup_path, persistent_db_path)
480
- except (PermissionError, OSError):
481
- logger.warning("Cannot write to persistent storage")
482
- backup_restored = True
483
- break
484
-
485
- if backup_restored:
486
- with sqlite3.connect(local_db_path) as conn:
487
- cursor = conn.cursor()
488
- cursor.execute("SELECT COUNT(*) FROM trees")
489
- tree_count = cursor.fetchone()[0]
490
- logger.info(f"Database restored from backup: {tree_count} trees")
491
-
492
- # Initialize database (creates tables if they don't exist)
493
- init_db()
494
-
495
- # Initial backup to persistent storage (if available)
496
- if persistent_db_path:
497
- _backup_to_persistent_storage()
498
- else:
499
- logger.info("Using local backup strategy only (no persistent storage)")
500
-
501
- # Log current status
502
- if local_db_path.exists():
503
- with get_db_connection() as conn:
504
- cursor = conn.cursor()
505
- cursor.execute("SELECT COUNT(*) FROM trees")
506
- tree_count = cursor.fetchone()[0]
507
- logger.info(f"TreeTrack initialized with {tree_count} trees")
508
-
509
- except Exception as e:
510
- logger.error(f"Application initialization failed: {e}")
511
- # Still try to initialize database with empty state
512
- init_db()
513
-
514
- # Initialize app with restoration capabilities
515
- initialize_app()
516
-
517
- def _backup_to_persistent_storage():
518
- """Backup database to HF Spaces persistent /data volume"""
519
- try:
520
- source_db = Path("data/trees.db")
521
- persistent_db = Path("/data/trees.db")
522
-
523
- if source_db.exists():
524
- # Try to ensure /data directory exists
525
- try:
526
- persistent_db.parent.mkdir(parents=True, exist_ok=True)
527
- shutil.copy2(source_db, persistent_db)
528
- logger.info(f"Database backed up to persistent storage: {persistent_db}")
529
- return True
530
- except (PermissionError, OSError) as e:
531
- logger.warning(f"Cannot backup to persistent storage: {e}")
532
- return False
533
- except Exception as e:
534
- logger.error(f"Persistent storage backup failed: {e}")
535
- return False
536
-
537
- def backup_database():
538
- """Backup database to accessible locations (HF Spaces compatible)"""
539
- try:
540
- source_db = Path("data/trees.db")
541
- if not source_db.exists():
542
- logger.warning("Source database does not exist")
543
- return False
544
-
545
- # 1. MOST IMPORTANT: Backup to persistent storage (/data)
546
- _backup_to_persistent_storage()
547
-
548
- # 2. Copy database to static directory for direct HTTP access
549
- static_db = Path("static/trees_database.db")
550
- shutil.copy2(source_db, static_db)
551
-
552
- # 3. Also copy to root level (in case git access works)
553
- root_db = Path("trees_database.db")
554
- shutil.copy2(source_db, root_db)
555
-
556
- # 4. Export to CSV in multiple locations
557
- static_csv = Path("static/trees_backup.csv")
558
- root_csv = Path("trees_backup.csv")
559
- _export_trees_to_csv(static_csv)
560
- _export_trees_to_csv(root_csv)
561
-
562
- # 5. Create comprehensive status files
563
- static_status = Path("static/database_status.txt")
564
- root_status = Path("database_status.txt")
565
- tree_count = _create_status_file(static_status, source_db)
566
- _create_status_file(root_status, source_db)
567
-
568
- # 6. Try git commit (may fail in HF Spaces but that's okay)
569
- if _is_docker_environment():
570
- git_success = _git_commit_backup([root_db, root_csv, root_status], tree_count)
571
- if git_success:
572
- logger.info(f"Database backed up to all locations including persistent storage: {tree_count} trees")
573
- else:
574
- logger.info(f"Database backed up to static files and persistent storage: {tree_count} trees")
575
- else:
576
- logger.info(f"Database backed up locally: {tree_count} trees")
577
-
578
- return True
579
-
580
- except Exception as e:
581
- logger.error(f"Database backup failed: {e}")
582
- return False
583
-
584
-
585
- def _export_trees_to_csv(csv_path: Path):
586
- """Export all trees to CSV format"""
587
- try:
588
- with get_db_connection() as conn:
589
- cursor = conn.cursor()
590
- cursor.execute("""
591
- SELECT id, latitude, longitude, local_name, scientific_name,
592
- common_name, tree_code, height, width, utility,
593
- storytelling_text, storytelling_audio, phenology_stages,
594
- photographs, notes, timestamp, created_by, updated_at
595
- FROM trees ORDER BY id
596
- """)
597
- trees = cursor.fetchall()
598
-
599
- import csv
600
- with open(csv_path, 'w', newline='', encoding='utf-8') as csvfile:
601
- writer = csv.writer(csvfile)
602
- # Header
603
- writer.writerow([
604
- 'id', 'latitude', 'longitude', 'local_name', 'scientific_name',
605
- 'common_name', 'tree_code', 'height', 'width', 'utility',
606
- 'storytelling_text', 'storytelling_audio', 'phenology_stages',
607
- 'photographs', 'notes', 'timestamp', 'created_by', 'updated_at'
608
- ])
609
- # Data
610
- writer.writerows(trees)
611
-
612
- logger.info(f"CSV backup created: {csv_path}")
613
- except Exception as e:
614
- logger.error(f"CSV export failed: {e}")
615
-
616
-
617
- def _create_status_file(status_file: Path, source_db: Path) -> int:
618
- """Create database status file and return tree count"""
619
- try:
620
- with get_db_connection() as conn:
621
- cursor = conn.cursor()
622
- cursor.execute("SELECT COUNT(*) FROM trees")
623
- tree_count = cursor.fetchone()[0]
624
-
625
- cursor.execute("SELECT COUNT(DISTINCT scientific_name) FROM trees WHERE scientific_name IS NOT NULL")
626
- unique_species = cursor.fetchone()[0]
627
-
628
- cursor.execute("SELECT MAX(timestamp) FROM trees")
629
- last_update = cursor.fetchone()[0] or "Never"
630
-
631
- with open(status_file, 'w', encoding='utf-8') as f:
632
- f.write("=== TreeTrack Database Status ===\n")
633
- f.write(f"Last Backup: {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
634
- f.write(f"Environment: {'Docker/HF Spaces' if _is_docker_environment() else 'Development'}\n")
635
- f.write(f"Database File: trees_database.db\n")
636
- f.write(f"CSV Export: trees_backup.csv\n")
637
- f.write(f"Database Size: {source_db.stat().st_size:,} bytes\n")
638
- f.write(f"Total Trees: {tree_count:,}\n")
639
- f.write(f"Unique Species: {unique_species}\n")
640
- f.write(f"Last Tree Added: {last_update}\n")
641
- f.write(f"\n=== Usage ===\n")
642
- f.write(f"β€’ Download 'trees_database.db' for SQLite access\n")
643
- f.write(f"β€’ View 'trees_backup.csv' for spreadsheet format\n")
644
- f.write(f"β€’ Auto-backup occurs after each tree operation\n")
645
- f.write(f"β€’ Data persists across Docker container restarts\n")
646
-
647
- return tree_count
648
- except Exception as e:
649
- logger.error(f"Status file creation failed: {e}")
650
- return 0
651
-
652
-
653
- def _is_docker_environment() -> bool:
654
- """Check if running in Docker environment (HF Spaces)"""
655
- return (
656
- os.path.exists('/.dockerenv') or
657
- os.getenv('SPACE_ID') is not None or
658
- '/app' in os.getcwd()
659
- )
660
-
661
-
662
- def _git_commit_file(file_path: Path, message: str) -> bool:
663
- """Commit a single file to git repository"""
664
- try:
665
- import subprocess
666
-
667
- # Setup git config if needed
668
- try:
669
- subprocess.run(['git', 'config', 'user.name', 'TreeTrack Bot'],
670
- check=True, capture_output=True, text=True)
671
- subprocess.run(['git', 'config', 'user.email', '[email protected]'],
672
- check=True, capture_output=True, text=True)
673
- except:
674
- pass # Git config might already be set
675
-
676
- # Add file to git
677
- if file_path.exists():
678
- subprocess.run(['git', 'add', str(file_path)], check=True)
679
-
680
- # Check if there are changes to commit
681
- result = subprocess.run(['git', 'diff', '--staged', '--quiet'],
682
- capture_output=True)
683
-
684
- if result.returncode == 0: # No changes
685
- return True
686
-
687
- # Commit changes
688
- subprocess.run(['git', 'commit', '-m', message],
689
- check=True, capture_output=True, text=True)
690
-
691
- logger.info(f"File committed to git: {file_path}")
692
- return True
693
-
694
- except subprocess.CalledProcessError as e:
695
- logger.error(f"Git commit failed: {e.stderr if hasattr(e, 'stderr') else str(e)}")
696
- return False
697
- except Exception as e:
698
- logger.error(f"Git backup failed: {e}")
699
- return False
700
-
701
-
702
- def _git_commit_backup(files: list, tree_count: int) -> bool:
703
- """Commit backup files to git repository using Docker-native approach"""
704
- try:
705
- import subprocess
706
-
707
- # Setup git config if needed
708
- try:
709
- subprocess.run(['git', 'config', 'user.name', 'TreeTrack Bot'],
710
- check=True, capture_output=True, text=True)
711
- subprocess.run(['git', 'config', 'user.email', '[email protected]'],
712
- check=True, capture_output=True, text=True)
713
- except:
714
- pass # Git config might already be set
715
-
716
- # Add backup files to git
717
- for file_path in files:
718
- if file_path.exists():
719
- subprocess.run(['git', 'add', str(file_path)], check=True)
720
-
721
- # Check if there are changes to commit
722
- result = subprocess.run(['git', 'diff', '--staged', '--quiet'],
723
- capture_output=True)
724
-
725
- if result.returncode == 0: # No changes
726
- logger.info("No database changes to commit")
727
- return True
728
-
729
- # Create commit message with tree count and timestamp
730
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
731
- commit_message = f"TreeTrack Auto-backup: {tree_count:,} trees - {timestamp}"
732
-
733
- # Commit changes
734
- subprocess.run(['git', 'commit', '-m', commit_message],
735
- check=True, capture_output=True, text=True)
736
-
737
- logger.info(f"Database backup committed to git: {tree_count} trees")
738
-
739
- # Note: HF Spaces automatically syncs commits to the repository
740
- # No need to explicitly push
741
-
742
- return True
743
-
744
- except subprocess.CalledProcessError as e:
745
- logger.error(f"Git commit failed: {e.stderr if hasattr(e, 'stderr') else str(e)}")
746
- return False
747
- except Exception as e:
748
- logger.error(f"Git backup failed: {e}")
749
- return False
750
-
751
-
752
- # Health check endpoint
753
- @app.get("/health", tags=["Health"])
754
- async def health_check():
755
- """Health check endpoint"""
756
- try:
757
- with get_db_connection() as conn:
758
- cursor = conn.cursor()
759
- cursor.execute("SELECT 1")
760
- cursor.fetchone()
761
-
762
- return {
763
- "status": "healthy",
764
- "timestamp": datetime.now().isoformat(),
765
- "version": "2.0.0",
766
- }
767
- except Exception as e:
768
- logger.error(f"Health check failed: {e}")
769
- raise
770
-
771
-
772
- # API Routes with enhanced error handling
773
- @app.get("/", response_class=HTMLResponse, tags=["Frontend"])
774
- async def read_root():
775
- """Serve the main application page"""
776
- try:
777
- with open("static/index.html", encoding="utf-8") as f:
778
- content = f.read()
779
- return HTMLResponse(content=content)
780
- except FileNotFoundError:
781
- logger.error("index.html not found")
782
- raise
783
- except Exception as e:
784
- logger.error(f"Error serving frontend: {e}")
785
- raise
786
-
787
-
788
- @app.get("/map", response_class=HTMLResponse, tags=["Frontend"])
789
- @app.get("/static/map.html", response_class=HTMLResponse, tags=["Frontend"])
790
- async def serve_map():
791
- """Serve the map page directly through FastAPI to bypass static caching"""
792
- try:
793
- with open("static/map.html", encoding="utf-8") as f:
794
- content = f.read()
795
- # Force no-cache headers
796
- response = HTMLResponse(content=content)
797
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
798
- response.headers["Pragma"] = "no-cache"
799
- response.headers["Expires"] = "0"
800
- return response
801
- except FileNotFoundError:
802
- logger.error("map.html not found")
803
- raise HTTPException(status_code=404, detail="Map page not found")
804
- except Exception as e:
805
- logger.error(f"Error serving map page: {e}")
806
- raise
807
-
808
-
809
- @app.get("/debug/file-content", tags=["Debug"])
810
- async def debug_file_content():
811
- """Debug endpoint to show actual file contents"""
812
- try:
813
- with open("static/map.html", encoding="utf-8") as f:
814
- content = f.read()
815
-
816
- # Extract key indicators
817
- has_fire_emoji = "" in content
818
- has_v4 = "V4.0" in content
819
- has_blue_theme = "#3b82f6" in content
820
- has_red_border = "#ff0000" in content
821
-
822
- return {
823
- "file_exists": True,
824
- "content_length": len(content),
825
- "indicators": {
826
- "has_fire_emoji": has_fire_emoji,
827
- "has_v4_version": has_v4,
828
- "has_blue_theme": has_blue_theme,
829
- "has_red_debug_border": has_red_border
830
- },
831
- "first_200_chars": content[:200],
832
- "title_line": next((line.strip() for line in content.split('\n') if '<title>' in line), "not found")
833
- }
834
- except Exception as e:
835
- return {
836
- "file_exists": False,
837
- "error": str(e)
838
- }
839
-
840
-
841
- @app.get("/api/trees", response_model=list[Tree], tags=["Trees"])
842
- async def get_trees(
843
- limit: int = 100,
844
- offset: int = 0,
845
- species: str = None,
846
- health_status: str = None,
847
- ):
848
- # Add validation inside the function
849
- if limit < 1 or limit > settings.server.max_trees_per_request:
850
- raise HTTPException(
851
- status_code=status.HTTP_400_BAD_REQUEST,
852
- detail=f"Limit must be between 1 and {settings.server.max_trees_per_request}",
853
- )
854
- if offset < 0:
855
- raise HTTPException(
856
- status_code=status.HTTP_400_BAD_REQUEST,
857
- detail="Offset must be non-negative",
858
- )
859
- try:
860
- with get_db_connection() as conn:
861
- cursor = conn.cursor()
862
-
863
- # Build query with optional filters
864
- query = "SELECT * FROM trees WHERE 1=1"
865
- params = []
866
-
867
- if species:
868
- query += " AND species LIKE ?"
869
- params.append(f"%{species}%")
870
-
871
- if health_status:
872
- query += " AND health_status = ?"
873
- params.append(health_status)
874
-
875
- query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
876
- params.extend([limit, offset])
877
-
878
- cursor.execute(query, params)
879
- rows = cursor.fetchall()
880
-
881
- # Parse JSON fields for each tree
882
- trees = []
883
- for row in rows:
884
- tree_data = dict(row)
885
-
886
- # Parse JSON fields back to Python objects
887
- if tree_data.get('utility'):
888
- try:
889
- tree_data['utility'] = json.loads(tree_data['utility'])
890
- except (json.JSONDecodeError, TypeError):
891
- tree_data['utility'] = None
892
-
893
- if tree_data.get('phenology_stages'):
894
- try:
895
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
896
- except (json.JSONDecodeError, TypeError):
897
- tree_data['phenology_stages'] = None
898
-
899
- if tree_data.get('photographs'):
900
- try:
901
- tree_data['photographs'] = json.loads(tree_data['photographs'])
902
- except (json.JSONDecodeError, TypeError):
903
- tree_data['photographs'] = None
904
-
905
- trees.append(tree_data)
906
-
907
- logger.info(f"Retrieved {len(trees)} trees")
908
- return trees
909
-
910
- except Exception as e:
911
- logger.error(f"Error retrieving trees: {e}")
912
- raise
913
-
914
-
915
- @app.post(
916
- "/api/trees",
917
- response_model=Tree,
918
- status_code=status.HTTP_201_CREATED,
919
- tags=["Trees"],
920
- )
921
- async def create_tree(tree: TreeCreate):
922
- """Create a new tree record with all 12 fields"""
923
- try:
924
- with get_db_connection() as conn:
925
- cursor = conn.cursor()
926
-
927
- # Convert lists and dicts to JSON strings for database storage
928
- utility_json = json.dumps(tree.utility) if tree.utility else None
929
- phenology_json = json.dumps(tree.phenology_stages) if tree.phenology_stages else None
930
- photographs_json = json.dumps(tree.photographs) if tree.photographs else None
931
-
932
- cursor.execute(
933
- """
934
- INSERT INTO trees (
935
- latitude, longitude, local_name, scientific_name, common_name,
936
- tree_code, height, width, utility, storytelling_text,
937
- storytelling_audio, phenology_stages, photographs, notes
938
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
939
- """,
940
- (
941
- tree.latitude,
942
- tree.longitude,
943
- tree.local_name,
944
- tree.scientific_name,
945
- tree.common_name,
946
- tree.tree_code,
947
- tree.height,
948
- tree.width,
949
- utility_json,
950
- tree.storytelling_text,
951
- tree.storytelling_audio,
952
- phenology_json,
953
- photographs_json,
954
- tree.notes,
955
- ),
956
- )
957
-
958
- tree_id = cursor.lastrowid
959
- conn.commit()
960
-
961
- # Return the created tree with parsed JSON fields
962
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
963
- row = cursor.fetchone()
964
- if not row:
965
- raise HTTPException(
966
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
967
- detail="Failed to retrieve created tree",
968
- )
969
-
970
- # Parse JSON fields back to Python objects
971
- tree_data = dict(row)
972
- if tree_data.get('utility'):
973
- tree_data['utility'] = json.loads(tree_data['utility'])
974
- if tree_data.get('phenology_stages'):
975
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
976
- if tree_data.get('photographs'):
977
- tree_data['photographs'] = json.loads(tree_data['photographs'])
978
-
979
- logger.info(f"Created tree with ID: {tree_id}")
980
-
981
- # Backup database to visible location
982
- backup_database()
983
-
984
- return tree_data
985
-
986
- except sqlite3.IntegrityError as e:
987
- logger.error(f"Database integrity error: {e}")
988
- raise
989
- except Exception as e:
990
- logger.error(f"Error creating tree: {e}")
991
- raise
992
-
993
-
994
- @app.get("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
995
- async def get_tree(tree_id: int):
996
- """Get a specific tree by ID"""
997
- try:
998
- with get_db_connection() as conn:
999
- cursor = conn.cursor()
1000
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
1001
- tree = cursor.fetchone()
1002
-
1003
- if tree is None:
1004
- raise HTTPException(
1005
- status_code=status.HTTP_404_NOT_FOUND,
1006
- detail=f"Tree with ID {tree_id} not found",
1007
- )
1008
-
1009
- # Parse JSON fields back to Python objects
1010
- tree_data = dict(tree)
1011
- if tree_data.get('utility'):
1012
- try:
1013
- tree_data['utility'] = json.loads(tree_data['utility'])
1014
- except (json.JSONDecodeError, TypeError):
1015
- tree_data['utility'] = None
1016
-
1017
- if tree_data.get('phenology_stages'):
1018
- try:
1019
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
1020
- except (json.JSONDecodeError, TypeError):
1021
- tree_data['phenology_stages'] = None
1022
-
1023
- if tree_data.get('photographs'):
1024
- try:
1025
- tree_data['photographs'] = json.loads(tree_data['photographs'])
1026
- except (json.JSONDecodeError, TypeError):
1027
- tree_data['photographs'] = None
1028
-
1029
- return tree_data
1030
-
1031
- except HTTPException:
1032
- raise
1033
- except Exception as e:
1034
- logger.error(f"Error retrieving tree {tree_id}: {e}")
1035
- raise
1036
-
1037
-
1038
- @app.put("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
1039
- async def update_tree(tree_id: int, tree_update: TreeUpdate = None):
1040
- """Update a tree record"""
1041
- if not tree_update:
1042
- raise HTTPException(
1043
- status_code=status.HTTP_400_BAD_REQUEST, detail="No update data provided"
1044
- )
1045
-
1046
- try:
1047
- with get_db_connection() as conn:
1048
- cursor = conn.cursor()
1049
-
1050
- # Check if tree exists
1051
- cursor.execute("SELECT id FROM trees WHERE id = ?", (tree_id,))
1052
- if not cursor.fetchone():
1053
- raise HTTPException(
1054
- status_code=status.HTTP_404_NOT_FOUND,
1055
- detail=f"Tree with ID {tree_id} not found",
1056
- )
1057
-
1058
- # Build update query dynamically
1059
- update_fields = []
1060
- params = []
1061
-
1062
- for field, value in tree_update.model_dump(exclude_unset=True).items():
1063
- if value is not None:
1064
- update_fields.append(f"{field} = ?")
1065
- params.append(value)
1066
-
1067
- if not update_fields:
1068
- raise HTTPException(
1069
- status_code=status.HTTP_400_BAD_REQUEST,
1070
- detail="No valid fields to update",
1071
- )
1072
-
1073
- params.append(tree_id)
1074
- query = f"UPDATE trees SET {', '.join(update_fields)} WHERE id = ?"
1075
-
1076
- cursor.execute(query, params)
1077
- conn.commit()
1078
-
1079
- # Return updated tree
1080
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
1081
- updated_tree = cursor.fetchone()
1082
-
1083
- logger.info(f"Updated tree with ID: {tree_id}")
1084
-
1085
- # Backup database to visible location
1086
- backup_database()
1087
-
1088
- return dict(updated_tree)
1089
-
1090
- except HTTPException:
1091
- raise
1092
- except sqlite3.IntegrityError as e:
1093
- logger.error(f"Database integrity error updating tree {tree_id}: {e}")
1094
- raise
1095
- except Exception as e:
1096
- logger.error(f"Error updating tree {tree_id}: {e}")
1097
- raise
1098
-
1099
-
1100
- @app.delete("/api/trees/{tree_id}", tags=["Trees"])
1101
- async def delete_tree(tree_id: int):
1102
- """Delete a tree record"""
1103
- try:
1104
- with get_db_connection() as conn:
1105
- cursor = conn.cursor()
1106
- cursor.execute("DELETE FROM trees WHERE id = ?", (tree_id,))
1107
-
1108
- if cursor.rowcount == 0:
1109
- raise HTTPException(
1110
- status_code=status.HTTP_404_NOT_FOUND,
1111
- detail=f"Tree with ID {tree_id} not found",
1112
- )
1113
-
1114
- conn.commit()
1115
- logger.info(f"Deleted tree with ID: {tree_id}")
1116
-
1117
- # Backup database to visible location
1118
- backup_database()
1119
-
1120
- return {"message": f"Tree {tree_id} deleted successfully"}
1121
-
1122
- except HTTPException:
1123
- raise
1124
- except Exception as e:
1125
- logger.error(f"Error deleting tree {tree_id}: {e}")
1126
- raise
1127
-
1128
-
1129
- # File Upload Endpoints with persistent storage
1130
- @app.post("/api/upload/image", tags=["Files"])
1131
- async def upload_image(
1132
- file: UploadFile = File(...),
1133
- category: str = Form(...)
1134
- ):
1135
- """Upload an image file for tree documentation with git-based persistence"""
1136
- # Validate file type
1137
- if not file.content_type or not file.content_type.startswith('image/'):
1138
- raise HTTPException(
1139
- status_code=400,
1140
- detail="File must be an image"
1141
- )
1142
-
1143
- # Validate category
1144
- valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
1145
- if category not in valid_categories:
1146
- raise HTTPException(
1147
- status_code=400,
1148
- detail=f"Category must be one of: {valid_categories}"
1149
- )
1150
-
1151
- try:
1152
- # Generate unique filename
1153
- file_id = str(uuid.uuid4())
1154
- file_extension = Path(file.filename).suffix.lower()
1155
- filename = f"{file_id}{file_extension}"
1156
-
1157
- # Save to local uploads directory
1158
- local_file_path = Path("uploads/images") / filename
1159
-
1160
- # Also save to static directory for persistence via git
1161
- static_file_path = Path("static/uploads/images")
1162
- static_file_path.mkdir(parents=True, exist_ok=True)
1163
- persistent_file_path = static_file_path / filename
1164
-
1165
- # Read file content once
1166
- content = await file.read()
1167
-
1168
- # Save to both locations
1169
- async with aiofiles.open(local_file_path, 'wb') as f:
1170
- await f.write(content)
1171
- async with aiofiles.open(persistent_file_path, 'wb') as f:
1172
- await f.write(content)
1173
-
1174
- # Commit to git for persistence across container restarts
1175
- if _is_docker_environment():
1176
- _git_commit_file(persistent_file_path, f"Add image: {filename} ({category})")
1177
-
1178
- logger.info(f"Image uploaded and persisted: {filename}, category: {category}")
1179
-
1180
- return {
1181
- "filename": filename,
1182
- "file_path": str(local_file_path),
1183
- "persistent_path": str(persistent_file_path),
1184
- "category": category,
1185
- "size": len(content),
1186
- "content_type": file.content_type
1187
- }
1188
-
1189
- except Exception as e:
1190
- logger.error(f"Error uploading image: {e}")
1191
- raise HTTPException(status_code=500, detail="Failed to upload image")
1192
-
1193
-
1194
- @app.post("/api/upload/audio", tags=["Files"])
1195
- async def upload_audio(file: UploadFile = File(...)):
1196
- """Upload an audio file for storytelling"""
1197
- # Validate file type
1198
- if not file.content_type or not file.content_type.startswith('audio/'):
1199
- raise HTTPException(
1200
- status_code=400,
1201
- detail="File must be an audio file"
1202
- )
1203
-
1204
- try:
1205
- # Generate unique filename
1206
- file_id = str(uuid.uuid4())
1207
- file_extension = Path(file.filename).suffix.lower()
1208
- filename = f"{file_id}{file_extension}"
1209
- file_path = Path("uploads/audio") / filename
1210
-
1211
- # Save file
1212
- async with aiofiles.open(file_path, 'wb') as f:
1213
- content = await file.read()
1214
- await f.write(content)
1215
-
1216
- logger.info(f"Audio uploaded: {filename}")
1217
-
1218
- return {
1219
- "filename": filename,
1220
- "file_path": str(file_path),
1221
- "size": len(content),
1222
- "content_type": file.content_type
1223
- }
1224
-
1225
- except Exception as e:
1226
- logger.error(f"Error uploading audio: {e}")
1227
- raise HTTPException(status_code=500, detail="Failed to upload audio")
1228
-
1229
-
1230
- @app.get("/api/files/{file_type}/{filename}", tags=["Files"])
1231
- async def get_file(file_type: str, filename: str):
1232
- """Serve uploaded files"""
1233
- if file_type not in ["images", "audio"]:
1234
- raise HTTPException(status_code=400, detail="Invalid file type")
1235
-
1236
- file_path = Path(f"uploads/{file_type}/{filename}")
1237
-
1238
- if not file_path.exists():
1239
- raise HTTPException(status_code=404, detail="File not found")
1240
-
1241
- return FileResponse(file_path)
1242
-
1243
-
1244
- # Utility endpoints for form data
1245
- @app.get("/api/utilities", tags=["Data"])
1246
- async def get_utilities():
1247
- """Get list of valid utility options"""
1248
- return {
1249
- "utilities": [
1250
- "Religious", "Timber", "Biodiversity", "Hydrological benefit",
1251
- "Faunal interaction", "Food", "Medicine", "Shelter", "Cultural"
1252
- ]
1253
- }
1254
-
1255
-
1256
- @app.get("/api/phenology-stages", tags=["Data"])
1257
- async def get_phenology_stages():
1258
- """Get list of valid phenology stages"""
1259
- return {
1260
- "stages": [
1261
- "New leaves", "Old leaves", "Open flowers", "Fruiting",
1262
- "Ripe fruit", "Recent fruit drop", "Other"
1263
- ]
1264
- }
1265
-
1266
-
1267
- @app.get("/api/photo-categories", tags=["Data"])
1268
- async def get_photo_categories():
1269
- """Get list of valid photo categories"""
1270
- return {
1271
- "categories": ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
1272
- }
1273
-
1274
-
1275
- # Auto-suggestion endpoints for master tree database
1276
- @app.get("/api/tree-suggestions", tags=["Data"])
1277
- async def get_tree_suggestions_api(query: str = "", limit: int = 10):
1278
- """Get auto-suggestions for tree names from master database"""
1279
- if not query or len(query.strip()) == 0:
1280
- return {"suggestions": []}
1281
-
1282
- try:
1283
- # Initialize master database if it doesn't exist
1284
- create_master_tree_database()
1285
-
1286
- suggestions = get_tree_suggestions(query.strip(), limit)
1287
-
1288
- return {
1289
- "query": query,
1290
- "suggestions": suggestions,
1291
- "count": len(suggestions)
1292
- }
1293
-
1294
- except Exception as e:
1295
- logger.error(f"Error getting tree suggestions: {e}")
1296
- return {"suggestions": [], "error": str(e)}
1297
-
1298
-
1299
- @app.get("/api/tree-codes", tags=["Data"])
1300
- async def get_tree_codes_api():
1301
- """Get all available tree codes from master database"""
1302
- try:
1303
- # Initialize master database if it doesn't exist
1304
- create_master_tree_database()
1305
-
1306
- tree_codes = get_all_tree_codes()
1307
-
1308
- return {
1309
- "tree_codes": tree_codes,
1310
- "count": len(tree_codes)
1311
- }
1312
-
1313
- except Exception as e:
1314
- logger.error(f"Error getting tree codes: {e}")
1315
- return {"tree_codes": [], "error": str(e)}
1316
-
1317
-
1318
- @app.get("/api/master-database/status", tags=["System"])
1319
- async def get_master_database_status():
1320
- """Get status of master tree species database"""
1321
- try:
1322
- master_db_path = Path("data/master_trees.db")
1323
-
1324
- if not master_db_path.exists():
1325
- return {
1326
- "exists": False,
1327
- "initialized": False,
1328
- "species_count": 0,
1329
- "message": "Master database not initialized"
1330
- }
1331
-
1332
- # Check database content
1333
- import sqlite3
1334
- conn = sqlite3.connect(master_db_path)
1335
- cursor = conn.cursor()
1336
-
1337
- cursor.execute("SELECT COUNT(*) FROM master_species")
1338
- species_count = cursor.fetchone()[0]
1339
-
1340
- cursor.execute("SELECT COUNT(DISTINCT tree_code) FROM master_species WHERE tree_code != ''")
1341
- unique_codes = cursor.fetchone()[0]
1342
-
1343
- conn.close()
1344
-
1345
- return {
1346
- "exists": True,
1347
- "initialized": True,
1348
- "species_count": species_count,
1349
- "unique_codes": unique_codes,
1350
- "database_size": master_db_path.stat().st_size,
1351
- "message": f"Master database contains {species_count} species with {unique_codes} unique codes"
1352
- }
1353
-
1354
- except Exception as e:
1355
- logger.error(f"Error checking master database status: {e}")
1356
- return {
1357
- "exists": False,
1358
- "initialized": False,
1359
- "error": str(e)
1360
- }
1361
-
1362
-
1363
- # Direct file download endpoints
1364
- @app.get("/download/database", tags=["Downloads"])
1365
- async def download_database():
1366
- """Download the SQLite database file"""
1367
- db_file = Path("static/trees_database.db")
1368
- if not db_file.exists():
1369
- # Try root location
1370
- db_file = Path("trees_database.db")
1371
- if not db_file.exists():
1372
- raise HTTPException(status_code=404, detail="Database file not found")
1373
-
1374
- return FileResponse(
1375
- path=str(db_file),
1376
- filename="trees_database.db",
1377
- media_type="application/x-sqlite3"
1378
- )
1379
-
1380
- @app.get("/download/csv", tags=["Downloads"])
1381
- async def download_csv():
1382
- """Download the CSV backup file"""
1383
- csv_file = Path("static/trees_backup.csv")
1384
- if not csv_file.exists():
1385
- # Try root location
1386
- csv_file = Path("trees_backup.csv")
1387
- if not csv_file.exists():
1388
- raise HTTPException(status_code=404, detail="CSV file not found")
1389
-
1390
- return FileResponse(
1391
- path=str(csv_file),
1392
- filename="trees_backup.csv",
1393
- media_type="text/csv"
1394
- )
1395
-
1396
- @app.get("/download/status", tags=["Downloads"])
1397
- async def download_status():
1398
- """Download the status report file"""
1399
- status_file = Path("static/database_status.txt")
1400
- if not status_file.exists():
1401
- # Try root location
1402
- status_file = Path("database_status.txt")
1403
- if not status_file.exists():
1404
- raise HTTPException(status_code=404, detail="Status file not found")
1405
-
1406
- return FileResponse(
1407
- path=str(status_file),
1408
- filename="database_status.txt",
1409
- media_type="text/plain"
1410
- )
1411
-
1412
- @app.get("/api/persistence/status", tags=["System"])
1413
- async def get_persistence_status():
1414
- """Get database backup system status"""
1415
- try:
1416
- # Check if database exists
1417
- db_path = Path("data/trees.db")
1418
- backup_path = Path("trees_database.db")
1419
-
1420
- if not db_path.exists():
1421
- return {
1422
- "enabled": False,
1423
- "status": "no_database",
1424
- "message": "No database found yet"
1425
- }
1426
-
1427
- # Get database info
1428
- with get_db_connection() as conn:
1429
- cursor = conn.cursor()
1430
- cursor.execute("SELECT COUNT(*) FROM trees")
1431
- tree_count = cursor.fetchone()[0]
1432
-
1433
- return {
1434
- "enabled": True,
1435
- "status": "healthy",
1436
- "stats": {
1437
- "database_size": db_path.stat().st_size,
1438
- "total_trees": tree_count,
1439
- "backup_exists": backup_path.exists(),
1440
- "last_backup": backup_path.stat().st_mtime if backup_path.exists() else None
1441
- },
1442
- "message": f"Database backup system active - {tree_count} trees stored"
1443
- }
1444
- except Exception as e:
1445
- return {
1446
- "enabled": False,
1447
- "status": "error",
1448
- "message": f"Backup system error: {str(e)}"
1449
- }
1450
-
1451
-
1452
- @app.post("/api/persistence/backup", tags=["System"])
1453
- async def force_backup():
1454
- """Force an immediate database backup"""
1455
- success = backup_database()
1456
-
1457
- return {
1458
- "success": success,
1459
- "message": "Database backed up successfully" if success else "Backup failed",
1460
- "timestamp": datetime.now().isoformat()
1461
- }
1462
-
1463
-
1464
- @app.get("/api/stats", response_model=StatsResponse, tags=["Statistics"])
1465
- async def get_stats():
1466
- """Get comprehensive tree statistics"""
1467
- try:
1468
- with get_db_connection() as conn:
1469
- cursor = conn.cursor()
1470
-
1471
- # Total trees
1472
- cursor.execute("SELECT COUNT(*) as total FROM trees")
1473
- total = cursor.fetchone()[0]
1474
-
1475
- # Trees by species (limit to top 20)
1476
- cursor.execute("""
1477
- SELECT species, COUNT(*) as count
1478
- FROM trees
1479
- GROUP BY species
1480
- ORDER BY count DESC
1481
- LIMIT 20
1482
- """)
1483
- species_stats = [
1484
- {"species": row[0], "count": row[1]} for row in cursor.fetchall()
1485
- ]
1486
-
1487
- # Trees by health status
1488
- cursor.execute("""
1489
- SELECT health_status, COUNT(*) as count
1490
- FROM trees
1491
- GROUP BY health_status
1492
- ORDER BY count DESC
1493
- """)
1494
- health_stats = [
1495
- {"status": row[0], "count": row[1]} for row in cursor.fetchall()
1496
- ]
1497
-
1498
- # Average measurements
1499
- cursor.execute("""
1500
- SELECT
1501
- COALESCE(AVG(height), 0) as avg_height,
1502
- COALESCE(AVG(diameter), 0) as avg_diameter
1503
- FROM trees
1504
- WHERE height IS NOT NULL AND diameter IS NOT NULL
1505
- """)
1506
- avg_stats = cursor.fetchone()
1507
-
1508
- return StatsResponse(
1509
- total_trees=total,
1510
- species_distribution=species_stats,
1511
- health_distribution=health_stats,
1512
- average_height=round(avg_stats[0], 2) if avg_stats[0] else 0,
1513
- average_diameter=round(avg_stats[1], 2) if avg_stats[1] else 0,
1514
- last_updated=datetime.now().isoformat(),
1515
- )
1516
-
1517
- except Exception as e:
1518
- logger.error(f"Error retrieving statistics: {e}")
1519
- raise
1520
-
1521
-
1522
- # Version management endpoints
1523
- @app.get("/api/version", tags=["System"])
1524
- async def get_version():
1525
- """Get current application version and asset versions"""
1526
- try:
1527
- version_file = Path("version.json")
1528
- if version_file.exists():
1529
- async with aiofiles.open(version_file, 'r') as f:
1530
- content = await f.read()
1531
- version_data = json.loads(content)
1532
- else:
1533
- # Fallback version data
1534
- version_data = {
1535
- "version": "3.0",
1536
- "timestamp": int(time.time()),
1537
- "build": "development",
1538
- "commit": "local"
1539
- }
1540
-
1541
- version_data["server_time"] = datetime.now().isoformat()
1542
- return version_data
1543
-
1544
- except Exception as e:
1545
- logger.error(f"Error reading version: {e}")
1546
- return {
1547
- "version": "unknown",
1548
- "timestamp": int(time.time()),
1549
- "build": "error",
1550
- "error": str(e)
1551
- }
1552
-
1553
-
1554
- @app.post("/api/version/update", tags=["System"])
1555
- async def update_version():
1556
- """Force update version and clear cache"""
1557
- try:
1558
- # Update version timestamp
1559
- timestamp = int(time.time())
1560
- version_number = f"3.{timestamp}"
1561
-
1562
- version_data = {
1563
- "version": version_number,
1564
- "timestamp": timestamp,
1565
- "build": "development",
1566
- "commit": "local",
1567
- "updated_by": "api"
1568
- }
1569
-
1570
- # Write version file
1571
- version_file = Path("version.json")
1572
- async with aiofiles.open(version_file, 'w') as f:
1573
- await f.write(json.dumps(version_data, indent=4))
1574
-
1575
- logger.info(f"Version updated to {version_number}")
1576
-
1577
- return {
1578
- "success": True,
1579
- "message": "Version updated successfully",
1580
- "new_version": version_number,
1581
- "timestamp": timestamp,
1582
- "instructions": [
1583
- "Clear browser cache: Ctrl+Shift+R (Windows) / Cmd+Shift+R (Mac)",
1584
- "Or open DevTools > Application > Service Workers > Unregister"
1585
- ]
1586
- }
1587
-
1588
- except Exception as e:
1589
- logger.error(f"Error updating version: {e}")
1590
- return {
1591
- "success": False,
1592
- "error": str(e)
1593
- }
1594
-
1595
-
1596
- # Error handlers for better error responses
1597
- @app.exception_handler(404)
1598
- async def not_found_handler(request: Request, exc: Exception):
1599
- return JSONResponse(
1600
- status_code=404,
1601
- content={"detail": "Resource not found", "path": str(request.url.path)},
1602
- )
1603
-
1604
-
1605
- @app.exception_handler(500)
1606
- async def internal_error_handler(request: Request, exc: Exception):
1607
- logger.error(f"Internal server error: {exc}")
1608
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
1609
-
1610
-
1611
- if __name__ == "__main__":
1612
- uvicorn.run(
1613
- "app:app", host="127.0.0.1", port=8000, reload=True, log_level="info"
1614
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_supabase.py DELETED
@@ -1,601 +0,0 @@
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
@@ -1,204 +1,108 @@
1
  """
2
- Configuration management for Tree Mapping Application
3
- Implements environment-based configuration with validation and security best practices
4
  """
5
 
6
-
7
  import os
8
  from functools import lru_cache
9
  from pathlib import Path
 
10
 
11
- from pydantic import Field, field_validator, model_validator
12
  from pydantic_settings import BaseSettings
13
 
14
 
15
- class DatabaseConfig(BaseSettings):
16
- """Database configuration settings"""
17
-
18
- # Database settings
19
- db_path: str = Field(default="data/trees.db", env="DB_PATH")
20
- db_timeout: int = Field(default=30, env="DB_TIMEOUT", ge=1, le=300)
21
- db_max_connections: int = Field(default=10, env="DB_MAX_CONNECTIONS", ge=1, le=100)
22
- db_backup_enabled: bool = Field(default=True, env="DB_BACKUP_ENABLED")
23
- db_backup_interval: int = Field(
24
- default=3600, env="DB_BACKUP_INTERVAL", ge=300
25
- ) # seconds
26
-
27
- @field_validator("db_path")
28
- def validate_db_path(cls, v):
29
- """Ensure database path is secure"""
30
- path = Path(v)
31
-
32
- # Prevent path traversal attacks
33
- if ".." in str(path) or str(path).startswith("/"):
34
- raise ValueError("Database path contains invalid characters")
35
-
36
- # Ensure parent directory exists
37
- path.parent.mkdir(parents=True, exist_ok=True)
38
-
39
- return str(path)
40
-
41
-
42
  class SecurityConfig(BaseSettings):
43
  """Security configuration settings"""
44
-
45
- # Security settings
46
- secret_key: str = Field(
47
- default="dev-secret-key-change-in-production", env="SECRET_KEY"
48
- )
49
- allowed_hosts: list[str] = Field(
50
- default=["localhost", "127.0.0.1"], env="ALLOWED_HOSTS"
51
- )
52
- cors_origins: list[str] = Field(
53
- default=["http://localhost:8000", "http://127.0.0.1:8000"], env="CORS_ORIGINS"
54
  )
 
 
55
  max_request_size: int = Field(
56
- default=1048576, env="MAX_REQUEST_SIZE", ge=1024
57
- ) # 1MB default
58
- rate_limit_requests: int = Field(default=100, env="RATE_LIMIT_REQUESTS", ge=1)
59
- rate_limit_window: int = Field(default=60, env="RATE_LIMIT_WINDOW", ge=1) # seconds
60
-
61
- # Content Security Policy
62
- csp_default_src: str = Field(default="'self'", env="CSP_DEFAULT_SRC")
63
- csp_script_src: str = Field(
64
- default="'self' 'unsafe-inline' https://unpkg.com https://cdn.plot.ly",
65
- env="CSP_SCRIPT_SRC",
66
- )
67
- csp_style_src: str = Field(
68
- default="'self' 'unsafe-inline' https://unpkg.com", env="CSP_STYLE_SRC"
69
- )
70
- csp_img_src: str = Field(default="'self' data: https:", env="CSP_IMG_SRC")
71
- csp_connect_src: str = Field(default="'self'", env="CSP_CONNECT_SRC")
72
-
73
- @field_validator("secret_key")
74
- def validate_secret_key(cls, v):
75
- """Ensure secret key is secure in production"""
76
- if v == "dev-secret-key-change-in-production":
77
- env = os.getenv("ENVIRONMENT", "development")
78
- if env.lower() in ["production", "prod"]:
79
- raise ValueError("Must set a secure SECRET_KEY in production")
80
-
81
- if len(v) < 32:
82
- raise ValueError("Secret key must be at least 32 characters long")
83
-
84
- return v
85
-
86
- @field_validator("allowed_hosts")
87
- def validate_allowed_hosts(cls, v):
88
- """Validate allowed hosts format"""
89
- for host in v:
90
- if not host or ".." in host:
91
- raise ValueError(f"Invalid host: {host}")
92
  return v
93
 
94
 
95
  class ServerConfig(BaseSettings):
96
  """Server configuration settings"""
97
-
98
- # Server settings
99
- host: str = Field(default="127.0.0.1", env="HOST")
100
- port: int = Field(default=8000, env="PORT", ge=1, le=65535)
101
- workers: int = Field(default=1, env="WORKERS", ge=1, le=8)
102
  reload: bool = Field(default=False, env="RELOAD")
103
  debug: bool = Field(default=False, env="DEBUG")
104
-
105
  # Request handling
106
  request_timeout: int = Field(default=30, env="REQUEST_TIMEOUT", ge=1, le=300)
107
  max_trees_per_request: int = Field(
108
  default=1000, env="MAX_TREES_PER_REQUEST", ge=1, le=10000
109
  )
110
 
111
- @field_validator("host")
112
- def validate_host(cls, v):
113
- """Validate host format"""
114
- if not v or v.count(".") > 3:
115
- raise ValueError("Invalid host format")
116
- return v
117
 
118
-
119
- class LoggingConfig(BaseSettings):
120
- """Logging configuration settings"""
121
-
122
- # Logging settings
123
- log_level: str = Field(default="INFO", env="LOG_LEVEL")
124
- log_file: str = Field(default="logs/app.log", env="LOG_FILE")
125
- log_max_size: int = Field(
126
- default=10485760, env="LOG_MAX_SIZE", ge=1024
127
- ) # 10MB default
128
- log_backup_count: int = Field(default=5, env="LOG_BACKUP_COUNT", ge=1, le=20)
129
- log_format: str = Field(
130
- default="%(asctime)s - %(name)s - %(levelname)s - %(message)s", env="LOG_FORMAT"
131
- )
132
-
133
- # Access logging
134
- access_log_enabled: bool = Field(default=True, env="ACCESS_LOG_ENABLED")
135
- access_log_file: str = Field(default="logs/access.log", env="ACCESS_LOG_FILE")
136
-
137
- @field_validator("log_level")
138
- def validate_log_level(cls, v):
139
- """Validate log level"""
140
- valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
141
- if v.upper() not in valid_levels:
142
- raise ValueError(f"Log level must be one of: {valid_levels}")
143
- return v.upper()
144
-
145
- @field_validator("log_file", "access_log_file")
146
- def validate_log_file(cls, v):
147
- """Ensure log directory exists"""
148
- if v:
149
- log_path = Path(v)
150
- log_path.parent.mkdir(parents=True, exist_ok=True)
151
  return v
152
-
153
-
154
- class CacheConfig(BaseSettings):
155
- """Cache configuration settings"""
156
-
157
- # Cache settings
158
- cache_enabled: bool = Field(default=True, env="CACHE_ENABLED")
159
- cache_ttl_trees: int = Field(default=300, env="CACHE_TTL_TREES", ge=1) # 5 minutes
160
- cache_ttl_stats: int = Field(default=600, env="CACHE_TTL_STATS", ge=1) # 10 minutes
161
- cache_max_size: int = Field(default=1000, env="CACHE_MAX_SIZE", ge=1)
162
-
163
-
164
- class MonitoringConfig(BaseSettings):
165
- """Monitoring and observability configuration"""
166
-
167
- # Monitoring settings
168
- metrics_enabled: bool = Field(default=True, env="METRICS_ENABLED")
169
- health_check_enabled: bool = Field(default=True, env="HEALTH_CHECK_ENABLED")
170
- performance_monitoring: bool = Field(default=True, env="PERFORMANCE_MONITORING")
171
-
172
- # Alerting thresholds
173
- error_rate_threshold: float = Field(
174
- default=0.05, env="ERROR_RATE_THRESHOLD", ge=0.0, le=1.0
175
- )
176
- response_time_threshold: float = Field(
177
- default=2.0, env="RESPONSE_TIME_THRESHOLD", ge=0.1
178
- )
179
 
180
 
181
  class ApplicationConfig(BaseSettings):
182
  """Main application configuration"""
183
-
184
- # Environment
185
- environment: str = Field(default="development", env="ENVIRONMENT")
186
- app_name: str = Field(default="Tree Mapping API", env="APP_NAME")
187
- app_version: str = Field(default="2.0.0", env="APP_VERSION")
188
  app_description: str = Field(
189
- default="Secure API for mapping and tracking trees", env="APP_DESCRIPTION"
 
190
  )
191
-
 
192
  # Feature flags
193
  enable_api_docs: bool = Field(default=True, env="ENABLE_API_DOCS")
194
- enable_frontend: bool = Field(default=True, env="ENABLE_FRONTEND")
195
  enable_statistics: bool = Field(default=True, env="ENABLE_STATISTICS")
196
-
197
- # Data validation
198
- strict_validation: bool = Field(default=True, env="STRICT_VALIDATION")
199
  max_species_length: int = Field(default=200, env="MAX_SPECIES_LENGTH", ge=1, le=500)
200
  max_notes_length: int = Field(default=2000, env="MAX_NOTES_LENGTH", ge=1, le=10000)
201
-
202
  @field_validator("environment")
203
  def validate_environment(cls, v):
204
  """Validate environment"""
@@ -207,102 +111,24 @@ class ApplicationConfig(BaseSettings):
207
  raise ValueError(f"Environment must be one of: {valid_envs}")
208
  return v.lower()
209
 
210
- class Config:
211
- env_file = ".env"
212
- env_file_encoding = "utf-8"
213
- case_sensitive = False
214
-
215
 
216
  class Settings(BaseSettings):
217
  """Combined application settings"""
218
-
219
  # Sub-configurations
220
- database: DatabaseConfig = DatabaseConfig()
221
  security: SecurityConfig = SecurityConfig()
222
  server: ServerConfig = ServerConfig()
223
- logging: LoggingConfig = LoggingConfig()
224
- cache: CacheConfig = CacheConfig()
225
- monitoring: MonitoringConfig = MonitoringConfig()
226
  app: ApplicationConfig = ApplicationConfig()
227
-
228
- @model_validator(mode="before")
229
- def validate_production_settings(cls, values):
230
- """Additional validation for production environment"""
231
- app_config = values.get("app")
232
- security_config = values.get("security")
233
- server_config = values.get("server")
234
-
235
- if app_config and app_config.environment == "production":
236
- # Production-specific validations
237
- if server_config and server_config.debug:
238
- raise ValueError("Debug mode must be disabled in production")
239
-
240
- if (
241
- security_config
242
- and security_config.secret_key == "dev-secret-key-change-in-production"
243
- ):
244
- raise ValueError("Must set a secure SECRET_KEY in production")
245
-
246
- return values
247
-
248
- def get_database_url(self) -> str:
249
- """Get database connection URL"""
250
- return f"sqlite:///{self.database.db_path}"
251
-
252
- def get_log_config(self) -> dict:
253
- """Get logging configuration dictionary"""
254
- return {
255
- "version": 1,
256
- "disable_existing_loggers": False,
257
- "formatters": {
258
- "default": {
259
- "format": self.logging.log_format,
260
- },
261
- "access": {
262
- "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
263
- },
264
- },
265
- "handlers": {
266
- "default": {
267
- "formatter": "default",
268
- "class": "logging.handlers.RotatingFileHandler",
269
- "filename": self.logging.log_file,
270
- "maxBytes": self.logging.log_max_size,
271
- "backupCount": self.logging.log_backup_count,
272
- }
273
- if self.logging.log_file
274
- else {
275
- "formatter": "default",
276
- "class": "logging.StreamHandler",
277
- },
278
- "access": {
279
- "formatter": "access",
280
- "class": "logging.handlers.RotatingFileHandler",
281
- "filename": self.logging.access_log_file,
282
- "maxBytes": self.logging.log_max_size,
283
- "backupCount": self.logging.log_backup_count,
284
- }
285
- if self.logging.access_log_file and self.logging.access_log_enabled
286
- else {
287
- "formatter": "access",
288
- "class": "logging.StreamHandler",
289
- },
290
- },
291
- "loggers": {
292
- "": {
293
- "handlers": ["default"],
294
- "level": self.logging.log_level,
295
- },
296
- "uvicorn.access": {
297
- "handlers": ["access"],
298
- "level": "INFO",
299
- "propagate": False,
300
- }
301
- if self.logging.access_log_enabled
302
- else {},
303
- },
304
- }
305
-
306
  def get_cors_config(self) -> dict:
307
  """Get CORS configuration"""
308
  return {
@@ -311,25 +137,7 @@ class Settings(BaseSettings):
311
  "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
312
  "allow_headers": ["*"],
313
  }
314
-
315
- def get_csp_header(self) -> str:
316
- """Get Content Security Policy header value"""
317
- return (
318
- f"default-src {self.security.csp_default_src}; "
319
- f"script-src {self.security.csp_script_src}; "
320
- f"style-src {self.security.csp_style_src}; "
321
- f"img-src {self.security.csp_img_src}; "
322
- f"connect-src {self.security.csp_connect_src}"
323
- )
324
-
325
- def is_development(self) -> bool:
326
- """Check if running in development mode"""
327
- return self.app.environment == "development"
328
-
329
- def is_production(self) -> bool:
330
- """Check if running in production mode"""
331
- return self.app.environment == "production"
332
-
333
  class Config:
334
  env_file = ".env"
335
  env_file_encoding = "utf-8"
@@ -342,62 +150,28 @@ def get_settings() -> Settings:
342
  return Settings()
343
 
344
 
345
- def setup_logging(settings: Settings) -> None:
346
- """Setup application logging"""
347
- import logging.config
348
-
349
- # Ensure log directories exist
350
- if settings.logging.log_file:
351
- Path(settings.logging.log_file).parent.mkdir(parents=True, exist_ok=True)
352
 
353
- if settings.logging.access_log_file:
354
- Path(settings.logging.access_log_file).parent.mkdir(parents=True, exist_ok=True)
355
 
356
- # Configure logging
357
- log_config = settings.get_log_config()
358
- logging.config.dictConfig(log_config)
359
-
360
- # Log startup information
361
- logger = logging.getLogger(__name__)
362
- logger.info(f"Starting {settings.app.app_name} v{settings.app.app_version}")
363
- logger.info(f"Environment: {settings.app.environment}")
364
- logger.info(f"Log level: {settings.logging.log_level}")
365
-
366
-
367
- def validate_settings() -> Settings:
368
- """Validate and return application settings"""
369
- try:
370
- settings = get_settings()
371
-
372
- # Additional runtime validations
373
- if settings.is_production():
374
- # Check critical production settings
375
- if (
376
- not settings.security.secret_key
377
- or len(settings.security.secret_key) < 32
378
- ):
379
- raise ValueError("Production requires a secure SECRET_KEY")
380
-
381
- if settings.server.debug:
382
- raise ValueError("Debug mode must be disabled in production")
383
-
384
- return settings
385
-
386
- except Exception as e:
387
- print(f"Configuration error: {e}")
388
- raise
389
 
390
 
391
  if __name__ == "__main__":
392
  # Test configuration loading
393
  try:
394
- settings = validate_settings()
395
- print(" Configuration loaded successfully")
 
396
  print(f"Environment: {settings.app.environment}")
397
- print(f"Database: {settings.database.db_path}")
398
  print(f"Server: {settings.server.host}:{settings.server.port}")
399
- print(f"Log level: {settings.logging.log_level}")
400
-
 
401
  except Exception as e:
402
- print(f" Configuration error: {e}")
403
  exit(1)
 
1
  """
2
+ Configuration management for TreeTrack - Supabase Edition
3
+ Environment-based configuration for cloud deployment
4
  """
5
 
 
6
  import os
7
  from functools import lru_cache
8
  from pathlib import Path
9
+ from typing import List
10
 
11
+ from pydantic import Field, field_validator
12
  from pydantic_settings import BaseSettings
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class SecurityConfig(BaseSettings):
16
  """Security configuration settings"""
17
+
18
+ # CORS settings for web deployment
19
+ cors_origins: List[str] = Field(
20
+ default=["*"], env="CORS_ORIGINS" # Allow all origins for HuggingFace Spaces
 
 
 
 
 
 
21
  )
22
+
23
+ # Request limits
24
  max_request_size: int = Field(
25
+ default=10485760, env="MAX_REQUEST_SIZE", ge=1024
26
+ ) # 10MB default for file uploads
27
+
28
+ @field_validator("cors_origins")
29
+ def validate_cors_origins(cls, v):
30
+ """Validate CORS origins"""
31
+ if isinstance(v, str):
32
+ # Handle comma-separated string
33
+ return [origin.strip() for origin in v.split(",") if origin.strip()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return v
35
 
36
 
37
  class ServerConfig(BaseSettings):
38
  """Server configuration settings"""
39
+
40
+ # Server settings - optimized for HuggingFace Spaces
41
+ host: str = Field(default="0.0.0.0", env="HOST")
42
+ port: int = Field(default=7860, env="PORT", ge=1, le=65535) # HF Spaces default
43
+ workers: int = Field(default=1, env="WORKERS", ge=1, le=4)
44
  reload: bool = Field(default=False, env="RELOAD")
45
  debug: bool = Field(default=False, env="DEBUG")
46
+
47
  # Request handling
48
  request_timeout: int = Field(default=30, env="REQUEST_TIMEOUT", ge=1, le=300)
49
  max_trees_per_request: int = Field(
50
  default=1000, env="MAX_TREES_PER_REQUEST", ge=1, le=10000
51
  )
52
 
 
 
 
 
 
 
53
 
54
+ class SupabaseConfig(BaseSettings):
55
+ """Supabase configuration settings"""
56
+
57
+ # Required Supabase credentials
58
+ supabase_url: str = Field(env="SUPABASE_URL")
59
+ supabase_anon_key: str = Field(env="SUPABASE_ANON_KEY")
60
+ supabase_service_role_key: str = Field(env="SUPABASE_SERVICE_ROLE_KEY")
61
+
62
+ # Storage bucket names
63
+ image_bucket: str = Field(default="tree-images", env="IMAGE_BUCKET")
64
+ audio_bucket: str = Field(default="tree-audio", env="AUDIO_BUCKET")
65
+
66
+ # File URL expiry (in seconds)
67
+ signed_url_expiry: int = Field(default=3600, env="SIGNED_URL_EXPIRY", ge=300)
68
+
69
+ @field_validator("supabase_url")
70
+ def validate_supabase_url(cls, v):
71
+ """Validate Supabase URL format"""
72
+ if not v or not v.startswith("https://"):
73
+ raise ValueError("SUPABASE_URL must be a valid HTTPS URL")
74
+ return v
75
+
76
+ @field_validator("supabase_anon_key", "supabase_service_role_key")
77
+ def validate_keys(cls, v):
78
+ """Validate Supabase keys"""
79
+ if v and len(v) < 50: # Only validate if provided
80
+ raise ValueError("Supabase keys must be valid if provided")
 
 
 
 
 
 
81
  return v
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  class ApplicationConfig(BaseSettings):
85
  """Main application configuration"""
86
+
87
+ # Application metadata
88
+ app_name: str = Field(default="TreeTrack", env="APP_NAME")
89
+ app_version: str = Field(default="3.0.0", env="APP_VERSION")
 
90
  app_description: str = Field(
91
+ default="Tree mapping and tracking with cloud storage",
92
+ env="APP_DESCRIPTION"
93
  )
94
+ environment: str = Field(default="production", env="ENVIRONMENT")
95
+
96
  # Feature flags
97
  enable_api_docs: bool = Field(default=True, env="ENABLE_API_DOCS")
98
+ enable_frontend: bool = Field(default=True, env="ENABLE_FRONTEND")
99
  enable_statistics: bool = Field(default=True, env="ENABLE_STATISTICS")
100
+ enable_master_db: bool = Field(default=True, env="ENABLE_MASTER_DB")
101
+
102
+ # Data validation limits
103
  max_species_length: int = Field(default=200, env="MAX_SPECIES_LENGTH", ge=1, le=500)
104
  max_notes_length: int = Field(default=2000, env="MAX_NOTES_LENGTH", ge=1, le=10000)
105
+
106
  @field_validator("environment")
107
  def validate_environment(cls, v):
108
  """Validate environment"""
 
111
  raise ValueError(f"Environment must be one of: {valid_envs}")
112
  return v.lower()
113
 
 
 
 
 
 
114
 
115
  class Settings(BaseSettings):
116
  """Combined application settings"""
117
+
118
  # Sub-configurations
 
119
  security: SecurityConfig = SecurityConfig()
120
  server: ServerConfig = ServerConfig()
121
+ supabase: SupabaseConfig = SupabaseConfig()
 
 
122
  app: ApplicationConfig = ApplicationConfig()
123
+
124
+ def is_development(self) -> bool:
125
+ """Check if running in development mode"""
126
+ return self.app.environment == "development"
127
+
128
+ def is_production(self) -> bool:
129
+ """Check if running in production mode"""
130
+ return self.app.environment == "production"
131
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def get_cors_config(self) -> dict:
133
  """Get CORS configuration"""
134
  return {
 
137
  "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
138
  "allow_headers": ["*"],
139
  }
140
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  class Config:
142
  env_file = ".env"
143
  env_file_encoding = "utf-8"
 
150
  return Settings()
151
 
152
 
153
+ # For backward compatibility - expose individual configs
154
+ def get_security_config():
155
+ return get_settings().security
 
 
 
 
156
 
157
+ def get_server_config():
158
+ return get_settings().server
159
 
160
+ def get_supabase_config():
161
+ return get_settings().supabase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
 
164
  if __name__ == "__main__":
165
  # Test configuration loading
166
  try:
167
+ settings = get_settings()
168
+ print("βœ… Configuration loaded successfully")
169
+ print(f"App: {settings.app.app_name} v{settings.app.app_version}")
170
  print(f"Environment: {settings.app.environment}")
 
171
  print(f"Server: {settings.server.host}:{settings.server.port}")
172
+ print(f"Supabase URL: {settings.supabase.supabase_url}")
173
+ print(f"CORS Origins: {settings.security.cors_origins}")
174
+
175
  except Exception as e:
176
+ print(f"❌ Configuration error: {e}")
177
  exit(1)
data/master_trees.db ADDED
Binary file (36.9 kB). View file
 
supabase_client.py CHANGED
@@ -10,10 +10,18 @@ from pathlib import Path
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
- # Supabase configuration
14
- SUPABASE_URL = os.getenv("SUPABASE_URL", "https://puwgehualigbuxlopnkg.supabase.co")
15
- SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
16
- SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
 
 
 
 
 
 
 
 
17
 
18
  # Initialize Supabase client
19
  supabase: Optional[Client] = None
@@ -38,146 +46,6 @@ def get_service_client() -> Client:
38
 
39
  return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
40
 
41
- # Database operations
42
- class SupabaseTreeDB:
43
- """Supabase database operations for trees"""
44
-
45
- def __init__(self):
46
- self.client = get_supabase_client()
47
-
48
- def create_tree(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
49
- """Create a new tree record"""
50
- try:
51
- result = self.client.table('trees').insert(tree_data).execute()
52
- if result.data:
53
- logger.info(f"Created tree with ID: {result.data[0].get('id')}")
54
- return result.data[0]
55
- else:
56
- raise Exception("No data returned from insert operation")
57
- except Exception as e:
58
- logger.error(f"Error creating tree: {e}")
59
- raise
60
-
61
- def get_trees(self, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
62
- """Get trees with pagination"""
63
- try:
64
- result = self.client.table('trees') \
65
- .select("*") \
66
- .order('created_at', desc=True) \
67
- .range(offset, offset + limit - 1) \
68
- .execute()
69
-
70
- logger.info(f"Retrieved {len(result.data)} trees")
71
- return result.data
72
- except Exception as e:
73
- logger.error(f"Error retrieving trees: {e}")
74
- raise
75
-
76
- def get_tree(self, tree_id: int) -> Optional[Dict[str, Any]]:
77
- """Get a specific tree by ID"""
78
- try:
79
- result = self.client.table('trees') \
80
- .select("*") \
81
- .eq('id', tree_id) \
82
- .execute()
83
-
84
- if result.data:
85
- return result.data[0]
86
- return None
87
- except Exception as e:
88
- logger.error(f"Error retrieving tree {tree_id}: {e}")
89
- raise
90
-
91
- def update_tree(self, tree_id: int, tree_data: Dict[str, Any]) -> Dict[str, Any]:
92
- """Update a tree record"""
93
- try:
94
- result = self.client.table('trees') \
95
- .update(tree_data) \
96
- .eq('id', tree_id) \
97
- .execute()
98
-
99
- if result.data:
100
- logger.info(f"Updated tree with ID: {tree_id}")
101
- return result.data[0]
102
- else:
103
- raise Exception(f"Tree with ID {tree_id} not found")
104
- except Exception as e:
105
- logger.error(f"Error updating tree {tree_id}: {e}")
106
- raise
107
-
108
- def delete_tree(self, tree_id: int) -> bool:
109
- """Delete a tree record"""
110
- try:
111
- result = self.client.table('trees') \
112
- .delete() \
113
- .eq('id', tree_id) \
114
- .execute()
115
-
116
- logger.info(f"Deleted tree with ID: {tree_id}")
117
- return True
118
- except Exception as e:
119
- logger.error(f"Error deleting tree {tree_id}: {e}")
120
- raise
121
-
122
- def get_tree_count(self) -> int:
123
- """Get total number of trees"""
124
- try:
125
- result = self.client.table('trees') \
126
- .select("id", count="exact") \
127
- .execute()
128
-
129
- return result.count if result.count is not None else 0
130
- except Exception as e:
131
- logger.error(f"Error getting tree count: {e}")
132
- return 0
133
-
134
- # File storage operations
135
- class SupabaseStorage:
136
- """Supabase storage operations for files"""
137
-
138
- def __init__(self):
139
- self.client = get_supabase_client()
140
-
141
- def upload_file(self, bucket_name: str, file_path: str, file_data: bytes) -> str:
142
- """Upload file to Supabase storage (private buckets)"""
143
- try:
144
- result = self.client.storage.from_(bucket_name).upload(file_path, file_data)
145
-
146
- if result:
147
- # For private buckets, return the file path (we'll generate signed URLs when needed)
148
- logger.info(f"File uploaded successfully: {file_path}")
149
- return file_path
150
- else:
151
- raise Exception("Upload failed")
152
- except Exception as e:
153
- logger.error(f"Error uploading file {file_path}: {e}")
154
- raise
155
-
156
- def delete_file(self, bucket_name: str, file_path: str) -> bool:
157
- """Delete file from Supabase storage"""
158
- try:
159
- result = self.client.storage.from_(bucket_name).remove([file_path])
160
- logger.info(f"File deleted: {file_path}")
161
- return True
162
- except Exception as e:
163
- logger.error(f"Error deleting file {file_path}: {e}")
164
- return False
165
-
166
- def get_public_url(self, bucket_name: str, file_path: str) -> str:
167
- """Get public URL for a file"""
168
- return self.client.storage.from_(bucket_name).get_public_url(file_path)
169
-
170
- def get_signed_url(self, bucket_name: str, file_path: str, expires_in: int = 3600) -> str:
171
- """Get signed URL for private file (expires in seconds, default 1 hour)"""
172
- try:
173
- result = self.client.storage.from_(bucket_name).create_signed_url(file_path, expires_in)
174
- if result and 'signedURL' in result:
175
- return result['signedURL']
176
- else:
177
- raise Exception("Failed to generate signed URL")
178
- except Exception as e:
179
- logger.error(f"Error generating signed URL for {file_path}: {e}")
180
- raise
181
 
182
  # Initialize database table (run once)
183
  def create_trees_table():
@@ -244,8 +112,8 @@ def create_storage_buckets():
244
  client = get_service_client()
245
 
246
  buckets = [
247
- {"name": "tree-images", "public": True},
248
- {"name": "tree-audio", "public": True}
249
  ]
250
 
251
  for bucket in buckets:
 
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
+ # Supabase configuration - all must be provided via environment variables
14
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
15
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
16
+ SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
17
+
18
+ # Validate required environment variables (defer validation to runtime)
19
+ if not SUPABASE_URL:
20
+ logger.warning("SUPABASE_URL not found - will cause connection failures")
21
+ if not SUPABASE_ANON_KEY:
22
+ logger.warning("SUPABASE_ANON_KEY not found - will cause connection failures")
23
+ if not SUPABASE_SERVICE_ROLE_KEY:
24
+ logger.warning("SUPABASE_SERVICE_ROLE_KEY not provided - admin functions will be limited")
25
 
26
  # Initialize Supabase client
27
  supabase: Optional[Client] = None
 
46
 
47
  return create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  # Initialize database table (run once)
51
  def create_trees_table():
 
112
  client = get_service_client()
113
 
114
  buckets = [
115
+ {"name": "tree-images", "public": False},
116
+ {"name": "tree-audio", "public": False}
117
  ]
118
 
119
  for bucket in buckets:
supabase_database.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supabase database implementation for TreeTrack
3
+ Standalone implementation for Supabase Postgres operations
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from typing import Dict, List, Optional, Any
9
+ from supabase_client import get_supabase_client, get_service_client
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SupabaseDatabase:
15
+ """Supabase implementation of DatabaseInterface"""
16
+
17
+ def __init__(self):
18
+ self.client = get_supabase_client()
19
+ logger.info("SupabaseDatabase initialized")
20
+
21
+ def initialize_database(self) -> bool:
22
+ """Initialize database tables and indexes (already done via SQL)"""
23
+ # Tables are created via SQL in Supabase dashboard
24
+ # This just tests that the table exists
25
+ try:
26
+ result = self.client.table('trees').select("id").limit(1).execute()
27
+ logger.info("Trees table verified in Supabase")
28
+ return True
29
+ except Exception as e:
30
+ logger.error(f"Failed to verify trees table: {e}")
31
+ return False
32
+
33
+ def test_connection(self) -> bool:
34
+ """Test Supabase connection"""
35
+ try:
36
+ result = self.client.table('trees').select("id").limit(1).execute()
37
+ logger.info("Supabase connection successful")
38
+ return True
39
+ except Exception as e:
40
+ logger.error(f"Supabase connection failed: {e}")
41
+ return False
42
+
43
+ def create_tree(self, tree_data: Dict[str, Any]) -> Dict[str, Any]:
44
+ """Create a new tree record"""
45
+ try:
46
+ # Supabase handles JSON fields automatically, no need to stringify
47
+ result = self.client.table('trees').insert(tree_data).execute()
48
+
49
+ if result.data:
50
+ created_tree = result.data[0]
51
+ logger.info(f"Created tree with ID: {created_tree.get('id')}")
52
+ return created_tree
53
+ else:
54
+ raise Exception("No data returned from insert operation")
55
+
56
+ except Exception as e:
57
+ logger.error(f"Error creating tree: {e}")
58
+ raise
59
+
60
+ def get_trees(self, limit: int = 100, offset: int = 0,
61
+ species: str = None, health_status: str = None) -> List[Dict[str, Any]]:
62
+ """Get trees with pagination and optional filters"""
63
+ try:
64
+ query = self.client.table('trees').select("*")
65
+
66
+ # Apply filters
67
+ if species:
68
+ query = query.ilike('scientific_name', f'%{species}%')
69
+
70
+ if health_status:
71
+ # Note: health_status field doesn't exist in our schema
72
+ # This is for future compatibility
73
+ pass
74
+
75
+ # Apply pagination and ordering
76
+ result = query.order('created_at', desc=True) \
77
+ .range(offset, offset + limit - 1) \
78
+ .execute()
79
+
80
+ logger.info(f"Retrieved {len(result.data)} trees from Supabase")
81
+ return result.data
82
+
83
+ except Exception as e:
84
+ logger.error(f"Error retrieving trees: {e}")
85
+ raise
86
+
87
+ def get_tree(self, tree_id: int) -> Optional[Dict[str, Any]]:
88
+ """Get a specific tree by ID"""
89
+ try:
90
+ result = self.client.table('trees') \
91
+ .select("*") \
92
+ .eq('id', tree_id) \
93
+ .execute()
94
+
95
+ if result.data:
96
+ return result.data[0]
97
+ return None
98
+
99
+ except Exception as e:
100
+ logger.error(f"Error retrieving tree {tree_id}: {e}")
101
+ raise
102
+
103
+ def update_tree(self, tree_id: int, tree_data: Dict[str, Any]) -> Dict[str, Any]:
104
+ """Update a tree record"""
105
+ try:
106
+ # Remove id from update data if present
107
+ update_data = {k: v for k, v in tree_data.items() if k != 'id'}
108
+
109
+ result = self.client.table('trees') \
110
+ .update(update_data) \
111
+ .eq('id', tree_id) \
112
+ .execute()
113
+
114
+ if result.data:
115
+ updated_tree = result.data[0]
116
+ logger.info(f"Updated tree with ID: {tree_id}")
117
+ return updated_tree
118
+ else:
119
+ raise Exception(f"Tree with ID {tree_id} not found")
120
+
121
+ except Exception as e:
122
+ logger.error(f"Error updating tree {tree_id}: {e}")
123
+ raise
124
+
125
+ def delete_tree(self, tree_id: int) -> bool:
126
+ """Delete a tree record"""
127
+ try:
128
+ result = self.client.table('trees') \
129
+ .delete() \
130
+ .eq('id', tree_id) \
131
+ .execute()
132
+
133
+ logger.info(f"Deleted tree with ID: {tree_id}")
134
+ return True
135
+
136
+ except Exception as e:
137
+ logger.error(f"Error deleting tree {tree_id}: {e}")
138
+ raise
139
+
140
+ def get_tree_count(self) -> int:
141
+ """Get total number of trees"""
142
+ try:
143
+ result = self.client.table('trees') \
144
+ .select("id", count="exact") \
145
+ .execute()
146
+
147
+ return result.count if result.count is not None else 0
148
+
149
+ except Exception as e:
150
+ logger.error(f"Error getting tree count: {e}")
151
+ return 0
152
+
153
+ def get_species_distribution(self, limit: int = 20) -> List[Dict[str, Any]]:
154
+ """Get trees by species distribution"""
155
+ try:
156
+ # Use Supabase RPC for complex aggregation
157
+ # This requires creating a stored procedure in Supabase
158
+ # For now, we'll do a simple approach
159
+
160
+ result = self.client.table('trees') \
161
+ .select("scientific_name") \
162
+ .not_.is_('scientific_name', 'null') \
163
+ .execute()
164
+
165
+ # Group by species in Python (not optimal but works)
166
+ species_count = {}
167
+ for tree in result.data:
168
+ species = tree.get('scientific_name', 'Unknown')
169
+ species_count[species] = species_count.get(species, 0) + 1
170
+
171
+ # Convert to list and sort
172
+ distribution = [
173
+ {"species": species, "count": count}
174
+ for species, count in species_count.items()
175
+ ]
176
+ distribution.sort(key=lambda x: x['count'], reverse=True)
177
+
178
+ return distribution[:limit]
179
+
180
+ except Exception as e:
181
+ logger.error(f"Error getting species distribution: {e}")
182
+ return []
183
+
184
+ def get_health_distribution(self) -> List[Dict[str, Any]]:
185
+ """Get trees by health status distribution"""
186
+ try:
187
+ # Health status field doesn't exist in our current schema
188
+ # Return empty for compatibility
189
+ return []
190
+
191
+ except Exception as e:
192
+ logger.error(f"Error getting health distribution: {e}")
193
+ return []
194
+
195
+ def get_average_measurements(self) -> Dict[str, float]:
196
+ """Get average height and diameter"""
197
+ try:
198
+ # Note: we have 'width' field, not 'diameter'
199
+ result = self.client.table('trees') \
200
+ .select("height, width") \
201
+ .not_.is_('height', 'null') \
202
+ .not_.is_('width', 'null') \
203
+ .execute()
204
+
205
+ if not result.data:
206
+ return {"average_height": 0, "average_diameter": 0}
207
+
208
+ heights = [float(tree['height']) for tree in result.data if tree['height']]
209
+ widths = [float(tree['width']) for tree in result.data if tree['width']]
210
+
211
+ avg_height = sum(heights) / len(heights) if heights else 0
212
+ avg_width = sum(widths) / len(widths) if widths else 0
213
+
214
+ return {
215
+ "average_height": round(avg_height, 2),
216
+ "average_diameter": round(avg_width, 2) # Using width as diameter
217
+ }
218
+
219
+ except Exception as e:
220
+ logger.error(f"Error getting average measurements: {e}")
221
+ return {"average_height": 0, "average_diameter": 0}
222
+
223
+ def backup_database(self) -> bool:
224
+ """Backup database (not needed for Supabase - automatically backed up)"""
225
+ logger.info("Supabase automatically backs up data - no manual backup needed")
226
+ return True
227
+
228
+ def restore_database(self) -> bool:
229
+ """Restore database (not needed for Supabase)"""
230
+ logger.info("Supabase data is persistent - no restore needed")
231
+ return True
supabase_storage.py CHANGED
@@ -18,7 +18,7 @@ class SupabaseFileStorage:
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]:
 
18
  def __init__(self):
19
  self.client = get_supabase_client()
20
  self.image_bucket = "tree-images"
21
+ self.audio_bucket = "tree-audio"
22
  logger.info("SupabaseFileStorage initialized")
23
 
24
  def upload_image(self, file_data: bytes, filename: str, category: str) -> Dict[str, Any]: