Project 2: Weather Dashboard
Welcome to your second complete Python application! Weβre building a beautiful web-based weather dashboard that displays current conditions, forecasts, and interactive charts using real weather data.
Project Overview
WeatherWise is a full-stack web application that provides:
- π€οΈ Real-time weather conditions for any location
- π 7-day weather forecast with interactive charts
- π GPS-based location detection
- π¨ Modern, responsive web interface
- β Favorite cities management
- π Auto-refresh weather data
- π± Mobile-friendly design
Learning Objectives
By the end of this project, youβll be able to:
- Build full-stack web applications with Flask
- Integrate external APIs and handle authentication
- Design and implement database schemas
- Create responsive web interfaces with HTML/CSS/JavaScript
- Implement proper error handling and logging
- Deploy web applications for public access
Project Requirements
Core Features
-
Current Weather Display
- Temperature, humidity, wind speed, conditions
- Weather icons and descriptions
- Last updated timestamp
- Location coordinates
-
Weather Forecast
- 7-day forecast with daily highs/lows
- Hourly forecast for current day
- Weather condition probabilities
- Interactive charts and graphs
-
Location Management
- Search cities by name
- GPS-based current location detection
- Favorite cities for quick access
- Location history and preferences
-
Data Visualization
- Temperature trend charts
- Precipitation forecasts
- Wind speed and direction
- Weather condition summaries
Advanced Features
-
User Experience
- Responsive design for all devices
- Dark/light theme toggle
- Loading animations and transitions
- Error handling with user-friendly messages
-
Data Management
- SQLite database for user preferences
- Weather data caching for performance
- Favorite cities persistence
- Search history
-
API Integration
- OpenWeatherMap API integration
- Geocoding for location search
- Weather icons and images
- Error handling for API failures
Project Structure
weatherwise/
βββ app/
β βββ __init__.py # Flask application factory
β βββ routes.py # Web routes and view functions
β βββ models.py # Database models
β βββ weather_api.py # Weather API integration
β βββ forms.py # Web forms
β βββ utils.py # Helper functions
βββ templates/
β βββ base.html # Base template
β βββ index.html # Home page
β βββ city.html # City weather page
β βββ favorites.html # Favorites page
β βββ error.html # Error page
βββ static/
β βββ css/
β β βββ style.css # Main stylesheet
β β βββ responsive.css # Mobile styles
β βββ js/
β β βββ weather.js # Weather functionality
β β βββ charts.js # Chart visualizations
β β βββ app.js # General app functionality
β βββ img/
β βββ icons/ # Weather icons
β βββ logo.png
βββ migrations/ # Database migrations
βββ tests/
β βββ test_weather_api.py
β βββ test_routes.py
β βββ test_models.py
βββ config.py # Configuration settings
βββ requirements.txt # Python dependencies
βββ run.py # Application entry point
βββ README.md # Documentation
Step 1: Set Up Flask Application
Letβs start by creating the Flask application structure.
# config.py
import os
from datetime import timedelta
class Config:
"""Base configuration."""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///weatherwise.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Weather API Configuration
WEATHER_API_KEY = os.environ.get('WEATHER_API_KEY') or 'your-api-key-here'
WEATHER_API_BASE_URL = 'https://api.openweathermap.org/data/2.5'
WEATHER_ICON_URL = 'https://openweathermap.org/img/wn/{icon}@2x.png'
# Cache settings
CACHE_TYPE = 'simple'
CACHE_DEFAULT_TIMEOUT = 300 # 5 minutes
# Session settings
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
SQLALCHEMY_ECHO = True
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
# Add production-specific settings here
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_caching import Cache
import click
db = SQLAlchemy()
cache = Cache()
def create_app(config_name='default'):
"""Application factory function."""
app = Flask(__name__)
# Load configuration
from config import config
app.config.from_object(config[config_name])
# Initialize extensions
db.init_app(app)
cache.init_app(app)
# Register blueprints
from app.routes import main_bp
app.register_blueprint(main_bp)
# Register CLI commands
register_cli_commands(app)
return app
def register_cli_commands(app):
"""Register Flask CLI commands."""
@app.cli.command('init-db')
def init_db_command():
"""Initialize the database."""
db.create_all()
click.echo('Database initialized.')
@app.cli.command('create-admin')
@click.argument('username')
@click.argument('email')
def create_admin_command(username, email):
"""Create an admin user."""
from app.models import User
user = User(username=username, email=email, is_admin=True)
user.set_password('admin123') # Change in production
db.session.add(user)
db.session.commit()
click.echo(f'Admin user {username} created.')
if __name__ == '__main__':
app = create_app()
app.run()
# run.py
#!/usr/bin/env python3
"""
WeatherWise - Weather Dashboard Application
"""
import os
from app import create_app
app = create_app(os.environ.get('FLASK_ENV') or 'development')
if __name__ == '__main__':
app.run(
host='0.0.0.0',
port=int(os.environ.get('PORT', 5000)),
debug=app.config['DEBUG']
)
Step 2: Create Database Models
Design the database schema for user preferences and weather data.
# app/models.py
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from app import db
class User(UserMixin, db.Model):
"""User model for authentication and preferences."""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128))
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# User preferences
temperature_unit = db.Column(db.String(10), default='celsius') # celsius, fahrenheit
theme = db.Column(db.String(10), default='light') # light, dark
default_location = db.Column(db.String(100)) # Default city name
# Relationships
favorite_cities = db.relationship('FavoriteCity', backref='user', lazy='dynamic')
def set_password(self, password):
"""Set user password hash."""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Check user password."""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class FavoriteCity(db.Model):
"""User's favorite cities for quick access."""
id = db.Column(db.Integer, primary_key=True)
city_name = db.Column(db.String(100), nullable=False)
country_code = db.Column(db.String(10))
latitude = db.Column(db.Float)
longitude = db.Column(db.Float)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign key
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
def __repr__(self):
return f'<FavoriteCity {self.city_name}>'
class WeatherCache(db.Model):
"""Cache for weather API responses."""
id = db.Column(db.Integer, primary_key=True)
cache_key = db.Column(db.String(200), unique=True, nullable=False)
data = db.Column(db.Text, nullable=False) # JSON data
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
def is_expired(self):
"""Check if cache entry is expired."""
return datetime.utcnow() > self.expires_at
@staticmethod
def clean_expired():
"""Remove expired cache entries."""
WeatherCache.query.filter(WeatherCache.expires_at < datetime.utcnow()).delete()
db.session.commit()
class SearchHistory(db.Model):
"""Track user search history."""
id = db.Column(db.Integer, primary_key=True)
query = db.Column(db.String(200), nullable=False)
result_count = db.Column(db.Integer, default=0)
searched_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign key (optional - can be anonymous)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
def __repr__(self):
return f'<SearchHistory {self.query}>'
Step 3: Weather API Integration
Create the weather API client to fetch real weather data.
# app/weather_api.py
import requests
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from flask import current_app
from app.models import WeatherCache
from app import db
class WeatherAPI:
"""OpenWeatherMap API client."""
def __init__(self):
self.api_key = current_app.config['WEATHER_API_KEY']
self.base_url = current_app.config['WEATHER_API_BASE_URL']
self.icon_url_template = current_app.config['WEATHER_ICON_URL']
def get_current_weather(self, lat: float, lon: float) -> Optional[Dict]:
"""Get current weather for coordinates."""
cache_key = f"current_{lat}_{lon}"
cached_data = self._get_cached_data(cache_key)
if cached_data:
return cached_data
url = f"{self.base_url}/weather"
params = {
'lat': lat,
'lon': lon,
'appid': self.api_key,
'units': 'metric'
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Cache the result
self._cache_data(cache_key, data, minutes=10)
return self._format_current_weather(data)
except requests.RequestException as e:
current_app.logger.error(f"Weather API error: {e}")
return None
def get_weather_forecast(self, lat: float, lon: float) -> Optional[Dict]:
"""Get 7-day weather forecast."""
cache_key = f"forecast_{lat}_{lon}"
cached_data = self._get_cached_data(cache_key)
if cached_data:
return cached_data
url = f"{self.base_url}/onecall"
params = {
'lat': lat,
'lon': lon,
'exclude': 'minutely,alerts',
'appid': self.api_key,
'units': 'metric'
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Cache the result
self._cache_data(cache_key, data, minutes=30)
return self._format_forecast(data)
except requests.RequestException as e:
current_app.logger.error(f"Forecast API error: {e}")
return None
def geocode_city(self, city_name: str) -> List[Dict]:
"""Geocode city name to coordinates."""
cache_key = f"geocode_{city_name.lower()}"
cached_data = self._get_cached_data(cache_key)
if cached_data:
return cached_data
url = "https://api.openweathermap.org/geo/1.0/direct"
params = {
'q': city_name,
'limit': 5,
'appid': self.api_key
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Cache the result
self._cache_data(cache_key, data, hours=24)
return self._format_geocode_results(data)
except requests.RequestException as e:
current_app.logger.error(f"Geocoding API error: {e}")
return []
def reverse_geocode(self, lat: float, lon: float) -> Optional[str]:
"""Get city name from coordinates."""
cache_key = f"reverse_{lat}_{lon}"
cached_data = self._get_cached_data(cache_key)
if cached_data:
return cached_data
url = "https://api.openweathermap.org/geo/1.0/reverse"
params = {
'lat': lat,
'lon': lon,
'limit': 1,
'appid': self.api_key
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data:
city_name = f"{data[0]['name']}, {data[0]['country']}"
# Cache the result
self._cache_data(cache_key, city_name, hours=24)
return city_name
except requests.RequestException as e:
current_app.logger.error(f"Reverse geocoding API error: {e}")
return None
def _get_cached_data(self, cache_key: str) -> Optional[Dict]:
"""Get data from cache if available and not expired."""
cache_entry = WeatherCache.query.filter_by(cache_key=cache_key).first()
if cache_entry and not cache_entry.is_expired():
try:
return json.loads(cache_entry.data)
except json.JSONDecodeError:
pass
return None
def _cache_data(self, cache_key: str, data: Dict, minutes: int = 10, hours: int = 0):
"""Cache data with expiration time."""
expires_at = datetime.utcnow() + timedelta(minutes=minutes, hours=hours)
# Remove existing cache entry
WeatherCache.query.filter_by(cache_key=cache_key).delete()
# Add new cache entry
cache_entry = WeatherCache(
cache_key=cache_key,
data=json.dumps(data),
expires_at=expires_at
)
db.session.add(cache_entry)
db.session.commit()
def _format_current_weather(self, data: Dict) -> Dict:
"""Format current weather data for frontend."""
return {
'location': {
'name': data['name'],
'country': data['sys']['country'],
'lat': data['coord']['lat'],
'lon': data['coord']['lon']
},
'weather': {
'main': data['weather'][0]['main'],
'description': data['weather'][0]['description'],
'icon': self.icon_url_template.format(icon=data['weather'][0]['icon'])
},
'temperature': {
'current': round(data['main']['temp']),
'feels_like': round(data['main']['feels_like']),
'min': round(data['main']['temp_min']),
'max': round(data['main']['temp_max'])
},
'details': {
'humidity': data['main']['humidity'],
'pressure': data['main']['pressure'],
'wind_speed': data['wind']['speed'],
'wind_direction': data['wind'].get('deg', 0),
'visibility': data.get('visibility', 0) / 1000, # Convert to km
'clouds': data['clouds']['all']
},
'sun': {
'sunrise': datetime.fromtimestamp(data['sys']['sunrise']).strftime('%H:%M'),
'sunset': datetime.fromtimestamp(data['sys']['sunset']).strftime('%H:%M')
},
'updated_at': datetime.now().isoformat()
}
def _format_forecast(self, data: Dict) -> Dict:
"""Format forecast data for frontend."""
# Current hour details
hourly = []
for hour in data['hourly'][:24]: # Next 24 hours
hourly.append({
'time': datetime.fromtimestamp(hour['dt']).strftime('%H:%M'),
'temp': round(hour['temp']),
'feels_like': round(hour['feels_like']),
'humidity': hour['humidity'],
'wind_speed': hour['wind_speed'],
'weather': {
'main': hour['weather'][0]['main'],
'description': hour['weather'][0]['description'],
'icon': self.icon_url_template.format(icon=hour['weather'][0]['icon'])
},
'precipitation': hour.get('rain', {}).get('1h', 0) + hour.get('snow', {}).get('1h', 0)
})
# Daily forecast
daily = []
for day in data['daily'][:7]: # Next 7 days
daily.append({
'date': datetime.fromtimestamp(day['dt']).strftime('%Y-%m-%d'),
'day': datetime.fromtimestamp(day['dt']).strftime('%A'),
'temp': {
'min': round(day['temp']['min']),
'max': round(day['temp']['max']),
'morning': round(day['temp']['morn']),
'day': round(day['temp']['day']),
'evening': round(day['temp']['eve']),
'night': round(day['temp']['night'])
},
'weather': {
'main': day['weather'][0]['main'],
'description': day['weather'][0]['description'],
'icon': self.icon_url_template.format(icon=day['weather'][0]['icon'])
},
'precipitation': {
'probability': day['pop'] * 100, # Convert to percentage
'rain': day.get('rain', 0),
'snow': day.get('snow', 0)
},
'humidity': day['humidity'],
'wind_speed': day['wind_speed'],
'uvi': day['uvi']
})
return {
'hourly': hourly,
'daily': daily,
'updated_at': datetime.now().isoformat()
}
def _format_geocode_results(self, data: List[Dict]) -> List[Dict]:
"""Format geocoding results."""
results = []
for item in data:
results.append({
'name': item['name'],
'country': item['country'],
'state': item.get('state', ''),
'lat': item['lat'],
'lon': item['lon'],
'display_name': f"{item['name']}, {item.get('state', '')}, {item['country']}".strip(', ')
})
return results
Step 4: Create Web Routes
Implement the Flask routes for the web interface.
# app/routes.py
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app
from flask_login import login_required, current_user
import json
from datetime import datetime
from app.weather_api import WeatherAPI
from app.models import FavoriteCity, SearchHistory, db
from app.forms import CitySearchForm, FavoriteCityForm
from app.utils import get_client_ip, validate_coordinates
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Home page with current location weather."""
form = CitySearchForm()
# Try to get user's location from IP or default
lat, lon = None, None
# Check if user has a default location
if current_user.is_authenticated and current_user.default_location:
# Geocode the default location
weather_api = WeatherAPI()
geocode_results = weather_api.geocode_city(current_user.default_location)
if geocode_results:
lat, lon = geocode_results[0]['lat'], geocode_results[0]['lon']
# If no default location, try to get from request
if not lat and request.args.get('lat') and request.args.get('lon'):
try:
lat = float(request.args.get('lat'))
lon = float(request.args.get('lon'))
if not validate_coordinates(lat, lon):
lat, lon = None, None
except ValueError:
lat, lon = None, None
weather_data = None
forecast_data = None
if lat and lon:
weather_api = WeatherAPI()
weather_data = weather_api.get_current_weather(lat, lon)
forecast_data = weather_api.get_weather_forecast(lat, lon)
return render_template('index.html',
weather=weather_data,
forecast=forecast_data,
form=form)
@main_bp.route('/city/<city_name>')
def city_weather(city_name):
"""Weather page for a specific city."""
form = CitySearchForm()
weather_api = WeatherAPI()
geocode_results = weather_api.geocode_city(city_name)
if not geocode_results:
flash(f"City '{city_name}' not found.", 'error')
return redirect(url_for('main.index'))
# Use the first result
city_data = geocode_results[0]
lat, lon = city_data['lat'], city_data['lon']
weather_data = weather_api.get_current_weather(lat, lon)
forecast_data = weather_api.get_weather_forecast(lat, lon)
# Log search history
if current_user.is_authenticated:
search_history = SearchHistory(
query=city_name,
result_count=len(geocode_results),
user_id=current_user.id
)
db.session.add(search_history)
db.session.commit()
return render_template('city.html',
city=city_data,
weather=weather_data,
forecast=forecast_data,
form=form)
@main_bp.route('/search', methods=['POST'])
def search_city():
"""Search for cities."""
form = CitySearchForm()
if form.validate_on_submit():
city_name = form.city.data
weather_api = WeatherAPI()
results = weather_api.geocode_city(city_name)
if not results:
flash(f"No cities found for '{city_name}'.", 'warning')
return redirect(url_for('main.index'))
if len(results) == 1:
# Direct redirect if only one result
return redirect(url_for('main.city_weather', city_name=city_name))
else:
# Show search results page (you could create this)
return render_template('search_results.html',
query=city_name,
results=results,
form=form)
return redirect(url_for('main.index'))
@main_bp.route('/api/weather/<float:lat>/<float:lon>')
def api_weather(lat, lon):
"""API endpoint for weather data."""
if not validate_coordinates(lat, lon):
return jsonify({'error': 'Invalid coordinates'}), 400
weather_api = WeatherAPI()
weather_data = weather_api.get_current_weather(lat, lon)
if weather_data:
return jsonify(weather_data)
else:
return jsonify({'error': 'Weather data not available'}), 503
@main_bp.route('/api/forecast/<float:lat>/<float:lon>')
def api_forecast(lat, lon):
"""API endpoint for forecast data."""
if not validate_coordinates(lat, lon):
return jsonify({'error': 'Invalid coordinates'}), 400
weather_api = WeatherAPI()
forecast_data = weather_api.get_weather_forecast(lat, lon)
if forecast_data:
return jsonify(forecast_data)
else:
return jsonify({'error': 'Forecast data not available'}), 503
@main_bp.route('/favorites')
@login_required
def favorites():
"""User's favorite cities."""
form = FavoriteCityForm()
favorites = current_user.favorite_cities.all()
# Get weather for favorite cities
weather_api = WeatherAPI()
favorites_weather = []
for fav in favorites:
weather = weather_api.get_current_weather(fav.latitude, fav.longitude)
if weather:
favorites_weather.append({
'city': fav,
'weather': weather
})
return render_template('favorites.html',
favorites=favorites_weather,
form=form)
@main_bp.route('/add_favorite', methods=['POST'])
@login_required
def add_favorite():
"""Add a city to favorites."""
form = FavoriteCityForm()
if form.validate_on_submit():
city_name = form.city_name.data
# Check if already in favorites
existing = FavoriteCity.query.filter_by(
user_id=current_user.id,
city_name=city_name
).first()
if existing:
flash(f"'{city_name}' is already in your favorites.", 'info')
return redirect(url_for('main.favorites'))
# Geocode the city
weather_api = WeatherAPI()
geocode_results = weather_api.geocode_city(city_name)
if not geocode_results:
flash(f"City '{city_name}' not found.", 'error')
return redirect(url_for('main.favorites'))
# Add to favorites
city_data = geocode_results[0]
favorite = FavoriteCity(
city_name=city_data['name'],
country_code=city_data['country'],
latitude=city_data['lat'],
longitude=city_data['lon'],
user_id=current_user.id
)
db.session.add(favorite)
db.session.commit()
flash(f"'{city_name}' added to favorites!", 'success')
return redirect(url_for('main.favorites'))
@main_bp.route('/remove_favorite/<int:favorite_id>', methods=['POST'])
@login_required
def remove_favorite(favorite_id):
"""Remove a city from favorites."""
favorite = FavoriteCity.query.get_or_404(favorite_id)
# Ensure user owns this favorite
if favorite.user_id != current_user.id:
flash("You don't have permission to remove this favorite.", 'error')
return redirect(url_for('main.favorites'))
db.session.delete(favorite)
db.session.commit()
flash(f"'{favorite.city_name}' removed from favorites.", 'success')
return redirect(url_for('main.favorites'))
@main_bp.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
"""User settings page."""
if request.method == 'POST':
# Update user preferences
temperature_unit = request.form.get('temperature_unit')
theme = request.form.get('theme')
default_location = request.form.get('default_location')
if temperature_unit in ['celsius', 'fahrenheit']:
current_user.temperature_unit = temperature_unit
if theme in ['light', 'dark']:
current_user.theme = theme
current_user.default_location = default_location or None
db.session.commit()
flash('Settings updated successfully!', 'success')
return render_template('settings.html')
@main_bp.errorhandler(404)
def page_not_found(e):
"""Handle 404 errors."""
return render_template('error.html', error_code=404, error_message="Page not found"), 404
@main_bp.errorhandler(500)
def internal_error(e):
"""Handle 500 errors."""
current_app.logger.error(f"Internal error: {e}")
return render_template('error.html', error_code=500, error_message="Internal server error"), 500
Step 5: Create HTML Templates
Design the web interface with modern HTML and CSS.
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}WeatherWise - Weather Dashboard{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body class="{% if current_user.is_authenticated and current_user.theme == 'dark' %}dark-theme{% endif %}">
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<i class="fas fa-cloud-sun"></i> WeatherWise
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.index') }}">
<i class="fas fa-home"></i> Home
</a>
</li>
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.favorites') }}">
<i class="fas fa-star"></i> Favorites
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.settings') }}">
<i class="fas fa-cog"></i> Settings
</a>
</li>
{% endif %}
</ul>
<!-- Search Form -->
<form class="d-flex" method="POST" action="{{ url_for('main.search_city') }}">
<input class="form-control me-2" type="search" name="city" placeholder="Search city..." required>
<button class="btn btn-outline-light" type="submit">
<i class="fas fa-search"></i>
</button>
</form>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'warning' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-light text-center text-muted mt-5 py-3">
<div class="container">
<p>© 2024 WeatherWise. Built with Flask and OpenWeatherMap API.</p>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Chart.js for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom JavaScript -->
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- templates/index.html -->
{% extends "base.html" %}
{% block title %}Weather Dashboard - {{ weather.location.name if weather else 'WeatherWise' }}{% endblock %}
{% block content %}
<div class="row">
<!-- Current Weather -->
<div class="col-lg-8">
{% if weather %}
<div class="card weather-card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">
<i class="fas fa-map-marker-alt"></i>
{{ weather.location.name }}, {{ weather.location.country }}
</h5>
<small>Last updated: {{ weather.updated_at | strftime('%H:%M') }}</small>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="current-weather">
<img src="{{ weather.weather.icon }}" alt="{{ weather.weather.description }}" class="weather-icon">
<div class="temperature">
<span class="temp-main">{{ weather.temperature.current }}Β°C</span>
<span class="temp-feels">Feels like {{ weather.temperature.feels_like }}Β°C</span>
</div>
<div class="weather-desc">{{ weather.weather.description | title }}</div>
</div>
</div>
<div class="col-md-6">
<div class="weather-details">
<div class="detail-row">
<span class="detail-label">High / Low:</span>
<span class="detail-value">{{ weather.temperature.max }}Β° / {{ weather.temperature.min }}Β°</span>
</div>
<div class="detail-row">
<span class="detail-label">Humidity:</span>
<span class="detail-value">{{ weather.details.humidity }}%</span>
</div>
<div class="detail-row">
<span class="detail-label">Wind:</span>
<span class="detail-value">{{ weather.details.wind_speed }} m/s</span>
</div>
<div class="detail-row">
<span class="detail-label">Pressure:</span>
<span class="detail-value">{{ weather.details.pressure }} hPa</span>
</div>
<div class="detail-row">
<span class="detail-label">Visibility:</span>
<span class="detail-value">{{ "%.1f"|format(weather.details.visibility) }} km</span>
</div>
<div class="detail-row">
<span class="detail-label">Sunrise / Sunset:</span>
<span class="detail-value">{{ weather.sun.sunrise }} / {{ weather.sun.sunset }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center">
<i class="fas fa-cloud-sun fa-3x text-muted mb-3"></i>
<h5>Weather data not available</h5>
<p>Please search for a city or allow location access.</p>
<button class="btn btn-primary" onclick="getCurrentLocation()">
<i class="fas fa-location-arrow"></i> Use Current Location
</button>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Search Card -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0"><i class="fas fa-search"></i> Search City</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('main.search_city') }}">
<div class="input-group">
<input type="text" class="form-control" name="city" placeholder="Enter city name..." required>
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
</div>
{% if current_user.is_authenticated %}
<!-- Quick Actions -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0"><i class="fas fa-star"></i> Quick Actions</h6>
</div>
<div class="card-body">
<a href="{{ url_for('main.favorites') }}" class="btn btn-outline-primary btn-sm mb-2">
<i class="fas fa-star"></i> View Favorites
</a>
<button class="btn btn-outline-secondary btn-sm" onclick="getCurrentLocation()">
<i class="fas fa-location-arrow"></i> Current Location
</button>
</div>
</div>
{% endif %}
<!-- Weather Tips -->
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0"><i class="fas fa-info-circle"></i> Weather Tips</h6>
</div>
<div class="card-body">
<ul class="list-unstyled small">
<li><i class="fas fa-umbrella text-primary"></i> Rain expected? Don't forget your umbrella!</li>
<li><i class="fas fa-sun text-warning"></i> UV Index high? Wear sunscreen and a hat.</li>
<li><i class="fas fa-wind text-info"></i> Windy day? Secure loose outdoor items.</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Forecast Section -->
{% if forecast %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0"><i class="fas fa-calendar-alt"></i> 7-Day Forecast</h5>
</div>
<div class="card-body">
<div class="forecast-container">
{% for day in forecast.daily %}
<div class="forecast-day">
<div class="day-name">{{ day.day[:3] }}</div>
<img src="{{ day.weather.icon }}" alt="{{ day.weather.description }}" class="forecast-icon">
<div class="temp-high">{{ day.temp.max }}Β°</div>
<div class="temp-low">{{ day.temp.min }}Β°</div>
<div class="precipitation">{{ "%.0f"|format(day.precipitation.probability) }}%</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/weather.js') }}"></script>
{% endblock %}
Step 6: Add CSS Styling
Create beautiful, responsive styles for the weather dashboard.
/* static/css/style.css */
/* Weather Card Styles */
.weather-card {
border: none;
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.weather-card .card-header {
border-radius: 15px 15px 0 0 !important;
}
.current-weather {
text-align: center;
padding: 20px 0;
}
.weather-icon {
width: 80px;
height: 80px;
margin-bottom: 10px;
}
.temperature .temp-main {
font-size: 3rem;
font-weight: 300;
color: #333;
}
.temperature .temp-feels {
font-size: 0.9rem;
color: #666;
margin-top: -5px;
}
.weather-desc {
font-size: 1.1rem;
color: #555;
margin-top: 5px;
}
.weather-details {
padding-left: 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: 500;
color: #666;
}
.detail-value {
font-weight: 600;
color: #333;
}
/* Forecast Styles */
.forecast-container {
display: flex;
justify-content: space-between;
overflow-x: auto;
padding: 10px 0;
}
.forecast-day {
text-align: center;
min-width: 100px;
padding: 10px;
border-radius: 10px;
background: #f8f9fa;
margin: 0 5px;
}
.forecast-icon {
width: 40px;
height: 40px;
margin: 5px 0;
}
.temp-high {
font-weight: 600;
color: #d9534f;
}
.temp-low {
color: #5bc0de;
margin-top: -5px;
}
.day-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.precipitation {
font-size: 0.8rem;
color: #666;
margin-top: 5px;
}
/* Dark Theme */
.dark-theme {
background-color: #1a1a1a;
color: #ffffff;
}
.dark-theme .navbar {
background-color: #2c3e50 !important;
}
.dark-theme .card {
background-color: #2c3e50;
color: #ffffff;
border-color: #34495e;
}
.dark-theme .forecast-day {
background: #34495e;
color: #ffffff;
}
.dark-theme .detail-row {
border-bottom-color: #34495e;
}
/* Responsive Design */
@media (max-width: 768px) {
.current-weather {
margin-bottom: 20px;
}
.weather-details {
padding-left: 0;
text-align: center;
}
.forecast-container {
padding-bottom: 20px;
}
.forecast-day {
min-width: 80px;
padding: 8px;
}
}
/* Loading Animation */
.loading {
opacity: 0.6;
pointer-events: none;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Weather Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.weather-card {
animation: fadeIn 0.5s ease-out;
}
.forecast-day {
transition: transform 0.2s ease;
}
.forecast-day:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
Step 7: Add JavaScript Functionality
Create interactive features with JavaScript.
// static/js/weather.js
// Get current location weather
function getCurrentLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function(position) {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
// Redirect to weather page with coordinates
window.location.href = `/?lat=${lat}&lon=${lon}`;
},
function(error) {
console.error('Geolocation error:', error);
alert('Unable to get your location. Please search for a city instead.');
}
);
} else {
alert('Geolocation is not supported by this browser.');
}
}
// Auto-refresh weather data
let autoRefreshInterval;
function startAutoRefresh() {
// Refresh every 10 minutes
autoRefreshInterval = setInterval(function() {
if (document.visibilityState === 'visible') {
location.reload();
}
}, 10 * 60 * 1000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
}
// Start auto-refresh when page loads
document.addEventListener('DOMContentLoaded', function() {
startAutoRefresh();
// Stop auto-refresh when page is not visible
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') {
stopAutoRefresh();
} else {
startAutoRefresh();
}
});
});
// Temperature unit conversion
function convertTemperature(temp, fromUnit, toUnit) {
if (fromUnit === toUnit) return temp;
if (fromUnit === 'celsius' && toUnit === 'fahrenheit') {
return (temp * 9/5) + 32;
} else if (fromUnit === 'fahrenheit' && toUnit === 'celsius') {
return (temp - 32) * 5/9;
}
return temp;
}
// Weather data caching
const weatherCache = new Map();
function cacheWeatherData(key, data, ttl = 10 * 60 * 1000) { // 10 minutes default
const expiry = Date.now() + ttl;
weatherCache.set(key, { data, expiry });
}
function getCachedWeatherData(key) {
const cached = weatherCache.get(key);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
weatherCache.delete(key);
return null;
}
// Error handling
function showWeatherError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
errorDiv.innerHTML = `
<i class="fas fa-exclamation-triangle"></i> ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const container = document.querySelector('.container');
container.insertBefore(errorDiv, container.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
errorDiv.remove();
}, 5000);
}
// Loading states
function setLoading(element, loading) {
if (loading) {
element.classList.add('loading');
const spinner = document.createElement('div');
spinner.className = 'spinner';
element.appendChild(spinner);
} else {
element.classList.remove('loading');
const spinner = element.querySelector('.spinner');
if (spinner) {
spinner.remove();
}
}
}
// Search suggestions (autocomplete)
let searchTimeout;
function setupSearchSuggestions() {
const searchInput = document.querySelector('input[name="city"]');
if (!searchInput) return;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (query.length < 2) return;
searchTimeout = setTimeout(() => {
fetch(`/api/geocode/${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
showSearchSuggestions(data);
})
.catch(error => {
console.error('Search suggestion error:', error);
});
}, 300);
});
}
function showSearchSuggestions(suggestions) {
// Remove existing suggestions
const existing = document.querySelector('.search-suggestions');
if (existing) existing.remove();
if (!suggestions || suggestions.length === 0) return;
const suggestionsDiv = document.createElement('div');
suggestionsDiv.className = 'search-suggestions list-group position-absolute w-100';
suggestionsDiv.style.zIndex = '1000';
suggestions.forEach(suggestion => {
const item = document.createElement('button');
item.className = 'list-group-item list-group-item-action';
item.textContent = suggestion.display_name;
item.addEventListener('click', () => {
document.querySelector('input[name="city"]').value = suggestion.name;
suggestionsDiv.remove();
// Submit form
item.closest('form').submit();
});
suggestionsDiv.appendChild(item);
});
// Position suggestions
const input = document.querySelector('input[name="city"]');
input.parentNode.style.position = 'relative';
input.parentNode.appendChild(suggestionsDiv);
// Hide suggestions when clicking outside
document.addEventListener('click', function hideSuggestions(e) {
if (!suggestionsDiv.contains(e.target) && e.target !== input) {
suggestionsDiv.remove();
document.removeEventListener('click', hideSuggestions);
}
});
}
// Initialize features when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupSearchSuggestions();
// Add loading states to forms
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', function() {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Loading...';
}
});
});
});
Step 8: Create Requirements and Setup
# requirements.txt
Flask==2.3.3
Flask-SQLAlchemy==3.0.5
Flask-Caching==2.1.0
requests==2.31.0
python-dotenv==1.0.0
Werkzeug==2.3.7
Jinja2==3.1.2
Click==8.1.7
# .env.example
SECRET_KEY=your-secret-key-here
WEATHER_API_KEY=your-openweathermap-api-key-here
FLASK_ENV=development
DATABASE_URL=sqlite:///weatherwise.db
Step 9: Testing the Application
Create comprehensive tests for the weather dashboard.
# tests/test_weather_api.py
import unittest
from unittest.mock import patch, MagicMock
from app.weather_api import WeatherAPI
class TestWeatherAPI(unittest.TestCase):
"""Test cases for WeatherAPI class."""
def setUp(self):
"""Set up test fixtures."""
self.api = WeatherAPI()
@patch('app.weather_api.requests.get')
def test_get_current_weather_success(self, mock_get):
"""Test successful current weather retrieval."""
# Mock response
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {
'name': 'London',
'sys': {'country': 'GB'},
'coord': {'lat': 51.5074, 'lon': -0.1278},
'weather': [{'main': 'Clouds', 'description': 'overcast clouds', 'icon': '04d'}],
'main': {
'temp': 15.5, 'feels_like': 14.8, 'temp_min': 13.0, 'temp_max': 18.0,
'humidity': 72, 'pressure': 1013
},
'wind': {'speed': 3.5, 'deg': 180},
'visibility': 10000,
'clouds': {'all': 90},
'sys': {'sunrise': 1609459200, 'sunset': 1609488000}
}
mock_get.return_value = mock_response
result = self.api.get_current_weather(51.5074, -0.1278)
self.assertIsNotNone(result)
self.assertEqual(result['location']['name'], 'London')
self.assertEqual(result['temperature']['current'], 16) # Rounded
self.assertEqual(result['weather']['main'], 'Clouds')
@patch('app.weather_api.requests.get')
def test_get_current_weather_api_error(self, mock_get):
"""Test handling of API errors."""
mock_get.side_effect = Exception("API Error")
result = self.api.get_current_weather(51.5074, -0.1278)
self.assertIsNone(result)
def test_format_current_weather(self):
"""Test weather data formatting."""
raw_data = {
'name': 'Paris',
'sys': {'country': 'FR'},
'coord': {'lat': 48.8566, 'lon': 2.3522},
'weather': [{'main': 'Clear', 'description': 'clear sky', 'icon': '01d'}],
'main': {'temp': 20.0, 'feels_like': 19.5, 'temp_min': 18.0, 'temp_max': 22.0, 'humidity': 65, 'pressure': 1015},
'wind': {'speed': 2.1, 'deg': 90},
'visibility': 10000,
'clouds': {'all': 20},
'sys': {'sunrise': 1609459200, 'sunset': 1609488000}
}
result = self.api._format_current_weather(raw_data)
self.assertEqual(result['location']['name'], 'Paris')
self.assertEqual(result['temperature']['current'], 20)
self.assertEqual(result['weather']['main'], 'Clear')
self.assertEqual(result['details']['humidity'], 65)
if __name__ == '__main__':
unittest.main()
Step 10: Deployment Preparation
Create deployment configurations for production.
# wsgi.py
from app import create_app
app = create_app('production')
if __name__ == "__main__":
app.run()
# Procfile (for Heroku deployment)
web: gunicorn wsgi:app
# Dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "run.py"]
Usage Examples
Basic Usage
# Install dependencies
pip install -r requirements.txt
# Set environment variables
export WEATHER_API_KEY=your-api-key-here
export SECRET_KEY=your-secret-key-here
# Initialize database
flask init-db
# Run the application
python run.py
API Endpoints
# Get current weather
GET /api/weather/51.5074/-0.1278
# Get forecast
GET /api/forecast/51.5074/-0.1278
# Search cities
POST /search (form data: city="London")
User Features
# Add favorite city
POST /add_favorite (form data: city_name="London")
# View favorites
GET /favorites
# Update settings
POST /settings (form data: temperature_unit="fahrenheit", theme="dark")
Advanced Features to Consider
- Weather Alerts - Severe weather notifications
- Historical Data - Past weather trends
- Weather Maps - Interactive weather maps
- Multiple Locations - Compare weather across cities
- Weather Widgets - Embeddable weather widgets
- Mobile App - Companion mobile application
- Weather API - Public API for other developers
Summary
WeatherWise demonstrates modern web development:
Core Technologies:
- Flask web framework with application factory pattern
- SQLAlchemy ORM for database management
- OpenWeatherMap API integration
- Responsive HTML/CSS with Bootstrap
- Interactive JavaScript features
Key Skills:
- Full-stack web application development
- RESTful API design and consumption
- Database design and relationships
- User authentication and sessions
- Error handling and logging
- Responsive web design
- API rate limiting and caching
Production Features:
- Environment-based configuration
- Database migrations
- Caching for performance
- Error monitoring and logging
- Security best practices
Next Steps:
- Obtain OpenWeatherMap API key
- Implement user authentication
- Add comprehensive error handling
- Deploy to cloud platform
- Add advanced weather features
Congratulations! Youβve built a complete web application! π¦οΈ
Ready for the next project? Letβs build a File Organizer! π