Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
797 lines
37 KiB
Python
797 lines
37 KiB
Python
"""Accounts panel — lives inside Monitoring, Admin/Sub-admin only.
|
||
|
||
CRUD on accounts + groups (a small org tree: Group -> Sub-admin -> members),
|
||
plus a per-account usage/cost table sourced from the shared cross-machine
|
||
telemetry store (``core/telemetry_shared.py``) — no Microsoft Graph API, see
|
||
that module's docstring.
|
||
|
||
Sub-admin sees the exact same UI as Admin, just pre-filtered to their own
|
||
group (``groups.group_for_user``); they cannot create/delete other
|
||
sub-admins or reassign roles/groups outside their own group.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional
|
||
|
||
from PySide6.QtCore import Qt, Signal
|
||
from PySide6.QtWidgets import (
|
||
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||
QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMessageBox,
|
||
QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QTreeWidget,
|
||
QTreeWidgetItem, QVBoxLayout, QWidget,
|
||
)
|
||
|
||
from ..core import accounts, groups, telemetry_shared
|
||
from ..core import usage_tracker as ut
|
||
from ..core.worker import AgentWorker
|
||
from ..i18n import on_language_changed, tr
|
||
from ..state import AppContext
|
||
from .icons import DOT_AMBER, icon
|
||
from .widgets import fmt_tokens
|
||
|
||
_PERIODS = ("day", "week", "month", "year")
|
||
_PERIOD_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
|
||
|
||
|
||
class _OrgTree(QTreeWidget):
|
||
"""Org tree with drag-and-drop member moving: drag an account item onto a
|
||
group item to move it there. No confirm button — Qt's own drag gesture
|
||
(press, drag onto a visibly-highlighted target, release) is already a
|
||
deliberate action that can't happen from a stray single click, unlike the
|
||
earlier explicit "enable move" button flow this replaces."""
|
||
member_dropped = Signal(str, str) # username, target_group_id ("" = ungrouped)
|
||
|
||
def __init__(self):
|
||
"""Bật kéo–thả ngay từ đầu; quyền kéo thật sự do :meth:`set_drag_enabled` quyết
|
||
định sau khi biết vai trò người đăng nhập.
|
||
"""
|
||
super().__init__()
|
||
self.setDropIndicatorShown(True)
|
||
self.setDragDropMode(QTreeWidget.DragDrop)
|
||
|
||
def set_drag_enabled(self, enabled: bool) -> None:
|
||
"""Cho phép hay cấm kéo–thả. Chỉ Admin mới được chuyển người giữa các nhóm."""
|
||
self.setDragEnabled(enabled)
|
||
self.setAcceptDrops(enabled)
|
||
|
||
def _drop_kinds(self, event):
|
||
"""(target_kind, source_kind) tuples for the drop TARGET under the
|
||
cursor and the drag SOURCE (``self.currentItem()``), used by all
|
||
three drag/drop overrides so they gate identically."""
|
||
target = self.itemAt(event.position().toPoint())
|
||
source = self.currentItem()
|
||
target_kind = target.data(0, Qt.UserRole) if target else None
|
||
source_kind = source.data(0, Qt.UserRole) if source else None
|
||
return target_kind, source_kind
|
||
|
||
def _drop_is_valid(self, event) -> bool:
|
||
"""Chỉ nhận đúng một kiểu thả: một TÀI KHOẢN thả vào một NHÓM.
|
||
|
||
Mọi hướng khác (nhóm vào nhóm, tài khoản vào tài khoản) đều bị từ chối —
|
||
cây này biểu diễn quan hệ nhóm chứa người, không có cấp lồng nhau.
|
||
"""
|
||
target_kind, source_kind = self._drop_kinds(event)
|
||
return bool(target_kind and target_kind[0] == "group"
|
||
and source_kind and source_kind[0] == "account")
|
||
|
||
def dragEnterEvent(self, event) -> None:
|
||
"""Nhận con trỏ kéo vào cây nếu đây là kiểu thả hợp lệ."""
|
||
if self.dragEnabled() and self._drop_is_valid(event):
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event) -> None:
|
||
"""Cập nhật con trỏ khi rê qua từng mục — chỉ mục hợp lệ mới nhận."""
|
||
if self._drop_is_valid(event):
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event) -> None:
|
||
"""Handled entirely ourselves (data model + a full refresh()) —
|
||
never delegates to Qt's own default reparenting, since the tree is
|
||
always rebuilt from ``accounts``/``groups`` storage anyway."""
|
||
if self._drop_is_valid(event):
|
||
target_kind, source_kind = self._drop_kinds(event)
|
||
event.acceptProposedAction()
|
||
self.member_dropped.emit(source_kind[1], target_kind[1])
|
||
else:
|
||
event.ignore()
|
||
|
||
|
||
class AccountEditDialog(QDialog):
|
||
"""Add/Edit one Account. ``locked_role``/``locked_group_id`` (Sub-admin
|
||
editing a member of their own group) disable the role/group pickers so a
|
||
Sub-admin can't promote someone or move them out of their group."""
|
||
|
||
def __init__(self, parent=None, account: Optional[accounts.Account] = None,
|
||
available_groups: Optional[List[groups.Group]] = None,
|
||
allow_role_edit: bool = True, allow_group_edit: bool = True,
|
||
fixed_group_id: str = ""):
|
||
"""Dựng form thêm/sửa tài khoản.
|
||
|
||
``account`` là None thì đây là form thêm mới. ``allow_role_edit`` và
|
||
``allow_group_edit`` tắt cho Sub-admin: họ sửa được thành viên nhóm mình
|
||
nhưng không được tự nâng vai trò hay chuyển người sang nhóm khác.
|
||
"""
|
||
super().__init__(parent)
|
||
self._existing = account
|
||
self.setWindowTitle(tr("accounts.edit_title") if account else tr("accounts.add_title"))
|
||
self.resize(360, 280)
|
||
form = QFormLayout(self)
|
||
self.user_edit = QLineEdit(account.username if account else "")
|
||
self.user_edit.setEnabled(account is None) # username is the identity key — no rename
|
||
form.addRow(tr("accounts.f_username"), self.user_edit)
|
||
self.name_edit = QLineEdit(account.display_name if account else "")
|
||
form.addRow(tr("accounts.f_display_name"), self.name_edit)
|
||
self.email_edit = QLineEdit(account.email if account else "")
|
||
self.email_edit.setPlaceholderText(tr("accounts.f_email_placeholder"))
|
||
form.addRow(tr("accounts.f_email"), self.email_edit)
|
||
self.role_combo = QComboBox()
|
||
for r in accounts.ROLES:
|
||
self.role_combo.addItem(tr(f"accounts.role.{r}"), r)
|
||
if account:
|
||
idx = self.role_combo.findData(account.role)
|
||
if idx >= 0:
|
||
self.role_combo.setCurrentIndex(idx)
|
||
self.role_combo.setEnabled(allow_role_edit)
|
||
form.addRow(tr("accounts.f_role"), self.role_combo)
|
||
self.dept_edit = QLineEdit(account.department if account else "")
|
||
form.addRow(tr("accounts.f_department"), self.dept_edit)
|
||
self.group_combo = QComboBox()
|
||
self.group_combo.addItem(tr("accounts.no_group"), "")
|
||
for g in (available_groups or []):
|
||
self.group_combo.addItem(g.name, g.group_id)
|
||
preset_group = fixed_group_id or (account.group_id if account else "")
|
||
idx = self.group_combo.findData(preset_group)
|
||
if idx >= 0:
|
||
self.group_combo.setCurrentIndex(idx)
|
||
self.group_combo.setEnabled(allow_group_edit)
|
||
form.addRow(tr("accounts.f_group"), self.group_combo)
|
||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
form.addRow(buttons)
|
||
|
||
def result_fields(self) -> Dict[str, str]:
|
||
"""Nội dung form dưới dạng dict, đã cắt khoảng trắng thừa."""
|
||
return {
|
||
"username": self.user_edit.text().strip(),
|
||
"display_name": self.name_edit.text().strip(),
|
||
"email": self.email_edit.text().strip(),
|
||
"role": self.role_combo.currentData(),
|
||
"department": self.dept_edit.text().strip(),
|
||
"group_id": self.group_combo.currentData() or "",
|
||
}
|
||
|
||
|
||
class AccountsTab(QWidget):
|
||
"""Màn Tài khoản: cây nhóm–người dùng, form sửa và bảng thống kê mức dùng.
|
||
|
||
Dữ liệu nằm ở thư mục dùng chung (OneDrive/ổ mạng) chứ không ở máy, nên mọi
|
||
máy trỏ vào cùng thư mục đều thấy chung một danh sách.
|
||
|
||
Phân quyền chạy xuyên suốt màn này: Admin thấy và sửa tất cả; Sub-admin chỉ
|
||
thấy nhóm của mình và chỉ sửa được thành viên thường trong đó.
|
||
"""
|
||
def __init__(self, ctx: AppContext):
|
||
"""Dựng giao diện và nạp lần đầu."""
|
||
super().__init__()
|
||
self.ctx = ctx
|
||
self._current_username = ""
|
||
|
||
root = QVBoxLayout(self)
|
||
self._shared_hint = QLabel("")
|
||
self._shared_hint.setObjectName("hint")
|
||
self._shared_hint.setWordWrap(True)
|
||
root.addWidget(self._shared_hint)
|
||
|
||
split = QSplitter(Qt.Horizontal)
|
||
root.addWidget(split, 1)
|
||
|
||
# ---- left: org tree + CRUD buttons --------------------------------
|
||
left = QWidget()
|
||
ll = QVBoxLayout(left)
|
||
ll.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Smart search: instant substring filter over the org tree, plus an
|
||
# AI button that turns a natural-language query into keywords first.
|
||
search_row = QHBoxLayout()
|
||
self.search_edit = QLineEdit()
|
||
self.search_edit.textChanged.connect(self._apply_tree_filter)
|
||
self.ai_search_btn = QPushButton()
|
||
self.ai_search_btn.setIcon(icon("sparkle"))
|
||
self.ai_search_btn.clicked.connect(self._ai_search)
|
||
search_row.addWidget(self.search_edit, 1)
|
||
search_row.addWidget(self.ai_search_btn)
|
||
ll.addLayout(search_row)
|
||
|
||
# Scope the tree to just one Group (on top of the free-text search
|
||
# above, which already searches within whichever groups are shown) —
|
||
# handy once there are many groups and you only want to look at one.
|
||
self.group_filter_combo = QComboBox()
|
||
self.group_filter_combo.currentIndexChanged.connect(lambda _i: self._apply_tree_filter())
|
||
ll.addWidget(self.group_filter_combo)
|
||
|
||
self.tree = _OrgTree()
|
||
self.tree.setHeaderHidden(True)
|
||
self.tree.currentItemChanged.connect(self._on_tree_select)
|
||
# Only Admin may drag a member into a different group — Sub-admin's
|
||
# tree stays plain (they're scoped to their own group anyway).
|
||
self.tree.set_drag_enabled(self._is_admin())
|
||
self.tree.member_dropped.connect(self._on_member_dropped)
|
||
ll.addWidget(self.tree, 1)
|
||
|
||
btns = QHBoxLayout()
|
||
self.add_btn = QPushButton()
|
||
self.add_btn.setIcon(icon("plus"))
|
||
self.add_btn.clicked.connect(self._add_account)
|
||
self.edit_btn = QPushButton()
|
||
self.edit_btn.setIcon(icon("edit"))
|
||
self.edit_btn.clicked.connect(self._edit_account)
|
||
self.del_btn = QPushButton()
|
||
self.del_btn.setIcon(icon("trash"))
|
||
self.del_btn.clicked.connect(self._delete_account)
|
||
self.code_btn = QPushButton()
|
||
self.code_btn.setIcon(icon("key"))
|
||
self.code_btn.clicked.connect(self._regenerate_code)
|
||
for b in (self.add_btn, self.edit_btn, self.del_btn, self.code_btn):
|
||
btns.addWidget(b)
|
||
ll.addLayout(btns)
|
||
|
||
# Group creation is an Admin-only capability — the button is HIDDEN
|
||
# for every other role (a visible button whose click silently no-ops
|
||
# was the old, confusing behaviour).
|
||
self.group_btn = QPushButton()
|
||
self.group_btn.setIcon(icon("folder"))
|
||
self.group_btn.clicked.connect(self._add_group)
|
||
self.group_btn.setVisible(self._is_admin())
|
||
ll.addWidget(self.group_btn)
|
||
|
||
# Bulk import from Excel (Admin only): download the template, fill a
|
||
# row per person, import — groups + accounts are created together.
|
||
excel_row = QHBoxLayout()
|
||
self.excel_template_btn = QPushButton()
|
||
self.excel_template_btn.setIcon(icon("download"))
|
||
self.excel_template_btn.clicked.connect(self._export_excel_template)
|
||
self.excel_import_btn = QPushButton()
|
||
self.excel_import_btn.setIcon(icon("upload"))
|
||
self.excel_import_btn.clicked.connect(self._import_excel)
|
||
self.excel_template_btn.setVisible(self._is_admin())
|
||
self.excel_import_btn.setVisible(self._is_admin())
|
||
excel_row.addWidget(self.excel_template_btn)
|
||
excel_row.addWidget(self.excel_import_btn)
|
||
ll.addLayout(excel_row)
|
||
split.addWidget(left)
|
||
|
||
# ---- right: per-account usage/cost table --------------------------
|
||
right = QWidget()
|
||
rl = QVBoxLayout(right)
|
||
rl.setContentsMargins(0, 0, 0, 0)
|
||
head = QHBoxLayout()
|
||
self._usage_title = QLabel("")
|
||
self._usage_title.setStyleSheet("font-weight:700;")
|
||
head.addWidget(self._usage_title, 1)
|
||
self.period_combo = QComboBox()
|
||
for p in _PERIODS:
|
||
self.period_combo.addItem(tr(f"accounts.period.{p}"), p)
|
||
self.period_combo.currentIndexChanged.connect(self.refresh)
|
||
head.addWidget(self.period_combo)
|
||
self.refresh_btn = QPushButton()
|
||
self.refresh_btn.setIcon(icon("refresh"))
|
||
self.refresh_btn.clicked.connect(self.refresh)
|
||
head.addWidget(self.refresh_btn)
|
||
rl.addLayout(head)
|
||
|
||
self.usage_table = QTableWidget(0, 6)
|
||
self.usage_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||
self.usage_table.verticalHeader().setVisible(False)
|
||
self.usage_table.horizontalHeader().setStretchLastSection(True)
|
||
rl.addWidget(self.usage_table, 1)
|
||
split.addWidget(right)
|
||
split.setStretchFactor(0, 0)
|
||
split.setStretchFactor(1, 1)
|
||
split.setSizes([260, 640])
|
||
|
||
on_language_changed(self._retranslate)
|
||
self._retranslate()
|
||
self.refresh()
|
||
|
||
# ---- role-scoped repository access ---------------------------------
|
||
def _shared_dir(self) -> str:
|
||
"""Thư mục dùng chung đang cấu hình; '' nghĩa là chưa đặt và màn này không có
|
||
gì để hiện.
|
||
"""
|
||
return self.ctx.config.shared_dir
|
||
|
||
def _accounts_dir(self):
|
||
"""Thư mục chứa file tài khoản bên trong thư mục dùng chung."""
|
||
return accounts.accounts_dir(self._shared_dir())
|
||
|
||
def _groups_dir(self):
|
||
"""Thư mục chứa file nhóm bên trong thư mục dùng chung."""
|
||
return groups.groups_dir(self._shared_dir())
|
||
|
||
def _is_admin(self) -> bool:
|
||
"""Người đang đăng nhập có phải Admin không."""
|
||
return self.ctx.role == "admin"
|
||
|
||
def _my_group(self) -> Optional[groups.Group]:
|
||
"""Nhóm của người đang đăng nhập; ``None`` nếu chưa đăng nhập hoặc chưa vào
|
||
nhóm nào.
|
||
"""
|
||
acc = self.ctx.account
|
||
if acc is None:
|
||
return None
|
||
return groups.group_for_user(acc.username, self._groups_dir())
|
||
|
||
def _visible_groups(self) -> List[groups.Group]:
|
||
"""Các nhóm người đang đăng nhập được phép thấy — Admin thấy hết, còn lại chỉ
|
||
thấy nhóm của mình.
|
||
"""
|
||
all_groups = groups.list_groups(self._groups_dir())
|
||
if self._is_admin():
|
||
return all_groups
|
||
mine = self._my_group()
|
||
return [mine] if mine else []
|
||
|
||
def _visible_accounts(self) -> List[accounts.Account]:
|
||
"""Các tài khoản người đang đăng nhập được phép thấy.
|
||
|
||
Lọc ngay tại đây chứ không lọc ở chỗ hiển thị: mọi thứ vẽ ra sau đó đều đi
|
||
qua hàm này, nên không có đường nào lộ tài khoản ngoài phạm vi.
|
||
"""
|
||
all_accounts = accounts.list_accounts(self._accounts_dir())
|
||
if self._is_admin():
|
||
return all_accounts
|
||
mine = self._my_group()
|
||
if mine is None:
|
||
return []
|
||
members = set(mine.member_usernames) | {mine.subadmin_username}
|
||
return [a for a in all_accounts if a.username in members]
|
||
|
||
# ---- tree ------------------------------------------------------------
|
||
def refresh(self) -> None:
|
||
"""Nạp lại cả cây và bảng thống kê. Chưa đặt thư mục dùng chung thì chỉ hiện
|
||
lời nhắc và để trống.
|
||
"""
|
||
shared_dir = self._shared_dir()
|
||
if not shared_dir:
|
||
self._shared_hint.setText(tr("accounts.no_shared_dir"))
|
||
self.tree.clear()
|
||
self.usage_table.setRowCount(0)
|
||
return
|
||
self._shared_hint.setText(tr("accounts.shared_dir_hint", path=shared_dir))
|
||
self._reload_tree()
|
||
self._reload_usage_table()
|
||
|
||
def _reload_group_filter_combo(self) -> None:
|
||
"""Repopulate the "scope to one group" combo, keeping whichever
|
||
group_id was selected before (falls back to "All groups" if that
|
||
group no longer exists — e.g. it was just deleted)."""
|
||
current = self.group_filter_combo.currentData()
|
||
self.group_filter_combo.blockSignals(True)
|
||
self.group_filter_combo.clear()
|
||
self.group_filter_combo.addItem(tr("accounts.filter_all_groups"), "")
|
||
for g in self._visible_groups():
|
||
self.group_filter_combo.addItem(g.name, g.group_id)
|
||
idx = self.group_filter_combo.findData(current)
|
||
self.group_filter_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||
self.group_filter_combo.blockSignals(False)
|
||
|
||
def _reload_tree(self) -> None:
|
||
"""Dựng lại cây nhóm → thành viên từ dữ liệu trên đĩa."""
|
||
self._reload_group_filter_combo()
|
||
self.tree.clear()
|
||
my_accounts = {a.username: a for a in self._visible_accounts()}
|
||
for g in self._visible_groups():
|
||
g_item = QTreeWidgetItem([g.name])
|
||
g_item.setIcon(0, icon("folder"))
|
||
g_item.setData(0, Qt.UserRole, ("group", g.group_id))
|
||
self.tree.addTopLevelItem(g_item)
|
||
if g.subadmin_username and g.subadmin_username in my_accounts:
|
||
acc = my_accounts.pop(g.subadmin_username)
|
||
self._add_account_item(g_item, acc, is_subadmin=True)
|
||
for uname in g.member_usernames:
|
||
acc = my_accounts.pop(uname, None)
|
||
if acc is not None:
|
||
self._add_account_item(g_item, acc)
|
||
g_item.setExpanded(True)
|
||
if my_accounts:
|
||
ungrouped = QTreeWidgetItem([tr("accounts.ungrouped")])
|
||
ungrouped.setIcon(0, icon("folder"))
|
||
ungrouped.setData(0, Qt.UserRole, ("group", ""))
|
||
self.tree.addTopLevelItem(ungrouped)
|
||
for acc in my_accounts.values():
|
||
self._add_account_item(ungrouped, acc)
|
||
ungrouped.setExpanded(True)
|
||
self._apply_tree_filter() # a rebuilt tree must respect the active search
|
||
|
||
def _add_account_item(self, parent: QTreeWidgetItem, acc: accounts.Account,
|
||
is_subadmin: bool = False) -> None:
|
||
"""Thêm một dòng tài khoản vào dưới một nhóm.
|
||
|
||
Sub-admin được đánh dấu bằng biểu tượng ngôi sao thay vì ký tự ★ — ký tự
|
||
hiển thị khác nhau tuỳ phông của từng máy.
|
||
"""
|
||
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
|
||
item = QTreeWidgetItem([label])
|
||
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
|
||
item.setIcon(0, icon("star", color=DOT_AMBER))
|
||
item.setData(0, Qt.UserRole, ("account", acc.username))
|
||
if acc.email:
|
||
item.setToolTip(0, acc.email)
|
||
parent.addChild(item)
|
||
|
||
def _on_tree_select(self, *_a) -> None:
|
||
"""Ghi nhớ tài khoản vừa chọn rồi vẽ lại (các nút sửa/xoá bật theo lựa chọn)."""
|
||
item = self.tree.currentItem()
|
||
kind_id = item.data(0, Qt.UserRole) if item else None
|
||
self._current_username = kind_id[1] if kind_id and kind_id[0] == "account" else ""
|
||
self.refresh()
|
||
|
||
# ---- account CRUD ------------------------------------------------------
|
||
def _add_account(self) -> None:
|
||
"""Mở form thêm tài khoản rồi lưu.
|
||
|
||
Sub-admin bị khoá cứng vào nhóm của chính họ, không chọn được nhóm khác.
|
||
"""
|
||
shared_dir = self._shared_dir()
|
||
if not shared_dir:
|
||
return
|
||
fixed_group = "" if self._is_admin() else (self._my_group().group_id if self._my_group() else "")
|
||
dlg = AccountEditDialog(
|
||
self, available_groups=self._visible_groups(),
|
||
allow_role_edit=self._is_admin(), allow_group_edit=self._is_admin(),
|
||
fixed_group_id=fixed_group)
|
||
if not dlg.exec():
|
||
return
|
||
fields = dlg.result_fields()
|
||
if not fields["username"]:
|
||
return
|
||
directory = self._accounts_dir()
|
||
# Single-admin invariant: the app has exactly ONE admin account —
|
||
# creating a second one is refused outright, whoever asks.
|
||
if fields["role"] == "admin" and accounts.admin_exists(directory):
|
||
QMessageBox.warning(self, tr("accounts.add_title"),
|
||
tr("accounts.err_admin_exists"))
|
||
return
|
||
existing_codes = {a.code for a in accounts.list_accounts(directory)}
|
||
account = accounts.new_account(
|
||
fields["username"], fields["role"] if self._is_admin() else "user",
|
||
display_name=fields["display_name"], department=fields["department"],
|
||
email=fields["email"], group_id=fields["group_id"] or fixed_group,
|
||
created_by=self.ctx.account.username if self.ctx.account else "",
|
||
existing_codes=existing_codes)
|
||
accounts.save_account(account, directory)
|
||
if account.group_id:
|
||
self._add_member_to_group(account.group_id, account.username)
|
||
QMessageBox.information(
|
||
self, tr("login.code_shown_title"),
|
||
tr("login.code_shown_body", username=account.username, code=account.code))
|
||
self.refresh()
|
||
|
||
def _selected_account(self) -> Optional[accounts.Account]:
|
||
"""Tài khoản đang chọn trên cây, đọc lại từ đĩa; ``None`` nếu không chọn dòng
|
||
tài khoản nào.
|
||
"""
|
||
if not self._current_username:
|
||
return None
|
||
return accounts.find_by_username(self._current_username, self._accounts_dir())
|
||
|
||
def _edit_account(self) -> None:
|
||
"""Mở form sửa tài khoản đang chọn rồi lưu."""
|
||
account = self._selected_account()
|
||
if account is None:
|
||
return
|
||
fixed_group = "" if self._is_admin() else account.group_id
|
||
dlg = AccountEditDialog(
|
||
self, account=account, available_groups=self._visible_groups(),
|
||
allow_role_edit=self._is_admin(), allow_group_edit=self._is_admin(),
|
||
fixed_group_id=fixed_group)
|
||
if not dlg.exec():
|
||
return
|
||
fields = dlg.result_fields()
|
||
# Single-admin invariant also on PROMOTION: an account may only be
|
||
# made admin when no OTHER account already holds that role.
|
||
if (self._is_admin() and fields["role"] == "admin"
|
||
and accounts.admin_exists(self._accounts_dir(),
|
||
exclude_username=account.username)):
|
||
QMessageBox.warning(self, tr("accounts.edit_title"),
|
||
tr("accounts.err_admin_exists"))
|
||
return
|
||
old_group_id = account.group_id
|
||
account.display_name = fields["display_name"]
|
||
account.department = fields["department"]
|
||
account.email = fields["email"]
|
||
if self._is_admin():
|
||
account.role = fields["role"]
|
||
account.group_id = fields["group_id"]
|
||
accounts.save_account(account, self._accounts_dir())
|
||
if self._is_admin() and account.group_id != old_group_id:
|
||
if old_group_id:
|
||
self._remove_member_from_group(old_group_id, account.username)
|
||
if account.group_id:
|
||
self._add_member_to_group(account.group_id, account.username)
|
||
self.refresh()
|
||
|
||
def _delete_account(self) -> None:
|
||
"""Xoá tài khoản đang chọn, có hỏi lại.
|
||
|
||
Sub-admin chỉ xoá được thành viên thường — không xoá được Admin hay một
|
||
Sub-admin khác.
|
||
"""
|
||
account = self._selected_account()
|
||
if account is None:
|
||
return
|
||
if not self._is_admin() and account.role != "user":
|
||
return # Sub-admin can only remove ordinary members, never another sub-admin
|
||
if QMessageBox.question(
|
||
self, tr("accounts.delete_title"),
|
||
tr("accounts.delete_confirm", username=account.username)) != QMessageBox.Yes:
|
||
return
|
||
accounts.delete_account(account.username, self._accounts_dir())
|
||
if account.group_id:
|
||
self._remove_member_from_group(account.group_id, account.username)
|
||
self._current_username = ""
|
||
self.refresh()
|
||
|
||
def _regenerate_code(self) -> None:
|
||
"""Cấp lại mã đăng nhập mới cho tài khoản đang chọn và hiện mã ra một lần.
|
||
|
||
Mã mới phải khác mọi mã đang dùng, nên danh sách mã hiện có được truyền vào
|
||
hàm sinh mã.
|
||
"""
|
||
account = self._selected_account()
|
||
if account is None:
|
||
return
|
||
directory = self._accounts_dir()
|
||
existing_codes = {a.code for a in accounts.list_accounts(directory) if a.username != account.username}
|
||
account.code = accounts.generate_code(existing_codes)
|
||
accounts.save_account(account, directory)
|
||
QMessageBox.information(
|
||
self, tr("login.code_shown_title"),
|
||
tr("login.code_shown_body", username=account.username, code=account.code))
|
||
|
||
# ---- smart search over the org tree ------------------------------------
|
||
def _apply_tree_filter(self, text: str = "") -> None:
|
||
"""Instant substring filter: hide accounts whose label doesn't match;
|
||
a group stays visible while any of its children match (or itself
|
||
matches). Empty text shows everything. On top of that, the "scope to
|
||
one group" combo can hide every OTHER group's top-level item outright
|
||
(its own members are never even substring-checked in that case)."""
|
||
needle = (text or self.search_edit.text()).strip().lower()
|
||
group_scope = self.group_filter_combo.currentData() or ""
|
||
for gi in range(self.tree.topLevelItemCount()):
|
||
g_item = self.tree.topLevelItem(gi)
|
||
kind_id = g_item.data(0, Qt.UserRole)
|
||
g_id = kind_id[1] if kind_id else ""
|
||
if group_scope and g_id != group_scope:
|
||
g_item.setHidden(True)
|
||
continue
|
||
any_child = False
|
||
for ci in range(g_item.childCount()):
|
||
child = g_item.child(ci)
|
||
match = not needle or needle in child.text(0).lower()
|
||
child.setHidden(not match)
|
||
any_child = any_child or match
|
||
g_match = not needle or needle in g_item.text(0).lower()
|
||
g_item.setHidden(not (g_match or any_child))
|
||
if g_match and needle:
|
||
for ci in range(g_item.childCount()):
|
||
g_item.child(ci).setHidden(False)
|
||
|
||
def _ai_search(self) -> None:
|
||
"""AI-assisted search: the typed text is treated as a natural-language
|
||
question ("ai trong nhóm CAE chưa có phòng ban?") — the model turns it
|
||
into plain search keywords, which then run through the same substring
|
||
filter. Falls back to using the raw text on any provider error."""
|
||
query = self.search_edit.text().strip()
|
||
if not query or getattr(self, "_ai_search_worker", None) is not None:
|
||
return
|
||
self.ai_search_btn.setEnabled(False)
|
||
ctx = self.ctx
|
||
|
||
def job(worker: AgentWorker):
|
||
"""Chạy nền: nhờ model rút câu hỏi tự nhiên thành 1–3 từ khoá tìm kiếm."""
|
||
provider = ctx.build_active_provider()
|
||
reply = provider.chat([
|
||
{"role": "system", "content":
|
||
"Turn the user's natural-language people-search question into 1-3 SHORT "
|
||
"search keywords (a name, username, department, group or role word) that "
|
||
"would appear in an account list. Reply with ONLY the single best keyword, "
|
||
"no explanation."},
|
||
{"role": "user", "content": query},
|
||
], cancel=worker.stop_event)
|
||
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
|
||
|
||
def done(result: dict) -> None:
|
||
"""Đổ từ khoá vào ô tìm kiếm — chính việc gán chữ đã kích hoạt lọc lại cây."""
|
||
self._ai_search_worker = None
|
||
self.ai_search_btn.setEnabled(True)
|
||
keyword = result.get("keyword") or query
|
||
self.search_edit.setText(keyword) # textChanged re-applies the filter
|
||
|
||
def failed(_err: str) -> None:
|
||
"""Gọi model hỏng thì lọc thẳng bằng câu người dùng gõ, không báo lỗi: tìm
|
||
kiếm là tiện ích, không đáng chặn người dùng.
|
||
"""
|
||
self._ai_search_worker = None
|
||
self.ai_search_btn.setEnabled(True)
|
||
self._apply_tree_filter(query)
|
||
|
||
w = AgentWorker(job)
|
||
w.finished_ok.connect(done)
|
||
w.failed.connect(failed)
|
||
self._ai_search_worker = w
|
||
w.start()
|
||
|
||
# ---- bulk import from Excel (Admin only) --------------------------------
|
||
def _export_excel_template(self) -> None:
|
||
"""Xuất file Excel mẫu để Admin điền danh sách tài khoản rồi nhập lại."""
|
||
if not self._is_admin():
|
||
return
|
||
from ..core import account_excel
|
||
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, tr("accounts.excel_template_btn"), "accounts_template.xlsx",
|
||
"Excel (*.xlsx)")
|
||
if not path:
|
||
return
|
||
try:
|
||
account_excel.export_template(path)
|
||
except Exception as exc: # noqa: BLE001 — a locked/unwritable target file
|
||
QMessageBox.warning(self, tr("accounts.excel_template_btn"), str(exc))
|
||
|
||
def _import_excel(self) -> None:
|
||
"""Nhập tài khoản hàng loạt từ file Excel. Chỉ Admin, và phải đã đặt thư mục
|
||
dùng chung.
|
||
"""
|
||
if not self._is_admin() or not self._shared_dir():
|
||
return
|
||
from ..core import account_excel
|
||
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self, tr("accounts.excel_import_btn"), "", "Excel (*.xlsx)")
|
||
if not path:
|
||
return
|
||
try:
|
||
created, warnings = account_excel.import_accounts(
|
||
path, self._shared_dir(),
|
||
created_by=self.ctx.account.username if self.ctx.account else "")
|
||
except ValueError as exc:
|
||
QMessageBox.warning(self, tr("accounts.excel_import_btn"), str(exc))
|
||
return
|
||
codes_path = account_excel.export_issued_codes(
|
||
created, Path(path).with_name(Path(path).stem + "_codes.xlsx"))
|
||
lines = [tr("accounts.excel_imported", n=len(created))]
|
||
if codes_path is not None:
|
||
lines.append(tr("accounts.excel_codes_saved", path=str(codes_path)))
|
||
lines += [f"• {a.username}: {a.code}" for a in created[:20]]
|
||
if warnings:
|
||
lines.append("")
|
||
lines += warnings[:10]
|
||
QMessageBox.information(self, tr("accounts.excel_import_btn"), "\n".join(lines))
|
||
self.refresh()
|
||
|
||
# ---- move a member between groups (Admin only, drag-and-drop) ---------
|
||
def _on_member_dropped(self, username: str, target_group_id: str) -> None:
|
||
"""Xử lý kéo một người sang nhóm khác: gỡ khỏi nhóm cũ, thêm vào nhóm mới rồi
|
||
lưu cả ba nơi (tài khoản, nhóm cũ, nhóm mới).
|
||
"""
|
||
if not self._is_admin():
|
||
return
|
||
account = accounts.find_by_username(username, self._accounts_dir())
|
||
if account is None:
|
||
return
|
||
old_group_id = account.group_id
|
||
if target_group_id == old_group_id:
|
||
return
|
||
account.group_id = target_group_id
|
||
accounts.save_account(account, self._accounts_dir())
|
||
if old_group_id:
|
||
self._remove_member_from_group(old_group_id, account.username)
|
||
if target_group_id:
|
||
self._add_member_to_group(target_group_id, account.username)
|
||
self.refresh()
|
||
|
||
# ---- group CRUD (Admin only) -------------------------------------------
|
||
def _add_group(self) -> None:
|
||
"""Hỏi tên rồi tạo một nhóm mới. Chỉ Admin."""
|
||
if not self._is_admin():
|
||
return
|
||
name, ok = QInputDialog.getText(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
|
||
if not (ok and name.strip()):
|
||
return
|
||
group = groups.new_group(name.strip())
|
||
groups.save_group(group, self._groups_dir())
|
||
self.refresh()
|
||
|
||
def _add_member_to_group(self, group_id: str, username: str) -> None:
|
||
"""Thêm một người vào danh sách thành viên của nhóm, bỏ qua nếu đã có."""
|
||
g = groups.load_group(group_id, self._groups_dir())
|
||
if g is None:
|
||
return
|
||
if username not in g.member_usernames:
|
||
g.member_usernames.append(username)
|
||
groups.save_group(g, self._groups_dir())
|
||
|
||
def _remove_member_from_group(self, group_id: str, username: str) -> None:
|
||
"""Gỡ một người khỏi nhóm.
|
||
|
||
Người bị gỡ mà đang là Sub-admin của nhóm thì phải xoá luôn vai trò ấy —
|
||
không thì nhóm còn trỏ tới một người không còn ở trong nó.
|
||
"""
|
||
g = groups.load_group(group_id, self._groups_dir())
|
||
if g is None:
|
||
return
|
||
g.member_usernames = [m for m in g.member_usernames if m != username]
|
||
# A departing Sub-admin must not stay listed as the group's subadmin
|
||
# (e.g. when moved to a different group or deleted) — otherwise the
|
||
# group would keep "pointing" at someone no longer in it.
|
||
if g.subadmin_username == username:
|
||
g.subadmin_username = ""
|
||
groups.save_group(g, self._groups_dir())
|
||
|
||
# ---- usage/cost table --------------------------------------------------
|
||
def _pricing(self) -> Dict:
|
||
"""Bảng đơn giá đang dùng: mặc định chồng bởi phần người dùng tự đặt trong cấu
|
||
hình.
|
||
"""
|
||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||
|
||
def _reload_usage_table(self) -> None:
|
||
"""Nạp lại bảng mức dùng theo kỳ đang chọn, gom sự kiện theo từng tài khoản."""
|
||
shared_dir = self._shared_dir()
|
||
period = self.period_combo.currentData() or "day"
|
||
start = date.today() - timedelta(days=_PERIOD_DAYS.get(period, 1) - 1)
|
||
events = telemetry_shared.load_shared_usage_events(shared_dir, start=start)
|
||
by_account: Dict[str, List[dict]] = {}
|
||
for ev in events:
|
||
by_account.setdefault(ev.get("account", ""), []).append(ev)
|
||
|
||
accounts_by_username = {a.username: a for a in self._visible_accounts()}
|
||
pricing = self._pricing()
|
||
rows = []
|
||
for username, acc in accounts_by_username.items():
|
||
acc_events = by_account.get(username, [])
|
||
s = ut.summarize(acc_events)
|
||
costs = ut.cost_usd_events(acc_events, pricing) # honors the per-model price table
|
||
machines = sorted({e.get("machine", "") for e in acc_events if e.get("machine")})
|
||
rows.append((
|
||
acc.display_name or acc.username, acc.username, ", ".join(machines) or "—",
|
||
acc.department or "—", fmt_tokens(s["total"]), ut.format_cost(sum(costs.values()), pricing),
|
||
))
|
||
rows.sort(key=lambda r: r[0].lower())
|
||
self.usage_table.setRowCount(len(rows))
|
||
for row, cells in enumerate(rows):
|
||
for col, text in enumerate(cells):
|
||
self.usage_table.setItem(row, col, QTableWidgetItem(str(text)))
|
||
|
||
# ---- i18n --------------------------------------------------------------
|
||
def _retranslate(self) -> None:
|
||
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn, nút và gợi ý."""
|
||
self._shared_hint.setText(tr("accounts.no_shared_dir"))
|
||
self.add_btn.setText(tr("accounts.add_btn"))
|
||
self.edit_btn.setText(tr("accounts.edit_btn"))
|
||
self.del_btn.setText(tr("accounts.delete_btn"))
|
||
self.code_btn.setText(tr("accounts.generate_code_btn"))
|
||
self.group_btn.setText(tr("accounts.new_group_btn"))
|
||
if self._is_admin():
|
||
self.tree.setToolTip(tr("accounts.drag_move_hint"))
|
||
self.search_edit.setPlaceholderText(tr("accounts.search_placeholder"))
|
||
self.ai_search_btn.setText(tr("accounts.ai_search_btn"))
|
||
self.ai_search_btn.setToolTip(tr("accounts.ai_search_tooltip"))
|
||
self.excel_template_btn.setText(tr("accounts.excel_template_btn"))
|
||
self.excel_import_btn.setText(tr("accounts.excel_import_btn"))
|
||
self._usage_title.setText(tr("accounts.usage_title"))
|
||
self.refresh_btn.setText(tr("monitoring.refresh"))
|
||
self.usage_table.setHorizontalHeaderLabels([
|
||
tr("accounts.col_name"), tr("accounts.col_account"), tr("accounts.col_machine"),
|
||
tr("accounts.col_department"), tr("accounts.col_tokens"), tr("accounts.col_cost"),
|
||
])
|
||
self.refresh()
|