feat: add cdr validation and normalization domain
This commit is contained in:
@@ -3,7 +3,9 @@ name = "telecom-cdr-pipeline"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "High-throughput telecom CDR ingestion and analytics pipeline"
|
description = "High-throughput telecom CDR ingestion and analytics pipeline"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"phonenumbers>=8.13",
|
||||||
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
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",
|
||||||
|
)
|
||||||
77
tests/unit/test_normalization.py
Normal file
77
tests/unit/test_normalization.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from core.domain.normalization import generate_cdr_id, normalize_cdr, normalize_rat
|
||||||
|
from core.domain.validation import parse_raw_cdr
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_valid_cdr() -> None:
|
||||||
|
payload = {
|
||||||
|
"raw_id": "raw-us-east-9988221",
|
||||||
|
"src_ip": "10.240.12.1",
|
||||||
|
"device_id": "310410998877665",
|
||||||
|
"caller_num": "0019875551234",
|
||||||
|
"svc": "4G_DATA",
|
||||||
|
"up_bytes": 1048576,
|
||||||
|
"down_bytes": 4194304,
|
||||||
|
"rat": 6,
|
||||||
|
"lac": "4021",
|
||||||
|
"ci": "8812",
|
||||||
|
"epoch_start": 1726070000,
|
||||||
|
"epoch_end": 1726070300,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = parse_raw_cdr(payload)
|
||||||
|
|
||||||
|
normalized = normalize_cdr(raw)
|
||||||
|
|
||||||
|
assert normalized.raw_id == "raw-us-east-9988221"
|
||||||
|
assert normalized.imsi == "310410998877665"
|
||||||
|
assert normalized.msisdn == "+19875551234"
|
||||||
|
|
||||||
|
assert normalized.service_type == "4G_DATA"
|
||||||
|
assert normalized.radio_access_type == "LTE_4G"
|
||||||
|
|
||||||
|
assert normalized.cell_id == "4021_8812"
|
||||||
|
|
||||||
|
assert normalized.bytes_uploaded == 1048576
|
||||||
|
assert normalized.bytes_downloaded == 4194304
|
||||||
|
assert normalized.total_bytes == 5242880
|
||||||
|
|
||||||
|
assert normalized.duration_seconds == 300
|
||||||
|
|
||||||
|
assert str(normalized.source_ip) == "10.240.12.1"
|
||||||
|
|
||||||
|
def test_cdr_id_is_deterministic() -> None:
|
||||||
|
first = generate_cdr_id("raw-123")
|
||||||
|
second = generate_cdr_id("raw-123")
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
def test_different_raw_ids_generate_different_cdr_ids() -> None:
|
||||||
|
first = generate_cdr_id("raw-123")
|
||||||
|
second = generate_cdr_id("raw-456")
|
||||||
|
|
||||||
|
assert first != second
|
||||||
|
|
||||||
|
def test_rat_mapping() -> None:
|
||||||
|
assert normalize_rat(1) == "3G"
|
||||||
|
assert normalize_rat(6) == "LTE_4G"
|
||||||
|
assert normalize_rat(9) == "5G_NR"
|
||||||
|
|
||||||
|
def test_missing_source_ip_defaults_to_zero_address() -> None:
|
||||||
|
payload = {
|
||||||
|
"raw_id": "raw-001",
|
||||||
|
"device_id": "310410998877665",
|
||||||
|
"caller_num": "0019875551234",
|
||||||
|
"svc": "4G_DATA",
|
||||||
|
"up_bytes": 100,
|
||||||
|
"down_bytes": 200,
|
||||||
|
"rat": 6,
|
||||||
|
"lac": "4021",
|
||||||
|
"ci": "8812",
|
||||||
|
"epoch_start": 1726070000,
|
||||||
|
"epoch_end": 1726070300,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = parse_raw_cdr(payload)
|
||||||
|
normalized = normalize_cdr(raw)
|
||||||
|
|
||||||
|
assert str(normalized.source_ip) == "0.0.0.0"
|
||||||
71
tests/unit/test_validation.py
Normal file
71
tests/unit/test_validation.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.domain.errors import CDRProcessingError, FailureReason
|
||||||
|
from core.domain.validation import parse_raw_cdr
|
||||||
|
|
||||||
|
|
||||||
|
def valid_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"raw_id": "raw-001",
|
||||||
|
"src_ip": "10.240.12.1",
|
||||||
|
"device_id": "310410998877665",
|
||||||
|
"caller_num": "0019875551234",
|
||||||
|
"svc": "4G_DATA",
|
||||||
|
"up_bytes": 1048576,
|
||||||
|
"down_bytes": 4194304,
|
||||||
|
"rat": 6,
|
||||||
|
"lac": "4021",
|
||||||
|
"ci": "8812",
|
||||||
|
"epoch_start": 1726070000,
|
||||||
|
"epoch_end": 1726070300,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_missing_required_field() -> None:
|
||||||
|
payload = valid_payload()
|
||||||
|
del payload["caller_num"]
|
||||||
|
|
||||||
|
with pytest.raises(CDRProcessingError) as exc_info:
|
||||||
|
parse_raw_cdr(payload)
|
||||||
|
|
||||||
|
error = exc_info.value
|
||||||
|
|
||||||
|
assert error.reason == FailureReason.MISSING_REQUIRED_FIELD
|
||||||
|
assert error.failed_field == "caller_num"
|
||||||
|
|
||||||
|
def test_invalid_rat() -> None:
|
||||||
|
payload = valid_payload()
|
||||||
|
payload["rat"] = 99
|
||||||
|
|
||||||
|
with pytest.raises(CDRProcessingError) as exc_info:
|
||||||
|
parse_raw_cdr(payload)
|
||||||
|
|
||||||
|
error = exc_info.value
|
||||||
|
|
||||||
|
assert error.reason == FailureReason.INVALID_RAT_VALUE
|
||||||
|
assert error.failed_field == "rat"
|
||||||
|
|
||||||
|
def test_epoch_start_after_end_is_invalid() -> None:
|
||||||
|
payload = valid_payload()
|
||||||
|
|
||||||
|
payload["epoch_start"] = 200
|
||||||
|
payload["epoch_end"] = 100
|
||||||
|
|
||||||
|
with pytest.raises(CDRProcessingError) as exc_info:
|
||||||
|
parse_raw_cdr(payload)
|
||||||
|
|
||||||
|
error = exc_info.value
|
||||||
|
|
||||||
|
assert error.reason == FailureReason.INVALID_EPOCH
|
||||||
|
|
||||||
|
def test_negative_uploaded_bytes_is_invalid() -> None:
|
||||||
|
payload = valid_payload()
|
||||||
|
|
||||||
|
payload["up_bytes"] = -1
|
||||||
|
|
||||||
|
with pytest.raises(CDRProcessingError) as exc_info:
|
||||||
|
parse_raw_cdr(payload)
|
||||||
|
|
||||||
|
error = exc_info.value
|
||||||
|
|
||||||
|
assert error.reason == FailureReason.NORMALIZATION_ERROR
|
||||||
|
assert error.failed_field == "up_bytes"
|
||||||
Reference in New Issue
Block a user