Compare commits

..

13 Commits

Author SHA1 Message Date
Maximilian Zettler fa3e79a1b3 update typing 4 years ago
Maximilian Zettler d81471969e fix way to long timeout for connect 4 years ago
Maximilian Zettler 98f6a88fe6 cleanup gui code 4 years ago
Maximilian Zettler 89a8b43a39 refresh main window once per second 4 years ago
Maximilian Zettler 061e72a8f6 add password dialog 4 years ago
Maximilian Zettler 5578dd9af7 add error popup 4 years ago
Maximilian Zettler 0885bf300a enhance get_hours_present 4 years ago
Maximilian Zettler 9037a0f91c add doc in timebot class and fix enum usage 4 years ago
Maximilian Zettler 2ab0d6d920 wip: add get_hours_present method 4 years ago
Maximilian Zettler e32192f8dd update button text 4 years ago
Maximilian Zettler 836e7d9b0e add auto refresh of status and run status_update in thread 4 years ago
Maximilian Zettler c537ae87b7 update readme 4 years ago
Maximilian Zettler 9c905cfaa8 wip: add qt5 ui 4 years ago
  1. 14
      Makefile
  2. 2
      README.md
  3. 82
      poetry.lock
  4. 1
      pyproject.toml
  5. 43
      timebot/app.py
  6. 37
      timebot/constants.py
  7. 172
      timebot/gui.py
  8. 145
      timebot/timebot.py

@ -0,0 +1,14 @@
.PHONY: nuitka clean env
#export CCFLAGS += -O1
#export CXXFLAGS += -O1
nuitka:
poetry run python -m nuitka --standalone --onefile --enable-plugin=pyqt5 --output-dir=build/nuitka timebot/app.py
env:
env
clean:
rm -rf dist
rm -rf build

@ -20,7 +20,7 @@ This is a poetry managed project. For details see:
### App Image ### App Image
``poetry run python -m nuitka --standalone --onefile --output-dir=build/nuitka timebot/app.py`` ``poetry run python -m nuitka --standalone --onefile --enable-plugin=pyqt5 --output-dir=build/nuitka timebot/app.py``
# ERRORS # ERRORS

82
poetry.lock generated

