Finalize worklog implementation

This commit is contained in:
Fabian 2021-11-30 18:25:29 +01:00
parent 32621bcc87
commit 2e348e68b4
5 changed files with 181 additions and 74 deletions

View File

@ -7,7 +7,7 @@ from collections.abc import MutableMapping
from copy import deepcopy from copy import deepcopy
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from threading import Thread, Event from threading import Thread, Event
from typing import List, Tuple, Dict from typing import List, Tuple, Dict, Union
try: try:
from PySide6 import QtCore from PySide6 import QtCore
@ -330,27 +330,27 @@ def duration_to_str(duration: timedelta) -> str:
class PrevNextable: class PrevNextable:
def __init__(self, data: LogCommentsData): def __init__(self, data: LogCommentsData):
self._data = data self._data = data
self._date = date.today() self.date = date.today()
self._prev = None self._prev = None
self._next = None self._next = None
self._update_prev_next() self._update_prev_next()
def _update_prev_next(self): def _update_prev_next(self):
self._prev, self._next = self._data.get_prev_next_avail(self._date) self._prev, self._next = self._data.get_prev_next_avail(self.date)
def prev_next_avail(self) -> Tuple[bool, bool]: def prev_next_avail(self) -> Tuple[bool, bool]:
return self._prev is not None, self._next is not None return self._prev is not None, self._next is not None
def previous(self): def previous(self):
self._date = self._prev self.date = self._prev
self._update_prev_next() self._update_prev_next()
def next(self): def next(self):
self._date = self._next self.date = self._next
self._update_prev_next() self._update_prev_next()
def date(self) -> str: def date_str(self) -> str:
return self._date.strftime("%Y-%m-%d") return self.date.strftime("%Y-%m-%d")
class Report(PrevNextable): class Report(PrevNextable):
@ -359,8 +359,8 @@ class Report(PrevNextable):
self._not_log_len = 0 self._not_log_len = 0
def report(self) -> Tuple[List[List[str]], int]: def report(self) -> Tuple[List[List[str]], int]:
log = self._data.get_log(self._date) log = self._data.get_log(self.date)
if self._date == date.today(): if self.date == date.today():
log.append((datetime.now(), "End")) log.append((datetime.now(), "End"))
ret = [] ret = []
for i, t in enumerate(log): for i, t in enumerate(log):
@ -375,12 +375,12 @@ class Report(PrevNextable):
ret.append(["", "", ""]) ret.append(["", "", ""])
ret.append(["", "Sums", ""]) ret.append(["", "Sums", ""])
tasks_summary, total_sum = summary(self._data, self._date) tasks_summary, total_sum = summary(self._data, self.date)
for task, duration in tasks_summary.items(): for task, duration in tasks_summary.items():
ret.append([task, "", duration_to_str(duration)]) ret.append([task, "", duration_to_str(duration)])
ret.append(["Total sum", "", duration_to_str(total_sum)]) ret.append(["Total sum", "", duration_to_str(total_sum)])
self._not_log_len = 3 + len(tasks_summary) self._not_log_len = 3 + len(tasks_summary)
if self._date == date.today(): if self.date == date.today():
self._not_log_len += 1 self._not_log_len += 1
editable_len = len(ret) - (4 + len(tasks_summary)) editable_len = len(ret) - (4 + len(tasks_summary))
@ -391,10 +391,10 @@ class Report(PrevNextable):
if not report: if not report:
return return
report = list(map( report = list(map(
lambda x: (datetime.combine(self._date, datetime.strptime(x[1], "%H:%M").time()), x[0]), lambda x: (datetime.combine(self.date, datetime.strptime(x[1], "%H:%M").time()), x[0]),
report report
)) ))
self._data.set_log(self._date, report) self._data.set_log(self.date, report)
class Worklog(PrevNextable): class Worklog(PrevNextable):
@ -403,22 +403,22 @@ class Worklog(PrevNextable):
self._worklog = [] self._worklog = []
@property @property
def worklog(self) -> List[List[str]]: def worklog(self) -> List[List[Union[str, timedelta]]]:
tasks_summary, total_sum = summary(self._data, self._date) tasks_summary, total_sum = summary(self._data, self.date)
comments = self._data.get_comments(self._date) comments = self._data.get_comments(self.date)
self._worklog = [] self._worklog = []
for task, duration in tasks_summary.items(): for task, duration in tasks_summary.items():
self._worklog.append([task, comments.setdefault(task, ""), duration_to_str(duration)]) self._worklog.append([task, comments.setdefault(task, ""), duration])
self._worklog.append(["Total sum", "", duration_to_str(total_sum)]) self._worklog.append(["Total sum", "", total_sum])
return deepcopy(self._worklog) return deepcopy(self._worklog)
@worklog.setter @worklog.setter
def worklog(self, worklog: List[List[str]]): def worklog(self, worklog: List[List[str]]):
log = self._data.get_log(self._date) log = self._data.get_log(self.date)
set_comments = dict() set_comments = dict()
for i, (task, comment, duration) in enumerate(worklog[:-1]): for i, (task, comment, duration) in enumerate(worklog[:-1]):
set_comments[task] = comment set_comments[task] = comment
if self._worklog[i][0] != task: if self._worklog[i][0] != task:
log = list(map(lambda x: (x[0], x[1].replace(self._worklog[i][0], task)), log)) log = list(map(lambda x: (x[0], x[1].replace(self._worklog[i][0], task)), log))
self._data.set_comments(self._date, set_comments) self._data.set_comments(self.date, set_comments)
self._data.set_log(self._date, log) self._data.set_log(self.date, log)

