RoyAalekh commited on
Commit
a27587f
·
1 Parent(s): bb407fe

Add TreeTrack FastAPI application with Docker deployment for HF Spaces

Browse files
Files changed (7) hide show
  1. Dockerfile +27 -0
  2. README.md +58 -4
  3. app.py +679 -0
  4. config.py +402 -0
  5. requirements.txt +0 -0
  6. static/app.js +1159 -0
  7. static/index.html +362 -0
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ # Use Python 3.11 as base image
3
+ FROM python:3.11-slim
4
+
5
+ # Create user for security
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ # Set working directory
11
+ WORKDIR /app
12
+
13
+ # Copy requirements and install dependencies
14
+ COPY --chown=user ./requirements.txt requirements.txt
15
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
16
+
17
+ # Copy application files
18
+ COPY --chown=user . /app
19
+
20
+ # Create data directory
21
+ RUN mkdir -p /app/data && chmod 700 /app/data
22
+
23
+ # Expose port 7860 as required by HF Spaces
24
+ EXPOSE 7860
25
+
26
+ # Command to run the application
27
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,66 @@
1
  ---
2
  title: TreeTrack
3
- emoji: 🐢
4
- colorFrom: gray
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  license: mit
9
- short_description: Tree Mapping tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: TreeTrack
3
+ emoji: 🌳
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: mit
10
+ short_description: Enhanced tree mapping and urban forestry management application
11
+ ---
12
+
13
+ # 🌳 TreeTrack - Enhanced Tree Mapping Application
14
+
15
+ A **secure, robust, and high-performance** web application for mapping, tracking, and managing urban forest data using FastAPI with comprehensive security implementations and best practices.
16
+
17
+ ## 🎯 Features
18
+
19
+ - **Interactive Tree Mapping**: Add, edit, and visualize trees on an interactive map
20
+ - **Comprehensive Data Management**: Track species, health status, dimensions, and more
21
+ - **Advanced Security**: Input validation, SQL injection prevention, XSS protection
22
+ - **Performance Optimized**: Database indexing, caching, and efficient queries
23
+ - **RESTful API**: Full API documentation with FastAPI's automatic OpenAPI docs
24
+ - **Responsive Design**: Works on desktop and mobile devices
25
+
26
+ ## 🚀 Usage
27
+
28
+ 1. **View Trees**: Browse the interactive map to see all registered trees
29
+ 2. **Add New Trees**: Click on the map to add new tree entries with detailed information
30
+ 3. **Edit Trees**: Update existing tree data including health status and measurements
31
+ 4. **API Access**: Use the `/docs` endpoint for full API documentation
32
+ 5. **Statistics**: View comprehensive statistics about your urban forest
33
+
34
+ ## 📊 API Endpoints
35
+
36
+ - `GET /` - Main application interface
37
+ - `GET /docs` - Interactive API documentation
38
+ - `GET /api/trees` - List all trees with filtering
39
+ - `POST /api/trees` - Add new tree
40
+ - `GET /api/trees/{id}` - Get specific tree details
41
+ - `PUT /api/trees/{id}` - Update tree information
42
+ - `DELETE /api/trees/{id}` - Remove tree
43
+ - `GET /api/stats` - Get forest statistics
44
+ - `GET /health` - Application health check
45
+
46
+ ## 🛡️ Security Features
47
+
48
+ - Input validation and sanitization
49
+ - SQL injection prevention
50
+ - XSS protection with Content Security Policy
51
+ - CORS configuration
52
+ - Secure file path validation
53
+ - Comprehensive error handling
54
+
55
+ ## 🔧 Technical Stack
56
+
57
+ - **Backend**: FastAPI (Python 3.11+)
58
+ - **Database**: SQLite with WAL mode
59
+ - **Frontend**: Vanilla JavaScript with modern practices
60
+ - **Mapping**: Interactive maps with marker clustering
61
+ - **Validation**: Pydantic models with custom validators
62
+ - **Security**: Multiple layers of protection
63
+
64
  ---
65
 
