feat: Flask 애플리케이션 모듈화 및 웹 대시보드 구현

- Flask Blueprint 아키텍처로 전환 (dashboard, upload, backup, status)
- app.py 681줄  95줄로 축소 (86% 감소)
- HTML 템플릿 모듈화 (base.html + 기능별 templates)
- CSS/JS 파일 분리 (common + 기능별 파일)
- 대시보드 기능 추가 (통계, 주간 예보, 방문객 추이)
- 파일 업로드 웹 인터페이스 구현
- 백업/복구 관리 UI 구현
- Docker 배포 환경 개선
- .gitignore 업데이트 (uploads, backups, cache 등)
This commit is contained in:
2025-12-26 17:31:37 +09:00
parent 9dab27529d
commit 7121f250bc
46 changed files with 6345 additions and 191 deletions

View File

@ -1,13 +1,17 @@
import os, sys
import os, sys
import requests
import json
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError
import traceback
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from conf import db, db_schema
from requests_utils import make_requests_session
CACHE_FILE = os.path.join(os.path.dirname(__file__), '..', 'cache', 'air_num_rows.json')
class AirQualityCollector:
def __init__(self, config, engine, table):
@ -20,6 +24,24 @@ class AirQualityCollector:
self.engine = engine
self.table = table
self.yesterday = (datetime.now() - timedelta(days=1)).date()
self.session = make_requests_session()
# load cache
self._num_rows_cache = {}
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
self._num_rows_cache = json.load(f)
except Exception:
self._num_rows_cache = {}
def _save_num_rows_cache(self):
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(self._num_rows_cache, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[WARN] num_rows 캐시 저장 실패: {e}")
def get_latest_date(self, conn, station):
try:
@ -30,6 +52,7 @@ class AirQualityCollector:
return result
except Exception as e:
print(f"[ERROR] 가장 최근 날짜 조회 실패: {e}")
traceback.print_exc()
return None
def save_data_to_db(self, items, conn, station):
@ -37,7 +60,7 @@ class AirQualityCollector:
try:
item_date = datetime.strptime(item['msurDt'], '%Y-%m-%d').date()
except Exception as e:
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
print(f"[ERROR] 날짜 파싱 오류: {item.get('msurDt')} {e}")
continue
data = {
@ -53,7 +76,7 @@ class AirQualityCollector:
try:
if self.debug:
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
print(f"[DEBUG] {item_date} [{station}] DB 저장 시도: {data}")
continue
if self.force_update:
@ -75,6 +98,7 @@ class AirQualityCollector:
print(f"[ERROR] DB 중복 오류: {e}")
except Exception as e:
print(f"[ERROR] DB 저장 실패: {e}")
traceback.print_exc()
def fetch_air_quality_data_range(self, start_date_str, end_date_str, station_name, num_of_rows=100, page_no=1):
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
@ -88,23 +112,39 @@ class AirQualityCollector:
'msrstnName': station_name,
}
resp = None
try:
resp = requests.get(url, params=params, timeout=10)
resp = self.session.get(url, params=params, timeout=20)
resp.raise_for_status()
data = resp.json()
return data.get('response', {}).get('body', {}).get('items', [])
except requests.RequestException as e:
print(f"[ERROR] 요청 실패: {e}")
return []
except ValueError as e:
print(f"[ERROR] JSON 파싱 실패: {e}")
except Exception as e:
body_preview = None
try:
if resp is not None:
body_preview = resp.text[:1000]
except Exception:
body_preview = None
print(f"[ERROR] 요청 실패: {e} status={getattr(resp, 'status_code', 'n/a')} body_preview={body_preview}")
traceback.print_exc()
return []
def test_num_of_rows(self, station_name, date_str):
# 캐시 확인
try:
cache_key = f"{station_name}:{date_str}"
if cache_key in self._num_rows_cache:
val = int(self._num_rows_cache[cache_key])
print(f"[INFO] 캐시된 numOfRows 사용: {val} ({cache_key})")
return val
except Exception:
pass
max_rows = 1000
min_rows = 100
while max_rows >= min_rows:
resp = None
try:
url = "http://apis.data.go.kr/B552584/ArpltnStatsSvc/getMsrstnAcctoRDyrg"
params = {
@ -116,13 +156,25 @@ class AirQualityCollector:
'inqEndDt': date_str,
'msrstnName': station_name,
}
resp = requests.get(url, params=params, timeout=10)
resp = self.session.get(url, params=params, timeout=20)
resp.raise_for_status()
resp.json()
resp.json() # 성공하면 해당 max_rows 사용 가능
print(f"[INFO] numOfRows 최대값 탐색 성공: {max_rows}")
# 캐시에 저장
try:
self._num_rows_cache[f"{station_name}:{date_str}"] = max_rows
self._save_num_rows_cache()
except Exception:
pass
return max_rows
except Exception as e:
print(f"[WARN] numOfRows={max_rows} 실패: {e}, 100 감소 후 재시도")
body_preview = None
try:
if resp is not None:
body_preview = resp.text[:500]
except Exception:
body_preview = None
print(f"[WARN] numOfRows={max_rows} 실패: {e}, body_preview={body_preview} 100 감소 후 재시도")
max_rows -= 100
print("[WARN] 최소 numOfRows 값(100)도 실패했습니다. 기본값 100 사용")
@ -160,7 +212,7 @@ class AirQualityCollector:
current_start = current_end + timedelta(days=1)
print("\n[INFO] 모든 측정소 데이터 처리 완료")
if __name__ == '__main__':
config = db.load_config()
collector = AirQualityCollector(config, db.engine, db_schema.air)