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