VOC_Monitor/app/models/model.py

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
}
}
}