Skip to content

Commit 487ace1

Browse files
feat: comprehensive refactoring and UI improvements
- Complete modular refactoring with API/Services/Models separation - Compact temperature controls with improved unit switching - Fixed Energy Cutoff and DFT Functional 2-column layout alignment - Added solid phase calculation notice - Improved error handling and logging - Added comprehensive test suite - Better CSS organization with component separation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9a58f5f commit 487ace1

33 files changed

+4787
-1895
lines changed

app/api/__init__.py

Whitespace-only changes.

app/api/diagrams.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
2+
from fastapi.responses import JSONResponse
3+
4+
from ..core.logging import get_logger
5+
from ..core.security import hash_api_key, create_rate_limit_key, validate_api_key
6+
from ..models.requests import DiagramRequest
7+
from ..models.responses import DiagramResponse, ErrorResponse
8+
from ..services.materials_client import MaterialsProjectClient
9+
from ..services.phase_analyzer import PhaseAnalyzer
10+
from ..services.rate_limiter import rate_limiter
11+
12+
logger = get_logger(__name__)
13+
router = APIRouter(prefix="/diagrams", tags=["diagrams"])
14+
15+
16+
def get_client_ip(request: Request) -> str:
17+
"""Extract client IP from request (proxy-aware)."""
18+
forwarded_for = request.headers.get("x-forwarded-for")
19+
if forwarded_for:
20+
return forwarded_for.split(",")[0].strip()
21+
return request.client.host or "unknown"
22+
23+
24+
@router.post("/", response_model=DiagramResponse)
25+
async def generate_diagram(
26+
request: DiagramRequest,
27+
x_api_key: str = Header(..., alias="X-API-KEY"),
28+
client_ip: str = Depends(get_client_ip)
29+
):
30+
"""
31+
Generate a phase diagram with detailed phase information.
32+
33+
This endpoint creates a phase diagram using Materials Project data
34+
and returns both the plot data and detailed phase information.
35+
"""
36+
# Validate API key format
37+
if not validate_api_key(x_api_key):
38+
logger.warning(f"Invalid API key format from IP: {client_ip}")
39+
raise HTTPException(
40+
status_code=401,
41+
detail="Invalid API key format"
42+
)
43+
44+
# Create rate limiting key
45+
api_key_hash = hash_api_key(x_api_key)
46+
rate_limit_key = create_rate_limit_key(api_key_hash, client_ip)
47+
48+
# Check rate limit
49+
if not rate_limiter.is_allowed(rate_limit_key):
50+
remaining_time = rate_limiter.get_reset_time(rate_limit_key) - __import__('time').time()
51+
raise HTTPException(
52+
status_code=429,
53+
detail=f"Rate limit exceeded. Please wait {int(remaining_time)} seconds before making another request.",
54+
headers={"Retry-After": str(int(remaining_time))}
55+
)
56+
57+
# Log request details
58+
logger.info(f"API Request: formulas={request.formulas}, T={request.temperature}K, "
59+
f"e_cut={request.energy_cutoff}, functional={request.functional}, "
60+
f"key_hash={api_key_hash}, IP={client_ip}")
61+
62+
try:
63+
# Create services
64+
materials_client = MaterialsProjectClient(x_api_key)
65+
phase_analyzer = PhaseAnalyzer(materials_client)
66+
67+
# Generate phase diagram
68+
result = phase_analyzer.generate_phase_diagram(
69+
formulas=request.formulas,
70+
temperature=request.temperature,
71+
energy_cutoff=request.energy_cutoff,
72+
functional=request.functional,
73+
api_key=x_api_key
74+
)
75+
76+
logger.info(f"Diagram generated successfully: {len(result.phase_info)} phases")
77+
return result
78+
79+
except ValueError as e:
80+
# Client errors (bad input, invalid API key, etc.)
81+
logger.warning(f"Client error: {str(e)}")
82+
raise HTTPException(status_code=400, detail=str(e))
83+
84+
except Exception as e:
85+
# Server errors
86+
logger.error(f"Server error: {str(e)}", exc_info=True)
87+
raise HTTPException(
88+
status_code=500,
89+
detail="Internal server error occurred while generating phase diagram"
90+
)

app/api/health.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import datetime
2+
from fastapi import APIRouter
3+
4+
from ..core.config import settings
5+
from ..models.responses import HealthResponse
6+
7+
router = APIRouter(prefix="/health", tags=["health"])
8+
9+
10+
@router.get("/", response_model=HealthResponse)
11+
async def health_check():
12+
"""Health check endpoint."""
13+
return HealthResponse(
14+
status="healthy",
15+
app_name=settings.app_name,
16+
version=settings.app_version,
17+
timestamp=datetime.utcnow().isoformat()
18+
)

