recolul 1.20.0__tar.gz
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.
- recolul-1.20.0/LICENSE +21 -0
- recolul-1.20.0/PKG-INFO +72 -0
- recolul-1.20.0/README.md +57 -0
- recolul-1.20.0/RecoLul.egg-info/PKG-INFO +72 -0
- recolul-1.20.0/RecoLul.egg-info/SOURCES.txt +26 -0
- recolul-1.20.0/RecoLul.egg-info/dependency_links.txt +1 -0
- recolul-1.20.0/RecoLul.egg-info/entry_points.txt +2 -0
- recolul-1.20.0/RecoLul.egg-info/requires.txt +11 -0
- recolul-1.20.0/RecoLul.egg-info/top_level.txt +1 -0
- recolul-1.20.0/pyproject.toml +3 -0
- recolul-1.20.0/recolul/__init__.py +1 -0
- recolul-1.20.0/recolul/cli.py +127 -0
- recolul-1.20.0/recolul/config.py +46 -0
- recolul-1.20.0/recolul/duration.py +59 -0
- recolul-1.20.0/recolul/errors.py +8 -0
- recolul-1.20.0/recolul/plotting.py +27 -0
- recolul-1.20.0/recolul/recoru/__init__.py +0 -0
- recolul-1.20.0/recolul/recoru/attendance_chart.py +121 -0
- recolul-1.20.0/recolul/recoru/recoru_session.py +81 -0
- recolul-1.20.0/recolul/time.py +154 -0
- recolul-1.20.0/setup.cfg +42 -0
recolul-1.20.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [year] [fullname]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
recolul-1.20.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: recolul
|
|
3
|
+
Version: 1.20.0
|
|
4
|
+
Summary: Overtime management for RecoRu
|
|
5
|
+
Home-page: https://github.com/Maerig/recolul
|
|
6
|
+
Author: Jean-Loup Roussel-Clouet
|
|
7
|
+
Author-email: jean-loup@cryptact.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Provides-Extra: gui
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
|
|
16
|
+
# RecoLul
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pip install recolul
|
|
22
|
+
recolul config
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Overtime balance
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
$ recolul balance
|
|
31
|
+
Monthly overtime balance: 00:51
|
|
32
|
+
|
|
33
|
+
Last day 2/6(月)
|
|
34
|
+
Clock-in: 09:10
|
|
35
|
+
Working hours: 07:19
|
|
36
|
+
Break: 01:00
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Overtime balance graph
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
$ recolul graph
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
### When to leave
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
$ recolul when
|
|
51
|
+
Leave today at 17:43 to avoid overtime (includes a 1-hour break).
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Config
|
|
55
|
+
|
|
56
|
+
### Environment variables
|
|
57
|
+
|
|
58
|
+
- `RECORU_AUTH_ID`
|
|
59
|
+
- `RECORU_CONTRACT_ID`
|
|
60
|
+
- `RECORU_PASSWORD`
|
|
61
|
+
|
|
62
|
+
### Config file
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
recolul config
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Build
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
python -m build
|
|
72
|
+
```
|
recolul-1.20.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# RecoLul
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install recolul
|
|
7
|
+
recolul config
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
### Overtime balance
|
|
13
|
+
|
|
14
|
+
```shell
|
|
15
|
+
$ recolul balance
|
|
16
|
+
Monthly overtime balance: 00:51
|
|
17
|
+
|
|
18
|
+
Last day 2/6(月)
|
|
19
|
+
Clock-in: 09:10
|
|
20
|
+
Working hours: 07:19
|
|
21
|
+
Break: 01:00
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Overtime balance graph
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
$ recolul graph
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
### When to leave
|
|
33
|
+
|
|
34
|
+
```shell
|
|
35
|
+
$ recolul when
|
|
36
|
+
Leave today at 17:43 to avoid overtime (includes a 1-hour break).
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Config
|
|
40
|
+
|
|
41
|
+
### Environment variables
|
|
42
|
+
|
|
43
|
+
- `RECORU_AUTH_ID`
|
|
44
|
+
- `RECORU_CONTRACT_ID`
|
|
45
|
+
- `RECORU_PASSWORD`
|
|
46
|
+
|
|
47
|
+
### Config file
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
recolul config
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Build
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
python -m build
|
|
57
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: recolul
|
|
3
|
+
Version: 1.20.0
|
|
4
|
+
Summary: Overtime management for RecoRu
|
|
5
|
+
Home-page: https://github.com/Maerig/recolul
|
|
6
|
+
Author: Jean-Loup Roussel-Clouet
|
|
7
|
+
Author-email: jean-loup@cryptact.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Provides-Extra: gui
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
|
|
16
|
+
# RecoLul
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pip install recolul
|
|
22
|
+
recolul config
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Overtime balance
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
$ recolul balance
|
|
31
|
+
Monthly overtime balance: 00:51
|
|
32
|
+
|
|
33
|
+
Last day 2/6(月)
|
|
34
|
+
Clock-in: 09:10
|
|
35
|
+
Working hours: 07:19
|
|
36
|
+
Break: 01:00
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Overtime balance graph
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
$ recolul graph
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
### When to leave
|
|
48
|
+
|
|
49
|
+
```shell
|
|
50
|
+
$ recolul when
|
|
51
|
+
Leave today at 17:43 to avoid overtime (includes a 1-hour break).
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Config
|
|
55
|
+
|
|
56
|
+
### Environment variables
|
|
57
|
+
|
|
58
|
+
- `RECORU_AUTH_ID`
|
|
59
|
+
- `RECORU_CONTRACT_ID`
|
|
60
|
+
- `RECORU_PASSWORD`
|
|
61
|
+
|
|
62
|
+
### Config file
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
recolul config
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Build
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
python -m build
|
|
72
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.cfg
|
|
5
|
+
RecoLul.egg-info/PKG-INFO
|
|
6
|
+
RecoLul.egg-info/SOURCES.txt
|
|
7
|
+
RecoLul.egg-info/dependency_links.txt
|
|
8
|
+
RecoLul.egg-info/entry_points.txt
|
|
9
|
+
RecoLul.egg-info/requires.txt
|
|
10
|
+
RecoLul.egg-info/top_level.txt
|
|
11
|
+
recolul/__init__.py
|
|
12
|
+
recolul/cli.py
|
|
13
|
+
recolul/config.py
|
|
14
|
+
recolul/duration.py
|
|
15
|
+
recolul/errors.py
|
|
16
|
+
recolul/plotting.py
|
|
17
|
+
recolul/time.py
|
|
18
|
+
recolul.egg-info/PKG-INFO
|
|
19
|
+
recolul.egg-info/SOURCES.txt
|
|
20
|
+
recolul.egg-info/dependency_links.txt
|
|
21
|
+
recolul.egg-info/entry_points.txt
|
|
22
|
+
recolul.egg-info/requires.txt
|
|
23
|
+
recolul.egg-info/top_level.txt
|
|
24
|
+
recolul/recoru/__init__.py
|
|
25
|
+
recolul/recoru/attendance_chart.py
|
|
26
|
+
recolul/recoru/recoru_session.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recolul
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.20.0"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
|
|
5
|
+
from recolul import __version__, plotting, time
|
|
6
|
+
from recolul.config import Config
|
|
7
|
+
from recolul.duration import Duration
|
|
8
|
+
from recolul.errors import NoClockInError
|
|
9
|
+
from recolul.recoru.attendance_chart import AttendanceChart
|
|
10
|
+
from recolul.recoru.recoru_session import RecoruSession
|
|
11
|
+
from recolul.time import get_row_work_time, until_today
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def balance(exclude_last_day: bool) -> None:
|
|
15
|
+
full_attendance_chart = _get_attendance_chart()
|
|
16
|
+
attendance_chart = until_today(full_attendance_chart)
|
|
17
|
+
if exclude_last_day and len(attendance_chart) > 1:
|
|
18
|
+
attendance_chart = attendance_chart[:-1]
|
|
19
|
+
overtime_balance, total_workplace_times = time.get_overtime_balance(attendance_chart)
|
|
20
|
+
print(f"Monthly overtime balance: {overtime_balance}")
|
|
21
|
+
print(f"Total time per workplace:")
|
|
22
|
+
for workplace, total_work_time in total_workplace_times.items():
|
|
23
|
+
print(f" {workplace}: {total_work_time}")
|
|
24
|
+
print(
|
|
25
|
+
f"Maximum WFH time this month: {Duration(60) * time.count_working_days(full_attendance_chart)}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if exclude_last_day:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
last_day = attendance_chart[-1]
|
|
32
|
+
print(f"\nLast day {last_day.day.text}")
|
|
33
|
+
print(f" Clock-in: {max(entry.clock_in_time for entry in last_day.entries)}")
|
|
34
|
+
print(f" Working hours: {get_row_work_time(last_day)}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def when_to_leave() -> None:
|
|
38
|
+
attendance_chart = until_today(_get_attendance_chart())
|
|
39
|
+
try:
|
|
40
|
+
leave_times = time.get_leave_time(attendance_chart)
|
|
41
|
+
except NoClockInError:
|
|
42
|
+
print("You have already clocked out.")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if len(leave_times) == 1:
|
|
46
|
+
break_msg = "(break time included)" if leave_times[0].includes_break else "(break time not included)"
|
|
47
|
+
print(f"Leave at {leave_times[0].min_time} to avoid overtime {break_msg}.")
|
|
48
|
+
else:
|
|
49
|
+
print(
|
|
50
|
+
f"Leave between {leave_times[0].min_time} and {leave_times[0].max_time}, or """
|
|
51
|
+
f"after {leave_times[1].min_time}."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def update_config() -> None:
|
|
56
|
+
"""Needs to match Config.load"""
|
|
57
|
+
recoru_contract_id = input("recoru.contractId: ")
|
|
58
|
+
recoru_auth_id = input("recoru.authId: ")
|
|
59
|
+
recoru_password = getpass("recoru.password: ")
|
|
60
|
+
config = Config(
|
|
61
|
+
recoru_contract_id=recoru_contract_id,
|
|
62
|
+
recoru_auth_id=recoru_auth_id,
|
|
63
|
+
recoru_password=recoru_password
|
|
64
|
+
)
|
|
65
|
+
config.save()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def graph(exclude_last_day: bool) -> None:
|
|
69
|
+
attendance_chart = until_today(_get_attendance_chart())
|
|
70
|
+
if exclude_last_day and len(attendance_chart) > 1:
|
|
71
|
+
attendance_chart = attendance_chart[:-1]
|
|
72
|
+
days, history, _ = time.get_overtime_history(attendance_chart)
|
|
73
|
+
plotting.plot_overtime_balance_history(days, history)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
parser = argparse.ArgumentParser(prog="recolul")
|
|
78
|
+
parser.add_argument("-v", "--version", action="version", version=__version__)
|
|
79
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
80
|
+
|
|
81
|
+
balance_parser = subparsers.add_parser("balance", help="Calculate overtime balance")
|
|
82
|
+
balance_parser.add_argument(
|
|
83
|
+
"--exclude-last-day",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Exclude last/current day from the calculation"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
subparsers.add_parser("when", help="Calculate at which time to leave to avoid overtime this month")
|
|
89
|
+
|
|
90
|
+
subparsers.add_parser("config", help="Init or update config")
|
|
91
|
+
|
|
92
|
+
graph_parser = subparsers.add_parser("graph", help="Display a graph of overtime balance over the month")
|
|
93
|
+
graph_parser.add_argument(
|
|
94
|
+
"--exclude-last-day",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Exclude last/current day from the graph"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
args = parser.parse_args(sys.argv[1:])
|
|
100
|
+
match args.command:
|
|
101
|
+
case "balance":
|
|
102
|
+
balance(exclude_last_day=args.exclude_last_day)
|
|
103
|
+
case "when":
|
|
104
|
+
when_to_leave()
|
|
105
|
+
case "config":
|
|
106
|
+
update_config()
|
|
107
|
+
case "graph":
|
|
108
|
+
graph(exclude_last_day=args.exclude_last_day)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_attendance_chart() -> AttendanceChart:
|
|
112
|
+
config = Config.from_env() or Config.load()
|
|
113
|
+
if not config:
|
|
114
|
+
raise RuntimeError(f"No config found")
|
|
115
|
+
|
|
116
|
+
with RecoruSession(
|
|
117
|
+
contract_id=config.recoru_contract_id,
|
|
118
|
+
auth_id=config.recoru_auth_id,
|
|
119
|
+
password=config.recoru_password
|
|
120
|
+
) as recoru_session:
|
|
121
|
+
attendance_chart = recoru_session.get_attendance_chart()
|
|
122
|
+
|
|
123
|
+
return attendance_chart
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import os.path
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG_PATH = os.path.realpath(f"{__file__}/../config.ini")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Config:
|
|
10
|
+
recoru_contract_id: str
|
|
11
|
+
recoru_auth_id: str
|
|
12
|
+
recoru_password: str
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_env(cls):
|
|
16
|
+
try:
|
|
17
|
+
return cls(
|
|
18
|
+
recoru_contract_id=os.environ["RECORU_CONTRACT_ID"],
|
|
19
|
+
recoru_auth_id=os.getenv("RECORU_AUTH_ID"),
|
|
20
|
+
recoru_password=os.environ["RECORU_PASSWORD"]
|
|
21
|
+
)
|
|
22
|
+
except KeyError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def load(cls, path: str = DEFAULT_CONFIG_PATH):
|
|
27
|
+
if not os.path.isfile(path):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
31
|
+
config.read(path)
|
|
32
|
+
return cls(
|
|
33
|
+
recoru_contract_id=config["recoru"]["contractId"],
|
|
34
|
+
recoru_auth_id=config["recoru"]["authId"],
|
|
35
|
+
recoru_password=config["recoru"]["password"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def save(self, path: str = DEFAULT_CONFIG_PATH):
|
|
39
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
40
|
+
config["recoru"] = {
|
|
41
|
+
"authId": self.recoru_auth_id,
|
|
42
|
+
"contractId": self.recoru_contract_id,
|
|
43
|
+
"password": self.recoru_password
|
|
44
|
+
}
|
|
45
|
+
with open(path, "w") as config_file:
|
|
46
|
+
config.write(config_file)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Duration:
|
|
5
|
+
@classmethod
|
|
6
|
+
def parse(cls, duration: str):
|
|
7
|
+
if duration == "":
|
|
8
|
+
return cls(0)
|
|
9
|
+
hours, minutes = duration.split(":")
|
|
10
|
+
total_minutes = 60 * int(hours) + int(minutes)
|
|
11
|
+
return cls(total_minutes)
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def now(cls):
|
|
15
|
+
return cls.parse(datetime.now().strftime("%H:%M"))
|
|
16
|
+
|
|
17
|
+
def __init__(self, minutes: int = 0):
|
|
18
|
+
self.minutes: int = minutes
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"{'-' if self.minutes < 0 else ''}{abs(self.minutes) // 60:02}:{abs(self.minutes) % 60:02}"
|
|
22
|
+
|
|
23
|
+
__str__ = __repr__
|
|
24
|
+
|
|
25
|
+
def __eq__(self, other):
|
|
26
|
+
return self.minutes == other.minutes
|
|
27
|
+
|
|
28
|
+
def __neq__(self, other):
|
|
29
|
+
return self.minutes != other.minutes
|
|
30
|
+
|
|
31
|
+
def __add__(self, other):
|
|
32
|
+
return Duration(self.minutes + other.minutes)
|
|
33
|
+
|
|
34
|
+
def __sub__(self, other):
|
|
35
|
+
return Duration(self.minutes - other.minutes)
|
|
36
|
+
|
|
37
|
+
def __neg__(self):
|
|
38
|
+
return Duration(-self.minutes)
|
|
39
|
+
|
|
40
|
+
def __mul__(self, other):
|
|
41
|
+
return Duration(other * self.minutes)
|
|
42
|
+
|
|
43
|
+
def __lt__(self, other):
|
|
44
|
+
return self.minutes < other.minutes
|
|
45
|
+
|
|
46
|
+
def __gt__(self, other):
|
|
47
|
+
return self.minutes > other.minutes
|
|
48
|
+
|
|
49
|
+
def __le__(self, other):
|
|
50
|
+
return self.minutes <= other.minutes
|
|
51
|
+
|
|
52
|
+
def __ge__(self, other):
|
|
53
|
+
return self.minutes >= other.minutes
|
|
54
|
+
|
|
55
|
+
def __abs__(self):
|
|
56
|
+
return Duration(abs(self.minutes))
|
|
57
|
+
|
|
58
|
+
def __bool__(self):
|
|
59
|
+
return self.minutes > 0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class InvalidRecoruLoginError(Exception):
|
|
2
|
+
"""Invalid RecoRu login information"""
|
|
3
|
+
def __init__(self):
|
|
4
|
+
super().__init__("Invalid RecoRu login information. Run recolul config")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoClockInError(Exception):
|
|
8
|
+
"""A clock-in time was expected but wasn't found"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import plotly.graph_objects as go
|
|
2
|
+
|
|
3
|
+
from recolul.duration import Duration
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def plot_overtime_balance_history(days: list[str], overtime_history: list[Duration]) -> None:
|
|
7
|
+
cumulative_overtime_history = []
|
|
8
|
+
for overtime in overtime_history:
|
|
9
|
+
last_cumulative_overtime = (
|
|
10
|
+
cumulative_overtime_history[-1] if cumulative_overtime_history
|
|
11
|
+
else Duration()
|
|
12
|
+
)
|
|
13
|
+
cumulative_overtime_history.append(last_cumulative_overtime + overtime)
|
|
14
|
+
|
|
15
|
+
fig = go.Figure(
|
|
16
|
+
data=go.Scatter(
|
|
17
|
+
x=days,
|
|
18
|
+
y=[duration.minutes for duration in cumulative_overtime_history],
|
|
19
|
+
text=[str(duration) for duration in cumulative_overtime_history],
|
|
20
|
+
hovertemplate="%{x} %{text}<extra></extra>",
|
|
21
|
+
mode="lines+markers"
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
fig.update_layout(
|
|
25
|
+
yaxis_title="Overtime balance (minutes)"
|
|
26
|
+
)
|
|
27
|
+
fig.show()
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import TypeAlias
|
|
4
|
+
|
|
5
|
+
from bs4 import Tag
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChartColumn(str, Enum):
|
|
9
|
+
DATE = "日付"
|
|
10
|
+
WORKPLACE = "作業場所"
|
|
11
|
+
CATEGORY = "勤務区分"
|
|
12
|
+
START = "開始"
|
|
13
|
+
END = "終了"
|
|
14
|
+
WORK_TIME = "労働時間"
|
|
15
|
+
MEMO = "メモ"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChartHeader:
|
|
19
|
+
"""Header of the attendance chart"""
|
|
20
|
+
def __init__(self, tag: Tag):
|
|
21
|
+
self._column_indices: dict[ChartColumn, int] = {
|
|
22
|
+
ChartCell(column).text.strip(): i
|
|
23
|
+
for i, column in enumerate(tag.find_all("td", recursive=False))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def get_column_index(self, column: ChartColumn) -> int:
|
|
27
|
+
return self._column_indices[column]
|
|
28
|
+
|
|
29
|
+
def has_column(self, column: ChartColumn) -> bool:
|
|
30
|
+
return column in self._column_indices
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChartCell:
|
|
34
|
+
"""Single cell of the attendance chart"""
|
|
35
|
+
def __init__(self, tag: Tag):
|
|
36
|
+
self._tag = tag
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def text(self) -> str:
|
|
40
|
+
return self._tag.text.strip()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def color(self) -> str:
|
|
44
|
+
label = self._tag.find("label", recursive=False)
|
|
45
|
+
if not label:
|
|
46
|
+
return ""
|
|
47
|
+
return label.attrs\
|
|
48
|
+
.get("style", "")\
|
|
49
|
+
.removeprefix("color: ")\
|
|
50
|
+
.removesuffix(";")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ChartRowEntry:
|
|
54
|
+
"""Sub-row of the attendance chart"""
|
|
55
|
+
def __init__(self, header: ChartHeader, tag: Tag):
|
|
56
|
+
self._header = header
|
|
57
|
+
self._tag = tag
|
|
58
|
+
|
|
59
|
+
def __getitem__(self, column: ChartColumn) -> ChartCell:
|
|
60
|
+
column_index = self._header.get_column_index(column)
|
|
61
|
+
column = self._tag.select_one(f"td:nth-child({column_index + 1})", recursive=False)
|
|
62
|
+
return ChartCell(column)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def day(self) -> ChartCell:
|
|
66
|
+
return self[ChartColumn.DATE]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def workplace(self) -> str:
|
|
70
|
+
return self[ChartColumn.WORKPLACE].text
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def category(self) -> str:
|
|
74
|
+
return self[ChartColumn.CATEGORY].text
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def clock_in_time(self) -> str:
|
|
78
|
+
return self[ChartColumn.START].text
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def clock_out_time(self) -> str:
|
|
82
|
+
return self[ChartColumn.END].text
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def work_time(self) -> str:
|
|
86
|
+
return self[ChartColumn.WORK_TIME].text
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def memo(self) -> str:
|
|
90
|
+
return self[ChartColumn.MEMO].text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ChartRow:
|
|
94
|
+
"""Row of the attendance chart"""
|
|
95
|
+
_date_regex = re.compile(r"^(\d{1,2})\/(\d{1,2})\(.\)$")
|
|
96
|
+
|
|
97
|
+
def __init__(self, entries: list[ChartRowEntry]):
|
|
98
|
+
assert entries, "Empty ChartRow"
|
|
99
|
+
self._entries = entries
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def entries(self) -> list[ChartRowEntry]:
|
|
103
|
+
return self._entries
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def day(self) -> ChartCell:
|
|
107
|
+
return self._entries[0][ChartColumn.DATE]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def day_of_month(self) -> int:
|
|
111
|
+
match = ChartRow._date_regex.match(self._entries[0].day.text)
|
|
112
|
+
if not match:
|
|
113
|
+
return 0
|
|
114
|
+
return int(match.group(2))
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def memo(self) -> str:
|
|
118
|
+
return self._entries[0][ChartColumn.MEMO].text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
AttendanceChart: TypeAlias = list[ChartRow]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from bs4 import BeautifulSoup
|
|
3
|
+
|
|
4
|
+
from recolul.errors import InvalidRecoruLoginError
|
|
5
|
+
from recolul.recoru.attendance_chart import AttendanceChart, ChartHeader, ChartRow, ChartRowEntry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RecoruSession:
|
|
9
|
+
def __init__(self, contract_id: str, auth_id: str, password: str):
|
|
10
|
+
self._contract_id: str = contract_id
|
|
11
|
+
self._auth_id: str = auth_id
|
|
12
|
+
self._password: str = password
|
|
13
|
+
|
|
14
|
+
self._session: requests.Session | None = None
|
|
15
|
+
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
self._session = requests.Session()
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
21
|
+
self._session.close()
|
|
22
|
+
self._session = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def session(self) -> requests.Session:
|
|
26
|
+
assert self._session, "RecoruSession should be used as a context manager"
|
|
27
|
+
return self._session
|
|
28
|
+
|
|
29
|
+
def get_attendance_chart(self) -> AttendanceChart:
|
|
30
|
+
self._login()
|
|
31
|
+
|
|
32
|
+
response = self.session.post("https://app.recoru.in/ap/home/loadAttendanceChartGadget")
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return self._parse_attendance_chart(response.text)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def read_attendance_chart_file(cls, path: str) -> AttendanceChart:
|
|
38
|
+
"""Used for testing"""
|
|
39
|
+
|
|
40
|
+
with open(path, "rt", encoding="UTF-8") as attendance_chart_file:
|
|
41
|
+
text = attendance_chart_file.read()
|
|
42
|
+
return cls._parse_attendance_chart(text)
|
|
43
|
+
|
|
44
|
+
def _login(self):
|
|
45
|
+
# Get a session ID
|
|
46
|
+
self.session.get("https://app.recoru.in/ap/")
|
|
47
|
+
|
|
48
|
+
url = "https://app.recoru.in/ap/login"
|
|
49
|
+
form_data = {
|
|
50
|
+
"contractId": self._contract_id,
|
|
51
|
+
"authId": self._auth_id,
|
|
52
|
+
"password": self._password
|
|
53
|
+
}
|
|
54
|
+
response = self.session.post(url, data=form_data)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
if "message-err" in response.text:
|
|
57
|
+
raise InvalidRecoruLoginError()
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _parse_attendance_chart(text: str) -> AttendanceChart:
|
|
61
|
+
soup = BeautifulSoup(text, "html.parser")
|
|
62
|
+
table = soup.select_one("#ID-attendanceChartGadgetTable")
|
|
63
|
+
|
|
64
|
+
table_header = table.select_one("thead > tr", recursive=False)
|
|
65
|
+
header = ChartHeader(table_header)
|
|
66
|
+
|
|
67
|
+
chart_rows = []
|
|
68
|
+
current_row_entries = []
|
|
69
|
+
table_body = table.find("tbody", recursive=False)
|
|
70
|
+
for row in table_body.find_all("tr", recursive=False):
|
|
71
|
+
entry = ChartRowEntry(header, row)
|
|
72
|
+
if entry.day.text: # New row
|
|
73
|
+
# Append previous row
|
|
74
|
+
if current_row_entries:
|
|
75
|
+
chart_rows.append(ChartRow(current_row_entries))
|
|
76
|
+
current_row_entries = [entry]
|
|
77
|
+
else: # Row with multiple entries
|
|
78
|
+
current_row_entries.append(entry)
|
|
79
|
+
if current_row_entries:
|
|
80
|
+
chart_rows.append(ChartRow(current_row_entries))
|
|
81
|
+
return chart_rows
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from recolul.duration import Duration
|
|
6
|
+
from recolul.errors import NoClockInError
|
|
7
|
+
from recolul.recoru.attendance_chart import AttendanceChart, ChartRow, ChartRowEntry
|
|
8
|
+
|
|
9
|
+
_MIN_HOURS_FOR_MANDATORY_BREAK = Duration(6 * 60)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def until_today(attendance_chart: AttendanceChart) -> AttendanceChart:
|
|
13
|
+
"""Return a slice of the attendance chart that only contains rows until today"""
|
|
14
|
+
current_day_of_month = datetime.now().day
|
|
15
|
+
return [
|
|
16
|
+
row for row in attendance_chart
|
|
17
|
+
if row.day_of_month <= current_day_of_month
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_entry_work_time(entry: ChartRowEntry) -> Duration:
|
|
22
|
+
"""
|
|
23
|
+
Get work time from the column if available,
|
|
24
|
+
else calculate it from clock-in time and current time
|
|
25
|
+
"""
|
|
26
|
+
category = entry.category
|
|
27
|
+
if category.startswith(
|
|
28
|
+
("Half Day Leave", "Flexible Holiday AM", "Flexible Holiday PM")
|
|
29
|
+
):
|
|
30
|
+
return Duration(4 * 60)
|
|
31
|
+
if category.endswith(("Leave", "Leagve")) or category == "Flexible Holiday":
|
|
32
|
+
return Duration(8 * 60)
|
|
33
|
+
|
|
34
|
+
if not (raw_clock_in_time := entry.clock_in_time):
|
|
35
|
+
return Duration(0)
|
|
36
|
+
clock_in_time = Duration.parse(raw_clock_in_time)
|
|
37
|
+
|
|
38
|
+
if raw_clock_out_time := entry.clock_out_time:
|
|
39
|
+
clock_out_time = Duration.parse(raw_clock_out_time)
|
|
40
|
+
else:
|
|
41
|
+
# Current day
|
|
42
|
+
clock_out_time = Duration.now()
|
|
43
|
+
|
|
44
|
+
if clock_out_time < clock_in_time:
|
|
45
|
+
# After midnight
|
|
46
|
+
clock_out_time += Duration(24 * 60)
|
|
47
|
+
|
|
48
|
+
work_time = clock_out_time - clock_in_time
|
|
49
|
+
break_time = (
|
|
50
|
+
Duration(60) if work_time >= _MIN_HOURS_FOR_MANDATORY_BREAK
|
|
51
|
+
else Duration(0)
|
|
52
|
+
)
|
|
53
|
+
return work_time - break_time
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_row_work_time(row: ChartRow) -> Duration:
|
|
57
|
+
total_work_time = Duration()
|
|
58
|
+
for entry in row.entries:
|
|
59
|
+
total_work_time += get_entry_work_time(entry)
|
|
60
|
+
return total_work_time
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_overtime_history(attendance_chart: AttendanceChart) -> tuple[list[str], list[Duration], dict[str, Duration]]:
|
|
64
|
+
days = []
|
|
65
|
+
overtime_history = []
|
|
66
|
+
total_workplace_times = defaultdict(Duration)
|
|
67
|
+
for row in attendance_chart:
|
|
68
|
+
day = row.day.text
|
|
69
|
+
if _is_working_day(row) or _is_swap_day(row):
|
|
70
|
+
required_time = Duration(8 * 60)
|
|
71
|
+
else:
|
|
72
|
+
required_time = Duration(0)
|
|
73
|
+
|
|
74
|
+
row_work_time = Duration()
|
|
75
|
+
for entry in row.entries:
|
|
76
|
+
entry_work_time = get_entry_work_time(entry)
|
|
77
|
+
row_work_time += entry_work_time
|
|
78
|
+
|
|
79
|
+
workplace = entry.workplace or "HF Bldg." # Workplace is empty for paid leaves
|
|
80
|
+
total_workplace_times[workplace] += entry_work_time
|
|
81
|
+
|
|
82
|
+
if not (required_time or row_work_time):
|
|
83
|
+
# Can have work time during holidays
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
days.append(day)
|
|
87
|
+
overtime_history.append(row_work_time - required_time)
|
|
88
|
+
|
|
89
|
+
return days, overtime_history, total_workplace_times
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_overtime_balance(attendance_chart: AttendanceChart) -> tuple[Duration, dict[str, Duration]]:
|
|
93
|
+
_, history, total_workplace_times = get_overtime_history(attendance_chart)
|
|
94
|
+
return sum(history, Duration()), total_workplace_times
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclasses.dataclass
|
|
98
|
+
class LeaveTime:
|
|
99
|
+
includes_break: bool
|
|
100
|
+
min_time: Duration
|
|
101
|
+
max_time: Duration | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_leave_time(attendance_chart: AttendanceChart) -> list[LeaveTime]:
|
|
105
|
+
day_base_hours = Duration(8 * 60)
|
|
106
|
+
overtime_balance, _ = get_overtime_balance(attendance_chart[:-1])
|
|
107
|
+
|
|
108
|
+
last_row = attendance_chart[-1]
|
|
109
|
+
last_clock_in = None
|
|
110
|
+
for entry in last_row.entries:
|
|
111
|
+
if entry.clock_in_time and not entry.clock_out_time:
|
|
112
|
+
# In progress
|
|
113
|
+
last_clock_in = Duration.parse(entry.clock_in_time)
|
|
114
|
+
else:
|
|
115
|
+
# Complete entry
|
|
116
|
+
overtime_balance += get_entry_work_time(entry)
|
|
117
|
+
if not last_clock_in:
|
|
118
|
+
raise NoClockInError()
|
|
119
|
+
|
|
120
|
+
required_today = day_base_hours - overtime_balance
|
|
121
|
+
leave_time_without_break = last_clock_in + required_today
|
|
122
|
+
leave_time_with_break = last_clock_in + required_today + Duration(60)
|
|
123
|
+
if required_today > _MIN_HOURS_FOR_MANDATORY_BREAK:
|
|
124
|
+
# When more than 6 hours must be achieved during the day,
|
|
125
|
+
# add a mandatory 1-hour break time.
|
|
126
|
+
return [
|
|
127
|
+
LeaveTime(includes_break=True, min_time=leave_time_with_break)
|
|
128
|
+
]
|
|
129
|
+
if required_today > _MIN_HOURS_FOR_MANDATORY_BREAK - Duration(60):
|
|
130
|
+
# When the required time is between 5 and 6 hours, there is a first interval where
|
|
131
|
+
# the overtime balance becomes positive, and then it becomes negative again when 6
|
|
132
|
+
# hours have been worked because an hour is subtracted for break time.
|
|
133
|
+
first_leave_time = LeaveTime(
|
|
134
|
+
includes_break=False,
|
|
135
|
+
min_time=leave_time_without_break,
|
|
136
|
+
max_time=last_clock_in + _MIN_HOURS_FOR_MANDATORY_BREAK
|
|
137
|
+
)
|
|
138
|
+
second_leave_time = LeaveTime(includes_break=True, min_time=leave_time_with_break)
|
|
139
|
+
return [first_leave_time, second_leave_time]
|
|
140
|
+
return [
|
|
141
|
+
LeaveTime(includes_break=False, min_time=leave_time_without_break)
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def count_working_days(attendance_chart: AttendanceChart) -> int:
|
|
146
|
+
return sum(1 for row in attendance_chart if _is_working_day(row))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_swap_day(row: ChartRow) -> bool:
|
|
150
|
+
return "swap day" in row.memo.lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_working_day(row: ChartRow) -> bool:
|
|
154
|
+
return row.day.text and row.day.color not in ["blue", "red"]
|
recolul-1.20.0/setup.cfg
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
name = recolul
|
|
3
|
+
version = attr: recolul.__version__
|
|
4
|
+
author = Jean-Loup Roussel-Clouet
|
|
5
|
+
author_email = jean-loup@cryptact.com
|
|
6
|
+
description = Overtime management for RecoRu
|
|
7
|
+
long_description = file: README.md
|
|
8
|
+
long_description_content_type = text/markdown
|
|
9
|
+
url = https://github.com/Maerig/recolul
|
|
10
|
+
classifiers =
|
|
11
|
+
Programming Language :: Python :: 3
|
|
12
|
+
Operating System :: OS Independent
|
|
13
|
+
|
|
14
|
+
[options]
|
|
15
|
+
packages = find:
|
|
16
|
+
install_requires =
|
|
17
|
+
beautifulsoup4~=4.11
|
|
18
|
+
plotly~=5.18
|
|
19
|
+
requests~=2.31
|
|
20
|
+
python_requires = >=3.10
|
|
21
|
+
|
|
22
|
+
[options.entry_points]
|
|
23
|
+
console_scripts =
|
|
24
|
+
recolul = recolul.cli:main
|
|
25
|
+
|
|
26
|
+
[options.extras_require]
|
|
27
|
+
dev =
|
|
28
|
+
build~=1.0.3
|
|
29
|
+
pytest~=7.4
|
|
30
|
+
twine~=5.1.1
|
|
31
|
+
gui =
|
|
32
|
+
pyside6~=6.7.0
|
|
33
|
+
|
|
34
|
+
[options.packages.find]
|
|
35
|
+
include =
|
|
36
|
+
recolul
|
|
37
|
+
recolul.*
|
|
38
|
+
|
|
39
|
+
[egg_info]
|
|
40
|
+
tag_build =
|
|
41
|
+
tag_date = 0
|
|
42
|
+
|