Skip to content

Commit

Permalink
#3 upload timesheets to kgl and parse salary sheet (start)
Browse files Browse the repository at this point in the history
  • Loading branch information
synsi23b committed Sep 8, 2022
1 parent 080e84b commit b090a32
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 20 deletions.
44 changes: 38 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ programmed against kimai version 1.21

# setup

## apt

```bash
sudo apt install xvfb
# maybe no libre office. the pdf it makes is not good. Tweak the template file and it might work
# used to make pdfs from the xlsx timesheets instead of convertapi
sudo apt install libreoffice --no-install-recommends --no-install-suggests
```
## create second database for keeping own records on kimai host

```sql
Expand All @@ -26,10 +34,6 @@ pip3 install -r requirements.txt
```
2. some information like the database and email information gets read from the kimai env file, so make sure kimai is in the default path */var/www/kimai2* and its environment file is populated
3. setup crontabs to run for a user that can read the kimai env file, for an example see work-life-checker.
4. install libreoffice for commandline xlsx -> pdf conversion
```bash
apt install libreoffice --no-install-recommends --no-install-suggests
```


## cron entries
Expand Down Expand Up @@ -78,8 +82,36 @@ The arguments to supply are the new Users First and Last name, e-mail address, g

The users group name needs to match a Project name in the Kimai instance. From this grouping, the users salary is also retrieved. The salary needs to be set on the project level in Kimai. The group/project will also be used to check for seasonal changes in allowable worktime per month regardless of monthly hours set.

The monthly hours will be used to create a budgeted activity for this user only, but going over budget is acceptable (set in kimai settings)
The monthly hours will be used to create a budgeted activity for this user only, but going over budget is acceptable (set in Kimai settings)

Another way to create multiple users at once is to supply a csv file after the flag --file. There is an example file in the files folder.

User creation will trigger an e-mail using the kimai credentials. The email contains the user manuals and the login credentials.
User creation will trigger an e-mail using the Kimai credentials. The email contains the user manuals and the login credentials.

# create_timesheet_kgl script

the script will take the timesheets of projects marked with \*generate_sheets\* as the first line of their description. Other parts of the description serve as a configuration space using yaml to set the Vorlesungsfreie Zeit or maximum hours per month and week. For example:

```yaml
*generate_sheets*
max_weekly: 40
max_monthly: 0
max_weekly_season: 20
seasons:
- 14.10.2024 - 14.02.2025
- 15.04.2024 - 19.07.2024
- 16.10.2023 - 09.02.2024
- 11.04.2023 - 14.07.2023
- 17.10.2022 - 10.02.2023
```
When run, the script will test whether the day is before or past the 15th. and collect the timesheets accordingly. if it is after the 15th, for the current month, before, for the preceding month. When the script is called with the parameter "--preliminary" the timesheets wont be transmitted to KGL, but just to the users to inform them of their current times.
Further checks done by the script:
- warn users with open timesheets that are longer than 12 hours
- warn users about overwork in a week or month
- warn users that have no timesheet at all
The script should be run a lot of times during the end of month period with the preliminary flag to get everyone's timesheets in order. If a user changes something, this change will be detected and they will get another mail after at least 15 minutes have passed since the last change.
Without the preliminary flag, the script should only be run one time sometime at the start of the new month. It will create PDFs of the timesheets using convertapi. These can be uploaded to the Nextcloud and KGL. The upload to KGL is done via selenium and might brake should they change their website. Similarily, convertapi needs a fresh api token every now and than, unless you pay for it.
41 changes: 29 additions & 12 deletions create_timesheet_kgl.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
import argparse
import calendar
import convertapi
from kgl_online import WebPortal



convertapi.api_secret = 'DsVuSJPj5aOIZPxE'
THIS_LOCATION = Path(__file__).parent.resolve()


def utc_unaware_to_tz(dt:datetime, tz) -> datetime:
Expand All @@ -24,12 +24,11 @@ def utc_unaware_to_tz(dt:datetime, tz) -> datetime:

def fill_hours_files(alias:str, sheets:list, outfolder:Path, fileprefix:str = ""):
company_data = {}
this_location = Path(__file__).parent.resolve()
#print(this_location.parent)
with open(str(this_location.parent / "kgl_info.yaml")) as inf:
#print(THIS_LOCATION.parent)
with open(str(THIS_LOCATION.parent / "kgl_info.yaml")) as inf:
company_data = yaml.load(inf, yaml.Loader)
fname = "Stundenzettel MM_JJ - Mitarbeiter_Lohn.xlsx"
folder = this_location / "files"
folder = THIS_LOCATION / "files"
workbook = load_workbook(filename=str(folder / fname))
wsheet = workbook.active

Expand Down Expand Up @@ -200,11 +199,8 @@ def send_missing_sheets_msg(year, month, worker):
db_util.set_missing_sheet_reminder_send(worker[0], year, month)


