updated gui and enforcement
This commit is contained in:
+569
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
|
||||
USERS_FILE = CONFIG_DIR / "users.yaml"
|
||||
STATE_FILE = CONFIG_DIR / "state.json"
|
||||
|
||||
DAY_NAMES = (
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
)
|
||||
|
||||
_config_lock = threading.RLock()
|
||||
|
||||
|
||||
DEFAULT_USERS_CONFIG = {
|
||||
"users": []
|
||||
}
|
||||
|
||||
DEFAULT_STATE = {
|
||||
"usage": {},
|
||||
"temporary_grants": [],
|
||||
}
|
||||
|
||||
|
||||
def _ensure_config_dir() -> None:
|
||||
CONFIG_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
|
||||
def _atomic_write(
|
||||
path: Path,
|
||||
content: str,
|
||||
) -> None:
|
||||
_ensure_config_dir()
|
||||
|
||||
directory = path.parent
|
||||
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=directory,
|
||||
text=True,
|
||||
)
|
||||
|
||||
temporary_path = Path(temporary_name)
|
||||
|
||||
try:
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as file:
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
|
||||
os.replace(
|
||||
temporary_path,
|
||||
path,
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
temporary_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_users_config(
|
||||
data: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
return copy.deepcopy(
|
||||
DEFAULT_USERS_CONFIG
|
||||
)
|
||||
|
||||
users = data.get("users")
|
||||
|
||||
if not isinstance(users, list):
|
||||
users = []
|
||||
|
||||
normalized_users = []
|
||||
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
|
||||
normalized = dict(user)
|
||||
|
||||
normalized.setdefault(
|
||||
"enabled",
|
||||
True,
|
||||
)
|
||||
|
||||
normalized.setdefault(
|
||||
"daily_allowance",
|
||||
{},
|
||||
)
|
||||
|
||||
normalized.setdefault(
|
||||
"access_windows",
|
||||
{},
|
||||
)
|
||||
|
||||
normalized_users.append(
|
||||
normalized
|
||||
)
|
||||
|
||||
return {
|
||||
"users": normalized_users
|
||||
}
|
||||
|
||||
|
||||
def _normalize_state(
|
||||
data: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
return copy.deepcopy(
|
||||
DEFAULT_STATE
|
||||
)
|
||||
|
||||
state = dict(data)
|
||||
|
||||
if not isinstance(
|
||||
state.get("usage"),
|
||||
dict,
|
||||
):
|
||||
state["usage"] = {}
|
||||
|
||||
if not isinstance(
|
||||
state.get("temporary_grants"),
|
||||
list,
|
||||
):
|
||||
state["temporary_grants"] = []
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def load_users_config() -> dict[str, Any]:
|
||||
with _config_lock:
|
||||
_ensure_config_dir()
|
||||
|
||||
if not USERS_FILE.exists():
|
||||
save_users_config(
|
||||
DEFAULT_USERS_CONFIG
|
||||
)
|
||||
|
||||
try:
|
||||
with USERS_FILE.open(
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as file:
|
||||
data = yaml.safe_load(file)
|
||||
|
||||
except (
|
||||
OSError,
|
||||
yaml.YAMLError,
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Unable to read {USERS_FILE}"
|
||||
)
|
||||
|
||||
return _normalize_users_config(
|
||||
data
|
||||
)
|
||||
|
||||
|
||||
def save_users_config(
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
with _config_lock:
|
||||
normalized = _normalize_users_config(
|
||||
data
|
||||
)
|
||||
|
||||
content = yaml.safe_dump(
|
||||
normalized,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
)
|
||||
|
||||
_atomic_write(
|
||||
USERS_FILE,
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def load_state() -> dict[str, Any]:
|
||||
with _config_lock:
|
||||
_ensure_config_dir()
|
||||
|
||||
if not STATE_FILE.exists():
|
||||
save_state(
|
||||
DEFAULT_STATE
|
||||
)
|
||||
|
||||
try:
|
||||
with STATE_FILE.open(
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as file:
|
||||
data = json.load(file)
|
||||
|
||||
except (
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Unable to read {STATE_FILE}"
|
||||
)
|
||||
|
||||
return _normalize_state(
|
||||
data
|
||||
)
|
||||
|
||||
|
||||
def save_state(
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
with _config_lock:
|
||||
normalized = _normalize_state(
|
||||
data
|
||||
)
|
||||
|
||||
content = json.dumps(
|
||||
normalized,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
_atomic_write(
|
||||
STATE_FILE,
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def next_user_id(
|
||||
users: list[dict[str, Any]],
|
||||
) -> int:
|
||||
highest = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
user_id = int(
|
||||
user.get("id", 0)
|
||||
)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
continue
|
||||
|
||||
highest = max(
|
||||
highest,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return highest + 1
|
||||
|
||||
|
||||
def find_user(
|
||||
user_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
config = load_users_config()
|
||||
|
||||
try:
|
||||
requested_id = int(user_id)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
return None
|
||||
|
||||
for user in config["users"]:
|
||||
try:
|
||||
current_id = int(
|
||||
user.get("id")
|
||||
)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
continue
|
||||
|
||||
if current_id == requested_id:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_user_by_username(
|
||||
username: str,
|
||||
) -> dict[str, Any] | None:
|
||||
config = load_users_config()
|
||||
|
||||
for user in config["users"]:
|
||||
if (
|
||||
str(
|
||||
user.get("username", "")
|
||||
)
|
||||
== username
|
||||
):
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_day_name(
|
||||
weekday: int,
|
||||
) -> str:
|
||||
weekday = int(weekday)
|
||||
|
||||
if not 0 <= weekday <= 6:
|
||||
raise ValueError(
|
||||
"weekday must be between 0 and 6"
|
||||
)
|
||||
|
||||
return DAY_NAMES[weekday]
|
||||
|
||||
|
||||
def normalize_day_name(
|
||||
day: str,
|
||||
) -> str:
|
||||
value = str(day).strip().lower()
|
||||
|
||||
aliases = {
|
||||
"sun": "sunday",
|
||||
"mon": "monday",
|
||||
"tue": "tuesday",
|
||||
"tues": "tuesday",
|
||||
"wed": "wednesday",
|
||||
"thu": "thursday",
|
||||
"thur": "thursday",
|
||||
"thurs": "thursday",
|
||||
"fri": "friday",
|
||||
"sat": "saturday",
|
||||
}
|
||||
|
||||
return aliases.get(
|
||||
value,
|
||||
value,
|
||||
)
|
||||
|
||||
|
||||
def python_weekday_to_name(
|
||||
python_weekday: int,
|
||||
) -> str:
|
||||
python_weekday = int(
|
||||
python_weekday
|
||||
)
|
||||
|
||||
if not 0 <= python_weekday <= 6:
|
||||
raise ValueError(
|
||||
"weekday must be between 0 and 6"
|
||||
)
|
||||
|
||||
return DAY_NAMES[
|
||||
(python_weekday + 1) % 7
|
||||
]
|
||||
|
||||
|
||||
def parse_time(
|
||||
value: Any,
|
||||
) -> int:
|
||||
if isinstance(value, int):
|
||||
return max(
|
||||
0,
|
||||
min(
|
||||
value,
|
||||
1439,
|
||||
),
|
||||
)
|
||||
|
||||
text = str(value).strip()
|
||||
|
||||
if ":" not in text:
|
||||
return max(
|
||||
0,
|
||||
min(
|
||||
int(text),
|
||||
1439,
|
||||
),
|
||||
)
|
||||
|
||||
hours_text, minutes_text = (
|
||||
text.split(
|
||||
":",
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
hours = int(
|
||||
hours_text
|
||||
)
|
||||
|
||||
minutes = int(
|
||||
minutes_text
|
||||
)
|
||||
|
||||
if not 0 <= hours <= 23:
|
||||
raise ValueError(
|
||||
f"Invalid hour: {hours}"
|
||||
)
|
||||
|
||||
if not 0 <= minutes <= 59:
|
||||
raise ValueError(
|
||||
f"Invalid minute: {minutes}"
|
||||
)
|
||||
|
||||
return (
|
||||
hours * 60
|
||||
+ minutes
|
||||
)
|
||||
|
||||
|
||||
def format_time(
|
||||
minute: int,
|
||||
) -> str:
|
||||
minute = int(minute)
|
||||
|
||||
minute = max(
|
||||
0,
|
||||
min(
|
||||
minute,
|
||||
1439,
|
||||
),
|
||||
)
|
||||
|
||||
hours = minute // 60
|
||||
minutes = minute % 60
|
||||
|
||||
return (
|
||||
f"{hours:02d}:"
|
||||
f"{minutes:02d}"
|
||||
)
|
||||
|
||||
|
||||
def normalize_allowances(
|
||||
allowances: Any,
|
||||
) -> dict[str, int]:
|
||||
if not isinstance(
|
||||
allowances,
|
||||
dict,
|
||||
):
|
||||
allowances = {}
|
||||
|
||||
result = {}
|
||||
|
||||
for day in DAY_NAMES:
|
||||
value = allowances.get(
|
||||
day,
|
||||
0,
|
||||
)
|
||||
|
||||
try:
|
||||
seconds = int(value)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
seconds = 0
|
||||
|
||||
result[day] = max(
|
||||
0,
|
||||
seconds,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def normalize_access_windows(
|
||||
windows: Any,
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
if not isinstance(
|
||||
windows,
|
||||
dict,
|
||||
):
|
||||
windows = {}
|
||||
|
||||
result = {}
|
||||
|
||||
for day in DAY_NAMES:
|
||||
day_windows = windows.get(
|
||||
day,
|
||||
[],
|
||||
)
|
||||
|
||||
if not isinstance(
|
||||
day_windows,
|
||||
list,
|
||||
):
|
||||
continue
|
||||
|
||||
normalized = []
|
||||
|
||||
for window in day_windows:
|
||||
if isinstance(
|
||||
window,
|
||||
dict,
|
||||
):
|
||||
start = window.get(
|
||||
"start"
|
||||
)
|
||||
end = window.get(
|
||||
"end"
|
||||
)
|
||||
|
||||
elif (
|
||||
isinstance(
|
||||
window,
|
||||
(list, tuple),
|
||||
)
|
||||
and len(window) >= 2
|
||||
):
|
||||
start = window[0]
|
||||
end = window[1]
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
start_minute = parse_time(
|
||||
start
|
||||
)
|
||||
end_minute = parse_time(
|
||||
end
|
||||
)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
continue
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"start": format_time(
|
||||
start_minute
|
||||
),
|
||||
"end": format_time(
|
||||
end_minute
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if normalized:
|
||||
result[day] = normalized
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user