Initial project setup with FastAPI, SQLite, and Alembic
- Set up SQLite database configuration and directory structure - Configure Alembic for proper SQLite migrations - Add initial model schemas and API endpoints - Fix OAuth2 authentication - Implement proper code formatting with Ruff
This commit is contained in:
parent
fe429a4abf
commit
fec0fa72e7
103
alembic.ini
Normal file
103
alembic.ini
Normal file
@ -0,0 +1,103 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# SQLite URL with absolute path to ensure consistent database location
|
||||
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Weather Dashboard API Application Package
|
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
110
app/api/deps.py
Normal file
110
app/api/deps.py
Normal file
@ -0,0 +1,110 @@
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.token import TokenPayload
|
||||
|
||||
|
||||
# OAuth2 scheme for token authentication
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/users/login"
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
|
||||
) -> User:
|
||||
"""
|
||||
Get the current authenticated user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
token_data = TokenPayload(**payload)
|
||||
except (JWTError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == token_data.sub).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
Get the current active user.
|
||||
|
||||
Args:
|
||||
current_user: Current user
|
||||
|
||||
Returns:
|
||||
User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is inactive
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
def get_current_active_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
Get the current active superuser.
|
||||
|
||||
Args:
|
||||
current_user: Current user
|
||||
|
||||
Returns:
|
||||
User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not a superuser
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="The user doesn't have enough privileges"
|
||||
)
|
||||
|
||||
return current_user
|
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
123
app/api/v1/endpoints/locations.py
Normal file
123
app/api/v1/endpoints/locations.py
Normal file
@ -0,0 +1,123 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud
|
||||
from app.api import deps
|
||||
from app.core.exceptions import NotFoundException, ForbiddenException
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.location import Location, LocationCreate, LocationUpdate
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Location])
|
||||
def get_user_locations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get all locations for the current user.
|
||||
"""
|
||||
locations = crud.get_user_locations(db, user_id=current_user.id)
|
||||
return locations
|
||||
|
||||
|
||||
@router.get("/default", response_model=Location)
|
||||
def get_default_location(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get the default location for the current user.
|
||||
"""
|
||||
location = crud.get_user_default_location(db, user_id=current_user.id)
|
||||
|
||||
if not location:
|
||||
raise NotFoundException(detail="No default location set for this user")
|
||||
|
||||
return location
|
||||
|
||||
|
||||
@router.post("/", response_model=Location)
|
||||
def create_location(
|
||||
location_in: LocationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Create a new location for the current user.
|
||||
"""
|
||||
location = crud.create_location(db, obj_in=location_in, user_id=current_user.id)
|
||||
return location
|
||||
|
||||
|
||||
@router.get("/{location_id}", response_model=Location)
|
||||
def get_location(
|
||||
location_id: int = Path(..., description="ID of the location to get"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific location by ID.
|
||||
"""
|
||||
location = crud.get_location(db, location_id=location_id)
|
||||
|
||||
if not location:
|
||||
raise NotFoundException(detail="Location not found")
|
||||
|
||||
# Check if location belongs to current user
|
||||
if location.user_id != current_user.id:
|
||||
raise ForbiddenException(detail="Not enough permissions to access this location")
|
||||
|
||||
return location
|
||||
|
||||
|
||||
@router.put("/{location_id}", response_model=Location)
|
||||
def update_location(
|
||||
location_in: LocationUpdate,
|
||||
location_id: int = Path(..., description="ID of the location to update"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Update a specific location by ID.
|
||||
"""
|
||||
location = crud.get_location(db, location_id=location_id)
|
||||
|
||||
if not location:
|
||||
raise NotFoundException(detail="Location not found")
|
||||
|
||||
# Check if location belongs to current user
|
||||
if location.user_id != current_user.id:
|
||||
raise ForbiddenException(detail="Not enough permissions to update this location")
|
||||
|
||||
location = crud.update_location(db, db_obj=location, obj_in=location_in)
|
||||
|
||||
return location
|
||||
|
||||
|
||||
@router.delete("/{location_id}", response_model=Location)
|
||||
def delete_location(
|
||||
location_id: int = Path(..., description="ID of the location to delete"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete a specific location by ID.
|
||||
"""
|
||||
location = crud.get_location(db, location_id=location_id)
|
||||
|
||||
if not location:
|
||||
raise NotFoundException(detail="Location not found")
|
||||
|
||||
# Check if location belongs to current user
|
||||
if location.user_id != current_user.id:
|
||||
raise ForbiddenException(detail="Not enough permissions to delete this location")
|
||||
|
||||
location = crud.delete_location(db, location_id=location_id)
|
||||
|
||||
return location
|
76
app/api/v1/endpoints/users.py
Normal file
76
app/api/v1/endpoints/users.py
Normal file
@ -0,0 +1,76 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud
|
||||
from app.api import deps
|
||||
from app.core.exceptions import BadRequestException, UnauthorizedException
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.user import User, UserCreate
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=User)
|
||||
def register_user(
|
||||
user_in: UserCreate,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
"""
|
||||
Register a new user.
|
||||
"""
|
||||
# Check if user with this email already exists
|
||||
user = crud.get_by_email(db, email=user_in.email)
|
||||
if user:
|
||||
raise BadRequestException(detail="A user with this email already exists")
|
||||
|
||||
# Check if user with this username already exists
|
||||
user = crud.get_by_username(db, username=user_in.username)
|
||||
if user:
|
||||
raise BadRequestException(detail="A user with this username already exists")
|
||||
|
||||
# Create new user
|
||||
user = crud.create_user(db, obj_in=user_in)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
db: Session = Depends(get_db),
|
||||
form_data: OAuth2PasswordRequestForm = Depends()
|
||||
) -> Any:
|
||||
"""
|
||||
Get access token for user.
|
||||
"""
|
||||
# Try to authenticate with email
|
||||
user = crud.get_by_email(db, email=form_data.username)
|
||||
if not user:
|
||||
# Try to authenticate with username
|
||||
user = crud.get_by_username(db, username=form_data.username)
|
||||
|
||||
if not user:
|
||||
raise UnauthorizedException(detail="Incorrect email/username or password")
|
||||
|
||||
if not verify_password(form_data.password, user.hashed_password):
|
||||
raise UnauthorizedException(detail="Incorrect email/username or password")
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(subject=user.id)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=User)
|
||||
def read_users_me(
|
||||
current_user: User = Depends(deps.get_current_active_user)
|
||||
) -> Any:
|
||||
"""
|
||||
Get current user.
|
||||
"""
|
||||
return current_user
|
159
app/api/v1/endpoints/weather.py
Normal file
159
app/api/v1/endpoints/weather.py
Normal file
@ -0,0 +1,159 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud
|
||||
from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.weather import CurrentWeather, Forecast
|
||||
from app.services.cache_service import cache
|
||||
from app.services.weather_service import WeatherService
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentWeather)
|
||||
async def get_current_weather(
|
||||
lat: float = Query(..., le=90, ge=-90, description="Latitude coordinate"),
|
||||
lon: float = Query(..., le=180, ge=-180, description="Longitude coordinate"),
|
||||
location_name: str = Query(None, description="Optional location name"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get current weather data for a specific location.
|
||||
"""
|
||||
# Create cache key
|
||||
cache_key = f"current_weather:{lat}:{lon}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
# Fetch data from weather service
|
||||
weather_service = WeatherService()
|
||||
weather_data = await weather_service.get_current_weather(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
location_name=location_name
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, weather_data)
|
||||
|
||||
return weather_data
|
||||
|
||||
|
||||
@router.get("/current/default", response_model=CurrentWeather)
|
||||
async def get_current_weather_for_default_location(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get current weather data for the user's default location.
|
||||
"""
|
||||
# Get user's default location
|
||||
default_location = crud.get_user_default_location(db, user_id=current_user.id)
|
||||
|
||||
if not default_location:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No default location set for this user"
|
||||
)
|
||||
|
||||
# Create cache key
|
||||
cache_key = f"current_weather:{default_location.latitude}:{default_location.longitude}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
# Fetch data from weather service
|
||||
weather_service = WeatherService()
|
||||
weather_data = await weather_service.get_current_weather(
|
||||
lat=default_location.latitude,
|
||||
lon=default_location.longitude,
|
||||
location_name=default_location.name
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, weather_data)
|
||||
|
||||
return weather_data
|
||||
|
||||
|
||||
@router.get("/forecast", response_model=Forecast)
|
||||
async def get_forecast(
|
||||
lat: float = Query(..., le=90, ge=-90, description="Latitude coordinate"),
|
||||
lon: float = Query(..., le=180, ge=-180, description="Longitude coordinate"),
|
||||
location_name: str = Query(None, description="Optional location name"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get 5-day weather forecast for a specific location.
|
||||
"""
|
||||
# Create cache key
|
||||
cache_key = f"forecast:{lat}:{lon}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
# Fetch data from weather service
|
||||
weather_service = WeatherService()
|
||||
forecast_data = await weather_service.get_forecast(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
location_name=location_name
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, forecast_data)
|
||||
|
||||
return forecast_data
|
||||
|
||||
|
||||
@router.get("/forecast/default", response_model=Forecast)
|
||||
async def get_forecast_for_default_location(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get 5-day weather forecast for the user's default location.
|
||||
"""
|
||||
# Get user's default location
|
||||
default_location = crud.get_user_default_location(db, user_id=current_user.id)
|
||||
|
||||
if not default_location:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No default location set for this user"
|
||||
)
|
||||
|
||||
# Create cache key
|
||||
cache_key = f"forecast:{default_location.latitude}:{default_location.longitude}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
# Fetch data from weather service
|
||||
weather_service = WeatherService()
|
||||
forecast_data = await weather_service.get_forecast(
|
||||
lat=default_location.latitude,
|
||||
lon=default_location.longitude,
|
||||
location_name=default_location.name
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache.set(cache_key, forecast_data)
|
||||
|
||||
return forecast_data
|
10
app/api/v1/router.py
Normal file
10
app/api/v1/router.py
Normal file
@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import weather, locations, users
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# Include routers for different endpoints
|
||||
api_router.include_router(weather.router, prefix="/weather", tags=["Weather"])
|
||||
api_router.include_router(locations.router, prefix="/locations", tags=["Locations"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
44
app/core/config.py
Normal file
44
app/core/config.py
Normal file
@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
# Project info
|
||||
PROJECT_NAME: str = "Weather Dashboard API"
|
||||
PROJECT_DESCRIPTION: str = "API for weather data, forecasts, and more"
|
||||
VERSION: str = "0.1.0"
|
||||
|
||||
# API
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["*"]
|
||||
|
||||
# OpenWeather API
|
||||
OPENWEATHER_API_KEY: str = ""
|
||||
OPENWEATHER_API_URL: str = "https://api.openweathermap.org/data/2.5"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "REPLACE_THIS_WITH_A_SECURE_SECRET_KEY"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Cache
|
||||
CACHE_EXPIRATION_SECONDS: int = 300 # 5 minutes
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Create settings instance
|
||||
settings = Settings()
|
||||
|
||||
# Database paths
|
||||
DB_DIR = Path("/app") / "storage" / "db"
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
|
73
app/core/exceptions.py
Normal file
73
app/core/exceptions.py
Normal file
@ -0,0 +1,73 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class WeatherAPIException(HTTPException):
|
||||
"""Custom exception for the Weather API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: Any = None,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(status_code=status_code, detail=detail, headers=headers)
|
||||
|
||||
|
||||
class WeatherServiceException(WeatherAPIException):
|
||||
"""Exception for weather service errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str = "Weather service error",
|
||||
status_code: int = status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
) -> None:
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
class NotFoundException(WeatherAPIException):
|
||||
"""Exception for resources not found."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str = "Resource not found",
|
||||
) -> None:
|
||||
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
|
||||
|
||||
class ForbiddenException(WeatherAPIException):
|
||||
"""Exception for forbidden actions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str = "Not enough permissions",
|
||||
) -> None:
|
||||
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
||||
|
||||
class BadRequestException(WeatherAPIException):
|
||||
"""Exception for bad requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str = "Bad request",
|
||||
) -> None:
|
||||
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
class UnauthorizedException(WeatherAPIException):
|
||||
"""Exception for unauthorized requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str = "Not authenticated",
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
if not headers:
|
||||
headers = {"WWW-Authenticate": "Bearer"}
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers=headers
|
||||
)
|
52
app/core/middleware.py
Normal file
52
app/core/middleware.py
Normal file
@ -0,0 +1,52 @@
|
||||
import time
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessTimeMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to log request processing time."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
start_time = time.time()
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
# Log request details and processing time
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} {response.status_code} "
|
||||
f"Processed in {process_time:.4f} seconds"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def setup_middlewares(app: FastAPI) -> None:
|
||||
"""Set up all middlewares for the application."""
|
||||
|
||||
# Add CORS middleware first
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add trusted host middleware
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware, allowed_hosts=["*"]
|
||||
)
|
||||
|
||||
# Add custom processing time middleware
|
||||
app.add_middleware(ProcessTimeMiddleware)
|
91
app/core/security.py
Normal file
91
app/core/security.py
Normal file
@ -0,0 +1,91 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
subject: Token subject (usually user ID)
|
||||
expires_delta: Token expiration time
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify password against hash.
|
||||
|
||||
Args:
|
||||
plain_password: Plain text password
|
||||
hashed_password: Hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches hash
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
Hash a password.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Authenticate a user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
email: User email
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
User object if authentication successful, None otherwise
|
||||
"""
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
20
app/crud/__init__.py
Normal file
20
app/crud/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Re-export the CRUD functions
|
||||
from app.crud.user import get as get_user
|
||||
from app.crud.user import get_by_email
|
||||
from app.crud.user import get_by_username
|
||||
from app.crud.user import create as create_user
|
||||
from app.crud.user import update as update_user
|
||||
from app.crud.user import delete as delete_user
|
||||
|
||||
from app.crud.location import get as get_location
|
||||
from app.crud.location import get_user_locations
|
||||
from app.crud.location import get_user_default_location
|
||||
from app.crud.location import create as create_location
|
||||
from app.crud.location import update as update_location
|
||||
from app.crud.location import delete as delete_location
|
||||
|
||||
__all__ = [
|
||||
"get_user", "get_by_email", "get_by_username", "create_user", "update_user", "delete_user",
|
||||
"get_location", "get_user_locations", "get_user_default_location", "create_location",
|
||||
"update_location", "delete_location"
|
||||
]
|
133
app/crud/location.py
Normal file
133
app/crud/location.py
Normal file
@ -0,0 +1,133 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.location import Location
|
||||
from app.schemas.location import LocationCreate, LocationUpdate
|
||||
|
||||
|
||||
def get(db: Session, location_id: int) -> Optional[Location]:
|
||||
"""
|
||||
Get a location by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
location_id: Location ID
|
||||
|
||||
Returns:
|
||||
Location object if found, None otherwise
|
||||
"""
|
||||
return db.query(Location).filter(Location.id == location_id).first()
|
||||
|
||||
|
||||
def get_user_locations(db: Session, user_id: int) -> List[Location]:
|
||||
"""
|
||||
Get all locations for a user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
List of location objects
|
||||
"""
|
||||
return db.query(Location).filter(Location.user_id == user_id).all()
|
||||
|
||||
|
||||
def get_user_default_location(db: Session, user_id: int) -> Optional[Location]:
|
||||
"""
|
||||
Get the default location for a user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Default location object if found, None otherwise
|
||||
"""
|
||||
return db.query(Location).filter(
|
||||
Location.user_id == user_id,
|
||||
Location.is_default
|
||||
).first()
|
||||
|
||||
|
||||
def create(db: Session, obj_in: LocationCreate, user_id: int) -> Location:
|
||||
"""
|
||||
Create a new location.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
obj_in: Location creation data
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Created location object
|
||||
"""
|
||||
# If this is set as default, unset any existing default
|
||||
if obj_in.is_default:
|
||||
existing_default = get_user_default_location(db, user_id)
|
||||
if existing_default:
|
||||
existing_default.is_default = False
|
||||
db.add(existing_default)
|
||||
|
||||
db_obj = Location(
|
||||
**obj_in.dict(),
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
def update(db: Session, db_obj: Location, obj_in: LocationUpdate) -> Location:
|
||||
"""
|
||||
Update a location.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_obj: Location object to update
|
||||
obj_in: Location update data
|
||||
|
||||
Returns:
|
||||
Updated location object
|
||||
"""
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
|
||||
# If this is set as default, unset any existing default
|
||||
if update_data.get("is_default"):
|
||||
existing_default = get_user_default_location(db, db_obj.user_id)
|
||||
if existing_default and existing_default.id != db_obj.id:
|
||||
existing_default.is_default = False
|
||||
db.add(existing_default)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
def delete(db: Session, location_id: int) -> Optional[Location]:
|
||||
"""
|
||||
Delete a location.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
location_id: Location ID
|
||||
|
||||
Returns:
|
||||
Deleted location object if found, None otherwise
|
||||
"""
|
||||
location = db.query(Location).filter(Location.id == location_id).first()
|
||||
|
||||
if location:
|
||||
db.delete(location)
|
||||
db.commit()
|
||||
|
||||
return location
|
121
app/crud/user.py
Normal file
121
app/crud/user.py
Normal file
@ -0,0 +1,121 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
|
||||
def get(db: Session, user_id: int) -> Optional[User]:
|
||||
"""
|
||||
Get a user by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
|
||||
def get_by_email(db: Session, email: str) -> Optional[User]:
|
||||
"""
|
||||
Get a user by email.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
email: User email
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def get_by_username(db: Session, username: str) -> Optional[User]:
|
||||
"""
|
||||
Get a user by username.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
username: Username
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
|
||||
def create(db: Session, obj_in: UserCreate) -> User:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
obj_in: User creation data
|
||||
|
||||
Returns:
|
||||
Created user object
|
||||
"""
|
||||
db_obj = User(
|
||||
email=obj_in.email,
|
||||
username=obj_in.username,
|
||||
hashed_password=get_password_hash(obj_in.password),
|
||||
is_active=obj_in.is_active,
|
||||
)
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
def update(db: Session, db_obj: User, obj_in: UserUpdate) -> User:
|
||||
"""
|
||||
Update a user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_obj: User object to update
|
||||
obj_in: User update data
|
||||
|
||||
Returns:
|
||||
Updated user object
|
||||
"""
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
|
||||
if "password" in update_data:
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
def delete(db: Session, user_id: int) -> Optional[User]:
|
||||
"""
|
||||
Delete a user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Deleted user object if found, None otherwise
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if user:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return user
|
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
30
app/db/session.py
Normal file
30
app/db/session.py
Normal file
@ -0,0 +1,30 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import SQLALCHEMY_DATABASE_URL
|
||||
|
||||
# Create SQLAlchemy engine
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} # Only needed for SQLite
|
||||
)
|
||||
|
||||
# Create SessionLocal class
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Dependency to get DB session
|
||||
def get_db():
|
||||
"""
|
||||
Dependency for getting the database session.
|
||||
Creates a new session for each request and closes it when done.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
5
app/models/__init__.py
Normal file
5
app/models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# Re-export models
|
||||
from app.models.user import User
|
||||
from app.models.location import Location
|
||||
|
||||
__all__ = ["User", "Location"]
|
26
app/models/location.py
Normal file
26
app/models/location.py
Normal file
@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class Location(Base):
|
||||
"""Location model for storing locations and their weather data."""
|
||||
|
||||
__tablename__ = "locations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
country = Column(String, nullable=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Foreign keys
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="locations")
|
23
app/models/user.py
Normal file
23
app/models/user.py
Normal file
@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and identification."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
locations = relationship("Location", back_populates="user", cascade="all, delete-orphan")
|
12
app/schemas/__init__.py
Normal file
12
app/schemas/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Re-export schemas
|
||||
from app.schemas.user import User, UserCreate, UserUpdate, UserInDB
|
||||
from app.schemas.location import Location, LocationCreate, LocationUpdate
|
||||
from app.schemas.weather import WeatherData, ForecastItem, Forecast, CurrentWeather, WeatherQuery
|
||||
from app.schemas.token import Token, TokenPayload
|
||||
|
||||
__all__ = [
|
||||
"User", "UserCreate", "UserUpdate", "UserInDB",
|
||||
"Location", "LocationCreate", "LocationUpdate",
|
||||
"WeatherData", "ForecastItem", "Forecast", "CurrentWeather", "WeatherQuery",
|
||||
"Token", "TokenPayload"
|
||||
]
|
43
app/schemas/location.py
Normal file
43
app/schemas/location.py
Normal file
@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LocationBase(BaseModel):
|
||||
"""Base schema for location data."""
|
||||
name: str
|
||||
latitude: float = Field(..., le=90, ge=-90)
|
||||
longitude: float = Field(..., le=180, ge=-180)
|
||||
country: Optional[str] = None
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
|
||||
class LocationCreate(LocationBase):
|
||||
"""Schema for creating a new location."""
|
||||
pass
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
"""Schema for updating an existing location."""
|
||||
name: Optional[str] = None
|
||||
latitude: Optional[float] = Field(None, le=90, ge=-90)
|
||||
longitude: Optional[float] = Field(None, le=180, ge=-180)
|
||||
country: Optional[str] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class LocationInDBBase(LocationBase):
|
||||
"""Base schema for location with DB fields."""
|
||||
id: int
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Location(LocationInDBBase):
|
||||
"""Schema for location response data."""
|
||||
pass
|
14
app/schemas/token.py
Normal file
14
app/schemas/token.py
Normal file
@ -0,0 +1,14 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for access token."""
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Schema for token payload."""
|
||||
sub: Optional[int] = None
|
44
app/schemas/user.py
Normal file
44
app/schemas/user.py
Normal file
@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base schema for user information."""
|
||||
email: EmailStr
|
||||
username: str
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a new user."""
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating an existing user."""
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
"""Base schema for user with DB fields."""
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class User(UserInDBBase):
|
||||
"""Schema for user response data (no password)."""
|
||||
pass
|
||||
|
||||
|
||||
class UserInDB(UserInDBBase):
|
||||
"""Schema for user in DB (includes password)."""
|
||||
hashed_password: str
|
56
app/schemas/weather.py
Normal file
56
app/schemas/weather.py
Normal file
@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WeatherData(BaseModel):
|
||||
"""Schema for current weather data."""
|
||||
temperature: float # Temperature in Celsius
|
||||
feels_like: float # Feels like temperature in Celsius
|
||||
humidity: int = Field(..., ge=0, le=100) # Humidity in percentage
|
||||
pressure: int # Atmospheric pressure in hPa
|
||||
wind_speed: float # Wind speed in meters/sec
|
||||
wind_direction: int = Field(..., ge=0, le=360) # Wind direction in degrees
|
||||
weather_condition: str # Main weather condition (e.g., "Rain", "Clear")
|
||||
weather_description: str # Detailed weather description
|
||||
weather_icon: str # Icon code for the weather condition
|
||||
clouds: int = Field(..., ge=0, le=100) # Cloudiness in percentage
|
||||
timestamp: datetime # Time of data calculation
|
||||
sunrise: datetime # Sunrise time
|
||||
sunset: datetime # Sunset time
|
||||
|
||||
|
||||
class ForecastItem(BaseModel):
|
||||
"""Schema for a single forecast item."""
|
||||
timestamp: datetime # Time of forecasted data
|
||||
temperature: float # Temperature in Celsius
|
||||
feels_like: float # Feels like temperature in Celsius
|
||||
humidity: int = Field(..., ge=0, le=100) # Humidity in percentage
|
||||
pressure: int # Atmospheric pressure in hPa
|
||||
wind_speed: float # Wind speed in meters/sec
|
||||
wind_direction: int = Field(..., ge=0, le=360) # Wind direction in degrees
|
||||
weather_condition: str # Main weather condition
|
||||
weather_description: str # Detailed weather description
|
||||
weather_icon: str # Icon code for the weather condition
|
||||
clouds: int = Field(..., ge=0, le=100) # Cloudiness in percentage
|
||||
probability_of_precipitation: float = Field(..., ge=0, le=1) # Probability of precipitation
|
||||
|
||||
|
||||
class Forecast(BaseModel):
|
||||
"""Schema for weather forecast data."""
|
||||
location_name: str
|
||||
items: List[ForecastItem]
|
||||
|
||||
|
||||
class CurrentWeather(BaseModel):
|
||||
"""Schema for current weather response."""
|
||||
location_name: str
|
||||
weather: WeatherData
|
||||
|
||||
|
||||
class WeatherQuery(BaseModel):
|
||||
"""Schema for querying weather data."""
|
||||
latitude: float = Field(..., le=90, ge=-90)
|
||||
longitude: float = Field(..., le=180, ge=-180)
|
||||
location_name: Optional[str] = None
|
5
app/services/__init__.py
Normal file
5
app/services/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# Re-export services
|
||||
from app.services.weather_service import WeatherService
|
||||
from app.services.cache_service import CacheService, cache
|
||||
|
||||
__all__ = ["WeatherService", "CacheService", "cache"]
|
69
app/services/cache_service.py
Normal file
69
app/services/cache_service.py
Normal file
@ -0,0 +1,69 @@
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Simple in-memory cache service."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get value from cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Cached value or None if not found or expired
|
||||
"""
|
||||
if key not in self._cache:
|
||||
return None
|
||||
|
||||
cache_item = self._cache[key]
|
||||
|
||||
# Check if cache item has expired
|
||||
if time.time() > cache_item["expires_at"]:
|
||||
# Remove expired item
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
return cache_item["value"]
|
||||
|
||||
def set(self, key: str, value: Any, expires_in: Optional[int] = None) -> None:
|
||||
"""
|
||||
Set value in cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache
|
||||
expires_in: Expiration time in seconds (default from settings)
|
||||
"""
|
||||
if expires_in is None:
|
||||
expires_in = settings.CACHE_EXPIRATION_SECONDS
|
||||
|
||||
self._cache[key] = {
|
||||
"value": value,
|
||||
"expires_at": time.time() + expires_in
|
||||
}
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""
|
||||
Delete value from cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
"""
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached values."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# Create a singleton instance of the cache
|
||||
cache = CacheService()
|
194
app/services/weather_service.py
Normal file
194
app/services/weather_service.py
Normal file
@ -0,0 +1,194 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.weather import WeatherData, ForecastItem, Forecast, CurrentWeather
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeatherService:
|
||||
"""Service for interacting with the OpenWeatherMap API."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.OPENWEATHER_API_KEY
|
||||
self.base_url = settings.OPENWEATHER_API_URL
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("OpenWeatherMap API key not set. Weather data will not be available.")
|
||||
|
||||
async def get_current_weather(self, lat: float, lon: float, location_name: Optional[str] = None) -> CurrentWeather:
|
||||
"""
|
||||
Get current weather data for a specific location.
|
||||
|
||||
Args:
|
||||
lat: Latitude coordinate
|
||||
lon: Longitude coordinate
|
||||
location_name: Optional name of the location
|
||||
|
||||
Returns:
|
||||
CurrentWeather object with location name and weather data
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise HTTPException(status_code=503, detail="Weather service not available. API key not configured.")
|
||||
|
||||
url = f"{self.base_url}/weather"
|
||||
params = {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"appid": self.api_key,
|
||||
"units": "metric", # Use metric units (Celsius)
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract location name from response if not provided
|
||||
if not location_name:
|
||||
location_name = data.get("name", f"Location ({lat}, {lon})")
|
||||
|
||||
# Parse weather data
|
||||
weather_data = self._parse_current_weather(data)
|
||||
|
||||
return CurrentWeather(
|
||||
location_name=location_name,
|
||||
weather=weather_data
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from OpenWeatherMap API: {e}")
|
||||
if e.response.status_code == 401:
|
||||
raise HTTPException(status_code=503, detail="Weather service authentication failed.")
|
||||
elif e.response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="Weather data not found for this location.")
|
||||
else:
|
||||
raise HTTPException(status_code=503, detail=f"Weather service error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching weather data: {e}")
|
||||
raise HTTPException(status_code=503, detail="Failed to fetch weather data.")
|
||||
|
||||
async def get_forecast(self, lat: float, lon: float, location_name: Optional[str] = None) -> Forecast:
|
||||
"""
|
||||
Get 5-day weather forecast for a specific location.
|
||||
|
||||
Args:
|
||||
lat: Latitude coordinate
|
||||
lon: Longitude coordinate
|
||||
location_name: Optional name of the location
|
||||
|
||||
Returns:
|
||||
Forecast object with location name and forecast items
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise HTTPException(status_code=503, detail="Weather service not available. API key not configured.")
|
||||
|
||||
url = f"{self.base_url}/forecast"
|
||||
params = {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"appid": self.api_key,
|
||||
"units": "metric", # Use metric units (Celsius)
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract location name from response if not provided
|
||||
if not location_name:
|
||||
city_data = data.get("city", {})
|
||||
location_name = city_data.get("name", f"Location ({lat}, {lon})")
|
||||
|
||||
# Parse forecast data
|
||||
forecast_items = self._parse_forecast(data)
|
||||
|
||||
return Forecast(
|
||||
location_name=location_name,
|
||||
items=forecast_items
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from OpenWeatherMap API: {e}")
|
||||
if e.response.status_code == 401:
|
||||
raise HTTPException(status_code=503, detail="Weather service authentication failed.")
|
||||
elif e.response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="Weather forecast not found for this location.")
|
||||
else:
|
||||
raise HTTPException(status_code=503, detail=f"Weather service error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching weather forecast: {e}")
|
||||
raise HTTPException(status_code=503, detail="Failed to fetch weather forecast.")
|
||||
|
||||
def _parse_current_weather(self, data: Dict[str, Any]) -> WeatherData:
|
||||
"""Parse current weather data from OpenWeatherMap API response."""
|
||||
try:
|
||||
weather = data.get("weather", [{}])[0]
|
||||
main = data.get("main", {})
|
||||
wind = data.get("wind", {})
|
||||
clouds = data.get("clouds", {})
|
||||
sys = data.get("sys", {})
|
||||
|
||||
return WeatherData(
|
||||
temperature=main.get("temp", 0),
|
||||
feels_like=main.get("feels_like", 0),
|
||||
humidity=main.get("humidity", 0),
|
||||
pressure=main.get("pressure", 0),
|
||||
wind_speed=wind.get("speed", 0),
|
||||
wind_direction=wind.get("deg", 0),
|
||||
weather_condition=weather.get("main", "Unknown"),
|
||||
weather_description=weather.get("description", "Unknown"),
|
||||
weather_icon=weather.get("icon", ""),
|
||||
clouds=clouds.get("all", 0),
|
||||
timestamp=datetime.fromtimestamp(data.get("dt", 0)),
|
||||
sunrise=datetime.fromtimestamp(sys.get("sunrise", 0)),
|
||||
sunset=datetime.fromtimestamp(sys.get("sunset", 0)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing current weather data: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to parse weather data.")
|
||||
|
||||
def _parse_forecast(self, data: Dict[str, Any]) -> List[ForecastItem]:
|
||||
"""Parse forecast data from OpenWeatherMap API response."""
|
||||
try:
|
||||
forecast_items = []
|
||||
|
||||
for item in data.get("list", []):
|
||||
weather = item.get("weather", [{}])[0]
|
||||
main = item.get("main", {})
|
||||
wind = item.get("wind", {})
|
||||
clouds = item.get("clouds", {})
|
||||
|
||||
forecast_item = ForecastItem(
|
||||
timestamp=datetime.fromtimestamp(item.get("dt", 0)),
|
||||
temperature=main.get("temp", 0),
|
||||
feels_like=main.get("feels_like", 0),
|
||||
humidity=main.get("humidity", 0),
|
||||
pressure=main.get("pressure", 0),
|
||||
wind_speed=wind.get("speed", 0),
|
||||
wind_direction=wind.get("deg", 0),
|
||||
weather_condition=weather.get("main", "Unknown"),
|
||||
weather_description=weather.get("description", "Unknown"),
|
||||
weather_icon=weather.get("icon", ""),
|
||||
clouds=clouds.get("all", 0),
|
||||
probability_of_precipitation=item.get("pop", 0),
|
||||
)
|
||||
|
||||
forecast_items.append(forecast_item)
|
||||
|
||||
return forecast_items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing forecast data: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to parse forecast data.")
|
88
main.py
Normal file
88
main.py
Normal file
@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import WeatherAPIException
|
||||
from app.core.middleware import setup_middlewares
|
||||
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Initialize FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
description=settings.PROJECT_DESCRIPTION,
|
||||
version=settings.VERSION,
|
||||
openapi_url="/openapi.json",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Set up middlewares
|
||||
setup_middlewares(app)
|
||||
|
||||
# Include API router
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(WeatherAPIException)
|
||||
async def weather_api_exception_handler(request: Request, exc: WeatherAPIException):
|
||||
"""Handle custom WeatherAPIException."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Handle request validation errors."""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
error_msg = {
|
||||
"loc": error["loc"],
|
||||
"msg": error["msg"],
|
||||
"type": error["type"],
|
||||
}
|
||||
errors.append(error_msg)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"detail": "Validation error",
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle all uncaught exceptions."""
|
||||
logger.error(f"Uncaught exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
1
migrations/README
Normal file
1
migrations/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration with SQLAlchemy.
|
78
migrations/env.py
Normal file
78
migrations/env.py
Normal file
@ -0,0 +1,78 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import Base from our application to autogenerate migrations
|
||||
from app.db.session import Base
|
||||
|
||||
# This is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # Important for SQLite migrations
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=is_sqlite, # Important for SQLite to handle migrations properly
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
63
migrations/versions/ae76d37b4f3c_initial_migration.py
Normal file
63
migrations/versions/ae76d37b4f3c_initial_migration.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: ae76d37b4f3c
|
||||
Revises:
|
||||
Create Date: 2023-09-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ae76d37b4f3c'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create users table
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_superuser', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
|
||||
# Create locations table
|
||||
op.create_table(
|
||||
'locations',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('latitude', sa.Float(), nullable=False),
|
||||
sa.Column('longitude', sa.Float(), nullable=False),
|
||||
sa.Column('country', sa.String(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_locations_name'), 'locations', ['name'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_locations_name'), table_name='locations')
|
||||
op.drop_index(op.f('ix_locations_id'), table_name='locations')
|
||||
op.drop_table('locations')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@ -0,0 +1,13 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn>=0.21.1
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.10.0
|
||||
httpx>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
ruff>=0.0.270
|
||||
pytest>=7.3.1
|
Loading…
x
Reference in New Issue
Block a user