"""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()