Skip to content

Commit 65b638c

Browse files
committed
Initial public repository push
0 parents  commit 65b638c

8 files changed

+315
-0
lines changed

.github/workflows/main.yml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Updater
2+
3+
on:
4+
schedule:
5+
- cron: "0 18 * * *"
6+
workflow_dispatch:
7+
8+
jobs:
9+
execute:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 15
12+
steps:
13+
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
14+
- uses: actions/checkout@v2
15+
- name: Set up Python
16+
uses: actions/[email protected]
17+
with:
18+
cache: pip
19+
python-version: 3.9
20+
- name: Install dependencies
21+
run: pip install -r requirements.txt
22+
23+
- name: Notify discord
24+
env:
25+
SPECIAL_PROJECT_ID: ${{ secrets.SPECIAL_PROJECT_ID }}
26+
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
27+
API_KEY: ${{ secrets.API_KEY }}
28+
WORKSPACE_ID: ${{ secrets.WORKSPACE_ID }}
29+
USER_ID: ${{ secrets.USER_ID }}
30+
SPECIAL_PROJECT_NAME: ${{ secrets.SPECIAL_PROJECT_NAME }}
31+
USER_NAME: ${{ secrets.USER_NAME }}
32+
run: |
33+
python productivity_update.py

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Anshaj Khare
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# clockify-bot

config.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import os
2+
3+
4+
class Config(object):
5+
special_project_id = os.getenv("SPECIAL_PROJECT_ID")
6+
discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL")
7+
api_key = os.getenv("API_KEY")
8+
workspace_id = os.getenv("WORKSPACE_ID")
9+
user_id = os.getenv("USER_ID")
10+
special_project_name = os.getenv("SPECIAL_PROJECT_NAME")
11+
user_name = os.getenv("USER_NAME")

productivity_update.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from typing import Tuple
2+
3+
import pandas as pd
4+
import requests
5+
6+
from config import Config
7+
from report_api import ReportApi
8+
from time_entries_api import TimeEntriesApi
9+
10+
11+
class ProductivityUpdate:
12+
def __init__(self):
13+
self.report = ReportApi()
14+
15+
def _get_daily_message(self) -> str:
16+
message = ""
17+
time_api = TimeEntriesApi()
18+
recent_entries = time_api.get_recent_entries()
19+
entries = time_api.get_todays_entries(recent_entries)
20+
if not entries:
21+
message += self._get_no_work_today_message()
22+
else:
23+
summary_df = self.generate_summary_df(entries)
24+
total_hours, total_minutes = self.get_total_time(summary_df)
25+
prep_hours, prep_minutes = self.get_special_project_time(summary_df)
26+
message += self._get_work_done_today_message(
27+
total_hours, total_minutes, prep_hours, prep_minutes
28+
)
29+
return message
30+
31+
def _get_work_done_today_message(
32+
self, total_hours: int, total_minutes: int, prep_hours: int, prep_minutes: int
33+
) -> str:
34+
35+
return "🕰 Daily stats - Total time: {total_hours} hours and {total_minutes} minutes. Prep time: {prep_hours} hours and {prep_minutes} minutes".format(
36+
total_hours=total_hours,
37+
total_minutes=total_minutes,
38+
prep_hours=prep_hours,
39+
prep_minutes=prep_minutes,
40+
)
41+
42+
def _get_no_work_today_message(self) -> str:
43+
return "🕰 {} did not work today".format(Config.user_name)
44+
45+
def generate_message(self) -> str:
46+
message = "Updates for {}\n".format(Config.user_name)
47+
message += self._get_daily_message()
48+
weekly_message = ReportApi().report("weekly")
49+
message += "\n" + weekly_message
50+
return message
51+
52+
def generate_summary_df(self, entries: list):
53+
time_api = TimeEntriesApi()
54+
projects = time_api.get_projects()
55+
entry_df = time_api.get_entries_df(entries)
56+
projects_df = time_api.get_projects_df(projects)
57+
entry_df["hours"] = entry_df["end"] - entry_df["start"]
58+
entry_df = entry_df.groupby("project_id").agg({"hours": sum}).reset_index()
59+
summary_df = pd.merge(entry_df, projects_df, on="project_id")
60+
return summary_df
61+
62+
def get_total_time(self, summary_df: pd.DataFrame) -> Tuple[int, int]:
63+
total_time = summary_df["hours"].sum().seconds
64+
total_hours = total_time // (60 * 60)
65+
total_minutes = (total_time - total_hours * 60 * 60) // 60
66+
return total_hours, total_minutes
67+
68+
def get_special_project_time(self, summary_df: pd.DataFrame):
69+
if Config.special_project_name in summary_df["name"].tolist():
70+
prep_time = (
71+
summary_df[summary_df["name"] == Config.special_project_name]["hours"]
72+
.tolist()[0]
73+
.seconds
74+
)
75+
prep_hours = prep_time // (60 * 60)
76+
prep_minutes = (prep_time - prep_hours * 60 * 60) // 60
77+
return prep_hours, prep_minutes
78+
else:
79+
return 0, 0
80+
81+
def notify(self, message: str) -> int:
82+
message_dict = {"content": message}
83+
84+
resp = requests.post(
85+
url=Config.discord_webhook_url,
86+
json=message_dict,
87+
headers={"Content-Type": "application/json"},
88+
)
89+
return 200
90+
91+
def run(self):
92+
message = self.generate_message()
93+
return self.notify(message)
94+
95+
96+
if __name__ == "__main__":
97+
p = ProductivityUpdate()
98+
p.run()