View File

@ -4,6 +4,7 @@ import sys
from functools import partial from functools import partial
from fime.config import Config from fime.config import Config
from fime.worklog import WorklogDialog
try: try:
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
@ -12,7 +13,7 @@ except ImportError:
from PySide2 import QtCore, QtWidgets from PySide2 import QtCore, QtWidgets
PYSIDE_6 = False PYSIDE_6 = False
from fime.data import Tasks, Log, Data, LogCommentsData from fime.data import Tasks, Log, Data, LogCommentsData, Worklog
from fime.exceptions import FimeException from fime.exceptions import FimeException
from fime.import_task import ImportTask from fime.import_task import ImportTask
from fime.report import Report from fime.report import Report
@ -28,18 +29,19 @@ class App:
data = Data() data = Data()
self.tasks = Tasks(data) self.tasks = Tasks(data)
lcd = LogCommentsData(data) lcd = LogCommentsData(data)
self.worklogData = Worklog(lcd)
self.log = Log(lcd) self.log = Log(lcd)
self._active_task = self.log.last_log() or "Nothing" self._active_task = self.log.last_log() or "Nothing"
self.config = Config() config = Config()
if self.config.tray_theme == "light": if config.tray_theme == "light":
icon = get_icon("appointment-new-light") icon = get_icon("appointment-new-light")
else: else:
icon = get_icon("appointment-new") icon = get_icon("appointment-new")
self.menu = QtWidgets.QMenu(None) self.menu = QtWidgets.QMenu(None)
self.import_task = ImportTask(self.config, None) self.import_task = ImportTask(config, None)
self.import_task.accepted.connect(self.new_task_imported) self.import_task.accepted.connect(self.new_task_imported)
self.taskEdit = TaskEdit(None) self.taskEdit = TaskEdit(None)
@ -48,6 +50,9 @@ class App:
self.reportDialog = Report(self.tasks, None) self.reportDialog = Report(self.tasks, None)
self.reportDialog.accepted.connect(self.report_done) self.reportDialog.accepted.connect(self.report_done)
self.worklogDialog = WorklogDialog(config, None)
self.worklogDialog.accepted.connect(self.report_done)
self.tray = QtWidgets.QSystemTrayIcon() self.tray = QtWidgets.QSystemTrayIcon()
self.tray.setIcon(icon) self.tray.setIcon(icon)
self.tray.setContextMenu(self.menu) self.tray.setContextMenu(self.menu)
@ -120,6 +125,9 @@ class App:
report_action = self.menu.addAction("Report") report_action = self.menu.addAction("Report")
report_action.triggered.connect(self.report) report_action.triggered.connect(self.report)
worklog_action = self.menu.addAction("Worklog")
worklog_action.triggered.connect(self.worklog)
self.menu.addSeparator() self.menu.addSeparator()
exit_action = self.menu.addAction("Close") exit_action = self.menu.addAction("Close")
@ -146,6 +154,11 @@ class App:
self.reportDialog.set_data(self.log.report()) self.reportDialog.set_data(self.log.report())
self.reportDialog.show() self.reportDialog.show()
@QtCore.Slot()
def worklog(self):
self.worklogDialog.set_data(self.worklogData)
self.worklogDialog.show()
@QtCore.Slot() @QtCore.Slot()
def new_task_slot(self): def new_task_slot(self):
self.import_task.reset_task_text() self.import_task.reset_task_text()
@ -157,14 +170,21 @@ class App:
self.taskEdit.show() self.taskEdit.show()
def excepthook(original, e_type, e_value, tb_obj):
if e_type is FimeException:
QtWidgets.QMessageBox.critical(None, "Error", str(e_value), QtWidgets.QMessageBox.Ok)
else:
original(e_type, e_value, tb_obj)
def main(): def main():
try: # important for QStandardPath to be correct
# important for QStandardPath to be correct QtCore.QCoreApplication.setApplicationName("fime")
QtCore.QCoreApplication.setApplicationName("fime") # also catches exceptions in other threads
app = App() original_excepthook = sys.excepthook
app.run() sys.excepthook = partial(excepthook, original_excepthook)
except FimeException as e: app = App()
QtWidgets.QMessageBox.critical(None, "Error", str(e), QtWidgets.QMessageBox.Ok) app.run()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -107,7 +107,7 @@ class Report(QtWidgets.QDialog):
self._report.save(self._report_data) self._report.save(self._report_data)
def update_title(self): def update_title(self):
self.setWindowTitle(f"Report {self._report.date()}") self.setWindowTitle(f"Report {self._report.date_str()}")
def refresh_table(self): def refresh_table(self):
self._report_data, self._edit_len = self._report.report() self._report_data, self._edit_len = self._report.report()