app/core/__init__.py

Whitespace-only changes.

app/core/config.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os
2+
from typing import List
3+
4+
try:
5+
from pydantic import BaseSettings
6+
except ImportError:
7+
from pydantic_settings import BaseSettings
8+
9+
10+
class Settings(BaseSettings):
11+
# Application
12+
app_name: str = "PhaseNavigator"
13+
app_version: str = "1.0.0"
14+
debug: bool = False
15+
16+
# Rate Limiting
17+
rate_limit_window: int = 30 # seconds
18+
rate_limit_max_requests: int = 10
19+
20+
# Phase Diagram
21+
default_functional: str = "GGA_GGA_U_R2SCAN"
22+
supported_functionals: List[str] = ["GGA_GGA_U_R2SCAN", "R2SCAN", "GGA_GGA_U"]
23+
24+
# Temperature constraints
25+
min_temperature: int = 300
26+
max_temperature: int = 2000
27+
default_temperature: int = 0
28+
29+
# Energy constraints
30+
default_energy_cutoff: float = 0.2
31+
max_energy_cutoff: float = 2.0
32+
33+
# Formula constraints
34+
min_formulas: int = 2
35+
max_formulas: int = 4
36+
37+
# Logging
38+
log_level: str = "INFO"
39+
log_file: str = "phasenav.log"
40+
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
41+
42+
# Security
43+
api_key_hash_length: int = 12
44+
45+
class Config:
46+
env_file = ".env"
47+
case_sensitive = True
48+
49+
50+
# Global settings instance
51+
settings = Settings()

app/core/logging.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import logging
2+
import sys
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
from .config import settings
7+
8+
9+
def setup_logging(
10+
level: Optional[str] = None,
11+
log_file: Optional[str] = None,
12+
log_format: Optional[str] = None
13+
) -> logging.Logger:
14+
"""
15+
Setup logging configuration for the application.
16+
17+
Args:
18+
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
19+
log_file: Path to log file
20+
log_format: Log message format
21+
22+
Returns:
23+
Configured logger instance
24+
"""
25+
# Use settings defaults if not provided
26+
level = level or settings.log_level
27+
log_file = log_file or settings.log_file
28+
log_format = log_format or settings.log_format
29+
30+
# Create logger
31+
logger = logging.getLogger("phasenav")
32+
logger.setLevel(getattr(logging, level.upper()))
33+
34+
# Remove existing handlers to avoid duplicates
35+
logger.handlers.clear()
36+
37+
# Create formatter
38+
formatter = logging.Formatter(log_format)
39+
40+
# Console handler
41+
console_handler = logging.StreamHandler(sys.stdout)
42+
console_handler.setFormatter(formatter)
43+
logger.addHandler(console_handler)
44+
45+
# File handler
46+
if log_file:
47+
file_handler = logging.FileHandler(log_file)
48+
file_handler.setFormatter(formatter)
49+
logger.addHandler(file_handler)
50+
51+
return logger
52+
53+
54+
def get_logger(name: str = "phasenav") -> logging.Logger:
55+
"""Get a logger instance."""
56+
return logging.getLogger(name)
57+
58+
59+
# Initialize default logger
60+
logger = setup_logging()

app/core/security.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import hashlib
2+
from typing import Tuple
3+
4+
from .config import settings
5+
6+
7+
def hash_api_key(api_key: str) -> str:
8+
"""
9+
Generate a short SHA-256 hash of the API key for logging and rate limiting.
10+
11+
Args:
12+
api_key: The Materials Project API key
13+
14+
Returns:
15+
Short hash of the API key
16+
"""
17+
return hashlib.sha256(api_key.encode()).hexdigest()[:settings.api_key_hash_length]
18+
19+
20+
def create_rate_limit_key(api_key_hash: str, client_ip: str) -> Tuple[str, str]:
21+
"""
22+
Create a rate limiting key from API key hash and client IP.
23+
24+
Args:
25+
api_key_hash: Hashed API key
26+
client_ip: Client IP address
27+
28+
Returns:
29+
Tuple of (api_key_hash, client_ip) for rate limiting
30+
"""
31+
return (api_key_hash, client_ip)
32+
33+
34+
def validate_api_key(api_key: str) -> bool:
35+
"""
36+
Basic validation for Materials Project API key format.
37+
38+
Args:
39+
api_key: The API key to validate
40+
41+
Returns:
42+
True if the key appears to be in correct format
43+
"""
44+
if not api_key or not isinstance(api_key, str):
45+
return False
46+
47+
# Materials Project API keys are typically 32 characters long
48+
if len(api_key) < 20:
49+
return False
50+
51+
return True

0 commit comments

Comments
 (0)