Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add notification for missed or low timesheet #44

Merged
merged 15 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frappe_slack_connector/api/slack_interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
from frappe_slack_connector.slack.interactions.submit_leave import (
handler as submit_leave_handler,
)
from frappe_slack_connector.slack.interactions.submit_timesheet import (
handler as submit_timesheet_handler,
)
from frappe_slack_connector.slack.interactions.timesheet_filters import (
handle_timesheet_filter,
)


@frappe.whitelist(allow_guest=True)
Expand Down Expand Up @@ -68,10 +74,15 @@ def event():
return
elif block_id == "half_day_checkbox":
return half_day_checkbox_handler(slack, payload)
elif block_id in ("project_block", "task_block"):
return handle_timesheet_filter(slack, payload)
else:
return approve_leave_handler(slack, payload)

elif event_type == "view_submission":
if payload["view"]["callback_id"] == "timesheet_modal":
return submit_timesheet_handler(slack, payload)

return submit_leave_handler(slack, payload)

else:
Expand Down
187 changes: 187 additions & 0 deletions frappe_slack_connector/api/slash_timesheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import frappe

from frappe_slack_connector.db.user_meta import get_userid_from_slackid
from frappe_slack_connector.helpers.error import generate_error_log
from frappe_slack_connector.helpers.http_response import send_http_response
from frappe_slack_connector.slack.app import SlackIntegration


@frappe.whitelist(allow_guest=True)
def slash_timesheet():
"""
API endpoint for the Slash command to open the modal for timesheet creation
Slash command: /timesheet
"""
slack = SlackIntegration()
try:
slack.verify_slack_request(
signature=frappe.request.headers.get("X-Slack-Signature"),
timestamp=frappe.request.headers.get("X-Slack-Request-Timestamp"),
req_data=frappe.request.get_data(as_text=True),
)
except Exception:
return send_http_response("Invalid request", status_code=403)
try:
user_email = get_userid_from_slackid(frappe.form_dict.get("user_id"))
if user_email is None:
raise Exception("User not found on ERP")

# TODO: Move this to a function in db folder
projects = frappe.get_all(
"Project",
filters={"status": "Open"},
fields=["name", "project_name"],
user=user_email,
)
tasks = frappe.get_list(
"Task",
user=user_email,
fields=["name", "subject"],
ignore_permissions=True,
)

if not projects:
raise Exception("No projects found")
if not tasks:
raise Exception("No tasks found")

slack.slack_app.client.views_open(
trigger_id=frappe.form_dict.get("trigger_id"),
view={
"type": "modal",
"callback_id": "timesheet_modal",
"title": {"type": "plain_text", "text": "Timesheet Entry"},
"blocks": build_timesheet_form(projects, tasks),
"close": {"type": "plain_text", "text": "Cancel", "emoji": True},
"submit": {
"type": "plain_text",
"text": "Submit",
},
},
)

except Exception as e:
generate_error_log("Error opening modal", exception=e)
slack.slack_app.client.views_open(
trigger_id=frappe.form_dict.get("trigger_id"),
view={
"type": "modal",
"callback_id": "timesheet_error",
"title": {"type": "plain_text", "text": "Error"},
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":warning: Error submitting timesheet",
"emoji": True,
},
},
{"type": "divider"},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Error Details:*\n```{str(e)}```",
},
},
],
},
)

return send_http_response(
status_code=204,
is_empty=True,
)


def build_timesheet_form(projects: list, tasks: list) -> list:
"""
Build the form for the timesheet modal
Provide options for project and task
"""

blocks = [
{
"type": "input",
"block_id": "entry_date",
"element": {
"type": "datepicker",
"action_id": "date_picker",
"placeholder": {
"type": "plain_text",
"text": "Select a date",
},
"initial_date": frappe.utils.today(),
},
"label": {"type": "plain_text", "text": "Date"},
},
{
"type": "input",
"dispatch_action": True,
"block_id": "project_block",
"element": {
"type": "static_select",
"action_id": "project_select",
"options": [
{
"text": {
"type": "plain_text",
"text": project.get("project_name"),
},
"value": project.get("name"),
}
for project in projects
],
"placeholder": {"type": "plain_text", "text": "Enter project name"},
},
"label": {"type": "plain_text", "text": "Project", "emoji": True},
},
{
"type": "input",
"block_id": "task_block",
"dispatch_action": True,
"element": {
"type": "static_select",
"action_id": "task_select",
"options": [
{
"text": {
"type": "plain_text",
"text": task.get("subject"),
},
"value": task.get("name"),
}
for task in tasks
],
"placeholder": {
"type": "plain_text",
"text": "Enter task description",
},
},
"label": {"type": "plain_text", "text": "Task", "emoji": True},
},
{
"type": "input",
"block_id": "hours_block",
"element": {
"type": "number_input",
"action_id": "hours_input",
"is_decimal_allowed": True,
"min_value": "0.1",
"placeholder": {"type": "plain_text", "text": "Enter hours worked"},
},
"label": {"type": "plain_text", "text": "Hours", "emoji": True},
},
{
"type": "input",
"block_id": "description",
"element": {
"type": "plain_text_input",
"action_id": "description_input",
"multiline": True,
},
"label": {"type": "plain_text", "text": "Description", "emoji": True},
},
]
return blocks
71 changes: 71 additions & 0 deletions frappe_slack_connector/db/employee.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import frappe
from frappe.utils import datetime
from hrms.hr.utils import get_holiday_list_for_employee