View File

@ -1,7 +1,7 @@
from functools import reduce, partial from functools import reduce, partial
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
from fime.data import Worklog from fime.data import Worklog, duration_to_str
from fime.progressindicator import ProgressIndicator from fime.progressindicator import ProgressIndicator
from fime.task_completer import TaskCompleter from fime.task_completer import TaskCompleter
from fime.util import get_icon, EditStartedDetector, Status from fime.util import get_icon, EditStartedDetector, Status
@ -143,7 +143,7 @@ class WorklogDialog(QtWidgets.QDialog):
self.upload_button = QtWidgets.QPushButton() self.upload_button = QtWidgets.QPushButton()
self.upload_button.setText("Upload") self.upload_button.setText("Upload")
self.upload_button.setIcon(get_icon("cloud-upload")) self.upload_button.setIcon(get_icon("cloud-upload"))
#self.upload_button.pressed.connect(self.del_log) self.upload_button.pressed.connect(self.upload)
self.upload_button.setAutoDefault(False) self.upload_button.setAutoDefault(False)
self.upload_button.setMinimumWidth(self.upload_button.minimumSizeHint().width() * 1.33) self.upload_button.setMinimumWidth(self.upload_button.minimumSizeHint().width() * 1.33)
self.upload_button.setEnabled(False) self.upload_button.setEnabled(False)
@ -172,6 +172,7 @@ class WorklogDialog(QtWidgets.QDialog):
def set_data(self, worklog: Worklog): def set_data(self, worklog: Worklog):
self._worklog = worklog self._worklog = worklog
self.rest.purge_cache()
self.update_all() self.update_all()
def update_all(self): def update_all(self):
@ -201,7 +202,7 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.worklog = self._worklog_data self._worklog.worklog = self._worklog_data
def update_title(self): def update_title(self):
self.setWindowTitle(f"Worklog {self._worklog.date()}") self.setWindowTitle(f"Worklog {self._worklog.date_str()}")
def refresh_table(self): def refresh_table(self):
self._worklog_data = self._worklog.worklog self._worklog_data = self._worklog.worklog
@ -231,7 +232,7 @@ class WorklogDialog(QtWidgets.QDialog):
if row == 0 and not self._focussed: if row == 0 and not self._focussed:
text_edit.setFocus() text_edit.setFocus()
self._focussed = True self._focussed = True
item2 = QtWidgets.QTableWidgetItem(self._worklog_data[row][2]) item2 = QtWidgets.QTableWidgetItem(duration_to_str(self._worklog_data[row][2]))
self.tableWidget.setItem(row, 2, item2) self.tableWidget.setItem(row, 2, item2)
item2.setFlags(item2.flags() & QtCore.Qt.ItemIsEnabled) item2.setFlags(item2.flags() & QtCore.Qt.ItemIsEnabled)
self._changing_items = False self._changing_items = False
@ -298,6 +299,7 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.previous() self._worklog.previous()
self._focussed = False self._focussed = False
self.update_all() self.update_all()
self.upload_button.setEnabled(False)
@QtCore.Slot() @QtCore.Slot()
def next(self): def next(self):
@ -305,6 +307,14 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.next() self._worklog.next()
self._focussed = False self._focussed = False
self.update_all() self.update_all()
self.upload_button.setEnabled(False)
@QtCore.Slot()
def upload(self):
issues = list(map(lambda x: (x[0].split()[0], x[1], x[2]), self._worklog_data[:-1]))
self.rest.upload(issues, self._worklog.date)
self.update_statuses()
self.update_timer.start()
@QtCore.Slot() @QtCore.Slot()
def cell_changed(self, row, _): def cell_changed(self, row, _):
@ -313,6 +323,7 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog_data[row][0] = self.tableWidget.item(row, 0).text() self._worklog_data[row][0] = self.tableWidget.item(row, 0).text()
self.save() self.save()
self.update_all() self.update_all()
self.upload_button.setEnabled(False)
@QtCore.Slot() @QtCore.Slot()
def text_edit_changed(self, text, row): def text_edit_changed(self, text, row):

