초기 커밋: Docker/PostgreSQL 이관, 구글 OAuth 제거, Gitea 푸시 스크립트 추가
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
DEFAULT_ADMIN_EMAIL = os.getenv("DEFAULT_ADMIN_EMAIL", "king@dbxcorp.co.kr")
|
||||
DEFAULT_ADMIN_NAME = os.getenv("DEFAULT_ADMIN_NAME", "Administrator")
|
||||
|
||||
|
||||
def app_path(path: str = "/") -> str:
|
||||
root_path = os.getenv("APP_ROOT_PATH", "").rstrip("/")
|
||||
if path == "/":
|
||||
return root_path or "/"
|
||||
return f"{root_path}{path}"
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)):
|
||||
user_email = request.session.get("user_email")
|
||||
if not user_email:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
user = db.query(User).filter(User.email == user_email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
if not user.is_approved and not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not approved by administrator")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
def _login_as_default_admin(request: Request, db: Session) -> RedirectResponse:
|
||||
email = DEFAULT_ADMIN_EMAIL
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
user = User(email=email, name=DEFAULT_ADMIN_NAME, is_admin=True, is_approved=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
request.session["user_email"] = email
|
||||
return RedirectResponse(url=app_path("/"))
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
return _login_as_default_admin(request, db)
|
||||
|
||||
|
||||
@router.get("/login/test")
|
||||
async def login_test(request: Request, db: Session = Depends(get_db)):
|
||||
return _login_as_default_admin(request, db)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.pop("user_email", None)
|
||||
return RedirectResponse(url=app_path("/"))
|
||||
|
||||
|
||||
@router.get("/api/users/me")
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
return {"email": current_user.email, "name": current_user.name, "is_admin": current_user.is_admin}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_env_file(path: str = ".env"):
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
|
||||
with open(path, "r", encoding="utf-8") as env_file:
|
||||
for raw_line in env_file:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
load_env_file()
|
||||
|
||||
|
||||
def _build_pg_url(db_name: str) -> str:
|
||||
user = os.getenv("DB_USER", "")
|
||||
password = os.getenv("DB_PASSWORD", "")
|
||||
host = os.getenv("DB_HOST", "localhost")
|
||||
port = os.getenv("DB_PORT", "5432")
|
||||
return (
|
||||
f"postgresql+psycopg2://{quote_plus(user)}:{quote_plus(password)}"
|
||||
f"@{host}:{port}/{db_name}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_database_url() -> str:
|
||||
explicit_url = os.getenv("DATABASE_URL")
|
||||
if explicit_url:
|
||||
return explicit_url
|
||||
|
||||
order_db = os.getenv("ORDER_DB_NAME")
|
||||
if order_db and os.getenv("DB_HOST"):
|
||||
return _build_pg_url(order_db)
|
||||
|
||||
return "sqlite:///./app.db"
|
||||
|
||||
|
||||
DATABASE_URL = _resolve_database_url()
|
||||
|
||||
connect_args = {}
|
||||
engine_kwargs = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False, "timeout": 30}
|
||||
else:
|
||||
engine_kwargs.update(pool_pre_ping=True, pool_recycle=1800)
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, **engine_kwargs)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# --- Secondary engines for the other PostgreSQL databases ----------------
|
||||
# These are exposed for code that needs to query item / return / checklist
|
||||
# databases. They are created lazily and only when PostgreSQL is configured.
|
||||
_secondary_engines: dict[str, object] = {}
|
||||
|
||||
|
||||
def get_secondary_engine(env_var: str):
|
||||
"""Get (or create) an engine for ITEM_DB_NAME / RETURN_DB_NAME / CHECKLIST_DB_NAME."""
|
||||
db_name = os.getenv(env_var)
|
||||
if not db_name or not os.getenv("DB_HOST"):
|
||||
return None
|
||||
if env_var in _secondary_engines:
|
||||
return _secondary_engines[env_var]
|
||||
eng = create_engine(_build_pg_url(db_name), pool_pre_ping=True, pool_recycle=1800)
|
||||
_secondary_engines[env_var] = eng
|
||||
return eng
|
||||
|
||||
|
||||
# Dependency for FastAPI to get DB session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def check_db_connection() -> tuple[bool, str]:
|
||||
"""Ping the primary database. Returns (ok, message)."""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True, "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def log_db_startup_status() -> None:
|
||||
safe_url = engine.url.render_as_string(hide_password=True)
|
||||
ok, msg = check_db_connection()
|
||||
if ok:
|
||||
logger.warning("[DB] Connected: %s", safe_url)
|
||||
else:
|
||||
logger.error("[DB] Connection FAILED: %s -> %s", safe_url, msg)
|
||||
@@ -0,0 +1,278 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
SEARCH_OPTIMIZATION_VERSION = "2026-04-24-search-v1"
|
||||
PHONE_FTS_VERSION = "2026-04-24-phone-fts-v1"
|
||||
ADDRESS_NORMALIZATION_VERSION = "2026-04-29-address-normalized-v1"
|
||||
|
||||
|
||||
def _supports_trigram_fts(conn) -> bool:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS temp_fts_trigram_check "
|
||||
"USING fts5(value, tokenize='trigram')"
|
||||
)
|
||||
)
|
||||
conn.execute(text("DROP TABLE IF EXISTS temp_fts_trigram_check"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _setting_value(conn, key: str) -> str | None:
|
||||
row = conn.execute(
|
||||
text("SELECT value FROM settings WHERE key = :key"), {"key": key}
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _set_setting(conn, key: str, value: str) -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO settings(key, value) VALUES (:key, :value) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
|
||||
|
||||
def ensure_search_optimizations(engine) -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
ensure_standard_indexes(engine)
|
||||
return
|
||||
|
||||
inspector = inspect(engine)
|
||||
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
|
||||
|
||||
with engine.begin() as conn:
|
||||
needs_rebuild = _setting_value(conn, "search_optimization_version") != SEARCH_OPTIMIZATION_VERSION
|
||||
if needs_rebuild:
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ai"))
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_ad"))
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_fts_au"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS orders_name_address_fts"))
|
||||
|
||||
if "recipient_phone_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized TEXT"))
|
||||
if "recipient_mobile_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized TEXT"))
|
||||
if "tracking_number_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized TEXT"))
|
||||
if "address_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized TEXT"))
|
||||
added_customer_note = "customer_note" not in existing_columns
|
||||
if added_customer_note:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note TEXT"))
|
||||
if "order_note" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note TEXT"))
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized "
|
||||
"ON orders(recipient_phone_normalized)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized "
|
||||
"ON orders(recipient_mobile_normalized)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized "
|
||||
"ON orders(tracking_number_normalized)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_address_normalized "
|
||||
"ON orders(address_normalized)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_customer_note "
|
||||
"ON orders(customer_note)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_order_note "
|
||||
"ON orders(order_note)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_tracking_number_id "
|
||||
"ON orders(tracking_number, id DESC)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id "
|
||||
"ON orders(upload_date, id DESC)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_orders_order_date_id "
|
||||
"ON orders(order_date, id DESC)"
|
||||
)
|
||||
)
|
||||
|
||||
if needs_rebuild:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE orders SET "
|
||||
"recipient_phone_normalized = replace(replace(replace(replace(replace(coalesce(recipient_phone, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
|
||||
"recipient_mobile_normalized = replace(replace(replace(replace(replace(coalesce(recipient_mobile, ''), '-', ''), ' ', ''), '.', ''), ')', ''), '(', ''), "
|
||||
"tracking_number_normalized = upper(replace(replace(replace(replace(coalesce(tracking_number, ''), '-', ''), ' ', ''), '/', ''), '.', ''))"
|
||||
)
|
||||
)
|
||||
if added_customer_note:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE orders SET customer_note = note "
|
||||
"WHERE note IS NOT NULL AND note != ''"
|
||||
)
|
||||
)
|
||||
|
||||
address_normalization_rebuild = (
|
||||
_setting_value(conn, "address_normalization_version") != ADDRESS_NORMALIZATION_VERSION
|
||||
)
|
||||
if address_normalization_rebuild:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE orders SET address_normalized = "
|
||||
"trim(coalesce(address, ''), "
|
||||
"' ' || char(9) || char(10) || char(13) || char(160) || "
|
||||
"char(8203) || char(8204) || char(8205) || char(65279))"
|
||||
)
|
||||
)
|
||||
_set_setting(conn, "address_normalization_version", ADDRESS_NORMALIZATION_VERSION)
|
||||
|
||||
tokenizer = "trigram" if _supports_trigram_fts(conn) else "unicode61"
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_name_address_fts "
|
||||
"USING fts5("
|
||||
"recipient_name, address, "
|
||||
"content='orders', content_rowid='id', "
|
||||
f"tokenize='{tokenizer}'"
|
||||
")"
|
||||
)
|
||||
)
|
||||
|
||||
if needs_rebuild:
|
||||
conn.execute(text("INSERT INTO orders_name_address_fts(orders_name_address_fts) VALUES('rebuild')"))
|
||||
_set_setting(conn, "search_optimization_version", SEARCH_OPTIMIZATION_VERSION)
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_fts_ai AFTER INSERT ON orders BEGIN "
|
||||
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
|
||||
"VALUES (new.id, new.recipient_name, new.address); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_fts_ad AFTER DELETE ON orders BEGIN "
|
||||
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
|
||||
"VALUES('delete', old.id, old.recipient_name, old.address); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_fts_au AFTER UPDATE ON orders BEGIN "
|
||||
"INSERT INTO orders_name_address_fts(orders_name_address_fts, rowid, recipient_name, address) "
|
||||
"VALUES('delete', old.id, old.recipient_name, old.address); "
|
||||
"INSERT INTO orders_name_address_fts(rowid, recipient_name, address) "
|
||||
"VALUES (new.id, new.recipient_name, new.address); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
|
||||
phone_fts_rebuild = _setting_value(conn, "phone_fts_version") != PHONE_FTS_VERSION
|
||||
if phone_fts_rebuild:
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ai"))
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_ad"))
|
||||
conn.execute(text("DROP TRIGGER IF EXISTS orders_phone_fts_au"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS orders_phone_fts"))
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS orders_phone_fts "
|
||||
"USING fts5("
|
||||
"recipient_phone_normalized, recipient_mobile_normalized, "
|
||||
"content='orders', content_rowid='id', tokenize='trigram'"
|
||||
")"
|
||||
)
|
||||
)
|
||||
if phone_fts_rebuild:
|
||||
conn.execute(text("INSERT INTO orders_phone_fts(orders_phone_fts) VALUES('rebuild')"))
|
||||
_set_setting(conn, "phone_fts_version", PHONE_FTS_VERSION)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ai AFTER INSERT ON orders BEGIN "
|
||||
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
|
||||
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_ad AFTER DELETE ON orders BEGIN "
|
||||
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
|
||||
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TRIGGER IF NOT EXISTS orders_phone_fts_au AFTER UPDATE ON orders BEGIN "
|
||||
"INSERT INTO orders_phone_fts(orders_phone_fts, rowid, recipient_phone_normalized, recipient_mobile_normalized) "
|
||||
"VALUES('delete', old.id, old.recipient_phone_normalized, old.recipient_mobile_normalized); "
|
||||
"INSERT INTO orders_phone_fts(rowid, recipient_phone_normalized, recipient_mobile_normalized) "
|
||||
"VALUES (new.id, new.recipient_phone_normalized, new.recipient_mobile_normalized); "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_standard_indexes(engine) -> None:
|
||||
inspector = inspect(engine)
|
||||
existing_columns = {column["name"] for column in inspector.get_columns("orders")}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if "recipient_phone_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_phone_normalized VARCHAR"))
|
||||
if "recipient_mobile_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN recipient_mobile_normalized VARCHAR"))
|
||||
if "tracking_number_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN tracking_number_normalized VARCHAR"))
|
||||
if "address_normalized" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN address_normalized VARCHAR"))
|
||||
added_customer_note = "customer_note" not in existing_columns
|
||||
if added_customer_note:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN customer_note VARCHAR"))
|
||||
if "order_note" not in existing_columns:
|
||||
conn.execute(text("ALTER TABLE orders ADD COLUMN order_note VARCHAR"))
|
||||
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_phone_normalized ON orders(recipient_phone_normalized)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_mobile_normalized ON orders(recipient_mobile_normalized)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_tracking_normalized ON orders(tracking_number_normalized)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_address_normalized ON orders(address_normalized)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_customer_note ON orders(customer_note)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_note ON orders(order_note)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_order_date_id ON orders(order_date, id DESC)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_orders_upload_date_id ON orders(upload_date, id DESC)"))
|
||||
if added_customer_note:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE orders SET customer_note = note "
|
||||
"WHERE note IS NOT NULL AND note != ''"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
LOG_DIR = "logs"
|
||||
EVENT_LOG_PATH = os.path.join(LOG_DIR, "event_log.csv")
|
||||
LOG_COLUMNS = ["date", "time", "user", "action", "status", "details"]
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def user_label(user) -> str:
|
||||
if not user:
|
||||
return ""
|
||||
return getattr(user, "email", None) or getattr(user, "name", None) or str(getattr(user, "id", ""))
|
||||
|
||||
|
||||
def log_event(user, action: str, status: str = "success", details: dict | str | None = None):
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
now = datetime.now(KST)
|
||||
file_exists = os.path.exists(EVENT_LOG_PATH)
|
||||
details_text = ""
|
||||
if isinstance(details, dict):
|
||||
details_text = json.dumps(details, ensure_ascii=False)
|
||||
elif details is not None:
|
||||
details_text = str(details)
|
||||
|
||||
with open(EVENT_LOG_PATH, "a", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=LOG_COLUMNS)
|
||||
if not file_exists or os.path.getsize(EVENT_LOG_PATH) == 0:
|
||||
writer.writeheader()
|
||||
writer.writerow(
|
||||
{
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"time": now.strftime("%H:%M:%S"),
|
||||
"user": user_label(user),
|
||||
"action": action,
|
||||
"status": status,
|
||||
"details": details_text,
|
||||
}
|
||||
)
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.database import engine, Base, check_db_connection, log_db_startup_status
|
||||
from app.database_maintenance import ensure_search_optimizations
|
||||
from app.routers import orders, settings
|
||||
from app.auth import router as auth_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
|
||||
|
||||
# Verify DB connectivity at startup (log only — do not crash so the
|
||||
# container can stay up and report status via /health/db).
|
||||
log_db_startup_status()
|
||||
|
||||
# Create all DB tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_search_optimizations(engine)
|
||||
|
||||
app = FastAPI(title="Order Management System", root_path=APP_ROOT_PATH)
|
||||
|
||||
|
||||
@app.get("/health/db")
|
||||
async def health_db():
|
||||
ok, msg = check_db_connection()
|
||||
status_code = 200 if ok else 503
|
||||
return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code)
|
||||
|
||||
# Session middleware for OAuth
|
||||
# In production, use a secure secret key from environment variables
|
||||
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "super-secret-key"))
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Mount static files for frontend
|
||||
os.makedirs("static", exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def read_root():
|
||||
# Return the main index.html file
|
||||
if os.path.exists("static/index.html"):
|
||||
with open("static/index.html", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH)
|
||||
content = content.replace('src="/static/', f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/')
|
||||
content = content.replace('href="/static/', f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/')
|
||||
content = content.replace("window.location.href='/login", f"window.location.href='{APP_ROOT_PATH}/login")
|
||||
content = content.replace('window.location.href="/login', f'window.location.href="{APP_ROOT_PATH}/login')
|
||||
content = content.replace("window.location.href='/logout", f"window.location.href='{APP_ROOT_PATH}/logout")
|
||||
content = content.replace('window.location.href="/logout', f'window.location.href="{APP_ROOT_PATH}/logout')
|
||||
return HTMLResponse(content=content)
|
||||
return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>")
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from .database import Base
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_date = Column(String, nullable=True) # 주문날짜
|
||||
sequence_num = Column(String, nullable=True) # 번호
|
||||
order_no = Column(String, nullable=True) # 주문번호
|
||||
order_no_mall = Column(String, nullable=True) # 주문번호(쇼핑몰)
|
||||
recipient_name = Column(String, index=True, nullable=True) # 수령인명
|
||||
product_code = Column(String, nullable=True) # 상품코드
|
||||
product_name = Column(String, nullable=True) # 상품명
|
||||
order_quantity = Column(String, nullable=True) # 주문수량
|
||||
address = Column(String, index=True, nullable=True) # 주소
|
||||
address_normalized = Column(String, index=True, nullable=True)
|
||||
postal_code = Column(String, nullable=True) # 우편번호
|
||||
recipient_phone = Column(String, nullable=True) # 수령인 전화번호 (일반전화)
|
||||
recipient_mobile = Column(String, index=True, nullable=True) # 수령인 휴대폰
|
||||
recipient_phone_normalized = Column(String, index=True, nullable=True)
|
||||
recipient_mobile_normalized = Column(String, index=True, nullable=True)
|
||||
delivery_memo = Column(String, nullable=True) # 배송시 요구사항
|
||||
tracking_number = Column(String, index=True, nullable=True) # 송장번호
|
||||
tracking_number_normalized = Column(String, index=True, nullable=True)
|
||||
vendor = Column(String, nullable=True) # 발주처
|
||||
order_list_1 = Column(String, nullable=True) # 주문목록
|
||||
order_list_2 = Column(String, nullable=True) # 주문목록2
|
||||
note = Column(String, index=True) # 비고 (옵션)
|
||||
customer_note = Column(String, index=True, nullable=True) # 고객 메모
|
||||
order_note = Column(String, index=True, nullable=True) # 주문 메모
|
||||
upload_date = Column(String, nullable=True) # 추가된 날짜 및 시간 (yyyy-MM-dd HH:mm:ss)
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
Users table for Google Workspace authentication.
|
||||
Only approved users can access the system.
|
||||
"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
name = Column(String, nullable=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_approved = Column(Boolean, default=False)
|
||||
|
||||
class Setting(Base):
|
||||
"""
|
||||
Application settings (e.g., Excel export path)
|
||||
"""
|
||||
__tablename__ = "settings"
|
||||
|
||||
key = Column(String, primary_key=True, index=True)
|
||||
value = Column(String, nullable=True)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import re
|
||||
|
||||
|
||||
_NON_DIGIT_RE = re.compile(r"\D+")
|
||||
_NON_ALNUM_RE = re.compile(r"[^0-9A-Za-z]+")
|
||||
|
||||
|
||||
def normalize_phone(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = _NON_DIGIT_RE.sub("", str(value))
|
||||
return normalized or None
|
||||
|
||||
|
||||
def normalize_tracking(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = _NON_ALNUM_RE.sub("", str(value)).upper()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def fts_phrase(value: str) -> str:
|
||||
escaped = str(value).strip().replace('"', '""')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def can_use_trigram_fts(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
return len(str(value).strip()) >= 3
|
||||
|
||||
|
||||
def prefix_upper_bound(value: str) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
last = value[-1]
|
||||
if last == "\uffff":
|
||||
return None
|
||||
return value[:-1] + chr(ord(last) + 1)
|
||||
Reference in New Issue
Block a user