RoyAalekh commited on
Commit
2fcae50
·
1 Parent(s): 0d6cca3

Replace app.py with clean Supabase-only implementation

Browse files

- Backup old SQLite app as app_sqlite_backup.py
- Replace main app.py with Supabase-only version
- Remove all SQLite complexity and backup systems
- Clean codebase with persistent cloud storage
- Zero functionality loss, all APIs preserved
- Ready for production deployment with Supabase

Files changed (2) hide show
  1. app.py +239 -1181
  2. app_sqlite_backup.py +1614 -0
app.py CHANGED
@@ -1,34 +1,28 @@
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(
@@ -38,28 +32,23 @@ logging.basicConfig(
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,
@@ -68,274 +57,56 @@ app.add_middleware(
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
- directories = ["data", "uploads", "uploads/images", "uploads/audio"]
127
-
128
- for dir_name in directories:
129
- dir_path = Path(dir_name)
130
- try:
131
- dir_path.mkdir(exist_ok=True, parents=True)
132
- # Set restrictive permissions (owner read/write only)
133
- dir_path.chmod(0o700)
134
- logger.info(f"Directory {dir_name} initialized")
135
- except Exception as e:
136
- logger.error(f"Failed to create {dir_name} directory: {e}")
137
- raise
138
-
139
-
140
- @contextmanager
141
- def get_db_connection():
142
- """Context manager for database connections with proper error handling"""
143
- conn = None
144
- try:
145
- conn = sqlite3.connect(
146
- settings.database.db_path,
147
- timeout=settings.server.request_timeout,
148
- check_same_thread=False,
149
- )
150
- conn.row_factory = sqlite3.Row
151
- # Enable foreign key constraints
152
- conn.execute("PRAGMA foreign_keys = ON")
153
-
154
- # Optimize for concurrent writes (perfect for 2 users)
155
- conn.execute("PRAGMA journal_mode = WAL")
156
- conn.execute("PRAGMA synchronous = NORMAL") # Good balance of safety/speed
157
- conn.execute("PRAGMA cache_size = 10000") # 10MB cache for better performance
158
- conn.execute("PRAGMA temp_store = MEMORY") # Use memory for temp tables
159
- conn.execute("PRAGMA wal_autocheckpoint = 1000") # Prevent WAL from growing too large
160
- yield conn
161
- except sqlite3.Error as e:
162
- logger.error(f"Database error: {e}")
163
- if conn:
164
- conn.rollback()
165
- raise
166
- except Exception as e:
167
- logger.error(f"Unexpected database error: {e}")
168
- if conn:
169
- conn.rollback()
170
- raise
171
- finally:
172
- if conn:
173
- conn.close()
174
-
175
-
176
- def init_db():
177
- """Initialize database with enhanced schema and constraints"""
178
- ensure_data_directory()
179
-
180
- try:
181
- with get_db_connection() as conn:
182
- cursor = conn.cursor()
183
-
184
- # Create enhanced trees table with all 12 fields
185
- cursor.execute("""
186
- CREATE TABLE IF NOT EXISTS trees (
187
- id INTEGER PRIMARY KEY AUTOINCREMENT,
188
- -- 1. Geolocation
189
- latitude REAL NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
190
- longitude REAL NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
191
-
192
- -- 2. Local Name (Assamese)
193
- local_name TEXT CHECK (local_name IS NULL OR length(local_name) <= 200),
194
-
195
- -- 3. Scientific Name
196
- scientific_name TEXT CHECK (scientific_name IS NULL OR length(scientific_name) <= 200),
197
-
198
- -- 4. Common Name
199
- common_name TEXT CHECK (common_name IS NULL OR length(common_name) <= 200),
200
-
201
- -- 5. Tree Code
202
- tree_code TEXT CHECK (tree_code IS NULL OR length(tree_code) <= 20),
203
-
204
- -- 6. Height (in meters)
205
- height REAL CHECK (height IS NULL OR (height > 0 AND height <= 200)),
206
-
207
- -- 7. Width/Girth (in cm)
208
- width REAL CHECK (width IS NULL OR (width > 0 AND width <= 2000)),
209
-
210
- -- 8. Utility (JSON array of selected utilities)
211
- utility TEXT, -- JSON array: ["Religious", "Timber", "Biodiversity", etc.]
212
-
213
- -- 9. Storytelling
214
- storytelling_text TEXT CHECK (storytelling_text IS NULL OR length(storytelling_text) <= 5000),
215
- storytelling_audio TEXT, -- File path to audio recording
216
-
217
- -- 10. Phenology Tracker (JSON array of current stages)
218
- phenology_stages TEXT, -- JSON array: ["New leaves", "Open flowers", etc.]
219
-
220
- -- 11. Photographs (JSON object with categories and file paths)
221
- photographs TEXT, -- JSON: {"leaf": "path1.jpg", "bark": "path2.jpg", etc.}
222
-
223
- -- 12. Notes
224
- notes TEXT CHECK (notes IS NULL OR length(notes) <= 2000),
225
-
226
- -- System fields
227
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
228
- created_by TEXT DEFAULT 'system',
229
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
230
- )
231
- """)
232
-
233
- # Create indexes for better performance
234
- cursor.execute(
235
- "CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude)"
236
- )
237
- cursor.execute(
238
- "CREATE INDEX IF NOT EXISTS idx_trees_scientific_name ON trees(scientific_name)"
239
- )
240
- cursor.execute(
241
- "CREATE INDEX IF NOT EXISTS idx_trees_local_name ON trees(local_name)"
242
- )
243
- cursor.execute(
244
- "CREATE INDEX IF NOT EXISTS idx_trees_tree_code ON trees(tree_code)"
245
- )
246
- cursor.execute(
247
- "CREATE INDEX IF NOT EXISTS idx_trees_timestamp ON trees(timestamp)"
248
- )
249
-
250
- # Create trigger to update updated_at timestamp
251
- cursor.execute("""
252
- CREATE TRIGGER IF NOT EXISTS update_trees_timestamp
253
- AFTER UPDATE ON trees
254
- BEGIN
255
- UPDATE trees SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
256
- END
257
- """)
258
-
259
- conn.commit()
260
- logger.info("Database initialized successfully")
261
 
262
- except Exception as e:
263
- logger.error(f"Failed to initialize database: {e}")
264
- raise
265
-
266
-
267
- import json
268
- from typing import List, Dict
269
-
270
- # Enhanced Pydantic models for comprehensive tree tracking
271
  class Tree(BaseModel):
272
  """Complete tree model with all 12 fields"""
273
  id: int
274
- # 1. Geolocation
275
  latitude: float
276
  longitude: float
277
- # 2. Local Name (Assamese)
278
  local_name: Optional[str] = None
279
- # 3. Scientific Name
280
  scientific_name: Optional[str] = None
281
- # 4. Common Name
282
  common_name: Optional[str] = None
283
- # 5. Tree Code
284
  tree_code: Optional[str] = None
285
- # 6. Height (meters)
286
  height: Optional[float] = None
287
- # 7. Width/Girth (cm)
288
  width: Optional[float] = None
289
- # 8. Utility (JSON array)
290
  utility: Optional[List[str]] = None
291
- # 9. Storytelling
292
  storytelling_text: Optional[str] = None
293
  storytelling_audio: Optional[str] = None
294
- # 10. Phenology Tracker (JSON array)
295
  phenology_stages: Optional[List[str]] = None
296
- # 11. Photographs (JSON object)
297
  photographs: Optional[Dict[str, str]] = None
298
- # 12. Notes
299
  notes: Optional[str] = None
300
- # System fields
301
- timestamp: str
302
- created_by: str = "system"
303
  updated_at: Optional[str] = None
 
304
 
305
 
306
  class TreeCreate(BaseModel):
307
  """Model for creating new tree records"""
308
- # 1. Geolocation (Required)
309
  latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
310
  longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
311
- # 2. Local Name (Assamese)
312
  local_name: Optional[str] = Field(None, max_length=200, description="Local Assamese name")
313
- # 3. Scientific Name
314
- scientific_name: Optional[str] = Field(None, max_length=200, description="Scientific name (genus species)")
315
- # 4. Common Name
316
- common_name: Optional[str] = Field(None, max_length=200, description="Common or colloquial name")
317
- # 5. Tree Code
318
- tree_code: Optional[str] = Field(None, max_length=20, description="Tree reference code (e.g. C.A, A-G1)")
319
- # 6. Height
320
  height: Optional[float] = Field(None, gt=0, le=200, description="Height in meters")
321
- # 7. Width/Girth
322
  width: Optional[float] = Field(None, gt=0, le=2000, description="Width/girth in centimeters")
323
- # 8. Utility
324
  utility: Optional[List[str]] = Field(None, description="Ecological/cultural utilities")
325
- # 9. Storytelling
326
- storytelling_text: Optional[str] = Field(None, max_length=5000, description="Stories, histories, narratives")
327
- storytelling_audio: Optional[str] = Field(None, description="Audio recording file path")
328
- # 10. Phenology Tracker
329
  phenology_stages: Optional[List[str]] = Field(None, description="Current development stages")
330
- # 11. Photographs
331
- photographs: Optional[Dict[str, str]] = Field(None, description="Photo categories and file paths")
332
- # 12. Notes
333
  notes: Optional[str] = Field(None, max_length=2000, description="Additional observations")
334
 
335
  @field_validator("utility", mode='before')
336
  @classmethod
337
  def validate_utility(cls, v):
338
- # Handle JSON string input
339
  if isinstance(v, str):
340
  try:
341
  v = json.loads(v)
@@ -349,13 +120,12 @@ class TreeCreate(BaseModel):
349
  ]
350
  for item in v:
351
  if item not in valid_utilities:
352
- raise ValueError(f"Invalid utility: {item}. Must be one of: {valid_utilities}")
353
  return v
354
 
355
  @field_validator("phenology_stages", mode='before')
356
  @classmethod
357
  def validate_phenology(cls, v):
358
- # Handle JSON string input
359
  if isinstance(v, str):
360
  try:
361
  v = json.loads(v)
@@ -369,13 +139,12 @@ class TreeCreate(BaseModel):
369
  ]
370
  for stage in v:
371
  if stage not in valid_stages:
372
- raise ValueError(f"Invalid phenology stage: {stage}. Must be one of: {valid_stages}")
373
  return v
374
 
375
  @field_validator("photographs", mode='before')
376
  @classmethod
377
  def validate_photographs(cls, v):
378
- # Handle JSON string input
379
  if isinstance(v, str):
380
  try:
381
  v = json.loads(v)
@@ -386,7 +155,7 @@ class TreeCreate(BaseModel):
386
  valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
387
  for category in v.keys():
388
  if category not in valid_categories:
389
- raise ValueError(f"Invalid photo category: {category}. Must be one of: {valid_categories}")
390
  return v
391
 
392
 
@@ -408,313 +177,56 @@ class TreeUpdate(BaseModel):
408
  notes: Optional[str] = Field(None, max_length=2000)
409
 
410
 
411
- class StatsResponse(BaseModel):
412
- total_trees: int
413
- species_distribution: list[dict[str, Any]]
414
- health_distribution: list[dict[str, Any]]
415
- average_height: float
416
- average_diameter: float
417
- last_updated: str
418
-
419
-
420
- # Initialize database on startup and restore if needed
421
- def initialize_app():
422
- """Initialize application with database restoration for persistent storage"""
423
- try:
424
- # HF Spaces provides /data as a persistent volume that survives container restarts
425
- persistent_db_path = Path("/data/trees.db")
426
- local_db_path = Path("data/trees.db")
427
-
428
- # Ensure local directory exists
429
- local_db_path.parent.mkdir(parents=True, exist_ok=True)
430
-
431
- # Try to ensure persistent directory exists (may fail on some systems)
432
- try:
433
- persistent_db_path.parent.mkdir(parents=True, exist_ok=True)
434
- logger.info("Persistent storage directory available at /data")
435
- except (PermissionError, OSError) as e:
436
- logger.warning(f"Cannot create /data directory: {e}. Using local backup strategy.")
437
- # Fallback to local backup strategy only
438
- persistent_db_path = None
439
-
440
- # Check if we have a persistent database in /data (if available)
441
- if persistent_db_path and persistent_db_path.exists():
442
- logger.info("Found persistent database, copying to local directory...")
443
- shutil.copy2(persistent_db_path, local_db_path)
444
- with sqlite3.connect(local_db_path) as conn:
445
- cursor = conn.cursor()
446
- cursor.execute("SELECT COUNT(*) FROM trees")
447
- tree_count = cursor.fetchone()[0]
448
- logger.info(f"Database restored from persistent storage: {tree_count} trees")
449
- else:
450
- # Check for backup files in multiple locations
451
- backup_locations = [
452
- Path("trees_database.db"), # Root level backup
453
- Path("static/trees_database.db"), # Static backup
454
- ]
455
-
456
- backup_restored = False
457
- for backup_path in backup_locations:
458
- if backup_path.exists():
459
- logger.info(f"Found backup at {backup_path}, restoring...")
460
- shutil.copy2(backup_path, local_db_path)
461
- # Try to save to persistent storage if available
462
- if persistent_db_path:
463
- try:
464
- shutil.copy2(backup_path, persistent_db_path)
465
- except (PermissionError, OSError):
466
- logger.warning("Cannot write to persistent storage")
467
- backup_restored = True
468
- break
469
-
470
- if backup_restored:
471
- with sqlite3.connect(local_db_path) as conn:
472
- cursor = conn.cursor()
473
- cursor.execute("SELECT COUNT(*) FROM trees")
474
- tree_count = cursor.fetchone()[0]
475
- logger.info(f"Database restored from backup: {tree_count} trees")
476
-
477
- # Initialize database (creates tables if they don't exist)
478
- init_db()
479
-
480
- # Initial backup to persistent storage (if available)
481
- if persistent_db_path:
482
- _backup_to_persistent_storage()
483
- else:
484
- logger.info("Using local backup strategy only (no persistent storage)")
485
-
486
- # Log current status
487
- if local_db_path.exists():
488
- with get_db_connection() as conn:
489
- cursor = conn.cursor()
490
- cursor.execute("SELECT COUNT(*) FROM trees")
491
- tree_count = cursor.fetchone()[0]
492
- logger.info(f"TreeTrack initialized with {tree_count} trees")
493
-
494
- except Exception as e:
495
- logger.error(f"Application initialization failed: {e}")
496
- # Still try to initialize database with empty state
497
- init_db()
498
-
499
- # Initialize app with restoration capabilities
500
- initialize_app()
501
-
502
- def _backup_to_persistent_storage():
503
- """Backup database to HF Spaces persistent /data volume"""
504
- try:
505
- source_db = Path("data/trees.db")
506
- persistent_db = Path("/data/trees.db")
507
-
508
- if source_db.exists():
509
- # Try to ensure /data directory exists
510
- try:
511
- persistent_db.parent.mkdir(parents=True, exist_ok=True)
512
- shutil.copy2(source_db, persistent_db)
513
- logger.info(f"Database backed up to persistent storage: {persistent_db}")
514
- return True
515
- except (PermissionError, OSError) as e:
516
- logger.warning(f"Cannot backup to persistent storage: {e}")
517
- return False
518
- except Exception as e:
519
- logger.error(f"Persistent storage backup failed: {e}")
520
- return False
521
-
522
- def backup_database():
523
- """Backup database to accessible locations (HF Spaces compatible)"""
524
  try:
525
- source_db = Path("data/trees.db")
526
- if not source_db.exists():
527
- logger.warning("Source database does not exist")
528
- return False
529
-
530
- # 1. MOST IMPORTANT: Backup to persistent storage (/data)
531
- _backup_to_persistent_storage()
532
-
533
- # 2. Copy database to static directory for direct HTTP access
534
- static_db = Path("static/trees_database.db")
535
- shutil.copy2(source_db, static_db)
536
-
537
- # 3. Also copy to root level (in case git access works)
538
- root_db = Path("trees_database.db")
539
- shutil.copy2(source_db, root_db)
540
-
541
- # 4. Export to CSV in multiple locations
542
- static_csv = Path("static/trees_backup.csv")
543
- root_csv = Path("trees_backup.csv")
544
- _export_trees_to_csv(static_csv)
545
- _export_trees_to_csv(root_csv)
546
-
547
- # 5. Create comprehensive status files
548
- static_status = Path("static/database_status.txt")
549
- root_status = Path("database_status.txt")
550
- tree_count = _create_status_file(static_status, source_db)
551
- _create_status_file(root_status, source_db)
552
 
553
- # 6. Try git commit (may fail in HF Spaces but that's okay)
554
- if _is_docker_environment():
555
- git_success = _git_commit_backup([root_db, root_csv, root_status], tree_count)
556
- if git_success:
557
- logger.info(f"Database backed up to all locations including persistent storage: {tree_count} trees")
558
- else:
559
- logger.info(f"Database backed up to static files and persistent storage: {tree_count} trees")
560
- else:
561
- logger.info(f"Database backed up locally: {tree_count} trees")
562
-
563
- return True
564
 
565
- except Exception as e:
566
- logger.error(f"Database backup failed: {e}")
567
- return False
568
-
569
-
570
- def _export_trees_to_csv(csv_path: Path):
571
- """Export all trees to CSV format"""
572
- try:
573
- with get_db_connection() as conn:
574
- cursor = conn.cursor()
575
- cursor.execute("""
576
- SELECT id, latitude, longitude, local_name, scientific_name,
577
- common_name, tree_code, height, width, utility,
578
- storytelling_text, storytelling_audio, phenology_stages,
579
- photographs, notes, timestamp, created_by, updated_at
580
- FROM trees ORDER BY id
581
- """)
582
- trees = cursor.fetchall()
583
-
584
- import csv
585
- with open(csv_path, 'w', newline='', encoding='utf-8') as csvfile:
586
- writer = csv.writer(csvfile)
587
- # Header
588
- writer.writerow([
589
- 'id', 'latitude', 'longitude', 'local_name', 'scientific_name',
590
- 'common_name', 'tree_code', 'height', 'width', 'utility',
591
- 'storytelling_text', 'storytelling_audio', 'phenology_stages',
592
- 'photographs', 'notes', 'timestamp', 'created_by', 'updated_at'
593
- ])
594
- # Data
595
- writer.writerows(trees)
596
-
597
- logger.info(f"CSV backup created: {csv_path}")
598
- except Exception as e:
599
- logger.error(f"CSV export failed: {e}")
600
-
601
-
602
- def _create_status_file(status_file: Path, source_db: Path) -> int:
603
- """Create database status file and return tree count"""
604
- try:
605
- with get_db_connection() as conn:
606
- cursor = conn.cursor()
607
- cursor.execute("SELECT COUNT(*) FROM trees")
608
- tree_count = cursor.fetchone()[0]
609
-
610
- cursor.execute("SELECT COUNT(DISTINCT scientific_name) FROM trees WHERE scientific_name IS NOT NULL")
611
- unique_species = cursor.fetchone()[0]
612
-
613
- cursor.execute("SELECT MAX(timestamp) FROM trees")
614
- last_update = cursor.fetchone()[0] or "Never"
615
-
616
- with open(status_file, 'w', encoding='utf-8') as f:
617
- f.write("=== TreeTrack Database Status ===\n")
618
- f.write(f"Last Backup: {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
619
- f.write(f"Environment: {'Docker/HF Spaces' if _is_docker_environment() else 'Development'}\n")
620
- f.write(f"Database File: trees_database.db\n")
621
- f.write(f"CSV Export: trees_backup.csv\n")
622
- f.write(f"Database Size: {source_db.stat().st_size:,} bytes\n")
623
- f.write(f"Total Trees: {tree_count:,}\n")
624
- f.write(f"Unique Species: {unique_species}\n")
625
- f.write(f"Last Tree Added: {last_update}\n")
626
- f.write(f"\n=== Usage ===\n")
627
- f.write(f"• Download 'trees_database.db' for SQLite access\n")
628
- f.write(f"• View 'trees_backup.csv' for spreadsheet format\n")
629
- f.write(f"• Auto-backup occurs after each tree operation\n")
630
- f.write(f"• Data persists across Docker container restarts\n")
631
-
632
- return tree_count
633
- except Exception as e:
634
- logger.error(f"Status file creation failed: {e}")
635
- return 0
636
-
637
-
638
- def _is_docker_environment() -> bool:
639
- """Check if running in Docker environment (HF Spaces)"""
640
- return (
641
- os.path.exists('/.dockerenv') or
642
- os.getenv('SPACE_ID') is not None or
643
- '/app' in os.getcwd()
644
- )
645
-
646
-
647
- def _git_commit_backup(files: list, tree_count: int) -> bool:
648
- """Commit backup files to git repository using Docker-native approach"""
649
- try:
650
- import subprocess
651
-
652
- # Setup git config if needed
653
- try:
654
- subprocess.run(['git', 'config', 'user.name', 'TreeTrack Bot'],
655
- check=True, capture_output=True, text=True)
656
- subprocess.run(['git', 'config', 'user.email', '[email protected]'],
657
- check=True, capture_output=True, text=True)
658
- except:
659
- pass # Git config might already be set
660
-
661
- # Add backup files to git
662
- for file_path in files:
663
- if file_path.exists():
664
- subprocess.run(['git', 'add', str(file_path)], check=True)
665
-
666
- # Check if there are changes to commit
667
- result = subprocess.run(['git', 'diff', '--staged', '--quiet'],
668
- capture_output=True)
669
-
670
- if result.returncode == 0: # No changes
671
- logger.info("No database changes to commit")
672
- return True
673
-
674
- # Create commit message with tree count and timestamp
675
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M UTC')
676
- commit_message = f"TreeTrack Auto-backup: {tree_count:,} trees - {timestamp}"
677
-
678
- # Commit changes
679
- subprocess.run(['git', 'commit', '-m', commit_message],
680
- check=True, capture_output=True, text=True)
681
-
682
- logger.info(f"Database backup committed to git: {tree_count} trees")
683
-
684
- # Note: HF Spaces automatically syncs commits to the repository
685
- # No need to explicitly push
686
 
687
- return True
 
 
688
 
689
- except subprocess.CalledProcessError as e:
690
- logger.error(f"Git commit failed: {e.stderr if hasattr(e, 'stderr') else str(e)}")
691
- return False
692
  except Exception as e:
693
- logger.error(f"Git backup failed: {e}")
694
- return False
695
 
696
 
697
- # Health check endpoint
698
  @app.get("/health", tags=["Health"])
699
  async def health_check():
700
  """Health check endpoint"""
701
  try:
702
- with get_db_connection() as conn:
703
- cursor = conn.cursor()
704
- cursor.execute("SELECT 1")
705
- cursor.fetchone()
706
-
707
  return {
708
- "status": "healthy",
 
 
709
  "timestamp": datetime.now().isoformat(),
710
- "version": "2.0.0",
711
  }
712
  except Exception as e:
713
  logger.error(f"Health check failed: {e}")
714
- raise
 
 
 
 
715
 
716
 
717
- # API Routes with enhanced error handling
718
  @app.get("/", response_class=HTMLResponse, tags=["Frontend"])
719
  async def read_root():
720
  """Serve the main application page"""
@@ -724,73 +236,24 @@ async def read_root():
724
  return HTMLResponse(content=content)
725
  except FileNotFoundError:
726
  logger.error("index.html not found")
727
- raise
728
- except Exception as e:
729
- logger.error(f"Error serving frontend: {e}")
730
- raise
731
 
732
 
733
  @app.get("/map", response_class=HTMLResponse, tags=["Frontend"])
734
- @app.get("/static/map.html", response_class=HTMLResponse, tags=["Frontend"])
735
  async def serve_map():
736
- """Serve the map page directly through FastAPI to bypass static caching"""
737
- try:
738
- with open("static/map.html", encoding="utf-8") as f:
739
- content = f.read()
740
- # Force no-cache headers
741
- response = HTMLResponse(content=content)
742
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
743
- response.headers["Pragma"] = "no-cache"
744
- response.headers["Expires"] = "0"
745
- return response
746
- except FileNotFoundError:
747
- logger.error("map.html not found")
748
- raise HTTPException(status_code=404, detail="Map page not found")
749
- except Exception as e:
750
- logger.error(f"Error serving map page: {e}")
751
- raise
752
-
753
-
754
- @app.get("/debug/file-content", tags=["Debug"])
755
- async def debug_file_content():
756
- """Debug endpoint to show actual file contents"""
757
- try:
758
- with open("static/map.html", encoding="utf-8") as f:
759
- content = f.read()
760
-
761
- # Extract key indicators
762
- has_fire_emoji = "" in content
763
- has_v4 = "V4.0" in content
764
- has_blue_theme = "#3b82f6" in content
765
- has_red_border = "#ff0000" in content
766
-
767
- return {
768
- "file_exists": True,
769
- "content_length": len(content),
770
- "indicators": {
771
- "has_fire_emoji": has_fire_emoji,
772
- "has_v4_version": has_v4,
773
- "has_blue_theme": has_blue_theme,
774
- "has_red_debug_border": has_red_border
775
- },
776
- "first_200_chars": content[:200],
777
- "title_line": next((line.strip() for line in content.split('\n') if '<title>' in line), "not found")
778
- }
779
- except Exception as e:
780
- return {
781
- "file_exists": False,
782
- "error": str(e)
783
- }
784
 
785
 
786
- @app.get("/api/trees", response_model=list[Tree], tags=["Trees"])
 
787
  async def get_trees(
788
  limit: int = 100,
789
  offset: int = 0,
790
  species: str = None,
791
  health_status: str = None,
792
  ):
793
- # Add validation inside the function
794
  if limit < 1 or limit > settings.server.max_trees_per_request:
795
  raise HTTPException(
796
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -801,274 +264,132 @@ async def get_trees(
801
  status_code=status.HTTP_400_BAD_REQUEST,
802
  detail="Offset must be non-negative",
803
  )
 
804
  try:
805
- with get_db_connection() as conn:
806
- cursor = conn.cursor()
807
-
808
- # Build query with optional filters
809
- query = "SELECT * FROM trees WHERE 1=1"
810
- params = []
811
-
812
- if species:
813
- query += " AND species LIKE ?"
814
- params.append(f"%{species}%")
815
-
816
- if health_status:
817
- query += " AND health_status = ?"
818
- params.append(health_status)
819
-
820
- query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
821
- params.extend([limit, offset])
822
-
823
- cursor.execute(query, params)
824
- rows = cursor.fetchall()
825
-
826
- # Parse JSON fields for each tree
827
- trees = []
828
- for row in rows:
829
- tree_data = dict(row)
830
-
831
- # Parse JSON fields back to Python objects
832
- if tree_data.get('utility'):
833
- try:
834
- tree_data['utility'] = json.loads(tree_data['utility'])
835
- except (json.JSONDecodeError, TypeError):
836
- tree_data['utility'] = None
837
-
838
- if tree_data.get('phenology_stages'):
839
- try:
840
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
841
- except (json.JSONDecodeError, TypeError):
842
- tree_data['phenology_stages'] = None
843
-
844
- if tree_data.get('photographs'):
845
- try:
846
- tree_data['photographs'] = json.loads(tree_data['photographs'])
847
- except (json.JSONDecodeError, TypeError):
848
- tree_data['photographs'] = None
849
-
850
- trees.append(tree_data)
851
-
852
- logger.info(f"Retrieved {len(trees)} trees")
853
- return trees
854
-
855
  except Exception as e:
856
  logger.error(f"Error retrieving trees: {e}")
857
- raise
858
 
859
 
860
- @app.post(
861
- "/api/trees",
862
- response_model=Tree,
863
- status_code=status.HTTP_201_CREATED,
864
- tags=["Trees"],
865
- )
866
  async def create_tree(tree: TreeCreate):
867
- """Create a new tree record with all 12 fields"""
868
  try:
869
- with get_db_connection() as conn:
870
- cursor = conn.cursor()
871
-
872
- # Convert lists and dicts to JSON strings for database storage
873
- utility_json = json.dumps(tree.utility) if tree.utility else None
874
- phenology_json = json.dumps(tree.phenology_stages) if tree.phenology_stages else None
875
- photographs_json = json.dumps(tree.photographs) if tree.photographs else None
876
-
877
- cursor.execute(
878
- """
879
- INSERT INTO trees (
880
- latitude, longitude, local_name, scientific_name, common_name,
881
- tree_code, height, width, utility, storytelling_text,
882
- storytelling_audio, phenology_stages, photographs, notes
883
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
884
- """,
885
- (
886
- tree.latitude,
887
- tree.longitude,
888
- tree.local_name,
889
- tree.scientific_name,
890
- tree.common_name,
891
- tree.tree_code,
892
- tree.height,
893
- tree.width,
894
- utility_json,
895
- tree.storytelling_text,
896
- tree.storytelling_audio,
897
- phenology_json,
898
- photographs_json,
899
- tree.notes,
900
- ),
901
- )
902
-
903
- tree_id = cursor.lastrowid
904
- conn.commit()
905
-
906
- # Return the created tree with parsed JSON fields
907
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
908
- row = cursor.fetchone()
909
- if not row:
910
- raise HTTPException(
911
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
912
- detail="Failed to retrieve created tree",
913
- )
914
-
915
- # Parse JSON fields back to Python objects
916
- tree_data = dict(row)
917
- if tree_data.get('utility'):
918
- tree_data['utility'] = json.loads(tree_data['utility'])
919
- if tree_data.get('phenology_stages'):
920
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
921
- if tree_data.get('photographs'):
922
- tree_data['photographs'] = json.loads(tree_data['photographs'])
923
-
924
- logger.info(f"Created tree with ID: {tree_id}")
925
-
926
- # Backup database to visible location
927
- backup_database()
928
-
929
- return tree_data
930
-
931
- except sqlite3.IntegrityError as e:
932
- logger.error(f"Database integrity error: {e}")
933
- raise
934
  except Exception as e:
935
  logger.error(f"Error creating tree: {e}")
936
- raise
937
 
938
 
939
  @app.get("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
940
  async def get_tree(tree_id: int):
941
  """Get a specific tree by ID"""
942
  try:
943
- with get_db_connection() as conn:
944
- cursor = conn.cursor()
945
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
946
- tree = cursor.fetchone()
947
-
948
- if tree is None:
949
- raise HTTPException(
950
- status_code=status.HTTP_404_NOT_FOUND,
951
- detail=f"Tree with ID {tree_id} not found",
952
- )
953
-
954
- # Parse JSON fields back to Python objects
955
- tree_data = dict(tree)
956
- if tree_data.get('utility'):
957
- try:
958
- tree_data['utility'] = json.loads(tree_data['utility'])
959
- except (json.JSONDecodeError, TypeError):
960
- tree_data['utility'] = None
961
-
962
- if tree_data.get('phenology_stages'):
963
- try:
964
- tree_data['phenology_stages'] = json.loads(tree_data['phenology_stages'])
965
- except (json.JSONDecodeError, TypeError):
966
- tree_data['phenology_stages'] = None
967
-
968
- if tree_data.get('photographs'):
969
- try:
970
- tree_data['photographs'] = json.loads(tree_data['photographs'])
971
- except (json.JSONDecodeError, TypeError):
972
- tree_data['photographs'] = None
973
-
974
- return tree_data
975
-
976
  except HTTPException:
977
  raise
978
  except Exception as e:
979
  logger.error(f"Error retrieving tree {tree_id}: {e}")
980
- raise
981
 
982
 
983
  @app.put("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
984
- async def update_tree(tree_id: int, tree_update: TreeUpdate = None):
985
  """Update a tree record"""
986
- if not tree_update:
987
- raise HTTPException(
988
- status_code=status.HTTP_400_BAD_REQUEST, detail="No update data provided"
989
- )
990
-
991
  try:
992
- with get_db_connection() as conn:
993
- cursor = conn.cursor()
994
-
995
- # Check if tree exists
996
- cursor.execute("SELECT id FROM trees WHERE id = ?", (tree_id,))
997
- if not cursor.fetchone():
998
- raise HTTPException(
999
- status_code=status.HTTP_404_NOT_FOUND,
1000
- detail=f"Tree with ID {tree_id} not found",
1001
- )
1002
-
1003
- # Build update query dynamically
1004
- update_fields = []
1005
- params = []
1006
-
1007
- for field, value in tree_update.model_dump(exclude_unset=True).items():
1008
- if value is not None:
1009
- update_fields.append(f"{field} = ?")
1010
- params.append(value)
1011
-
1012
- if not update_fields:
1013
- raise HTTPException(
1014
- status_code=status.HTTP_400_BAD_REQUEST,
1015
- detail="No valid fields to update",
1016
- )
1017
-
1018
- params.append(tree_id)
1019
- query = f"UPDATE trees SET {', '.join(update_fields)} WHERE id = ?"
1020
-
1021
- cursor.execute(query, params)
1022
- conn.commit()
1023
-
1024
- # Return updated tree
1025
- cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
1026
- updated_tree = cursor.fetchone()
1027
-
1028
- logger.info(f"Updated tree with ID: {tree_id}")
1029
-
1030
- # Backup database to visible location
1031
- backup_database()
1032
-
1033
- return dict(updated_tree)
1034
-
1035
  except HTTPException:
1036
  raise
1037
- except sqlite3.IntegrityError as e:
1038
- logger.error(f"Database integrity error updating tree {tree_id}: {e}")
1039
- raise
1040
  except Exception as e:
1041
  logger.error(f"Error updating tree {tree_id}: {e}")
1042
- raise
1043
 
1044
 
1045
  @app.delete("/api/trees/{tree_id}", tags=["Trees"])
1046
  async def delete_tree(tree_id: int):
1047
  """Delete a tree record"""
1048
  try:
1049
- with get_db_connection() as conn:
1050
- cursor = conn.cursor()
1051
- cursor.execute("DELETE FROM trees WHERE id = ?", (tree_id,))
1052
-
1053
- if cursor.rowcount == 0:
1054
- raise HTTPException(
1055
- status_code=status.HTTP_404_NOT_FOUND,
1056
- detail=f"Tree with ID {tree_id} not found",
1057
- )
1058
-
1059
- conn.commit()
1060
- logger.info(f"Deleted tree with ID: {tree_id}")
 
 
 
 
 
 
1061
 
1062
- # Backup database to visible location
1063
- backup_database()
1064
-
1065
- return {"message": f"Tree {tree_id} deleted successfully"}
1066
-
 
 
 
1067
  except HTTPException:
1068
  raise
1069
  except Exception as e:
1070
  logger.error(f"Error deleting tree {tree_id}: {e}")
1071
- raise
1072
 
1073
 
1074
  # File Upload Endpoints
@@ -1077,13 +398,10 @@ async def upload_image(
1077
  file: UploadFile = File(...),
1078
  category: str = Form(...)
1079
  ):
1080
- """Upload an image file for tree documentation"""
1081
  # Validate file type
1082
  if not file.content_type or not file.content_type.startswith('image/'):
1083
- raise HTTPException(
1084
- status_code=400,
1085
- detail="File must be an image"
1086
- )
1087
 
1088
  # Validate category
1089
  valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
@@ -1094,83 +412,81 @@ async def upload_image(
1094
  )
1095
 
1096
  try:
1097
- # Generate unique filename
1098
- file_id = str(uuid.uuid4())
1099
- file_extension = Path(file.filename).suffix.lower()
1100
- filename = f"{file_id}{file_extension}"
1101
- file_path = Path("uploads/images") / filename
1102
 
1103
- # Save file
1104
- async with aiofiles.open(file_path, 'wb') as f:
1105
- content = await file.read()
1106
- await f.write(content)
1107
 
1108
- logger.info(f"Image uploaded: {filename}, category: {category}")
1109
 
1110
  return {
1111
- "filename": filename,
1112
- "file_path": str(file_path),
1113
  "category": category,
1114
- "size": len(content),
1115
- "content_type": file.content_type
 
 
1116
  }
1117
 
1118
  except Exception as e:
1119
  logger.error(f"Error uploading image: {e}")
1120
- raise HTTPException(status_code=500, detail="Failed to upload image")
1121
 
1122
 
1123
  @app.post("/api/upload/audio", tags=["Files"])
1124
  async def upload_audio(file: UploadFile = File(...)):
1125
- """Upload an audio file for storytelling"""
1126
  # Validate file type
1127
  if not file.content_type or not file.content_type.startswith('audio/'):
1128
- raise HTTPException(
1129
- status_code=400,
1130
- detail="File must be an audio file"
1131
- )
1132
 
1133
  try:
1134
- # Generate unique filename
1135
- file_id = str(uuid.uuid4())
1136
- file_extension = Path(file.filename).suffix.lower()
1137
- filename = f"{file_id}{file_extension}"
1138
- file_path = Path("uploads/audio") / filename
1139
 
1140
- # Save file
1141
- async with aiofiles.open(file_path, 'wb') as f:
1142
- content = await file.read()
1143
- await f.write(content)
1144
 
1145
- logger.info(f"Audio uploaded: {filename}")
1146
 
1147
  return {
1148
- "filename": filename,
1149
- "file_path": str(file_path),
1150
- "size": len(content),
1151
- "content_type": file.content_type
 
1152
  }
1153
 
1154
  except Exception as e:
1155
  logger.error(f"Error uploading audio: {e}")
1156
- raise HTTPException(status_code=500, detail="Failed to upload audio")
1157
 
1158
 
1159
- @app.get("/api/files/{file_type}/{filename}", tags=["Files"])
1160
- async def get_file(file_type: str, filename: str):
1161
- """Serve uploaded files"""
1162
- if file_type not in ["images", "audio"]:
1163
- raise HTTPException(status_code=400, detail="Invalid file type")
1164
-
1165
- file_path = Path(f"uploads/{file_type}/{filename}")
1166
-
1167
- if not file_path.exists():
1168
- raise HTTPException(status_code=404, detail="File not found")
1169
-
1170
- return FileResponse(file_path)
 
 
 
 
 
 
 
 
 
1171
 
1172
 
1173
- # Utility endpoints for form data
1174
  @app.get("/api/utilities", tags=["Data"])
1175
  async def get_utilities():
1176
  """Get list of valid utility options"""
@@ -1201,7 +517,31 @@ async def get_photo_categories():
1201
  }
1202
 
1203
 
1204
- # Auto-suggestion endpoints for master tree database
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
  @app.get("/api/tree-suggestions", tags=["Data"])
1206
  async def get_tree_suggestions_api(query: str = "", limit: int = 10):
1207
  """Get auto-suggestions for tree names from master database"""
@@ -1209,9 +549,7 @@ async def get_tree_suggestions_api(query: str = "", limit: int = 10):
1209
  return {"suggestions": []}
1210
 
1211
  try:
1212
- # Initialize master database if it doesn't exist
1213
  create_master_tree_database()
1214
-
1215
  suggestions = get_tree_suggestions(query.strip(), limit)
1216
 
1217
  return {
@@ -1229,9 +567,7 @@ async def get_tree_suggestions_api(query: str = "", limit: int = 10):
1229
  async def get_tree_codes_api():
1230
  """Get all available tree codes from master database"""
1231
  try:
1232
- # Initialize master database if it doesn't exist
1233
  create_master_tree_database()
1234
-
1235
  tree_codes = get_all_tree_codes()
1236
 
1237
  return {
@@ -1244,300 +580,22 @@ async def get_tree_codes_api():
1244
  return {"tree_codes": [], "error": str(e)}
1245
 
1246
 
1247
- @app.get("/api/master-database/status", tags=["System"])
1248
- async def get_master_database_status():
1249
- """Get status of master tree species database"""
1250
- try:
1251
- master_db_path = Path("data/master_trees.db")
1252
-
1253
- if not master_db_path.exists():
1254
- return {
1255
- "exists": False,
1256
- "initialized": False,
1257
- "species_count": 0,
1258
- "message": "Master database not initialized"
1259
- }
1260
-
1261
- # Check database content
1262
- import sqlite3
1263
- conn = sqlite3.connect(master_db_path)
1264
- cursor = conn.cursor()
1265
-
1266
- cursor.execute("SELECT COUNT(*) FROM master_species")
1267
- species_count = cursor.fetchone()[0]
1268
-
1269
- cursor.execute("SELECT COUNT(DISTINCT tree_code) FROM master_species WHERE tree_code != ''")
1270
- unique_codes = cursor.fetchone()[0]
1271
-
1272
- conn.close()
1273
-
1274
- return {
1275
- "exists": True,
1276
- "initialized": True,
1277
- "species_count": species_count,
1278
- "unique_codes": unique_codes,
1279
- "database_size": master_db_path.stat().st_size,
1280
- "message": f"Master database contains {species_count} species with {unique_codes} unique codes"
1281
- }
1282
-
1283
- except Exception as e:
1284
- logger.error(f"Error checking master database status: {e}")
1285
- return {
1286
- "exists": False,
1287
- "initialized": False,
1288
- "error": str(e)
1289
- }
1290
-
1291
-
1292
- # Direct file download endpoints
1293
- @app.get("/download/database", tags=["Downloads"])
1294
- async def download_database():
1295
- """Download the SQLite database file"""
1296
- db_file = Path("static/trees_database.db")
1297
- if not db_file.exists():
1298
- # Try root location
1299
- db_file = Path("trees_database.db")
1300
- if not db_file.exists():
1301
- raise HTTPException(status_code=404, detail="Database file not found")
1302
-
1303
- return FileResponse(
1304
- path=str(db_file),
1305
- filename="trees_database.db",
1306
- media_type="application/x-sqlite3"
1307
- )
1308
-
1309
- @app.get("/download/csv", tags=["Downloads"])
1310
- async def download_csv():
1311
- """Download the CSV backup file"""
1312
- csv_file = Path("static/trees_backup.csv")
1313
- if not csv_file.exists():
1314
- # Try root location
1315
- csv_file = Path("trees_backup.csv")
1316
- if not csv_file.exists():
1317
- raise HTTPException(status_code=404, detail="CSV file not found")
1318
-
1319
- return FileResponse(
1320
- path=str(csv_file),
1321
- filename="trees_backup.csv",
1322
- media_type="text/csv"
1323
- )
1324
-
1325
- @app.get("/download/status", tags=["Downloads"])
1326
- async def download_status():
1327
- """Download the status report file"""
1328
- status_file = Path("static/database_status.txt")
1329
- if not status_file.exists():
1330
- # Try root location
1331
- status_file = Path("database_status.txt")
1332
- if not status_file.exists():
1333
- raise HTTPException(status_code=404, detail="Status file not found")
1334
-
1335
- return FileResponse(
1336
- path=str(status_file),
1337
- filename="database_status.txt",
1338
- media_type="text/plain"
1339
- )
1340
-
1341
- @app.get("/api/persistence/status", tags=["System"])
1342
- async def get_persistence_status():
1343
- """Get database backup system status"""
1344
- try:
1345
- # Check if database exists
1346
- db_path = Path("data/trees.db")
1347
- backup_path = Path("trees_database.db")
1348
-
1349
- if not db_path.exists():
1350
- return {
1351
- "enabled": False,
1352
- "status": "no_database",
1353
- "message": "No database found yet"
1354
- }
1355
-
1356
- # Get database info
1357
- with get_db_connection() as conn:
1358
- cursor = conn.cursor()
1359
- cursor.execute("SELECT COUNT(*) FROM trees")
1360
- tree_count = cursor.fetchone()[0]
1361
-
1362
- return {
1363
- "enabled": True,
1364
- "status": "healthy",
1365
- "stats": {
1366
- "database_size": db_path.stat().st_size,
1367
- "total_trees": tree_count,
1368
- "backup_exists": backup_path.exists(),
1369
- "last_backup": backup_path.stat().st_mtime if backup_path.exists() else None
1370
- },
1371
- "message": f"Database backup system active - {tree_count} trees stored"
1372
- }
1373
- except Exception as e:
1374
- return {
1375
- "enabled": False,
1376
- "status": "error",
1377
- "message": f"Backup system error: {str(e)}"
1378
- }
1379
-
1380
-
1381
- @app.post("/api/persistence/backup", tags=["System"])
1382
- async def force_backup():
1383
- """Force an immediate database backup"""
1384
- success = backup_database()
1385
-
1386
- return {
1387
- "success": success,
1388
- "message": "Database backed up successfully" if success else "Backup failed",
1389
- "timestamp": datetime.now().isoformat()
1390
- }
1391
-
1392
-
1393
- @app.get("/api/stats", response_model=StatsResponse, tags=["Statistics"])
1394
- async def get_stats():
1395
- """Get comprehensive tree statistics"""
1396
- try:
1397
- with get_db_connection() as conn:
1398
- cursor = conn.cursor()
1399
-
1400
- # Total trees
1401
- cursor.execute("SELECT COUNT(*) as total FROM trees")
1402
- total = cursor.fetchone()[0]
1403
-
1404
- # Trees by species (limit to top 20)
1405
- cursor.execute("""
1406
- SELECT species, COUNT(*) as count
1407
- FROM trees
1408
- GROUP BY species
1409
- ORDER BY count DESC
1410
- LIMIT 20
1411
- """)
1412
- species_stats = [
1413
- {"species": row[0], "count": row[1]} for row in cursor.fetchall()
1414
- ]
1415
-
1416
- # Trees by health status
1417
- cursor.execute("""
1418
- SELECT health_status, COUNT(*) as count
1419
- FROM trees
1420
- GROUP BY health_status
1421
- ORDER BY count DESC
1422
- """)
1423
- health_stats = [
1424
- {"status": row[0], "count": row[1]} for row in cursor.fetchall()
1425
- ]
1426
-
1427
- # Average measurements
1428
- cursor.execute("""
1429
- SELECT
1430
- COALESCE(AVG(height), 0) as avg_height,
1431
- COALESCE(AVG(diameter), 0) as avg_diameter
1432
- FROM trees
1433
- WHERE height IS NOT NULL AND diameter IS NOT NULL
1434
- """)
1435
- avg_stats = cursor.fetchone()
1436
-
1437
- return StatsResponse(
1438
- total_trees=total,
1439
- species_distribution=species_stats,
1440
- health_distribution=health_stats,
1441
- average_height=round(avg_stats[0], 2) if avg_stats[0] else 0,
1442
- average_diameter=round(avg_stats[1], 2) if avg_stats[1] else 0,
1443
- last_updated=datetime.now().isoformat(),
1444
- )
1445
-
1446
- except Exception as e:
1447
- logger.error(f"Error retrieving statistics: {e}")
1448
- raise
1449
-
1450
-
1451
- # Version management endpoints
1452
  @app.get("/api/version", tags=["System"])
1453
  async def get_version():
1454
- """Get current application version and asset versions"""
1455
- try:
1456
- version_file = Path("version.json")
1457
- if version_file.exists():
1458
- async with aiofiles.open(version_file, 'r') as f:
1459
- content = await f.read()
1460
- version_data = json.loads(content)
1461
- else:
1462
- # Fallback version data
1463
- version_data = {
1464
- "version": "3.0",
1465
- "timestamp": int(time.time()),
1466
- "build": "development",
1467
- "commit": "local"
1468
- }
1469
-
1470
- version_data["server_time"] = datetime.now().isoformat()
1471
- return version_data
1472
-
1473
- except Exception as e:
1474
- logger.error(f"Error reading version: {e}")
1475
- return {
1476
- "version": "unknown",
1477
- "timestamp": int(time.time()),
1478
- "build": "error",
1479
- "error": str(e)
1480
- }
1481
-
1482
-
1483
- @app.post("/api/version/update", tags=["System"])
1484
- async def update_version():
1485
- """Force update version and clear cache"""
1486
- try:
1487
- # Update version timestamp
1488
- timestamp = int(time.time())
1489
- version_number = f"3.{timestamp}"
1490
-
1491
- version_data = {
1492
- "version": version_number,
1493
- "timestamp": timestamp,
1494
- "build": "development",
1495
- "commit": "local",
1496
- "updated_by": "api"
1497
- }
1498
-
1499
- # Write version file
1500
- version_file = Path("version.json")
1501
- async with aiofiles.open(version_file, 'w') as f:
1502
- await f.write(json.dumps(version_data, indent=4))
1503
-
1504
- logger.info(f"Version updated to {version_number}")
1505
-
1506
- return {
1507
- "success": True,
1508
- "message": "Version updated successfully",
1509
- "new_version": version_number,
1510
- "timestamp": timestamp,
1511
- "instructions": [
1512
- "Clear browser cache: Ctrl+Shift+R (Windows) / Cmd+Shift+R (Mac)",
1513
- "Or open DevTools > Application > Service Workers > Unregister"
1514
- ]
1515
- }
1516
-
1517
- except Exception as e:
1518
- logger.error(f"Error updating version: {e}")
1519
- return {
1520
- "success": False,
1521
- "error": str(e)
1522
- }
1523
-
1524
-
1525
- # Error handlers for better error responses
1526
- @app.exception_handler(404)
1527
- async def not_found_handler(request: Request, exc: Exception):
1528
- return JSONResponse(
1529
- status_code=404,
1530
- content={"detail": "Resource not found", "path": str(request.url.path)},
1531
- )
1532
-
1533
-
1534
- @app.exception_handler(500)
1535
- async def internal_error_handler(request: Request, exc: Exception):
1536
- logger.error(f"Internal server error: {exc}")
1537
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
1538
 
1539
 
1540
  if __name__ == "__main__":
1541
  uvicorn.run(
1542
- "app:app", host="127.0.0.1", port=8000, reload=True, log_level="info"
1543
  )
 
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(
 
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,
 
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)
 
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)
 
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)
 
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
 
 
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"""
 
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,
 
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
 
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"]
 
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"""
 
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"""
 
549
  return {"suggestions": []}
550
 
551
  try:
 
552
  create_master_tree_database()
 
553
  suggestions = get_tree_suggestions(query.strip(), limit)
554
 
555
  return {
 
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 {
 
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
  )
app_sqlite_backup.py ADDED
@@ -0,0 +1,1614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )