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 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.
@@ -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
+ ![Overtime balance graph](./doc/graph_example.png)
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,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
+ ![Overtime balance graph](./doc/graph_example.png)
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
+ ![Overtime balance graph](./doc/graph_example.png)
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,2 @@
1
+ [console_scripts]
2
+ recolul = recolul.cli:main
@@ -0,0 +1,11 @@
1
+ beautifulsoup4~=4.11
2
+ plotly~=5.18
3
+ requests~=2.31
4
+
5
+ [dev]
6
+ build~=1.0.3
7
+ pytest~=7.4
8
+ twine~=5.1.1
9
+
10
+ [gui]
11
+ pyside6~=6.7.0
@@ -0,0 +1 @@
1
+ recolul
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools == 65.5.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -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"]
@@ -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
+