Files
cowork-local/ui/accounts_tab.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
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>
2026-08-17 11:38:18 +09:00

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