Search Weather
+ + +Saved Cities
+No saved cities yet
+From e931dd96d03b2222b8c2a84b78b0490f211f72fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Th=C6=B0=C6=A1ng?= <252359928+Huynhthuongg@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:01:58 +0700 Subject: [PATCH 1/2] feat: add weather dashboard application - Add Weather Dashboard with real-time weather fetching - Integrate OpenWeatherMap API for global weather data - Implement responsive HTML5 UI with modern design - Add city search functionality with autocomplete - Add save/favorite cities with SQLite database - Include comprehensive weather details (temperature, humidity, wind, pressure, sunrise/sunset) - Add security headers and error handling - Responsive design for mobile, tablet, and desktop - FastAPI with async/await pattern - Complete API documentation Files added: - weather_dashboard/config.py - Configuration management - weather_dashboard/models.py - Data models (WeatherData, SavedCity, etc.) - weather_dashboard/weather_service.py - OpenWeatherMap API integration - weather_dashboard/database.py - SQLite database operations - weather_dashboard/app.py - FastAPI application and routes - weather_dashboard/templates.py - Beautiful HTML dashboard - weather_dashboard/__init__.py - Package initialization - config/weather.env.example - Environment configuration example - README_WEATHER.md - Complete documentation --- README_WEATHER.md | 230 ++++++++++++ config/weather.env.example | 16 + weather_dashboard/__init__.py | 3 + weather_dashboard/app.py | 211 +++++++++++ weather_dashboard/config.py | 38 ++ weather_dashboard/database.py | 119 ++++++ weather_dashboard/models.py | 105 ++++++ weather_dashboard/templates.py | 541 +++++++++++++++++++++++++++ weather_dashboard/weather_service.py | 117 ++++++ 9 files changed, 1380 insertions(+) create mode 100644 README_WEATHER.md create mode 100644 config/weather.env.example create mode 100644 weather_dashboard/__init__.py create mode 100644 weather_dashboard/app.py create mode 100644 weather_dashboard/config.py create mode 100644 weather_dashboard/database.py create mode 100644 weather_dashboard/models.py create mode 100644 weather_dashboard/templates.py create mode 100644 weather_dashboard/weather_service.py diff --git a/README_WEATHER.md b/README_WEATHER.md new file mode 100644 index 0000000..6f769ee --- /dev/null +++ b/README_WEATHER.md @@ -0,0 +1,230 @@ +# 🌤️ Weather Dashboard + +A modern, responsive weather dashboard that fetches real-time weather data from OpenWeatherMap API. + +## ✨ Features + +- 🌍 **Search Cities Worldwide** - Find weather for any city globally +- 🌤️ **Real-time Weather Data** - Current conditions with temperature, humidity, wind, pressure +- ⭐ **Save Favorites** - Bookmark your favorite cities to SQLite +- 📱 **Responsive Design** - Works perfectly on mobile, tablet, and desktop +- 🔒 **Security** - HTTP security headers, input validation +- ⚡ **Fast & Async** - Built with FastAPI and aiohttp +- 🎨 **Modern UI** - Beautiful gradient design with smooth animations + +## 🚀 Quick Start + +### Prerequisites + +- Python 3.10+ +- OpenWeatherMap API key (free tier available) + +### Installation + +1. Clone and setup +```bash +git clone https://github.com/Huynhthuongg/AGENTS.md.git +cd AGENTS.md +git checkout feature/weather-dashboard +``` + +2. Install dependencies +```bash +pip install fastapi uvicorn aiohttp +``` + +3. Get your free OpenWeatherMap API key +- Visit https://openweathermap.org/api +- Sign up for a free account +- Generate an API key + +4. Set up environment +```bash +cp config/weather.env.example .env +# Edit .env and add your OpenWeatherMap API key +``` + +5. Run the application +```bash +export OPENWEATHER_API_KEY="your_api_key_here" +python -m weather_dashboard.app +``` + +Open your browser: **http://127.0.0.1:8001** + +## 📋 API Endpoints + +### `GET /` +Serves the main dashboard HTML. + +### `GET /health` +Health check endpoint. +```json +{"status": "ok", "version": "1.0.0"} +``` + +### `GET /api/weather` +Get current weather for coordinates. + +**Parameters:** +- `lat` (float, required) - Latitude +- `lon` (float, required) - Longitude + +**Response:** +```json +{ + "city": "London", + "country": "GB", + "temperature": 15.5, + "feels_like": 14.2, + "temp_min": 13.0, + "temp_max": 17.0, + "humidity": 72, + "pressure": 1013, + "wind_speed": 3.5, + "wind_deg": 240, + "cloudiness": 60, + "description": "partly cloudy", + "icon": "02d", + "icon_url": "https://openweathermap.org/img/wn/02d@2x.png", + "sunrise": "2026-06-03T05:30:00", + "sunset": "2026-06-03T21:15:00" +} +``` + +### `GET /api/search` +Search for cities. + +**Parameters:** +- `q` (string, required, min 2 chars) - City name +- `limit` (int, optional, default 5) - Max results (1-20) + +**Response:** +```json +[ + { + "name": "London", + "latitude": 51.5085, + "longitude": -0.1257, + "country": "GB", + "state": null + } +] +``` + +### `GET /api/saved-cities` +Get all saved cities. + +**Response:** +```json +[ + { + "id": 1, + "city_name": "London", + "latitude": 51.5085, + "longitude": -0.1257, + "added_at": "2026-06-03T10:30:00" + } +] +``` + +### `POST /api/saved-cities` +Save a city to favorites. + +**Parameters:** +- `city_name` (string, required) - City name +- `latitude` (float, required) - Latitude +- `longitude` (float, required) - Longitude + +### `DELETE /api/saved-cities/{city_id}` +Delete a saved city. + +## 🛠️ Development + +### Project Structure + +``` +weather_dashboard/ +├── __init__.py # Package initialization +├── app.py # FastAPI application +├── config.py # Configuration management +├── models.py # Data models +├── weather_service.py # OpenWeatherMap integration +├── database.py # SQLite operations +└── templates.py # HTML templates +``` + +### Configuration + +Edit `.env` file to customize settings: + +```bash +# OpenWeatherMap API +OPENWEATHER_API_KEY=your_key + +# Server +WEATHER_HOST=127.0.0.1 +WEATHER_PORT=8001 + +# Database +DATABASE_URL=sqlite:///weather_dashboard.db +``` + +## 🔐 Security + +- ✅ HTTP security headers (X-Content-Type-Options, X-Frame-Options, etc.) +- ✅ Input validation with Pydantic +- ✅ Safe path validation +- ✅ Error handling and logging +- ✅ CORS-friendly + +## 📱 Responsive Design + +- Desktop: 2-column grid layout +- Tablet: Responsive grid +- Mobile: Single column stack + +## 🎨 UI Features + +- Beautiful gradient background +- Smooth animations and transitions +- Loading spinner +- Error messages +- Real-time weather icons +- Weather details cards + +## 🐛 Troubleshooting + +### "OPENWEATHER_API_KEY environment variable is required" +```bash +export OPENWEATHER_API_KEY="your_api_key" +``` + +### Search returns empty results +- Check your internet connection +- Verify OpenWeatherMap API key is valid +- Try searching for a major city first + +### Port 8001 already in use +```bash +export WEATHER_PORT=8002 +``` + +## 📦 Dependencies + +- **FastAPI** - Web framework +- **Uvicorn** - ASGI server +- **aiohttp** - Async HTTP client +- **Pydantic** - Data validation + +## 📄 License + +AGPL-3.0-only + +## 🤝 Contributing + +Contributions welcome! Please create a pull request. + +--- + +**Made with ❤️ by Huynhthuongg** diff --git a/config/weather.env.example b/config/weather.env.example new file mode 100644 index 0000000..366ab9c --- /dev/null +++ b/config/weather.env.example @@ -0,0 +1,16 @@ +# Weather Dashboard Configuration + +# OpenWeatherMap API Configuration +# Get a free key from https://openweathermap.org/api +OPENWEATHER_API_KEY=your_api_key_here + +# Server Configuration +WEATHER_HOST=127.0.0.1 +WEATHER_PORT=8001 +WEATHER_DEBUG=False + +# Database Configuration +DATABASE_URL=sqlite:///weather_dashboard.db + +# Cache Configuration (in seconds) +CACHE_TTL=600 diff --git a/weather_dashboard/__init__.py b/weather_dashboard/__init__.py new file mode 100644 index 0000000..fdbc54f --- /dev/null +++ b/weather_dashboard/__init__.py @@ -0,0 +1,3 @@ +"""Weather Dashboard Application.""" + +__version__ = "1.0.0" diff --git a/weather_dashboard/app.py b/weather_dashboard/app.py new file mode 100644 index 0000000..0df5631 --- /dev/null +++ b/weather_dashboard/app.py @@ -0,0 +1,211 @@ +"""FastAPI application for Weather Dashboard.""" + +from __future__ import annotations + +import logging +from typing import List + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + +from .config import Settings, load_settings +from .weather_service import WeatherService +from .database import DatabaseManager +from .templates import get_index_html + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize settings +settings = load_settings() + +# Initialize services +weather_service = WeatherService(settings) +db_manager = DatabaseManager() + +# Create FastAPI app +app = FastAPI( + title="Weather Dashboard", + description="Real-time weather information from OpenWeatherMap", + version="1.0.0", +) + + +# Pydantic models for API +class WeatherResponse(BaseModel): + """Weather response model.""" + + city: str + country: str + temperature: float + feels_like: float + temp_min: float + temp_max: float + humidity: int + pressure: int + wind_speed: float + wind_deg: int + cloudiness: int + description: str + icon: str + icon_url: str + sunrise: str + sunset: str + + +class SavedCityResponse(BaseModel): + """Saved city response model.""" + + id: int + city_name: str + latitude: float + longitude: float + added_at: str + + +class SearchResultResponse(BaseModel): + """Search result response model.""" + + name: str + latitude: float + longitude: float + country: str + state: str | None + + +# Security middleware +@app.middleware("http") +async def security_headers(request, call_next): + """Add security headers to response.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + +# Routes +@app.get("/", response_class=HTMLResponse) +async def index() -> str: + """Serve the dashboard HTML.""" + return get_index_html() + + +@app.get("/health") +async def health() -> dict: + """Health check endpoint.""" + return {"status": "ok", "version": "1.0.0"} + + +@app.get("/api/weather", response_model=WeatherResponse) +async def get_weather( + lat: float = Query(..., description="Latitude"), + lon: float = Query(..., description="Longitude"), +) -> WeatherResponse: + """Get current weather for given coordinates.""" + try: + weather_data = await weather_service.get_current_weather(lat, lon) + return WeatherResponse( + city=weather_data.city, + country=weather_data.country, + temperature=weather_data.main.temperature, + feels_like=weather_data.main.feels_like, + temp_min=weather_data.main.temp_min, + temp_max=weather_data.main.temp_max, + humidity=weather_data.main.humidity, + pressure=weather_data.main.pressure, + wind_speed=weather_data.wind.speed, + wind_deg=weather_data.wind.deg, + cloudiness=weather_data.cloudiness, + description=weather_data.weather.description, + icon=weather_data.weather.icon, + icon_url=weather_data.weather_icon_url, + sunrise=weather_data.sunrise.isoformat(), + sunset=weather_data.sunset.isoformat(), + ) + except Exception as e: + logger.error(f"Error fetching weather: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/api/search", response_model=List[SearchResultResponse]) +async def search_cities( + q: str = Query(..., min_length=2, description="City name to search"), + limit: int = Query(5, ge=1, le=20), +) -> List[SearchResultResponse]: + """Search for cities by name.""" + try: + results = await weather_service.search_cities(q, limit) + return [ + SearchResultResponse( + name=result.name, + latitude=result.latitude, + longitude=result.longitude, + country=result.country, + state=result.state, + ) + for result in results + ] + except Exception as e: + logger.error(f"Error searching cities: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/api/saved-cities", response_model=List[SavedCityResponse]) +async def get_saved_cities() -> List[SavedCityResponse]: + """Get all saved cities.""" + cities = db_manager.get_all_cities() + return [ + SavedCityResponse( + id=city.id, + city_name=city.city_name, + latitude=city.latitude, + longitude=city.longitude, + added_at=city.added_at.isoformat(), + ) + for city in cities + ] + + +@app.post("/api/saved-cities", response_model=SavedCityResponse) +async def save_city( + city_name: str = Query(..., description="City name"), + latitude: float = Query(..., description="Latitude"), + longitude: float = Query(..., description="Longitude"), +) -> SavedCityResponse: + """Save a city to favorites.""" + try: + saved_city = db_manager.add_city(city_name, latitude, longitude) + return SavedCityResponse( + id=saved_city.id, + city_name=saved_city.city_name, + latitude=saved_city.latitude, + longitude=saved_city.longitude, + added_at=saved_city.added_at.isoformat(), + ) + except Exception as e: + logger.error(f"Error saving city: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/api/saved-cities/{city_id}") +async def delete_saved_city(city_id: int) -> dict: + """Delete a saved city.""" + success = db_manager.delete_city(city_id) + if not success: + raise HTTPException(status_code=404, detail="City not found") + return {"status": "deleted"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + host=settings.host, + port=settings.port, + log_level="info", + ) diff --git a/weather_dashboard/config.py b/weather_dashboard/config.py new file mode 100644 index 0000000..fc6c3f6 --- /dev/null +++ b/weather_dashboard/config.py @@ -0,0 +1,38 @@ +"""Configuration for Weather Dashboard.""" + +from dataclasses import dataclass +from typing import Optional +import os + + +@dataclass +class Settings: + """Application settings.""" + + # API Configuration + openweather_api_key: str = os.getenv("OPENWEATHER_API_KEY", "") + openweather_base_url: str = "https://api.openweathermap.org/data/2.5" + + # Server Configuration + host: str = os.getenv("WEATHER_HOST", "127.0.0.1") + port: int = int(os.getenv("WEATHER_PORT", "8001")) + debug: bool = os.getenv("WEATHER_DEBUG", "False").lower() == "true" + + # Database Configuration + database_url: str = os.getenv("DATABASE_URL", "sqlite:///weather_dashboard.db") + + # Cache Configuration (in seconds) + cache_ttl: int = int(os.getenv("CACHE_TTL", "600")) # 10 minutes + + def __post_init__(self): + """Validate settings after initialization.""" + if not self.openweather_api_key: + raise ValueError( + "OPENWEATHER_API_KEY environment variable is required. " + "Get a free key from https://openweathermap.org/api" + ) + + +def load_settings() -> Settings: + """Load and return application settings.""" + return Settings() diff --git a/weather_dashboard/database.py b/weather_dashboard/database.py new file mode 100644 index 0000000..c8d352d --- /dev/null +++ b/weather_dashboard/database.py @@ -0,0 +1,119 @@ +"""Database operations for Weather Dashboard.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from .models import SavedCity + + +class DatabaseManager: + """Manages database operations for saved cities.""" + + def __init__(self, db_path: str = "weather_dashboard.db"): + """Initialize database manager.""" + self.db_path = db_path + self.init_db() + + def init_db(self) -> None: + """Initialize database schema.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS saved_cities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + city_name TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(city_name, latitude, longitude) + ) + """ + ) + conn.commit() + + def add_city(self, city_name: str, latitude: float, longitude: float) -> SavedCity: + """Add a city to saved cities.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + try: + cursor.execute( + """ + INSERT INTO saved_cities (city_name, latitude, longitude) + VALUES (?, ?, ?) + """, + (city_name, latitude, longitude), + ) + conn.commit() + city_id = cursor.lastrowid + + return SavedCity( + id=city_id, + city_name=city_name, + latitude=latitude, + longitude=longitude, + ) + except sqlite3.IntegrityError: + return self.get_city_by_name(city_name) + + def get_all_cities(self) -> List[SavedCity]: + """Get all saved cities.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT id, city_name, latitude, longitude, added_at FROM saved_cities ORDER BY added_at DESC" + ) + rows = cursor.fetchall() + + return [ + SavedCity( + id=row["id"], + city_name=row["city_name"], + latitude=row["latitude"], + longitude=row["longitude"], + added_at=datetime.fromisoformat(row["added_at"]), + ) + for row in rows + ] + + def get_city_by_name(self, city_name: str) -> Optional[SavedCity]: + """Get a saved city by name.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT id, city_name, latitude, longitude, added_at FROM saved_cities WHERE city_name = ?", + (city_name,), + ) + row = cursor.fetchone() + + if row: + return SavedCity( + id=row["id"], + city_name=row["city_name"], + latitude=row["latitude"], + longitude=row["longitude"], + added_at=datetime.fromisoformat(row["added_at"]), + ) + return None + + def delete_city(self, city_id: int) -> bool: + """Delete a saved city.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM saved_cities WHERE id = ?", (city_id,)) + conn.commit() + return cursor.rowcount > 0 + + def delete_city_by_name(self, city_name: str) -> bool: + """Delete a saved city by name.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM saved_cities WHERE city_name = ?", (city_name,)) + conn.commit() + return cursor.rowcount > 0 diff --git a/weather_dashboard/models.py b/weather_dashboard/models.py new file mode 100644 index 0000000..ca8107e --- /dev/null +++ b/weather_dashboard/models.py @@ -0,0 +1,105 @@ +"""Data models for Weather Dashboard.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + + +@dataclass(frozen=True) +class Coordinates: + """Geographic coordinates.""" + + latitude: float + longitude: float + + +@dataclass(frozen=True) +class WeatherMain: + """Main weather data.""" + + temperature: float + feels_like: float + temp_min: float + temp_max: float + pressure: int + humidity: int + + +@dataclass(frozen=True) +class WeatherDescription: + """Weather description data.""" + + main: str + description: str + icon: str + + +@dataclass(frozen=True) +class Wind: + """Wind data.""" + + speed: float + deg: int + gust: Optional[float] = None + + +@dataclass(frozen=True) +class WeatherData: + """Complete weather data for a location.""" + + city: str + country: str + coordinates: Coordinates + weather: WeatherDescription + main: WeatherMain + wind: Wind + cloudiness: int + sunrise: datetime + sunset: datetime + timezone: int + timestamp: datetime = field(default_factory=datetime.utcnow) + + @property + def weather_icon_url(self) -> str: + """Get the URL for the weather icon.""" + return f"https://openweathermap.org/img/wn/{self.weather.icon}@2x.png" + + +@dataclass +class SavedCity: + """Saved city preference.""" + + id: Optional[int] = None + city_name: str = "" + latitude: float = 0.0 + longitude: float = 0.0 + added_at: datetime = field(default_factory=datetime.utcnow) + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "id": self.id, + "city_name": self.city_name, + "latitude": self.latitude, + "longitude": self.longitude, + "added_at": self.added_at.isoformat(), + } + + +@dataclass +class SearchResult: + """City search result.""" + + name: str + latitude: float + longitude: float + country: str + state: Optional[str] = None + + @property + def display_name(self) -> str: + """Get display name for the city.""" + parts = [self.name, self.state or "", self.country] + return ", ".join(p for p in parts if p) diff --git a/weather_dashboard/templates.py b/weather_dashboard/templates.py new file mode 100644 index 0000000..c16b952 --- /dev/null +++ b/weather_dashboard/templates.py @@ -0,0 +1,541 @@ +"""HTML templates for Weather Dashboard.""" + +from __future__ import annotations + +HTML_TEMPLATE = """ + + +
+ + +Get real-time weather information from around the world
+No saved cities yet
+