report_api.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import datetime as dt
2+
import json
3+
import logging
4+
import os
5+
from config import Config
6+
from typing import Dict, Tuple
7+
8+
import requests
9+
10+
BASE_API = "https://reports.api.clockify.me/v1"
11+
12+
logger = logging.getLogger("clockify_reports_api")
13+
14+
15+
class ReportApi(object):
16+
def __init__(self) -> None:
17+
api_key = os.getenv("API_KEY")
18+
self.workspace_id = os.getenv("WORKSPACE_ID")
19+
self.allowed_types = ["weekly"]
20+
self.headers = {"X-Api-Key": api_key}
21+
self.api_endpoint = (
22+
BASE_API
23+
+ "/workspaces/{workspaceId}/reports/summary".format(
24+
workspaceId=self.workspace_id
25+
)
26+
)
27+
28+
def report(self, type_: str):
29+
if type_ not in self.allowed_types:
30+
return
31+
if type_ == "weekly":
32+
api_response = self._get_weekly_report()
33+
34+
total_time, prep_time = self._extract_time_values(api_response=api_response)
35+
36+
report = "⏱ Weekly stats - Total time: {total_time} Prep Time: {prep_time}".format(
37+
total_time=self._format_seconds(total_time),
38+
prep_time=self._format_seconds(prep_time),
39+
)
40+
return report
41+
42+
def _get_weekly_report(self):
43+
url = BASE_API + "/workspaces/{workspaceId}/reports/weekly".format(
44+
workspaceId=self.workspace_id
45+
)
46+
utc_now = dt.datetime.utcnow().replace(
47+
hour=0, minute=0, second=0, microsecond=0
48+
)
49+
days_since_week_start = utc_now.weekday()
50+
request_json = {
51+
"dateRangeStart": (
52+
utc_now - dt.timedelta(days=days_since_week_start)
53+
).isoformat(),
54+
"dateRangeEnd": (
55+
utc_now + dt.timedelta(days=7 - days_since_week_start)
56+
).isoformat(),
57+
"weeklyFilter": {"group": "PROJECT", "subgroup": "TIME"},
58+
}
59+
resp = requests.post(url=url, headers=self.headers, json=request_json)
60+
logger.info("Response code: %s" % resp.status_code)
61+
return json.loads(resp.text)
62+
63+
def _extract_time_values(
64+
self, api_response: Dict, type_: str = None
65+
) -> Tuple[int, int]:
66+
total_time = api_response["totals"][0]["totalTime"]
67+
prep_time = 0
68+
projects = api_response["groupOne"]
69+
for project in projects:
70+
if project["_id"] == Config.special_project_id:
71+
prep_time = project["duration"]
72+
return total_time, prep_time
73+
74+
def _format_seconds(self, seconds: int):
75+
hours = seconds // (60 * 60)
76+
minutes = (seconds - hours * 60 * 60) // 60
77+
return "{hours} hours {minutes} minutes".format(hours=hours, minutes=minutes)
78+
79+
80+
if __name__ == "__main__":
81+
c = ReportApi()
82+
print(c.report("weekly"))

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests
2+
pandas

time_entries_api.py

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import datetime as dt
2+
import json
3+
import pandas as pd
4+
import pytz
5+
import requests
6+
7+
from config import Config
8+
9+
BASE_API = "https://api.clockify.me/api/v1"
10+
TIME_ENTRIES_ENDPOINT = "/workspaces/{workspaceId}/user/{userId}/time-entries"
11+
PROJECTS_ENDPOINT = "/workspaces/{workspaceId}/projects"
12+
13+
14+
class TimeEntriesApi(object):
15+
def __init__(self) -> None:
16+
17+
self.headers = {"X-Api-Key": Config.api_key}
18+
self.time_api = BASE_API + TIME_ENTRIES_ENDPOINT.format(
19+
workspaceId=Config.workspace_id, userId=Config.user_id
20+
)
21+
22+
def get_recent_entries(self) -> list:
23+
resp = requests.get(self.time_api, headers=self.headers)
24+
entries = json.loads(resp.text)
25+
return entries
26+
27+
def get_projects(self) -> list:
28+
project_api = BASE_API + PROJECTS_ENDPOINT.format(
29+
workspaceId=Config.workspace_id
30+
)
31+
resp = requests.get(project_api, headers=self.headers)
32+
projects = json.loads(resp.text)
33+
return projects
34+
35+
def get_todays_entries(self, entries: list) -> list:
36+
entries_today = []
37+
today = dt.datetime.now(pytz.utc).replace(
38+
hour=0, minute=0, second=0, microsecond=0
39+
)
40+
window_start = today - dt.timedelta(hours=5, minutes=30)
41+
window_end = today.replace(hour=18, minute=30)
42+
43+
for entry in entries:
44+
time_interval = entry["timeInterval"]
45+
if not time_interval["end"]:
46+
continue
47+
start = pd.to_datetime(time_interval["start"])
48+
end = pd.to_datetime(time_interval["end"])
49+
50+
if start > window_start and end < window_end:
51+
entries_today.append(
52+
dict(project_id=entry["projectId"], start=start, end=end)
53+
)
54+
return entries_today
55+
56+
def get_projects_df(self, projects: list) -> pd.DataFrame:
57+
projects_list = []
58+
for project in projects:
59+
projects_list.append(dict(project_id=project["id"], name=project["name"]))
60+
projects_df = pd.DataFrame(projects_list)
61+
return projects_df
62+
63+
def get_entries_df(self, entries: list) -> pd.DataFrame:
64+
entry_df = None
65+
if entries:
66+
entry_df = pd.DataFrame(entries)
67+
return entry_df

0 commit comments

Comments
 (0)