from frappe_slack_connector.helpers.error import generate_error_log

Expand Down Expand Up @@ -40,3 +42,72 @@ def get_employee_company_email(user_email: str = None):
exception=e,
)
return None


def get_employee_from_user(user=None):
"""
Get the employee doc for the given user
"""
user = frappe.session.user
employee = frappe.db.get_value("Employee", {"user_id": user})

if not employee:
frappe.throw(frappe._("Employee not found"))
return employee


def get_user_from_employee(employee: str):
"""
Get the user for the given employee
"""
return frappe.get_value("Employee", employee, "user_id")


def get_employee(filters=None, fieldname=None):
"""
Get the employee doc for the given filters
"""
import json

if not fieldname:
fieldname = ["name", "employee_name", "image"]

if fieldname and isinstance(fieldname, str):
fieldname = json.loads(fieldname)

if filters and isinstance(filters, str):
filters = json.loads(filters)

return frappe.db.get_value(
"Employee", filters=filters, fieldname=fieldname, as_dict=True
)


def check_if_date_is_holiday(date: datetime.date, employee: str) -> bool:
"""
Check if the given date is a non-working day for the given employee
"""
holiday_list = get_holiday_list_for_employee(employee)
is_holiday = frappe.db.exists(
"Holiday",
{
"holiday_date": date,
"parent": holiday_list,
},
)

# Check if it's a full-day leave
is_leave = frappe.db.exists(
"Leave Application",
{
"employee": employee,
"from_date": ("<=", date),
"to_date": (">=", date),
"half_day": 0, # This ensures only full day leaves are considered
"status": (
"in",
["Open", "Approved"],
),
},
)
return any((is_holiday, is_leave))
96 changes: 96 additions & 0 deletions frappe_slack_connector/db/timesheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import frappe
from frappe.utils import datetime, getdate

from frappe_slack_connector.db.employee import get_employee_from_user


def get_employee_working_hours(employee: str = None) -> dict:
"""
Get the working hours and frequency for the given employee
"""
if not employee:
employee = get_employee_from_user()
working_hour, working_frequency = frappe.get_value(
"Employee",
employee,
["custom_working_hours", "custom_work_schedule"],
)
if not working_hour:
working_hour = frappe.db.get_single_value(
"HR Settings", "standard_working_hours"
)
if not working_frequency:
working_frequency = "Per Day"
return {"working_hour": working_hour or 8, "working_frequency": working_frequency}


def get_employee_daily_working_norm(employee: str) -> int:
"""
Get the daily working norm for the given employee
"""
working_details = get_employee_working_hours(employee)
if working_details.get("working_frequency") != "Per Day":
return working_details.get("working_hour") / 5
return working_details.get("working_hour")


def get_reported_time_by_employee(employee: str, date: datetime.date) -> int:
"""
Get the total reported time by the employee for the given date
"""
if_exists = frappe.db.exists(
"Timesheet",
{
"employee": employee,
"start_date": date,
},
)
if not if_exists:
return 0

timesheets = frappe.get_all(
"Timesheet",
filters={
"employee": employee,
"start_date": date,
"end_date": date,
},
fields=["total_hours"],
)
total_hours = 0
for timesheet in timesheets:
total_hours += timesheet.total_hours
return total_hours


def create_timesheet_detail(
date: str,
hours: float,
description: str,
task: str,
employee: str,
parent: str | None = None,
):
if parent:
timesheet = frappe.get_doc("Timesheet", parent)
else:
timesheet = frappe.get_doc({"doctype": "Timesheet", "employee": employee})

project, custom_is_billable = frappe.get_value(
"Task", task, ["project", "custom_is_billable"]
)

timesheet.update({"parent_project": project})
timesheet.append(
"time_logs",
{
"task": task,
"hours": hours,
"description": description,
"from_time": getdate(date),
"to_time": getdate(date),
"project": project,
"is_billable": custom_is_billable,
},
)
timesheet.save()
Loading