Skip to content

Commit ff44037

Browse files
ebuzerdrmz44m3nu
andauthored
Add ArchiveTableModel (QAbstractTableModel) (#2517)
* Add ArchiveTableModel (QAbstractTableModel) spike Self-contained table model for archive rows: time/size/duration formatting, trigger icons, sort-role keys, and an editable name column, with isolated unit tests. Not yet wired into ArchiveTab. * Address spike review: ArchiveRole accessor, theme-aware icon cache, restore Trigger header --------- Co-authored-by: Manu <3916435+m3nu@users.noreply.github.com>
1 parent 450f944 commit ff44037

2 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""
2+
Qt table model exposing `ArchiveModel` rows to the ArchiveTab `QTableView`.
3+
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from datetime import timedelta
9+
from typing import Any, Dict, List, Optional
10+
11+
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QObject, Qt
12+
13+
from vorta.i18n import trans_late, translate
14+
from vorta.store.models import ArchiveModel
15+
from vorta.utils import find_best_unit_for_sizes, pretty_bytes, uses_dark_mode
16+
from vorta.views.utils import get_colored_icon
17+
18+
#: Decimal digits shown in the size column.
19+
SIZE_DECIMAL_DIGITS = 1
20+
21+
22+
class ArchiveTableModel(QAbstractTableModel):
23+
"""Table model for backup archive rows; the name column is editable for in-place rename."""
24+
25+
# Column indices in render order (matching the legacy QTableWidget layout).
26+
COL_TIME = 0
27+
COL_SIZE = 1
28+
COL_DURATION = 2
29+
COL_MOUNT = 3
30+
COL_NAME = 4
31+
COL_TRIGGER = 5
32+
33+
#: Role returning native, comparable values for sorting via a proxy model.
34+
SortRole = Qt.ItemDataRole.UserRole
35+
#: Role returning the backing ArchiveModel; auto-mapped through the sort proxy.
36+
ArchiveRole = Qt.ItemDataRole.UserRole + 1
37+
38+
_HEADERS = (
39+
trans_late('Form', 'Date'),
40+
trans_late('Form', 'Size'),
41+
trans_late('Form', 'Duration'),
42+
trans_late('Form', 'Mount Point'),
43+
trans_late('Form', 'Name'),
44+
trans_late('Form', 'Trigger'),
45+
)
46+
47+
_TRIGGER_ICONS = {'scheduled': 'clock-o', 'user': 'user'}
48+
49+
def __init__(self, parent: Optional[QObject] = None):
50+
"""Init."""
51+
super().__init__(parent)
52+
self._rows: List[ArchiveModel] = []
53+
self._mount_points: Dict[str, str] = {}
54+
self._fixed_unit: Optional[int] = None
55+
self._icon_cache: Dict[str, Any] = {} # themed icons; invalidated on dark-mode switch
56+
self._icon_cache_dark: Optional[bool] = None
57+
58+
def set_rows(
59+
self,
60+
rows: List[ArchiveModel],
61+
mount_points: Optional[Dict[str, str]] = None,
62+
use_fixed_units: bool = False,
63+
) -> None:
64+
"""Replace the model contents and notify attached views.
65+
66+
``mount_points`` maps archive name to its current mount path; ``use_fixed_units``
67+
(injected by the view) pins all rows to one shared size unit.
68+
"""
69+
self.beginResetModel()
70+
self._rows = list(rows)
71+
self._mount_points = dict(mount_points or {})
72+
if use_fixed_units:
73+
self._fixed_unit = find_best_unit_for_sizes((r.size for r in self._rows), precision=SIZE_DECIMAL_DIGITS)
74+
else:
75+
self._fixed_unit = None
76+
self.endResetModel()
77+
78+
def archive_at(self, row: int) -> Optional[ArchiveModel]:
79+
"""Return the `ArchiveModel` backing ``row``, or None if out of range."""
80+
if 0 <= row < len(self._rows):
81+
return self._rows[row]
82+
return None
83+
84+
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
85+
if parent.isValid():
86+
return 0
87+
return len(self._rows)
88+
89+
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
90+
if parent.isValid():
91+
return 0
92+
return len(self._HEADERS)
93+
94+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
95+
if not index.isValid():
96+
return None
97+
98+
row = self._rows[index.row()]
99+
column = index.column()
100+
101+
if role == Qt.ItemDataRole.DisplayRole:
102+
return self._display_data(row, column)
103+
if role == Qt.ItemDataRole.EditRole and column == self.COL_NAME:
104+
return row.name # pre-fill the rename editor with the current name
105+
if role == self.SortRole:
106+
return self._sort_data(row, column)
107+
if role == self.ArchiveRole:
108+
return row # proxy-safe accessor: the proxy forwards data() through mapToSource
109+
if role == Qt.ItemDataRole.DecorationRole and column == self.COL_TRIGGER:
110+
return self._trigger_icon(row)
111+
if role == Qt.ItemDataRole.ToolTipRole and column == self.COL_TRIGGER:
112+
return self._trigger_tooltip(row)
113+
return None
114+
115+
def _display_data(self, row: ArchiveModel, column: int) -> Any:
116+
if column == self.COL_TIME:
117+
return row.time.strftime('%Y-%m-%d %H:%M')
118+
if column == self.COL_SIZE:
119+
return pretty_bytes(row.size, fixed_unit=self._fixed_unit, precision=SIZE_DECIMAL_DIGITS)
120+
if column == self.COL_DURATION:
121+
if row.duration is None:
122+
return ''
123+
return str(timedelta(seconds=round(row.duration)))
124+
if column == self.COL_MOUNT:
125+
return self._mount_points.get(row.name, '')
126+
if column == self.COL_NAME:
127+
return row.name
128+
return None # trigger column is icon-only
129+
130+
def _sort_data(self, row: ArchiveModel, column: int) -> Any:
131+
"""Native comparable value used by the sort proxy."""
132+
if column == self.COL_TIME:
133+
return row.time
134+
if column == self.COL_SIZE:
135+
return row.size or 0
136+
if column == self.COL_DURATION:
137+
return row.duration or 0
138+
if column == self.COL_MOUNT:
139+
return self._mount_points.get(row.name, '')
140+
if column == self.COL_NAME:
141+
return row.name
142+
if column == self.COL_TRIGGER:
143+
return row.trigger or ''
144+
return None
145+
146+
def _trigger_icon(self, row: ArchiveModel) -> Any:
147+
icon_name = self._TRIGGER_ICONS.get(row.trigger)
148+
if icon_name is None:
149+
return None
150+
dark = uses_dark_mode()
151+
if dark != self._icon_cache_dark: # theme switched: themed icons are stale
152+
self._icon_cache.clear()
153+
self._icon_cache_dark = dark
154+
if icon_name not in self._icon_cache:
155+
self._icon_cache[icon_name] = get_colored_icon(icon_name)
156+
return self._icon_cache[icon_name]
157+
158+
def _trigger_tooltip(self, row: ArchiveModel) -> Any:
159+
if row.trigger == 'scheduled':
160+
return translate('ArchiveTab', 'Scheduled')
161+
if row.trigger == 'user':
162+
return translate('ArchiveTab', 'User initiated')
163+
return None
164+
165+
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
166+
flags = super().flags(index)
167+
if index.isValid() and index.column() == self.COL_NAME:
168+
flags |= Qt.ItemFlag.ItemIsEditable
169+
return flags
170+
171+
def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool:
172+
"""Store an in-place edited archive name; the view performs the real rename."""
173+
if not index.isValid() or role != Qt.ItemDataRole.EditRole or index.column() != self.COL_NAME:
174+
return False
175+
self._rows[index.row()].name = value
176+
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole])
177+
return True
178+
179+
def headerData(
180+
self,
181+
section: int,
182+
orientation: Qt.Orientation,
183+
role: int = Qt.ItemDataRole.DisplayRole,
184+
) -> Any:
185+
if role != Qt.ItemDataRole.DisplayRole:
186+
return None
187+
if orientation == Qt.Orientation.Horizontal and 0 <= section < len(self._HEADERS):
188+
return translate('Form', self._HEADERS[section])
189+
return None
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
from datetime import datetime as dt
2+
3+
from PyQt6.QtCore import QModelIndex, Qt
4+
5+
from vorta.store.models import ArchiveModel
6+
from vorta.views.partials.archive_table_model import ArchiveTableModel
7+
8+
9+
def _archive(name, time, size=1000, duration=60, trigger='user'):
10+
"""Build an in-memory ArchiveModel (not persisted) for feeding the model."""
11+
return ArchiveModel(name=name, time=time, size=size, duration=duration, trigger=trigger, snapshot_id='x')
12+
13+
14+
def test_model_is_empty_before_set_rows():
15+
"""Column count is intrinsic; row count starts at zero."""
16+
model = ArchiveTableModel()
17+
assert model.rowCount() == 0
18+
assert model.columnCount() == 6
19+
20+
21+
def test_set_rows_populates_and_preserves_order():
22+
"""`set_rows` replaces the contents and keeps the given row order."""
23+
model = ArchiveTableModel()
24+
model.set_rows(
25+
[
26+
_archive('a', dt(2024, 1, 3)),
27+
_archive('b', dt(2024, 1, 1)),
28+
_archive('c', dt(2024, 1, 2)),
29+
]
30+
)
31+
32+
assert model.rowCount() == 3
33+
names = [model.data(model.index(r, ArchiveTableModel.COL_NAME)) for r in range(3)]
34+
assert names == ['a', 'b', 'c']
35+
36+
37+
def test_display_data_formats_each_column():
38+
"""`data()` formats time/size/duration and exposes name; trigger is icon-only."""
39+
model = ArchiveTableModel()
40+
model.set_rows([_archive('my-archive', dt(2024, 1, 15, 10, 30), size=1500, duration=90, trigger='user')])
41+
42+
def cell(column):
43+
return model.data(model.index(0, column), Qt.ItemDataRole.DisplayRole)
44+
45+
assert cell(ArchiveTableModel.COL_TIME) == '2024-01-15 10:30'
46+
assert cell(ArchiveTableModel.COL_SIZE) == '1.5 KB'
47+
assert cell(ArchiveTableModel.COL_DURATION) == '0:01:30'
48+
assert cell(ArchiveTableModel.COL_MOUNT) == ''
49+
assert cell(ArchiveTableModel.COL_NAME) == 'my-archive'
50+
assert cell(ArchiveTableModel.COL_TRIGGER) is None
51+
52+
53+
def test_mount_point_is_shown_when_present():
54+
"""The mount column reflects the mount_points map passed to set_rows."""
55+
model = ArchiveTableModel()
56+
model.set_rows([_archive('m', dt(2024, 1, 1))], mount_points={'m': '/mnt/x'})
57+
assert model.data(model.index(0, ArchiveTableModel.COL_MOUNT)) == '/mnt/x'
58+
59+
60+
def test_duration_none_renders_empty():
61+
"""A null duration renders as an empty string."""
62+
model = ArchiveTableModel()
63+
model.set_rows([_archive('d', dt(2024, 1, 1), duration=None)])
64+
assert model.data(model.index(0, ArchiveTableModel.COL_DURATION)) == ''
65+
66+
67+
def test_fixed_units_share_one_unit_across_rows():
68+
"""With use_fixed_units, every row uses the same unit; dynamic sizes each independently."""
69+
model = ArchiveTableModel()
70+
rows = [_archive('small', dt(2024, 1, 1), size=2000), _archive('big', dt(2024, 1, 2), size=5_000_000_000)]
71+
72+
model.set_rows(rows, use_fixed_units=True)
73+
fixed = [model.data(model.index(r, ArchiveTableModel.COL_SIZE)) for r in range(2)]
74+
assert fixed == ['2.0 KB', '5000000.0 KB'] # both rows pinned to the same shared unit
75+
76+
model.set_rows(rows, use_fixed_units=False)
77+
assert model.data(model.index(1, ArchiveTableModel.COL_SIZE)) == '5.0 GB' # dynamic: own best unit
78+
79+
80+
def test_sort_role_returns_native_comparable_values():
81+
"""SortRole yields raw values so columns sort numerically/chronologically, not by text."""
82+
model = ArchiveTableModel()
83+
t = dt(2024, 1, 15, 10, 30)
84+
model.set_rows([_archive('z-name', t, size=2048, duration=12.5, trigger='scheduled')])
85+
86+
def sort_value(column):
87+
return model.data(model.index(0, column), ArchiveTableModel.SortRole)
88+
89+
assert sort_value(ArchiveTableModel.COL_TIME) == t
90+
assert sort_value(ArchiveTableModel.COL_SIZE) == 2048
91+
assert sort_value(ArchiveTableModel.COL_DURATION) == 12.5
92+
assert sort_value(ArchiveTableModel.COL_NAME) == 'z-name'
93+
assert sort_value(ArchiveTableModel.COL_TRIGGER) == 'scheduled'
94+
95+
96+
def test_sort_role_orders_sizes_numerically():
97+
"""Raw size sort keys order correctly where display strings would not."""
98+
model = ArchiveTableModel()
99+
model.set_rows([_archive('big', dt(2024, 1, 1), size=2048), _archive('small', dt(2024, 1, 2), size=999)])
100+
101+
keys = [model.data(model.index(r, ArchiveTableModel.COL_SIZE), ArchiveTableModel.SortRole) for r in range(2)]
102+
assert keys == [2048, 999]
103+
assert keys[1] < keys[0] # 999 < 2048, even though '999 B' > '2.0 kB' as text
104+
105+
106+
def test_only_name_column_is_editable():
107+
"""The name column is editable; the others are not."""
108+
model = ArchiveTableModel()
109+
model.set_rows([_archive('n', dt(2024, 1, 1))])
110+
111+
name_flags = model.flags(model.index(0, ArchiveTableModel.COL_NAME))
112+
time_flags = model.flags(model.index(0, ArchiveTableModel.COL_TIME))
113+
assert name_flags & Qt.ItemFlag.ItemIsEditable
114+
assert not (time_flags & Qt.ItemFlag.ItemIsEditable)
115+
116+
117+
def test_set_data_updates_name_and_emits_datachanged():
118+
"""Editing the name updates the backing row and notifies views."""
119+
model = ArchiveTableModel()
120+
model.set_rows([_archive('old', dt(2024, 1, 1))])
121+
122+
received = []
123+
model.dataChanged.connect(lambda tl, br, roles=[]: received.append((tl.row(), tl.column())))
124+
125+
assert model.setData(model.index(0, ArchiveTableModel.COL_NAME), 'new') is True
126+
assert model.archive_at(0).name == 'new'
127+
assert received == [(0, ArchiveTableModel.COL_NAME)]
128+
129+
130+
def test_set_data_rejects_non_name_column_or_wrong_role():
131+
"""setData only accepts EditRole writes to the name column."""
132+
model = ArchiveTableModel()
133+
model.set_rows([_archive('x', dt(2024, 1, 1))])
134+
135+
assert model.setData(model.index(0, ArchiveTableModel.COL_TIME), 'y') is False
136+
assert model.setData(model.index(0, ArchiveTableModel.COL_NAME), 'y', Qt.ItemDataRole.DisplayRole) is False
137+
138+
139+
def test_archive_role_returns_backing_object():
140+
"""ArchiveRole exposes the ArchiveModel so callers read it through the (auto-mapping) proxy."""
141+
model = ArchiveTableModel()
142+
only = _archive('only', dt(2024, 1, 1))
143+
model.set_rows([only])
144+
145+
for column in range(model.columnCount()):
146+
assert model.data(model.index(0, column), ArchiveTableModel.ArchiveRole) is only
147+
148+
149+
def test_archive_at_returns_object_or_none():
150+
"""archive_at returns the backing ArchiveModel and None when out of range."""
151+
model = ArchiveTableModel()
152+
only = _archive('only', dt(2024, 1, 1))
153+
model.set_rows([only])
154+
155+
assert model.archive_at(0) is only
156+
assert model.archive_at(5) is None
157+
assert model.archive_at(-1) is None
158+
159+
160+
def test_trigger_icon_and_tooltip():
161+
"""The trigger column exposes an icon and tooltip per trigger kind."""
162+
model = ArchiveTableModel()
163+
model.set_rows(
164+
[
165+
_archive('s', dt(2024, 1, 1), trigger='scheduled'),
166+
_archive('u', dt(2024, 1, 2), trigger='user'),
167+
_archive('n', dt(2024, 1, 3), trigger=None),
168+
]
169+
)
170+
171+
def deco(row):
172+
return model.data(model.index(row, ArchiveTableModel.COL_TRIGGER), Qt.ItemDataRole.DecorationRole)
173+
174+
def tooltip(row):
175+
return model.data(model.index(row, ArchiveTableModel.COL_TRIGGER), Qt.ItemDataRole.ToolTipRole)
176+
177+
assert deco(0) is not None and deco(1) is not None
178+
assert deco(2) is None
179+
assert tooltip(0) == 'Scheduled'
180+
assert tooltip(1) == 'User initiated'
181+
assert tooltip(2) is None
182+
183+
184+
def test_header_data_returns_column_labels():
185+
"""Horizontal headers expose the canonical labels, matching the legacy .ui."""
186+
model = ArchiveTableModel()
187+
assert (
188+
model.headerData(ArchiveTableModel.COL_TIME, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole) == 'Date'
189+
)
190+
assert (
191+
model.headerData(ArchiveTableModel.COL_NAME, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole) == 'Name'
192+
)
193+
assert (
194+
model.headerData(ArchiveTableModel.COL_TRIGGER, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
195+
== 'Trigger'
196+
)
197+
assert model.headerData(0, Qt.Orientation.Vertical, Qt.ItemDataRole.DisplayRole) is None
198+
199+
200+
def test_data_returns_none_for_invalid_index_or_unsupported_role():
201+
"""Invalid indices and unhandled roles yield None."""
202+
model = ArchiveTableModel()
203+
model.set_rows([_archive('x', dt(2024, 1, 1))])
204+
205+
assert model.data(QModelIndex()) is None
206+
assert model.data(model.index(0, ArchiveTableModel.COL_TIME), Qt.ItemDataRole.EditRole) is None

0 commit comments

Comments
 (0)