if __name__ == "__main__":
thisfile = Path(__file__).resolve()
logging.basicConfig(filename=str(thisfile.parent.parent / f"kimai2_autohire_{thisfile.stem}.log"),
format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)

def main(kgl_cred):
convertapi.api_secret = kgl_cred["convertapi"]
parser = argparse.ArgumentParser(description="export timesheets for users of projects with *generate_sheets* in project description")
#parser.add_argument("--today", action="store_true", help="use today as a time base to generate monthly report rather than last month")
#parser.add_argument("--lastmonth", action="store_true", help="whether or not this generation is preliminary, e.g. not a real export")
Expand All @@ -229,6 +225,7 @@ def send_missing_sheets_msg(year, month, worker):
preliminary = args.preliminary
workers, missing = create_reports(year, month, preliminary, outf)

report_to_kgl = []
for w in workers:
# address, subject, txt-msg, attachments
send_mail(w[0]._mail, w[1], w[2], [w[3]])
Expand All @@ -239,5 +236,25 @@ def send_missing_sheets_msg(year, month, worker):
w[0].set_last_generation_sheet()
if not preliminary:
w[0].mark_sheets_exported()
report_to_kgl.append(str(w[3]))
for m in missing:
send_missing_sheets_msg(year, month, m)
send_missing_sheets_msg(year, month, m)
if report_to_kgl:
# open kgl webprotal and send message with attachments
with WebPortal(kgl_cred) as kgl:
kgl.login()
kgl.upload_timesheets(report_to_kgl, year, month)
return 0

