CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.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()
|