from __future__ import annotations
import logging
import re
from dataclasses import asdict, dataclass
from datetime import date, timedelta
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
from config import settings
from services.secret_box import decrypt_secret
logger = logging.getLogger(__name__)
DEFAULT_LIST_URL = "http://tw-compbase.supermicro.com:6699"
DEFAULT_FILL_URL = "http://tw-compbase.supermicro.com:6671"
_HIDDEN_FIELDS = (
"__VIEWSTATE",
"__VIEWSTATEGENERATOR",
"__EVENTVALIDATION",
"__VIEWSTATEENCRYPTED",
"__EVENTTARGET",
"__EVENTARGUMENT",
"__LASTFOCUS",
)
@dataclass
class AbnormalRow:
date: date
rid: str | None
exp_in: str
exp_out: str
actual_in: str
actual_out: str
status: str # missing_out | missing_in | other
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["date"] = self.date.isoformat()
return data
class CompBaseClient:
def __init__(
self,
*,
list_url: str | None = None,
fill_url: str | None = None,
) -> None:
self.list_url = (list_url or getattr(settings, "compbase_list_url", "") or DEFAULT_LIST_URL).rstrip("/")
self.fill_url = (fill_url or getattr(settings, "compbase_fill_url", "") or DEFAULT_FILL_URL).rstrip("/")
def _credentials(self) -> tuple[str, str]:
username = (getattr(settings, "compbase_username", "") or "").strip()
encrypted = getattr(settings, "compbase_password", "") or ""
if not username or not encrypted:
username = (getattr(settings, "pts_username", "") or "").strip()
encrypted = getattr(settings, "pts_password", "") or ""
if not username or not encrypted:
raise ValueError(
"Missing CompBase credentials. Set CompBase or PTS username/password in Settings."
)
try:
password = decrypt_secret(encrypted)
except ValueError as exc:
raise ValueError("Stored password could not be decrypted. Re-enter password in Settings.") from exc
if not password:
raise ValueError("CompBase password is empty. Re-enter password in Settings.")
return username, password
def _auth(self):
try:
from httpx_ntlm import HttpNtlmAuth
except ImportError as exc:
raise ValueError("httpx-ntlm is not installed. Run: pip install httpx-ntlm") from exc
username, password = self._credentials()
return HttpNtlmAuth(username, password)
def _make_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
auth=self._auth(),
timeout=60.0,
follow_redirects=True,
)
@staticmethod
def _extract_hidden(html: str) -> dict[str, str]:
out: dict[str, str] = {}
for name in _HIDDEN_FIELDS:
m = re.search(
rf']+name=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']',
html,
re.I,
)
if not m:
m = re.search(
rf']+id=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']',
html,
re.I,
)
if m:
out[name] = m.group(1)
return out
@staticmethod
def _extract_selected_selects(html: str) -> dict[str, str]:
out: dict[str, str] = {}
for m in re.finditer(
r'',
html,
re.I,
):
name, body = m.group(1), m.group(2)
selected = re.search(
r'", m.group(1), re.I):
val_m = re.search(r'value=["\']([^"\']*)["\']', om.group(0), re.I)
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", om.group(1))).strip()
opts.append(val_m.group(1) if val_m else text)
return opts
@staticmethod
def _input_value(html: str, name: str) -> str:
m = re.search(
rf']+name=["\']{re.escape(name)}["\'][^>]*>',
html,
re.I,
)
if not m:
return ""
val = re.search(r'value=["\']([^"\']*)["\']', m.group(0), re.I)
return val.group(1) if val else ""
@staticmethod
def _normalize_clock(value: str) -> str:
"""Normalize '5:53:00 PM' / '5:53 PM' → '5:53PM' for comparison."""
text = re.sub(r"\s+", " ", (value or "").strip()).upper()
# drop seconds if present: H:MM:SS AM/PM → H:MM AM/PM
text = re.sub(
r"\b(\d{1,2}:\d{2}):\d{2}\s*(AM|PM)\b",
r"\1 \2",
text,
)
return re.sub(r"[^0-9APM:]", "", text)
def _choose_out_time(
self,
options: list[str],
exp_out: str,
*,
out_time_mode: str,
) -> str:
usable = [
o
for o in options
if o and o not in {"------", "自行輸入"} and "自行" not in o
]
if not usable:
raise ValueError(f"No usable out-time options: {options}")
if out_time_mode == "latest_option":
return usable[-1]
exp_key = self._normalize_clock(exp_out)
for opt in usable:
if self._normalize_clock(opt) == exp_key and exp_key:
return opt
return usable[0]
async def fill_rid(
self,
rid: str,
*,
out_time_mode: str = "expected",
dry_run: bool = False,
row_date: date | None = None,
) -> dict[str, Any]:
rid = str(rid).strip()
if not rid:
raise ValueError("RID is required")
async with self._make_client() as client:
url = f"{self.fill_url}/?RID={rid}"
page = await client.get(url)
if page.status_code in {401, 403}:
raise ValueError("CompBase fill page NTLM login failed.")
page.raise_for_status()
html = page.text
exp_out = self._span_text(html, "ExpOut")
in_time = self._input_value(html, "InTime") or self._span_text(html, "ExpIn")
options = self._select_options(html, "preSetOutTimeList")
chosen = self._choose_out_time(options, exp_out, out_time_mode=out_time_mode)
label_date = self._span_text(html, "Label3") or (row_date.isoformat() if row_date else "")
result: dict[str, Any] = {
"rid": rid,
"date": label_date,
"exp_out": exp_out,
"in_time": in_time,
"chosen_out_time": chosen,
"options": options,
"dry_run": dry_run,
}
if dry_run:
result["ok"] = True
result["message"] = "dry_run"
return result
data: dict[str, str] = {}
data.update(self._extract_hidden(html))
data["RID"] = self._input_value(html, "RID") or rid
data["InTime"] = in_time
data["preSetOutTimeList"] = chosen
data["Button1"] = "送出"
resp = await client.post(url, data=data)
resp.raise_for_status()
msg = self._span_text(resp.text, "Msg")
# re-parse msg with colors if empty
if not msg:
mm = re.search(
r'id=["\']Msg["\'][^>]*>([\s\S]*?)',
resp.text,
re.I,
)
if mm:
msg = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", mm.group(1))).strip()
lower = msg.lower()
failed = any(
token in msg
for token in ("失敗", "錯誤", "error", "invalid", "無法", "拒絕")
)
result["message"] = msg or "submitted"
result["ok"] = not failed
if failed:
result["error"] = msg
logger.info(
"CompBase fill RID=%s date=%s out=%s ok=%s msg=%s",
rid,
label_date,
chosen,
result["ok"],
msg,
)
return result
async def fill_recent(
self,
days: int = 14,
*,
dry_run: bool = False,
out_time_mode: str = "expected",
) -> dict[str, Any]:
scan = await self.list_missing(days)
filled: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
skipped_rows = list(scan.get("skipped") or [])
for item in scan.get("fillable") or []:
rid = item.get("rid")
if not rid:
skipped_rows.append({**item, "reason": "no_rid"})
continue
try:
day = date.fromisoformat(item["date"]) if item.get("date") else None
result = await self.fill_rid(
str(rid),
out_time_mode=out_time_mode,
dry_run=dry_run,
row_date=day,
)
if result.get("ok"):
filled.append({**item, **result})
else:
errors.append({**item, **result})
except Exception as exc: # noqa: BLE001
logger.exception("CompBase fill failed for RID=%s", rid)
errors.append({**item, "ok": False, "error": str(exc)})
# annotate skipped without rid
for row in skipped_rows:
row.setdefault("reason", "no_rid" if not row.get("rid") else row.get("status", "skipped"))
return {
"ok": len(errors) == 0,
"dry_run": dry_run,
"days": days,
"out_time_mode": out_time_mode,
"start": scan.get("start"),
"end": scan.get("end"),
"fillable_count": scan.get("fillable_count"),
"filled": filled,
"skipped": skipped_rows,
"errors": errors,
"filled_count": len(filled),
"skipped_count": len(skipped_rows),
"error_count": len(errors),
}
async def status(self) -> dict[str, Any]:
username = (getattr(settings, "compbase_username", "") or "").strip()
source = "compbase"
if not username:
username = (getattr(settings, "pts_username", "") or "").strip()
source = "pts" if username else "none"
has_password = bool(
(getattr(settings, "compbase_password", "") or "")
or (getattr(settings, "pts_password", "") or "")
)
result: dict[str, Any] = {
"list_url": self.list_url,
"fill_url": self.fill_url,
"username": username or None,
"credential_source": source,
"has_credentials": bool(username and has_password),
"default_days": int(getattr(settings, "compbase_default_days", 14) or 14),
"out_time_mode": getattr(settings, "compbase_out_time_mode", "expected") or "expected",
"connected": False,
}
if not result["has_credentials"]:
result["error"] = "No credentials configured"
return result
try:
async with self._make_client() as client:
resp = await client.get(f"{self.list_url}/")
result["connected"] = resp.status_code == 200
result["http_status"] = resp.status_code
if resp.status_code == 200:
# pull employee name if present
m = re.search(r'id=["\']CName["\'][^>]*>([^<]+)', resp.text)
if m:
result["display_name"] = m.group(1).strip()
m = re.search(r'id=["\']EmpID["\'][^>]*>([^<]+)', resp.text)
if m:
result["emp_id"] = m.group(1).strip()
else:
result["error"] = f"HTTP {resp.status_code}"
except Exception as exc: # noqa: BLE001
result["error"] = str(exc)
return result