395 lines
12 KiB
Python
395 lines
12 KiB
Python
"""
|
|
VOC 게시글 데이터 모델
|
|
|
|
이 모듈은 VOC(Voice of Customer) 게시글의 데이터 구조를 정의합니다.
|
|
Pydantic을 사용하여 데이터 유효성을 입력 시점에 엄격하게 검증하며,
|
|
로직 내부에서는 오염된 데이터가 돌아다니지 않도록 합니다.
|
|
|
|
주요 기능:
|
|
- 필수 필드 검증 (id, title, writer, department, status)
|
|
- 선택 필드 처리 (content, attachment 등)
|
|
- 날짜 형식 검증 (YYYY-MM-DD HH:MM:SS)
|
|
- 공개 여부 검증 (0 또는 1만 허용)
|
|
- 자동 타입 변환 (문자열 → 정수 등)
|
|
|
|
사용 예시:
|
|
>>> post = VOCPost(
|
|
... id="12345",
|
|
... title="1호선 서면역 소음 발생",
|
|
... writer="홍길동",
|
|
... department="차량",
|
|
... status="접수"
|
|
... )
|
|
>>> print(post.id)
|
|
12345
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-17
|
|
버전: 2.0 (데이터 검증 강화)
|
|
"""
|
|
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationInfo
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
import re
|
|
|
|
|
|
class VOCPost(BaseModel):
|
|
"""
|
|
VOC 게시글 데이터 모델
|
|
|
|
목록 및 상세 정보를 포함하는 통합 모델입니다.
|
|
Pydantic을 통해 데이터 유효성을 자동으로 검증하며,
|
|
잘못된 데이터가 입력되면 ValidationError를 발생시킵니다.
|
|
|
|
Attributes:
|
|
id (str): 접수 번호 (Primary Key, 필수)
|
|
title (str): 게시글 제목 (필수)
|
|
writer (str): 작성자 이름 (필수)
|
|
department (str): 담당 부서 (필수, 예: "차량", "시설")
|
|
status (str): 처리 상태 (필수, 예: "접수", "처리중", "완료")
|
|
date (str): 작성일 (YYYY-MM-DD HH:MM:SS 형식)
|
|
is_public (int): 공개 여부 (0: 비공개, 1: 공개)
|
|
is_related (int): 관심글 여부 (0: 일반, 1: 관심)
|
|
channel (str): 접수 경로 (예: "인터넷", "전화")
|
|
content (str): 질의 내용 본문 (선택)
|
|
attachment (str): 첨부파일명 (선택)
|
|
|
|
Validators:
|
|
validate_id: ID가 비어있지 않은지 확인
|
|
validate_is_public: 0 또는 1만 허용
|
|
validate_is_related: 0 또는 1만 허용
|
|
validate_date_format: 날짜 형식 검증 (선택)
|
|
|
|
사용 시나리오:
|
|
1. 크롤링 결과 → VOCPost 변환 (데이터 검증)
|
|
2. DB 조회 결과 → VOCPost 변환
|
|
3. UI 입력 → VOCPost 변환
|
|
"""
|
|
|
|
# ========================================================================
|
|
# 필수 필드 (Required Fields)
|
|
# ========================================================================
|
|
|
|
id: str = Field(
|
|
...,
|
|
description="접수 번호 (Primary Key)",
|
|
min_length=1,
|
|
max_length=50
|
|
)
|
|
|
|
title: str = Field(
|
|
...,
|
|
description="게시글 제목",
|
|
min_length=1,
|
|
max_length=500
|
|
)
|
|
|
|
writer: str = Field(
|
|
...,
|
|
description="작성자 이름",
|
|
min_length=1,
|
|
max_length=100
|
|
)
|
|
|
|
department: str = Field(
|
|
default="",
|
|
description="담당 부서 (예: 차량, 시설, 역무)",
|
|
max_length=50
|
|
)
|
|
|
|
status: str = Field(
|
|
...,
|
|
description="처리 상태 (예: 접수, 처리중, 답변완료)",
|
|
min_length=1,
|
|
max_length=50
|
|
)
|
|
|
|
# ========================================================================
|
|
# 기본값이 있는 필드 (Fields with Defaults)
|
|
# ========================================================================
|
|
|
|
date: str = Field(
|
|
"",
|
|
description="작성 일자 (YYYY-MM-DD HH:MM:SS 형식, 상세 수집 시 갱신)"
|
|
)
|
|
|
|
is_public: int = Field(
|
|
1,
|
|
description="공개 여부 (1: 공개, 0: 비공개)",
|
|
ge=0, # 0 이상
|
|
le=1 # 1 이하
|
|
)
|
|
|
|
is_related: int = Field(
|
|
0,
|
|
description="관심 부서/키워드 연관 여부 (1: 연관, 0: 미연관)",
|
|
ge=0,
|
|
le=1
|
|
)
|
|
|
|
channel: str = Field(
|
|
"",
|
|
description="접수 채널 (예: 홈페이지, 모바일, 전화)"
|
|
)
|
|
|
|
# ========================================================================
|
|
# 선택 필드 (Optional Fields)
|
|
# ========================================================================
|
|
|
|
content: Optional[str] = Field(
|
|
None,
|
|
description="질의 내용 본문 (초기엔 None, 상세 조회 후 채워짐)"
|
|
)
|
|
|
|
answer: Optional[str] = Field(
|
|
None,
|
|
description="답변 내용"
|
|
)
|
|
|
|
station: Optional[str] = Field(
|
|
None,
|
|
description="관련 역명 (예: 서면, 부산역)"
|
|
)
|
|
|
|
attachment: Optional[str] = Field(
|
|
None,
|
|
description="첨부파일명 (없으면 None 또는 '없음')"
|
|
)
|
|
|
|
voc_type: Optional[str] = Field(
|
|
None,
|
|
description="VOC 유형 분류 (예: 불만, 제안, 문의)"
|
|
)
|
|
|
|
response_type: Optional[str] = Field(
|
|
None,
|
|
description="응답 방식 (예: 전화, 메일, 문자)"
|
|
)
|
|
|
|
summary: Optional[str] = Field(
|
|
None,
|
|
description="내용 요약 (AI 분석용, 향후 확장)"
|
|
)
|
|
|
|
# ========================================================================
|
|
# 타임스탬프 필드 (Timestamp Fields)
|
|
# ========================================================================
|
|
|
|
created_at: Optional[datetime] = Field(
|
|
None,
|
|
description="데이터 생성 시각 (DB 자동 생성)"
|
|
)
|
|
|
|
updated_at: Optional[datetime] = Field(
|
|
None,
|
|
description="데이터 수정 시각"
|
|
)
|
|
|
|
last_checked_at: Optional[datetime] = Field(
|
|
None,
|
|
description="마지막 크롤링 확인 시각"
|
|
)
|
|
|
|
checked_at: Optional[datetime] = Field(
|
|
None,
|
|
description="사용자가 게시글을 확인한 시각 (읽음 처리)"
|
|
)
|
|
|
|
# ========================================================================
|
|
# Validators (데이터 유효성 검사)
|
|
# ========================================================================
|
|
|
|
@field_validator('id')
|
|
@classmethod
|
|
def validate_id(cls, v):
|
|
"""
|
|
ID 검증: 비어있지 않고, 공백만 있지 않은지 확인
|
|
|
|
Args:
|
|
v: 검증할 ID 값
|
|
|
|
Returns:
|
|
str: 검증된 ID (앞뒤 공백 제거)
|
|
|
|
Raises:
|
|
ValueError: ID가 비어있거나 공백만 있는 경우
|
|
"""
|
|
if not v or not v.strip():
|
|
raise ValueError("ID는 비어있을 수 없습니다.")
|
|
return v.strip()
|
|
|
|
@field_validator('title', 'writer', 'status')
|
|
@classmethod
|
|
def validate_required_strings(cls, v, info: ValidationInfo):
|
|
"""
|
|
필수 문자열 필드 검증: 비어있지 않은지 확인
|
|
|
|
department는 비어있을 수 있으므로 제외합니다.
|
|
|
|
Args:
|
|
v: 검증할 값
|
|
info: 검증 정보
|
|
|
|
Returns:
|
|
str: 검증된 값 (앞뒤 공백 제거)
|
|
|
|
Raises:
|
|
ValueError: 값이 비어있거나 공백만 있는 경우
|
|
"""
|
|
if not v or not v.strip():
|
|
raise ValueError(f"{info.field_name}은(는) 비어있을 수 없습니다.")
|
|
return v.strip()
|
|
|
|
@field_validator('department')
|
|
@classmethod
|
|
def validate_department(cls, v):
|
|
"""
|
|
부서 필드 검증: 비어있을 수 있으며, 공백 제거만 수행
|
|
|
|
웹사이트에서 부서 정보가 비어있는 경우가 있어
|
|
빈 문자열을 허용합니다.
|
|
|
|
Args:
|
|
v: 검증할 값
|
|
|
|
Returns:
|
|
str: 공백이 제거된 값 (빈 문자열 가능)
|
|
"""
|
|
if v is None:
|
|
return ""
|
|
return v.strip()
|
|
|
|
@field_validator('is_public', 'is_related')
|
|
@classmethod
|
|
def validate_binary_int(cls, v, info: ValidationInfo):
|
|
"""
|
|
이진 정수 필드 검증: 0 또는 1만 허용
|
|
|
|
크롤링 데이터에서 문자열 "1"이나 "0"이 들어올 수 있어
|
|
정수로 변환 후 검증합니다.
|
|
|
|
Args:
|
|
v: 검증할 값
|
|
info: 검증 정보
|
|
|
|
Returns:
|
|
int: 검증된 값 (0 또는 1)
|
|
|
|
Raises:
|
|
ValueError: 0 또는 1이 아닌 경우
|
|
"""
|
|
# 문자열인 경우 정수로 변환
|
|
if isinstance(v, str):
|
|
if v not in ("0", "1"):
|
|
raise ValueError(f"{info.field_name}은(는) 0 또는 1만 가능합니다. (입력값: {v})")
|
|
return int(v)
|
|
|
|
if v not in (0, 1):
|
|
raise ValueError(f"{info.field_name}은(는) 0 또는 1만 가능합니다. (입력값: {v})")
|
|
return v
|
|
|
|
@field_validator('date')
|
|
@classmethod
|
|
def validate_date_format(cls, v):
|
|
"""
|
|
날짜 형식 검증: YYYY-MM-DD HH:MM:SS 또는 YYYY-MM-DD HH:MM 형식 확인
|
|
|
|
빈 문자열은 허용합니다 (초기 상태).
|
|
HH:MM 형식(초 없음)이 들어오면 HH:MM:00으로 변환합니다.
|
|
|
|
Args:
|
|
v: 검증할 날짜 문자열
|
|
|
|
Returns:
|
|
str: 검증된 날짜 문자열 (YYYY-MM-DD HH:MM:SS 형식)
|
|
|
|
Raises:
|
|
ValueError: 날짜 형식이 잘못된 경우
|
|
"""
|
|
if not v: # 빈 문자열은 허용
|
|
return v
|
|
|
|
# YYYY-MM-DD HH:MM:SS 형식 검증
|
|
pattern_full = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
|
|
# YYYY-MM-DD HH:MM 형식 검증 (초 없음)
|
|
pattern_short = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$'
|
|
|
|
if re.match(pattern_full, v):
|
|
# 완전한 형식 - 그대로 검증
|
|
try:
|
|
datetime.strptime(v, "%Y-%m-%d %H:%M:%S")
|
|
return v
|
|
except ValueError as e:
|
|
raise ValueError(f"유효하지 않은 날짜입니다: {v} ({e})")
|
|
|
|
elif re.match(pattern_short, v):
|
|
# 초 없는 형식 - :00 추가
|
|
v_full = f"{v}:00"
|
|
try:
|
|
datetime.strptime(v_full, "%Y-%m-%d %H:%M:%S")
|
|
return v_full
|
|
except ValueError as e:
|
|
raise ValueError(f"유효하지 않은 날짜입니다: {v} ({e})")
|
|
|
|
else:
|
|
raise ValueError(
|
|
f"날짜 형식이 잘못되었습니다. "
|
|
f"'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DD HH:MM' 형식이어야 합니다. (입력값: {v})"
|
|
)
|
|
|
|
@model_validator(mode='before')
|
|
@classmethod
|
|
def validate_attachment(cls, values):
|
|
"""
|
|
첨부파일 필드 정규화: "없음" → None 변환
|
|
|
|
크롤링 결과에서 "없음"이 들어오는 경우가 있어,
|
|
이를 None으로 통일합니다.
|
|
|
|
Args:
|
|
values: 전체 필드 값 딕셔너리
|
|
|
|
Returns:
|
|
dict: 정규화된 필드 값
|
|
"""
|
|
if isinstance(values, dict):
|
|
attachment = values.get('attachment')
|
|
if attachment and isinstance(attachment, str) and attachment.strip() in ("없음", "N/A", "-"):
|
|
values['attachment'] = None
|
|
return values
|
|
|
|
# ========================================================================
|
|
# Config (설정)
|
|
# ========================================================================
|
|
|
|
# Pydantic v2 모델 설정
|
|
model_config = {
|
|
# datetime을 ISO 형식 문자열로 변환
|
|
"json_encoders": {
|
|
datetime: lambda v: v.isoformat() if v else None
|
|
},
|
|
|
|
# 추가 필드 허용 안 함 (엄격 모드)
|
|
"extra": 'forbid',
|
|
|
|
# 필드 별칭 허용 (v2: populate_by_name)
|
|
"populate_by_name": True,
|
|
|
|
# 스키마 예시 (v2: json_schema_extra)
|
|
"json_schema_extra": {
|
|
"example": {
|
|
"id": "12345",
|
|
"title": "1호선 서면역 소음 발생",
|
|
"writer": "홍길동",
|
|
"department": "차량",
|
|
"status": "접수",
|
|
"date": "2026-02-17 14:30:00",
|
|
"is_public": 1,
|
|
"is_related": 1,
|
|
"channel": "인터넷",
|
|
"content": "서면역에서 열차 소음이 심합니다.",
|
|
"attachment": None
|
|
}
|
|
}
|
|
}
|