Skip to content

Commit ff63ee6

Browse files
Asyiqin NasirAsyiqin Nasir
authored andcommitted
Merge branch 'upgrade/version-1.3.0'
2 parents ea7507c + 7039e33 commit ff63ee6

5 files changed

Lines changed: 263 additions & 28 deletions

File tree

release.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# ─────────────────────────────────────────
66
# Check / update the version below before running:
77
# 8 April 2025
8-
VERSION="v1.2.1" # <── change this version before running
8+
VERSION="v1.3.0" # <── change this version before running
99
# ─────────────────────────────────────────
1010

1111
set -euo pipefail

src/models/analitik_sekolah.py

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,24 @@
1313
from src.core.time import _utc_now
1414

1515

16-
class AnalitikItem(BaseModel):
17-
"""Individual analytics item with type, percentage, and total."""
16+
class PeringkatItem(BaseModel):
17+
"""Breakdown of peringkat distribution within a jenis category."""
18+
19+
peringkat: str = Field(..., description="Peringkat (e.g., Rendah, Menengah)")
20+
total: int = Field(..., description="Jumlah sekolah untuk peringkat ini dalam jenis ini")
21+
22+
23+
class AnalitikJenisItem(BaseModel):
24+
"""Analytics item for jenisLabel dimension."""
25+
26+
jenis: str = Field(..., description="Kategori atau jenis")
27+
peratus: float = Field(..., description="Peratusan daripada jumlah keseluruhan")
28+
total: int = Field(..., description="Jumlah bilangan untuk kategori ini")
29+
peringkatBreakdown: list[PeringkatItem] = Field(default_factory=list, description="Breakdown mengikut peringkat dalam jenis ini")
30+
31+
32+
class AnalitikBantuanItem(BaseModel):
33+
"""Analytics item for bantuan dimension."""
1834

1935
jenis: str = Field(..., description="Kategori atau jenis")
2036
peratus: float = Field(..., description="Peratusan daripada jumlah keseluruhan")
@@ -24,8 +40,8 @@ class AnalitikItem(BaseModel):
2440
class AnalitikSekolahData(BaseModel):
2541
"""Container for all analytics dimensions in array format."""
2642

27-
jenisLabel: list[AnalitikItem] = Field(default_factory=list, description="Analisis mengikut jenis/label sekolah")
28-
bantuan: list[AnalitikItem] = Field(default_factory=list, description="Analisis mengikut jenis bantuan")
43+
jenisLabel: list[AnalitikJenisItem] = Field(default_factory=list, description="Analisis mengikut jenis/label sekolah")
44+
bantuan: list[AnalitikBantuanItem] = Field(default_factory=list, description="Analisis mengikut jenis bantuan")
2945

3046

3147
_settings = get_settings()
@@ -55,12 +71,16 @@ def from_sekolah_list(cls, sekolah_list: list["Sekolah"]) -> "AnalitikSekolah":
5571

5672
# Initialize analytics containers with defaultdicts for dynamic counting
5773
jenis_counts = defaultdict(int)
74+
jenis_peringkat_counts = defaultdict(lambda: defaultdict(int))
5875
bantuan_counts = defaultdict(int)
5976

6077
# Process each sekolah to build analytics dynamically
6178
for sekolah in sekolah_list:
6279
# Count categories
63-
cls._increment_count(jenis_counts, sekolah.jenisLabel)
80+
jenis_key = cls._normalize_value(sekolah.jenisLabel)
81+
peringkat_key = cls._normalize_peringkat_value(sekolah.peringkat)
82+
jenis_counts[jenis_key] += 1
83+
jenis_peringkat_counts[jenis_key][peringkat_key] += 1
6484
cls._increment_count(bantuan_counts, sekolah.bantuan)
6585

6686
# Sum totals
@@ -77,10 +97,14 @@ def from_sekolah_list(cls, sekolah_list: list["Sekolah"]) -> "AnalitikSekolah":
7797
student_count += sekolah.enrolmenKhas
7898
total_pelajar += student_count
7999

80-
# Convert counts to AnalitikItem arrays
100+
# Convert counts to analytics arrays
81101
data = AnalitikSekolahData(
82-
jenisLabel=cls._convert_to_analitik_items(jenis_counts, jumlah_sekolah),
83-
bantuan=cls._convert_to_analitik_items(bantuan_counts, jumlah_sekolah),
102+
jenisLabel=cls._convert_to_analitik_jenis_items(
103+
jenis_counts,
104+
jumlah_sekolah,
105+
jenis_peringkat_counts=jenis_peringkat_counts,
106+
),
107+
bantuan=cls._convert_to_analitik_bantuan_items(bantuan_counts, jumlah_sekolah),
84108
)
85109

