260710_0
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""B03 업로드 및 B04 WF1 분석 결과 이메일 발송."""
|
||||
|
||||
import html
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from common_util.common_util_email import send_email
|
||||
from config.config_system import APP_PUBLIC_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
if isinstance(value, int | float):
|
||||
return f"{value:,}"
|
||||
return "N/A"
|
||||
|
||||
|
||||
def _format_elevation(value: Any) -> str:
|
||||
if isinstance(value, int | float):
|
||||
return f"{value:.2f}"
|
||||
return "N/A"
|
||||
|
||||
|
||||
def _extract_elevation_range(bounds: dict[str, Any], statistics: dict[str, Any]) -> tuple[Any, Any]:
|
||||
if isinstance(bounds.get("z"), list | tuple) and len(bounds["z"]) >= 2:
|
||||
return bounds["z"][0], bounds["z"][1]
|
||||
return (
|
||||
bounds.get("min_z", statistics.get("min_z")),
|
||||
bounds.get("max_z", statistics.get("max_z")),
|
||||
)
|
||||
|
||||
|
||||
def _app_url(path: str) -> str:
|
||||
return f"{APP_PUBLIC_BASE_URL}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _email_shell(title: str, body: str, *, accent: str = "#2563eb") -> str:
|
||||
box_style = (
|
||||
f"border: 1px solid #e5e7eb; border-left: 4px solid {accent}; "
|
||||
"padding: 18px; margin: 20px 0;"
|
||||
)
|
||||
row_style = (
|
||||
"display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; "
|
||||
"border-bottom: 1px solid #f3f4f6;"
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #111827;
|
||||
background: #f3f4f6;
|
||||
}}
|
||||
.container {{ max-width: 640px; margin: 0 auto; padding: 24px; }}
|
||||
.header {{
|
||||
background: {accent};
|
||||
color: #ffffff;
|
||||
padding: 28px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{ margin: 0; font-size: 24px; letter-spacing: 0; }}
|
||||
.content {{ background: #ffffff; padding: 28px; border-radius: 0 0 8px 8px; }}
|
||||
.box {{ {box_style} }}
|
||||
.row {{ {row_style} }}
|
||||
.row:last-child {{ border-bottom: 0; }}
|
||||
.label {{ color: #6b7280; font-weight: 700; }}
|
||||
.value {{ color: #111827; text-align: right; }}
|
||||
.notice {{ background: #eff6ff; border: 1px solid #bfdbfe; padding: 16px; border-radius: 6px; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
background: {accent};
|
||||
color: #ffffff !important;
|
||||
padding: 12px 22px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.footer {{ color: #6b7280; font-size: 12px; text-align: center; margin-top: 24px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>{html.escape(title)}</h1></div>
|
||||
<div class="content">
|
||||
{body}
|
||||
<div class="footer">
|
||||
<p>© 2026 Aislo - AI 임도 설계 솔루션</p>
|
||||
<p>문의: support@aislo.example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _summary_row(label: str, value: str) -> str:
|
||||
return (
|
||||
'<div class="row">'
|
||||
f'<span class="label">{html.escape(label)}</span>'
|
||||
f'<span class="value">{value}</span>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
async def send_file_upload_complete_email(
|
||||
*,
|
||||
to_email: str,
|
||||
user_name: str,
|
||||
project_name: str,
|
||||
file_name: str,
|
||||
file_size_mb: float,
|
||||
metadata: dict[str, Any],
|
||||
) -> bool:
|
||||
"""파일 업로드 완료 직후 사용자에게 WF1 분석 시작을 안내한다."""
|
||||
bounds = metadata.get("bounds") or {}
|
||||
statistics = metadata.get("statistics") or {}
|
||||
min_z, max_z = _extract_elevation_range(bounds, statistics)
|
||||
point_count = metadata.get("point_count", metadata.get("points", 0))
|
||||
epsg = metadata.get("epsg", "N/A")
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("파일명", html.escape(file_name)),
|
||||
_summary_row("파일 크기", f"{file_size_mb:.2f} MB"),
|
||||
_summary_row("포인트 수", _format_number(point_count)),
|
||||
_summary_row("좌표계", f"EPSG:{html.escape(str(epsg))}"),
|
||||
_summary_row(
|
||||
"고도 범위",
|
||||
f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
body = f"""
|
||||
<p>안녕하세요, <strong>{html.escape(user_name or "사용자")}</strong>님.</p>
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 입력 파일이 저장되었습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<div class="notice">
|
||||
지표면 분석(WF1)이 백그라운드에서 자동으로 시작되었습니다.
|
||||
완료되면 분석 요약과 결과 페이지 링크를 다시 보내드립니다.
|
||||
</div>
|
||||
"""
|
||||
subject = f"파일 업로드 완료 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body),
|
||||
)
|
||||
logger.info("파일 업로드 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
|
||||
|
||||
async def send_analysis_completion_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
project_name: str,
|
||||
to_email: str,
|
||||
analysis_result: dict[str, Any],
|
||||
) -> bool:
|
||||
"""WF1 Surface 분석 완료 후 결과 요약 이메일을 발송한다."""
|
||||
processed = analysis_result.get("processed") or {}
|
||||
statistics = processed.get("statistics") or {}
|
||||
bounds = processed.get("bounds") or {}
|
||||
models = analysis_result.get("models") or []
|
||||
min_z = statistics.get("min_z", bounds.get("min_z"))
|
||||
max_z = statistics.get("max_z", bounds.get("max_z"))
|
||||
point_count = processed.get("point_count", 0)
|
||||
result_url = _app_url(f"/projects/{project_id}/surface")
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("분석 포인트 수", _format_number(point_count)),
|
||||
_summary_row(
|
||||
"고도 범위",
|
||||
f"{_format_elevation(min_z)} ~ {_format_elevation(max_z)} m",
|
||||
),
|
||||
_summary_row("생성 모델 수", f"{len(models):,}개"),
|
||||
_summary_row("포함 데이터", "DTM, TIN, 미리보기 레이어"),
|
||||
]
|
||||
)
|
||||
|
||||
body = f"""
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 지표면 분석이 완료되었습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<p>결과는 지표면 분석 페이지에서 확인할 수 있습니다.</p>
|
||||
<p style="text-align: center; margin: 26px 0;">
|
||||
<a class="button" href="{html.escape(result_url)}">분석 결과 보기</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;">
|
||||
로그인이 필요한 경우 인증 후 같은 결과 페이지로 이동합니다.
|
||||
</p>
|
||||
"""
|
||||
subject = f"임도 지표면 분석 완료 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body),
|
||||
)
|
||||
logger.info("WF1 완료 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
|
||||
|
||||
async def send_analysis_error_email(
|
||||
*,
|
||||
project_id: UUID,
|
||||
project_name: str,
|
||||
to_email: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""WF1 Surface 분석 실패 알림 이메일을 발송한다."""
|
||||
rows = "\n".join(
|
||||
[
|
||||
_summary_row("프로젝트 ID", html.escape(str(project_id))),
|
||||
_summary_row("오류 내용", html.escape(error_message)),
|
||||
]
|
||||
)
|
||||
body = f"""
|
||||
<p>프로젝트 <strong>{html.escape(project_name)}</strong>의 지표면 분석 중 오류가 발생했습니다.</p>
|
||||
<div class="box">
|
||||
{rows}
|
||||
</div>
|
||||
<p>기술 지원팀에서 확인할 수 있도록 서버 로그에도 같은 오류를 기록했습니다.</p>
|
||||
"""
|
||||
subject = f"지표면 분석 실패 알림 - {project_name}"
|
||||
success = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html=_email_shell(subject, body, accent="#dc2626"),
|
||||
)
|
||||
logger.info("WF1 오류 이메일 발송 %s: %s", "성공" if success else "실패", to_email)
|
||||
return success
|
||||
Reference in New Issue
Block a user