@ -30,7 +30,7 @@ python-versions = "*"
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "2.0.11" version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main" category = "main"
optional = false optional = false
@ -82,7 +82,7 @@ python-versions = ">=3.5"
[[package]] [[package]]
name = "nuitka" name = "nuitka"
version = "0.6.19.5" version = "0.6.19.7"
description = "Python compiler with full language support and CPython compatibility" description = "Python compiler with full language support and CPython compatibility"
category = "dev" category = "dev"
optional = false optional = false
@ -132,6 +132,34 @@ python-versions = ">=3.6"
[package.extras] [package.extras]
diagrams = ["jinja2", "railroad-diagrams"] diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pyqt5"
version = "5.15.6"
description = "Python bindings for the Qt cross platform application toolkit"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
PyQt5-Qt5 = ">=5.15.2"
PyQt5-sip = ">=12.8,<13"
[[package]]
name = "pyqt5-qt5"
version = "5.15.2"
description = "The subset of a Qt installation needed by PyQt5."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyqt5-sip"
version = "12.9.1"
description = "The sip module support for PyQt5"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "5.4.3" version = "5.4.3"
@ -175,7 +203,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.0.1" version = "4.1.1"
description = "Backported and Experimental Type Hints for Python 3.6+" description = "Backported and Experimental Type Hints for Python 3.6+"
category = "dev" category = "dev"
optional = false optional = false
@ -217,7 +245,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.6" python-versions = "^3.6"
content-hash = "69c9457fa471c11ea8c160fbc1fd743e319c7cf8fd88730a60f318d4059e7448" content-hash = "55bdfbbf52fa812d5d62f1d8117311eff037ce586754aa3541e5917903497643"
[metadata.files] [metadata.files]
atomicwrites = [ atomicwrites = [
@ -233,8 +261,8 @@ certifi = [
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
] ]
charset-normalizer = [ charset-normalizer = [
{file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"}, {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
{file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"}, {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
] ]
colorama = [ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -253,7 +281,7 @@ more-itertools = [
{file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"},
] ]
nuitka = [ nuitka = [
{file = "Nuitka-0.6.19.5.tar.gz", hash = "sha256:b100789ea71aff8814cf52958e20f82c4c5a01aaa8a8eca38cd6a7e1cc5cae25"}, {file = "Nuitka-0.6.19.7.tar.gz", hash = "sha256:15f9618f3536b9933fe0e621ecfc8cb6ec0893cf23ef28be95392ac192a464be"},
] ]
packaging = [ packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
@ -271,6 +299,42 @@ pyparsing = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
] ]
pyqt5 = [
{file = "PyQt5-5.15.6-cp36-abi3-macosx_10_13_x86_64.whl", hash = "sha256:33ced1c876f6a26e7899615a5a4efef2167c263488837c7beed023a64cebd051"},
{file = "PyQt5-5.15.6-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:9d6efad0377aa78bf081a20ac752ce86096ded18f04c592d98f5b92dc879ad0a"},
{file = "PyQt5-5.15.6-cp36-abi3-win32.whl", hash = "sha256:9d2dcdaf82263ae56023410a7af15d1fd746c8e361733a7d0d1bd1f004ec2793"},
{file = "PyQt5-5.15.6-cp36-abi3-win_amd64.whl", hash = "sha256:f411ecda52e488e1d3c5cce7563e1b2ca9cf0b7531e3c25b03d9a7e56e07e7fc"},
{file = "PyQt5-5.15.6.tar.gz", hash = "sha256:80343bcab95ffba619f2ed2467fd828ffeb0a251ad7225be5fc06dcc333af452"},
]
pyqt5-qt5 = [
{file = "PyQt5_Qt5-5.15.2-py3-none-macosx_10_13_intel.whl", hash = "sha256:76980cd3d7ae87e3c7a33bfebfaee84448fd650bad6840471d6cae199b56e154"},
{file = "PyQt5_Qt5-5.15.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:1988f364ec8caf87a6ee5d5a3a5210d57539988bf8e84714c7d60972692e2f4a"},
{file = "PyQt5_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9cc7a768b1921f4b982ebc00a318ccb38578e44e45316c7a4a850e953e1dd327"},
{file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"},
]
pyqt5-sip = [
{file = "PyQt5_sip-12.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b2e553e21b7ff124007a6b9168f8bb8c171fdf230d31ca0588df180f10bacbe"},
{file = "PyQt5_sip-12.9.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:5740a1770d6b92a5dca8bb0bda4620baf0d7a726beb864f69c667ddac91d6f64"},
{file = "PyQt5_sip-12.9.1-cp310-cp310-win32.whl", hash = "sha256:9699286fcdf4f75a4b91c7e4832c0f926af18d648c62a4ed72dd294c1a93705a"},
{file = "PyQt5_sip-12.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:e2792af660da7479799f53028da88190ae8b4a0ad5acc2acbfd6c7bbfe110d58"},
{file = "PyQt5_sip-12.9.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7ee08ad0ebf85b935f5d8d38306f8665fff9a6026c14fc0a7d780649e889c096"},
{file = "PyQt5_sip-12.9.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d21420b0739df2607864e2c80ca01994bc40cb116da6ad024ea8d9f407b0356"},
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win32.whl", hash = "sha256:ffd25051962c593d1c3c30188b9fbd8589ba17acd23a0202dc987bd3552fa611"},
{file = "PyQt5_sip-12.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:78ef8f1f41819661aa8e3117d6c1cd76fa14aef265e5bfd515dbfc64d412416b"},
{file = "PyQt5_sip-12.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5e641182bfee0501267c55e687832e4efe05becdae9e555d3695d706009b6598"},
{file = "PyQt5_sip-12.9.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9a977d2835a5fbf250b00d61267dc228bdec9e20c7420d4e8d54d6f20410f87"},
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win32.whl", hash = "sha256:cec6ebf0b1163b18f09bc523160c467a9528b6dca129753827ac0bc432b332ae"},
{file = "PyQt5_sip-12.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:82c1b3080db7634fa318fddbb3cfaa30e63a67bca1001a76958c31f30b774a9d"},
{file = "PyQt5_sip-12.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cfaad4a773c18b963092589b1a98153d36624601de8597a4dc287e5a295d5625"},
{file = "PyQt5_sip-12.9.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ce7a8b3af9db378c46b345d9809d481a74c4bfcd3129486c054fbdbc6a3503f9"},
{file = "PyQt5_sip-12.9.1-cp38-cp38-win32.whl", hash = "sha256:8fe5b3e4bbb8b472d05631cad21028d073f9f8eda770041449514cb3824a867f"},
{file = "PyQt5_sip-12.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:5d59c4a5e856a35c41b47f5a23e1635b38cd1672f4f0122a68ebcb6889523ff2"},
{file = "PyQt5_sip-12.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b56aedf7b0a496e4a8bd6087566888cea448aa01c76126cdb8b140e3ff3f5d93"},
{file = "PyQt5_sip-12.9.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:53e23dcc0fc3857204abd47660e383b930941bd1aeaf3c78ed59c5c12dd48010"},
{file = "PyQt5_sip-12.9.1-cp39-cp39-win32.whl", hash = "sha256:ee188eac5fd94dfe8d9e04a9e7fbda65c3535d5709278d8b7367ebd54f00e27f"},
{file = "PyQt5_sip-12.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:989d51c41456cc496cb96f0b341464932b957040d26561f0bb4cf5a0914d6b36"},
{file = "PyQt5_sip-12.9.1.tar.gz", hash = "sha256:2f24f299b44c511c23796aafbbb581bfdebf78d0905657b7cee2141b4982030e"},
]
pytest = [ pytest = [
{file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"},
{file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"},
@ -280,8 +344,8 @@ requests = [
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
] ]
typing-extensions = [ typing-extensions = [
{file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
{file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
] ]
urllib3 = [ urllib3 = [
{file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"},

@ -11,6 +11,7 @@ timebot = 'timebot.app:run'
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.6" python = "^3.6"
requests = "^2.27.1" requests = "^2.27.1"
PyQt5 = "^5.15.6"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^5.2" pytest = "^5.2"

@ -3,9 +3,10 @@ import configparser
import datetime import datetime
import logging import logging
import os import os
import signal
import sys import sys
from timebot.constants import SIMPLE_DATETIME_FORMAT_HUMAN, PUNCH_COMMANDS, SIMPLE_DATE_FORMAT, SIMPLE_TIME_FORMAT from timebot.constants import PUNCH_COMMANDS, DateFormats
from timebot.timebot import MobatimeApi, TimeBot, parse_user_time_input from timebot.timebot import MobatimeApi, TimeBot, parse_user_time_input
logger = logging.getLogger() logger = logging.getLogger()
@ -24,15 +25,18 @@ def run():
subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name') subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name')
# subparser command: `status` # subparser command: `status`
parser_status = subparsers.add_parser("status", help="show your current tracking status") subparsers.add_parser("status", help="show your current tracking status")
# subparser command: `gui`
subparsers.add_parser("gui", help="start qt5 gui")
# subparser command: `list-entries` # subparser command: `list-entries`
parser_list_entries = subparsers.add_parser("list-entries", help="use this command to list your time entries") parser_list_entries = subparsers.add_parser("list-entries", help="use this command to list your time entries")
parser_list_entries.add_argument("--start-date", parser_list_entries.add_argument("--start-date",
help=f"start date filter in format `{SIMPLE_DATETIME_FORMAT_HUMAN}` " help=f"start date filter in format `{DateFormats.SIMPLE_DATETIME.evalue}` "
f"(default: now - 10days; unset for default)") f"(default: now - 10days; unset for default)")
parser_list_entries.add_argument("--end-date", parser_list_entries.add_argument("--end-date",
help=f"end date filter in format `{SIMPLE_DATETIME_FORMAT_HUMAN}` " help=f"end date filter in format `{DateFormats.SIMPLE_DATETIME.evalue}` "
f"(default: now; unset for default)") f"(default: now; unset for default)")
parser_list_entries.add_argument("--items", help="max items to request per page", default=20, type=int) parser_list_entries.add_argument("--items", help="max items to request per page", default=20, type=int)
@ -43,8 +47,9 @@ def run():
help=f"type of time entry; this can be {', '.join(PUNCH_COMMANDS)}", help=f"type of time entry; this can be {', '.join(PUNCH_COMMANDS)}",
default="punch_in", default="punch_in",
choices=PUNCH_COMMANDS) choices=PUNCH_COMMANDS)
parser_punch.add_argument("-s", help=f"timestamp in format `{SIMPLE_DATETIME_FORMAT_HUMAN}` or `now`", parser_punch.add_argument("-s",
default="now") help=f"timestamp in format `{DateFormats.SIMPLE_DATETIME.evalue}`, "
f"`{DateFormats.SIMPLE_TIME.evalue}` or `now`", default="now")
# subparser command: `smart-punch` # subparser command: `smart-punch`
parser_smart_punch = subparsers.add_parser("smart-punch", parser_smart_punch = subparsers.add_parser("smart-punch",
@ -52,8 +57,8 @@ def run():
"entries; this command tries to detect your last action and will " "entries; this command tries to detect your last action and will "
"create entries in to following order: " "create entries in to following order: "
"punch_in -> break_start -> break_end -> punch_out") "punch_in -> break_start -> break_end -> punch_out")
parser_smart_punch.add_argument("-s", help=f"timestamp in format `{SIMPLE_DATETIME_FORMAT_HUMAN}` or `now`", parser_smart_punch.add_argument("-s", help=f"timestamp in format `{DateFormats.SIMPLE_DATETIME.evalue}`,"
default="now") f" `{DateFormats.SIMPLE_TIME.evalue}` or `now`", default="now")
args = parser.parse_args() args = parser.parse_args()
@ -77,8 +82,8 @@ def run():
punch_datetime = parse_user_time_input(args.s) punch_datetime = parse_user_time_input(args.s)
logger.info("running `{}` with date `{}` and time `{}`".format( logger.info("running `{}` with date `{}` and time `{}`".format(
args.t, args.t,
punch_datetime.strftime(SIMPLE_DATE_FORMAT), punch_datetime.strftime(DateFormats.SIMPLE_DATE.value),
punch_datetime.strftime(SIMPLE_TIME_FORMAT), punch_datetime.strftime(DateFormats.SIMPLE_TIME.value),
)) ))
getattr(tb, args.t)(punch_datetime) getattr(tb, args.t)(punch_datetime)
elif args.subparser_name == "smart-punch": elif args.subparser_name == "smart-punch":
@ -99,15 +104,15 @@ def run():
for i in data: for i in data:
print("Entry: {} - DateTime: {} - Note: {}".format(i["entryName"], i["dateTime"], i["note"])) print("Entry: {} - DateTime: {} - Note: {}".format(i["entryName"], i["dateTime"], i["note"]))
elif args.subparser_name == "status": elif args.subparser_name == "status":
tracking_data = tb.mobatime_api.get_tracking_data() print(tb.status())
account_info = tb.mobatime_api.get_account_information() elif args.subparser_name == "gui":
print("Tracking Info:") from PyQt5.QtWidgets import QApplication
print(f" Current State: {tracking_data['actualState']}") from timebot.gui import TimebotMainWindow
print(f" Last Booking: {tracking_data['lastBooking']}") signal.signal(signal.SIGINT, lambda *_args: QApplication.quit())
print(f" Last Booking Date: {tracking_data['lastBookingDate']}") app = QApplication(sys.argv)
print("Account Infos:") main_window = TimebotMainWindow(timebot=tb)
for i in account_info: main_window.show()
print(f" {i['accountName']}: {i['value']}") sys.exit(app.exec_())
else: else:
logger.error("Noting done... dunno what you want!") logger.error("Noting done... dunno what you want!")
sys.exit(1) sys.exit(1)

@ -1,13 +1,32 @@
COMING_ENTRY_CODE_ID = 16 from enum import Enum
LEAVING_ENTRY_CODE_ID = 32 from types import DynamicClassAttribute
BREAK_START_ENTRY_CODE_ID = 48
BREAK_END_ENTRY_CODE_ID = 64
PUNCH_COMMANDS = ("punch_in", "punch_out", "break_start", "break_end")
SIMPLE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M" class PunchCodes(Enum):
SIMPLE_DATETIME_FORMAT_HUMAN = "%%Y-%%m-%%dT%%H:%%M" COMING = 16
SIMPLE_DATE_FORMAT = "%Y-%m-%d" LEAVING = 32
SIMPLE_TIME_FORMAT = "%H:%M" BREAK_START = 48
BREAK_END = 64
class DateFormats(Enum):
SIMPLE_DATETIME = "%Y-%m-%dT%H:%M"
FULL_DATETIME = "%Y-%m-%dT%H:%M:%S"
SIMPLE_DATE = "%Y-%m-%d"
SIMPLE_TIME = "%H:%M"
@DynamicClassAttribute
def evalue(self):
"""
Return escaped version of value if value is string.
"%" will be escaped -> "%%"
"""
if isinstance(self._value_, (str,)):
return self._value_.replace("%", "%%")
return self._value_
PUNCH_COMMANDS = ("punch_in", "punch_out", "break_start", "break_end")
USER_AGENT = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) Gecko/20100101 Firefox/94.0" USER_AGENT = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) Gecko/20100101 Firefox/94.0"

@ -0,0 +1,172 @@
import datetime
import logging
from PyQt5 import QtGui
from PyQt5.QtCore import QTimer, QObject, pyqtSignal, QThread, Qt
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel, QMessageBox, QInputDialog, QLineEdit, QHBoxLayout
from timebot.timebot import TimeBot, TimebotObtainPasswordError
package_logger = logging.getLogger(__name__)
class HoursPresentLabelArea(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.text_box_hours_present_label = QLabel()
self.text_box_hours_present_label.setText("Hours present:")
self.text_box_hours_present = QLabel()
self.text_box_hours_present.setStyleSheet("background : #E0E0E0")
self.text_box_hours_present.setAlignment(Qt.AlignCenter)
layout = QHBoxLayout()
layout.addWidget(self.text_box_hours_present_label)
layout.addWidget(self.text_box_hours_present)
self.setLayout(layout)
class TimebotMainWindow(QWidget):
def __init__(self, timebot: TimeBot, parent=None):
super().__init__(parent)
self.timebot = timebot
self.timebot.mobatime_api.ask_for_password = False
self.setWindowTitle("Timebot")
self.setWindowIcon(QtGui.QIcon.fromTheme('face-devilish'))
self.resize(300, 270)
self.hours_present = datetime.timedelta(microseconds=0)
self.text_box_status = QLabel()
self.text_box_hours_present_area = HoursPresentLabelArea(parent=self)
self.btn_update_status = QPushButton("Refresh Status")
layout = QVBoxLayout()
layout.addWidget(self.text_box_status)
layout.addWidget(self.text_box_hours_present_area)
layout.addWidget(self.btn_update_status)
self.setLayout(layout)
self.btn_update_status.clicked.connect(self.update_status)
self.status_timer_time = 60000 # msec -> 60s
self.status_timer = QTimer()
self.status_timer.timeout.connect(self.update_status)
self.status_timer.start(self.status_timer_time)
self.main_window_timer_time = 1000 # msec -> 1s
self.main_window_timer = QTimer()
self.main_window_timer.timeout.connect(self.update_main_window)
self.main_window_timer.start(self.main_window_timer_time)
self.error_msg = QMessageBox()
self.error_msg.setWindowTitle("Timebot ERROR")
self.error_msg.setIcon(QMessageBox.Critical)
self.error_msg.finished.connect(self.error_msg_finished)
self.password_dialog = QInputDialog()
self.update_status_running = False
self.update_status()
def update_main_window(self):
self.main_window_info_thread = QThread()
self.main_window_info_worker = MainWindowInfoWorker(self)
self.main_window_info_worker.moveToThread(self.main_window_info_thread)
self.main_window_info_thread.started.connect(self.main_window_info_worker.run)
self.main_window_info_worker.finished.connect(self.main_window_info_thread.quit)
self.main_window_info_worker.finished.connect(self.main_window_info_worker.deleteLater)
self.main_window_info_thread.finished.connect(self.main_window_info_thread.deleteLater)
self.main_window_info_thread.start()
def update_status(self):
if self.update_status_running:
return
self.status_thread = QThread()
self.status_worker = TimebotApiWorker(self, self.timebot)
self.status_worker.moveToThread(self.status_thread)
self.status_thread.started.connect(self.status_worker.run)
self.status_worker.finished.connect(self.status_thread.quit)
self.status_worker.finished.connect(self.status_worker.deleteLater)
self.status_thread.finished.connect(self.status_thread.deleteLater)
self.status_thread.finished.connect(self.update_status_finished)
self.status_worker.error.connect(lambda exp, *args: self.error_msg_show(str(exp)))
self.status_worker.obtain_password.connect(lambda *args: self.get_password(self.update_status))
self.status_thread.start()
self.update_status_started()
def get_password(self, callback: callable = None):
self.status_timer.stop()
password, ok = self.password_dialog.getText(self, "Timebot Password", 'Enter your password:',
QLineEdit.Password)
if ok:
self.timebot.mobatime_api.password = password
try:
_ = self.timebot.mobatime_api.session
except Exception as e:
self.error_msg_show(str(e))
self.timebot.mobatime_api.password = None
if callback is not None:
callback()
self.status_timer.start(self.status_timer_time)
def error_msg_show(self, error_text: str):
self.error_msg.setText(error_text)
self.status_timer.stop()
self.error_msg.exec_()
def error_msg_finished(self):
self.status_timer.start(self.status_timer_time)
def update_status_started(self):
self.btn_update_status.setEnabled(False)
self.btn_update_status.setText("Refreshing...")
self.update_status_running = True
def update_status_finished(self):
self.btn_update_status.setEnabled(True)
self.btn_update_status.setText("Refresh Status")
self.update_status_running = False
def update_hours_present(self, override: datetime.timedelta = None):
if override is not None:
self.hours_present: datetime.timedelta = override
elif self.hours_present > datetime.timedelta(seconds=self.main_window_timer_time / 100):
self.hours_present = self.hours_present + datetime.timedelta(seconds=1)
hp = self.hours_present - datetime.timedelta(microseconds=self.hours_present.microseconds)
self.text_box_hours_present_area.text_box_hours_present.setText(str(hp))
class TimebotApiWorker(QObject):
error = pyqtSignal(object)
obtain_password = pyqtSignal()
finished = pyqtSignal()
def __init__(self, tmw: TimebotMainWindow, timebot: TimeBot):
super().__init__()
self.tmw = tmw
self.timebot = timebot
def run(self):
try:
self.tmw.update_hours_present(override=self.timebot.get_hours_present())
self.tmw.text_box_status.setText(self.timebot.status())
except TimebotObtainPasswordError:
self.obtain_password.emit()
except Exception as e:
self.error.emit(e)
self.finished.emit()
class MainWindowInfoWorker(QObject):
finished = pyqtSignal()
def __init__(self, tmw: TimebotMainWindow):
super().__init__()
self.tmw = tmw
def run(self):
self.tmw.update_hours_present()
self.finished.emit()

@ -3,17 +3,19 @@ import getpass
import logging import logging
import pickle import pickle
import sys import sys
from typing import Union
import requests as requests import requests as requests
from timebot.constants import COMING_ENTRY_CODE_ID, LEAVING_ENTRY_CODE_ID, BREAK_START_ENTRY_CODE_ID, \ from timebot.constants import PunchCodes, DateFormats, USER_AGENT
BREAK_END_ENTRY_CODE_ID, SIMPLE_DATE_FORMAT, SIMPLE_TIME_FORMAT, SIMPLE_DATETIME_FORMAT, \
USER_AGENT
package_logger = logging.getLogger(__name__) package_logger = logging.getLogger(__name__)
class TimebotObtainPasswordError(Exception):
pass
class TimeParseError(Exception): class TimeParseError(Exception):
pass pass
@ -21,20 +23,21 @@ class TimeParseError(Exception):
def parse_user_time_input(stamp: str) -> datetime.datetime: def parse_user_time_input(stamp: str) -> datetime.datetime:
errors = [] errors = []
try: try:
package_logger.debug(f"trying to parse date with format \"{SIMPLE_DATETIME_FORMAT}\"") package_logger.debug(f"trying to parse date with format \"{DateFormats.SIMPLE_DATETIME.value}\"")
return datetime.datetime.strptime(stamp, SIMPLE_DATETIME_FORMAT) return datetime.datetime.strptime(stamp, DateFormats.SIMPLE_DATETIME.value)
except ValueError as e: except ValueError as e:
errors.append(e) errors.append(e)
try: try:
package_logger.debug(f"trying to parse date with format \"{SIMPLE_TIME_FORMAT}\"") package_logger.debug(f"trying to parse date with format \"{DateFormats.SIMPLE_TIME}\"")
t = datetime.datetime.strptime(stamp, SIMPLE_TIME_FORMAT) t = datetime.datetime.strptime(stamp, DateFormats.SIMPLE_TIME.value)
dt = datetime.datetime.now() dt = datetime.datetime.now()
dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) dt = dt.replace(hour=0, minute=0, second=0, microsecond=0)
dt += datetime.timedelta(hours=t.hour, minutes=t.minute) dt += datetime.timedelta(hours=t.hour, minutes=t.minute)
return dt return dt
except ValueError as e: except ValueError as e:
errors.append(e) errors.append(e)
package_logger.error(f"could not parse date with format \"{SIMPLE_DATETIME_FORMAT}\" or \"{SIMPLE_TIME_FORMAT}\"") package_logger.error(f"could not parse date with format \"{DateFormats.SIMPLE_DATETIME.value}\" or"
f" \"{DateFormats.SIMPLE_TIME.value}\"")
raise TimeParseError(errors) raise TimeParseError(errors)
@ -45,7 +48,7 @@ class MobatimeApi:
self.baseurl = self._sanitize_baseurl(baseurl) self.baseurl = self._sanitize_baseurl(baseurl)
self.user = user self.user = user
self.password = password self.password = password
self._ask_for_password = ask_for_password self.ask_for_password = ask_for_password
self._save_session = save_session self._save_session = save_session
self._session = None self._session = None
self._cookie_store = cookie_store self._cookie_store = cookie_store
@ -74,7 +77,7 @@ class MobatimeApi:
self.logger.warning(e) # file does not exist... ignored self.logger.warning(e) # file does not exist... ignored
except (EOFError, pickle.UnpicklingError) as e: except (EOFError, pickle.UnpicklingError) as e:
self.logger.warning(e) # file seems to be corrupt... ignoring self.logger.warning(e) # file seems to be corrupt... ignoring
request = self._session.get(self.baseurl + "Employee/GetEmployeeList") request = self._session.get(self.baseurl + "Employee/GetEmployeeList", timeout=2)
if 400 <= request.status_code < 500: if 400 <= request.status_code < 500:
self.logger.debug(f"got error {request.status_code}... trying to log in") self.logger.debug(f"got error {request.status_code}... trying to log in")
self._login(self._session) self._login(self._session)
@ -89,17 +92,21 @@ class MobatimeApi:
:raises: on status code != 2xx :raises: on status code != 2xx
""" """
if self.password is None and self._ask_for_password: if self.password is None and self.ask_for_password:
self.password = self._get_password() self.password = self._get_password()
elif self.password and not self.ask_for_password:
pass
else: else:
raise Exception("could not obtain password") raise TimebotObtainPasswordError("Unauthorized: could not obtain password")
login_data = { login_data = {
"username": self.user, "username": self.user,
"password": self.password, "password": self.password,
} }
session.cookies.clear_session_cookies() session.cookies.clear_session_cookies()
request = session.post(self.baseurl + "Account/LogOn", data=login_data) session.post(self.baseurl + "Account/LogOn",
request.raise_for_status() data=login_data,
timeout=2).raise_for_status() # This always gives 200 ... even with wrong password
session.get(self.baseurl + "Employee/GetEmployeeList", timeout=2).raise_for_status()
@staticmethod @staticmethod
def _get_password(): def _get_password():
@ -156,7 +163,7 @@ class MobatimeApi:
List all employees which are obviously in the same team as you. List all employees which are obviously in the same team as you.
:return: list of employees :return: list of employees
""" """
request = self.session.get(self.baseurl + "Employee/GetEmployees") request = self.session.get(self.baseurl + "Employee/GetEmployees", timeout=2)
request.raise_for_status() request.raise_for_status()
return request.json() return request.json()
@ -166,7 +173,7 @@ class MobatimeApi:
:return: all user information as dict :return: all user information as dict
""" """
request = self.session.get(self.baseurl + "Employee/GetUserProfileForCurrentUser") request = self.session.get(self.baseurl + "Employee/GetUserProfileForCurrentUser", timeout=2)
request.raise_for_status() request.raise_for_status()
return request.json() return request.json()
@ -179,8 +186,8 @@ class MobatimeApi:
:param str note: free text note added to the Mobatime entry :param str note: free text note added to the Mobatime entry
:return: requests response object :return: requests response object
""" """
punch_date = punch_datetime.strftime(SIMPLE_DATE_FORMAT) punch_date = punch_datetime.strftime(DateFormats.SIMPLE_DATE.value)
punch_time = punch_datetime.strftime(SIMPLE_TIME_FORMAT) punch_time = punch_datetime.strftime(DateFormats.SIMPLE_TIME.value)
entry_data = { entry_data = {
"periode0Date": punch_date, "periode0Date": punch_date,
"periode0Time": punch_time, "periode0Time": punch_time,
@ -190,10 +197,10 @@ class MobatimeApi:
} }
if note: if note:
entry_data["note"] = note entry_data["note"] = note
request = self.session.post(self.baseurl + "Entry/SaveEntry", data=entry_data) request = self.session.post(self.baseurl + "Entry/SaveEntry", data=entry_data, timeout=2)
return request return request
def get_entries(self, entries: int = 10, def get_entries(self, entries: Union[str, None] = 10,
start_date: datetime.datetime = None, start_date: datetime.datetime = None,
end_date: datetime.datetime = None) -> list: end_date: datetime.datetime = None) -> list:
""" """
@ -217,14 +224,14 @@ class MobatimeApi:
filters["filter"]["filters"].append({ filters["filter"]["filters"].append({
"field": "startDate", "field": "startDate",
"operator": "gte", "operator": "gte",
"value": start_date.strftime(SIMPLE_DATETIME_FORMAT), "value": start_date.strftime(DateFormats.SIMPLE_DATETIME.value),
}) })
filters["filter"]["filters"].append({ filters["filter"]["filters"].append({
"field": "endDate", "field": "endDate",
"operator": "lte", "operator": "lte",
"value": end_date.strftime(SIMPLE_DATETIME_FORMAT), "value": end_date.strftime(DateFormats.SIMPLE_DATETIME.value),
}) })
request = self.session.post(self.baseurl + "Entry/GetEntries", json=filters) request = self.session.post(self.baseurl + "Entry/GetEntries", json=filters, timeout=2)
request.raise_for_status() request.raise_for_status()
return request.json()["data"] return request.json()["data"]
@ -241,7 +248,7 @@ class MobatimeApi:
:return: dict of the info mentioned above :return: dict of the info mentioned above
""" """
request = self.session.get(self.baseurl + "Tracking/GetTrackingData") request = self.session.get(self.baseurl + "Tracking/GetTrackingData", timeout=2)
request.raise_for_status() request.raise_for_status()
return request.json() return request.json()
@ -254,7 +261,7 @@ class MobatimeApi:
:return: list with mentioned infos :return: list with mentioned infos
""" """
request = self.session.get(self.baseurl + "Employee/GetAccountInformation") request = self.session.get(self.baseurl + "Employee/GetAccountInformation", timeout=2)
request.raise_for_status() request.raise_for_status()
return request.json() return request.json()
@ -269,30 +276,38 @@ class TimeBot:
:param datetime.datetime punch_datetime: datetime object :param datetime.datetime punch_datetime: datetime object
:raises: on status code != 2xx :raises: on status code != 2xx
""" """
self.mobatime_api.save_entry(punch_datetime, COMING_ENTRY_CODE_ID, note="da").raise_for_status() self.mobatime_api.save_entry(punch_datetime, PunchCodes.COMING.value, note="da").raise_for_status()
def punch_out(self, punch_datetime: datetime.datetime): def punch_out(self, punch_datetime: datetime.datetime):
""" """
:param datetime.datetime punch_datetime: datetime object :param datetime.datetime punch_datetime: datetime object
:raises: on status code != 2xx :raises: on status code != 2xx
""" """
self.mobatime_api.save_entry(punch_datetime, LEAVING_ENTRY_CODE_ID, note="weg").raise_for_status() self.mobatime_api.save_entry(punch_datetime, PunchCodes.LEAVING.value, note="weg").raise_for_status()
def break_start(self, punch_datetime: datetime.datetime): def break_start(self, punch_datetime: datetime.datetime):
""" """
:param datetime.datetime punch_datetime: datetime object :param datetime.datetime punch_datetime: datetime object
:raises: on status code != 2xx :raises: on status code != 2xx
""" """
self.mobatime_api.save_entry(punch_datetime, BREAK_START_ENTRY_CODE_ID, note="pause").raise_for_status() self.mobatime_api.save_entry(punch_datetime, PunchCodes.BREAK_START.value, note="pause").raise_for_status()
def break_end(self, punch_datetime: datetime.datetime): def break_end(self, punch_datetime: datetime.datetime):
""" """
:param datetime.datetime punch_datetime: datetime object :param datetime.datetime punch_datetime: datetime object
:raises: on status code != 2xx :raises: on status code != 2xx
""" """
self.mobatime_api.save_entry(punch_datetime, BREAK_END_ENTRY_CODE_ID, note="pause ende").raise_for_status() self.mobatime_api.save_entry(punch_datetime, PunchCodes.BREAK_END.value, note="pause ende").raise_for_status()
def smart_punch(self, punch_datetime): def smart_punch(self, punch_datetime: datetime.datetime) -> None:
"""
Perform smart punch for today.
This tries to detect your last action and will create entries in to following order:
punch_in -> break_start -> break_end -> punch_out
:param datetime.datetime punch_datetime: a datetime object to identify the current day aka. today
"""
last_punch = self.mobatime_api.get_entries( last_punch = self.mobatime_api.get_entries(
1, 1,
start_date=punch_datetime.replace(hour=0, minute=0, second=0, microsecond=0), start_date=punch_datetime.replace(hour=0, minute=0, second=0, microsecond=0),
@ -302,16 +317,16 @@ class TimeBot:
if not last_punch or last_punch[0]["entryNumber"] is None: if not last_punch or last_punch[0]["entryNumber"] is None:
self.logger.debug("could not detect any time entry for today... punching in") self.logger.debug("could not detect any time entry for today... punching in")
method = "punch_in" method = "punch_in"
elif last_punch[0]["entryNumber"] == COMING_ENTRY_CODE_ID: elif last_punch[0]["entryNumber"] == PunchCodes.COMING.value:
self.logger.debug("your last entry was `punch_in`... starting break") self.logger.debug("your last entry was `punch_in`... starting break")
method = "break_start" method = "break_start"
elif last_punch[0]["entryNumber"] == BREAK_START_ENTRY_CODE_ID: elif last_punch[0]["entryNumber"] == PunchCodes.BREAK_START.value:
self.logger.debug("your last entry was `break_start`... ending break") self.logger.debug("your last entry was `break_start`... ending break")
method = "break_end" method = "break_end"
elif last_punch[0]["entryNumber"] == BREAK_END_ENTRY_CODE_ID: elif last_punch[0]["entryNumber"] == PunchCodes.BREAK_END.value:
self.logger.debug("your last entry was `break_end`... punching out") self.logger.debug("your last entry was `break_end`... punching out")
method = "punch_out" method = "punch_out"
elif last_punch[0]["entryNumber"] == LEAVING_ENTRY_CODE_ID: elif last_punch[0]["entryNumber"] == PunchCodes.LEAVING.value:
self.logger.error("your last entry was `punch_out`... punching in again with this command is not supported") self.logger.error("your last entry was `punch_out`... punching in again with this command is not supported")
sys.exit(1) sys.exit(1)
if method is None: if method is None:
@ -320,7 +335,63 @@ class TimeBot:
exit(1) exit(1)
self.logger.info("running `{}` with date `{}` and time `{}`".format( self.logger.info("running `{}` with date `{}` and time `{}`".format(
method, method,
punch_datetime.strftime(SIMPLE_DATE_FORMAT), punch_datetime.strftime(DateFormats.SIMPLE_DATE.value),
punch_datetime.strftime(SIMPLE_TIME_FORMAT), punch_datetime.strftime(DateFormats.SIMPLE_TIME.value),
)) ))
getattr(self, method)(punch_datetime) getattr(self, method)(punch_datetime)
def status(self) -> str:
"""
Get and format Mobatime tracking data and and account information.
The output ist formatted to print it to the console.
:return: preformatted status text
"""
tracking_data = self.mobatime_api.get_tracking_data()
account_info = self.mobatime_api.get_account_information()
ret = "Tracking Info:\n"
ret += f" Current State: {tracking_data['actualState']}\n"
ret += f" Last Booking: {tracking_data['lastBooking']}\n"
ret += f" Last Booking Date: {tracking_data['lastBookingDate']}\n"
ret += "Account Infos:\n"
for i in account_info:
ret += f" {i['accountName']}: {i['value']}\n"
return ret
def get_hours_present(self) -> datetime.timedelta:
"""
Calculate today's hours present.
:return: timedelta object with today's hours present
"""
now = datetime.datetime.now()
punches = self.mobatime_api.get_entries(
None,
start_date=now.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=now.replace(hour=0, minute=0, second=0, microsecond=0),
)
punch_out_time = now
if punches and punches[0]["entryNumber"] == PunchCodes.LEAVING.value:
punch_out_time = datetime.datetime.strptime(punches.pop(0)["dateTime"], DateFormats.FULL_DATETIME.value)
punch_in_time = None
if punches and punches[-1]["entryNumber"] == PunchCodes.COMING.value:
punch_in_time = datetime.datetime.strptime(punches.pop(-1)["dateTime"], DateFormats.FULL_DATETIME.value)
if punch_in_time is None:
return datetime.timedelta(seconds=0)
breaks = []
end = now
for i in punches:
if i["entryNumber"] == PunchCodes.BREAK_END.value:
end = datetime.datetime.strptime(i["dateTime"], DateFormats.FULL_DATETIME.value)
if i["entryNumber"] == PunchCodes.BREAK_START.value:
start = datetime.datetime.strptime(i["dateTime"], DateFormats.FULL_DATETIME.value)
breaks.append(end - start)
end = now
time_delta = punch_out_time - punch_in_time
for i in breaks:
time_delta = time_delta - i
return time_delta

Loading…
Cancel
Save