86110
return cls(
@@ -98,8 +122,12 @@ def _increment_count(counter: dict, value: Optional[str]) -> None:
98122
counter[key] = counter.get(key, 0) + 1
99123

100124
@staticmethod
101-
def _convert_to_analitik_items(counter: defaultdict, total: int) -> list[AnalitikItem]:
102-
"""Convert defaultdict to list of AnalitikItem objects."""
125+
def _convert_to_analitik_jenis_items(
126+
counter: defaultdict,
127+
total: int,
128+
jenis_peringkat_counts: Optional[dict[str, dict]] = None,
129+
) -> list[AnalitikJenisItem]:
130+
"""Convert defaultdict to list of AnalitikJenisItem objects."""
103131
items = []
104132

105133
# Add existing categories
@@ -109,32 +137,106 @@ def _convert_to_analitik_items(counter: defaultdict, total: int) -> list[Analiti
109137
else:
110138
peratus = 0.0
111139

112-
items.append(AnalitikItem(
140+
# Build peringkat breakdown for this jenis if data is available
141+
peringkat_breakdown = []
142+
if jenis_peringkat_counts and jenis in jenis_peringkat_counts:
143+
peringkat_counts = jenis_peringkat_counts[jenis]
144+
for peringkat_key, peringkat_count in peringkat_counts.items():
145+
if peringkat_key != "TIADA MAKLUMAT": # Only include actual peringkat values
146+
peringkat_breakdown.append(
147+
PeringkatItem(
148+
peringkat=AnalitikSekolah._display_peringkat(peringkat_key),
149+
total=peringkat_count
150+
)
151+
)
152+
# Add TIADA MAKLUMAT if it exists
153+
if "TIADA MAKLUMAT" in peringkat_counts and peringkat_counts["TIADA MAKLUMAT"] > 0:
154+
peringkat_breakdown.append(
155+
PeringkatItem(
156+
peringkat="TIADA MAKLUMAT",
157+
total=peringkat_counts["TIADA MAKLUMAT"]
158+
)
159+
)
160+
# Sort by total descending
161+
peringkat_breakdown.sort(key=lambda x: x.total, reverse=True)
162+
163+
items.append(AnalitikJenisItem(
113164
jenis=jenis,
114165
peratus=peratus,
115-
total=count
166+
total=count,
167+
peringkatBreakdown=peringkat_breakdown
116168
))
117169

118170
# Ensure "Tiada Maklumat" exists even if count is 0
119171
tiada_maklumat_exists = any(item.jenis == "TIADA MAKLUMAT" for item in items)
120172
if not tiada_maklumat_exists:
121-
items.append(AnalitikItem(
173+
items.append(AnalitikJenisItem(
122174
jenis="TIADA MAKLUMAT",
123175
peratus=0.0,
124-
total=0
176+
total=0,
177+
peringkatBreakdown=[]
125178
))
126179

127180
# Sort by total count descending
128181
items.sort(key=lambda x: x.total, reverse=True)
129182
return items
130183

184+
@staticmethod
185+
def _convert_to_analitik_bantuan_items(
186+
counter: defaultdict,
187+
total: int,
188+
) -> list[AnalitikBantuanItem]:
189+
"""Convert defaultdict to list of AnalitikBantuanItem objects."""
190+
items = []
191+
192+
for jenis, count in counter.items():
193+
if total > 0:
194+
peratus = round((count / total) * 100, 1)
195+
else:
196+
peratus = 0.0
197+
198+
items.append(AnalitikBantuanItem(
199+
jenis=jenis,
200+
peratus=peratus,
201+
total=count,
202+
))
203+
204+
tiada_maklumat_exists = any(item.jenis == "TIADA MAKLUMAT" for item in items)
205+
if not tiada_maklumat_exists:
206+
items.append(AnalitikBantuanItem(
207+
jenis="TIADA MAKLUMAT",
208+
peratus=0.0,
209+
total=0,
210+
))
211+
212+
items.sort(key=lambda x: x.total, reverse=True)
213+
return items
214+
131215
@staticmethod
132216
def _normalize_value(value: Optional[str]) -> str:
133217
"""Normalize field values for consistent analytics categorization."""
134218
if value is None or str(value).strip() == "":
135219
return "TIADA MAKLUMAT"
136220
return str(value).strip().upper()
137221

222+
@classmethod
223+
def _normalize_peringkat_value(cls, value: Optional[str]) -> str:
224+
if value is not None and hasattr(value, "value"):
225+
value = getattr(value, "value")
226+
normalized = cls._normalize_value(value)
227+
if normalized in {"RENDAH", "MENENGAH"}:
228+
return normalized
229+
return "TIADA MAKLUMAT"
230+
231+
@classmethod
232+
def _display_peringkat(cls, value: str) -> str:
233+
normalized = cls._normalize_peringkat_value(value)
234+
if normalized == "RENDAH":
235+
return "RENDAH"
236+
if normalized == "MENENGAH":
237+
return "MENENGAH"
238+
return "TIADA MAKLUMAT"
239+
138240
def to_document(self) -> dict:
139241
"""Convert the analytics to a Mongo-ready document, omitting None fields."""
140242
doc = self.model_dump(exclude_none=True, by_alias=True)
@@ -145,5 +247,7 @@ def to_document(self) -> dict:
145247
__all__ = [
146248
"AnalitikSekolah",
147249
"AnalitikSekolahData",
148-
"AnalitikItem",
250+
"AnalitikJenisItem",
251+
"AnalitikBantuanItem",
252+
"PeringkatItem",
149253
]

src/models/entiti_sekolah.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pydantic import ConfigDict
1010
from typing_extensions import Literal
1111
from src.config.settings import get_settings
12-
from src.models.sekolah import SekolahStatus
12+
from src.models.sekolah import SekolahStatus, PeringkatEnum
1313
from src.models.negeri_enum import NegeriEnum
1414

1515

@@ -36,6 +36,7 @@ class InfoPentadbiran(BaseModel):
3636
negeri: NegeriEnum | None = Field(default=None, description="State the school is located in")
3737
ppd: Optional[str] = Field(default=None, description="Pejabat Pendidikan Daerah (district office)")
3838
parlimen: Optional[str] = Field(default=None, description="Parliament constituency")
39+
peringkat: PeringkatEnum | None = Field(default=None, description="School tier (RENDAH/MENENGAH)")
3940
bantuan: Optional[str] = Field(default=None, description="Bantuan classification")
4041
bilSesi: Optional[str] = Field(default=None, description="Number of school sessions")
4142
sesi: Optional[str] = Field(default=None, description="School session")
@@ -135,6 +136,7 @@ def from_sekolah(
135136
negeri=sekolah.negeri,
136137
ppd=sekolah.ppd,
137138
parlimen=sekolah.parlimen,
139+
peringkat=sekolah.peringkat,
138140
bantuan=sekolah.bantuan,
139141
bilSesi=sekolah.bilSesi,
140142
sesi=sekolah.sesi,

src/models/sekolah.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
MISSING_VALUES = {"TIADA", "", "NONE", "-", "--", "BELUM ADA"}
1616
BOOL_ADA_MAP = {"ADA": True, "TIADA": False, "": None}
1717
BOOL_YA_MAP = {"YA": True, "TIDAK": False, "": None}
18+
PERINGKAT_VALUES = {"RENDAH", "MENENGAH"}
1819

1920
_settings = get_settings()
2021

@@ -23,14 +24,21 @@ class SekolahStatus(str, Enum):
2324
ACTIVE = "ACTIVE"
2425
INACTIVE = "INACTIVE"
2526

27+
28+
class PeringkatEnum(str, Enum):
29+
"""Enum for school tier (Rendah/Menengah)."""
30+
RENDAH = "RENDAH"
31+
MENENGAH = "MENENGAH"
32+
33+
2634
class Sekolah(BaseModel):
2735
collection_name: ClassVar[str] = _settings.sekolah_collection
2836

2937
negeri: NegeriEnum | None = Field(default=None, alias="NEGERI")
3038
ppd: Optional[str] = Field(default=None, alias="PPD")
3139
parlimen: Optional[str] = Field(default=None, alias="PARLIMEN")
3240
dun: Optional[str] = Field(default=None, alias="DUN")
33-
peringkat: Optional[str] = Field(default=None, alias="PERINGKAT")
41+
peringkat: PeringkatEnum | None = Field(default=None, alias="PERINGKAT")
3442
jenisLabel: Optional[str] = Field(default=None, alias="JENIS/LABEL")
3543
kodSekolah: str = Field(..., alias="KODSEKOLAH")
3644
namaSekolah: Optional[str] = Field(default=None, alias="NAMASEKOLAH")
@@ -91,6 +99,19 @@ def normalize_dun(cls, value):
9199
text = str(value).strip()
92100
return text or None
93101

102+
@field_validator("peringkat", mode="before")
103+
def normalize_peringkat(cls, value):
104+
if value is None:
105+
return None
106+
if isinstance(value, PeringkatEnum):
107+
return value
108+
text = str(value).strip().upper()
109+
if not text or text in MISSING_VALUES:
110+
return None
111+
if text in {"RENDAH", "MENENGAH"}:
112+
return PeringkatEnum(text)
113+
return None
114+
94115
@field_validator(
95116
"poskodSurat",
96117
"enrolmenPrasekolah",
@@ -190,6 +211,9 @@ def to_document(self) -> dict:
190211
status_value = data.get("status")
191212
if isinstance(status_value, SekolahStatus):
192213
data["status"] = status_value.value
214+
peringkat_value = data.get("peringkat")
215+
if isinstance(peringkat_value, PeringkatEnum):
216+
data["peringkat"] = peringkat_value.value
193217
if self.koordinatXX is not None and self.koordinatYY is not None:
194218
data["location"] = {
195219
"type": "Point",
@@ -200,4 +224,4 @@ def to_document(self) -> dict:
200224
return data
201225

202226

203-
__all__ = ["Sekolah"]
227+
__all__ = ["Sekolah", "PeringkatEnum"]

0 commit comments

Comments
 (0)