View File

@ -1,8 +1,10 @@
import os import os
import sys
import traceback import traceback
from concurrent.futures import Future from concurrent.futures import Future
from datetime import date, datetime from datetime import date, datetime, timedelta, time
from functools import partial from functools import partial
from textwrap import dedent
from threading import Lock from threading import Lock
from typing import List, Dict, Tuple from typing import List, Dict, Tuple
@ -20,44 +22,51 @@ class WorklogRest:
self.user_url = os.path.join(config.jira_url, "rest/api/2/myself") self.user_url = os.path.join(config.jira_url, "rest/api/2/myself")
self.issue_url = os.path.join(config.jira_url, "rest/api/2/issue/{}") self.issue_url = os.path.join(config.jira_url, "rest/api/2/issue/{}")
self.worklog_url = os.path.join(config.jira_url, "rest/api/2/issue/{}/worklog") self.worklog_url = os.path.join(config.jira_url, "rest/api/2/issue/{}/worklog")
self.worklog_update_url = os.path.join(config.jira_url, "rest/api/2/issue/{issue}/worklog/{worklog}") self.worklog_update_url = os.path.join(config.jira_url, "rest/api/2/issue/{issue_key}/worklog/{worklog_id}")
self.session = FuturesSession() self.session = FuturesSession()
self.user = self._req_user() self._user = None
self._issue_state: Dict[str, Tuple[Status, str]] = {} self._issue_state: Dict[str, Tuple[Status, str]] = dict()
self._issue_state_lock = Lock() self._issue_state_lock = Lock()
self._req_user()
def _req_user(self): def _req_user(self):
return self.session.get( future = self.session.get(
self.user_url, self.user_url,
headers={ headers={
"Authorization": f"Bearer {self.config.jira_token}", "Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json", "Accept": "application/json",
}, },
) )
future.add_done_callback(self._resp_user)
def _get_user(self): def _resp_user(self, future):
try: try:
return self.user.result().json()["key"] self._user = future.result().json()["key"]
except Exception: except Exception:
raise FimeException("Could not get user key:\n" + traceback.format_exc()) print("Could not get user key:\n", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
def get_issues_state(self, issue_keys: List[str]): def get_issues_state(self, issue_keys: List[str]):
ret = [] ret = []
with self._issue_state_lock: with self._issue_state_lock:
for issue_key in issue_keys: for issue_key in issue_keys:
if issue_key not in self._issue_state: if issue_key not in self._issue_state:
self._issue_state[issue_key] = (Status.PROGRESS, "Fetching") self._issue_state[issue_key] = (Status.PROGRESS, "Working")
self._req_issue(issue_key) self._req_issue(issue_key)
ret.append(self._issue_state[issue_key]) ret.append(self._issue_state[issue_key])
return ret return ret
def purge_cache(self):
self._issue_state = dict()
def _req_issue(self, issue_key: str): def _req_issue(self, issue_key: str):
future = self.session.get(self.issue_url.format(issue_key), future = self.session.get(
headers={ self.issue_url.format(issue_key),
"Authorization": f"Bearer {self.config.jira_token}", headers={
"Accept": "application/json", "Authorization": f"Bearer {self.config.jira_token}",
}, "Accept": "application/json",
) },
)
future.add_done_callback(partial(self._resp_issue, issue_key)) future.add_done_callback(partial(self._resp_issue, issue_key))
def _resp_issue(self, issue_key: str, future: Future): def _resp_issue(self, issue_key: str, future: Future):
@ -69,28 +78,95 @@ class WorklogRest:
else: else:
self._issue_state[issue_key] = (Status.ERROR, "Could not find specified issue") self._issue_state[issue_key] = (Status.ERROR, "Could not find specified issue")
def get(self, issue_key: str, date: date): def _upload_sanity_check(self, issue_keys: List[str]):
resp = requests.get(self.issue_url.format(issue_key), if not self._user:
headers={ raise FimeException("Could not get user key")
"Authorization": f"Bearer {self.config.jira_token}", for issue_key in issue_keys:
"Accept": "application/json", if issue_key not in self._issue_state or self._issue_state[issue_key] is not Status.OK:
}, raise FimeException(f"Issue with key {issue_key} in unexpected state")
)
if resp.status_code != 200: def upload(self, issues: List[Tuple[str, str, timedelta]], pdate: date):
raise RuntimeError("issue does not exist") """@:param issues: [(
resp = requests.get(self.worklog_url.format(issue_key), "ISS-1234",
headers={ "I did some stuff",
"Authorization": f"Bearer {self.config.jira_token}", timedelta(seconds=300),
"Accept": "application/json", ), ... ]
}, """
) self._upload_sanity_check(list(map(lambda x: x[0], issues)))
for issue in issues:
future = self.session.get(
self.worklog_url.format(issue[0]),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
}
)
future.add_done_callback(partial(self._worklog_check_resp, issue[0], issue[1], issue[2], pdate))
self._issue_state[issue[0]] = (Status.PROGRESS, "Working")
def _worklog_check_resp(self, issue_key: str, comment: str, time_spent: timedelta, pdate: date, future: Future):
resp = future.result()
worklogs = resp.json()["worklogs"] worklogs = resp.json()["worklogs"]
found = False worklog_id = None
for log in worklogs: for log in worklogs:
if log["author"]["key"] == self.user \ if log["author"]["key"] == self._user \
and datetime.strptime(log["started"], "%Y-%m-%dT%H:%M:%S.%f%z").date() == date: and datetime.strptime(log["started"], "%Y-%m-%dT%H:%M:%S.%f%z").date() == pdate:
print(log["id"]) worklog_id = log["id"]
print(log["comment"])
found = True
break break
print(found) if worklog_id:
print(f"Found existing worklog for issue {issue_key} with id {worklog_id}")
self._worklog_update(issue_key, worklog_id, comment, time_spent, pdate)
else:
print(f"Did not find existing worklog for issue {issue_key}")
self._worklog_create(issue_key, comment, time_spent, pdate)
def _worklog_create(self, issue_key: str, comment: str, time_spent: timedelta, pdate: date):
future = self.session.post(
self.worklog_url.format(issue_key),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
json={
"started": datetime.combine(
pdate, time(8, 0), datetime.now().astimezone().tzinfo).strftime("%Y-%m-%dT%H:%M:%S.000%z"),
"timeSpentSeconds": time_spent.seconds,
"comment": comment,
}
)
future.add_done_callback(partial(self._worklog_resp, issue_key))
def _worklog_update(self, issue_key: str, worklog_id: str, comment: str, time_spent: timedelta, pdate: date):
future = self.session.put(
self.worklog_update_url.format(issue_key=issue_key, worklog_id=worklog_id),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
json={
"started": datetime.combine(
pdate, time(8, 0), datetime.now().astimezone().tzinfo).strftime("%Y-%m-%dT%H:%M:%S.000%z"),
"timeSpentSeconds": time_spent.seconds,
"comment": comment,
}
)
future.add_done_callback(partial(self._worklog_resp, issue_key))
def _worklog_resp(self, issue_key: str, future: Future):
resp: requests.Response = future.result()
with self._issue_state_lock:
if resp.status_code == 200:
self._issue_state[issue_key] = (Status.OK, "Successfully uploaded")
print(f"Successfully uploaded issue {issue_key}")
else:
msg = dedent(f"""\
Worklog upload failed:
Method: {resp.request.method}
URL: {resp.request.url}
Response code: {resp.status_code}
Response: {resp.text}
""")
self._issue_state[issue_key] = (Status.ERROR, msg)
print(msg, file=sys.stderr)