diff --git a/working_time/hooks.py b/working_time/hooks.py index 3bb1dea..4dc58b3 100644 --- a/working_time/hooks.py +++ b/working_time/hooks.py @@ -57,7 +57,7 @@ # ------------ # before_install = "working_time.install.before_install" -# after_install = "working_time.install.after_install" +after_install = "working_time.install.after_install" # Uninstallation # ------------ diff --git a/working_time/install.py b/working_time/install.py new file mode 100644 index 0000000..cd8cc37 --- /dev/null +++ b/working_time/install.py @@ -0,0 +1,87 @@ +# Copyright (c) 2023, ALYF GmbH and contributors +# For license information, please see license.txt + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def after_install(): + customize_project() + customize_timesheet() + insert_docs() + + +def customize_project(): + custom_fields = { + "Project": [ + { + "fieldname": "billing_rate", + "label": "Billing Rate per Hour", + "fieldtype": "Currency", + "options": "currency", + "insert_after": "cost_center", + "translatable": 0, + }, + { + "fieldname": "jira_section", + "label": "Jira", + "fieldtype": "Section Break", + "insert_after": "message", + "collapsible": 1, + }, + { + "fieldname": "jira_site", + "label": "Site", + "fieldtype": "Link", + "options": "Jira Site", + "insert_after": "jira_section", + "translatable": 0, + }, + ] + } + + create_custom_fields(custom_fields) + + +def customize_timesheet(): + custom_fields = { + "Timesheet Detail": [ + { + "fieldname": "jira_section", + "label": "Jira", + "fieldtype": "Section Break", + "insert_after": "costing_amount", + }, + { + "fieldname": "jira_issue_url", + "label": "Issue URL", + "fieldtype": "Data", + "Options": "URL", + "insert_after": "jira_section", + "read_only": 1, + "translatable": 0, + }, + ] + } + + create_custom_fields(custom_fields) + + +def insert_docs(): + docs = [ + { + "doctype": "Activity Type", + "activity_type": "Default", + } + ] + + for doc in docs: + filters = doc.copy() + + # Clean up filters. They need to be a plain dict without nested dicts or lists. + for key, value in doc.items(): + if isinstance(value, (list, dict)): + del filters[key] + + if not frappe.db.exists(filters): + frappe.get_doc(doc).insert(ignore_if_duplicate=True) diff --git a/working_time/jira_client.py b/working_time/jira_client.py new file mode 100644 index 0000000..1d4f1a9 --- /dev/null +++ b/working_time/jira_client.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023, ALYF GmbH and contributors +# For license information, please see license.txt + + +import json + +import frappe +from frappe import _ +import requests +from requests.auth import HTTPBasicAuth + + +class JiraClient: + def __init__(self, jira_site: str) -> None: + jira_site = frappe.get_doc("Jira Site", jira_site) + + self.url = f"https://{jira_site.name}" + self.session = requests.Session() + self.session.auth = HTTPBasicAuth( + jira_site.username, jira_site.get_password(fieldname="api_token") + ) + self.session.headers = {"Accept": "application/json"} + + def get(self, url: str, params=None): + response = self.session.get(url, params=params) + + try: + response.raise_for_status() + except requests.HTTPError: + error_text = json.loads(response.text) + error_message = ( + error_text.get("errorMessage") + or (error_text.get("errorMessages") or [None])[0] + or "Something went wrong." + ) + + frappe.throw(f"{url}: {_(error_message)}") + + return response.json() + + def get_issue_summary(self, key: str) -> str: + url = f"{self.url}/rest/api/2/issue/{key}" + params = { + "fields": "summary", + } + + return self.get(url, params=params).get("fields").get("summary") diff --git a/working_time/patches.txt b/working_time/patches.txt index e69de29..4c0f661 100644 --- a/working_time/patches.txt +++ b/working_time/patches.txt @@ -0,0 +1 @@ +working_time.patches.migrate_old_working_times \ No newline at end of file diff --git a/working_time/patches/migrate_old_working_times.py b/working_time/patches/migrate_old_working_times.py new file mode 100644 index 0000000..02db957 --- /dev/null +++ b/working_time/patches/migrate_old_working_times.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + doc_names = frappe.get_all("Working Time", pluck="name") + + for doc_name in doc_names: + date, break_time, working_time = frappe.db.get_value( + "Working Time", doc_name, ["from_date", "break_duration", "total_duration"] + ) + frappe.db.set_value( + "Working Time", + doc_name, + {"date": date, "break_time": break_time, "working_time": working_time}, + update_modified=False, + ) diff --git a/working_time/working_time/doctype/__init__.py b/working_time/working_time/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/working_time/working_time/doctype/jira_site/__init__.py b/working_time/working_time/doctype/jira_site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/working_time/working_time/doctype/jira_site/jira_site.js b/working_time/working_time/doctype/jira_site/jira_site.js new file mode 100644 index 0000000..b4090b6 --- /dev/null +++ b/working_time/working_time/doctype/jira_site/jira_site.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Jira Site', { + // refresh: function(frm) { + + // } +}); diff --git a/working_time/working_time/doctype/jira_site/jira_site.json b/working_time/working_time/doctype/jira_site/jira_site.json new file mode 100644 index 0000000..f0dbb37 --- /dev/null +++ b/working_time/working_time/doctype/jira_site/jira_site.json @@ -0,0 +1,71 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:site_url", + "creation": "2023-01-30 10:07:01.823009", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "site_url", + "column_break_2", + "username", + "column_break_4", + "api_token" + ], + "fields": [ + { + "description": "e.g. your-domain.atlassian.net", + "fieldname": "site_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Site URL", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "username", + "fieldtype": "Data", + "label": "Username", + "reqd": 1 + }, + { + "fieldname": "api_token", + "fieldtype": "Password", + "label": "API Token", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-01-30 10:13:44.680028", + "modified_by": "Administrator", + "module": "Working Time", + "name": "Jira Site", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "site_url" +} \ No newline at end of file diff --git a/working_time/working_time/doctype/jira_site/jira_site.py b/working_time/working_time/doctype/jira_site/jira_site.py new file mode 100644 index 0000000..9288c71 --- /dev/null +++ b/working_time/working_time/doctype/jira_site/jira_site.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023, ALYF GmbH and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class JiraSite(Document): + pass diff --git a/working_time/working_time/doctype/jira_site/test_jira_site.py b/working_time/working_time/doctype/jira_site/test_jira_site.py new file mode 100644 index 0000000..fc51308 --- /dev/null +++ b/working_time/working_time/doctype/jira_site/test_jira_site.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023, ALYF GmbH and Contributors +# See license.txt + +# import frappe +import unittest + +class TestJiraSite(unittest.TestCase): + pass diff --git a/working_time/working_time/doctype/working_time/__init__.py b/working_time/working_time/doctype/working_time/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/working_time/working_time/doctype/working_time/test_working_time.py b/working_time/working_time/doctype/working_time/test_working_time.py new file mode 100644 index 0000000..8182ea5 --- /dev/null +++ b/working_time/working_time/doctype/working_time/test_working_time.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023, ALYF GmbH and Contributors +# See license.txt + +# import frappe +import unittest + +class TestWorkingTime(unittest.TestCase): + pass diff --git a/working_time/working_time/doctype/working_time/working_time.js b/working_time/working_time/doctype/working_time/working_time.js new file mode 100644 index 0000000..596e9b2 --- /dev/null +++ b/working_time/working_time/doctype/working_time/working_time.js @@ -0,0 +1,17 @@ +// Copyright (c) 2023, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Working Time", { + setup: function (frm) { + frm.set_query("employee", "erpnext.controllers.queries.employee_query"); + }, +}); + +frappe.ui.form.on("Working Time Log", { + time_logs_add: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + row.from_time = frappe.datetime.now_time(false); + row.to_time = ""; // Otherwise Frappe may overwrite empty values with the current time on save. + frm.refresh_field("time_logs"); + }, +}); diff --git a/working_time/working_time/doctype/working_time/working_time.json b/working_time/working_time/doctype/working_time/working_time.json new file mode 100644 index 0000000..bd2d781 --- /dev/null +++ b/working_time/working_time/doctype/working_time/working_time.json @@ -0,0 +1,114 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-01-20 19:47:31.767082", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "employee", + "column_break_2", + "employee_name", + "section_break_4", + "date", + "time_logs", + "section_break_7", + "break_time", + "working_time", + "amended_from" + ], + "fields": [ + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fetch_from": "employee.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name", + "read_only": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "default": "Today", + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Working Time", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "working_time", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "in_list_view": 1, + "label": "Working Time", + "read_only": 1 + }, + { + "fieldname": "time_logs", + "fieldtype": "Table", + "label": "Time Logs", + "options": "Working Time Log" + }, + { + "fieldname": "break_time", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "label": "Break", + "read_only": 1 + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-01-25 01:15:25.964552", + "modified_by": "Administrator", + "module": "Working Time", + "name": "Working Time", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "employee_name" +} \ No newline at end of file diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py new file mode 100644 index 0000000..675bd45 --- /dev/null +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -0,0 +1,148 @@ +# Copyright (c) 2023, ALYF GmbH and contributors +# For license information, please see license.txt + +import math + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import time_diff_in_seconds +from working_time.jira_client import JiraClient + +HALF_DAY = 3.25 +OVERTIME_FACTOR = 1.15 +MAX_HALF_DAY = HALF_DAY * OVERTIME_FACTOR * 60 * 60 +FIVE_MINUTES = 5 * 60 +ONE_HOUR = 60 * 60 + + +class WorkingTime(Document): + def before_validate(self): + self.remove_seconds() + self.set_to_times() + self.set_durations() + + def validate(self): + for log in self.time_logs: + if log.duration and log.duration < 0: + frappe.throw(_("Time logs must be continuous")) + + def on_submit(self): + self.create_attendance() + self.create_timesheets() + + def remove_seconds(self): + for log in self.time_logs: + if log.from_time: + log.from_time = f"{log.from_time[:-2]}00" + + if log.to_time: + log.to_time = f"{log.to_time[:-2]}00" + + def set_to_times(self): + for i in range(0, len(self.time_logs) - 1): + self.time_logs[i].to_time = self.time_logs[i + 1].from_time + + def set_durations(self): + self.break_time = 0 + self.working_time = 0 + + for log in self.time_logs: + if log.from_time and log.to_time: + log.duration = time_diff_in_seconds(log.to_time, log.from_time) + + if log.duration: + if log.is_break: + self.break_time += log.duration + else: + self.working_time += log.duration + + def create_attendance(self): + if not frappe.db.exists( + "Attendance", {"employee": self.employee, "attendance_date": self.date} + ): + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": self.employee, + "status": "Present" + if self.working_time > MAX_HALF_DAY + else "Half Day", + "attendance_date": self.date, + } + ) + attendance.flags.ignore_permissions = True + attendance.save() + attendance.submit() + + def create_timesheets(self): + for log in self.time_logs: + if log.duration and log.project: + costing_rate = get_costing_rate(self.employee) + hours = math.ceil(log.duration / FIVE_MINUTES) * FIVE_MINUTES / ONE_HOUR + billing_hours = ( + math.ceil( + log.duration * float(log.billable[:-1]) / 100 / FIVE_MINUTES + ) + * FIVE_MINUTES + / ONE_HOUR + ) + + customer, billing_rate, jira_site = frappe.get_value( + "Project", + log.project, + ["customer", "billing_rate", "jira_site"], + ) + + frappe.get_doc( + { + "doctype": "Timesheet", + "time_logs": [ + { + "is_billable": 1, + "project": log.project, + "activity_type": "Default", + "base_billing_rate": billing_rate, + "base_costing_rate": costing_rate, + "costing_rate": costing_rate, + "billing_rate": billing_rate, + "hours": hours, + "from_time": self.date, + "billing_hours": billing_hours, + "description": get_description( + jira_site, log.key, log.note + ), + "jira_issue_url": get_jira_issue_url( + jira_site, log.key + ), + } + ], + "parent_project": log.project, + "customer": customer, + "employee": self.employee, + } + ).insert() + + +def get_costing_rate(employee): + return frappe.get_value( + "Activity Cost", + {"activity_type": "Default", "employee": employee}, + "costing_rate", + ) + + +def get_jira_issue_url(jira_site, key): + return f"https://{jira_site}/browse/{key}" if key else None + + +def get_description(jira_site, key, note): + if key: + description = f"{JiraClient(jira_site).get_issue_summary(key)} ({key})" + + if note: + description += f":\n\n{note}" + else: + description = note or "-" + + return description diff --git a/working_time/working_time/doctype/working_time_log/__init__.py b/working_time/working_time/doctype/working_time_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/working_time/working_time/doctype/working_time_log/working_time_log.json b/working_time/working_time/doctype/working_time_log/working_time_log.json new file mode 100644 index 0000000..3e68567 --- /dev/null +++ b/working_time/working_time/doctype/working_time_log/working_time_log.json @@ -0,0 +1,108 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-01-20 20:02:56.531992", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "from_time", + "to_time", + "column_break_3", + "duration", + "billable", + "section_break_5", + "project", + "key", + "note", + "section_break_9", + "is_break" + ], + "fields": [ + { + "columns": 1, + "fieldname": "duration", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "in_list_view": 1, + "label": "Duration", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "project", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Project", + "options": "Project" + }, + { + "columns": 1, + "fieldname": "key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Key" + }, + { + "fieldname": "note", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Note" + }, + { + "columns": 1, + "fieldname": "from_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "From", + "reqd": 1 + }, + { + "columns": 1, + "fieldname": "to_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "To" + }, + { + "columns": 1, + "default": "0", + "fieldname": "is_break", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Break" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break" + }, + { + "default": "100%", + "depends_on": "eval: doc.project", + "fieldname": "billable", + "fieldtype": "Select", + "label": "Billable", + "options": "25%\n50%\n75%\n100%\n125%\n150%" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-01-21 04:39:13.636311", + "modified_by": "Administrator", + "module": "Working Time", + "name": "Working Time Log", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/working_time/working_time/doctype/working_time_log/working_time_log.py b/working_time/working_time/doctype/working_time_log/working_time_log.py new file mode 100644 index 0000000..9e11aee --- /dev/null +++ b/working_time/working_time/doctype/working_time_log/working_time_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2023, ALYF GmbH and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class WorkingTimeLog(Document): + pass