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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
from functools import reduce, partial
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.task_completer import TaskCompleter
from fime.util import get_icon, EditStartedDetector, Status
@ -143,7 +143,7 @@ class WorklogDialog(QtWidgets.QDialog):
self.upload_button = QtWidgets.QPushButton()
self.upload_button.setText("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.setMinimumWidth(self.upload_button.minimumSizeHint().width() * 1.33)
self.upload_button.setEnabled(False)
@ -172,6 +172,7 @@ class WorklogDialog(QtWidgets.QDialog):
def set_data(self, worklog: Worklog):
self._worklog = worklog
self.rest.purge_cache()
self.update_all()
def update_all(self):
@ -201,7 +202,7 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.worklog = self._worklog_data
def update_title(self):
self.setWindowTitle(f"Worklog {self._worklog.date()}")
self.setWindowTitle(f"Worklog {self._worklog.date_str()}")
def refresh_table(self):
self._worklog_data = self._worklog.worklog
@ -231,7 +232,7 @@ class WorklogDialog(QtWidgets.QDialog):
if row == 0 and not self._focussed:
text_edit.setFocus()
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)
item2.setFlags(item2.flags() & QtCore.Qt.ItemIsEnabled)
self._changing_items = False
@ -298,6 +299,7 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.previous()
self._focussed = False
self.update_all()
self.upload_button.setEnabled(False)
@QtCore.Slot()
def next(self):
@ -305,6 +307,14 @@ class WorklogDialog(QtWidgets.QDialog):
self._worklog.next()
self._focussed = False
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()
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.save()
self.update_all()
self.upload_button.setEnabled(False)
@QtCore.Slot()
def text_edit_changed(self, text, row):

View File

@ -1,8 +1,10 @@
import os
import sys
import traceback
from concurrent.futures import Future
from datetime import date, datetime
from datetime import date, datetime, timedelta, time
from functools import partial
from textwrap import dedent
from threading import Lock
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.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_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.user = self._req_user()
self._issue_state: Dict[str, Tuple[Status, str]] = {}
self._user = None
self._issue_state: Dict[str, Tuple[Status, str]] = dict()
self._issue_state_lock = Lock()
self._req_user()
def _req_user(self):
return self.session.get(
future = self.session.get(
self.user_url,
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
},
)
future.add_done_callback(self._resp_user)
def _get_user(self):
def _resp_user(self, future):
try:
return self.user.result().json()["key"]
self._user = future.result().json()["key"]
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]):
ret = []
with self._issue_state_lock:
for issue_key in issue_keys:
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)
ret.append(self._issue_state[issue_key])
return ret
def purge_cache(self):
self._issue_state = dict()
def _req_issue(self, issue_key: str):
future = self.session.get(self.issue_url.format(issue_key),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
},
)
future = self.session.get(
self.issue_url.format(issue_key),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
},
)
future.add_done_callback(partial(self._resp_issue, issue_key))
def _resp_issue(self, issue_key: str, future: Future):
@ -69,28 +78,95 @@ class WorklogRest:
else:
self._issue_state[issue_key] = (Status.ERROR, "Could not find specified issue")
def get(self, issue_key: str, date: date):
resp = requests.get(self.issue_url.format(issue_key),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
},
)
if resp.status_code != 200:
raise RuntimeError("issue does not exist")
resp = requests.get(self.worklog_url.format(issue_key),
headers={
"Authorization": f"Bearer {self.config.jira_token}",
"Accept": "application/json",
},
)
def _upload_sanity_check(self, issue_keys: List[str]):
if not self._user:
raise FimeException("Could not get user key")
for issue_key in issue_keys:
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")
def upload(self, issues: List[Tuple[str, str, timedelta]], pdate: date):
"""@:param issues: [(
"ISS-1234",
"I did some stuff",
timedelta(seconds=300),
), ... ]
"""
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"]
found = False
worklog_id = None
for log in worklogs:
if log["author"]["key"] == self.user \
and datetime.strptime(log["started"], "%Y-%m-%dT%H:%M:%S.%f%z").date() == date:
print(log["id"])
print(log["comment"])
found = True
if log["author"]["key"] == self._user \
and datetime.strptime(log["started"], "%Y-%m-%dT%H:%M:%S.%f%z").date() == pdate:
worklog_id = log["id"]
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)