Initial commit
This commit is contained in:
20
backend/routes/__init__.py
Normal file
20
backend/routes/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from flask import Flask
|
||||
from .home import register_home_routes
|
||||
from .auth import register_auth_routes
|
||||
from .admin import register_admin_routes
|
||||
from .main import register_main_routes
|
||||
from .xml import register_xml_routes
|
||||
from .utilities import register_util_routes
|
||||
from .file_view import register_file_view
|
||||
|
||||
|
||||
def register_routes(app: Flask, socketio=None) -> None:
|
||||
"""블루프린트 일괄 등록. socketio는 main 라우트에서만 사용."""
|
||||
register_home_routes(app)
|
||||
register_auth_routes(app)
|
||||
register_admin_routes(app)
|
||||
register_main_routes(app, socketio)
|
||||
register_xml_routes(app)
|
||||
register_util_routes(app)
|
||||
register_file_view(app)
|
||||
BIN
backend/routes/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/admin.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/auth.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/file_view.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/file_view.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/home.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/home.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/utilities.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/utilities.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/routes/__pycache__/xml.cpython-313.pyc
Normal file
BIN
backend/routes/__pycache__/xml.cpython-313.pyc
Normal file
Binary file not shown.
126
backend/routes/admin.py
Normal file
126
backend/routes/admin.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# backend/routes/admin.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
abort,
|
||||
request,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from backend.models.user import User, db
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
# Blueprint 등록
|
||||
def register_admin_routes(app):
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
# 관리자 권한 데코레이터
|
||||
def admin_required(view_func: Callable):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("auth.login"))
|
||||
if not getattr(current_user, "is_admin", False):
|
||||
flash("관리자 권한이 필요합니다.", "danger")
|
||||
return redirect(url_for("main.index"))
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# 관리자 대시보드
|
||||
@admin_bp.route("/admin", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
users = db.session.query(User).order_by(User.id.asc()).all()
|
||||
return render_template("admin.html", users=users)
|
||||
|
||||
|
||||
# 사용자 승인
|
||||
@admin_bp.route("/admin/approve/<int:user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def approve_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
user.is_active = True
|
||||
db.session.commit()
|
||||
flash("사용자가 승인되었습니다.", "success")
|
||||
logging.info("✅ 승인된 사용자: %s (id=%s)", user.username, user.id)
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# 사용자 삭제
|
||||
@admin_bp.route("/admin/delete/<int:user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash("사용자가 삭제되었습니다.", "success")
|
||||
logging.info("🗑 삭제된 사용자: %s (id=%s)", username, user_id)
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# ▼▼▼ 사용자 비밀번호 변경(관리자용) ▼▼▼
|
||||
@admin_bp.route("/admin/users/<int:user_id>/reset_password", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_password(user_id: int):
|
||||
"""
|
||||
admin.html에서 각 사용자 행 아래 폼으로부터 POST:
|
||||
- name="new_password"
|
||||
- name="confirm_password"
|
||||
CSRF는 템플릿에서 {{ csrf_token() }} 또는 {{ form.hidden_tag() }}로 포함되어야 합니다.
|
||||
"""
|
||||
new_pw = (request.form.get("new_password") or "").strip()
|
||||
confirm = (request.form.get("confirm_password") or "").strip()
|
||||
|
||||
# 서버측 검증
|
||||
if not new_pw or not confirm:
|
||||
flash("비밀번호와 확인 값을 모두 입력하세요.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
if new_pw != confirm:
|
||||
flash("비밀번호 확인이 일치하지 않습니다.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
if len(new_pw) < 8:
|
||||
flash("비밀번호는 최소 8자 이상이어야 합니다.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
# passlib(Argon2id) 기반 set_password 사용 (models.user에 구현됨)
|
||||
user.set_password(new_pw)
|
||||
db.session.commit()
|
||||
flash(f"사용자(ID={user.id}) 비밀번호를 변경했습니다.", "success")
|
||||
current_app.logger.info(
|
||||
"ADMIN: reset password for user_id=%s by admin_id=%s",
|
||||
user.id, current_user.id
|
||||
)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("ADMIN: reset password failed: %s", e)
|
||||
flash("비밀번호 변경 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
180
backend/routes/auth.py
Normal file
180
backend/routes/auth.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# backend/routes/auth.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
request,
|
||||
session,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_user, logout_user, current_user, login_required
|
||||
|
||||
from backend.forms.auth_forms import RegistrationForm, LoginForm
|
||||
from backend.models.user import User, db
|
||||
|
||||
# ── (선택) Telegram: 미설정이면 조용히 패스
|
||||
try:
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
except Exception: # 라이브러리 미설치/미사용 환경
|
||||
Bot = None
|
||||
ParseMode = None
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 유틸
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def _is_safe_url(target: str) -> bool:
|
||||
"""로그인 후 next 파라미터의 안전성 확인(동일 호스트만 허용)."""
|
||||
ref = urlparse(request.host_url)
|
||||
test = urlparse(urljoin(request.host_url, target))
|
||||
return (test.scheme in ("http", "https")) and (ref.netloc == test.netloc)
|
||||
|
||||
|
||||
def _notify(text: str) -> None:
|
||||
"""텔레그램 알림 (설정 없으면 바로 return)."""
|
||||
token = (current_app.config.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
chat_id = (current_app.config.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||
if not (token and chat_id and Bot and ParseMode):
|
||||
return
|
||||
|
||||
def _send():
|
||||
try:
|
||||
bot = Bot(token=token)
|
||||
bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Telegram send failed: %s", e)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Blueprint 등록 훅
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def register_auth_routes(app):
|
||||
"""app.py에서 register_routes(app, socketio) 호출 시 사용."""
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
@app.before_request
|
||||
def _touch_session():
|
||||
# 요청마다 세션 갱신(만료 슬라이딩) + 로그아웃 플래그 정리
|
||||
session.modified = True
|
||||
if current_user.is_authenticated and session.get("just_logged_out"):
|
||||
session.pop("just_logged_out", None)
|
||||
flash("세션이 만료되어 자동 로그아웃 되었습니다.", "info")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 회원가입
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("REGISTER: already auth → /index")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
# 모델 내부에서 email/username 정규화됨(find_by_*)
|
||||
if User.find_by_email(form.email.data):
|
||||
flash("이미 등록된 이메일입니다.", "warning")
|
||||
current_app.logger.info("REGISTER: dup email %s", form.email.data)
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
if User.find_by_username(form.username.data):
|
||||
flash("이미 사용 중인 사용자명입니다.", "warning")
|
||||
current_app.logger.info("REGISTER: dup username %s", form.username.data)
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
user = User(username=form.username.data, email=form.email.data, is_active=False)
|
||||
user.set_password(form.password.data) # passlib: 기본 Argon2id
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
_notify(
|
||||
f"🆕 <b>신규 가입 요청</b>\n"
|
||||
f"📛 사용자: <code>{user.username}</code>\n"
|
||||
f"📧 이메일: <code>{user.email}</code>"
|
||||
)
|
||||
current_app.logger.info("REGISTER: created id=%s email=%s", user.id, user.email)
|
||||
flash("회원가입이 완료되었습니다. 관리자의 승인을 기다려주세요.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
else:
|
||||
if request.method == "POST":
|
||||
current_app.logger.info("REGISTER: form errors=%s", form.errors)
|
||||
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 로그인
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("LOGIN: already auth → /index")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
current_app.logger.info("LOGIN: form ok email=%s", form.email.data)
|
||||
user: Optional[User] = User.find_by_email(form.email.data)
|
||||
|
||||
if not user:
|
||||
flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger")
|
||||
current_app.logger.info("LOGIN: user not found")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
pass_ok = user.check_password(form.password.data) # passlib verify(+자동 재해시)
|
||||
current_app.logger.info(
|
||||
"LOGIN: found id=%s active=%s pass_ok=%s",
|
||||
user.id, user.is_active, pass_ok
|
||||
)
|
||||
|
||||
if not pass_ok:
|
||||
flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
if not user.is_active:
|
||||
flash("계정이 아직 승인되지 않았습니다.", "warning")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
# 성공
|
||||
login_user(user, remember=form.remember.data)
|
||||
session.permanent = True
|
||||
_notify(f"🔐 <b>로그인 성공</b>\n👤 <code>{user.username}</code>")
|
||||
current_app.logger.info("LOGIN: SUCCESS → redirect")
|
||||
|
||||
nxt = request.args.get("next")
|
||||
if nxt and _is_safe_url(nxt):
|
||||
return redirect(nxt)
|
||||
return redirect(url_for("main.index"))
|
||||
else:
|
||||
if request.method == "POST":
|
||||
current_app.logger.info("LOGIN: form errors=%s", form.errors)
|
||||
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 로그아웃
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/logout", methods=["GET"])
|
||||
@login_required
|
||||
def logout():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("LOGOUT: user=%s", current_user.username)
|
||||
logout_user()
|
||||
session["just_logged_out"] = True
|
||||
return redirect(url_for("auth.login"))
|
||||
95
backend/routes/file_view.py
Normal file
95
backend/routes/file_view.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from flask_login import login_required
|
||||
|
||||
from config import Config
|
||||
import chardet
|
||||
|
||||
file_view_bp = Blueprint("file_view", __name__)
|
||||
|
||||
|
||||
def register_file_view(app):
|
||||
"""블루프린트 등록"""
|
||||
app.register_blueprint(file_view_bp)
|
||||
|
||||
|
||||
def _safe_within(base: Path, target: Path) -> bool:
|
||||
"""
|
||||
target 이 base 디렉터리 내부인지 검사 (경로 탈출 방지)
|
||||
"""
|
||||
try:
|
||||
target.resolve().relative_to(base.resolve())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _decode_bytes(raw: bytes) -> str:
|
||||
"""
|
||||
파일 바이트 → 문자열 디코딩 (감지 → utf-8 → cp949 순서로 시도)
|
||||
"""
|
||||
enc = (chardet.detect(raw).get("encoding") or "utf-8").strip().lower()
|
||||
for cand in (enc, "utf-8", "cp949"):
|
||||
try:
|
||||
return raw.decode(cand)
|
||||
except Exception:
|
||||
continue
|
||||
# 최후의 수단: 손실 허용 디코딩
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@file_view_bp.route("/view_file", methods=["GET"])
|
||||
@login_required
|
||||
def view_file():
|
||||
"""
|
||||
파일 내용을 읽어 반환.
|
||||
- /view_file?folder=idrac_info&filename=abc.txt
|
||||
- /view_file?folder=backup&date=<백업폴더명>&filename=abc.txt
|
||||
- ?raw=1 을 붙이면 text/plain 으로 원문을 반환 (모달 표시용)
|
||||
"""
|
||||
folder = request.args.get("folder", "").strip()
|
||||
date = request.args.get("date", "").strip()
|
||||
filename = request.args.get("filename", "").strip()
|
||||
want_raw = request.args.get("raw")
|
||||
|
||||
if not filename:
|
||||
return jsonify({"error": "파일 이름이 없습니다."}), 400
|
||||
|
||||
# 파일명/폴더명은 유니코드 보존. 상위 경로만 제거하여 보안 유지
|
||||
safe_name = Path(filename).name
|
||||
|
||||
if folder == "backup":
|
||||
base = Path(Config.BACKUP_FOLDER)
|
||||
safe_date = Path(date).name if date else ""
|
||||
target = (base / safe_date / safe_name).resolve()
|
||||
else:
|
||||
base = Path(Config.IDRAC_INFO_FOLDER)
|
||||
target = (base / safe_name).resolve()
|
||||
|
||||
logging.info(
|
||||
"file_view: folder=%s date=%s filename=%s | base=%s | target=%s",
|
||||
folder, date, filename, str(base), str(target)
|
||||
)
|
||||
|
||||
if not _safe_within(base, target) or not target.is_file():
|
||||
logging.warning("file_view: 파일 없음: %s", str(target))
|
||||
return jsonify({"error": "파일을 찾을 수 없습니다."}), 404
|
||||
|
||||
try:
|
||||
raw = target.read_bytes()
|
||||
content = _decode_bytes(raw)
|
||||
|
||||
if want_raw:
|
||||
# 텍스트 원문 반환 (모달에서 fetch().text()로 사용)
|
||||
return Response(content, mimetype="text/plain; charset=utf-8")
|
||||
|
||||
# JSON으로 감싸서 반환 (기존 사용처 호환)
|
||||
return jsonify({"content": content})
|
||||
|
||||
except Exception as e:
|
||||
logging.error("file_view: 파일 읽기 실패: %s → %s", str(target), e)
|
||||
return jsonify({"error": "파일 열기 중 오류 발생"}), 500
|
||||
13
backend/routes/home.py
Normal file
13
backend/routes/home.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
home_bp = Blueprint("home", __name__, url_prefix="/home")
|
||||
|
||||
|
||||
def register_home_routes(app):
|
||||
app.register_blueprint(home_bp)
|
||||
|
||||
|
||||
@home_bp.route("/", methods=["GET"])
|
||||
def home():
|
||||
return render_template("home.html")
|
||||
208
backend/routes/main.py
Normal file
208
backend/routes/main.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import zipfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, send_from_directory, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from watchdog.observers import Observer
|
||||
from natsort import natsorted
|
||||
|
||||
from backend.services.ip_processor import (
|
||||
save_ip_addresses,
|
||||
process_ips_concurrently,
|
||||
get_progress,
|
||||
on_complete,
|
||||
)
|
||||
from backend.services.watchdog_handler import FileCreatedHandler
|
||||
from config import Config
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
executor = ThreadPoolExecutor(max_workers=Config.MAX_WORKERS)
|
||||
|
||||
|
||||
def register_main_routes(app, socketio):
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
@app.context_processor
|
||||
def inject_user():
|
||||
return dict(current_user=current_user)
|
||||
|
||||
@app.before_request
|
||||
def make_session_permanent():
|
||||
session.permanent = True
|
||||
if current_user.is_authenticated:
|
||||
session.modified = True
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
@main_bp.route("/index", methods=["GET"])
|
||||
@login_required
|
||||
def index():
|
||||
script_dir = Path(Config.SCRIPT_FOLDER)
|
||||
xml_dir = Path(Config.XML_FOLDER)
|
||||
info_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
backup_dir = Path(Config.BACKUP_FOLDER)
|
||||
|
||||
scripts = [f.name for f in script_dir.glob("*") if f.is_file() and f.name != ".env"]
|
||||
scripts = natsorted(scripts)
|
||||
xml_files = [f.name for f in xml_dir.glob("*.xml")]
|
||||
|
||||
# 페이지네이션
|
||||
page = int(request.args.get("page", 1))
|
||||
info_files = [f.name for f in info_dir.glob("*") if f.is_file()]
|
||||
info_files = natsorted(info_files)
|
||||
|
||||
start = (page - 1) * Config.FILES_PER_PAGE
|
||||
end = start + Config.FILES_PER_PAGE
|
||||
files_to_display = [{"name": Path(f).stem, "file": f} for f in info_files[start:end]]
|
||||
total_pages = (len(info_files) + Config.FILES_PER_PAGE - 1) // Config.FILES_PER_PAGE
|
||||
|
||||
# 백업 폴더 목록 (디렉터리만)
|
||||
backup_dirs = [d for d in backup_dir.iterdir() if d.is_dir()]
|
||||
backup_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
backup_page = int(request.args.get("backup_page", 1))
|
||||
start_b = (backup_page - 1) * Config.BACKUP_FILES_PER_PAGE
|
||||
end_b = start_b + Config.BACKUP_FILES_PER_PAGE
|
||||
backup_slice = backup_dirs[start_b:end_b]
|
||||
total_backup_pages = (len(backup_dirs) + Config.BACKUP_FILES_PER_PAGE - 1) // Config.BACKUP_FILES_PER_PAGE
|
||||
|
||||
backup_files = {}
|
||||
for d in backup_slice:
|
||||
files = [f.name for f in d.iterdir() if f.is_file()]
|
||||
backup_files[d.name] = {"files": files, "count": len(files)}
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
files_to_display=files_to_display,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
backup_files=backup_files,
|
||||
total_backup_pages=total_backup_pages,
|
||||
backup_page=backup_page,
|
||||
scripts=scripts,
|
||||
xml_files=xml_files,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/process_ips", methods=["POST"])
|
||||
@login_required
|
||||
def process_ips():
|
||||
ips = request.form.get("ips")
|
||||
selected_script = request.form.get("script")
|
||||
selected_xml_file = request.form.get("xmlFile")
|
||||
|
||||
if not ips or not selected_script:
|
||||
return jsonify({"error": "IP 주소와 스크립트를 모두 입력하세요."}), 400
|
||||
|
||||
xml_file_path = None
|
||||
if selected_script == "02-set_config.py" and selected_xml_file:
|
||||
xml_path = Path(Config.XML_FOLDER) / selected_xml_file
|
||||
if not xml_path.exists():
|
||||
return jsonify({"error": "선택한 XML 파일이 존재하지 않습니다."}), 400
|
||||
xml_file_path = str(xml_path)
|
||||
|
||||
job_id = str(time.time())
|
||||
session["job_id"] = job_id
|
||||
|
||||
ip_files = save_ip_addresses(ips, Config.UPLOAD_FOLDER)
|
||||
total_files = len(ip_files)
|
||||
|
||||
handler = FileCreatedHandler(job_id, total_files)
|
||||
observer = Observer()
|
||||
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
|
||||
observer.start()
|
||||
|
||||
future = executor.submit(
|
||||
process_ips_concurrently, ip_files, job_id, observer, selected_script, xml_file_path
|
||||
)
|
||||
future.add_done_callback(lambda x: on_complete(job_id))
|
||||
|
||||
logging.info(f"[AJAX] 작업 시작: {job_id}, script: {selected_script}")
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
|
||||
@main_bp.route("/progress_status/<job_id>")
|
||||
@login_required
|
||||
def progress_status(job_id: str):
|
||||
return jsonify({"progress": get_progress(job_id)})
|
||||
|
||||
|
||||
@main_bp.route("/backup", methods=["POST"])
|
||||
@login_required
|
||||
def backup_files():
|
||||
prefix = request.form.get("backup_prefix", "")
|
||||
if not prefix.startswith("PO"):
|
||||
flash("Backup 이름은 PO로 시작해야 합니다.")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
folder_name = f"{prefix}_{time.strftime('%Y%m%d')}"
|
||||
backup_path = Path(Config.BACKUP_FOLDER) / folder_name
|
||||
backup_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
info_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
for file in info_dir.iterdir():
|
||||
if file.is_file():
|
||||
shutil.move(str(file), str(backup_path / file.name))
|
||||
|
||||
flash("백업 완료되었습니다.")
|
||||
logging.info(f"백업 완료: {folder_name}")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/download/<filename>")
|
||||
@login_required
|
||||
def download_file(filename: str):
|
||||
# send_from_directory는 내부적으로 안전 검사를 수행
|
||||
return send_from_directory(Config.IDRAC_INFO_FOLDER, filename, as_attachment=True)
|
||||
|
||||
|
||||
@main_bp.route("/delete/<filename>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_file(filename: str):
|
||||
file_path = Path(Config.IDRAC_INFO_FOLDER) / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
flash(f"{filename} 삭제됨.")
|
||||
logging.info(f"파일 삭제됨: {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"파일 삭제 오류: {e}")
|
||||
flash("파일 삭제 중 오류가 발생했습니다.", "danger")
|
||||
else:
|
||||
flash("파일이 존재하지 않습니다.")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/download_zip", methods=["POST"])
|
||||
@login_required
|
||||
def download_zip():
|
||||
zip_filename = request.form.get("zip_filename", "export")
|
||||
zip_path = Path(Config.TEMP_ZIP_FOLDER) / f"{zip_filename}.zip"
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file in Path(Config.IDRAC_INFO_FOLDER).glob("*"):
|
||||
if file.is_file():
|
||||
zipf.write(file, arcname=file.name)
|
||||
|
||||
try:
|
||||
response = send_file(str(zip_path), as_attachment=True)
|
||||
return response
|
||||
finally:
|
||||
# 응답 후 임시 ZIP 삭제
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as e:
|
||||
logging.warning(f"임시 ZIP 삭제 실패: {e}")
|
||||
|
||||
|
||||
@main_bp.route("/download_backup/<date>/<filename>")
|
||||
@login_required
|
||||
def download_backup_file(date: str, filename: str):
|
||||
backup_path = Path(Config.BACKUP_FOLDER) / date
|
||||
return send_from_directory(str(backup_path), filename, as_attachment=True)
|
||||
195
backend/routes/utilities.py
Normal file
195
backend/routes/utilities.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, redirect, url_for, flash, jsonify, send_file
|
||||
from flask_login import login_required
|
||||
from config import Config
|
||||
|
||||
utils_bp = Blueprint("utils", __name__)
|
||||
|
||||
|
||||
def register_util_routes(app):
|
||||
app.register_blueprint(utils_bp)
|
||||
|
||||
|
||||
@utils_bp.route("/move_mac_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_mac_files():
|
||||
src = Path(Config.IDRAC_INFO_FOLDER)
|
||||
dst = Path(Config.MAC_FOLDER)
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for file in src.iterdir():
|
||||
if not file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
# 파일이 존재하는지 확인
|
||||
if not file.exists():
|
||||
errors.append(f"{file.name}: 파일이 존재하지 않음")
|
||||
continue
|
||||
|
||||
# 대상 파일이 이미 존재하는 경우 건너뛰기
|
||||
target = dst / file.name
|
||||
if target.exists():
|
||||
logging.warning(f"⚠️ 파일이 이미 존재하여 건너뜀: {file.name}")
|
||||
continue
|
||||
|
||||
shutil.move(str(file), str(target))
|
||||
moved += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{file.name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logging.error(f"❌ 파일 이동 실패: {error_msg}")
|
||||
|
||||
# 결과 로깅
|
||||
if moved > 0:
|
||||
logging.info(f"✅ MAC 파일 이동 완료 ({moved}개)")
|
||||
|
||||
if errors:
|
||||
logging.warning(f"⚠️ 일부 파일 이동 실패: {errors}")
|
||||
|
||||
# 하나라도 성공하면 success: true 반환
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"moved": moved,
|
||||
"errors": errors if errors else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ MAC 이동 중 치명적 오류: {e}")
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
@utils_bp.route("/move_guid_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_guid_files():
|
||||
src = Path(Config.IDRAC_INFO_FOLDER)
|
||||
dst = Path(Config.GUID_FOLDER)
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
for file in src.iterdir():
|
||||
if not file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
# 파일이 존재하는지 확인
|
||||
if not file.exists():
|
||||
errors.append(f"{file.name}: 파일이 존재하지 않음")
|
||||
continue
|
||||
|
||||
# 대상 파일이 이미 존재하는 경우 건너뛰기
|
||||
target = dst / file.name
|
||||
if target.exists():
|
||||
logging.warning(f"⚠️ 파일이 이미 존재하여 건너뜀: {file.name}")
|
||||
continue
|
||||
|
||||
shutil.move(str(file), str(target))
|
||||
moved += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{file.name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logging.error(f"❌ 파일 이동 실패: {error_msg}")
|
||||
|
||||
# 결과 메시지
|
||||
if moved > 0:
|
||||
flash(f"GUID 파일이 성공적으로 이동되었습니다. ({moved}개)", "success")
|
||||
logging.info(f"✅ GUID 파일 이동 완료 ({moved}개)")
|
||||
|
||||
if errors:
|
||||
logging.warning(f"⚠️ 일부 파일 이동 실패: {errors}")
|
||||
flash(f"일부 파일 이동 실패: {len(errors)}개", "warning")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ GUID 이동 오류: {e}")
|
||||
flash(f"오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@utils_bp.route("/update_server_list", methods=["POST"])
|
||||
@login_required
|
||||
def update_server_list():
|
||||
content = request.form.get("server_list_content")
|
||||
if not content:
|
||||
flash("내용을 입력하세요.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "server_list.txt"
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "excel.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
|
||||
timeout=300,
|
||||
)
|
||||
logging.info(f"서버 리스트 스크립트 실행 결과: {result.stdout}")
|
||||
flash("서버 리스트가 업데이트되었습니다.", "success")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"서버 리스트 스크립트 오류: {e.stderr}")
|
||||
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
|
||||
except Exception as e:
|
||||
logging.error(f"서버 리스트 처리 오류: {e}")
|
||||
flash(f"서버 리스트 처리 중 오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@utils_bp.route("/update_guid_list", methods=["POST"])
|
||||
@login_required
|
||||
def update_guid_list():
|
||||
content = request.form.get("server_list_content")
|
||||
if not content:
|
||||
flash("내용을 입력하세요.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "guid_list.txt"
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "GUIDtxtT0Execl.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
|
||||
timeout=300,
|
||||
)
|
||||
logging.info(f"GUID 리스트 스크립트 실행 결과: {result.stdout}")
|
||||
flash("GUID 리스트가 업데이트되었습니다.", "success")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"GUID 리스트 스크립트 오류: {e.stderr}")
|
||||
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
|
||||
except Exception as e:
|
||||
logging.error(f"GUID 리스트 처리 오류: {e}")
|
||||
flash(f"GUID 리스트 처리 중 오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@utils_bp.route("/download_excel")
|
||||
@login_required
|
||||
def download_excel():
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "mac_info.xlsx"
|
||||
if not path.is_file():
|
||||
flash("엑셀 파일을 찾을 수 없습니다.", "danger")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
logging.info(f"엑셀 파일 다운로드: {path}")
|
||||
return send_file(str(path), as_attachment=True, download_name="mac_info.xlsx")
|
||||
105
backend/routes/xml.py
Normal file
105
backend/routes/xml.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
from config import Config
|
||||
|
||||
xml_bp = Blueprint("xml", __name__)
|
||||
|
||||
|
||||
def register_xml_routes(app):
|
||||
app.register_blueprint(xml_bp)
|
||||
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in Config.ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
@xml_bp.route("/xml_management")
|
||||
@login_required
|
||||
def xml_management():
|
||||
xml_dir = Path(Config.XML_FOLDER)
|
||||
try:
|
||||
files = [f.name for f in xml_dir.iterdir() if f.is_file()]
|
||||
except FileNotFoundError:
|
||||
files = []
|
||||
flash("XML 폴더가 존재하지 않습니다.", "danger")
|
||||
return render_template("manage_xml.html", xml_files=files)
|
||||
|
||||
|
||||
@xml_bp.route("/upload_xml", methods=["POST"])
|
||||
@login_required
|
||||
def upload_xml():
|
||||
file = request.files.get("xmlFile")
|
||||
if not file or not file.filename:
|
||||
flash("업로드할 파일을 선택하세요.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
if allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
save_path = Path(Config.XML_FOLDER) / filename
|
||||
try:
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.save(str(save_path))
|
||||
# 텍스트 파일이므로 0644 권장
|
||||
try:
|
||||
save_path.chmod(0o644)
|
||||
except Exception:
|
||||
pass # Windows 등에서 무시
|
||||
logging.info(f"XML 업로드됨: {filename}")
|
||||
flash("파일이 성공적으로 업로드되었습니다.", "success")
|
||||
except Exception as e:
|
||||
logging.error(f"파일 업로드 오류: {e}")
|
||||
flash("파일 저장 중 오류가 발생했습니다.", "danger")
|
||||
else:
|
||||
flash("XML 확장자만 업로드할 수 있습니다.", "warning")
|
||||
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
|
||||
@xml_bp.route("/delete_xml/<filename>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_xml(filename: str):
|
||||
path = Path(Config.XML_FOLDER) / secure_filename(filename)
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
flash(f"{filename} 파일이 삭제되었습니다.", "success")
|
||||
logging.info(f"XML 삭제됨: {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"XML 삭제 오류: {e}")
|
||||
flash("파일 삭제 중 오류 발생", "danger")
|
||||
else:
|
||||
flash("해당 파일이 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
|
||||
@xml_bp.route("/edit_xml/<filename>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_xml(filename: str):
|
||||
path = Path(Config.XML_FOLDER) / secure_filename(filename)
|
||||
if not path.exists():
|
||||
flash("파일을 찾을 수 없습니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
if request.method == "POST":
|
||||
new_content = request.form.get("content", "")
|
||||
try:
|
||||
path.write_text(new_content, encoding="utf-8")
|
||||
logging.info(f"XML 수정됨: {filename}")
|
||||
flash("파일이 성공적으로 수정되었습니다.", "success")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
except Exception as e:
|
||||
logging.error(f"XML 저장 실패: {e}")
|
||||
flash("파일 저장 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logging.error(f"XML 열기 실패: {e}")
|
||||
flash("파일 열기 중 오류가 발생했습니다.", "danger")
|
||||
content = ""
|
||||
|
||||
return render_template("edit_xml.html", filename=filename, content=content)
|
||||
Reference in New Issue
Block a user