The Gitea repo was initialised from an earlier snapshot, so main and the machine this runs on had drifted apart in 153 files before any UI work started. This commit brings the branch up to the local tree as it stood on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows shows up as its own reviewable diff instead of being mixed in with the pre-existing divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
701 lines
32 KiB
Python
701 lines
32 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):
|
|
super().__init__()
|
|
self.setDropIndicatorShown(True)
|
|
self.setDragDropMode(QTreeWidget.DragDrop)
|
|
|
|
def set_drag_enabled(self, enabled: bool) -> None:
|
|
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:
|
|
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:
|
|
if self.dragEnabled() and self._drop_is_valid(event):
|
|
event.acceptProposedAction()
|
|
else:
|
|
event.ignore()
|
|
|
|
def dragMoveEvent(self, event) -> None:
|
|
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 = ""):
|
|
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]:
|
|
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):
|
|
def __init__(self, ctx: AppContext):
|
|
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:
|
|
return self.ctx.config.shared_dir
|
|
|
|
def _accounts_dir(self):
|
|
return accounts.accounts_dir(self._shared_dir())
|
|
|
|
def _groups_dir(self):
|
|
return groups.groups_dir(self._shared_dir())
|
|
|
|
def _is_admin(self) -> bool:
|
|
return self.ctx.role == "admin"
|
|
|
|
def _my_group(self) -> Optional[groups.Group]:
|
|
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]:
|
|
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]:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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]:
|
|
if not self._current_username:
|
|
return None
|
|
return accounts.find_by_username(self._current_username, self._accounts_dir())
|
|
|
|
def _edit_account(self) -> None:
|
|
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:
|
|
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:
|
|
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):
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 = 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:
|
|
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
|
|
|
def _reload_usage_table(self) -> None:
|
|
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:
|
|
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()
|