From 8878303cac321d425e253c4b6ac86a627656ec52 Mon Sep 17 00:00:00 2001 From: haneulai Date: Thu, 22 Jan 2026 14:22:47 +0900 Subject: [PATCH] Initial commit: FastAPI middleware server for solar power monitoring --- .gitignore | 34 ++++++++++ app/__init__.py | 1 + app/core/__init__.py | 1 + app/core/config.py | 33 ++++++++++ app/core/database.py | 28 ++++++++ app/main.py | 75 +++++++++++++++++++++ app/routers/__init__.py | 1 + app/routers/auth.py | 18 ++++++ app/routers/plants.py | 140 ++++++++++++++++++++++++++++++++++++++++ app/schemas/__init__.py | 2 + app/schemas/plant.py | 46 +++++++++++++ requirements.txt | 6 ++ 12 files changed, 385 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/main.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/plants.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/plant.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f50cc15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Environment +.env +.env.local +.env.*.local + +# Virtual Environment +venv/ +.venv/ +env/ +ENV/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Distribution / Build +dist/ +build/ +*.egg-info/ + +# Logs +*.log diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e32e435 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Solar Power Monitoring API Server diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..d4f041b --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core configuration module diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..885e960 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,33 @@ +""" +환경변수 설정 모듈 +- python-dotenv를 사용하여 .env 파일에서 환경변수 로드 +""" + +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + """애플리케이션 설정""" + + # Supabase 설정 + SUPABASE_URL: str + SUPABASE_KEY: str + + # 앱 설정 + APP_NAME: str = "Solar Power Monitoring API" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + """ + 설정 싱글톤 인스턴스 반환 + lru_cache를 사용하여 한 번만 로드 + """ + return Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..3f24f31 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,28 @@ +""" +Supabase 데이터베이스 클라이언트 모듈 +- 싱글톤 패턴으로 클라이언트 인스턴스 관리 +""" + +from supabase import create_client, Client +from functools import lru_cache +from .config import get_settings + + +@lru_cache() +def get_supabase_client() -> Client: + """ + Supabase 클라이언트 싱글톤 인스턴스 반환 + lru_cache를 사용하여 한 번만 생성 + """ + settings = get_settings() + return create_client( + settings.SUPABASE_URL, + settings.SUPABASE_KEY + ) + + +def get_db() -> Client: + """ + Supabase 클라이언트 반환 (의존성 주입용) + """ + return get_supabase_client() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..63f244a --- /dev/null +++ b/app/main.py @@ -0,0 +1,75 @@ +""" +FastAPI 애플리케이션 진입점 +- 태양광 발전 관제 시스템 미들웨어 서버 +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import get_settings +from app.routers import plants + +# 설정 로드 +settings = get_settings() + +# FastAPI 앱 생성 +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="태양광 발전 관제 시스템을 위한 미들웨어 API 서버", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS 미들웨어 설정 (모든 도메인 허용 - 개발용) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 라우터 등록 +app.include_router(plants.router) + + +@app.get("/", tags=["Health"]) +async def health_check() -> dict: + """ + 서버 상태 확인 (Health Check) + + Returns: + 서버 상태 및 버전 정보 + """ + return { + "status": "healthy", + "app_name": settings.APP_NAME, + "version": settings.APP_VERSION + } + + +@app.get("/health", tags=["Health"]) +async def detailed_health_check() -> dict: + """ + 상세 서버 상태 확인 + + Returns: + 서버 상태 및 연결 정보 + """ + return { + "status": "healthy", + "app_name": settings.APP_NAME, + "version": settings.APP_VERSION, + "supabase_connected": bool(settings.SUPABASE_URL) + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG + ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..6904e18 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# API Routers module diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..e0a49ad --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,18 @@ +""" +인증 관련 API 엔드포인트 (추후 예정) +- 로그인/로그아웃 +- 토큰 관리 +""" + +from fastapi import APIRouter + +router = APIRouter( + prefix="/auth", + tags=["Authentication"] +) + + +# TODO: 추후 구현 예정 +# - POST /auth/login +# - POST /auth/logout +# - POST /auth/refresh diff --git a/app/routers/plants.py b/app/routers/plants.py new file mode 100644 index 0000000..e9a7362 --- /dev/null +++ b/app/routers/plants.py @@ -0,0 +1,140 @@ +""" +발전소 관련 API 엔드포인트 +- 발전소 목록 조회 +- 발전 현황 조회 +""" + +from fastapi import APIRouter, HTTPException, Depends +from supabase import Client +from typing import List + +from app.core.database import get_db +from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase + +router = APIRouter( + prefix="/plants", + tags=["Plants"] +) + + +@router.get("/{company_id}", response_model=PlantsListResponse) +async def get_plants_by_company( + company_id: int, + db: Client = Depends(get_db) +) -> PlantsListResponse: + """ + 특정 업체의 모든 발전소 목록과 최신 발전 현황을 조회합니다. + + Args: + company_id: 업체 ID + + Returns: + 발전소 목록 및 각 발전소의 최신 발전 로그 + """ + try: + # 1. 해당 업체의 모든 발전소 조회 + plants_response = db.table("plants") \ + .select("*") \ + .eq("company_id", company_id) \ + .execute() + + plants = plants_response.data + + if not plants: + return PlantsListResponse( + status="success", + data=[], + total_count=0 + ) + + # 2. 각 발전소의 최신 발전 로그 조회 + result: List[PlantWithLatestLog] = [] + + for plant in plants: + # 해당 발전소의 최신 로그 1건 조회 + log_response = db.table("solar_logs") \ + .select("*") \ + .eq("plant_id", plant["id"]) \ + .order("timestamp", desc=True) \ + .limit(1) \ + .execute() + + latest_log = None + if log_response.data: + latest_log = SolarLogBase(**log_response.data[0]) + + plant_with_log = PlantWithLatestLog( + **plant, + latest_log=latest_log + ) + result.append(plant_with_log) + + return PlantsListResponse( + status="success", + data=result, + total_count=len(result) + ) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}" + ) + + +@router.get("/{company_id}/{plant_id}", response_model=dict) +async def get_plant_detail( + company_id: int, + plant_id: int, + db: Client = Depends(get_db) +) -> dict: + """ + 특정 발전소의 상세 정보를 조회합니다. + + Args: + company_id: 업체 ID + plant_id: 발전소 ID + + Returns: + 발전소 상세 정보 및 최근 발전 로그 + """ + try: + # 발전소 조회 + plant_response = db.table("plants") \ + .select("*") \ + .eq("id", plant_id) \ + .eq("company_id", company_id) \ + .single() \ + .execute() + + if not plant_response.data: + raise HTTPException( + status_code=404, + detail="발전소를 찾을 수 없습니다." + ) + + plant = plant_response.data + + # 최근 발전 로그 10건 조회 + logs_response = db.table("solar_logs") \ + .select("*") \ + .eq("plant_id", plant_id) \ + .order("timestamp", desc=True) \ + .limit(10) \ + .execute() + + return { + "status": "success", + "data": { + "plant": plant, + "recent_logs": logs_response.data or [] + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}" + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..3102422 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,2 @@ +# Pydantic Schemas module +from .plant import PlantBase, PlantResponse, PlantWithLatestLog, PlantsListResponse diff --git a/app/schemas/plant.py b/app/schemas/plant.py new file mode 100644 index 0000000..5aaea15 --- /dev/null +++ b/app/schemas/plant.py @@ -0,0 +1,46 @@ +""" +발전소 관련 Pydantic 스키마 +- 요청/응답 데이터 검증 +""" + +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class PlantBase(BaseModel): + """발전소 기본 정보 스키마""" + id: int + company_id: int + name: str + capacity: Optional[float] = Field(None, description="발전 용량 (kW)") + location: Optional[str] = Field(None, description="발전소 위치") + created_at: Optional[datetime] = None + + +class SolarLogBase(BaseModel): + """발전 로그 기본 정보 스키마""" + id: int + plant_id: int + timestamp: datetime + power_output: Optional[float] = Field(None, description="현재 발전량 (kW)") + daily_generation: Optional[float] = Field(None, description="일일 발전량 (kWh)") + total_generation: Optional[float] = Field(None, description="누적 발전량 (kWh)") + + +class PlantWithLatestLog(PlantBase): + """발전소 정보 + 최신 발전 현황""" + latest_log: Optional[SolarLogBase] = Field(None, description="최신 발전 로그") + + +class PlantResponse(BaseModel): + """단일 발전소 응답 스키마""" + status: str = "success" + data: PlantWithLatestLog + + +class PlantsListResponse(BaseModel): + """발전소 목록 응답 스키마""" + status: str = "success" + data: List[PlantWithLatestLog] + total_count: int = Field(description="총 발전소 수") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f60e478 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-dotenv>=1.0.0 +supabase>=2.0.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0