fix(시간 표시): 서버 시각 모두 끝 Z · Z 없는 옛 기록 = 한국 시각으로 읽음 (PLAN 593-2 · 요구 456)
- 서버: DB 시각 글(DATE_FORMAT) 끝 Z · B03 올린 때 · D04 확정 시각 · 건강 확인 시각 = UTC Z · 개요 최근 변경 T 치환 뺌 - 서버가 만드는 엑셀(E05 면적 계산 시각) = 한국 시각 글(kst_text · Z 없는 옛 글은 이미 한국 시각) - 화면: Z 없는 글 = 옛 로컬 기록 → +09:00 로 읽음(옛 파일은 고쳐 쓰지 않음) - 시험: test_593_time(UTC · 서울 · 뉴욕 시간대 · Z 없는 길 0 정적 검사 · D04 · kst_text) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,8 +137,8 @@ async def get_workflow_states_for_projects(
|
||||
await cursor.execute(
|
||||
f"""
|
||||
SELECT project_id, stage_no, stage_key, state, progress_percent, params, message,
|
||||
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at,
|
||||
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at
|
||||
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
|
||||
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
|
||||
FROM project_workflow_stages
|
||||
WHERE project_id IN ({format_strings})
|
||||
ORDER BY project_id, stage_no ASC
|
||||
@@ -505,7 +505,7 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
|
||||
bucket_seconds = 120
|
||||
else:
|
||||
bucket_seconds = max(120, (days * 86400) // target_points)
|
||||
date_fmt = "%%Y-%%m-%%dT%%H:%%i:%%s"
|
||||
date_fmt = "%%Y-%%m-%%dT%%H:%%i:%%sZ"
|
||||
await cursor.execute(
|
||||
f"""SELECT
|
||||
DATE_FORMAT(
|
||||
|
||||
@@ -282,7 +282,7 @@ _AUDIT_ACTIONS = ("PROJECT_CREATE", "PROJECT_UPDATE")
|
||||
async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]:
|
||||
"""최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개."""
|
||||
await cursor.execute(
|
||||
"""SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%d %%H:%%i:%%s') AS at,
|
||||
"""SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS at,
|
||||
u.name AS who
|
||||
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.resource_type = 'project' AND l.resource_ref = %s
|
||||
@@ -293,7 +293,7 @@ async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -
|
||||
items: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()]
|
||||
items += [
|
||||
{
|
||||
"at": s["completed_at"].replace("T", " "),
|
||||
"at": s["completed_at"],
|
||||
"who": None,
|
||||
"action": "STAGE_COMPLETE",
|
||||
"stage_no": s["stage_no"],
|
||||
|
||||
@@ -142,7 +142,7 @@ async def create_project(
|
||||
SELECT id AS project_id, name, region, road_type, project_year,
|
||||
estimated_length_m, route_start_m, route_end_m,
|
||||
memo, status, storage_path,
|
||||
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
|
||||
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS created_at
|
||||
FROM projects
|
||||
WHERE id = %s
|
||||
""",
|
||||
@@ -165,7 +165,7 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None:
|
||||
SELECT id AS project_id, user_id, company_id, name, region, road_type,
|
||||
project_year, estimated_length_m, route_start_m, route_end_m,
|
||||
memo, status, storage_path,
|
||||
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
|
||||
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS created_at
|
||||
FROM projects
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
|
||||
@@ -115,6 +115,7 @@ from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_ema
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_time import utc_text
|
||||
from common_util.common_util_workflow import load_project_workflow
|
||||
from common_util.common_util_workflow_state import (
|
||||
analysis_lock_owner,
|
||||
@@ -366,7 +367,7 @@ async def get_project_upload_overview(
|
||||
original_filename=str(row["original_filename"]),
|
||||
file_size_mb=float(row["file_size_mb"] or 0.0),
|
||||
status=str(row["status"]),
|
||||
uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None,
|
||||
uploaded_at=utc_text(row.get("upload_at")),
|
||||
relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None),
|
||||
metadata=_parse_metadata(row.get("metadata")),
|
||||
)
|
||||
@@ -382,7 +383,7 @@ async def get_project_upload_overview(
|
||||
progress_percent=round(
|
||||
100.0 * int(row["completed_chunks"]) / max(1, int(row["total_chunks"])), 1
|
||||
),
|
||||
updated_at=str(row["updated_at"]) if row.get("updated_at") else None,
|
||||
updated_at=utc_text(row.get("updated_at")),
|
||||
)
|
||||
for row in sessions
|
||||
],
|
||||
|
||||
@@ -30,6 +30,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import is_forest_service, verify_session
|
||||
from common_util.common_util_crs import tm_to_lonlat
|
||||
from common_util.common_util_time import utc_text
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail, send_completion_mail
|
||||
@@ -337,7 +338,7 @@ def year_summary(row: dict[str, Any]) -> dict[str, Any]:
|
||||
planned = body.get("planned") or []
|
||||
out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None}
|
||||
if row.get("confirmed_at"):
|
||||
out["confirmed_at"] = str(row["confirmed_at"])
|
||||
out["confirmed_at"] = utc_text(row["confirmed_at"])
|
||||
if out["saved"]:
|
||||
epsg = int(row["epsg"])
|
||||
out.update(
|
||||
@@ -356,7 +357,7 @@ def unit_summary(unit: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {key: unit[key] for key in ("piece_id", "line", "line_name", "year", "length_m")}
|
||||
out.update(saved=_saved(unit), confirmed_at=None, label=None)
|
||||
if unit["confirmed_at"]:
|
||||
out["confirmed_at"] = str(unit["confirmed_at"])
|
||||
out["confirmed_at"] = utc_text(unit["confirmed_at"])
|
||||
if out["saved"]:
|
||||
epsg = int(unit["epsg"])
|
||||
out.update(
|
||||
|
||||
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
|
||||
|
||||
_SOURCE_SQL = """
|
||||
SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year,
|
||||
DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at,
|
||||
DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS updated_at,
|
||||
s.state = 'COMPLETE' AS section_complete
|
||||
FROM projects p
|
||||
LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_time import kst_text
|
||||
from E02_StabGround.E02_StabGround_Model import load_defaults
|
||||
from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED
|
||||
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS
|
||||
@@ -98,7 +99,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f
|
||||
f"토층 {s['soil_depth_m']:g} m",
|
||||
)
|
||||
_set(sheet, "B7", scenario_text(s))
|
||||
_set(sheet, "B8", str(area["computed_at"]).replace("T", " "))
|
||||
_set(sheet, "B8", kst_text(area["computed_at"]))
|
||||
_set(sheet, "B9", _unconfirmed(s))
|
||||
for k in range(1, 7):
|
||||
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]]))
|
||||
|
||||
@@ -8,10 +8,10 @@ from datetime import date, datetime, timedelta, timezone
|
||||
KST = timezone(timedelta(hours=9), "KST")
|
||||
|
||||
|
||||
def utc_text(value: datetime | None) -> str | None:
|
||||
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None."""
|
||||
if value is None:
|
||||
return None
|
||||
def utc_text(value: datetime | str | None) -> str | None:
|
||||
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None · 글은 그대로."""
|
||||
if value is None or isinstance(value, str):
|
||||
return value or None
|
||||
if value.tzinfo is not None:
|
||||
value = value.astimezone(timezone.utc)
|
||||
return value.replace(tzinfo=None, microsecond=0).isoformat() + "Z"
|
||||
@@ -40,6 +40,20 @@ def kst_from_timestamp(seconds: float) -> datetime:
|
||||
return datetime.fromtimestamp(seconds, KST)
|
||||
|
||||
|
||||
def kst_text(value: datetime | str | None) -> str:
|
||||
"""저장된 시각 → 「2026-10-30 10:02:03」 한국 시각(파일 글용) · Z · +hh:mm 있으면 바꿈 · 없는 옛 글 = 이미 한국 시각."""
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value.strip().replace(" ", "T"))
|
||||
except ValueError:
|
||||
return value
|
||||
if value.tzinfo is None:
|
||||
return value.replace(microsecond=0).isoformat(sep=" ")
|
||||
return value.astimezone(KST).replace(tzinfo=None, microsecond=0).isoformat(sep=" ")
|
||||
|
||||
|
||||
def utc_now_naive() -> datetime:
|
||||
"""지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
/* =============================================================================
|
||||
* common_util_time.ts
|
||||
* 화면 시간 표시 — 저장·전송은 UTC 하나, 보일 때만 보는 사람 PC 시간대로 바꿈.
|
||||
* 서버 글(「2026-10-30 01:02:03」 · 「…T01:02:03」 = 시간대 표시 없음)은 UTC 로 읽음.
|
||||
* 서버가 내는 시각은 끝 Z(UTC) · 시간대 표시 없는 옛 기록(「2026-10-30 10:02:03」 · 「…T10:02:03」)은
|
||||
* 로컬(한국 시각, +09:00)로 적힌 것이라 한국 시각으로 읽음.
|
||||
* 짝: `common_util/common_util_time.py`(서버가 만드는 파일 글 = 한국 시각 고정).
|
||||
* ========================================================================== */
|
||||
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
const HAS_ZONE = /(Z|[+-]\d\d:?\d\d)$/;
|
||||
const OLD_LOCAL_ZONE = "+09:00"; // 옛 기록 = 한국 시각
|
||||
|
||||
/** 서버 시각 글 → 밀리초(UTC 기준) · 못 읽으면 null */
|
||||
/** 시각 글 → 밀리초(UTC 기준) · 시간대 표시 없는 옛 글 = 한국 시각 · 못 읽으면 null */
|
||||
export function parseUtcMs(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const iso = value.trim().replace(" ", "T");
|
||||
if (!iso.includes("T")) return null; // 날짜만 = 시각이 아니라 바꾸지 않음
|
||||
const ms = Date.parse(HAS_ZONE.test(iso) ? iso : `${iso}Z`);
|
||||
const ms = Date.parse(HAS_ZONE.test(iso) ? iso : `${iso}${OLD_LOCAL_ZONE}`);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
|
||||
@@ -360,8 +360,8 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT stage_no, stage_key, state, progress_percent, params, message,
|
||||
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at,
|
||||
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at
|
||||
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS started_at,
|
||||
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%sZ') AS completed_at
|
||||
FROM project_workflow_stages
|
||||
WHERE project_id = %s
|
||||
ORDER BY stage_no ASC
|
||||
|
||||
@@ -14,7 +14,6 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -112,7 +111,7 @@ from common_util.common_util_auth import (
|
||||
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
||||
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
||||
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
||||
from common_util.common_util_time import install_json_utc
|
||||
from common_util.common_util_time import install_json_utc, utc_from_timestamp
|
||||
from common_util.common_util_help_router import router as help_router
|
||||
from common_util.common_util_map_router import router as map_router
|
||||
from common_util.common_util_resource_monitor import sample_resources_loop
|
||||
@@ -232,7 +231,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
|
||||
len(reload_dirs()),
|
||||
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
|
||||
utc_from_timestamp(_CODE_MTIME_AT_START),
|
||||
)
|
||||
|
||||
# ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리).
|
||||
@@ -447,11 +446,9 @@ async def health():
|
||||
body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT}
|
||||
if DEBUG:
|
||||
newest = _newest_source_mtime()
|
||||
body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds")
|
||||
body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds")
|
||||
body["started_at"] = utc_from_timestamp(_STARTED_AT)
|
||||
body["code_mtime"] = utc_from_timestamp(_CODE_MTIME_AT_START)
|
||||
body["source_mtime"] = utc_from_timestamp(newest)
|
||||
# 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게.
|
||||
body["stale"] = newest > _CODE_MTIME_AT_START + 1.0
|
||||
return body
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""593-1 시간 표시 — UTC 글 → 보는 PC 시간대(TS) · 서버가 만드는 글 = UTC Z / 한국 시각(Python)."""
|
||||
"""593 시간 표시 — Z 글 → 보는 PC 시간대(TS) · Z 없는 옛 글 = 한국 시각 · 서버가 내는 글 = UTC Z / 파일 글 = 한국 시각(Python)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -21,11 +21,11 @@ import * as m from "./common_util_time.js";
|
||||
const now = Date.parse("2026-10-30T20:00:00Z");
|
||||
const out = {
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
date: ["2026-10-30 01:02:03", "2026-10-30T01:02:03", "2026-10-30T01:02:03Z", "2026-10-30T10:02:03+09:00", null, "", "x"].map((v) => m.dateTimeText(v)),
|
||||
date: ["2026-10-30 10:02:03", "2026-10-30T10:02:03", "2026-10-30T01:02:03Z", "2026-10-30T10:02:03+09:00", null, "", "x"].map((v) => m.dateTimeText(v)),
|
||||
day: [m.dateText("2026-10-30T20:30:00Z"), m.dateText("2026-10-30"), m.dateText(null), m.dateText("")],
|
||||
time: [m.timeText("2026-10-30 20:30:00"), m.timeText(null)],
|
||||
time: [m.timeText("2026-10-30T20:30:00Z"), m.timeText(null)],
|
||||
ago: ["2026-10-30T20:00:00Z", "2026-10-30T10:00:00Z", "2026-10-29T10:00:00Z", "2026-10-26T10:00:00Z", "2026-10-01T10:00:00Z", null].map((v) => m.agoText(v, "-", now)),
|
||||
kst: m.kstText("2026-10-30 01:02:03"),
|
||||
kst: m.kstText("2026-10-30 10:02:03"),
|
||||
};
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
@@ -60,11 +60,11 @@ def runner(tmp_path_factory):
|
||||
def test_seoul(runner):
|
||||
r = runner("Asia/Seoul")
|
||||
assert r["tz"] == "Asia/Seoul"
|
||||
# Z 없는 옛 글 · ISO · Z · +09:00 모두 같은 한국 시각
|
||||
# Z 없는 옛 글(한국 시각) · ISO · Z · +09:00 모두 같은 한국 시각
|
||||
assert r["date"][:4] == ["2026-10-30 10:02"] * 4
|
||||
assert r["date"][4:] == ["—", "—", "—"]
|
||||
assert r["day"] == ["2026-10-31", "2026-10-30", "-", "-"] # 20:30Z = 한국 다음 날 05:30
|
||||
assert r["time"] == ["05:30", ""]
|
||||
assert r["time"] == ["05:30", ""] # 20:30Z
|
||||
assert r["kst"] == "2026-10-30 10:02"
|
||||
# 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다
|
||||
assert r["ago"] == ["오늘", "어제", "2 일 전", "5 일 전", "2026-10-01", "-"]
|
||||
@@ -77,10 +77,17 @@ def test_other_zone_follows_viewer(runner):
|
||||
assert r["day"][1] == "2026-10-30" # 날짜만 있는 값은 안 바뀜
|
||||
|
||||
|
||||
def test_utc_zone_old_text_is_kst(runner):
|
||||
r = runner("UTC") # 옛 로컬 글 10:02(한국) = UTC 01:02
|
||||
assert r["date"][:4] == ["2026-10-30 01:02"] * 4
|
||||
assert r["kst"] == "2026-10-30 01:02"
|
||||
|
||||
|
||||
def test_server_utc_text():
|
||||
assert srv.utc_text(datetime(2026, 10, 30, 1, 2, 3, 999)) == "2026-10-30T01:02:03Z"
|
||||
assert srv.utc_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30T01:02:03Z"
|
||||
assert srv.utc_text(None) is None
|
||||
assert srv.utc_text("x") == "x" and srv.utc_text("") is None
|
||||
assert srv.utc_now_text().endswith("Z")
|
||||
assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z"
|
||||
|
||||
@@ -97,3 +104,47 @@ def test_응답_시각_끝_Z():
|
||||
srv.install_json_utc()
|
||||
body = jsonable_encoder({"at": datetime(2026, 10, 30, 1, 2, 3), "n": 1})
|
||||
assert body == {"at": "2026-10-30T01:02:03Z", "n": 1}
|
||||
|
||||
|
||||
def test_kst_text_파일_글():
|
||||
assert srv.kst_text("2026-10-30T01:02:03Z") == "2026-10-30 10:02:03"
|
||||
assert srv.kst_text("2026-10-30T10:02:03") == "2026-10-30 10:02:03" # 옛 글 = 한국 시각
|
||||
assert srv.kst_text("2026-10-30 10:02:03") == "2026-10-30 10:02:03"
|
||||
assert srv.kst_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30 10:02:03"
|
||||
assert srv.kst_text(None) == "" and srv.kst_text("x") == "x"
|
||||
|
||||
|
||||
def test_D04_확정_시각_끝_Z():
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Router import unit_summary, year_summary
|
||||
|
||||
at = datetime(2026, 10, 30, 1, 2, 3)
|
||||
assert year_summary({"year": 2026, "route_json": {}, "confirmed_at": at})["confirmed_at"] == (
|
||||
"2026-10-30T01:02:03Z"
|
||||
)
|
||||
unit = {
|
||||
"piece_id": None, "line": 1, "line_name": "본선", "year": 2026, "length_m": 0,
|
||||
"planned": [], "confirmed_at": at,
|
||||
} # fmt: skip
|
||||
assert unit_summary(unit)["confirmed_at"] == "2026-10-30T01:02:03Z"
|
||||
|
||||
|
||||
def _server_sources():
|
||||
skip = {"venv", "node_modules", "resources", "tmp", "docs", ".claude", "config", "storage"}
|
||||
for dirpath, dirs, files in os.walk(ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
||||
for name in files:
|
||||
if name.endswith(".py"):
|
||||
path = Path(dirpath) / name
|
||||
yield path, path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_서버_시각_글_Z_없는_길_0():
|
||||
# DB 시각을 글로 뽑는 SQL = 끝 Z · 시각 칸을 str() 로 감싸 내보내지 않음
|
||||
bad_sql = re.compile(r"%i:%%?s(?!Z)")
|
||||
bad_str = re.compile(r"""\bstr\([^)\n]*(_at|timestamp|_until)["']\]\)""")
|
||||
found = []
|
||||
for path, text in _server_sources():
|
||||
for no, line in enumerate(text.splitlines(), 1):
|
||||
if bad_sql.search(line) or bad_str.search(line):
|
||||
found.append(f"{path.name}:{no}")
|
||||
assert found == []
|
||||
|
||||
Reference in New Issue
Block a user