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:
Automated Action 2025-05-27 20:34:02 +00:00
parent fe429a4abf
commit fec0fa72e7
37 changed files with 1904 additions and 0 deletions

103
alembic.ini Normal file
View 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
View File

@ -0,0 +1 @@
# Weather Dashboard API Application Package

0
app/api/__init__.py Normal file
View File

110
app/api/deps.py Normal file
View 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
View File

View File

View 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

View 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

View 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
View 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
View File

44
app/core/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

30
app/db/session.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View 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()

View 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
View 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
View File

@ -0,0 +1 @@
Generic single-database configuration with SQLAlchemy.

78
migrations/env.py Normal file
View 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
View 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"}

View 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
View 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