zeed-movs-viewer 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- movsviewer/__init__.py +0 -0
- movsviewer/automation.py +131 -0
- movsviewer/chartview.py +456 -0
- movsviewer/constants.py +16 -0
- movsviewer/mainui.py +168 -0
- movsviewer/reader.py +42 -0
- movsviewer/resources/geckodriver.exe +0 -0
- movsviewer/resources/mainui.ui +57 -0
- movsviewer/resources/settingsui.ui +129 -0
- movsviewer/settings.py +51 -0
- movsviewer/validator.py +78 -0
- movsviewer/viewmodel.py +181 -0
- zeed_movs_viewer-0.0.0.dist-info/METADATA +15 -0
- zeed_movs_viewer-0.0.0.dist-info/RECORD +16 -0
- zeed_movs_viewer-0.0.0.dist-info/WHEEL +4 -0
- zeed_movs_viewer-0.0.0.dist-info/entry_points.txt +5 -0
movsviewer/__init__.py
ADDED
File without changes
|
movsviewer/automation.py
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
from contextlib import contextmanager
|
2
|
+
from logging import info
|
3
|
+
from os import listdir
|
4
|
+
from pathlib import Path
|
5
|
+
from tempfile import TemporaryDirectory
|
6
|
+
from typing import TYPE_CHECKING
|
7
|
+
|
8
|
+
from selenium.webdriver import Firefox
|
9
|
+
from selenium.webdriver.common.by import By
|
10
|
+
from selenium.webdriver.common.keys import Keys
|
11
|
+
from selenium.webdriver.firefox.firefox_profile import FirefoxProfile
|
12
|
+
from selenium.webdriver.firefox.options import Options
|
13
|
+
from selenium.webdriver.firefox.service import Service
|
14
|
+
from selenium.webdriver.remote.webelement import WebElement
|
15
|
+
from selenium.webdriver.support.expected_conditions import all_of
|
16
|
+
from selenium.webdriver.support.expected_conditions import (
|
17
|
+
element_to_be_clickable,
|
18
|
+
)
|
19
|
+
from selenium.webdriver.support.expected_conditions import (
|
20
|
+
invisibility_of_element,
|
21
|
+
)
|
22
|
+
from selenium.webdriver.support.expected_conditions import (
|
23
|
+
presence_of_element_located,
|
24
|
+
)
|
25
|
+
from selenium.webdriver.support.expected_conditions import url_contains
|
26
|
+
from selenium.webdriver.support.select import Select
|
27
|
+
from selenium.webdriver.support.wait import WebDriverWait
|
28
|
+
|
29
|
+
from movsviewer.constants import GECKODRIVER_PATH
|
30
|
+
|
31
|
+
if TYPE_CHECKING:
|
32
|
+
from collections.abc import Callable
|
33
|
+
from collections.abc import Iterator
|
34
|
+
|
35
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
36
|
+
|
37
|
+
|
38
|
+
def get_options(dtemp: str) -> Options:
|
39
|
+
options = Options()
|
40
|
+
|
41
|
+
options.profile = FirefoxProfile()
|
42
|
+
# set download folder
|
43
|
+
options.profile.set_preference('browser.download.folderList', 2)
|
44
|
+
options.profile.set_preference('browser.download.dir', dtemp)
|
45
|
+
|
46
|
+
return options
|
47
|
+
|
48
|
+
|
49
|
+
def _w(
|
50
|
+
wait: 'WebDriverWait[WebDriver]',
|
51
|
+
condition: """Callable[[tuple[str, str]],
|
52
|
+
Callable[[WebDriver], bool | WebElement]]""",
|
53
|
+
css_selector: str,
|
54
|
+
) -> WebElement:
|
55
|
+
ret = wait.until(condition((By.CSS_SELECTOR, css_selector)))
|
56
|
+
if not isinstance(ret, WebElement):
|
57
|
+
raise TypeError
|
58
|
+
return ret
|
59
|
+
|
60
|
+
|
61
|
+
def _c(wait: 'WebDriverWait[WebDriver]', css_selector: str) -> WebElement:
|
62
|
+
return _w(wait, element_to_be_clickable, css_selector)
|
63
|
+
|
64
|
+
|
65
|
+
def _p(wait: 'WebDriverWait[WebDriver]', css_selector: str) -> WebElement:
|
66
|
+
return _w(wait, presence_of_element_located, css_selector)
|
67
|
+
|
68
|
+
|
69
|
+
def _i(wait: 'WebDriverWait[WebDriver]', css_selector: str) -> WebElement:
|
70
|
+
return _w(wait, invisibility_of_element, css_selector)
|
71
|
+
|
72
|
+
|
73
|
+
def pl(wait: 'WebDriverWait[WebDriver]', wd: 'WebDriver') -> None:
|
74
|
+
_p(wait, '.pageLoader')
|
75
|
+
founds = wd.find_elements(By.CSS_SELECTOR, '.pageLoader')
|
76
|
+
wait.until(all_of(*(invisibility_of_element(found) for found in founds)))
|
77
|
+
|
78
|
+
|
79
|
+
HP = 'https://bancoposta.poste.it/bpol/public/BPOL_ListaMovimentiAPP/index.html'
|
80
|
+
|
81
|
+
|
82
|
+
@contextmanager
|
83
|
+
def get_movimenti(
|
84
|
+
username: str, password: str, num_conto: str, get_otp: 'Callable[[], str]'
|
85
|
+
) -> 'Iterator[Path]':
|
86
|
+
with (
|
87
|
+
TemporaryDirectory() as dtemp,
|
88
|
+
Firefox(
|
89
|
+
service=Service(executable_path=str(GECKODRIVER_PATH)),
|
90
|
+
options=get_options(dtemp),
|
91
|
+
) as wd
|
92
|
+
): # fmt: skip
|
93
|
+
wait = WebDriverWait(wd, 1000)
|
94
|
+
# login
|
95
|
+
wd.get(HP)
|
96
|
+
pl(wait, wd)
|
97
|
+
wd.find_element(By.CSS_SELECTOR, '#username').send_keys(username)
|
98
|
+
wd.find_element(By.CSS_SELECTOR, '#password').send_keys(
|
99
|
+
password + Keys.RETURN
|
100
|
+
)
|
101
|
+
wait.until(
|
102
|
+
url_contains(
|
103
|
+
'https://idp-poste.poste.it/jod-idp-retail/cas/app.html'
|
104
|
+
)
|
105
|
+
)
|
106
|
+
pl(wait, wd)
|
107
|
+
_c(wait, '#_prosegui').click()
|
108
|
+
otp = get_otp()
|
109
|
+
wd.find_element(By.CSS_SELECTOR, '#otp').send_keys(otp + Keys.RETURN)
|
110
|
+
|
111
|
+
# choose conto and download text
|
112
|
+
_p(wait, f'select.numconto>option[value="string:{num_conto}"]')
|
113
|
+
pl(wait, wd)
|
114
|
+
Select(_p(wait, 'select.numconto')).select_by_value(
|
115
|
+
f'string:{num_conto}'
|
116
|
+
)
|
117
|
+
|
118
|
+
# hide cookie banner
|
119
|
+
wd.execute_script(
|
120
|
+
'document.querySelector("#content-alert-cookie")'
|
121
|
+
'.style.display="none"'
|
122
|
+
)
|
123
|
+
_c(wait, '#select>option[value=TESTO]')
|
124
|
+
Select(_p(wait, '#select')).select_by_value('TESTO')
|
125
|
+
|
126
|
+
info('prima: %s', listdir(dtemp))
|
127
|
+
_c(wait, '#downloadApi').click()
|
128
|
+
_i(wait, '.waiting')
|
129
|
+
info('dopo: %s', listdir(dtemp))
|
130
|
+
|
131
|
+
yield Path(dtemp) / 'ListaMovimenti.txt'
|
movsviewer/chartview.py
ADDED
@@ -0,0 +1,456 @@
|
|
1
|
+
from abc import abstractmethod
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from datetime import UTC
|
4
|
+
from datetime import date
|
5
|
+
from datetime import datetime
|
6
|
+
from datetime import time
|
7
|
+
from decimal import Decimal
|
8
|
+
from itertools import accumulate
|
9
|
+
from itertools import chain
|
10
|
+
from itertools import groupby
|
11
|
+
from typing import TYPE_CHECKING
|
12
|
+
from typing import NamedTuple
|
13
|
+
from typing import Self
|
14
|
+
from typing import cast
|
15
|
+
from typing import override
|
16
|
+
|
17
|
+
from guilib.chartwidget.chartwidget import ChartWidget
|
18
|
+
from guilib.chartwidget.modelgui import SeriesModel
|
19
|
+
from guilib.chartwidget.modelgui import SeriesModelUnit
|
20
|
+
from guilib.chartwidget.viewmodel import SortFilterViewModel
|
21
|
+
from guilib.dates.converters import date2days
|
22
|
+
from guilib.dates.converters import date2QDateTime
|
23
|
+
from movslib.model import ZERO
|
24
|
+
from PySide6.QtCharts import QBarCategoryAxis
|
25
|
+
from PySide6.QtCharts import QBarSeries
|
26
|
+
from PySide6.QtCharts import QBarSet
|
27
|
+
from PySide6.QtCharts import QCategoryAxis
|
28
|
+
from PySide6.QtCharts import QChart
|
29
|
+
from PySide6.QtCharts import QLineSeries
|
30
|
+
from PySide6.QtCharts import QValueAxis
|
31
|
+
from PySide6.QtCore import Qt
|
32
|
+
|
33
|
+
from movsviewer.reader import read
|
34
|
+
|
35
|
+
if TYPE_CHECKING:
|
36
|
+
from collections.abc import Iterable
|
37
|
+
from collections.abc import Sequence
|
38
|
+
|
39
|
+
from guilib.chartwidget.model import Column
|
40
|
+
from guilib.chartwidget.model import ColumnHeader
|
41
|
+
from guilib.chartwidget.model import Info
|
42
|
+
from movslib.model import Row
|
43
|
+
from PySide6.QtWidgets import QGraphicsSceneMouseEvent
|
44
|
+
from PySide6.QtWidgets import QGraphicsSceneWheelEvent
|
45
|
+
|
46
|
+
|
47
|
+
class Point(NamedTuple):
|
48
|
+
data: date
|
49
|
+
mov: Decimal
|
50
|
+
|
51
|
+
|
52
|
+
def to_point(row: 'Row') -> Point:
|
53
|
+
if row.accrediti is not None:
|
54
|
+
mov = row.accrediti
|
55
|
+
elif row.addebiti is not None:
|
56
|
+
mov = -row.addebiti
|
57
|
+
else:
|
58
|
+
mov = ZERO
|
59
|
+
return Point(row.date, mov)
|
60
|
+
|
61
|
+
|
62
|
+
def build_series(
|
63
|
+
data: 'Sequence[Row]', epoch: date = date(2008, 1, 1)
|
64
|
+
) -> QLineSeries:
|
65
|
+
data = sorted(data, key=lambda row: row.date)
|
66
|
+
|
67
|
+
series = QLineSeries()
|
68
|
+
series.setName('data')
|
69
|
+
|
70
|
+
# add start and today
|
71
|
+
moves = chain(
|
72
|
+
(Point(epoch, ZERO),),
|
73
|
+
map(to_point, data),
|
74
|
+
(Point(datetime.now(tz=UTC).date(), ZERO),),
|
75
|
+
)
|
76
|
+
|
77
|
+
def sumy(a: Point, b: Point) -> Point:
|
78
|
+
return Point(b.data, a.mov + b.mov)
|
79
|
+
|
80
|
+
summes = accumulate(moves, func=sumy)
|
81
|
+
|
82
|
+
floats = (
|
83
|
+
(datetime.combine(data, time()).timestamp() * 1000, mov)
|
84
|
+
for data, mov in summes
|
85
|
+
)
|
86
|
+
|
87
|
+
# step the movements
|
88
|
+
last_mov: Decimal | None = None
|
89
|
+
for ts, mov in floats:
|
90
|
+
if last_mov is not None:
|
91
|
+
series.append(ts, float(last_mov))
|
92
|
+
series.append(ts, float(mov))
|
93
|
+
last_mov = mov
|
94
|
+
|
95
|
+
return series
|
96
|
+
|
97
|
+
|
98
|
+
def build_group_by_year_series(
|
99
|
+
data: 'Sequence[Row]',
|
100
|
+
) -> tuple[QBarSeries, QBarCategoryAxis]:
|
101
|
+
data = sorted(data, key=lambda row: row.date)
|
102
|
+
|
103
|
+
axis_x = QBarCategoryAxis()
|
104
|
+
|
105
|
+
series = QBarSeries()
|
106
|
+
barset = QBarSet('group by year')
|
107
|
+
series.append(barset)
|
108
|
+
|
109
|
+
def sum_points(points: 'Iterable[Point]') -> Decimal:
|
110
|
+
return sum((point.mov for point in points), start=ZERO)
|
111
|
+
|
112
|
+
years = []
|
113
|
+
for year, points in groupby(
|
114
|
+
map(to_point, data), lambda point: point.data.year
|
115
|
+
):
|
116
|
+
barset.append(float(sum_points(points)))
|
117
|
+
years.append(f'{year}')
|
118
|
+
axis_x.setCategories(years)
|
119
|
+
|
120
|
+
return series, axis_x
|
121
|
+
|
122
|
+
|
123
|
+
def build_group_by_month_series(
|
124
|
+
data: 'Sequence[Row]',
|
125
|
+
) -> tuple[QBarSeries, QBarCategoryAxis]:
|
126
|
+
data = sorted(data, key=lambda row: row.date)
|
127
|
+
|
128
|
+
axis_x = QBarCategoryAxis()
|
129
|
+
|
130
|
+
series = QBarSeries()
|
131
|
+
barset = QBarSet('group by month')
|
132
|
+
series.append(barset)
|
133
|
+
|
134
|
+
def sum_points(points: 'Iterable[Point]') -> Decimal:
|
135
|
+
return sum((point.mov for point in points), start=ZERO)
|
136
|
+
|
137
|
+
year_months = []
|
138
|
+
for (year, month), points in groupby(
|
139
|
+
map(to_point, data), lambda point: (point.data.year, point.data.month)
|
140
|
+
):
|
141
|
+
barset.append(float(sum_points(points)))
|
142
|
+
year_months.append(f'{year}-{month}')
|
143
|
+
axis_x.setCategories(year_months)
|
144
|
+
|
145
|
+
return series, axis_x
|
146
|
+
|
147
|
+
|
148
|
+
class Chart(QChart):
|
149
|
+
def __init__(self, data: 'Sequence[Row]') -> None:
|
150
|
+
super().__init__()
|
151
|
+
|
152
|
+
def years(data: 'Sequence[Row]') -> list[date]:
|
153
|
+
if not data:
|
154
|
+
return []
|
155
|
+
data = sorted(data, key=lambda row: row.date)
|
156
|
+
start = data[0].date.year - 1
|
157
|
+
end = data[-1].date.year + 1
|
158
|
+
return [date(year, 1, 1) for year in range(start, end + 1)]
|
159
|
+
|
160
|
+
def months(data: 'Sequence[Row]', step: int = 1) -> list[date]:
|
161
|
+
if not data:
|
162
|
+
return []
|
163
|
+
data = sorted(data, key=lambda row: row.date)
|
164
|
+
start = data[0].date.year - 1
|
165
|
+
end = data[-1].date.year + 1
|
166
|
+
return [
|
167
|
+
date(year, month, 1)
|
168
|
+
for year in range(start, end + 1)
|
169
|
+
for month in range(1, 13, step)
|
170
|
+
]
|
171
|
+
|
172
|
+
def reset_axis_x_labels() -> None:
|
173
|
+
if True:
|
174
|
+
pass
|
175
|
+
|
176
|
+
def ts(d: date) -> float:
|
177
|
+
return (
|
178
|
+
datetime(d.year, d.month, d.day, tzinfo=UTC).timestamp() * 1000
|
179
|
+
)
|
180
|
+
|
181
|
+
axis_y = QValueAxis()
|
182
|
+
axis_y.setTickType(QValueAxis.TickType.TicksDynamic)
|
183
|
+
axis_y.setTickAnchor(0.0)
|
184
|
+
axis_y.setTickInterval(10000.0)
|
185
|
+
axis_y.setMinorTickCount(9)
|
186
|
+
self.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft)
|
187
|
+
|
188
|
+
axis_x = QCategoryAxis()
|
189
|
+
axis_x.setLabelsPosition(
|
190
|
+
QCategoryAxis.AxisLabelsPosition.AxisLabelsPositionOnValue
|
191
|
+
)
|
192
|
+
for dt in months(data, 6):
|
193
|
+
axis_x.append(f'{dt}', ts(dt))
|
194
|
+
self.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom)
|
195
|
+
|
196
|
+
series = build_series(data)
|
197
|
+
self.addSeries(series)
|
198
|
+
series.attachAxis(axis_x)
|
199
|
+
series.attachAxis(axis_y)
|
200
|
+
|
201
|
+
group_by_year_series, axis_x_years = build_group_by_year_series(data)
|
202
|
+
self.addSeries(group_by_year_series)
|
203
|
+
self.addAxis(axis_x_years, Qt.AlignmentFlag.AlignBottom)
|
204
|
+
group_by_year_series.attachAxis(axis_y)
|
205
|
+
|
206
|
+
group_by_month_series, axis_x_months = build_group_by_month_series(data)
|
207
|
+
self.addSeries(group_by_month_series)
|
208
|
+
self.addAxis(axis_x_months, Qt.AlignmentFlag.AlignBottom)
|
209
|
+
group_by_month_series.attachAxis(axis_y)
|
210
|
+
|
211
|
+
@override
|
212
|
+
def wheelEvent(self, event: 'QGraphicsSceneWheelEvent') -> None:
|
213
|
+
super().wheelEvent(event)
|
214
|
+
y = event.delta()
|
215
|
+
if y < 0:
|
216
|
+
self.zoom(0.75) # zoomOut is ~ .5
|
217
|
+
elif y > 0:
|
218
|
+
self.zoomIn()
|
219
|
+
|
220
|
+
@override
|
221
|
+
def mousePressEvent(self, event: 'QGraphicsSceneMouseEvent') -> None:
|
222
|
+
super().mousePressEvent(event)
|
223
|
+
event.accept()
|
224
|
+
|
225
|
+
@override
|
226
|
+
def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent') -> None:
|
227
|
+
super().mouseMoveEvent(event)
|
228
|
+
|
229
|
+
x_curr, y_curr = cast(tuple[float, float], event.pos().toTuple())
|
230
|
+
x_prev, y_prev = cast(tuple[float, float], event.lastPos().toTuple())
|
231
|
+
self.scroll(x_prev - x_curr, y_curr - y_prev)
|
232
|
+
|
233
|
+
|
234
|
+
class CH:
|
235
|
+
def __init__(self, name: str) -> None:
|
236
|
+
self.name = name
|
237
|
+
|
238
|
+
@override
|
239
|
+
def __eq__(self, other: object) -> bool:
|
240
|
+
if not isinstance(other, CH):
|
241
|
+
return NotImplemented
|
242
|
+
return self.name == other.name
|
243
|
+
|
244
|
+
|
245
|
+
class C:
|
246
|
+
def __init__(
|
247
|
+
self, header: 'ColumnHeader', howmuch: 'Decimal | None'
|
248
|
+
) -> None:
|
249
|
+
self.header = header
|
250
|
+
self.howmuch = howmuch
|
251
|
+
|
252
|
+
|
253
|
+
class I: # noqa: E742
|
254
|
+
def __init__(self, when: 'date', columns: 'Sequence[Column]') -> None:
|
255
|
+
self.when = when
|
256
|
+
self.columns = columns
|
257
|
+
|
258
|
+
def howmuch(self, column_header: 'ColumnHeader') -> 'Decimal | None':
|
259
|
+
for column in self.columns:
|
260
|
+
if column.header == column_header:
|
261
|
+
return column.howmuch
|
262
|
+
return None
|
263
|
+
|
264
|
+
|
265
|
+
MONEY_HEADER = CH('money')
|
266
|
+
|
267
|
+
|
268
|
+
@dataclass
|
269
|
+
class SMFShared:
|
270
|
+
x_min: date
|
271
|
+
x_max: date
|
272
|
+
y_min: Decimal
|
273
|
+
y_max: Decimal
|
274
|
+
|
275
|
+
|
276
|
+
class SMFHelper:
|
277
|
+
def __init__(self, line_series: QLineSeries, shared: SMFShared) -> None:
|
278
|
+
self.line_series = line_series
|
279
|
+
self.shared = shared
|
280
|
+
self.acc = 0.0
|
281
|
+
self.last: date | None = None
|
282
|
+
|
283
|
+
@abstractmethod
|
284
|
+
def step(
|
285
|
+
self, when: date, when_d: int, howmuch: Decimal, howmuch_f: float
|
286
|
+
) -> None: ...
|
287
|
+
|
288
|
+
|
289
|
+
class SMFMoney(SMFHelper):
|
290
|
+
@override
|
291
|
+
def __init__(self, line_series: QLineSeries, shared: SMFShared) -> None:
|
292
|
+
super().__init__(line_series, shared)
|
293
|
+
self.line_series.setName('money')
|
294
|
+
|
295
|
+
@override
|
296
|
+
def step(
|
297
|
+
self,
|
298
|
+
when: date, # @UnusedVariable
|
299
|
+
when_d: int,
|
300
|
+
howmuch: Decimal,
|
301
|
+
howmuch_f: float,
|
302
|
+
) -> None:
|
303
|
+
self.line_series.append(when_d, 0)
|
304
|
+
# TODO @me: fix hover to deal with a variable number of items in series
|
305
|
+
# TOD000
|
306
|
+
self.line_series.append(when_d, howmuch_f)
|
307
|
+
|
308
|
+
self.shared.y_min = min(howmuch, self.shared.y_min)
|
309
|
+
self.shared.y_max = max(self.shared.y_max, howmuch)
|
310
|
+
|
311
|
+
|
312
|
+
class SMFMoneyAcc(SMFHelper):
|
313
|
+
@override
|
314
|
+
def __init__(self, line_series: QLineSeries, shared: SMFShared) -> None:
|
315
|
+
super().__init__(line_series, shared)
|
316
|
+
self.line_series.setName('money acc')
|
317
|
+
|
318
|
+
@override
|
319
|
+
def step(
|
320
|
+
self,
|
321
|
+
when: date, # @UnusedVariable
|
322
|
+
when_d: int,
|
323
|
+
howmuch: Decimal, # @UnusedVariable
|
324
|
+
howmuch_f: float,
|
325
|
+
) -> None:
|
326
|
+
self.line_series.append(when_d, self.acc)
|
327
|
+
self.acc += howmuch_f
|
328
|
+
self.line_series.append(when_d, self.acc)
|
329
|
+
|
330
|
+
if self.acc < self.shared.y_min:
|
331
|
+
self.shared.y_min = Decimal(self.acc)
|
332
|
+
if self.shared.y_max < self.acc:
|
333
|
+
self.shared.y_max = Decimal(self.acc)
|
334
|
+
|
335
|
+
|
336
|
+
class SMFMoneyByMonth(SMFHelper):
|
337
|
+
@override
|
338
|
+
def __init__(self, line_series: QLineSeries, shared: SMFShared) -> None:
|
339
|
+
super().__init__(line_series, shared)
|
340
|
+
self.line_series.setName('money by month')
|
341
|
+
|
342
|
+
@override
|
343
|
+
def step(
|
344
|
+
self,
|
345
|
+
when: date,
|
346
|
+
when_d: int,
|
347
|
+
howmuch: Decimal, # @UnusedVariable
|
348
|
+
howmuch_f: float,
|
349
|
+
) -> None:
|
350
|
+
# money_by_month
|
351
|
+
self.line_series.append(when_d, self.acc)
|
352
|
+
if self.last is None or self.last.month != when.month:
|
353
|
+
self.acc = 0.0
|
354
|
+
else:
|
355
|
+
self.acc += howmuch_f
|
356
|
+
self.line_series.append(when_d, self.acc)
|
357
|
+
self.last = when
|
358
|
+
|
359
|
+
if self.acc < self.shared.y_min:
|
360
|
+
self.shared.y_min = Decimal(self.acc)
|
361
|
+
if self.shared.y_max < self.acc:
|
362
|
+
self.shared.y_max = Decimal(self.acc)
|
363
|
+
|
364
|
+
|
365
|
+
class SMFMoneyByYear(SMFHelper):
|
366
|
+
@override
|
367
|
+
def __init__(self, line_series: QLineSeries, shared: SMFShared) -> None:
|
368
|
+
super().__init__(line_series, shared)
|
369
|
+
self.line_series.setName('money by year')
|
370
|
+
|
371
|
+
@override
|
372
|
+
def step(
|
373
|
+
self,
|
374
|
+
when: date,
|
375
|
+
when_d: int,
|
376
|
+
howmuch: Decimal, # @UnusedVariable
|
377
|
+
howmuch_f: float,
|
378
|
+
) -> None:
|
379
|
+
# money_by_year
|
380
|
+
self.line_series.append(when_d, self.acc)
|
381
|
+
if self.last is None or self.last.year != when.year:
|
382
|
+
self.acc = 0.0
|
383
|
+
else:
|
384
|
+
self.acc += howmuch_f
|
385
|
+
self.line_series.append(when_d, self.acc)
|
386
|
+
self.last = when
|
387
|
+
|
388
|
+
if self.acc < self.shared.y_min:
|
389
|
+
self.shared.y_min = Decimal(self.acc)
|
390
|
+
if self.shared.y_max < self.acc:
|
391
|
+
self.shared.y_max = Decimal(self.acc)
|
392
|
+
|
393
|
+
|
394
|
+
def series_model_factory(infos: 'Sequence[Info]') -> 'SeriesModel':
|
395
|
+
"""Extract money from info; accumulate and step; group by month / year."""
|
396
|
+
shared = SMFShared(
|
397
|
+
x_min=date.max,
|
398
|
+
x_max=date.min,
|
399
|
+
y_min=Decimal('inf'),
|
400
|
+
y_max=-Decimal('inf'),
|
401
|
+
)
|
402
|
+
|
403
|
+
money = SMFMoney(QLineSeries(), shared)
|
404
|
+
money_acc = SMFMoneyAcc(QLineSeries(), shared)
|
405
|
+
money_by_month = SMFMoneyByMonth(QLineSeries(), shared)
|
406
|
+
money_by_year = SMFMoneyByYear(QLineSeries(), shared)
|
407
|
+
|
408
|
+
for info in infos:
|
409
|
+
when = info.when
|
410
|
+
howmuch = info.howmuch(MONEY_HEADER)
|
411
|
+
if howmuch is None:
|
412
|
+
continue
|
413
|
+
|
414
|
+
shared.x_min = min(when, shared.x_min)
|
415
|
+
shared.x_max = max(when, shared.x_max)
|
416
|
+
|
417
|
+
when_d = date2days(when)
|
418
|
+
howmuch_f = float(howmuch)
|
419
|
+
|
420
|
+
money.step(when, when_d, howmuch, howmuch_f)
|
421
|
+
money_acc.step(when, when_d, howmuch, howmuch_f)
|
422
|
+
money_by_month.step(when, when_d, howmuch, howmuch_f)
|
423
|
+
money_by_year.step(when, when_d, howmuch, howmuch_f)
|
424
|
+
|
425
|
+
return SeriesModel(
|
426
|
+
[
|
427
|
+
money.line_series,
|
428
|
+
money_acc.line_series,
|
429
|
+
money_by_month.line_series,
|
430
|
+
money_by_year.line_series,
|
431
|
+
],
|
432
|
+
date2QDateTime(shared.x_min),
|
433
|
+
date2QDateTime(shared.x_max),
|
434
|
+
float(shared.y_min),
|
435
|
+
float(shared.y_max),
|
436
|
+
SeriesModelUnit.EURO,
|
437
|
+
)
|
438
|
+
|
439
|
+
|
440
|
+
class ChartWidgetWrapper(ChartWidget):
|
441
|
+
def __init__(self, data_path: str) -> None:
|
442
|
+
self.data_path = data_path
|
443
|
+
self.model = SortFilterViewModel()
|
444
|
+
super().__init__(self.model, None, series_model_factory, '%d/%m/%Y')
|
445
|
+
self.setCursor(Qt.CursorShape.CrossCursor)
|
446
|
+
self.reload()
|
447
|
+
|
448
|
+
def reload(self) -> Self:
|
449
|
+
_, data = read(self.data_path)
|
450
|
+
# convert data to infos
|
451
|
+
infos = [
|
452
|
+
I(row.date, [C(MONEY_HEADER, row.money)])
|
453
|
+
for row in sorted(data, key=lambda row: row.date)
|
454
|
+
]
|
455
|
+
self.model.update(infos)
|
456
|
+
return self
|
movsviewer/constants.py
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Final
|
3
|
+
|
4
|
+
|
5
|
+
def _resource(filename: str) -> Path:
|
6
|
+
return Path(__file__).with_name('resources') / filename
|
7
|
+
|
8
|
+
|
9
|
+
MAINUI_UI_PATH: Final = _resource('mainui.ui')
|
10
|
+
SETTINGSUI_UI_PATH: Final = _resource('settingsui.ui')
|
11
|
+
|
12
|
+
GECKODRIVER_PATH: Final = _resource('geckodriver.exe')
|
13
|
+
|
14
|
+
SETTINGS_USERNAME: Final = 'username'
|
15
|
+
SETTINGS_PASSWORD: Final = 'password'
|
16
|
+
SETTINGS_DATA_PATHS: Final = 'dataPaths'
|
movsviewer/mainui.py
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from sys import argv
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
from typing import Final
|
5
|
+
from typing import cast
|
6
|
+
|
7
|
+
from guilib.multitabs.widget import MultiTabs
|
8
|
+
from guilib.searchsheet.widget import SearchSheet
|
9
|
+
from PySide6.QtCore import QCoreApplication
|
10
|
+
from PySide6.QtCore import QItemSelection
|
11
|
+
from PySide6.QtCore import QItemSelectionModel
|
12
|
+
from PySide6.QtCore import Qt
|
13
|
+
from PySide6.QtQuick import QQuickWindow
|
14
|
+
from PySide6.QtQuick import QSGRendererInterface
|
15
|
+
from PySide6.QtUiTools import QUiLoader
|
16
|
+
from PySide6.QtWidgets import QApplication
|
17
|
+
from PySide6.QtWidgets import QDialog
|
18
|
+
from PySide6.QtWidgets import QDialogButtonBox
|
19
|
+
from PySide6.QtWidgets import QFileDialog
|
20
|
+
from PySide6.QtWidgets import QGridLayout
|
21
|
+
from PySide6.QtWidgets import QLineEdit
|
22
|
+
from PySide6.QtWidgets import QMainWindow
|
23
|
+
from PySide6.QtWidgets import QPlainTextEdit
|
24
|
+
from PySide6.QtWidgets import QToolButton
|
25
|
+
from PySide6.QtWidgets import QWidget
|
26
|
+
|
27
|
+
from movsviewer.chartview import ChartWidgetWrapper
|
28
|
+
from movsviewer.constants import MAINUI_UI_PATH
|
29
|
+
from movsviewer.constants import SETTINGSUI_UI_PATH
|
30
|
+
from movsviewer.settings import Settings
|
31
|
+
from movsviewer.validator import Validator
|
32
|
+
from movsviewer.viewmodel import SortFilterViewModel
|
33
|
+
|
34
|
+
if TYPE_CHECKING:
|
35
|
+
from collections.abc import Callable
|
36
|
+
|
37
|
+
from PySide6.QtGui import QAction
|
38
|
+
|
39
|
+
|
40
|
+
_DATA_PATHS_SEPARATOR = '; \n'
|
41
|
+
|
42
|
+
|
43
|
+
class Mainui(QMainWindow):
|
44
|
+
actionSettings: 'QAction' # noqa: N815
|
45
|
+
actionUpdate: 'QAction' # noqa: N815
|
46
|
+
gridLayout: QGridLayout # noqa: N815
|
47
|
+
centralwidget: QWidget
|
48
|
+
|
49
|
+
|
50
|
+
class Settingsui(QDialog):
|
51
|
+
usernameLineEdit: QLineEdit # noqa: N815
|
52
|
+
passwordLineEdit: QLineEdit # noqa: N815
|
53
|
+
dataPaths: QPlainTextEdit # noqa: N815
|
54
|
+
buttonBox: QDialogButtonBox # noqa: N815
|
55
|
+
openFileChooser: QToolButton # noqa: N815
|
56
|
+
|
57
|
+
|
58
|
+
def _set_data_paths(data_paths: QPlainTextEdit, file_names: list[str]) -> None:
|
59
|
+
data_paths.setPlainText(_DATA_PATHS_SEPARATOR.join(file_names))
|
60
|
+
|
61
|
+
|
62
|
+
def _get_data_paths(data_paths: QPlainTextEdit) -> list[str]:
|
63
|
+
return data_paths.toPlainText().split(_DATA_PATHS_SEPARATOR)
|
64
|
+
|
65
|
+
|
66
|
+
def new_settingsui(settings: Settings) -> Settingsui:
|
67
|
+
def save_settings() -> None:
|
68
|
+
settings.data_paths = _get_data_paths(settingsui.dataPaths)
|
69
|
+
settings.username = settingsui.usernameLineEdit.text()
|
70
|
+
settings.password = settingsui.passwordLineEdit.text()
|
71
|
+
|
72
|
+
def open_data_paths() -> None:
|
73
|
+
file_names, _ = QFileDialog.getOpenFileNames(
|
74
|
+
settingsui, dir=str(Path.home())
|
75
|
+
)
|
76
|
+
_set_data_paths(settingsui.dataPaths, file_names)
|
77
|
+
|
78
|
+
settingsui = cast(Settingsui, QUiLoader().load(SETTINGSUI_UI_PATH))
|
79
|
+
settingsui.usernameLineEdit.setText(settings.username)
|
80
|
+
settingsui.passwordLineEdit.setText(settings.password)
|
81
|
+
_set_data_paths(settingsui.dataPaths, settings.data_paths)
|
82
|
+
|
83
|
+
settingsui.accepted.connect(save_settings)
|
84
|
+
settingsui.openFileChooser.clicked.connect(open_data_paths)
|
85
|
+
|
86
|
+
return settingsui
|
87
|
+
|
88
|
+
|
89
|
+
class NewMainui:
|
90
|
+
sheets_charts: Final[
|
91
|
+
dict[str, tuple[SearchSheet, ChartWidgetWrapper, int]]
|
92
|
+
] = {}
|
93
|
+
|
94
|
+
settings: Settings
|
95
|
+
mainui: Mainui
|
96
|
+
multi_tabs: MultiTabs
|
97
|
+
|
98
|
+
def __call__(self, settings: Settings, settingsui: Settingsui) -> QWidget:
|
99
|
+
self.settings = settings
|
100
|
+
self.mainui = cast(Mainui, QUiLoader().load(MAINUI_UI_PATH))
|
101
|
+
|
102
|
+
self.multi_tabs = MultiTabs(self.mainui.centralwidget)
|
103
|
+
self.mainui.gridLayout.addWidget(self.multi_tabs, 0, 0, 1, 1)
|
104
|
+
|
105
|
+
self.mainui.actionUpdate.triggered.connect(self.update_helper)
|
106
|
+
self.mainui.actionSettings.triggered.connect(settingsui.show)
|
107
|
+
settingsui.accepted.connect(self.update_helper)
|
108
|
+
|
109
|
+
self.update_helper() # on startup load
|
110
|
+
|
111
|
+
return self.mainui
|
112
|
+
|
113
|
+
def new_search_sheet(
|
114
|
+
self, data_path: str
|
115
|
+
) -> tuple[SearchSheet, SortFilterViewModel]:
|
116
|
+
model = SortFilterViewModel(data_path)
|
117
|
+
sheet = SearchSheet(None)
|
118
|
+
sheet.set_model(model)
|
119
|
+
selection_model = sheet.selection_model()
|
120
|
+
selection_model.selectionChanged.connect(
|
121
|
+
self.update_status_bar(model, selection_model)
|
122
|
+
)
|
123
|
+
return sheet, model
|
124
|
+
|
125
|
+
def update_helper(self) -> None:
|
126
|
+
if not Validator(self.mainui, self.settings).validate():
|
127
|
+
return
|
128
|
+
|
129
|
+
data_paths = self.settings.data_paths[:]
|
130
|
+
for data_path, (sheet, chart, idx) in self.sheets_charts.items():
|
131
|
+
if data_path in data_paths:
|
132
|
+
data_paths.remove(data_path)
|
133
|
+
sheet.reload()
|
134
|
+
chart.reload()
|
135
|
+
else:
|
136
|
+
self.multi_tabs.remove_double_box(idx)
|
137
|
+
del self.sheets_charts[data_path]
|
138
|
+
for data_path in data_paths:
|
139
|
+
sheet, model = self.new_search_sheet(data_path)
|
140
|
+
chart = ChartWidgetWrapper(data_path)
|
141
|
+
idx = self.multi_tabs.add_double_box(sheet, chart, model.name)
|
142
|
+
self.sheets_charts[data_path] = (sheet, chart, idx)
|
143
|
+
|
144
|
+
def update_status_bar(
|
145
|
+
self, model: SortFilterViewModel, selection_model: QItemSelectionModel
|
146
|
+
) -> 'Callable[[QItemSelection, QItemSelection], None]':
|
147
|
+
return lambda _selected, _deselected: model.selection_changed(
|
148
|
+
selection_model, self.mainui.statusBar()
|
149
|
+
)
|
150
|
+
|
151
|
+
|
152
|
+
def main() -> None:
|
153
|
+
QCoreApplication.setAttribute(
|
154
|
+
Qt.ApplicationAttribute.AA_ShareOpenGLContexts
|
155
|
+
)
|
156
|
+
QQuickWindow.setGraphicsApi(
|
157
|
+
QSGRendererInterface.GraphicsApi.OpenGLRhi # @UndefinedVariable
|
158
|
+
)
|
159
|
+
|
160
|
+
app = QApplication(argv)
|
161
|
+
settings = Settings(argv[1:])
|
162
|
+
settingsui = new_settingsui(settings)
|
163
|
+
new_mainui = NewMainui()
|
164
|
+
mainui = new_mainui(settings, settingsui)
|
165
|
+
|
166
|
+
mainui.show()
|
167
|
+
|
168
|
+
raise SystemExit(app.exec())
|
movsviewer/reader.py
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
from typing import TYPE_CHECKING
|
2
|
+
from typing import Protocol
|
3
|
+
from typing import overload
|
4
|
+
|
5
|
+
from movslib.libretto import read_libretto
|
6
|
+
from movslib.movs import read_txt
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from movslib.model import KV
|
10
|
+
from movslib.model import Row
|
11
|
+
from movslib.model import Rows
|
12
|
+
|
13
|
+
|
14
|
+
class Reader(Protocol):
|
15
|
+
@overload
|
16
|
+
def __call__(self, fn: str) -> 'tuple[KV, list[Row]]': ...
|
17
|
+
|
18
|
+
@overload
|
19
|
+
def __call__(self, fn: str, name: str) -> 'tuple[KV, Rows]': ...
|
20
|
+
|
21
|
+
def __call__(
|
22
|
+
self, fn: str, name: str | None = None
|
23
|
+
) -> 'tuple[KV, list[Row] | Rows]': ...
|
24
|
+
|
25
|
+
|
26
|
+
def _get_reader(fn: str) -> Reader:
|
27
|
+
if fn.endswith('.xlsx'):
|
28
|
+
return read_libretto
|
29
|
+
return read_txt
|
30
|
+
|
31
|
+
|
32
|
+
@overload
|
33
|
+
def read(fn: str) -> 'tuple[KV, list[Row]]': ...
|
34
|
+
|
35
|
+
|
36
|
+
@overload
|
37
|
+
def read(fn: str, name: str) -> 'tuple[KV, Rows]': ...
|
38
|
+
|
39
|
+
|
40
|
+
def read(fn: str, name: str | None = None) -> 'tuple[KV, list[Row] | Rows]':
|
41
|
+
reader = _get_reader(fn)
|
42
|
+
return reader(fn) if name is None else reader(fn, name)
|
Binary file
|
@@ -0,0 +1,57 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
2
|
+
<ui version="4.0">
|
3
|
+
<class>MainWindow</class>
|
4
|
+
<widget class="QMainWindow" name="MainWindow">
|
5
|
+
<property name="geometry">
|
6
|
+
<rect>
|
7
|
+
<x>0</x>
|
8
|
+
<y>0</y>
|
9
|
+
<width>800</width>
|
10
|
+
<height>600</height>
|
11
|
+
</rect>
|
12
|
+
</property>
|
13
|
+
<property name="windowTitle">
|
14
|
+
<string>MainWindow</string>
|
15
|
+
</property>
|
16
|
+
<widget class="QWidget" name="centralwidget">
|
17
|
+
<layout class="QGridLayout" name="gridLayout"/>
|
18
|
+
</widget>
|
19
|
+
<widget class="QMenuBar" name="menubar">
|
20
|
+
<property name="geometry">
|
21
|
+
<rect>
|
22
|
+
<x>0</x>
|
23
|
+
<y>0</y>
|
24
|
+
<width>800</width>
|
25
|
+
<height>25</height>
|
26
|
+
</rect>
|
27
|
+
</property>
|
28
|
+
<widget class="QMenu" name="menu">
|
29
|
+
<property name="title">
|
30
|
+
<string>&File</string>
|
31
|
+
</property>
|
32
|
+
<addaction name="actionUpdate"/>
|
33
|
+
<addaction name="actionSettings"/>
|
34
|
+
<addaction name="action_Merge"/>
|
35
|
+
</widget>
|
36
|
+
<addaction name="menu"/>
|
37
|
+
</widget>
|
38
|
+
<widget class="QStatusBar" name="statusbar"/>
|
39
|
+
<action name="actionUpdate">
|
40
|
+
<property name="text">
|
41
|
+
<string>&Update</string>
|
42
|
+
</property>
|
43
|
+
</action>
|
44
|
+
<action name="actionSettings">
|
45
|
+
<property name="text">
|
46
|
+
<string>&Settings</string>
|
47
|
+
</property>
|
48
|
+
</action>
|
49
|
+
<action name="action_Merge">
|
50
|
+
<property name="text">
|
51
|
+
<string>&Merge</string>
|
52
|
+
</property>
|
53
|
+
</action>
|
54
|
+
</widget>
|
55
|
+
<resources/>
|
56
|
+
<connections/>
|
57
|
+
</ui>
|
@@ -0,0 +1,129 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
2
|
+
<ui version="4.0">
|
3
|
+
<class>Dialog</class>
|
4
|
+
<widget class="QDialog" name="Dialog">
|
5
|
+
<property name="geometry">
|
6
|
+
<rect>
|
7
|
+
<x>0</x>
|
8
|
+
<y>0</y>
|
9
|
+
<width>703</width>
|
10
|
+
<height>247</height>
|
11
|
+
</rect>
|
12
|
+
</property>
|
13
|
+
<property name="windowTitle">
|
14
|
+
<string>Dialog</string>
|
15
|
+
</property>
|
16
|
+
<layout class="QGridLayout" name="gridLayout">
|
17
|
+
<item row="2" column="0">
|
18
|
+
<widget class="QDialogButtonBox" name="buttonBox">
|
19
|
+
<property name="orientation">
|
20
|
+
<enum>Qt::Horizontal</enum>
|
21
|
+
</property>
|
22
|
+
<property name="standardButtons">
|
23
|
+
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
24
|
+
</property>
|
25
|
+
</widget>
|
26
|
+
</item>
|
27
|
+
<item row="1" column="0">
|
28
|
+
<widget class="QGroupBox" name="groupBox_2">
|
29
|
+
<property name="title">
|
30
|
+
<string>General</string>
|
31
|
+
</property>
|
32
|
+
<layout class="QFormLayout" name="formLayout">
|
33
|
+
<item row="0" column="0">
|
34
|
+
<widget class="QLabel" name="label_3">
|
35
|
+
<property name="text">
|
36
|
+
<string>Data paths</string>
|
37
|
+
</property>
|
38
|
+
</widget>
|
39
|
+
</item>
|
40
|
+
<item row="0" column="1">
|
41
|
+
<widget class="QSplitter" name="splitter">
|
42
|
+
<property name="orientation">
|
43
|
+
<enum>Qt::Horizontal</enum>
|
44
|
+
</property>
|
45
|
+
<widget class="QPlainTextEdit" name="dataPaths">
|
46
|
+
<property name="sizeAdjustPolicy">
|
47
|
+
<enum>QAbstractScrollArea::AdjustToContents</enum>
|
48
|
+
</property>
|
49
|
+
<property name="readOnly">
|
50
|
+
<bool>true</bool>
|
51
|
+
</property>
|
52
|
+
</widget>
|
53
|
+
<widget class="QToolButton" name="openFileChooser">
|
54
|
+
<property name="text">
|
55
|
+
<string>&Open...</string>
|
56
|
+
</property>
|
57
|
+
</widget>
|
58
|
+
</widget>
|
59
|
+
</item>
|
60
|
+
</layout>
|
61
|
+
</widget>
|
62
|
+
</item>
|
63
|
+
<item row="0" column="0">
|
64
|
+
<widget class="QGroupBox" name="groupBox">
|
65
|
+
<property name="title">
|
66
|
+
<string>Poste</string>
|
67
|
+
</property>
|
68
|
+
<layout class="QFormLayout" name="formLayout_2">
|
69
|
+
<item row="0" column="0">
|
70
|
+
<widget class="QLabel" name="label">
|
71
|
+
<property name="text">
|
72
|
+
<string>Username</string>
|
73
|
+
</property>
|
74
|
+
</widget>
|
75
|
+
</item>
|
76
|
+
<item row="0" column="1">
|
77
|
+
<widget class="QLineEdit" name="usernameLineEdit"/>
|
78
|
+
</item>
|
79
|
+
<item row="1" column="0">
|
80
|
+
<widget class="QLabel" name="label_2">
|
81
|
+
<property name="text">
|
82
|
+
<string>Password</string>
|
83
|
+
</property>
|
84
|
+
</widget>
|
85
|
+
</item>
|
86
|
+
<item row="1" column="1">
|
87
|
+
<widget class="QLineEdit" name="passwordLineEdit"/>
|
88
|
+
</item>
|
89
|
+
</layout>
|
90
|
+
</widget>
|
91
|
+
</item>
|
92
|
+
</layout>
|
93
|
+
</widget>
|
94
|
+
<resources/>
|
95
|
+
<connections>
|
96
|
+
<connection>
|
97
|
+
<sender>buttonBox</sender>
|
98
|
+
<signal>accepted()</signal>
|
99
|
+
<receiver>Dialog</receiver>
|
100
|
+
<slot>accept()</slot>
|
101
|
+
<hints>
|
102
|
+
<hint type="sourcelabel">
|
103
|
+
<x>257</x>
|
104
|
+
<y>190</y>
|
105
|
+
</hint>
|
106
|
+
<hint type="destinationlabel">
|
107
|
+
<x>157</x>
|
108
|
+
<y>199</y>
|
109
|
+
</hint>
|
110
|
+
</hints>
|
111
|
+
</connection>
|
112
|
+
<connection>
|
113
|
+
<sender>buttonBox</sender>
|
114
|
+
<signal>rejected()</signal>
|
115
|
+
<receiver>Dialog</receiver>
|
116
|
+
<slot>reject()</slot>
|
117
|
+
<hints>
|
118
|
+
<hint type="sourcelabel">
|
119
|
+
<x>325</x>
|
120
|
+
<y>190</y>
|
121
|
+
</hint>
|
122
|
+
<hint type="destinationlabel">
|
123
|
+
<x>286</x>
|
124
|
+
<y>199</y>
|
125
|
+
</hint>
|
126
|
+
</hints>
|
127
|
+
</connection>
|
128
|
+
</connections>
|
129
|
+
</ui>
|
movsviewer/settings.py
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
from typing import cast
|
2
|
+
|
3
|
+
from PySide6.QtCore import QSettings
|
4
|
+
|
5
|
+
from movsviewer.constants import SETTINGS_DATA_PATHS
|
6
|
+
from movsviewer.constants import SETTINGS_PASSWORD
|
7
|
+
from movsviewer.constants import SETTINGS_USERNAME
|
8
|
+
|
9
|
+
|
10
|
+
class Settings:
|
11
|
+
def __init__(self, argv1: list[str]) -> None:
|
12
|
+
self.settings = QSettings('ZeeD', 'mypyui')
|
13
|
+
self.argv1 = argv1
|
14
|
+
|
15
|
+
@property
|
16
|
+
def username(self) -> str:
|
17
|
+
value = self.settings.value(SETTINGS_USERNAME)
|
18
|
+
return cast(str, value) if value is not None else ''
|
19
|
+
|
20
|
+
@username.setter
|
21
|
+
def username(self, username: str) -> None:
|
22
|
+
self.settings.setValue(SETTINGS_USERNAME, username)
|
23
|
+
|
24
|
+
@property
|
25
|
+
def password(self) -> str:
|
26
|
+
value = self.settings.value(SETTINGS_PASSWORD)
|
27
|
+
return cast(str, value) if value is not None else ''
|
28
|
+
|
29
|
+
@password.setter
|
30
|
+
def password(self, password: str) -> None:
|
31
|
+
self.settings.setValue(SETTINGS_PASSWORD, password)
|
32
|
+
|
33
|
+
@property
|
34
|
+
def data_paths(self) -> list[str]:
|
35
|
+
if self.argv1:
|
36
|
+
return self.argv1
|
37
|
+
|
38
|
+
value = self.settings.value(SETTINGS_DATA_PATHS)
|
39
|
+
|
40
|
+
if value is None:
|
41
|
+
return []
|
42
|
+
if isinstance(value, str):
|
43
|
+
return [value]
|
44
|
+
if isinstance(value, list):
|
45
|
+
return value
|
46
|
+
|
47
|
+
raise ValueError(value)
|
48
|
+
|
49
|
+
@data_paths.setter
|
50
|
+
def data_paths(self, data_paths: list[str]) -> None:
|
51
|
+
self.settings.setValue(SETTINGS_DATA_PATHS, data_paths)
|
movsviewer/validator.py
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
from datetime import UTC
|
2
|
+
from datetime import date
|
3
|
+
from datetime import datetime
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
from PySide6.QtWidgets import QMessageBox
|
8
|
+
from PySide6.QtWidgets import QWidget
|
9
|
+
|
10
|
+
from movsviewer.reader import read
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from movslib.model import KV
|
14
|
+
from movslib.model import Rows
|
15
|
+
|
16
|
+
from movsviewer.settings import Settings
|
17
|
+
|
18
|
+
|
19
|
+
def validate_saldo(kv: 'KV', csv: 'Rows', messages: list[str]) -> bool:
|
20
|
+
messages.append(f'bpol.saldo_al: {kv.saldo_al}')
|
21
|
+
if kv.saldo_al:
|
22
|
+
ultimo_update = (datetime.now(tz=UTC).date() - kv.saldo_al).days
|
23
|
+
messages.append(
|
24
|
+
f'ultimo update: {ultimo_update} giorni fa'
|
25
|
+
)
|
26
|
+
messages.append(
|
27
|
+
f'bpol.saldo_contabile: {float(kv.saldo_contabile):_}'
|
28
|
+
)
|
29
|
+
messages.append(
|
30
|
+
f'bpol.saldo_disponibile: {float(kv.saldo_disponibile):_}'
|
31
|
+
)
|
32
|
+
|
33
|
+
s = sum(item.money for item in csv)
|
34
|
+
messages.append(f'Σ (item.accredito - item.addebito): {float(s):_}')
|
35
|
+
ret = kv.saldo_contabile == s == kv.saldo_disponibile
|
36
|
+
if not ret:
|
37
|
+
delta = max(
|
38
|
+
[abs(kv.saldo_contabile - s), abs(s - kv.saldo_disponibile)]
|
39
|
+
)
|
40
|
+
messages.append(f'Δ: {float(delta):_}')
|
41
|
+
return ret
|
42
|
+
|
43
|
+
|
44
|
+
def validate_dates(csv: 'Rows', messages: list[str]) -> bool:
|
45
|
+
data_contabile: date | None = None
|
46
|
+
for row in csv:
|
47
|
+
if data_contabile is not None and data_contabile < row.data_contabile:
|
48
|
+
messages.append(f'{data_contabile} < {row.data_contabile}!')
|
49
|
+
return False
|
50
|
+
return True
|
51
|
+
|
52
|
+
|
53
|
+
def validate(fn: str, messages: list[str]) -> bool:
|
54
|
+
messages.append(fn)
|
55
|
+
kv, csv = read(fn, Path(fn).stem)
|
56
|
+
return all(
|
57
|
+
[validate_saldo(kv, csv, messages), validate_dates(csv, messages)]
|
58
|
+
)
|
59
|
+
|
60
|
+
|
61
|
+
class Validator:
|
62
|
+
def __init__(self, parent: QWidget, settings: 'Settings') -> None:
|
63
|
+
self.parent = parent
|
64
|
+
self.settings = settings
|
65
|
+
|
66
|
+
def validate(self) -> bool:
|
67
|
+
for fn in self.settings.data_paths:
|
68
|
+
messages: list[str] = []
|
69
|
+
if not validate(fn, messages):
|
70
|
+
button = QMessageBox.warning(
|
71
|
+
self.parent,
|
72
|
+
f'{fn} seems has some problems!',
|
73
|
+
'\n'.join(messages),
|
74
|
+
QMessageBox.StandardButton.Yes
|
75
|
+
| QMessageBox.StandardButton.No,
|
76
|
+
)
|
77
|
+
return button is QMessageBox.StandardButton.Yes
|
78
|
+
return True
|
movsviewer/viewmodel.py
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
from dataclasses import fields
|
2
|
+
from datetime import date
|
3
|
+
from decimal import Decimal
|
4
|
+
from operator import iadd
|
5
|
+
from operator import isub
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import TYPE_CHECKING
|
8
|
+
from typing import Self
|
9
|
+
from typing import cast
|
10
|
+
from typing import override
|
11
|
+
|
12
|
+
from guilib.searchsheet.model import SearchableModel
|
13
|
+
from movslib.model import ZERO
|
14
|
+
from movslib.model import Row
|
15
|
+
from movslib.model import Rows
|
16
|
+
from PySide6.QtCore import QAbstractTableModel
|
17
|
+
from PySide6.QtCore import QItemSelectionModel
|
18
|
+
from PySide6.QtCore import QModelIndex
|
19
|
+
from PySide6.QtCore import QObject
|
20
|
+
from PySide6.QtCore import QPersistentModelIndex
|
21
|
+
from PySide6.QtCore import Qt
|
22
|
+
from PySide6.QtGui import QBrush
|
23
|
+
from PySide6.QtGui import QColor
|
24
|
+
|
25
|
+
from movsviewer.reader import read
|
26
|
+
|
27
|
+
if TYPE_CHECKING:
|
28
|
+
from PySide6.QtWidgets import QStatusBar
|
29
|
+
|
30
|
+
|
31
|
+
FIELD_NAMES = [field.name for field in fields(Row)]
|
32
|
+
|
33
|
+
T_FIELDS = date | Decimal | None | str
|
34
|
+
|
35
|
+
|
36
|
+
def _abs(row: Row) -> Decimal:
|
37
|
+
if row.addebiti is not None:
|
38
|
+
return -row.addebiti
|
39
|
+
if row.accrediti is not None:
|
40
|
+
return row.accrediti
|
41
|
+
return ZERO
|
42
|
+
|
43
|
+
|
44
|
+
T_INDEX = QModelIndex | QPersistentModelIndex
|
45
|
+
|
46
|
+
|
47
|
+
_INDEX = QModelIndex()
|
48
|
+
|
49
|
+
|
50
|
+
class ViewModel(QAbstractTableModel):
|
51
|
+
def __init__(self, data: Rows, parent: QObject | None = None) -> None:
|
52
|
+
super().__init__(parent)
|
53
|
+
self._set_data(data)
|
54
|
+
|
55
|
+
def _set_data(self, data: Rows) -> None:
|
56
|
+
self._data = data
|
57
|
+
abs_data = sorted([_abs(row) for row in data])
|
58
|
+
self._min = abs_data[0] if abs_data else ZERO
|
59
|
+
self._max = abs_data[-1] if abs_data else ZERO
|
60
|
+
|
61
|
+
@override
|
62
|
+
def rowCount(self, _parent: T_INDEX = _INDEX) -> int:
|
63
|
+
return len(self._data)
|
64
|
+
|
65
|
+
@override
|
66
|
+
def columnCount(self, _parent: T_INDEX = _INDEX) -> int:
|
67
|
+
return len(FIELD_NAMES)
|
68
|
+
|
69
|
+
@override
|
70
|
+
def headerData(
|
71
|
+
self,
|
72
|
+
section: int,
|
73
|
+
orientation: Qt.Orientation,
|
74
|
+
role: int = Qt.ItemDataRole.DisplayRole,
|
75
|
+
) -> str | None:
|
76
|
+
if role != Qt.ItemDataRole.DisplayRole:
|
77
|
+
return None
|
78
|
+
|
79
|
+
if orientation != Qt.Orientation.Horizontal:
|
80
|
+
return None
|
81
|
+
|
82
|
+
return FIELD_NAMES[section]
|
83
|
+
|
84
|
+
@override
|
85
|
+
def data(
|
86
|
+
self,
|
87
|
+
index: QModelIndex | QPersistentModelIndex,
|
88
|
+
role: int = Qt.ItemDataRole.DisplayRole,
|
89
|
+
) -> T_FIELDS | QBrush | None:
|
90
|
+
column = index.column()
|
91
|
+
row = index.row()
|
92
|
+
|
93
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
94
|
+
return str(getattr(self._data[row], FIELD_NAMES[column]))
|
95
|
+
|
96
|
+
if role == Qt.ItemDataRole.BackgroundRole:
|
97
|
+
max_, min_, val = self._max, self._min, _abs(self._data[row])
|
98
|
+
perc = (
|
99
|
+
(val - min_) / (max_ - min_) if max_ != min_ else Decimal(0.5)
|
100
|
+
)
|
101
|
+
|
102
|
+
hue = int(perc * 120) # 0..359 ; red=0, green=120
|
103
|
+
saturation = 223 # 0..255
|
104
|
+
lightness = 159 # 0..255
|
105
|
+
|
106
|
+
return QBrush(QColor.fromHsl(hue, saturation, lightness))
|
107
|
+
|
108
|
+
if role == Qt.ItemDataRole.UserRole:
|
109
|
+
return cast(T_FIELDS, getattr(self._data[row], FIELD_NAMES[column]))
|
110
|
+
|
111
|
+
return None
|
112
|
+
|
113
|
+
@override
|
114
|
+
def sort(
|
115
|
+
self, index: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder
|
116
|
+
) -> None:
|
117
|
+
def key(row: Row) -> date | Decimal | str: # T_FIELDS - None
|
118
|
+
e: T_FIELDS = getattr(row, FIELD_NAMES[index])
|
119
|
+
if e is None:
|
120
|
+
return ZERO
|
121
|
+
return e
|
122
|
+
|
123
|
+
self.layoutAboutToBeChanged.emit()
|
124
|
+
try:
|
125
|
+
self._data.sort(
|
126
|
+
key=key, reverse=order == Qt.SortOrder.DescendingOrder
|
127
|
+
)
|
128
|
+
finally:
|
129
|
+
self.layoutChanged.emit()
|
130
|
+
|
131
|
+
def load(self, data: Rows) -> None:
|
132
|
+
self.beginResetModel()
|
133
|
+
try:
|
134
|
+
self._set_data(data)
|
135
|
+
finally:
|
136
|
+
self.endResetModel()
|
137
|
+
|
138
|
+
@property
|
139
|
+
def name(self) -> str:
|
140
|
+
return self._data.name
|
141
|
+
|
142
|
+
|
143
|
+
class SortFilterViewModel(SearchableModel):
|
144
|
+
def __init__(self, data_path: str) -> None:
|
145
|
+
super().__init__(ViewModel(Rows('')))
|
146
|
+
self.data_path = data_path
|
147
|
+
self.reload()
|
148
|
+
|
149
|
+
@override
|
150
|
+
def sourceModel(self) -> ViewModel:
|
151
|
+
return cast(ViewModel, super().sourceModel())
|
152
|
+
|
153
|
+
@override
|
154
|
+
def sort(
|
155
|
+
self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder
|
156
|
+
) -> None:
|
157
|
+
self.sourceModel().sort(column, order)
|
158
|
+
|
159
|
+
def selection_changed(
|
160
|
+
self, selection_model: QItemSelectionModel, statusbar: 'QStatusBar'
|
161
|
+
) -> None:
|
162
|
+
addebiti_index = FIELD_NAMES.index('addebiti')
|
163
|
+
accrediti_index = FIELD_NAMES.index('accrediti')
|
164
|
+
|
165
|
+
bigsum = 0
|
166
|
+
for column, iop in ((addebiti_index, isub), (accrediti_index, iadd)):
|
167
|
+
for index in selection_model.selectedRows(column):
|
168
|
+
data = index.data(Qt.ItemDataRole.UserRole)
|
169
|
+
if data is not None:
|
170
|
+
bigsum = iop(bigsum, data)
|
171
|
+
|
172
|
+
statusbar.showMessage(f'⅀ = {bigsum}')
|
173
|
+
|
174
|
+
def reload(self) -> Self:
|
175
|
+
_, data = read(self.data_path, Path(self.data_path).stem)
|
176
|
+
self.sourceModel().load(data)
|
177
|
+
return self
|
178
|
+
|
179
|
+
@property
|
180
|
+
def name(self) -> str:
|
181
|
+
return self.sourceModel().name
|
@@ -0,0 +1,15 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: zeed-movs-viewer
|
3
|
+
Version: 0.0.0
|
4
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
5
|
+
Classifier: Programming Language :: Python :: 3.12
|
6
|
+
Project-URL: Homepage, https://github.com/ZeeD/movs-viewer
|
7
|
+
Project-URL: Repository, https://github.com/ZeeD/movs-viewer.git
|
8
|
+
Requires-Python: ==3.12.*
|
9
|
+
Requires-Dist: pyside6!=6.8,!=6.8.0.1,>=6.7
|
10
|
+
Requires-Dist: pythonqwt>=0.12.5
|
11
|
+
Requires-Dist: selenium>=4.22
|
12
|
+
Requires-Dist: zeed-guilib>=0.0.2
|
13
|
+
Requires-Dist: zeed-movs-merger>=0
|
14
|
+
Requires-Dist: zeed-movslib>=0.0.2
|
15
|
+
|
@@ -0,0 +1,16 @@
|
|
1
|
+
movsviewer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
movsviewer/automation.py,sha256=HwMiywH2YboXB8WyxP8hIoQgNMmNK-Gq_RWtHJh4zBg,4323
|
3
|
+
movsviewer/chartview.py,sha256=8XhOiXIo6vjL0uOGBhBQrl18u7q6X9OkdClfZTIhJDY,13470
|
4
|
+
movsviewer/constants.py,sha256=06WyNwgQwEkBrkvrryzEtdWV5mRUBycKe9RTe7PQ_mk,428
|
5
|
+
movsviewer/mainui.py,sha256=RLxEPoNL3uUxB4ieJemB8-P6-qZX7voUwwqR3rJ_XOA,5717
|
6
|
+
movsviewer/reader.py,sha256=8glE4CSv5RMe8ezP3RvKdzLqYhoT-Oy03My1oP5Dmdk,997
|
7
|
+
movsviewer/resources/geckodriver.exe,sha256=CCfcGwdNLMMpCWKc05nDO-3q6C88o3sEllUGV4HDGLM,3300792
|
8
|
+
movsviewer/resources/mainui.ui,sha256=lCMfw4A0KkVw9KP14raCaGzDKc7Hx2W8OexZxT3npQk,1376
|
9
|
+
movsviewer/resources/settingsui.ui,sha256=6eH5C9rkNrPlyp3JCQSW06drKhepwdQ0la4VGiqmPvE,3369
|
10
|
+
movsviewer/settings.py,sha256=yMLzNHiWYo9iUQYtRMNvs5UYbt0k4DndW7svNU2O_tg,1462
|
11
|
+
movsviewer/validator.py,sha256=huYRRRTIjVQHm6DlbtXVYROp0_SM5_7Vv7WV42d2or4,2560
|
12
|
+
movsviewer/viewmodel.py,sha256=ZEDy4I3bcSQh3ewQ_1K_fJ6ZJTJRHmwN0kH4rUPUSnk,5204
|
13
|
+
zeed_movs_viewer-0.0.0.dist-info/METADATA,sha256=3iC7EOQhBJ75187XeNUcLIcSA5ymg9hvq-UfTBer0PY,529
|
14
|
+
zeed_movs_viewer-0.0.0.dist-info/WHEEL,sha256=pM0IBB6ZwH3nkEPhtcp50KvKNX-07jYtnb1g1m6Z4Co,90
|
15
|
+
zeed_movs_viewer-0.0.0.dist-info/entry_points.txt,sha256=Cr9WqD8tBOMTJBuMV6-ayk_L49BmMfrKyZLk51-0008,71
|
16
|
+
zeed_movs_viewer-0.0.0.dist-info/RECORD,,
|