66
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Enhanced Tree Mapping FastAPI Application
3
+ Implements security, robustness, performance, and best practices improvements
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ import sqlite3
9
+ import time
10
+ from contextlib import contextmanager
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import uvicorn
16
+ from fastapi import FastAPI, HTTPException, Request, status
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
19
+ from fastapi.responses import HTMLResponse, JSONResponse
20
+ from fastapi.staticfiles import StaticFiles
21
+ from pydantic import BaseModel, Field, field_validator, model_validator
22
+
23
+ from config import get_settings
24
+
25
+ # Configure logging
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
29
+ handlers=[logging.FileHandler("app.log"), logging.StreamHandler()],
30
+ )
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # Import configuration
35
+
36
+
37
+ # Get configuration settings
38
+ settings = get_settings()
39
+
40
+ # Initialize FastAPI app with security headers
41
+ app = FastAPI(
42
+ title="Tree Mapping API",
43
+ description="Secure API for mapping and tracking trees",
44
+ version="2.0.0",
45
+ docs_url="/docs",
46
+ redoc_url="/redoc",
47
+ )
48
+
49
+ # CORS middleware - Essential for frontend-backend communication
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=settings.security.cors_origins,
53
+ allow_credentials=True,
54
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
55
+ allow_headers=["*"],
56
+ )
57
+
58
+ # TrustedHost middleware - Enable for production deployment
59
+ # app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.security.allowed_hosts)
60
+
61
+
62
+ # Security headers middleware - TEMPORARILY DISABLED FOR DEBUGGING
63
+ # @app.middleware("http")
64
+ # async def add_security_headers(request: Request, call_next):
65
+ # start_time = time.time()
66
+
67
+ # try:
68
+ # response = await call_next(request)
69
+
70
+ # # Add security headers
71
+ # response.headers["X-Content-Type-Options"] = "nosniff"
72
+ # response.headers["X-Frame-Options"] = "DENY"
73
+ # response.headers["X-XSS-Protection"] = "1; mode=block"
74
+ # response.headers["Strict-Transport-Security"] = (
75
+ # "max-age=31536000; includeSubDomains"
76
+ # )
77
+ # response.headers["Content-Security-Policy"] = (
78
+ # "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'
79
+ # )
80
+
81
+ # # Log request
82
+ # process_time = time.time() - start_time
83
+ # logger.info(
84
+ # f"{request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s"
85
+ # )
86
+
87
+ # return response
88
+ # except Exception as e:
89
+ # logger.error(f"Request failed: {request.method} {request.url.path} - {e!s}")
90
+ # raise
91
+
92
+
93
+ # Serve static files
94
+ app.mount("/static", StaticFiles(directory="static"), name="static")
95
+
96
+
97
+ # Database setup with proper error handling
98
+ def ensure_data_directory():
99
+ """Ensure data directory exists with proper permissions"""
100
+ data_dir = Path("data")
101
+ try:
102
+ data_dir.mkdir(exist_ok=True)
103
+ # Set restrictive permissions (owner read/write only)
104
+ data_dir.chmod(0o700)
105
+ logger.info("Data directory initialized")
106
+ except Exception as e:
107
+ logger.error(f"Failed to create data directory: {e}")
108
+ raise
109
+
110
+
111
+ @contextmanager
112
+ def get_db_connection():
113
+ """Context manager for database connections with proper error handling"""
114
+ conn = None
115
+ try:
116
+ conn = sqlite3.connect(
117
+ settings.database.db_path,
118
+ timeout=settings.server.request_timeout,
119
+ check_same_thread=False,
120
+ )
121
+ conn.row_factory = sqlite3.Row
122
+ # Enable foreign key constraints
123
+ conn.execute("PRAGMA foreign_keys = ON")
124
+ # Set journal mode for better concurrency
125
+ conn.execute("PRAGMA journal_mode = WAL")
126
+ yield conn
127
+ except sqlite3.Error as e:
128
+ logger.error(f"Database error: {e}")
129
+ if conn:
130
+ conn.rollback()
131
+ raise
132
+ except Exception as e:
133
+ logger.error(f"Unexpected database error: {e}")
134
+ if conn:
135
+ conn.rollback()
136
+ raise
137
+ finally:
138
+ if conn:
139
+ conn.close()
140
+
141
+
142
+ def init_db():
143
+ """Initialize database with enhanced schema and constraints"""
144
+ ensure_data_directory()
145
+
146
+ try:
147
+ with get_db_connection() as conn:
148
+ cursor = conn.cursor()
149
+
150
+ # Create trees table with constraints
151
+ cursor.execute("""
152
+ CREATE TABLE IF NOT EXISTS trees (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ latitude REAL NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
155
+ longitude REAL NOT NULL CHECK (longitude >= -180 AND longitude <= 180),
156
+ species TEXT NOT NULL CHECK (length(species) > 0 AND length(species) <= 200),
157
+ common_name TEXT CHECK (common_name IS NULL OR length(common_name) <= 200),
158
+ height REAL CHECK (height IS NULL OR (height > 0 AND height <= 200)),
159
+ diameter REAL CHECK (diameter IS NULL OR (diameter > 0 AND diameter <= 1000)),
160
+ health_status TEXT DEFAULT 'Good' CHECK (health_status IN ('Excellent', 'Good', 'Fair', 'Poor', 'Dead')),
161
+ age_estimate INTEGER CHECK (age_estimate IS NULL OR (age_estimate > 0 AND age_estimate <= 5000)),
162
+ notes TEXT CHECK (notes IS NULL OR length(notes) <= 2000),
163
+ last_inspection DATE,
164
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
165
+ created_by TEXT DEFAULT 'system',
166
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
167
+ )
168
+ """)
169
+
170
+ # Create indexes for better performance
171
+ cursor.execute(
172
+ "CREATE INDEX IF NOT EXISTS idx_trees_location ON trees(latitude, longitude)"
173
+ )
174
+ cursor.execute(
175
+ "CREATE INDEX IF NOT EXISTS idx_trees_species ON trees(species)"
176
+ )
177
+ cursor.execute(
178
+ "CREATE INDEX IF NOT EXISTS idx_trees_health ON trees(health_status)"
179
+ )
180
+ cursor.execute(
181
+ "CREATE INDEX IF NOT EXISTS idx_trees_timestamp ON trees(timestamp)"
182
+ )
183
+
184
+ # Create trigger to update updated_at timestamp
185
+ cursor.execute("""
186
+ CREATE TRIGGER IF NOT EXISTS update_trees_timestamp
187
+ AFTER UPDATE ON trees
188
+ BEGIN
189
+ UPDATE trees SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
190
+ END
191
+ """)
192
+
193
+ conn.commit()
194
+ logger.info("Database initialized successfully")
195
+
196
+ except Exception as e:
197
+ logger.error(f"Failed to initialize database: {e}")
198
+ raise
199
+
200
+
201
+ # Enhanced Pydantic models with validation
202
+ class HealthStatus(str):
203
+ """Valid health status values"""
204
+
205
+ EXCELLENT = "Excellent"
206
+ GOOD = "Good"
207
+ FAIR = "Fair"
208
+ POOR = "Poor"
209
+ DEAD = "Dead"
210
+
211
+
212
+ class TreeBase(BaseModel):
213
+ latitude: float = Field(
214
+ ..., ge=-90, le=90, description="Latitude in decimal degrees"
215
+ )
216
+ longitude: float = Field(
217
+ ..., ge=-180, le=180, description="Longitude in decimal degrees"
218
+ )
219
+ species: str = Field(
220
+ ..., min_length=1, max_length=200, description="Scientific species name"
221
+ )
222
+ common_name: str = Field(
223
+ None, max_length=200, description="Common name of the tree"
224
+ )
225
+ height: float = Field(None, gt=0, le=200, description="Height in meters")
226
+ diameter: float = Field(None, gt=0, le=1000, description="Diameter in centimeters")
227
+ health_status: str = Field(default="Good", description="Health status of the tree")
228
+ age_estimate: int = Field(None, gt=0, le=5000, description="Estimated age in years")
229
+ notes: str = Field(None, max_length=2000, description="Additional notes")
230
+ last_inspection: str = Field(
231
+ None, description="Date of last inspection (YYYY-MM-DD)"
232
+ )
233
+
234
+ # Restore validators for data integrity
235
+ @field_validator("species")
236
+ @classmethod
237
+ def validate_species(cls, v):
238
+ if not v or not v.strip():
239
+ raise ValueError("Species cannot be empty")
240
+ # Basic validation for scientific name format
241
+ if not re.match(r"^[A-Za-z\s\-\.]+$", v.strip()):
242
+ raise ValueError("Species name contains invalid characters")
243
+ return v.strip()
244
+
245
+ @field_validator("common_name")
246
+ @classmethod
247
+ def validate_common_name(cls, v):
248
+ if v is not None:
249
+ v = v.strip()
250
+ if not v:
251
+ return None
252
+ if not re.match(r"^[A-Za-z\s\-\.\']+$", v):
253
+ raise ValueError("Common name contains invalid characters")
254
+ return v
255
+
256
+ @field_validator("health_status")
257
+ @classmethod
258
+ def validate_health_status(cls, v):
259
+ valid_statuses = ["Excellent", "Good", "Fair", "Poor", "Dead"]
260
+ if v not in valid_statuses:
261
+ raise ValueError(
262
+ f"Health status must be one of: {', '.join(valid_statuses)}"
263
+ )
264
+ return v
265
+
266
+ @field_validator("notes")
267
+ @classmethod
268
+ def validate_notes(cls, v):
269
+ if v is not None:
270
+ v = v.strip()
271
+ if not v:
272
+ return None
273
+ # Basic XSS prevention
274
+ if "<" in v or ">" in v or "script" in v.lower():
275
+ raise ValueError("Notes contain potentially unsafe content")
276
+ return v
277
+
278
+ @field_validator("last_inspection")
279
+ @classmethod
280
+ def validate_dates(cls, v):
281
+ if v is not None:
282
+ try:
283
+ date_obj = datetime.strptime(v, "%Y-%m-%d")
284
+ if date_obj.date() > datetime.now().date():
285
+ raise ValueError("Date cannot be in the future")
286
+ return v
287
+ except ValueError as e:
288
+ if "does not match format" in str(e):
289
+ raise ValueError("Date must be in YYYY-MM-DD format") from e
290
+ return v
291
+
292
+
293
+
294
+ class Tree(BaseModel):
295
+ id: int
296
+ latitude: float
297
+ longitude: float
298
+ species: str
299
+ common_name: Optional[str] = None
300
+ height: Optional[float] = None
301
+ diameter: Optional[float] = None
302
+ health_status: str = "Good"
303
+ age_estimate: Optional[int] = None
304
+ notes: Optional[str] = None
305
+ last_inspection: Optional[str] = None
306
+ timestamp: str
307
+ created_by: str = "system"
308
+ updated_at: Optional[str] = None
309
+
310
+
311
+ class TreeCreate(BaseModel):
312
+ latitude: float = Field(..., ge=-90, le=90, description="Latitude in decimal degrees")
313
+ longitude: float = Field(..., ge=-180, le=180, description="Longitude in decimal degrees")
314
+ species: str = Field(..., min_length=1, max_length=200, description="Scientific species name")
315
+ common_name: str = Field(None, max_length=200, description="Common name of the tree")
316
+ height: float = Field(None, gt=0, le=200, description="Height in meters")
317
+ diameter: float = Field(None, gt=0, le=1000, description="Diameter in centimeters")
318
+ health_status: str = Field(default="Good", description="Health status of the tree")
319
+ age_estimate: int = Field(None, gt=0, le=5000, description="Estimated age in years")
320
+ notes: str = Field(None, max_length=2000, description="Additional notes")
321
+ last_inspection: str = Field(None, description="Date of last inspection (YYYY-MM-DD)")
322
+
323
+
324
+ class TreeUpdate(BaseModel):
325
+ latitude: float = Field(None, ge=-90, le=90)
326
+ longitude: float = Field(None, ge=-180, le=180)
327
+ species: str = Field(None, min_length=1, max_length=200)
328
+ common_name: str = Field(None, max_length=200)
329
+ height: float = Field(None, gt=0, le=200)
330
+ diameter: float = Field(None, gt=0, le=1000)
331
+ health_status: str = None
332
+ age_estimate: int = Field(None, gt=0, le=5000)
333
+ notes: str = Field(None, max_length=2000)
334
+ last_inspection: str = None
335
+
336
+
337
+ class StatsResponse(BaseModel):
338
+ total_trees: int
339
+ species_distribution: list[dict[str, Any]]
340
+ health_distribution: list[dict[str, Any]]
341
+ average_height: float
342
+ average_diameter: float
343
+ last_updated: str
344
+
345
+
346
+ # Initialize database on startup
347
+ init_db()
348
+
349
+
350
+ # Health check endpoint
351
+ @app.get("/health", tags=["Health"])
352
+ async def health_check():
353
+ """Health check endpoint"""
354
+ try:
355
+ with get_db_connection() as conn:
356
+ cursor = conn.cursor()
357
+ cursor.execute("SELECT 1")
358
+ cursor.fetchone()
359
+
360
+ return {
361
+ "status": "healthy",
362
+ "timestamp": datetime.now().isoformat(),
363
+ "version": "2.0.0",
364
+ }
365
+ except Exception as e:
366
+ logger.error(f"Health check failed: {e}")
367
+ raise
368
+
369
+
370
+ # API Routes with enhanced error handling
371
+ @app.get("/", response_class=HTMLResponse, tags=["Frontend"])
372
+ async def read_root():
373
+ """Serve the main application page"""
374
+ try:
375
+ with open("static/index.html", encoding="utf-8") as f:
376
+ content = f.read()
377
+ return HTMLResponse(content=content)
378
+ except FileNotFoundError:
379
+ logger.error("index.html not found")
380
+ raise
381
+ except Exception as e:
382
+ logger.error(f"Error serving frontend: {e}")
383
+ raise
384
+
385
+
386
+ @app.get("/api/trees", response_model=list[Tree], tags=["Trees"])
387
+ async def get_trees(
388
+ limit: int = 100,
389
+ offset: int = 0,
390
+ species: str = None,
391
+ health_status: str = None,
392
+ ):
393
+ # Add validation inside the function
394
+ if limit < 1 or limit > settings.server.max_trees_per_request:
395
+ raise HTTPException(
396
+ status_code=status.HTTP_400_BAD_REQUEST,
397
+ detail=f"Limit must be between 1 and {settings.server.max_trees_per_request}",
398
+ )
399
+ if offset < 0:
400
+ raise HTTPException(
401
+ status_code=status.HTTP_400_BAD_REQUEST,
402
+ detail="Offset must be non-negative",
403
+ )
404
+ try:
405
+ with get_db_connection() as conn:
406
+ cursor = conn.cursor()
407
+
408
+ # Build query with optional filters
409
+ query = "SELECT * FROM trees WHERE 1=1"
410
+ params = []
411
+
412
+ if species:
413
+ query += " AND species LIKE ?"
414
+ params.append(f"%{species}%")
415
+
416
+ if health_status:
417
+ query += " AND health_status = ?"
418
+ params.append(health_status)
419
+
420
+ query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
421
+ params.extend([limit, offset])
422
+
423
+ cursor.execute(query, params)
424
+ trees = [dict(row) for row in cursor.fetchall()]
425
+
426
+ logger.info(f"Retrieved {len(trees)} trees")
427
+ return trees
428
+
429
+ except Exception as e:
430
+ logger.error(f"Error retrieving trees: {e}")
431
+ raise
432
+
433
+
434
+ @app.post(
435
+ "/api/trees",
436
+ response_model=Tree,
437
+ status_code=status.HTTP_201_CREATED,
438
+ tags=["Trees"],
439
+ )
440
+ async def create_tree(tree: TreeCreate):
441
+ """Create a new tree record"""
442
+ try:
443
+ with get_db_connection() as conn:
444
+ cursor = conn.cursor()
445
+
446
+ # Set last_inspection to today if not provided (not from historical data)
447
+ last_inspection = tree.last_inspection
448
+ if last_inspection is None:
449
+ last_inspection = datetime.now().strftime("%Y-%m-%d")
450
+
451
+ cursor.execute(
452
+ """
453
+ INSERT INTO trees (
454
+ latitude, longitude, species, common_name, height,
455
+ diameter, health_status, age_estimate, notes,
456
+ last_inspection
457
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
458
+ """,
459
+ (
460
+ tree.latitude,
461
+ tree.longitude,
462
+ tree.species,
463
+ tree.common_name,
464
+ tree.height,
465
+ tree.diameter,
466
+ tree.health_status,
467
+ tree.age_estimate,
468
+ tree.notes,
469
+ last_inspection,
470
+ ),
471
+ )
472
+
473
+ tree_id = cursor.lastrowid
474
+ conn.commit()
475
+ # Return the created tree
476
+ cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
477
+ row = cursor.fetchone()
478
+ if not row:
479
+ raise HTTPException(
480
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
481
+ detail="Failed to retrieve created tree",
482
+ )
483
+
484
+ logger.info(f"Created tree with ID: {tree_id}")
485
+ return dict(row)
486
+
487
+ except sqlite3.IntegrityError as e:
488
+ logger.error(f"Database integrity error: {e}")
489
+ raise
490
+ except Exception as e:
491
+ logger.error(f"Error creating tree: {e}")
492
+ raise
493
+
494
+
495
+ @app.get("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
496
+ async def get_tree(tree_id: int):
497
+ """Get a specific tree by ID"""
498
+ try:
499
+ with get_db_connection() as conn:
500
+ cursor = conn.cursor()
501
+ cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
502
+ tree = cursor.fetchone()
503
+
504
+ if tree is None:
505
+ raise HTTPException(
506
+ status_code=status.HTTP_404_NOT_FOUND,
507
+ detail=f"Tree with ID {tree_id} not found",
508
+ )
509
+
510
+ return dict(tree)
511
+
512
+ except HTTPException:
513
+ raise
514
+ except Exception as e:
515
+ logger.error(f"Error retrieving tree {tree_id}: {e}")
516
+ raise
517
+
518
+
519
+ @app.put("/api/trees/{tree_id}", response_model=Tree, tags=["Trees"])
520
+ async def update_tree(tree_id: int, tree_update: TreeUpdate = None):
521
+ """Update a tree record"""
522
+ if not tree_update:
523
+ raise HTTPException(
524
+ status_code=status.HTTP_400_BAD_REQUEST, detail="No update data provided"
525
+ )
526
+
527
+ try:
528
+ with get_db_connection() as conn:
529
+ cursor = conn.cursor()
530
+
531
+ # Check if tree exists
532
+ cursor.execute("SELECT id FROM trees WHERE id = ?", (tree_id,))
533
+ if not cursor.fetchone():
534
+ raise HTTPException(
535
+ status_code=status.HTTP_404_NOT_FOUND,
536
+ detail=f"Tree with ID {tree_id} not found",
537
+ )
538
+
539
+ # Build update query dynamically
540
+ update_fields = []
541
+ params = []
542
+
543
+ for field, value in tree_update.model_dump(exclude_unset=True).items():
544
+ if value is not None:
545
+ update_fields.append(f"{field} = ?")
546
+ params.append(value)
547
+
548
+ if not update_fields:
549
+ raise HTTPException(
550
+ status_code=status.HTTP_400_BAD_REQUEST,
551
+ detail="No valid fields to update",
552
+ )
553
+
554
+ params.append(tree_id)
555
+ query = f"UPDATE trees SET {', '.join(update_fields)} WHERE id = ?"
556
+
557
+ cursor.execute(query, params)
558
+ conn.commit()
559
+
560
+ # Return updated tree
561
+ cursor.execute("SELECT * FROM trees WHERE id = ?", (tree_id,))
562
+ updated_tree = cursor.fetchone()
563
+
564
+ logger.info(f"Updated tree with ID: {tree_id}")
565
+ return dict(updated_tree)
566
+
567
+ except HTTPException:
568
+ raise
569
+ except sqlite3.IntegrityError as e:
570
+ logger.error(f"Database integrity error updating tree {tree_id}: {e}")
571
+ raise
572
+ except Exception as e:
573
+ logger.error(f"Error updating tree {tree_id}: {e}")
574
+ raise
575
+
576
+
577
+ @app.delete("/api/trees/{tree_id}", tags=["Trees"])
578
+ async def delete_tree(tree_id: int):
579
+ """Delete a tree record"""
580
+ try:
581
+ with get_db_connection() as conn:
582
+ cursor = conn.cursor()
583
+ cursor.execute("DELETE FROM trees WHERE id = ?", (tree_id,))
584
+
585
+ if cursor.rowcount == 0:
586
+ raise HTTPException(
587
+ status_code=status.HTTP_404_NOT_FOUND,
588
+ detail=f"Tree with ID {tree_id} not found",
589
+ )
590
+
591
+ conn.commit()
592
+ logger.info(f"Deleted tree with ID: {tree_id}")
593
+
594
+ return {"message": f"Tree {tree_id} deleted successfully"}
595
+
596
+ except HTTPException:
597
+ raise
598
+ except Exception as e:
599
+ logger.error(f"Error deleting tree {tree_id}: {e}")
600
+ raise
601
+
602
+
603
+ @app.get("/api/stats", response_model=StatsResponse, tags=["Statistics"])
604
+ async def get_stats():
605
+ """Get comprehensive tree statistics"""
606
+ try:
607
+ with get_db_connection() as conn:
608
+ cursor = conn.cursor()
609
+
610
+ # Total trees
611
+ cursor.execute("SELECT COUNT(*) as total FROM trees")
612
+ total = cursor.fetchone()[0]
613
+
614
+ # Trees by species (limit to top 20)
615
+ cursor.execute("""
616
+ SELECT species, COUNT(*) as count
617
+ FROM trees
618
+ GROUP BY species
619
+ ORDER BY count DESC
620
+ LIMIT 20
621
+ """)
622
+ species_stats = [
623
+ {"species": row[0], "count": row[1]} for row in cursor.fetchall()
624
+ ]
625
+
626
+ # Trees by health status
627
+ cursor.execute("""
628
+ SELECT health_status, COUNT(*) as count
629
+ FROM trees
630
+ GROUP BY health_status
631
+ ORDER BY count DESC
632
+ """)
633
+ health_stats = [
634
+ {"status": row[0], "count": row[1]} for row in cursor.fetchall()
635
+ ]
636
+
637
+ # Average measurements
638
+ cursor.execute("""
639
+ SELECT
640
+ COALESCE(AVG(height), 0) as avg_height,
641
+ COALESCE(AVG(diameter), 0) as avg_diameter
642
+ FROM trees
643
+ WHERE height IS NOT NULL AND diameter IS NOT NULL
644
+ """)
645
+ avg_stats = cursor.fetchone()
646
+
647
+ return StatsResponse(
648
+ total_trees=total,
649
+ species_distribution=species_stats,
650
+ health_distribution=health_stats,
651
+ average_height=round(avg_stats[0], 2) if avg_stats[0] else 0,
652
+ average_diameter=round(avg_stats[1], 2) if avg_stats[1] else 0,
653
+ last_updated=datetime.now().isoformat(),
654
+ )
655
+
656
+ except Exception as e:
657
+ logger.error(f"Error retrieving statistics: {e}")
658
+ raise
659
+
660
+
661
+ # Error handlers for better error responses
662
+ @app.exception_handler(404)
663
+ async def not_found_handler(request: Request, exc: Exception):
664
+ return JSONResponse(
665
+ status_code=404,
666
+ content={"detail": "Resource not found", "path": str(request.url.path)},
667
+ )
668
+
669
+
670
+ @app.exception_handler(500)
671
+ async def internal_error_handler(request: Request, exc: Exception):
672
+ logger.error(f"Internal server error: {exc}")
673
+ return JSONResponse(status_code=500, content={"detail": "Internal server error"})
674
+
675
+
676
+ if __name__ == "__main__":
677
+ uvicorn.run(
678
+ "app:app", host="127.0.0.1", port=8000, reload=True, log_level="info"
679
+ )
config.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for Tree Mapping Application
3
+ Implements environment-based configuration with validation and security best practices
4
+ """
5
+
6
+ import os
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+
10
+ from pydantic import Field, field_validator, model_validator
11
+ from pydantic_settings import BaseSettings
12
+
13
+
14
+ class DatabaseConfig(BaseSettings):
15
+ """Database configuration settings"""
16
+
17
+ # Database settings
18
+ db_path: str = Field(default="data/trees.db", env="DB_PATH")
19
+ db_timeout: int = Field(default=30, env="DB_TIMEOUT", ge=1, le=300)
20
+ db_max_connections: int = Field(default=10, env="DB_MAX_CONNECTIONS", ge=1, le=100)
21
+ db_backup_enabled: bool = Field(default=True, env="DB_BACKUP_ENABLED")
22
+ db_backup_interval: int = Field(
23
+ default=3600, env="DB_BACKUP_INTERVAL", ge=300
24
+ ) # seconds
25
+
26
+ @field_validator("db_path")
27
+ def validate_db_path(cls, v):
28
+ """Ensure database path is secure"""
29
+ path = Path(v)
30
+
31
+ # Prevent path traversal attacks
32
+ if ".." in str(path) or str(path).startswith("/"):
33
+ raise ValueError("Database path contains invalid characters")
34
+
35
+ # Ensure parent directory exists
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+
38
+ return str(path)
39
+
40
+
41
+ class SecurityConfig(BaseSettings):
42
+ """Security configuration settings"""
43
+
44
+ # Security settings
45
+ secret_key: str = Field(
46
+ default="dev-secret-key-change-in-production", env="SECRET_KEY"
47
+ )
48
+ allowed_hosts: list[str] = Field(
49
+ default=["localhost", "127.0.0.1"], env="ALLOWED_HOSTS"
50
+ )
51
+ cors_origins: list[str] = Field(
52
+ default=["http://localhost:8000", "http://127.0.0.1:8000"], env="CORS_ORIGINS"
53
+ )
54
+ max_request_size: int = Field(
55
+ default=1048576, env="MAX_REQUEST_SIZE", ge=1024
56
+ ) # 1MB default
57
+ rate_limit_requests: int = Field(default=100, env="RATE_LIMIT_REQUESTS", ge=1)
58
+ rate_limit_window: int = Field(default=60, env="RATE_LIMIT_WINDOW", ge=1) # seconds
59
+
60
+ # Content Security Policy
61
+ csp_default_src: str = Field(default="'self'", env="CSP_DEFAULT_SRC")
62
+ csp_script_src: str = Field(
63
+ default="'self' 'unsafe-inline' https://unpkg.com https://cdn.plot.ly",
64
+ env="CSP_SCRIPT_SRC",
65
+ )
66
+ csp_style_src: str = Field(
67
+ default="'self' 'unsafe-inline' https://unpkg.com", env="CSP_STYLE_SRC"
68
+ )
69
+ csp_img_src: str = Field(default="'self' data: https:", env="CSP_IMG_SRC")
70
+ csp_connect_src: str = Field(default="'self'", env="CSP_CONNECT_SRC")
71
+
72
+ @field_validator("secret_key")
73
+ def validate_secret_key(cls, v):
74
+ """Ensure secret key is secure in production"""
75
+ if v == "dev-secret-key-change-in-production":
76
+ env = os.getenv("ENVIRONMENT", "development")
77
+ if env.lower() in ["production", "prod"]:
78
+ raise ValueError("Must set a secure SECRET_KEY in production")
79
+
80
+ if len(v) < 32:
81
+ raise ValueError("Secret key must be at least 32 characters long")
82
+
83
+ return v
84
+
85
+ @field_validator("allowed_hosts")
86
+ def validate_allowed_hosts(cls, v):
87
+ """Validate allowed hosts format"""
88
+ for host in v:
89
+ if not host or ".." in host:
90
+ raise ValueError(f"Invalid host: {host}")
91
+ return v
92
+
93
+
94
+ class ServerConfig(BaseSettings):
95
+ """Server configuration settings"""
96
+
97
+ # Server settings
98
+ host: str = Field(default="127.0.0.1", env="HOST")
99
+ port: int = Field(default=8000, env="PORT", ge=1, le=65535)
100
+ workers: int = Field(default=1, env="WORKERS", ge=1, le=8)
101
+ reload: bool = Field(default=False, env="RELOAD")
102
+ debug: bool = Field(default=False, env="DEBUG")
103
+
104
+ # Request handling
105
+ request_timeout: int = Field(default=30, env="REQUEST_TIMEOUT", ge=1, le=300)
106
+ max_trees_per_request: int = Field(
107
+ default=1000, env="MAX_TREES_PER_REQUEST", ge=1, le=10000
108
+ )
109
+
110
+ @field_validator("host")
111
+ def validate_host(cls, v):
112
+ """Validate host format"""
113
+ if not v or v.count(".") > 3:
114
+ raise ValueError("Invalid host format")
115
+ return v
116
+
117
+
118
+ class LoggingConfig(BaseSettings):
119
+ """Logging configuration settings"""
120
+
121
+ # Logging settings
122
+ log_level: str = Field(default="INFO", env="LOG_LEVEL")
123
+ log_file: str = Field(default="logs/app.log", env="LOG_FILE")
124
+ log_max_size: int = Field(
125
+ default=10485760, env="LOG_MAX_SIZE", ge=1024
126
+ ) # 10MB default
127
+ log_backup_count: int = Field(default=5, env="LOG_BACKUP_COUNT", ge=1, le=20)
128
+ log_format: str = Field(
129
+ default="%(asctime)s - %(name)s - %(levelname)s - %(message)s", env="LOG_FORMAT"
130
+ )
131
+
132
+ # Access logging
133
+ access_log_enabled: bool = Field(default=True, env="ACCESS_LOG_ENABLED")
134
+ access_log_file: str = Field(default="logs/access.log", env="ACCESS_LOG_FILE")
135
+
136
+ @field_validator("log_level")
137
+ def validate_log_level(cls, v):
138
+ """Validate log level"""
139
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
140
+ if v.upper() not in valid_levels:
141
+ raise ValueError(f"Log level must be one of: {valid_levels}")
142
+ return v.upper()
143
+
144
+ @field_validator("log_file", "access_log_file")
145
+ def validate_log_file(cls, v):
146
+ """Ensure log directory exists"""
147
+ if v:
148
+ log_path = Path(v)
149
+ log_path.parent.mkdir(parents=True, exist_ok=True)
150
+ return v
151
+
152
+
153
+ class CacheConfig(BaseSettings):
154
+ """Cache configuration settings"""
155
+
156
+ # Cache settings
157
+ cache_enabled: bool = Field(default=True, env="CACHE_ENABLED")
158
+ cache_ttl_trees: int = Field(default=300, env="CACHE_TTL_TREES", ge=1) # 5 minutes
159
+ cache_ttl_stats: int = Field(default=600, env="CACHE_TTL_STATS", ge=1) # 10 minutes
160
+ cache_max_size: int = Field(default=1000, env="CACHE_MAX_SIZE", ge=1)
161
+
162
+
163
+ class MonitoringConfig(BaseSettings):
164
+ """Monitoring and observability configuration"""
165
+
166
+ # Monitoring settings
167
+ metrics_enabled: bool = Field(default=True, env="METRICS_ENABLED")
168
+ health_check_enabled: bool = Field(default=True, env="HEALTH_CHECK_ENABLED")
169
+ performance_monitoring: bool = Field(default=True, env="PERFORMANCE_MONITORING")
170
+
171
+ # Alerting thresholds
172
+ error_rate_threshold: float = Field(
173
+ default=0.05, env="ERROR_RATE_THRESHOLD", ge=0.0, le=1.0
174
+ )
175
+ response_time_threshold: float = Field(
176
+ default=2.0, env="RESPONSE_TIME_THRESHOLD", ge=0.1
177
+ )
178
+
179
+
180
+ class ApplicationConfig(BaseSettings):
181
+ """Main application configuration"""
182
+
183
+ # Environment
184
+ environment: str = Field(default="development", env="ENVIRONMENT")
185
+ app_name: str = Field(default="Tree Mapping API", env="APP_NAME")
186
+ app_version: str = Field(default="2.0.0", env="APP_VERSION")
187
+ app_description: str = Field(
188
+ default="Secure API for mapping and tracking trees", env="APP_DESCRIPTION"
189
+ )
190
+
191
+ # Feature flags
192
+ enable_api_docs: bool = Field(default=True, env="ENABLE_API_DOCS")
193
+ enable_frontend: bool = Field(default=True, env="ENABLE_FRONTEND")
194
+ enable_statistics: bool = Field(default=True, env="ENABLE_STATISTICS")
195
+
196
+ # Data validation
197
+ strict_validation: bool = Field(default=True, env="STRICT_VALIDATION")
198
+ max_species_length: int = Field(default=200, env="MAX_SPECIES_LENGTH", ge=1, le=500)
199
+ max_notes_length: int = Field(default=2000, env="MAX_NOTES_LENGTH", ge=1, le=10000)
200
+
201
+ @field_validator("environment")
202
+ def validate_environment(cls, v):
203
+ """Validate environment"""
204
+ valid_envs = ["development", "testing", "staging", "production"]
205
+ if v.lower() not in valid_envs:
206
+ raise ValueError(f"Environment must be one of: {valid_envs}")
207
+ return v.lower()
208
+
209
+ class Config:
210
+ env_file = ".env"
211
+ env_file_encoding = "utf-8"
212
+ case_sensitive = False
213
+
214
+
215
+ class Settings(BaseSettings):
216
+ """Combined application settings"""
217
+
218
+ # Sub-configurations
219
+ database: DatabaseConfig = DatabaseConfig()
220
+ security: SecurityConfig = SecurityConfig()
221
+ server: ServerConfig = ServerConfig()
222
+ logging: LoggingConfig = LoggingConfig()
223
+ cache: CacheConfig = CacheConfig()
224
+ monitoring: MonitoringConfig = MonitoringConfig()
225
+ app: ApplicationConfig = ApplicationConfig()
226
+
227
+ @model_validator(mode="before")
228
+ def validate_production_settings(cls, values):
229
+ """Additional validation for production environment"""
230
+ app_config = values.get("app")
231
+ security_config = values.get("security")
232
+ server_config = values.get("server")
233
+
234
+ if app_config and app_config.environment == "production":
235
+ # Production-specific validations
236
+ if server_config and server_config.debug:
237
+ raise ValueError("Debug mode must be disabled in production")
238
+
239
+ if (
240
+ security_config
241
+ and security_config.secret_key == "dev-secret-key-change-in-production"
242
+ ):
243
+ raise ValueError("Must set a secure SECRET_KEY in production")
244
+
245
+ return values
246
+
247
+ def get_database_url(self) -> str:
248
+ """Get database connection URL"""
249
+ return f"sqlite:///{self.database.db_path}"
250
+
251
+ def get_log_config(self) -> dict:
252
+ """Get logging configuration dictionary"""
253
+ return {
254
+ "version": 1,
255
+ "disable_existing_loggers": False,
256
+ "formatters": {
257
+ "default": {
258
+ "format": self.logging.log_format,
259
+ },
260
+ "access": {
261
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
262
+ },
263
+ },
264
+ "handlers": {
265
+ "default": {
266
+ "formatter": "default",
267
+ "class": "logging.handlers.RotatingFileHandler",
268
+ "filename": self.logging.log_file,
269
+ "maxBytes": self.logging.log_max_size,
270
+ "backupCount": self.logging.log_backup_count,
271
+ }
272
+ if self.logging.log_file
273
+ else {
274
+ "formatter": "default",
275
+ "class": "logging.StreamHandler",
276
+ },
277
+ "access": {
278
+ "formatter": "access",
279
+ "class": "logging.handlers.RotatingFileHandler",
280
+ "filename": self.logging.access_log_file,
281
+ "maxBytes": self.logging.log_max_size,
282
+ "backupCount": self.logging.log_backup_count,
283
+ }
284
+ if self.logging.access_log_file and self.logging.access_log_enabled
285
+ else {
286
+ "formatter": "access",
287
+ "class": "logging.StreamHandler",
288
+ },
289
+ },
290
+ "loggers": {
291
+ "": {
292
+ "handlers": ["default"],
293
+ "level": self.logging.log_level,
294
+ },
295
+ "uvicorn.access": {
296
+ "handlers": ["access"],
297
+ "level": "INFO",
298
+ "propagate": False,
299
+ }
300
+ if self.logging.access_log_enabled
301
+ else {},
302
+ },
303
+ }
304
+
305
+ def get_cors_config(self) -> dict:
306
+ """Get CORS configuration"""
307
+ return {
308
+ "allow_origins": self.security.cors_origins,
309
+ "allow_credentials": True,
310
+ "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
311
+ "allow_headers": ["*"],
312
+ }
313
+
314
+ def get_csp_header(self) -> str:
315
+ """Get Content Security Policy header value"""
316
+ return (
317
+ f"default-src {self.security.csp_default_src}; "
318
+ f"script-src {self.security.csp_script_src}; "
319
+ f"style-src {self.security.csp_style_src}; "
320
+ f"img-src {self.security.csp_img_src}; "
321
+ f"connect-src {self.security.csp_connect_src}"
322
+ )
323
+
324
+ def is_development(self) -> bool:
325
+ """Check if running in development mode"""
326
+ return self.app.environment == "development"
327
+
328
+ def is_production(self) -> bool:
329
+ """Check if running in production mode"""
330
+ return self.app.environment == "production"
331
+
332
+ class Config:
333
+ env_file = ".env"
334
+ env_file_encoding = "utf-8"
335
+ case_sensitive = False
336
+
337
+
338
+ @lru_cache
339
+ def get_settings() -> Settings:
340
+ """Get cached application settings"""
341
+ return Settings()
342
+
343
+
344
+ def setup_logging(settings: Settings) -> None:
345
+ """Setup application logging"""
346
+ import logging.config
347
+
348
+ # Ensure log directories exist
349
+ if settings.logging.log_file:
350
+ Path(settings.logging.log_file).parent.mkdir(parents=True, exist_ok=True)
351
+
352
+ if settings.logging.access_log_file:
353
+ Path(settings.logging.access_log_file).parent.mkdir(parents=True, exist_ok=True)
354
+
355
+ # Configure logging
356
+ log_config = settings.get_log_config()
357
+ logging.config.dictConfig(log_config)
358
+
359
+ # Log startup information
360
+ logger = logging.getLogger(__name__)
361
+ logger.info(f"Starting {settings.app.app_name} v{settings.app.app_version}")
362
+ logger.info(f"Environment: {settings.app.environment}")
363
+ logger.info(f"Log level: {settings.logging.log_level}")
364
+
365
+
366
+ def validate_settings() -> Settings:
367
+ """Validate and return application settings"""
368
+ try:
369
+ settings = get_settings()
370
+
371
+ # Additional runtime validations
372
+ if settings.is_production():
373
+ # Check critical production settings
374
+ if (
375
+ not settings.security.secret_key
376
+ or len(settings.security.secret_key) < 32
377
+ ):
378
+ raise ValueError("Production requires a secure SECRET_KEY")
379
+
380
+ if settings.server.debug:
381
+ raise ValueError("Debug mode must be disabled in production")
382
+
383
+ return settings
384
+
385
+ except Exception as e:
386
+ print(f"Configuration error: {e}")
387
+ raise
388
+
389
+
390
+ if __name__ == "__main__":
391
+ # Test configuration loading
392
+ try:
393
+ settings = validate_settings()
394
+ print("✅ Configuration loaded successfully")
395
+ print(f"Environment: {settings.app.environment}")
396
+ print(f"Database: {settings.database.db_path}")
397
+ print(f"Server: {settings.server.host}:{settings.server.port}")
398
+ print(f"Log level: {settings.logging.log_level}")
399
+
400
+ except Exception as e:
401
+ print(f"❌ Configuration error: {e}")
402
+ exit(1)
requirements.txt ADDED
Binary file (67.8 kB). View file
 
static/app.js ADDED
@@ -0,0 +1,1159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Enhanced Tree Mapping Application JavaScript
2
+ // Implements performance optimizations, error handling, and best practices
3
+
4
+ class TreeMapApp {
5
+ constructor() {
6
+ this.map = null;
7
+ this.trees = [];
8
+ this.selectedTree = null;
9
+ this.markers = [];
10
+ this.tempMarker = null;
11
+ this.markerCluster = null;
12
+ this.debounceTimer = null;
13
+ this.cache = new Map();
14
+ this.retryAttempts = 3;
15
+ this.requestTimeout = 10000;
16
+
17
+ // Performance monitoring
18
+ this.performanceMetrics = {
19
+ apiCalls: 0,
20
+ cacheHits: 0,
21
+ errors: 0
22
+ };
23
+
24
+ this.init();
25
+ }
26
+
27
+ async init() {
28
+ try {
29
+ this.showLoadingState(true);
30
+ await this.initMap();
31
+ this.initEventListeners();
32
+ await Promise.all([
33
+ this.loadTrees(),
34
+ this.loadStats()
35
+ ]);
36
+ this.updateTreeList();
37
+ this.updateTreemap();
38
+ this.showLoadingState(false);
39
+ console.log('Application initialized successfully');
40
+ } catch (error) {
41
+ console.error('Failed to initialize application:', error);
42
+ this.showError('Failed to initialize application. Please refresh the page.');
43
+ this.showLoadingState(false);
44
+ }
45
+ }
46
+
47
+ initMap() {
48
+ return new Promise((resolve, reject) => {
49
+ try {
50
+ // Initialize Leaflet map with better default settings
51
+ this.map = L.map('map', {
52
+ zoomControl: true,
53
+ attributionControl: true,
54
+ maxZoom: 18,
55
+ minZoom: 2
56
+ }).setView([51.5074, -0.1278], 13);
57
+
58
+ // Add OpenStreetMap tiles with error handling
59
+ const tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
60
+ attribution: '© OpenStreetMap contributors',
61
+ maxZoom: 18,
62
+ errorTileUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgZmlsbD0iI2VlZSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxOCIgZmlsbD0iIzk5OSI+VGlsZSBub3QgZm91bmQ8L3RleHQ+PC9zdmc+'
63
+ });
64
+
65
+ tileLayer.on('tileerror', (e) => {
66
+ console.warn('Tile loading error:', e);
67
+ });
68
+
69
+ tileLayer.addTo(this.map);
70
+
71
+ // Add click handler for adding trees with debouncing
72
+ this.map.on('click', this.debounce((e) => {
73
+ this.handleMapClick(e);
74
+ }, 300));
75
+
76
+ // Add map event listeners for performance
77
+ this.map.on('moveend', this.debounce(() => {
78
+ this.onMapMoveEnd();
79
+ }, 500));
80
+
81
+ resolve();
82
+ } catch (error) {
83
+ reject(error);
84
+ }
85
+ });
86
+ }
87
+
88
+ handleMapClick(e) {
89
+ try {
90
+ // Remove previous temporary marker if it exists
91
+ if (this.tempMarker) {
92
+ this.map.removeLayer(this.tempMarker);
93
+ }
94
+
95
+ // Validate coordinates
96
+ const lat = parseFloat(e.latlng.lat.toFixed(6));
97
+ const lng = parseFloat(e.latlng.lng.toFixed(6));
98
+
99
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
100
+ this.showError('Invalid coordinates selected');
101
+ return;
102
+ }
103
+
104
+ // Set coordinates in form
105
+ document.getElementById('latitude').value = lat;
106
+ document.getElementById('longitude').value = lng;
107
+
108
+ // Add temporary pin marker with animation
109
+ const tempIcon = L.divIcon({
110
+ className: 'temp-marker',
111
+ html: `<div style="
112
+ background-color: #ff6b6b;
113
+ width: 24px;
114
+ height: 24px;
115
+ border-radius: 50%;
116
+ border: 3px solid white;
117
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ color: white;
122
+ font-size: 14px;
123
+ font-weight: bold;
124
+ animation: pulse 1.5s infinite;
125
+ ">📍</div>`,
126
+ iconSize: [30, 30],
127
+ iconAnchor: [15, 15]
128
+ });
129
+
130
+ this.tempMarker = L.marker([lat, lng], { icon: tempIcon })
131
+ .addTo(this.map)
132
+ .bindPopup('Click "Add Tree" to save tree at this location')
133
+ .openPopup();
134
+
135
+ } catch (error) {
136
+ console.error('Error handling map click:', error);
137
+ this.showError('Error processing map click');
138
+ }
139
+ }
140
+
141
+ onMapMoveEnd() {
142
+ // Optimize marker visibility based on zoom level
143
+ const zoom = this.map.getZoom();
144
+ const bounds = this.map.getBounds();
145
+
146
+ // Hide/show markers based on zoom level for performance
147
+ this.markers.forEach(marker => {
148
+ const markerLatLng = marker.getLatLng();
149
+ if (bounds.contains(markerLatLng)) {
150
+ if (!this.map.hasLayer(marker)) {
151
+ this.map.addLayer(marker);
152
+ }
153
+ } else if (zoom < 10) {
154
+ if (this.map.hasLayer(marker)) {
155
+ this.map.removeLayer(marker);
156
+ }
157
+ }
158
+ });
159
+ }
160
+
161
+ initEventListeners() {
162
+ // Tree form submission with validation
163
+ const treeForm = document.getElementById('treeForm');
164
+ if (treeForm) {
165
+ treeForm.addEventListener('submit', (e) => {
166
+ e.preventDefault();
167
+ this.addTree();
168
+ });
169
+ }
170
+
171
+ // Clear form button
172
+ const clearBtn = document.getElementById('clearForm');
173
+ if (clearBtn) {
174
+ clearBtn.addEventListener('click', () => {
175
+ this.clearForm();
176
+ });
177
+ }
178
+
179
+ // Modal close
180
+ const closeBtn = document.querySelector('.close');
181
+ if (closeBtn) {
182
+ closeBtn.addEventListener('click', () => {
183
+ this.closeModal();
184
+ });
185
+ }
186
+
187
+ // Edit tree button
188
+ const editBtn = document.getElementById('editTree');
189
+ if (editBtn) {
190
+ editBtn.addEventListener('click', () => {
191
+ this.editTree();
192
+ });
193
+ }
194
+
195
+ // Delete tree button
196
+ const deleteBtn = document.getElementById('deleteTree');
197
+ if (deleteBtn) {
198
+ deleteBtn.addEventListener('click', () => {
199
+ this.deleteTree();
200
+ });
201
+ }
202
+
203
+ // Close modal when clicking outside
204
+ window.addEventListener('click', (e) => {
205
+ const modal = document.getElementById('treeModal');
206
+ if (e.target === modal) {
207
+ this.closeModal();
208
+ }
209
+ });
210
+
211
+ // Add keyboard shortcuts
212
+ document.addEventListener('keydown', (e) => {
213
+ if (e.key === 'Escape') {
214
+ this.closeModal();
215
+ }
216
+ });
217
+
218
+ // Add form validation listeners
219
+ this.addFormValidationListeners();
220
+ }
221
+
222
+ addFormValidationListeners() {
223
+ const inputs = document.querySelectorAll('#treeForm input, #treeForm select, #treeForm textarea');
224
+ inputs.forEach(input => {
225
+ input.addEventListener('blur', () => {
226
+ this.validateField(input);
227
+ });
228
+
229
+ input.addEventListener('input', this.debounce(() => {
230
+ this.validateField(input);
231
+ }, 500));
232
+ });
233
+ }
234
+
235
+ validateField(field) {
236
+ const value = field.value.trim();
237
+ let isValid = true;
238
+ let errorMessage = '';
239
+
240
+ // Clear previous error
241
+ this.clearFieldError(field);
242
+
243
+ switch (field.id) {
244
+ case 'latitude':
245
+ const lat = parseFloat(value);
246
+ if (value && (isNaN(lat) || lat < -90 || lat > 90)) {
247
+ isValid = false;
248
+ errorMessage = 'Latitude must be between -90 and 90';
249
+ }
250
+ break;
251
+
252
+ case 'longitude':
253
+ const lng = parseFloat(value);
254
+ if (value && (isNaN(lng) || lng < -180 || lng > 180)) {
255
+ isValid = false;
256
+ errorMessage = 'Longitude must be between -180 and 180';
257
+ }
258
+ break;
259
+
260
+ case 'species':
261
+ if (field.required && !value) {
262
+ isValid = false;
263
+ errorMessage = 'Species is required';
264
+ } else if (value && !/^[A-Za-z\s\-\.]+$/.test(value)) {
265
+ isValid = false;
266
+ errorMessage = 'Species name contains invalid characters';
267
+ }
268
+ break;
269
+
270
+ case 'height':
271
+ const height = parseFloat(value);
272
+ if (value && (isNaN(height) || height <= 0 || height > 200)) {
273
+ isValid = false;
274
+ errorMessage = 'Height must be between 0 and 200 meters';
275
+ }
276
+ break;
277
+
278
+ case 'diameter':
279
+ const diameter = parseFloat(value);
280
+ if (value && (isNaN(diameter) || diameter <= 0 || diameter > 1000)) {
281
+ isValid = false;
282
+ errorMessage = 'Diameter must be between 0 and 1000 cm';
283
+ }
284
+ break;
285
+
286
+ case 'age_estimate':
287
+ const age = parseInt(value);
288
+ if (value && (isNaN(age) || age <= 0 || age > 5000)) {
289
+ isValid = false;
290
+ errorMessage = 'Age must be between 0 and 5000 years';
291
+ }
292
+ break;
293
+ case 'last_inspection':
294
+ if (value && !this.isValidDate(value)) {
295
+ isValid = false;
296
+ errorMessage = 'Please enter a valid date (YYYY-MM-DD)';
297
+ } else if (value && new Date(value) > new Date()) {
298
+ isValid = false;
299
+ errorMessage = 'Date cannot be in the future';
300
+ }
301
+ break;
302
+ }
303
+
304
+ if (!isValid) {
305
+ this.showFieldError(field, errorMessage);
306
+ }
307
+
308
+ return isValid;
309
+ }
310
+
311
+ showFieldError(field, message) {
312
+ field.classList.add('error');
313
+ let errorDiv = field.parentNode.querySelector('.error-message');
314
+ if (!errorDiv) {
315
+ errorDiv = document.createElement('div');
316
+ errorDiv.className = 'error-message';
317
+ errorDiv.style.color = '#dc3545';
318
+ errorDiv.style.fontSize = '0.875rem';
319
+ errorDiv.style.marginTop = '0.25rem';
320
+ field.parentNode.appendChild(errorDiv);
321
+ }
322
+ errorDiv.textContent = message;
323
+ }
324
+
325
+ clearFieldError(field) {
326
+ field.classList.remove('error');
327
+ const errorDiv = field.parentNode.querySelector('.error-message');
328
+ if (errorDiv) {
329
+ errorDiv.remove();
330
+ }
331
+ }
332
+
333
+ isValidDate(dateString) {
334
+ const regex = /^\d{4}-\d{2}-\d{2}$/;
335
+ if (!regex.test(dateString)) return false;
336
+
337
+ const date = new Date(dateString);
338
+ const timestamp = date.getTime();
339
+
340
+ if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
341
+ return false;
342
+ }
343
+
344
+ return dateString === date.toISOString().split('T')[0];
345
+ }
346
+
347
+ async loadTrees() {
348
+ const cacheKey = 'trees_list';
349
+
350
+ // Check cache first
351
+ if (this.cache.has(cacheKey)) {
352
+ const cached = this.cache.get(cacheKey);
353
+ if (Date.now() - cached.timestamp < 30000) { // 30 second cache
354
+ this.trees = cached.data;
355
+ this.performanceMetrics.cacheHits++;
356
+ this.updateMapMarkers();
357
+ return;
358
+ }
359
+ }
360
+
361
+ try {
362
+ const response = await this.fetchWithRetry('/api/trees');
363
+ this.trees = await response.json();
364
+
365
+ // Cache the result
366
+ this.cache.set(cacheKey, {
367
+ data: this.trees,
368
+ timestamp: Date.now()
369
+ });
370
+
371
+ this.updateMapMarkers();
372
+ this.performanceMetrics.apiCalls++;
373
+
374
+ } catch (error) {
375
+ console.error('Error loading trees:', error);
376
+ this.showError('Failed to load trees. Please try again.');
377
+ this.performanceMetrics.errors++;
378
+ }
379
+ }
380
+
381
+ async loadStats() {
382
+ const cacheKey = 'stats';
383
+
384
+ // Check cache first
385
+ if (this.cache.has(cacheKey)) {
386
+ const cached = this.cache.get(cacheKey);
387
+ if (Date.now() - cached.timestamp < 60000) { // 1 minute cache
388
+ this.updateStatsDisplay(cached.data);
389
+ this.performanceMetrics.cacheHits++;
390
+ return;
391
+ }
392
+ }
393
+
394
+ try {
395
+ const response = await this.fetchWithRetry('/api/stats');
396
+ const stats = await response.json();
397
+
398
+ // Cache the result
399
+ this.cache.set(cacheKey, {
400
+ data: stats,
401
+ timestamp: Date.now()
402
+ });
403
+
404
+ this.updateStatsDisplay(stats);
405
+ this.performanceMetrics.apiCalls++;
406
+
407
+ } catch (error) {
408
+ console.error('Error loading stats:', error);
409
+ this.showError('Failed to load statistics.');
410
+ this.performanceMetrics.errors++;
411
+ }
412
+ }
413
+
414
+ updateStatsDisplay(stats) {
415
+ const elements = {
416
+ totalTrees: document.getElementById('totalTrees'),
417
+ avgHeight: document.getElementById('avgHeight'),
418
+ avgDiameter: document.getElementById('avgDiameter')
419
+ };
420
+
421
+ if (elements.totalTrees) elements.totalTrees.textContent = stats.total_trees || 0;
422
+ if (elements.avgHeight) elements.avgHeight.textContent = stats.average_height || 0;
423
+ if (elements.avgDiameter) elements.avgDiameter.textContent = stats.average_diameter || 0;
424
+ }
425
+
426
+ async fetchWithRetry(url, options = {}, retries = this.retryAttempts) {
427
+ const controller = new AbortController();
428
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
429
+
430
+ try {
431
+ const response = await fetch(url, {
432
+ ...options,
433
+ signal: controller.signal
434
+ });
435
+
436
+ clearTimeout(timeoutId);
437
+
438
+ // **CRITICAL: Don't throw error immediately for 422 - let caller handle it**
439
+ if (!response.ok && response.status !== 422) {
440
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
441
+ }
442
+
443
+ return response;
444
+
445
+ } catch (error) {
446
+ clearTimeout(timeoutId);
447
+
448
+ // **Don't retry on 422 validation errors**
449
+ if (error.message && error.message.includes('422')) {
450
+ throw error;
451
+ }
452
+
453
+ if (retries > 0 && !controller.signal.aborted) {
454
+ console.warn(`Request failed, retrying... (${retries} attempts left)`);
455
+ await this.delay(1000 * (this.retryAttempts - retries + 1));
456
+ return this.fetchWithRetry(url, options, retries - 1);
457
+ }
458
+
459
+ throw error;
460
+ }
461
+ }
462
+
463
+ updateMapMarkers() {
464
+ // Clear existing markers efficiently
465
+ this.markers.forEach(marker => {
466
+ if (this.map.hasLayer(marker)) {
467
+ this.map.removeLayer(marker);
468
+ }
469
+ });
470
+ this.markers = [];
471
+
472
+ // Create markers in batches for better performance
473
+ const batchSize = 50;
474
+ let currentBatch = 0;
475
+
476
+ const processBatch = () => {
477
+ const start = currentBatch * batchSize;
478
+ const end = Math.min(start + batchSize, this.trees.length);
479
+
480
+ for (let i = start; i < end; i++) {
481
+ const tree = this.trees[i];
482
+ try {
483
+ const marker = this.createTreeMarker(tree);
484
+ this.markers.push(marker);
485
+ } catch (error) {
486
+ console.error(`Error creating marker for tree ${tree.id}:`, error);
487
+ }
488
+ }
489
+
490
+ currentBatch++;
491
+
492
+ if (end < this.trees.length) {
493
+ // Process next batch asynchronously
494
+ setTimeout(processBatch, 10);
495
+ } else {
496
+ // All markers created, fit bounds if needed
497
+ this.fitMapBounds();
498
+ }
499
+ };
500
+
501
+ if (this.trees.length > 0) {
502
+ processBatch();
503
+ }
504
+ }
505
+
506
+ fitMapBounds() {
507
+ if (this.markers.length > 0) {
508
+ try {
509
+ const group = new L.featureGroup(this.markers);
510
+ const bounds = group.getBounds();
511
+
512
+ if (bounds.isValid()) {
513
+ this.map.fitBounds(bounds.pad(0.1), {
514
+ maxZoom: 15,
515
+ animate: true,
516
+ duration: 1
517
+ });
518
+ }
519
+ } catch (error) {
520
+ console.error('Error fitting map bounds:', error);
521
+ }
522
+ }
523
+ }
524
+
525
+ createTreeMarker(tree) {
526
+ // Validate tree data
527
+ if (!tree.latitude || !tree.longitude ||
528
+ tree.latitude < -90 || tree.latitude > 90 ||
529
+ tree.longitude < -180 || tree.longitude > 180) {
530
+ throw new Error('Invalid tree coordinates');
531
+ }
532
+
533
+ // Color based on health status
534
+ const healthColors = {
535
+ 'Excellent': '#28a745',
536
+ 'Good': '#28a745',
537
+ 'Fair': '#ffc107',
538
+ 'Poor': '#dc3545',
539
+ 'Dead': '#6c757d'
540
+ };
541
+
542
+ const color = healthColors[tree.health_status] || '#28a745';
543
+
544
+ // Create optimized custom icon
545
+ const icon = L.divIcon({
546
+ className: 'tree-marker',
547
+ html: `<div style="
548
+ background-color: ${color};
549
+ width: 20px;
550
+ height: 20px;
551
+ border-radius: 50%;
552
+ border: 2px solid white;
553
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
554
+ display: flex;
555
+ align-items: center;
556
+ justify-content: center;
557
+ color: white;
558
+ font-size: 12px;
559
+ font-weight: bold;
560
+ ">🌳</div>`,
561
+ iconSize: [24, 24],
562
+ iconAnchor: [12, 12]
563
+ });
564
+
565
+ const marker = L.marker([tree.latitude, tree.longitude], { icon })
566
+ .addTo(this.map)
567
+ .on('click', () => this.showTreeDetails(tree));
568
+
569
+ // Create popup content efficiently
570
+ const popupContent = this.createPopupContent(tree);
571
+ marker.bindPopup(popupContent, {
572
+ maxWidth: 250,
573
+ closeButton: true
574
+ });
575
+
576
+ return marker;
577
+ }
578
+
579
+ createPopupContent(tree) {
580
+ const safeText = (text) => text ? this.escapeHtml(text) : '';
581
+
582
+ return `
583
+ <div style="min-width: 200px;">
584
+ <h4>${safeText(tree.species)}</h4>
585
+ ${tree.common_name ? `<p><strong>Common:</strong> ${safeText(tree.common_name)}</p>` : ''}
586
+ <p><strong>Health:</strong> <span class="health-${tree.health_status.toLowerCase()}">${safeText(tree.health_status)}</span></p>
587
+ ${tree.height ? `<p><strong>Height:</strong> ${tree.height}m</p>` : ''}
588
+ ${tree.diameter ? `<p><strong>Diameter:</strong> ${tree.diameter}cm</p>` : ''}
589
+ <p><em>Click for more details</em></p>
590
+ </div>
591
+ `;
592
+ }
593
+
594
+ escapeHtml(text) {
595
+ const div = document.createElement('div');
596
+ div.textContent = text;
597
+ return div.innerHTML;
598
+ }
599
+
600
+ updateTreeList() {
601
+ const treeList = document.getElementById('treeList');
602
+ if (!treeList) return;
603
+
604
+ // Clear existing content
605
+ treeList.innerHTML = '';
606
+
607
+ // Show recent trees (limit for performance)
608
+ const recentTrees = this.trees.slice(0, 10);
609
+
610
+ if (recentTrees.length === 0) {
611
+ treeList.innerHTML = '<p style="text-align: center; color: #666; padding: 1rem;">No trees found</p>';
612
+ return;
613
+ }
614
+
615
+ const fragment = document.createDocumentFragment();
616
+
617
+ recentTrees.forEach(tree => {
618
+ const treeItem = document.createElement('div');
619
+ treeItem.className = 'tree-item';
620
+ treeItem.innerHTML = `
621
+ <div class="tree-species">${this.escapeHtml(tree.species)}</div>
622
+ <div class="tree-details">
623
+ ${tree.common_name ? this.escapeHtml(tree.common_name) : 'No common name'} •
624
+ <span class="health-${tree.health_status.toLowerCase()}">${this.escapeHtml(tree.health_status)}</span>
625
+ </div>
626
+ `;
627
+
628
+ treeItem.addEventListener('click', () => {
629
+ this.selectTree(tree);
630
+ this.showTreeDetails(tree);
631
+ });
632
+
633
+ fragment.appendChild(treeItem);
634
+ });
635
+
636
+ treeList.appendChild(fragment);
637
+ }
638
+
639
+ updateTreemap() {
640
+ const treemapElement = document.getElementById('treemap');
641
+ if (!treemapElement) return;
642
+
643
+ if (this.trees.length === 0) {
644
+ treemapElement.innerHTML = '<p style="text-align: center; color: #666; padding: 2rem;">No trees to display</p>';
645
+ return;
646
+ }
647
+
648
+ try {
649
+ // Prepare data for treemap
650
+ const speciesCount = {};
651
+ this.trees.forEach(tree => {
652
+ const species = tree.species || 'Unknown';
653
+ speciesCount[species] = (speciesCount[species] || 0) + 1;
654
+ });
655
+
656
+ const data = [{
657
+ type: "treemap",
658
+ labels: Object.keys(speciesCount),
659
+ values: Object.values(speciesCount),
660
+ parents: Array(Object.keys(speciesCount).length).fill(""),
661
+ textinfo: "label+value",
662
+ hovertemplate: '<b>%{label}</b><br>Count: %{value}<extra></extra>',
663
+ marker: {
664
+ colorscale: 'Greens',
665
+ showscale: false
666
+ }
667
+ }];
668
+
669
+ const layout = {
670
+ margin: { t: 0, l: 0, r: 0, b: 0 },
671
+ font: { size: 12 }
672
+ };
673
+
674
+ const config = {
675
+ displayModeBar: false,
676
+ responsive: true
677
+ };
678
+
679
+ Plotly.newPlot('treemap', data, layout, config);
680
+
681
+ } catch (error) {
682
+ console.error('Error creating treemap:', error);
683
+ treemapElement.innerHTML = '<p style="text-align: center; color: #dc3545; padding: 2rem;">Error loading treemap</p>';
684
+ }
685
+ }
686
+
687
+ selectTree(tree) {
688
+ // Remove previous selection
689
+ document.querySelectorAll('.tree-item').forEach(item => {
690
+ item.classList.remove('selected');
691
+ });
692
+
693
+ // Add selection to clicked item
694
+ if (event && event.currentTarget) {
695
+ event.currentTarget.classList.add('selected');
696
+ }
697
+
698
+ this.selectedTree = tree;
699
+
700
+ // Center map on selected tree with smooth animation
701
+ this.map.setView([tree.latitude, tree.longitude], 16, {
702
+ animate: true,
703
+ duration: 1
704
+ });
705
+ }
706
+
707
+ showTreeDetails(tree) {
708
+ const modal = document.getElementById('treeModal');
709
+ const details = document.getElementById('treeDetails');
710
+
711
+ if (!modal || !details) return;
712
+
713
+ const safeText = (text) => text ? this.escapeHtml(text) : '';
714
+
715
+ details.innerHTML = `
716
+ <div style="line-height: 1.6;">
717
+ <p><strong>Species:</strong> ${safeText(tree.species)}</p>
718
+ ${tree.common_name ? `<p><strong>Common Name:</strong> ${safeText(tree.common_name)}</p>` : ''}
719
+ <p><strong>Location:</strong> ${tree.latitude.toFixed(6)}, ${tree.longitude.toFixed(6)}</p>
720
+ ${tree.height ? `<p><strong>Height:</strong> ${tree.height} meters</p>` : ''}
721
+ ${tree.diameter ? `<p><strong>Diameter:</strong> ${tree.diameter} cm</p>` : ''}
722
+ <p><strong>Health Status:</strong> <span class="health-${tree.health_status.toLowerCase()}">${safeText(tree.health_status)}</span></p>
723
+ ${tree.age_estimate ? `<p><strong>Age Estimate:</strong> ${tree.age_estimate} years</p>` : ''}
724
+ ${tree.last_inspection ? `<p><strong>Last Inspection:</strong> ${tree.last_inspection}</p>` : ''}
725
+ ${tree.notes ? `<p><strong>Notes:</strong> ${safeText(tree.notes)}</p>` : ''}
726
+ <p><strong>Added:</strong> ${new Date(tree.timestamp).toLocaleDateString()}</p>
727
+ </div>
728
+ `;
729
+
730
+ this.selectedTree = tree;
731
+ modal.style.display = 'block';
732
+
733
+ // Focus management for accessibility
734
+ const closeBtn = modal.querySelector('.close');
735
+ if (closeBtn) closeBtn.focus();
736
+ }
737
+
738
+ closeModal() {
739
+ const modal = document.getElementById('treeModal');
740
+ if (modal) {
741
+ modal.style.display = 'none';
742
+ }
743
+ this.selectedTree = null;
744
+ }
745
+
746
+ editTree() {
747
+ if (!this.selectedTree) return;
748
+
749
+ try {
750
+ // Populate form with selected tree data
751
+ const tree = this.selectedTree;
752
+ const fields = {
753
+ 'latitude': tree.latitude,
754
+ 'longitude': tree.longitude,
755
+ 'species': tree.species,
756
+ 'common_name': tree.common_name || '',
757
+ 'height': tree.height || '',
758
+ 'diameter': tree.diameter || '',
759
+ 'health_status': tree.health_status,
760
+ 'age_estimate': tree.age_estimate || '',
761
+ 'last_inspection': tree.last_inspection || '',
762
+ 'notes': tree.notes || ''
763
+ };
764
+
765
+ Object.entries(fields).forEach(([fieldId, value]) => {
766
+ const element = document.getElementById(fieldId);
767
+ if (element) {
768
+ element.value = value;
769
+ this.clearFieldError(element);
770
+ }
771
+ });
772
+
773
+ // Change form button to update mode
774
+ const submitBtn = document.querySelector('#treeForm button[type="submit"]');
775
+ if (submitBtn) {
776
+ submitBtn.textContent = 'Update Tree';
777
+ submitBtn.dataset.mode = 'edit';
778
+ submitBtn.dataset.treeId = tree.id;
779
+ }
780
+
781
+ this.closeModal();
782
+
783
+ } catch (error) {
784
+ console.error('Error editing tree:', error);
785
+ this.showError('Error loading tree data for editing');
786
+ }
787
+ }
788
+
789
+ async deleteTree() {
790
+ if (!this.selectedTree) return;
791
+
792
+ const confirmed = await this.showConfirmDialog(
793
+ 'Delete Tree',
794
+ 'Are you sure you want to delete this tree? This action cannot be undone.'
795
+ );
796
+
797
+ if (!confirmed) return;
798
+
799
+ try {
800
+ this.showLoadingState(true);
801
+
802
+ const response = await this.fetchWithRetry(`/api/trees/${this.selectedTree.id}`, {
803
+ method: 'DELETE'
804
+ });
805
+
806
+ if (response.ok) {
807
+ // Clear cache
808
+ this.cache.clear();
809
+
810
+ await Promise.all([
811
+ this.loadTrees(),
812
+ this.loadStats()
813
+ ]);
814
+
815
+ this.updateTreeList();
816
+ this.updateTreemap();
817
+ this.closeModal();
818
+ this.showSuccess('Tree deleted successfully');
819
+ } else {
820
+ throw new Error('Failed to delete tree');
821
+ }
822
+
823
+ } catch (error) {
824
+ console.error('Error deleting tree:', error);
825
+ this.showError('Failed to delete tree. Please try again.');
826
+ } finally {
827
+ this.showLoadingState(false);
828
+ }
829
+ }
830
+
831
+ async addTree() {
832
+ // Validate form first
833
+ const form = document.getElementById('treeForm');
834
+ if (!form) {
835
+ console.error('Tree form not found');
836
+ return;
837
+ }
838
+
839
+ const inputs = form.querySelectorAll('input, select, textarea');
840
+ let isValid = true;
841
+
842
+ inputs.forEach(input => {
843
+ if (!this.validateField(input)) {
844
+ isValid = false;
845
+ }
846
+ });
847
+
848
+ if (!isValid) {
849
+ this.showError('Please fix the validation errors before submitting');
850
+ return;
851
+ }
852
+
853
+ let formData;
854
+ try {
855
+ formData = this.getFormData();
856
+ const jsonPayload = JSON.stringify(formData, null, 2);
857
+ console.log('JSON payload:', jsonPayload);
858
+ console.log('JSON payload length:', jsonPayload.length);
859
+ console.log('==========================');
860
+
861
+ } catch (error) {
862
+ console.error('=== FORM DATA ERROR ===');
863
+ console.error('Error in getFormData:', error);
864
+ console.error('Error stack:', error.stack);
865
+ console.error('======================');
866
+ this.showError(error.message);
867
+ return;
868
+ }
869
+
870
+ const submitBtn = document.querySelector('#treeForm button[type="submit"]');
871
+ const isEdit = submitBtn && submitBtn.dataset.mode === 'edit';
872
+
873
+ try {
874
+ this.showLoadingState(true);
875
+
876
+ const url = isEdit ? `/api/trees/${submitBtn.dataset.treeId}` : '/api/trees';
877
+ const method = isEdit ? 'PUT' : 'POST';
878
+
879
+ console.log('=== REQUEST DEBUG ===');
880
+ console.log('URL:', url);
881
+ console.log('Method:', method);
882
+ console.log('Headers:', { 'Content-Type': 'application/json' });
883
+ console.log('Body:', JSON.stringify(formData));
884
+ console.log('====================');
885
+
886
+ const response = await this.fetchWithRetry(url, {
887
+ method: method,
888
+ headers: {
889
+ 'Content-Type': 'application/json'
890
+ },
891
+ body: JSON.stringify(formData)
892
+ });
893
+
894
+ console.log('=== RESPONSE DEBUG ===');
895
+ console.log('Response status:', response.status);
896
+ console.log('Response ok:', response.ok);
897
+ console.log('Response headers:', [...response.headers.entries()]);
898
+ console.log('======================');
899
+
900
+ if (response.ok) {
901
+ console.log('=== SUCCESS ===');
902
+ console.log('Tree created successfully');
903
+ console.log('===============');
904
+
905
+ // Clear cache to force refresh
906
+ this.cache.clear();
907
+
908
+ await Promise.all([
909
+ this.loadTrees(),
910
+ this.loadStats()
911
+ ]);
912
+
913
+ this.updateTreeList();
914
+ this.updateTreemap();
915
+ this.clearForm();
916
+
917
+ // Remove temporary marker if it exists
918
+ if (this.tempMarker) {
919
+ this.map.removeLayer(this.tempMarker);
920
+ this.tempMarker = null;
921
+ }
922
+
923
+ // Reset form button
924
+ if (submitBtn) {
925
+ submitBtn.textContent = 'Add Tree';
926
+ submitBtn.dataset.mode = '';
927
+ delete submitBtn.dataset.treeId;
928
+ }
929
+
930
+ this.showSuccess(`Tree ${isEdit ? 'updated' : 'added'} successfully`);
931
+
932
+ } else {
933
+ let errorData;
934
+ try {
935
+ errorData = await response.json();
936
+ } catch (jsonError) {
937
+ console.error('Failed to parse error response as JSON:', jsonError);
938
+ errorData = {};
939
+ }
940
+
941
+ // **CRITICAL ERROR DEBUG**
942
+ console.error('=== SERVER ERROR RESPONSE ===');
943
+ console.error('Status:', response.status);
944
+ console.error('Status text:', response.statusText);
945
+ console.error('Response headers:', [...response.headers.entries()]);
946
+ console.error('Error data:', errorData);
947
+ console.error('Error detail:', errorData.detail);
948
+ console.error('=============================');
949
+
950
+ // Try to get response text if JSON parsing failed
951
+ if (!errorData.detail) {
952
+ try {
953
+ const responseText = await response.text();
954
+ console.error('Raw response text:', responseText);
955
+ } catch (textError) {
956
+ console.error('Failed to get response text:', textError);
957
+ }
958
+ }
959
+
960
+ throw new Error(errorData.detail || `Failed to ${isEdit ? 'update' : 'add'} tree (${response.status})`);
961
+ }
962
+
963
+ } catch (error) {
964
+ console.error('=== NETWORK/REQUEST ERROR ===');
965
+ console.error('Error type:', error.constructor.name);
966
+ console.error('Error message:', error.message);
967
+ console.error('Error stack:', error.stack);
968
+ console.error('=============================');
969
+
970
+ this.showError(error.message || `Failed to ${isEdit ? 'update' : 'add'} tree`);
971
+ } finally {
972
+ this.showLoadingState(false);
973
+ }
974
+ }
975
+
976
+ getFormData() {
977
+ const getValue = (id) => {
978
+ const element = document.getElementById(id);
979
+ return element ? element.value.trim() : '';
980
+ };
981
+
982
+ const getNumericValue = (id) => {
983
+ const value = getValue(id);
984
+ return value ? parseFloat(value) : null;
985
+ };
986
+
987
+ const getIntegerValue = (id) => {
988
+ const value = getValue(id);
989
+ return value ? parseInt(value) : null;
990
+ };
991
+
992
+ // Start with only required fields
993
+ const formData = {
994
+ latitude: getNumericValue('latitude'),
995
+ longitude: getNumericValue('longitude'),
996
+ species: getValue('species'),
997
+ health_status: getValue('health_status')
998
+ };
999
+
1000
+ // **CRITICAL: Only add optional fields if they have actual values**
1001
+ const commonName = getValue('common_name');
1002
+ if (commonName) {
1003
+ formData.common_name = commonName;
1004
+ }
1005
+
1006
+ const height = getNumericValue('height');
1007
+ if (height !== null && !isNaN(height)) {
1008
+ formData.height = height;
1009
+ }
1010
+
1011
+ const diameter = getNumericValue('diameter');
1012
+ if (diameter !== null && !isNaN(diameter)) {
1013
+ formData.diameter = diameter;
1014
+ }
1015
+
1016
+ const ageEstimate = getIntegerValue('age_estimate');
1017
+ if (ageEstimate !== null && !isNaN(ageEstimate)) {
1018
+ formData.age_estimate = ageEstimate;
1019
+ }
1020
+
1021
+ const lastInspection = getValue('last_inspection');
1022
+ if (lastInspection) {
1023
+ formData.last_inspection = lastInspection;
1024
+ }
1025
+
1026
+ const notes = getValue('notes');
1027
+ if (notes) {
1028
+ formData.notes = notes;
1029
+ }
1030
+
1031
+ // **CRITICAL FIX: Remove any phantom fields that shouldn't exist**
1032
+ // This handles browser autofill adding removed fields
1033
+ if (formData.hasOwnProperty('planted_date')) {
1034
+ delete formData.planted_date;
1035
+ console.warn('Removed phantom planted_date field from payload');
1036
+ }
1037
+
1038
+ return formData;
1039
+ }
1040
+
1041
+ clearForm() {
1042
+ const form = document.getElementById('treeForm');
1043
+ if (!form) return;
1044
+
1045
+ form.reset();
1046
+
1047
+ // Clear all field errors
1048
+ const inputs = form.querySelectorAll('input, select, textarea');
1049
+ inputs.forEach(input => {
1050
+ this.clearFieldError(input);
1051
+ });
1052
+
1053
+ // Reset form button
1054
+ const submitBtn = form.querySelector('button[type="submit"]');
1055
+ if (submitBtn) {
1056
+ submitBtn.textContent = 'Add Tree';
1057
+ submitBtn.dataset.mode = '';
1058
+ delete submitBtn.dataset.treeId;
1059
+ }
1060
+
1061
+ // Set default values
1062
+ const healthStatus = document.getElementById('health_status');
1063
+ if (healthStatus) {
1064
+ healthStatus.value = 'Good';
1065
+ }
1066
+ }
1067
+
1068
+ // Utility functions
1069
+ debounce(func, wait) {
1070
+ return function executedFunction(...args) {
1071
+ const later = () => {
1072
+ clearTimeout(this.debounceTimer);
1073
+ func(...args);
1074
+ };
1075
+ clearTimeout(this.debounceTimer);
1076
+ this.debounceTimer = setTimeout(later, wait);
1077
+ };
1078
+ }
1079
+
1080
+ delay(ms) {
1081
+ return new Promise(resolve => setTimeout(resolve, ms));
1082
+ }
1083
+
1084
+ showLoadingState(show) {
1085
+ // You can implement a loading spinner here
1086
+ const body = document.body;
1087
+ if (show) {
1088
+ body.style.cursor = 'wait';
1089
+ } else {
1090
+ body.style.cursor = 'default';
1091
+ }
1092
+ }
1093
+
1094
+ showError(message) {
1095
+ console.error(message);
1096
+ // You can implement a toast notification system here
1097
+ alert(`Error: ${message}`);
1098
+ }
1099
+
1100
+ showSuccess(message) {
1101
+ console.log(message);
1102
+ // You can implement a toast notification system here
1103
+ // For now, we'll use a simple alert
1104
+ // alert(message);
1105
+ }
1106
+
1107
+ showConfirmDialog(title, message) {
1108
+ return new Promise((resolve) => {
1109
+ const result = confirm(`${title}\n\n${message}`);
1110
+ resolve(result);
1111
+ });
1112
+ }
1113
+
1114
+ // Performance monitoring
1115
+ getPerformanceMetrics() {
1116
+ return {
1117
+ ...this.performanceMetrics,
1118
+ cacheSize: this.cache.size,
1119
+ markersCount: this.markers.length,
1120
+ treesCount: this.trees.length
1121
+ };
1122
+ }
1123
+
1124
+ logPerformanceMetrics() {
1125
+ console.log('Performance Metrics:', this.getPerformanceMetrics());
1126
+ }
1127
+ }
1128
+
1129
+ // Initialize the application when the page loads
1130
+ document.addEventListener('DOMContentLoaded', () => {
1131
+ try {
1132
+ window.treeMapApp = new TreeMapApp();
1133
+
1134
+ // Log performance metrics every 30 seconds in development
1135
+ if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
1136
+ setInterval(() => {
1137
+ window.treeMapApp.logPerformanceMetrics();
1138
+ }, 30000);
1139
+ }
1140
+
1141
+ } catch (error) {
1142
+ console.error('Failed to initialize TreeMapApp:', error);
1143
+ alert('Failed to initialize the application. Please refresh the page.');
1144
+ }
1145
+ });
1146
+
1147
+ // Handle page visibility changes for performance
1148
+ document.addEventListener('visibilitychange', () => {
1149
+ if (window.treeMapApp) {
1150
+ if (document.hidden) {
1151
+ // Page is hidden, pause expensive operations
1152
+ console.log('Page hidden, pausing operations');
1153
+ } else {
1154
+ // Page is visible, resume operations
1155
+ console.log('Page visible, resuming operations');
1156
+ // Optionally refresh data if it's been a while
1157
+ }
1158
+ }
1159
+ });
static/index.html ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title> Tree Mapping Application</title>
7
+
8
+ <!-- Leaflet CSS -->
9
+ <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
10
+
11
+ <!-- Plotly.js -->
12
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
13
+
14
+ <!-- Custom CSS -->
15
+ <style>
16
+ * {
17
+ margin: 0;
18
+ padding: 0;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
24
+ background-color: #f5f5f5;
25
+ color: #333;
26
+ }
27
+
28
+ .header {
29
+ background: linear-gradient(135deg, #2d5a27, #4a7c59);
30
+ color: white;
31
+ padding: 1rem;
32
+ text-align: center;
33
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
34
+ }
35
+
36
+ .container {
37
+ display: grid;
38
+ grid-template-columns: 300px 1fr 300px;
39
+ gap: 1rem;
40
+ padding: 1rem;
41
+ height: calc(100vh - 80px);
42
+ }
43
+
44
+ .sidebar {
45
+ background: white;
46
+ border-radius: 8px;
47
+ padding: 1rem;
48
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
49
+ overflow-y: auto;
50
+ }
51
+
52
+ .main-content {
53
+ background: white;
54
+ border-radius: 8px;
55
+ padding: 1rem;
56
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
57
+ display: flex;
58
+ flex-direction: column;
59
+ }
60
+
61
+ #map {
62
+ flex: 1;
63
+ border-radius: 8px;
64
+ min-height: 400px;
65
+ }
66
+
67
+ .form-group {
68
+ margin-bottom: 1rem;
69
+ }
70
+
71
+ .form-group label {
72
+ display: block;
73
+ margin-bottom: 0.5rem;
74
+ font-weight: 600;
75
+ color: #2d5a27;
76
+ }
77
+
78
+ .form-group input,
79
+ .form-group select,
80
+ .form-group textarea {
81
+ width: 100%;
82
+ padding: 0.5rem;
83
+ border: 1px solid #ddd;
84
+ border-radius: 4px;
85
+ font-size: 14px;
86
+ }
87
+
88
+ .form-group textarea {
89
+ resize: vertical;
90
+ min-height: 60px;
91
+ }
92
+
93
+ .btn {
94
+ background: #4a7c59;
95
+ color: white;
96
+ border: none;
97
+ padding: 0.75rem 1rem;
98
+ border-radius: 4px;
99
+ cursor: pointer;
100
+ font-size: 14px;
101
+ transition: background-color 0.3s;
102
+ }
103
+
104
+ .btn:hover {
105
+ background: #2d5a27;
106
+ }
107
+
108
+ .btn-danger {
109
+ background: #dc3545;
110
+ }
111
+
112
+ .btn-danger:hover {
113
+ background: #c82333;
114
+ }
115
+
116
+ .stats-card {
117
+ background: #f8f9fa;
118
+ padding: 1rem;
119
+ border-radius: 4px;
120
+ margin-bottom: 1rem;
121
+ border-left: 4px solid #4a7c59;
122
+ }
123
+
124
+ .stats-number {
125
+ font-size: 2rem;
126
+ font-weight: bold;
127
+ color: #2d5a27;
128
+ }
129
+
130
+ .stats-label {
131
+ font-size: 0.9rem;
132
+ color: #666;
133
+ }
134
+
135
+ .tree-list {
136
+ max-height: 300px;
137
+ overflow-y: auto;
138
+ }
139
+
140
+ .tree-item {
141
+ padding: 0.5rem;
142
+ border-bottom: 1px solid #eee;
143
+ cursor: pointer;
144
+ transition: background-color 0.3s;
145
+ }
146
+
147
+ .tree-item:hover {
148
+ background-color: #f8f9fa;
149
+ }
150
+
151
+ .tree-item.selected {
152
+ background-color: #e8f5e8;
153
+ border-left: 3px solid #4a7c59;
154
+ }
155
+
156
+ .tree-species {
157
+ font-weight: 600;
158
+ color: #2d5a27;
159
+ }
160
+
161
+ .tree-details {
162
+ font-size: 0.9rem;
163
+ color: #666;
164
+ }
165
+
166
+ .health-good { color: #28a745; }
167
+ .health-fair { color: #ffc107; }
168
+ .health-poor { color: #dc3545; }
169
+ .health-dead { color: #6c757d; }
170
+
171
+ #treemap {
172
+ height: 300px;
173
+ margin-top: 1rem;
174
+ }
175
+
176
+ .modal {
177
+ display: none;
178
+ position: fixed;
179
+ z-index: 1000;
180
+ left: 0;
181
+ top: 0;
182
+ width: 100%;
183
+ height: 100%;
184
+ background-color: rgba(0,0,0,0.5);
185
+ }
186
+
187
+ .modal-content {
188
+ background-color: white;
189
+ margin: 5% auto;
190
+ padding: 2rem;
191
+ border-radius: 8px;
192
+ width: 90%;
193
+ max-width: 500px;
194
+ max-height: 80vh;
195
+ overflow-y: auto;
196
+ }
197
+
198
+ .close {
199
+ color: #aaa;
200
+ float: right;
201
+ font-size: 28px;
202
+ font-weight: bold;
203
+ cursor: pointer;
204
+ }
205
+
206
+ .close:hover {
207
+ color: #000;
208
+ }
209
+
210
+ @keyframes pulse {
211
+ 0% {
212
+ transform: scale(1);
213
+ opacity: 1;
214
+ }
215
+ 50% {
216
+ transform: scale(1.1);
217
+ opacity: 0.8;
218
+ }
219
+ 100% {
220
+ transform: scale(1);
221
+ opacity: 1;
222
+ }
223
+ }
224
+
225
+ @media (max-width: 1024px) {
226
+ .container {
227
+ grid-template-columns: 1fr;
228
+ grid-template-rows: auto auto auto;
229
+ }
230
+
231
+ .sidebar {
232
+ order: 2;
233
+ }
234
+
235
+ .main-content {
236
+ order: 1;
237
+ }
238
+ }
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <div class="header">
243
+ <h1>🌳 Tree Mapping Application</h1>
244
+ <p>Track, map, and manage urban forest data</p>
245
+ </div>
246
+
247
+ <div class="container">
248
+ <!-- Left Sidebar - Tree Entry Form -->
249
+ <div class="sidebar">
250
+ <h3>Add New Tree</h3>
251
+ <form id="treeForm">
252
+ <div class="form-group">
253
+ <label for="latitude">Latitude</label>
254
+ <input type="number" id="latitude" step="0.000001" required>
255
+ </div>
256
+
257
+ <div class="form-group">
258
+ <label for="longitude">Longitude</label>
259
+ <input type="number" id="longitude" step="0.000001" required>
260
+ </div>
261
+
262
+ <div class="form-group">
263
+ <label for="species">Species (Scientific Name)</label>
264
+ <input type="text" id="species" required placeholder="e.g., Quercus robur">
265
+ </div>
266
+
267
+ <div class="form-group">
268
+ <label for="common_name">Common Name</label>
269
+ <input type="text" id="common_name" placeholder="e.g., English Oak">
270
+ </div>
271
+
272
+ <div class="form-group">
273
+ <label for="height">Height (meters)</label>
274
+ <input type="number" id="height" step="0.1" min="0">
275
+ </div>
276
+
277
+ <div class="form-group">
278
+ <label for="diameter">Diameter (cm)</label>
279
+ <input type="number" id="diameter" step="0.1" min="0">
280
+ </div>
281
+
282
+ <div class="form-group">
283
+ <label for="health_status">Health Status</label>
284
+ <select id="health_status">
285
+ <option value="Excellent">Excellent</option>
286
+ <option value="Good" selected>Good</option>
287
+ <option value="Fair">Fair</option>
288
+ <option value="Poor">Poor</option>
289
+ <option value="Dead">Dead</option>
290
+ </select>
291
+ </div>
292
+
293
+ <div class="form-group">
294
+ <label for="age_estimate">Age Estimate (years)</label>
295
+ <input type="number" id="age_estimate" min="0">
296
+ </div>
297
+
298
+
299
+ <div class="form-group">
300
+ <label for="last_inspection">Last Inspection</label>
301
+ <input type="date" id="last_inspection">
302
+ </div>
303
+
304
+ <div class="form-group">
305
+ <label for="notes">Notes</label>
306
+ <textarea id="notes" placeholder="Additional observations..."></textarea>
307
+ </div>
308
+
309
+ <button type="submit" class="btn">Add Tree</button>
310
+ <button type="button" id="clearForm" class="btn" style="background: #6c757d; margin-left: 0.5rem;">Clear</button>
311
+ </form>
312
+ </div>
313
+
314
+ <!-- Main Content - Map -->
315
+ <div class="main-content">
316
+ <h3>Tree Locations Map</h3>
317
+ <div id="map"></div>
318
+ </div>
319
+
320
+ <!-- Right Sidebar - Statistics and Tree List -->
321
+ <div class="sidebar">
322
+ <h3>Statistics</h3>
323
+ <div id="stats">
324
+ <div class="stats-card">
325
+ <div class="stats-number" id="totalTrees">0</div>
326
+ <div class="stats-label">Total Trees</div>
327
+ </div>
328
+ <div class="stats-card">
329
+ <div class="stats-number" id="avgHeight">0</div>
330
+ <div class="stats-label">Avg Height (m)</div>
331
+ </div>
332
+ <div class="stats-card">
333
+ <div class="stats-number" id="avgDiameter">0</div>
334
+ <div class="stats-label">Avg Diameter (cm)</div>
335
+ </div>
336
+ </div>
337
+
338
+ <div id="treemap"></div>
339
+
340
+ <h3>Recent Trees</h3>
341
+ <div id="treeList" class="tree-list"></div>
342
+ </div>
343
+ </div>
344
+
345
+ <!-- Tree Details Modal -->
346
+ <div id="treeModal" class="modal">
347
+ <div class="modal-content">
348
+ <span class="close">&times;</span>
349
+ <h3>Tree Details</h3>
350
+ <div id="treeDetails"></div>
351
+ <button id="editTree" class="btn">Edit</button>
352
+ <button id="deleteTree" class="btn btn-danger" style="margin-left: 0.5rem;">Delete</button>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Leaflet JS -->
357
+ <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
358
+
359
+ <!-- Custom JavaScript -->
360
+ <script src="/static/app.js"></script>
361
+ </body>
362
+ </html>