feat: add cdr validation and normalization domain
This commit is contained in:
0
src/core/domain/__init__.py
Normal file
0
src/core/domain/__init__.py
Normal file
23
src/core/domain/errors.py
Normal file
23
src/core/domain/errors.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class FailureReason(StrEnum):
|
||||
MISSING_REQUIRED_FIELD = "missing_required_field"
|
||||
INVALID_RAT_VALUE = "invalid_rat_value"
|
||||
INVALID_EPOCH = "invalid_epoch"
|
||||
MALFORMED_JSON = "malformed_json"
|
||||
NORMALIZATION_ERROR = "normalization_error"
|
||||
|
||||
|
||||
class CDRProcessingError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
reason: FailureReason,
|
||||
detail: str,
|
||||
failed_field: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(detail)
|
||||
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
self.failed_field = failed_field
|
||||
43
src/core/domain/models.py
Normal file
43
src/core/domain/models.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from ipaddress import IPv4Address
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawCDR:
|
||||
raw_id: str
|
||||
device_id: str
|
||||
caller_num: str
|
||||
svc: str
|
||||
rat: int
|
||||
epoch_start: int
|
||||
epoch_end: int
|
||||
up_bytes: int
|
||||
down_bytes: int
|
||||
lac: str
|
||||
ci: str
|
||||
src_ip: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedCDR:
|
||||
cdr_id: UUID
|
||||
raw_id: str
|
||||
|
||||
msisdn: str
|
||||
imsi: str
|
||||
|
||||
service_type: str
|
||||
radio_access_type: str
|
||||
cell_id: str
|
||||
|
||||
bytes_uploaded: int
|
||||
bytes_downloaded: int
|
||||
total_bytes: int
|
||||
duration_seconds: int
|
||||
|
||||
session_start_time: datetime
|
||||
session_end_time: datetime
|
||||
|
||||
source_ip: IPv4Address
|
||||
121
src/core/domain/normalization.py
Normal file
121
src/core/domain/normalization.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import IPv4Address, AddressValueError
|
||||
|
||||
import phonenumbers
|
||||
from phonenumbers import PhoneNumberFormat
|
||||
|
||||
from core.domain.errors import CDRProcessingError, FailureReason
|
||||
from core.domain.models import NormalizedCDR, RawCDR
|
||||
|
||||
|
||||
RAT_MAPPING = {
|
||||
1: "3G",
|
||||
6: "LTE_4G",
|
||||
9: "5G_NR",
|
||||
}
|
||||
|
||||
def normalize_rat(rat: int) -> str:
|
||||
try:
|
||||
return RAT_MAPPING[rat]
|
||||
except KeyError as exc:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.INVALID_RAT_VALUE,
|
||||
detail=f"Unsupported RAT value: {rat}.",
|
||||
failed_field="rat",
|
||||
) from exc
|
||||
|
||||
def _normalize_international_prefix(number: str) -> str:
|
||||
number = number.strip()
|
||||
|
||||
if number.startswith("+"):
|
||||
return number
|
||||
|
||||
if number.startswith("00"):
|
||||
return f"+{number[2:]}"
|
||||
|
||||
if number.startswith("011"):
|
||||
return f"+{number[3:]}"
|
||||
|
||||
return number
|
||||
|
||||
def normalize_msisdn(number: str) -> str:
|
||||
prepared_number = _normalize_international_prefix(number)
|
||||
|
||||
try:
|
||||
parsed = phonenumbers.parse(
|
||||
prepared_number,
|
||||
None,
|
||||
)
|
||||
except phonenumbers.NumberParseException as exc:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail=f"Unable to parse caller_num: {number}.",
|
||||
failed_field="caller_num",
|
||||
) from exc
|
||||
|
||||
if not phonenumbers.is_possible_number(parsed):
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail=f"caller_num is not a possible phone number: {number}.",
|
||||
failed_field="caller_num",
|
||||
)
|
||||
|
||||
return phonenumbers.format_number(
|
||||
parsed,
|
||||
PhoneNumberFormat.E164,
|
||||
)
|
||||
|
||||
def generate_cdr_id(raw_id: str) -> uuid.UUID:
|
||||
return uuid.uuid5(
|
||||
uuid.NAMESPACE_DNS,
|
||||
raw_id,
|
||||
)
|
||||
|
||||
def epoch_to_datetime(epoch: int) -> datetime:
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
epoch,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.INVALID_EPOCH,
|
||||
detail=f"Invalid Unix epoch value: {epoch}.",
|
||||
) from exc
|
||||
|
||||
def normalize_source_ip(source_ip: str | None) -> IPv4Address:
|
||||
if source_ip is None:
|
||||
return IPv4Address("0.0.0.0")
|
||||
|
||||
try:
|
||||
return IPv4Address(source_ip)
|
||||
except AddressValueError as exc:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail=f"Invalid IPv4 address: {source_ip}.",
|
||||
failed_field="src_ip",
|
||||
) from exc
|
||||
|
||||
def normalize_cdr(raw: RawCDR) -> NormalizedCDR:
|
||||
return NormalizedCDR(
|
||||
cdr_id=generate_cdr_id(raw.raw_id),
|
||||
raw_id=raw.raw_id,
|
||||
|
||||
msisdn=normalize_msisdn(raw.caller_num),
|
||||
imsi=raw.device_id,
|
||||
|
||||
service_type=raw.svc,
|
||||
radio_access_type=normalize_rat(raw.rat),
|
||||
cell_id=f"{raw.lac}_{raw.ci}",
|
||||
|
||||
bytes_uploaded=raw.up_bytes,
|
||||
bytes_downloaded=raw.down_bytes,
|
||||
total_bytes=raw.up_bytes + raw.down_bytes,
|
||||
duration_seconds=raw.epoch_end - raw.epoch_start,
|
||||
|
||||
session_start_time=epoch_to_datetime(raw.epoch_start),
|
||||
session_end_time=epoch_to_datetime(raw.epoch_end),
|
||||
|
||||
source_ip=normalize_source_ip(raw.src_ip),
|
||||
)
|
||||
139
src/core/domain/validation.py
Normal file
139
src/core/domain/validation.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.domain.errors import CDRProcessingError, FailureReason
|
||||
from core.domain.models import RawCDR
|
||||
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"raw_id",
|
||||
"device_id",
|
||||
"caller_num",
|
||||
"svc",
|
||||
"rat",
|
||||
"epoch_start",
|
||||
"epoch_end",
|
||||
"up_bytes",
|
||||
"down_bytes",
|
||||
"lac",
|
||||
"ci",
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_RAT_VALUES = {
|
||||
1,
|
||||
6,
|
||||
9,
|
||||
}
|
||||
|
||||
def _validate_required_fields(payload: Mapping[str, Any]) -> None:
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in payload or payload[field] is None:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.MISSING_REQUIRED_FIELD,
|
||||
detail=f"Required field '{field}' is missing or null.",
|
||||
failed_field=field,
|
||||
)
|
||||
|
||||
def _validate_type(
|
||||
payload: Mapping[str, Any],
|
||||
field: str,
|
||||
expected_type: type,
|
||||
) -> None:
|
||||
value = payload[field]
|
||||
|
||||
# bool is technically a subclass of int in Python.
|
||||
# We do not want True/False accepted as integer CDR fields.
|
||||
if expected_type is int and isinstance(value, bool):
|
||||
valid = False
|
||||
else:
|
||||
valid = isinstance(value, expected_type)
|
||||
|
||||
if not valid:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail=(
|
||||
f"Field '{field}' must be of type "
|
||||
f"{expected_type.__name__}."
|
||||
),
|
||||
failed_field=field,
|
||||
)
|
||||
|
||||
def parse_raw_cdr(payload: Mapping[str, Any]) -> RawCDR:
|
||||
_validate_required_fields(payload)
|
||||
|
||||
string_fields = (
|
||||
"raw_id",
|
||||
"device_id",
|
||||
"caller_num",
|
||||
"svc",
|
||||
"lac",
|
||||
"ci",
|
||||
)
|
||||
|
||||
integer_fields = (
|
||||
"rat",
|
||||
"epoch_start",
|
||||
"epoch_end",
|
||||
"up_bytes",
|
||||
"down_bytes",
|
||||
)
|
||||
|
||||
for field in string_fields:
|
||||
_validate_type(payload, field, str)
|
||||
|
||||
for field in integer_fields:
|
||||
_validate_type(payload, field, int)
|
||||
|
||||
src_ip = payload.get("src_ip")
|
||||
|
||||
if src_ip is not None:
|
||||
_validate_type(payload, "src_ip", str)
|
||||
|
||||
raw_cdr = RawCDR(
|
||||
raw_id=payload["raw_id"],
|
||||
device_id=payload["device_id"],
|
||||
caller_num=payload["caller_num"],
|
||||
svc=payload["svc"],
|
||||
rat=payload["rat"],
|
||||
epoch_start=payload["epoch_start"],
|
||||
epoch_end=payload["epoch_end"],
|
||||
up_bytes=payload["up_bytes"],
|
||||
down_bytes=payload["down_bytes"],
|
||||
lac=payload["lac"],
|
||||
ci=payload["ci"],
|
||||
src_ip=src_ip,
|
||||
)
|
||||
|
||||
validate_raw_cdr(raw_cdr)
|
||||
|
||||
return raw_cdr
|
||||
|
||||
def validate_raw_cdr(raw: RawCDR) -> None:
|
||||
if raw.epoch_start > raw.epoch_end:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.INVALID_EPOCH,
|
||||
detail="epoch_start must be less than or equal to epoch_end.",
|
||||
failed_field="epoch_start",
|
||||
)
|
||||
|
||||
if raw.up_bytes < 0:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail="up_bytes must be greater than or equal to zero.",
|
||||
failed_field="up_bytes",
|
||||
)
|
||||
|
||||
if raw.down_bytes < 0:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.NORMALIZATION_ERROR,
|
||||
detail="down_bytes must be greater than or equal to zero.",
|
||||
failed_field="down_bytes",
|
||||
)
|
||||
|
||||
if raw.rat not in SUPPORTED_RAT_VALUES:
|
||||
raise CDRProcessingError(
|
||||
reason=FailureReason.INVALID_RAT_VALUE,
|
||||
detail=f"Unsupported RAT value: {raw.rat}.",
|
||||
failed_field="rat",
|
||||
)
|
||||
Reference in New Issue
Block a user