if __name__ == "__main__":
thisfile = Path(__file__).resolve()
logging.basicConfig(filename=str(thisfile.parent.parent / f"kimai2_autohire_{thisfile.stem}.log"),
format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
try:
with open(str(THIS_LOCATION.parent / "kgl_cred.yaml")) as inf:
kgl_cred = yaml.load(inf, yaml.Loader)
ret = main(kgl_cred)
except Exception as e:
logging.exception(f"Uncaught exception in from main! {e}")
ret = -1
exit(ret)
2 changes: 1 addition & 1 deletion cron/export_to_nc.bash
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ YEARLONG=`date +%Y -d 'last month'`
YEAR=`echo $YEARLONG | cut -c 3,4`
MONTH=`date +%m -d 'last month'`

SRC="/var/lib/lxd/containers/kimai/rootfs/home/ubuntu/export/*${MONTH}_${YEAR}*.xlsx"
SRC="/var/lib/lxd/containers/kimai/rootfs/home/ubuntu/reports_kgl/Stundenzettel*${MONTH}_${YEAR}*.pdf"
DST="/media/softwareraid/nextcloud/__groupfolders/2/STUNDENZETTEL/$YEARLONG/$MONTH"
DSTP="/media/softwareraid/nextcloud/__groupfolders/2/STUNDENZETTEL/$YEARLONG"

Expand Down
3 changes: 3 additions & 0 deletions cron/kgl_cred.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
user: blablalba
pass: albalbalb
convertapi: secrettoken
184 changes: 184 additions & 0 deletions kgl_online.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service as FireService
from webdriver_manager.firefox import GeckoDriverManager
#from selenium.webdriver.chrome.service import Service as ChromeService
#from webdriver_manager.chrome import ChromeDriverManager
import PyPDF2
import re
from time import sleep
from pathlib import Path
from datetime import datetime
from pyvirtualdisplay.smartdisplay import SmartDisplay


class WebPortal:
def __init__(self, credentials):
self._driver = None
self._lastname = "none"
self._screenfolder = Path(__file__).resolve().parent / "screenshots"
self._creds = credentials
self._disp = None

def __enter__(self):
self._disp = SmartDisplay()
self._disp.start()
self._driver = webdriver.Firefox(service=FireService(GeckoDriverManager().install()))
#self._driver = webdriver.Chrome(service=ChromeService(ChromeDriverManager().install()))
return self

def __exit__(self, type, value, traceback):
if self._driver is not None:
self._driver.quit()
self._disp.stop()

def login(self):
self._driver.get("https://kundenportal.klg-rhein-main.de/")
# wait benutzer name
elem = self._await_name("bn")
elem.send_keys(self._creds["user"])
# wait password
elem = self._await_name("pw")
elem.send_keys(self._creds["pass"])
# press login button
elem = self._await_name("login")
self._screenshot()
elem.click()

def upload_timesheets(self, sheets_abspathstr:list, year, month):
# switch to message center page
elem = self._await_name("message")
self._screenshot()
elem.click()
# go to new message screen
elem = self._await_name("newMessage")
self._screenshot()
elem.click()
# fill in subject with current year / month
elem = self._await_name("subject")
elem.send_keys(f"Stundenzettel {month} / {year}")
# TODO sending the keys works, but they get appended to the original Message "mit freundlichen Gruessen.. "
elem = self._driver.find_element(By.CLASS_NAME, "note-editable")
#elem.send_keys(Keys.HOME)
elem.send_keys(f"\n\n*Diese Nachricht wurde von unserer Zeiterfassungssoftware automatisiert versendet*")
# switch to attachment screen by clicking little paperclip icon
#elem = self._driver.find_element(By.CLASS_NAME, "fa-paperclip")
# go to the list element containing the paperclip
#elem = elem.find_element(By.XPATH, "./..")
# TODO didnt work, using xpath from tab list parent and indexed location. Might break on UI layout change!
elem = self._driver.find_element(By.XPATH, '//*[@name="tab"]/ul/li[3]')
self._screenshot()
elem.click()
# open addfile pop up
elem = self._await_name("AddFile")
self._screenshot()
elem.click()
# transmit files to the page
elem = self._await_name("file[]")
for f in sheets_abspathstr:
elem.send_keys(f)
sleep(0.5)
while(not elem.is_enabled()):
sleep(0.5)
self._screenshot("files")
# close the file dialog and send the message
#elem = self._await_name("Close")
# there are multiple buttons called close :(
elems = self._driver.find_elements(By.NAME, "Close")
elem = None
for elem in elems:
if elem.text == 'Schließen':
break
elem.click()
sleep(10)
elem = self._await_name("send")
self._screenshot()
elem.click()
sleep(10)
self._screenshot("upload-completed")

def download_salary_file(self, year, month):
pass

def _await_name(self, name, timeout=60):
self._lastname = name
return WebDriverWait(self._driver, timeout=timeout).until(lambda d: d.find_element(By.NAME, name))

def _screenshot(self, override=""):
# sleep to give the browser some time to build the ui before screenshoting
sleep(1)
sname = datetime.utcnow().strftime("%y_%m_%d-%H_%M_%S_%f-")
if override != "":
sname += override
else:
sname += self._lastname
# take screenshot and store under name
spath = str(self._screenfolder / sname) + ".png"
print(spath)
img = self._disp.waitgrab()
img.save(spath)


def pdf_salary_extract(filepath, year, month):
year = year % 100
datenotmatch = True
res = []
# open the salary page
# download the salary file
# extract the data and form list
with open(filepath, "rb") as inf:
pdf = PyPDF2.PdfFileReader(inf)
if len(pdf.pages) > 1:
raise RuntimeError("KGL Webportal currently not supporting multi-page salary files")
text = pdf.pages[0].extract_text()
lines = text.split("\n")
mapat = re.compile(r'\s+(\d+)\s+(.+),\s(.+?)\s+(\d?\.?\d{1,3}),(\d{2}).+')
sumpat = re.compile(r'\s+Gesamtsumme:\s+(\d*\.?\d{1,3}),(\d{2})')
datepat = re.compile(r'AN-Übersichten\slt\.\sZV-Art\s+MONAT\s+(\d{2})\/(\d{2}).+')
sum = 0
sumextract = 0
for l in lines:
mch = mapat.match(l)
if mch:
eu, ce = mch.group(4), int(mch.group(5))
eu = int(eu.replace(".", ""))
eurocent = eu * 100 + ce
sum += eurocent
res.append({
"original": l,
"pe": int(mch.group(1)),
"lastname": mch.group(2),
"firstname": mch.group(3),
"eurocent": eurocent
})
smch = sumpat.match(l)
if smch:
eu, ce = smch.group(1), int(smch.group(2))
eu = int(eu.replace(".", ""))
sumextract = eu * 100 + ce
dmch = datepat.match(l)
if dmch and int(dmch.group(1)) == month and int(dmch.group(2)) == year:
datenotmatch = False
if sumextract != sum:
raise RuntimeError("Salary sum not matching")
if datenotmatch:
raise RuntimeError("Salary file is not fore the asked for month / year")
return res


if __name__ == "__main__":
pass
#p = Path("./upfiles").glob('**/*')
#files = [str(x.resolve()) for x in p if x.is_file()]
#print(files)
cred = {"user": "uname", "pass": "passw"}
with WebPortal(cred) as kgl:
kgl.login()
sleep(10)
kgl._screenshot()
#kgl.upload_timesheets(files, 2022, 8)
pass
#fp = "C:/Users/mail/Downloads/435_11256_000_000000_202207_20220808_095638_AN-Übersichten lt ZV-Art.PDF"
#print(pdf_salary_extract(fp, 2022, 7))

6 changes: 5 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ pytz
babel
openpyxl
pillow
convertapi
convertapi
selenium
PyVirtualDisplay
webdriver-manager
PyPDF2
4 changes: 4 additions & 0 deletions screenshots/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

0 comments on commit b090a32

Please sign in to comment.