diff --git a/src/fime/data.py b/src/fime/data.py index 19d39fd..0923ffe 100644 --- a/src/fime/data.py +++ b/src/fime/data.py @@ -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) diff --git a/src/fime/main.py b/src/fime/main.py index bbe400e..3afbb95 100755 --- a/src/fime/main.py +++ b/src/fime/main.py @@ -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__": diff --git a/src/fime/report.py b/src/fime/report.py index a5870ff..9ccbbe7 100644 --- a/src/fime/report.py +++ b/src/fime/report.py @@ -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() diff --git a/src/fime/worklog.py b/src/fime/worklog.py index 2cf24cc..ba44a6a 100644 --- a/src/fime/worklog.py +++ b/src/fime/worklog.py @@ -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): diff --git a/src/fime/worklog_rest.py b/src/fime/worklog_rest.py index 70333a3..a3cc0be 100644 --- a/src/fime/worklog_rest.py +++ b/src/fime/worklog_rest.py @@ -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)