From 645a6cfa07eb5ebff0559e95e77493cf5577d530 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 3 Nov 2022 11:33:25 +1100 Subject: [PATCH 01/29] Added date constraint function --- app.py | 6 +++ blocks.py | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index fe24ca7..9df001a 100644 --- a/app.py +++ b/app.py @@ -264,6 +264,12 @@ def select_date(ack, body, logger): ack() logger.debug(body) +@app.action("time_constraint_input") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) + + if __name__ == "__main__": # Create tables database.create_log_table() diff --git a/blocks.py b/blocks.py index dd97c09..49c06d9 100644 --- a/blocks.py +++ b/blocks.py @@ -1,6 +1,6 @@ # Slack block kit - https://api.slack.com/block-kit -from datetime import datetime +from datetime import datetime, timedelta # Get current date for time logging form def currentDate(): @@ -19,7 +19,8 @@ def timelog_form(): "block_id": "date_select_block", "element": { "type": "datepicker", - # YYYY-MM-DD format needs to be used here because SQL doesn't have a date data type so these are stored as strings - in this format lexicographical order is identical to chronological order. + # YYYY-MM-DD format needs to be used here because SQL doesn't have a date data type so these are stored as strings + # and in this format lexicographical order is identical to chronological order. "initial_date": currentDate().strftime("%Y-%m-%d"), "placeholder": { "type": "plain_text", @@ -132,6 +133,8 @@ def getusertables_form(): "text": "Number of entries to return for each user", } }, + # Add a date constraint selector + date_constraint(), { "type": "actions", "elements": [ @@ -146,4 +149,128 @@ def getusertables_form(): ] } ] + print(output) return output + +# If a seperate submit button is used a listener function will catch the default date_constraint_input event so it +# won't do anything and won't show up in logs +def date_constraint(): + # Let the user choose a date constraint - the time_constraint_response event listener will run the function at + # response_endpoint with the given date constraint + today = datetime.today() + output = { + "type": "input", + "element": { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Select an item", + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "All time", + }, + "value": "all_time" + }, + { + "text": { + "type": "plain_text", + "text": "Today", + }, + # "value": today + "value": "today" + }, + { + "text": { + "type": "plain_text", + "text": "This week", + }, + # Move back n days where n is the weekday number (so we reach the start of the week) + # Monday is 0 and Sunday is 6 here + # "value": today - timedelta(days = today.weekday()) + "value": "this_week" + }, + { + "text": { + "type": "plain_text", + "text": "This month", + }, + "value": "this_month" + # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) + # "value": today.replace(day=1) + } + ], + "action_id": "time_constraint_input" + }, + "label": { + "type": "plain_text", + "text": "Selection range", + } + } + + print("got it") + return output + +# def time_constraint_form(response_endpoint): +# # Let the user choose a date constraint - the time_constraint_response event listener will run the function at +# # response_endpoint with the given date constraint +# today = date.today() +# output = [ +# { +# "type": "input", +# "element": { +# "type": "static_select", +# "placeholder": { +# "type": "plain_text", +# "text": "Select an item", +# "emoji": true +# }, +# "options": [ +# { +# "text": { +# "type": "plain_text", +# "text": "Today", +# "emoji": true +# }, +# "value": today +# }, +# { +# "text": { +# "type": "plain_text", +# "text": "This week", +# "emoji": true +# }, +# # Move back n days where n is the weekday number (so we reach the start of the week) +# # Monday is 0 and Sunday is 6 here +# "value": today - datetime.timedelta(days = today.weekday()) +# }, +# { +# "text": { +# "type": "plain_text", +# "text": "This month", +# "emoji": true +# }, +# # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) +# "value": today.replace(day=1) +# }, +# { +# "text": { +# "type": "plain_text", +# "text": "All time", +# "emoji": true +# }, +# "value": "All time" +# } +# ], +# "value": response_endpoint, +# "action_id": "time_constraint_response" +# }, +# "label": { +# "type": "plain_text", +# "text": "Selection range", +# "emoji": true +# } +# } +# ] From 4722050a0537d5296b25e8a160dc0d8d536ad005 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 3 Nov 2022 12:55:55 +1100 Subject: [PATCH 02/29] Add parse date constraint function and update get_user_tables block --- app.py | 29 +++++++++++++++++++++++++++-- blocks.py | 23 ++++++++++++++++------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 9df001a..3be31d2 100644 --- a/app.py +++ b/app.py @@ -3,10 +3,14 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -from datetime import datetime +from datetime import datetime, timedelta from dotenv import load_dotenv from pathlib import Path +import sys +if not sys.version_info >= (3, 10): + raise Exception("Requires Python 3.10 or higher!") + dotenv_path = Path(".env") if dotenv_path.exists(): load_dotenv(dotenv_path=dotenv_path) @@ -22,6 +26,20 @@ def slack_table(title, message): return(f"*{title}*\n```{message}```") +def parse_date_constraint(constraint): + today = datetime.today() + # Requires python 3.10 or higher + match constraint: + case "today": + return today + case "this week": + # Move back n days where n is the weekday number (so we reach the start of the week (Monday is 0, Sunday is 6)) + return today - timedelta(days = today.weekday()) + case "this month": + # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) + return today.replace(day=1) + case "all time": + return None ################################### User validation ################################### @@ -118,7 +136,12 @@ def get_user_hours_form(ack, respond, body, command): @app.action("getusertables_response") def get_logged_hours(ack, body, respond, logger): ack() + print(body['state']['values']['date_constraint_block']) users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] + # If 'All time' is chosen start_date will be None + # start_date_text = body['state']['values']['date_constraint_block']['time_constraint_input']['selected_option']['value'] + start_date = parse_date_constraint() + try: num_entries = re.findall(r'\d+', body['state']['values']['num_entries_block']['num_entries_input']['value'])[0] except: @@ -126,6 +149,8 @@ def get_logged_hours(ack, body, respond, logger): sqlc = database.SQLConnection() output = "" + if (start_date): + output += "Date constraint: " + start_date.date() for user in users: name = user_name(user) table = sqlc.last_entries_table(user, num_entries) @@ -267,7 +292,7 @@ def select_date(ack, body, logger): @app.action("time_constraint_input") def handle_some_action(ack, body, logger): ack() - logger.info(body) + logger.debug(body) if __name__ == "__main__": diff --git a/blocks.py b/blocks.py index 49c06d9..4bdc1f5 100644 --- a/blocks.py +++ b/blocks.py @@ -124,13 +124,14 @@ def getusertables_form(): # Number of entries "type": "input", "block_id": "num_entries_block", - "element": { + "element": { "type": "plain_text_input", - "action_id": "num_entries_input" + "action_id": "num_entries_input", + "initial_value": "20" }, "label": { "type": "plain_text", - "text": "Number of entries to return for each user", + "text": "Maximum number of entries to return for each user", } }, # Add a date constraint selector @@ -149,7 +150,6 @@ def getusertables_form(): ] } ] - print(output) return output # If a seperate submit button is used a listener function will catch the default date_constraint_input event so it @@ -159,9 +159,18 @@ def date_constraint(): # response_endpoint with the given date constraint today = datetime.today() output = { + "block_id": "date_constraint_block", "type": "input", "element": { "type": "static_select", + # Default to all time + "initial_option": { + "text": { + "type": "plain_text", + "text": "All time", + }, + "value": "all time" + }, "placeholder": { "type": "plain_text", "text": "Select an item", @@ -172,7 +181,7 @@ def date_constraint(): "type": "plain_text", "text": "All time", }, - "value": "all_time" + "value": "all time" }, { "text": { @@ -190,14 +199,14 @@ def date_constraint(): # Move back n days where n is the weekday number (so we reach the start of the week) # Monday is 0 and Sunday is 6 here # "value": today - timedelta(days = today.weekday()) - "value": "this_week" + "value": "this week" }, { "text": { "type": "plain_text", "text": "This month", }, - "value": "this_month" + "value": "this month" # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) # "value": today.replace(day=1) } From ae105d91bd530e5c36fa985ba925889c7e60af12 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 3 Nov 2022 22:27:50 +1100 Subject: [PATCH 03/29] Fully implemented date constraint for getusertables --- app.py | 17 +++++++++-------- blocks.py | 11 +++++------ database.py | 23 +++++++---------------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index 3be31d2..5008f12 100644 --- a/app.py +++ b/app.py @@ -39,7 +39,8 @@ def parse_date_constraint(constraint): # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) return today.replace(day=1) case "all time": - return None + # Empty string - SQLite uses strings to store dates and this is the smallest possible string lexicographically + return "" ################################### User validation ################################### @@ -139,8 +140,8 @@ def get_logged_hours(ack, body, respond, logger): print(body['state']['values']['date_constraint_block']) users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] # If 'All time' is chosen start_date will be None - # start_date_text = body['state']['values']['date_constraint_block']['time_constraint_input']['selected_option']['value'] - start_date = parse_date_constraint() + start_date_text = body['state']['values']['date_constraint_block']['time_constraint_input']['selected_option']['value'] + start_date = parse_date_constraint(start_date_text) try: num_entries = re.findall(r'\d+', body['state']['values']['num_entries_block']['num_entries_input']['value'])[0] @@ -148,13 +149,13 @@ def get_logged_hours(ack, body, respond, logger): respond('Invalid input! Please try again.') sqlc = database.SQLConnection() - output = "" - if (start_date): - output += "Date constraint: " + start_date.date() + output = f"{num_entries} most recent entries " + if (start_date != ""): + output += start_date_text for user in users: name = user_name(user) - table = sqlc.last_entries_table(user, num_entries) - output += slack_table(f"{num_entries} most recent entries by {name}", table) + "\n" + table = sqlc.last_entries_table(user, num_entries, start_date) + output += "\n" + slack_table(f"{name}", table) respond(output) ################################### Commands without forms ################################### diff --git a/blocks.py b/blocks.py index 4bdc1f5..ce9d169 100644 --- a/blocks.py +++ b/blocks.py @@ -152,8 +152,7 @@ def getusertables_form(): ] return output -# If a seperate submit button is used a listener function will catch the default date_constraint_input event so it -# won't do anything and won't show up in logs +# This is a seperate function because it's a large block used in most of the commands and in some cases is the only def date_constraint(): # Let the user choose a date constraint - the time_constraint_response event listener will run the function at # response_endpoint with the given date constraint @@ -167,9 +166,9 @@ def date_constraint(): "initial_option": { "text": { "type": "plain_text", - "text": "All time", + "text": "This year", }, - "value": "all time" + "value": "This year" }, "placeholder": { "type": "plain_text", @@ -179,9 +178,9 @@ def date_constraint(): { "text": { "type": "plain_text", - "text": "All time", + "text": "This year", }, - "value": "all time" + "value": "This year" }, { "text": { diff --git a/database.py b/database.py index 445df14..4b2a540 100644 --- a/database.py +++ b/database.py @@ -83,8 +83,8 @@ def remove_last_entry(self, user_id): self.cur.execute("""DELETE FROM time_log WHERE (user_id, entry_num) IN ( SELECT user_id, entry_num FROM time_log - WHERE user_id = ? ORDER BY - entry_num DESC LIMIT 1);""", (user_id,)) + WHERE user_id = ? + ORDER BY entry_num DESC LIMIT 1);""", (user_id,)) # Get all entries by all users def timelog_table(self): @@ -97,11 +97,13 @@ def timelog_table(self): return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) # Get the last n entries by user as a table - def last_entries_table(self, user_id, num_entries): + def last_entries_table(self, user_id, num_entries, start_date = ""): res = self.cur.execute("""SELECT entry_num, entry_date, selected_date, minutes - FROM time_log WHERE user_id = ? + FROM time_log + WHERE user_id = ? + AND selected_date > ? ORDER BY entry_num DESC - LIMIT ?;""", (user_id, num_entries)) + LIMIT ?;""", (user_id, start_date, num_entries)) header = ["Entry Number", "Date Submitted", "Date of Log", "Minutes"] return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) @@ -128,17 +130,6 @@ def all_time_sums(self): ORDER BY time_sum;""") return(res.fetchall()) - # Get total minutes logged by user with given user_id within the given number of days of the current date - def time_sum_after_date(self, user_id, days): - today = datetime.date.today().strftime('%Y-%m-%d') - startDate = today - datetime.timedelta(days) - # If the user has entries in the database return their time logged within the specified period, otherwise return 0 - minutes = res.fetchone()[0] - if (minutes != None): - return(minutes) - else: - return(0) - # Get the top 10 contributors def leaderboard(self, num_users): # Returns a tuple of tuples containing the name of the user, a custom dispay name (or empty string), and the number of minutes logged From 64e2e9259b214a63ae42df66a272738b6f35c28e Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Sat, 5 Nov 2022 21:51:53 +1100 Subject: [PATCH 04/29] Added date overview and begun extending leaderboard functionality --- app.py | 72 +++++++++++++++++++++++++++++++--------------- blocks.py | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++ database.py | 10 +++++++ 3 files changed, 141 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index 5008f12..4f15a4d 100644 --- a/app.py +++ b/app.py @@ -158,6 +158,55 @@ def get_logged_hours(ack, body, respond, logger): output += "\n" + slack_table(f"{name}", table) respond(output) +@app.command("/dateoverview") +def get_date_overview_form(ack, respond, body): + ack() + if(is_admin(body['user_id'])): + respond(blocks=blocks.dateoverview_form()) + else: + respond("You must be an admin to use this command!") + +@app.action("dateoverview_response") +def get_date_overview(ack, body, respond, logger): + ack() + selected_date = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() + sqlc = database.SQLConnection() + table = sqlc.entries_for_date_table(selected_date) + respond("\n" + slack_table(f"All entries for {selected_date}", table)) + + +# Get a leaderboard with the top 10 contributors and their hours logged +@app.command("/leaderboard") +def leaderboard(ack, body, respond, logger, command): + ack() + if(is_admin(body['user_id'])): + respond(blocks=blocks.dateoverview_form()) + else: + respond("You must be an admin to use this command!") + +@app.action("leaderboard_response") +def leaderboard_response(ack, body, respond, logger, command) + try: + user_id = body['user_id'] + num_users = int(command['text']) if command['text'] != "" else 10 # Defaults to 5 entries + except: + logger.exception("Invalid user input, failed to fetch leaderboard") + respond("*Invalid input!* Please try again! You can get a leaderboard with n users with `/leaderboard n`. If you leave n blank a default value of 10 will be used.") + + if(is_admin(body['user_id'])): + sqlc = database.SQLConnection() + contributions = sqlc.leaderboard(num_users) + output = f"*Top {num_users} contributors*\n" + for i in contributions: + # Add custom display name if applicable + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + respond(output) + else: + respond("You must be an admin to use this command!") + + ################################### Commands without forms ################################### @app.command("/help") @@ -235,29 +284,6 @@ def log_database(ack, body, respond, command, logger): else: respond("You must be an admin to use this command!") -# Get a leaderboard with the top 10 contributors and their hours logged -@app.command("/leaderboard") -def leaderboard(ack, body, respond, logger, command): - ack() - try: - user_id = body['user_id'] - num_users = int(command['text']) if command['text'] != "" else 10 # Defaults to 5 entries - except: - logger.exception("Invalid user input, failed to fetch leaderboard") - respond("*Invalid input!* Please try again! You can get a leaderboard with n users with `/leaderboard n`. If you leave n blank a default value of 10 will be used.") - - if(is_admin(body['user_id'])): - sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(num_users) - output = f"*Top {num_users} contributors*\n" - for i in contributions: - # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" - respond(output) - else: - respond("You must be an admin to use this command!") ################################### Other events to be handled ################################### diff --git a/blocks.py b/blocks.py index ce9d169..9496acf 100644 --- a/blocks.py +++ b/blocks.py @@ -221,6 +221,88 @@ def date_constraint(): print("got it") return output +# Time logging form blocks +def dateoverview_form(): + output = [ + { + # Date picker + "type": "input", + "block_id": "date_select_block", + "element": { + "type": "datepicker", + "initial_date": currentDate().strftime("%Y-%m-%d"), + "placeholder": { + "type": "plain_text", + "text": "Select a date", + }, + "action_id": "date_select_input" + }, + "label": { + "type": "plain_text", + "text": "Date to log", + } + }, + { + # Submit button + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Click to submit and log hours" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Submit", + }, + "value": "placeholder", + "action_id": "dateoverview_response" + } + } + ] + return output + +def leaderboard_form(): + output = [ + { + # Date picker + "type": "input", + "block_id": "date_select_block", + "element": { + "type": "datepicker", + "initial_date": currentDate().strftime("%Y-%m-%d"), + "placeholder": { + "type": "plain_text", + "text": "Select a date", + }, + "action_id": "date_select_input" + }, + "label": { + "type": "plain_text", + "text": "Date to log", + } + }, + { + # Submit button + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Click to submit and log hours" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Submit", + }, + "value": "placeholder", + "action_id": "dateoverview_response" + } + } + ] + + + # def time_constraint_form(response_endpoint): # # Let the user choose a date constraint - the time_constraint_response event listener will run the function at # # response_endpoint with the given date constraint diff --git a/database.py b/database.py index 4b2a540..c414751 100644 --- a/database.py +++ b/database.py @@ -141,3 +141,13 @@ def leaderboard(self, num_users): ORDER BY totalMinutes DESC LIMIT ?;""", (num_users,)) return(res.fetchall()) + + def entries_for_date_table(self, selected_date): + # Get all entries by all users + res = self.cur.execute("""SELECT u.name, tl.minutes + FROM time_log tl + INNER JOIN user_names u + ON tl.user_id=u.user_id + WHERE tl.selected_date=?;""", (selected_date,)) + header = ["Name", "Minutes"] + return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) From fcd6aa1b11dd4f3cf39eed1fa0aa0a49734bf900 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Sat, 5 Nov 2022 22:52:06 +1100 Subject: [PATCH 05/29] Divided blocks into smaller functions and variables --- app.py | 7 +- blocks.py | 467 +++++++++++++++++------------------------------------- 2 files changed, 146 insertions(+), 328 deletions(-) diff --git a/app.py b/app.py index 4f15a4d..76756a7 100644 --- a/app.py +++ b/app.py @@ -180,15 +180,16 @@ def get_date_overview(ack, body, respond, logger): def leaderboard(ack, body, respond, logger, command): ack() if(is_admin(body['user_id'])): - respond(blocks=blocks.dateoverview_form()) + respond(blocks=blocks.leaderboard_form()) else: respond("You must be an admin to use this command!") @app.action("leaderboard_response") -def leaderboard_response(ack, body, respond, logger, command) +def leaderboard_response(ack, body, respond, logger, command): + ack() try: user_id = body['user_id'] - num_users = int(command['text']) if command['text'] != "" else 10 # Defaults to 5 entries + num_users = int(command['text']) if command['text'] != "" else 10 # Defaults to 10 entries except: logger.exception("Invalid user input, failed to fetch leaderboard") respond("*Invalid input!* Please try again! You can get a leaderboard with n users with `/leaderboard n`. If you leave n blank a default value of 10 will be used.") diff --git a/blocks.py b/blocks.py index 9496acf..e7ccdd1 100644 --- a/blocks.py +++ b/blocks.py @@ -2,365 +2,182 @@ from datetime import datetime, timedelta -# Get current date for time logging form -def currentDate(): - return datetime.now() +# # Get current date for time logging form +# def currentDate(): +# return datetime.now() -# Time logging form blocks -def timelog_form(): - output = [ - { - # Horizontal line - "type": "divider" +num_entries_block = { + # Number of entries + "type": "input", + "block_id": "num_entries_block", + "element": { + "type": "plain_text_input", + "action_id": "num_entries_input", + "initial_value": "20" + }, + "label": { + "type": "plain_text", + "text": "Maximum number of entries to return for each user", + } +} + +hours_input_block = { + # Hours input + "type": "input", + "block_id": "hours_block", + "element": { + "type": "plain_text_input", + "action_id": "hours_input" + }, + "label": { + "type": "plain_text", + "text": "Time logged (e.g. 2h, 25m)", + } +} + +user_select_block = { + "type": "input", + "block_id": "user_select_block", + "element": { + "type": "multi_users_select", + "placeholder": { + "type": "plain_text", + "text": "Select users", }, - { - # Date picker - "type": "input", - "block_id": "date_select_block", - "element": { - "type": "datepicker", - # YYYY-MM-DD format needs to be used here because SQL doesn't have a date data type so these are stored as strings - # and in this format lexicographical order is identical to chronological order. - "initial_date": currentDate().strftime("%Y-%m-%d"), - "placeholder": { - "type": "plain_text", - "text": "Select a date", - }, - "action_id": "date_select_input" - }, - "label": { + "action_id": "user_select_input" + }, + "label": { + "type": "plain_text", + "text": "Select users", + } +} + +date_constraint_block = { + "block_id": "date_constraint_block", + "type": "input", + "element": { + "type": "static_select", + "initial_option": { + "text": { "type": "plain_text", - "text": "Date to log", - } - }, - { - # Hours input - "type": "input", - "block_id": "hours_block", - "element": { - "type": "plain_text_input", - "action_id": "hours_input" + "text": "This year", }, - "label": { - "type": "plain_text", - "text": "Time logged (e.g. 2h, 25m)", - } + "value": "This year" }, - { - # Submit button - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Click to submit and log hours" - }, - "accessory": { - "type": "button", + "placeholder": { + "type": "plain_text", + "text": "Select an item", + }, + "options": [ + { "text": { "type": "plain_text", - "text": "Submit", + "text": "This year", }, - "value": "placeholder", - "action_id": "timelog_response" - } - } - ] - return output - -# User selection form for hour sum -def gethours_form(): - output = [ - { - "type": "input", - "block_id": "user_select_block", - "element": { - "type": "multi_users_select", - "placeholder": { + "value": "This year" + }, + { + "text": { "type": "plain_text", - "text": "Select users", + "text": "Today", }, - "action_id": "user_select_input" + "value": "today" }, - "label": { - "type": "plain_text", - "text": "Select users to view their total time logged", - } - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Confirm Selection", - }, - "action_id": "gethours_response" - } - ] - } - ] - return output - -# User selection form for table -def getusertables_form(): - output = [ - { - "type": "input", - "block_id": "user_select_block", - "element": { - "type": "multi_users_select", - "placeholder": { + { + "text": { "type": "plain_text", - "text": "Select users", + "text": "This week", }, - "action_id": "user_select_input" - }, - "label": { - "type": "plain_text", - "text": "Select users to their last n entries as a table", - } - }, - { - # Number of entries - "type": "input", - "block_id": "num_entries_block", - "element": { - "type": "plain_text_input", - "action_id": "num_entries_input", - "initial_value": "20" + "value": "this week" }, - "label": { - "type": "plain_text", - "text": "Maximum number of entries to return for each user", - } - }, - # Add a date constraint selector - date_constraint(), - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Confirm Selection", - }, - "action_id": "getusertables_response" + { + "text": { + "type": "plain_text", + "text": "This month", }, - ] - } - ] - return output + "value": "this month" + } + ], + "action_id": "time_constraint_input" + }, + "label": { + "type": "plain_text", + "text": "Selection range", + } +} -# This is a seperate function because it's a large block used in most of the commands and in some cases is the only -def date_constraint(): - # Let the user choose a date constraint - the time_constraint_response event listener will run the function at - # response_endpoint with the given date constraint - today = datetime.today() - output = { - "block_id": "date_constraint_block", +def date_select_block(): + return { + # Date picker "type": "input", + "block_id": "date_select_block", "element": { - "type": "static_select", - # Default to all time - "initial_option": { - "text": { - "type": "plain_text", - "text": "This year", - }, - "value": "This year" - }, + "type": "datepicker", + # YYYY-MM-DD format needs to be used here because SQL doesn't have a date data type so these are stored as strings + # and in this format lexicographical order is identical to chronological order. + "initial_date": datetime.now().strftime("%Y-%m-%d"), "placeholder": { "type": "plain_text", - "text": "Select an item", + "text": "Select a date", }, - "options": [ - { - "text": { - "type": "plain_text", - "text": "This year", - }, - "value": "This year" - }, - { - "text": { - "type": "plain_text", - "text": "Today", - }, - # "value": today - "value": "today" - }, - { - "text": { - "type": "plain_text", - "text": "This week", - }, - # Move back n days where n is the weekday number (so we reach the start of the week) - # Monday is 0 and Sunday is 6 here - # "value": today - timedelta(days = today.weekday()) - "value": "this week" - }, - { - "text": { - "type": "plain_text", - "text": "This month", - }, - "value": "this month" - # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) - # "value": today.replace(day=1) - } - ], - "action_id": "time_constraint_input" + "action_id": "date_select_input" }, "label": { "type": "plain_text", - "text": "Selection range", + "text": "Date to log", } } - print("got it") - return output - -# Time logging form blocks -def dateoverview_form(): - output = [ - { - # Date picker - "type": "input", - "block_id": "date_select_block", - "element": { - "type": "datepicker", - "initial_date": currentDate().strftime("%Y-%m-%d"), - "placeholder": { - "type": "plain_text", - "text": "Select a date", - }, - "action_id": "date_select_input" - }, - "label": { - "type": "plain_text", - "text": "Date to log", - } +def submit_button_block(form_action): + return { + # Submit button + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Confirm and submit" }, - { - # Submit button - "type": "section", + "accessory": { + "type": "button", "text": { - "type": "mrkdwn", - "text": "Click to submit and log hours" + "type": "plain_text", + "text": "Submit", }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Submit", - }, - "value": "placeholder", - "action_id": "dateoverview_response" - } + "value": "placeholder", + "action_id": form_action } + } + +# Time logging form +def timelog_form(): + return [ + date_select_block(), + hours_input_block, + submit_button_block("timelog_response") ] - return output -def leaderboard_form(): - output = [ - { - # Date picker - "type": "input", - "block_id": "date_select_block", - "element": { - "type": "datepicker", - "initial_date": currentDate().strftime("%Y-%m-%d"), - "placeholder": { - "type": "plain_text", - "text": "Select a date", - }, - "action_id": "date_select_input" - }, - "label": { - "type": "plain_text", - "text": "Date to log", - } - }, - { - # Submit button - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Click to submit and log hours" - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Submit", - }, - "value": "placeholder", - "action_id": "dateoverview_response" - } - } +# User selection form for hour sum +def gethours_form(): + return [ + user_select_block, + submit_button_block("gethours_response") ] +def getusertables_form(): + return [ + user_select_block, + num_entries_block, + date_constraint_block(), + submit_button_block("getusertables_response") + ] +def dateoverview_form(): + return [ + date_select_block, + submit_button_block("dateoverview_response") + ] -# def time_constraint_form(response_endpoint): -# # Let the user choose a date constraint - the time_constraint_response event listener will run the function at -# # response_endpoint with the given date constraint -# today = date.today() -# output = [ -# { -# "type": "input", -# "element": { -# "type": "static_select", -# "placeholder": { -# "type": "plain_text", -# "text": "Select an item", -# "emoji": true -# }, -# "options": [ -# { -# "text": { -# "type": "plain_text", -# "text": "Today", -# "emoji": true -# }, -# "value": today -# }, -# { -# "text": { -# "type": "plain_text", -# "text": "This week", -# "emoji": true -# }, -# # Move back n days where n is the weekday number (so we reach the start of the week) -# # Monday is 0 and Sunday is 6 here -# "value": today - datetime.timedelta(days = today.weekday()) -# }, -# { -# "text": { -# "type": "plain_text", -# "text": "This month", -# "emoji": true -# }, -# # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) -# "value": today.replace(day=1) -# }, -# { -# "text": { -# "type": "plain_text", -# "text": "All time", -# "emoji": true -# }, -# "value": "All time" -# } -# ], -# "value": response_endpoint, -# "action_id": "time_constraint_response" -# }, -# "label": { -# "type": "plain_text", -# "text": "Selection range", -# "emoji": true -# } -# } -# ] +def leaderboard_form(): + return [ + date_select_block(), + submit_button_block("leaderboard_response") + ] From 61e3d4f03ed18af2a9bade7f7bf593d612540866 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 7 Nov 2022 17:55:03 +1100 Subject: [PATCH 06/29] Add time restriction for leaderboard command --- app.py | 50 ++++++++++++++++++++++++-------------------------- blocks.py | 23 ++++++++++++++++++++--- database.py | 35 ++++++++++++++++++++++++----------- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/app.py b/app.py index 256d8f4..9e84cc8 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from dotenv import load_dotenv from pathlib import Path +from collections import namedtuple import sys if not sys.version_info >= (3, 10): @@ -26,21 +27,25 @@ def slack_table(title, message): return(f"*{title}*\n```{message}```") +date_range = namedtuple("date_range", ["start_date", "end_date"]) + +# Convert date constraint from form to YYYY-MM-DD string for database query def parse_date_constraint(constraint): today = datetime.today() + print(today.weekday()) # Requires python 3.10 or higher match constraint: case "today": - return today + return today.strftime('%Y-%m-%d') case "this week": # Move back n days where n is the weekday number (so we reach the start of the week (Monday is 0, Sunday is 6)) - return today - timedelta(days = today.weekday()) + return date_range(today - timedelta(days=today.weekday()), today).strftime('%Y-%m-%d') case "this month": # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) - return today.replace(day=1) + return date_range(today.replace(day=1), today).strftime('%Y-%m-%d') case "all time": # Empty string - SQLite uses strings to store dates and this is the smallest possible string lexicographically - return "" + return None ################################### User validation ################################### @@ -140,7 +145,7 @@ def get_logged_hours(ack, body, respond, logger): print(body['state']['values']['date_constraint_block']) users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] # If 'All time' is chosen start_date will be None - start_date_text = body['state']['values']['date_constraint_block']['time_constraint_input']['selected_option']['value'] + start_date_text = body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value'] start_date = parse_date_constraint(start_date_text) try: @@ -177,7 +182,7 @@ def get_date_overview(ack, body, respond, logger): # Get a leaderboard with the top 10 contributors and their hours logged @app.command("/leaderboard") -def leaderboard(ack, body, respond, logger, command): +def leaderboard(ack, body, respond): ack() if(is_admin(body['user_id'])): respond(blocks=blocks.leaderboard_form()) @@ -187,26 +192,20 @@ def leaderboard(ack, body, respond, logger, command): @app.action("leaderboard_response") def leaderboard_response(ack, body, respond, logger, command): ack() - try: - user_id = body['user_id'] - num_users = int(command['text']) if command['text'] != "" else 10 # Defaults to 10 entries - except: - logger.exception("Invalid user input, failed to fetch leaderboard") - respond("*Invalid input!* Please try again! You can get a leaderboard with n users with `/leaderboard n`. If you leave n blank a default value of 10 will be used.") - - if(is_admin(body['user_id'])): - sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(num_users) - output = f"*Top {num_users} contributors*\n" - for i in contributions: - # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" - respond(output) - else: - respond("You must be an admin to use this command!") + print(body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value']) + date_constraint = parse_date_constraint(body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value']) + num_users = body['state']['values']['num_users_block']['num_users_input']['value'] + print(date_constraint) + sqlc = database.SQLConnection() + contributions = sqlc.leaderboard(num_users, date_constraint) if date_constraint else sqlc.leaderboard(num_users) + output = f"*Top {num_users} contributors*\n" + for i in contributions: + # Add custom display name if applicable + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + respond(output) ################################### Commands without forms ################################### @@ -321,7 +320,6 @@ def handle_some_action(ack, body, logger): ack() logger.debug(body) - if __name__ == "__main__": # Create tables database.create_log_table() diff --git a/blocks.py b/blocks.py index e7ccdd1..06adf7b 100644 --- a/blocks.py +++ b/blocks.py @@ -21,6 +21,22 @@ } } +num_users_block = { + # Number of entries + "type": "input", + "block_id": "num_users_block", + "element": { + "type": "plain_text_input", + "action_id": "num_users_input", + "initial_value": "10" + }, + "label": { + "type": "plain_text", + "text": "Number of users to return", + } +} + + hours_input_block = { # Hours input "type": "input", @@ -98,7 +114,7 @@ "value": "this month" } ], - "action_id": "time_constraint_input" + "action_id": "date_constraint_input" }, "label": { "type": "plain_text", @@ -166,7 +182,7 @@ def getusertables_form(): return [ user_select_block, num_entries_block, - date_constraint_block(), + date_constraint_block, submit_button_block("getusertables_response") ] @@ -178,6 +194,7 @@ def dateoverview_form(): def leaderboard_form(): return [ - date_select_block(), + num_users_block, + date_constraint_block, submit_button_block("leaderboard_response") ] diff --git a/database.py b/database.py index c414751..a821d03 100644 --- a/database.py +++ b/database.py @@ -68,6 +68,7 @@ def insert_timelog_entry(self, user_id, selected_date, minutes): today = datetime.date.today().strftime('%Y-%m-%d') + # Get and increment the entry number res = self.cur.execute("""SELECT MAX(entry_num) FROM time_log WHERE user_id = ?;""", (user_id,)) @@ -76,7 +77,7 @@ def insert_timelog_entry(self, user_id, selected_date, minutes): entry_num = 1 else: entry_num += 1 - + self.cur.execute("INSERT INTO time_log VALUES (?,?,?,?,?, NULL);", (entry_num, user_id, today, selected_date, minutes )) def remove_last_entry(self, user_id): @@ -98,6 +99,7 @@ def timelog_table(self): # Get the last n entries by user as a table def last_entries_table(self, user_id, num_entries, start_date = ""): + print(start_date) res = self.cur.execute("""SELECT entry_num, entry_date, selected_date, minutes FROM time_log WHERE user_id = ? @@ -131,23 +133,34 @@ def all_time_sums(self): return(res.fetchall()) # Get the top 10 contributors - def leaderboard(self, num_users): - # Returns a tuple of tuples containing the name of the user, a custom dispay name (or empty string), and the number of minutes logged - res = self.cur.execute("""SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes - FROM user_names u - INNER JOIN time_log tl - ON u.user_id=tl.user_id - GROUP BY u.name, u.user_id, u.display_name - ORDER BY totalMinutes DESC - LIMIT ?;""", (num_users,)) + def leaderboard(self, num_users = None, date_constraint = None): + query = """SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes + FROM user_names u + INNER JOIN time_log tl + ON u.user_id=tl.user_id """ + params = [] + if date_constraint: + query += "WHERE selected_date > ? AND selected_date < ? " + params.append(date_constraint.start_date.date()) + params.append(date_constraint.end_date.date()) + # query += """ """ # Close the WHERE clause + query += """GROUP BY u.name, u.display_name + ORDER BY totalMinutes DESC """ + if num_users: + query += "LIMIT ?" + params.append(num_users) + print(query) + print(params) + res = self.cur.execute(query, params) return(res.fetchall()) def entries_for_date_table(self, selected_date): - # Get all entries by all users + # Get all entries by all users7 res = self.cur.execute("""SELECT u.name, tl.minutes FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id WHERE tl.selected_date=?;""", (selected_date,)) header = ["Name", "Minutes"] + print(selected_date) return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) From 8b2c553e431938179a942b88ed908cfce017da6f Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 7 Nov 2022 18:15:28 +1100 Subject: [PATCH 07/29] Fixed leaderboard date constraint by requiring string dates --- app.py | 8 ++++---- database.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index 9e84cc8..8eda8f3 100644 --- a/app.py +++ b/app.py @@ -36,13 +36,14 @@ def parse_date_constraint(constraint): # Requires python 3.10 or higher match constraint: case "today": - return today.strftime('%Y-%m-%d') + # This isn't great but it probably won't be used often + return date_range(today.strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) case "this week": # Move back n days where n is the weekday number (so we reach the start of the week (Monday is 0, Sunday is 6)) - return date_range(today - timedelta(days=today.weekday()), today).strftime('%Y-%m-%d') + return date_range((today - timedelta(days=today.weekday())).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) case "this month": # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) - return date_range(today.replace(day=1), today).strftime('%Y-%m-%d') + return date_range((today.replace(day=1)).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) case "all time": # Empty string - SQLite uses strings to store dates and this is the smallest possible string lexicographically return None @@ -88,7 +89,6 @@ def submit_timelog_form(ack, respond, body, logger): logger.info(f"New log entry of {time_input[0]} hours and {time_input[1]} minutes for {selected_date} by {user_id}") - # Open an SQL connection and add entry to database containing user input sqlc = database.SQLConnection() sqlc.insert_timelog_entry(user_id, selected_date, minutes) diff --git a/database.py b/database.py index a821d03..af77b84 100644 --- a/database.py +++ b/database.py @@ -140,9 +140,9 @@ def leaderboard(self, num_users = None, date_constraint = None): ON u.user_id=tl.user_id """ params = [] if date_constraint: - query += "WHERE selected_date > ? AND selected_date < ? " - params.append(date_constraint.start_date.date()) - params.append(date_constraint.end_date.date()) + query += "WHERE selected_date >= ? AND selected_date <= ? " + params.append(date_constraint.start_date) + params.append(date_constraint.end_date) # query += """ """ # Close the WHERE clause query += """GROUP BY u.name, u.display_name ORDER BY totalMinutes DESC """ From efee742e1c97204d2f1251dff46b854d7529ea73 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 8 Nov 2022 22:53:17 +1100 Subject: [PATCH 08/29] Removed num_users constraint from leaderboard and removed redundant command allusersums --- app.py | 22 +--------------------- blocks.py | 1 - database.py | 21 ++------------------- 3 files changed, 3 insertions(+), 41 deletions(-) diff --git a/app.py b/app.py index 8eda8f3..1d7420e 100644 --- a/app.py +++ b/app.py @@ -198,7 +198,7 @@ def leaderboard_response(ack, body, respond, logger, command): print(date_constraint) sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(num_users, date_constraint) if date_constraint else sqlc.leaderboard(num_users) + contributions = sqlc.leaderboard(date_constraint) if date_constraint else sqlc.leaderboard(date_constraint) output = f"*Top {num_users} contributors*\n" for i in contributions: # Add custom display name if applicable @@ -221,7 +221,6 @@ def help(ack, respond, body, command): output += """ \n*Admin Commands:* */gethours* Select users and get their total hours logged - */allusersums* Get the total hours logged by all users */getusertables* Select users to see their last few entries */allusertable* Responds with the last 30 entries from all users */leaderboard n* Responds with the top n contributors and their total time logged (defaults to 10)""" @@ -252,25 +251,6 @@ def delete_last(ack, respond, body, command): sqlc.remove_last_entry(body['user_id']) respond("Last entry removed!") -# Respond with the total time logged by all users -@app.command("/allusersums") -def get_logged_hours(ack, body, respond, logger): - ack() - if(is_admin(body['user_id'])): - sqlc = database.SQLConnection() - user_sum = sqlc.all_time_sums() - output = "" - # Add the time logged by each user to the output - for user in user_sum: - # Add a custom display name if the user has one set - display_name = " ("+user[1]+")" if user[1] != "" else "" - output += f"*{user[0]}*{display_name}: {int(user[2]/60)} hours and {int(user[2]%60)} minutes\n" - # Send output to Slack chat and console - logger.info("\n" + output) - respond(output) - else: - respond("You must be an admin to use this command!") - # Respond with last 30 hours entered by all users @app.command("/allusertable") def log_database(ack, body, respond, command, logger): diff --git a/blocks.py b/blocks.py index 06adf7b..653bbd7 100644 --- a/blocks.py +++ b/blocks.py @@ -194,7 +194,6 @@ def dateoverview_form(): def leaderboard_form(): return [ - num_users_block, date_constraint_block, submit_button_block("leaderboard_response") ] diff --git a/database.py b/database.py index af77b84..75061ca 100644 --- a/database.py +++ b/database.py @@ -120,20 +120,9 @@ def time_sum(self, user_id): return(minutes) else: return(0) - - # Get total minutes logged by all users - def all_time_sums(self): - # If the user has entries in the database return their total time logged - res = self.cur.execute("""SELECT u.name, u.display_name, SUM(tl.minutes) AS time_sum - FROM time_log tl - INNER JOIN user_names u - ON u.user_id=tl.user_id - GROUP BY u.name, u.display_name - ORDER BY time_sum;""") - return(res.fetchall()) - + # Get the top 10 contributors - def leaderboard(self, num_users = None, date_constraint = None): + def leaderboard(self, date_constraint = None): query = """SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes FROM user_names u INNER JOIN time_log tl @@ -143,14 +132,8 @@ def leaderboard(self, num_users = None, date_constraint = None): query += "WHERE selected_date >= ? AND selected_date <= ? " params.append(date_constraint.start_date) params.append(date_constraint.end_date) - # query += """ """ # Close the WHERE clause query += """GROUP BY u.name, u.display_name ORDER BY totalMinutes DESC """ - if num_users: - query += "LIMIT ?" - params.append(num_users) - print(query) - print(params) res = self.cur.execute(query, params) return(res.fetchall()) From 95972025f549bbe475780ef45fad15fa1bb6f883 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 8 Nov 2022 22:57:18 +1100 Subject: [PATCH 09/29] Move date object format to string to the database query code section for clarity --- app.py | 7 ++++--- database.py | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index 1d7420e..ce3787e 100644 --- a/app.py +++ b/app.py @@ -37,13 +37,13 @@ def parse_date_constraint(constraint): match constraint: case "today": # This isn't great but it probably won't be used often - return date_range(today.strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) + return date_range(today, today) case "this week": # Move back n days where n is the weekday number (so we reach the start of the week (Monday is 0, Sunday is 6)) - return date_range((today - timedelta(days=today.weekday())).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) + return date_range((today - timedelta(days=today.weekday())), today) case "this month": # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) - return date_range((today.replace(day=1)).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) + return date_range((today.replace(day=1)), today) case "all time": # Empty string - SQLite uses strings to store dates and this is the smallest possible string lexicographically return None @@ -176,6 +176,7 @@ def get_date_overview(ack, body, respond, logger): ack() selected_date = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() sqlc = database.SQLConnection() + print(selected_date) table = sqlc.entries_for_date_table(selected_date) respond("\n" + slack_table(f"All entries for {selected_date}", table)) diff --git a/database.py b/database.py index 75061ca..5305bb9 100644 --- a/database.py +++ b/database.py @@ -120,7 +120,7 @@ def time_sum(self, user_id): return(minutes) else: return(0) - + # Get the top 10 contributors def leaderboard(self, date_constraint = None): query = """SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes @@ -130,8 +130,8 @@ def leaderboard(self, date_constraint = None): params = [] if date_constraint: query += "WHERE selected_date >= ? AND selected_date <= ? " - params.append(date_constraint.start_date) - params.append(date_constraint.end_date) + params.append(date_constraint.start_date.strftime('%Y-%m-%d')) + params.append(date_constraint.end_date.strftime('%Y-%m-%d')) query += """GROUP BY u.name, u.display_name ORDER BY totalMinutes DESC """ res = self.cur.execute(query, params) @@ -145,5 +145,4 @@ def entries_for_date_table(self, selected_date): ON tl.user_id=u.user_id WHERE tl.selected_date=?;""", (selected_date,)) header = ["Name", "Minutes"] - print(selected_date) return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) From b132197ace782c572ac908de8ad34e8716cd1c03 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 8 Nov 2022 23:07:46 +1100 Subject: [PATCH 10/29] Removed unnecessary date constraints for table retrieval --- app.py | 22 +++++----------------- blocks.py | 1 - 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/app.py b/app.py index ce3787e..49fac0a 100644 --- a/app.py +++ b/app.py @@ -142,24 +142,16 @@ def get_user_hours_form(ack, respond, body, command): @app.action("getusertables_response") def get_logged_hours(ack, body, respond, logger): ack() - print(body['state']['values']['date_constraint_block']) users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] - # If 'All time' is chosen start_date will be None - start_date_text = body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value'] - start_date = parse_date_constraint(start_date_text) - try: num_entries = re.findall(r'\d+', body['state']['values']['num_entries_block']['num_entries_input']['value'])[0] except: respond('Invalid input! Please try again.') - sqlc = database.SQLConnection() - output = f"{num_entries} most recent entries " - if (start_date != ""): - output += start_date_text + output = "" for user in users: name = user_name(user) - table = sqlc.last_entries_table(user, num_entries, start_date) + table = sqlc.last_entries_table(user, num_entries) output += "\n" + slack_table(f"{name}", table) respond(output) @@ -176,7 +168,6 @@ def get_date_overview(ack, body, respond, logger): ack() selected_date = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() sqlc = database.SQLConnection() - print(selected_date) table = sqlc.entries_for_date_table(selected_date) respond("\n" + slack_table(f"All entries for {selected_date}", table)) @@ -193,14 +184,11 @@ def leaderboard(ack, body, respond): @app.action("leaderboard_response") def leaderboard_response(ack, body, respond, logger, command): ack() - print(body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value']) - date_constraint = parse_date_constraint(body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value']) - num_users = body['state']['values']['num_users_block']['num_users_input']['value'] - print(date_constraint) - + date_constraint_text = body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value'] + date_constraint = parse_date_constraint(date_constraint_text) sqlc = database.SQLConnection() contributions = sqlc.leaderboard(date_constraint) if date_constraint else sqlc.leaderboard(date_constraint) - output = f"*Top {num_users} contributors*\n" + output = f"*All contributors for {date_constraint_text} ranked by hours logged*\n" for i in contributions: # Add custom display name if applicable name = i[0] diff --git a/blocks.py b/blocks.py index 653bbd7..bf07d23 100644 --- a/blocks.py +++ b/blocks.py @@ -182,7 +182,6 @@ def getusertables_form(): return [ user_select_block, num_entries_block, - date_constraint_block, submit_button_block("getusertables_response") ] From 5466ca5cc1728ae1e053317ec54ce7dd0024f7ab Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 8 Nov 2022 23:50:23 +1100 Subject: [PATCH 11/29] Add weekly and yearly summary to gethours output for users --- app.py | 11 ++++++++--- database.py | 36 ++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/app.py b/app.py index 49fac0a..fa7c8fd 100644 --- a/app.py +++ b/app.py @@ -32,7 +32,6 @@ def slack_table(title, message): # Convert date constraint from form to YYYY-MM-DD string for database query def parse_date_constraint(constraint): today = datetime.today() - print(today.weekday()) # Requires python 3.10 or higher match constraint: case "today": @@ -222,7 +221,7 @@ def user_entries(ack, respond, body, command, logger): try: user_id = body['user_id'] name = user_name(user_id) - num_entries = int(command['text']) if command['text'] != "" else 5 # Defaults to 5 entries + num_entries = int(command['text']) if command['text'] != "" else 10 # Defaults to 10 entries except: logger.exception("Invalid user input, failed to create time log entry") respond("*Invalid input!* Please try again! You can generate a table with your last n entries with `/myentries n`. If you leave n blank a default value of 5 will be used.") @@ -230,7 +229,13 @@ def user_entries(ack, respond, body, command, logger): sqlc = database.SQLConnection() table = sqlc.last_entries_table(user_id, num_entries) - respond(slack_table(f"{num_entries} most recent entries by {name}", table)) + yearly_minutes = sqlc.time_sum(user_id, date_constraint=parse_date_constraint("this year")) + weekly_minutes = sqlc.time_sum(user_id, date_constraint=parse_date_constraint("this week")) + + output = f"\n*Hours logged this year*: {int(yearly_minutes/60)} hours and {int(yearly_minutes%60)} minutes" + output += f"\n*Hours logged this week:* {int(weekly_minutes/60)} hours and {int(weekly_minutes%60)} minutes\n\n\n" + output += slack_table(f"Your {num_entries} most recent entries", table) + respond(output) # Delete the last entry made by the user issuing the command @app.command("/deletelast") diff --git a/database.py b/database.py index 5305bb9..7563431 100644 --- a/database.py +++ b/database.py @@ -16,7 +16,7 @@ def create_log_table(): minutes INTEGER NOT NULL, project TEXT, - PRIMARY KEY (entry_num, user_id));""") + PRIMARY KEY (entry_num, user_id)) """) con.close() def create_user_table(): @@ -27,7 +27,7 @@ def create_user_table(): name TEXT NOT NULL, display_name TEXT, - PRIMARY KEY (user_id));""") + PRIMARY KEY (user_id)) """) class SQLConnection: @@ -45,7 +45,7 @@ def __del__(self): def validate_user(self, user_id, name, display_name): self.cur.execute("""INSERT INTO user_names (user_id, name, display_name) VALUES (?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET name=?, display_name=?;""", (user_id, name, display_name, name, display_name)) + ON CONFLICT(user_id) DO UPDATE SET name=?, display_name=? """, (user_id, name, display_name, name, display_name)) # Get user's full name and custom display name from database def user_name(self, user_id): @@ -71,21 +71,21 @@ def insert_timelog_entry(self, user_id, selected_date, minutes): # Get and increment the entry number res = self.cur.execute("""SELECT MAX(entry_num) FROM time_log - WHERE user_id = ?;""", (user_id,)) + WHERE user_id = ? """, (user_id,)) entry_num = res.fetchone()[0] if (entry_num == None): entry_num = 1 else: entry_num += 1 - self.cur.execute("INSERT INTO time_log VALUES (?,?,?,?,?, NULL);", (entry_num, user_id, today, selected_date, minutes )) + self.cur.execute("INSERT INTO time_log VALUES (?,?,?,?,?, NULL) ", (entry_num, user_id, today, selected_date, minutes )) def remove_last_entry(self, user_id): self.cur.execute("""DELETE FROM time_log WHERE (user_id, entry_num) IN ( SELECT user_id, entry_num FROM time_log WHERE user_id = ? - ORDER BY entry_num DESC LIMIT 1);""", (user_id,)) + ORDER BY entry_num DESC LIMIT 1) """, (user_id,)) # Get all entries by all users def timelog_table(self): @@ -93,28 +93,32 @@ def timelog_table(self): FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id - LIMIT 30;""") + LIMIT 30 """) header = ["Name", "Entry Number", "Date Submitted", "Date of Log", "Minutes"] return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) # Get the last n entries by user as a table - def last_entries_table(self, user_id, num_entries, start_date = ""): - print(start_date) + def last_entries_table(self, user_id, num_entries = 10): res = self.cur.execute("""SELECT entry_num, entry_date, selected_date, minutes FROM time_log WHERE user_id = ? - AND selected_date > ? ORDER BY entry_num DESC - LIMIT ?;""", (user_id, start_date, num_entries)) + LIMIT ? """, (user_id, num_entries)) header = ["Entry Number", "Date Submitted", "Date of Log", "Minutes"] return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) # Get total minutes logged by user with given user_id - def time_sum(self, user_id): + def time_sum(self, user_id, date_constraint = None): # If the user has entries in the database return their total time logged, otherwise return 0 - res = self.cur.execute("""SELECT SUM(minutes) - FROM time_log - WHERE user_id = ?;""", (user_id,)) + query = """SELECT SUM(minutes) + FROM time_log + WHERE user_id = ? """ + params = [user_id] + if date_constraint: + query += "AND selected_date >= ? AND selected_date <= ? " + params.append(date_constraint.start_date.strftime('%Y-%m-%d')) + params.append(date_constraint.end_date.strftime('%Y-%m-%d')) + res = self.cur.execute(query, params) minutes = res.fetchone()[0] if (minutes != None): return(minutes) @@ -143,6 +147,6 @@ def entries_for_date_table(self, selected_date): FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id - WHERE tl.selected_date=?;""", (selected_date,)) + WHERE tl.selected_date=? """, (selected_date,)) header = ["Name", "Minutes"] return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) From 00d86a145ca3da0f76728e10fa1bc6d41fd75cc2 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 10 Nov 2022 13:11:01 +1100 Subject: [PATCH 12/29] show date constraint in leaderboard output --- app.py | 19 +++++++++++-------- blocks.py | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index fa7c8fd..0e815c1 100644 --- a/app.py +++ b/app.py @@ -186,14 +186,17 @@ def leaderboard_response(ack, body, respond, logger, command): date_constraint_text = body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value'] date_constraint = parse_date_constraint(date_constraint_text) sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(date_constraint) if date_constraint else sqlc.leaderboard(date_constraint) - output = f"*All contributors for {date_constraint_text} ranked by hours logged*\n" - for i in contributions: - # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" - respond(output) + contributions = sqlc.leaderboard(date_constraint) + if contributions(): + output = f"*All contributors for {date_constraint_text} ranked by hours logged*\n" + for i in contributions: + # Add custom display name if applicable + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + respond(output) + else: + respond(f"No hours logged {date_constraint_text}!") ################################### Commands without forms ################################### diff --git a/blocks.py b/blocks.py index bf07d23..a348b9e 100644 --- a/blocks.py +++ b/blocks.py @@ -78,7 +78,7 @@ "type": "plain_text", "text": "This year", }, - "value": "This year" + "value": "this year" }, "placeholder": { "type": "plain_text", @@ -90,7 +90,7 @@ "type": "plain_text", "text": "This year", }, - "value": "This year" + "value": "this year" }, { "text": { From 05c2f84318d58100cf08807d3ed8fd99e35b0a98 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 10 Nov 2022 14:16:29 +1100 Subject: [PATCH 13/29] update date_constraint_input action handler to work with name change --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 0e815c1..10411b1 100644 --- a/app.py +++ b/app.py @@ -292,7 +292,7 @@ def select_date(ack, body, logger): ack() logger.debug(body) -@app.action("time_constraint_input") +@app.action("date_constraint_input") def handle_some_action(ack, body, logger): ack() logger.debug(body) From 512998157e9362682497db57b75174b22ee95879 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 11 Nov 2022 12:45:35 +1100 Subject: [PATCH 14/29] Replace table output with lists and improve command structure --- app.py | 114 ++++++++++++++++++++++++++-------------------------- blocks.py | 80 +++++++++++++++++++++--------------- database.py | 53 +++++++++++------------- 3 files changed, 130 insertions(+), 117 deletions(-) diff --git a/app.py b/app.py index 10411b1..764d6d0 100644 --- a/app.py +++ b/app.py @@ -23,30 +23,6 @@ # Set up logging for info messages logging.basicConfig(level=logging.INFO) -# Use the slack code block format to force monospace font (without this the table rows and lines will be missaligned) -def slack_table(title, message): - return(f"*{title}*\n```{message}```") - -date_range = namedtuple("date_range", ["start_date", "end_date"]) - -# Convert date constraint from form to YYYY-MM-DD string for database query -def parse_date_constraint(constraint): - today = datetime.today() - # Requires python 3.10 or higher - match constraint: - case "today": - # This isn't great but it probably won't be used often - return date_range(today, today) - case "this week": - # Move back n days where n is the weekday number (so we reach the start of the week (Monday is 0, Sunday is 6)) - return date_range((today - timedelta(days=today.weekday())), today) - case "this month": - # Replace the day part of the date with 1 (2022-11-23 becomes 2022-11-01) - return date_range((today.replace(day=1)), today) - case "all time": - # Empty string - SQLite uses strings to store dates and this is the smallest possible string lexicographically - return None - ################################### User validation ################################### # Get user's full name and custom display name (if applicable) from database @@ -80,8 +56,9 @@ def submit_timelog_form(ack, respond, body, logger): ack() user_id = body['user']['id'] # Get user-selected date, hours, and minutes from form - selected_date = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() + selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] time_input = re.findall(r'\d+', body['state']['values']['hours_block']['hours_input']['value']) # creates list containing two strings (hours and minutes) + summary = body['state']['values']['text_field_block']['text_input']['value'] try: minutes = int(time_input[0])*60 + int(time_input[1]) # user input (hours and minutes) stored as minutes only @@ -89,7 +66,7 @@ def submit_timelog_form(ack, respond, body, logger): logger.info(f"New log entry of {time_input[0]} hours and {time_input[1]} minutes for {selected_date} by {user_id}") sqlc = database.SQLConnection() - sqlc.insert_timelog_entry(user_id, selected_date, minutes) + sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) respond(f"Time logged: {time_input[0]} hours and {time_input[1]} minutes for date {selected_date}.") @@ -129,16 +106,16 @@ def get_logged_hours(ack, body, respond, logger): respond(output) # Get user-selection form (choose users to see tables for their logged hours per date) -@app.command("/getusertables") +@app.command("/getentries") def get_user_hours_form(ack, respond, body, command): ack() if(is_admin(body['user_id'])): - respond(blocks=blocks.getusertables_form()) + respond(blocks=blocks.getentries_form()) else: respond("You must be an admin to use this command!") # Form response: log tables for all users specified -@app.action("getusertables_response") +@app.action("getentries_response") def get_logged_hours(ack, body, respond, logger): ack() users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] @@ -149,9 +126,11 @@ def get_logged_hours(ack, body, respond, logger): sqlc = database.SQLConnection() output = "" for user in users: - name = user_name(user) - table = sqlc.last_entries_table(user, num_entries) - output += "\n" + slack_table(f"{name}", table) + output += f"\n\n\n*{user_name(user)}*" + entries = sqlc.last_entries_list(user, num_entries) + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" respond(output) @app.command("/dateoverview") @@ -165,10 +144,16 @@ def get_date_overview_form(ack, respond, body): @app.action("dateoverview_response") def get_date_overview(ack, body, respond, logger): ack() - selected_date = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() + selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] sqlc = database.SQLConnection() - table = sqlc.entries_for_date_table(selected_date) - respond("\n" + slack_table(f"All entries for {selected_date}", table)) + entries = sqlc.entries_for_date_list(selected_date) + output = f"*Overview for {selected_date}*" + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + respond(output) # Get a leaderboard with the top 10 contributors and their hours logged @@ -183,12 +168,12 @@ def leaderboard(ack, body, respond): @app.action("leaderboard_response") def leaderboard_response(ack, body, respond, logger, command): ack() - date_constraint_text = body['state']['values']['date_constraint_block']['date_constraint_input']['selected_option']['value'] - date_constraint = parse_date_constraint(date_constraint_text) + start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(date_constraint) + contributions = sqlc.leaderboard(start_date, end_date) if contributions(): - output = f"*All contributors for {date_constraint_text} ranked by hours logged*\n" + output = f"*All contributors between {start_date} and {end_date} ranked by hours logged*\n" for i in contributions: # Add custom display name if applicable name = i[0] @@ -196,7 +181,7 @@ def leaderboard_response(ack, body, respond, logger, command): output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" respond(output) else: - respond(f"No hours logged {date_constraint_text}!") + respond(f"No hours logged between {start_date} and {end_date}!") ################################### Commands without forms ################################### @@ -207,14 +192,15 @@ def help(ack, respond, body, command): \n*User Commands:* */timelog* Opens a time logging form */deletelast* Delete your last entry - */myentries n* Get a table with your last n entries (defaults to 5)""" + */myentries n* Get your last n entries (defaults to 5)""" if(is_admin(body['user_id'])): output += """ \n*Admin Commands:* */gethours* Select users and get their total hours logged - */getusertables* Select users to see their last few entries - */allusertable* Responds with the last 30 entries from all users - */leaderboard n* Responds with the top n contributors and their total time logged (defaults to 10)""" + */getentries* Select users to see their most recent entries + */lastentries n* Responds with the last 30 entries from all users + */leaderboard n* Responds with the top n contributors and their total time logged (defaults to 10) + */dateoverview* Responds with all entries for a given date""" respond(output) # Respond with a table showing the last n entries made by the user issuing the command @@ -230,14 +216,18 @@ def user_entries(ack, respond, body, command, logger): respond("*Invalid input!* Please try again! You can generate a table with your last n entries with `/myentries n`. If you leave n blank a default value of 5 will be used.") sqlc = database.SQLConnection() - table = sqlc.last_entries_table(user_id, num_entries) + entries = sqlc.last_entries_list(user_id, num_entries) + today = datetime.today() + yearly_minutes = sqlc.time_sum(user_id, today - timedelta(days=365), today) + weekly_minutes = sqlc.time_sum(user_id, today - timedelta(days=today.weekday()), today) - yearly_minutes = sqlc.time_sum(user_id, date_constraint=parse_date_constraint("this year")) - weekly_minutes = sqlc.time_sum(user_id, date_constraint=parse_date_constraint("this week")) + output = f"\n*Hours logged in the last 365 days*: {int(yearly_minutes/60)} hours and {yearly_minutes%60} minutes" + output += f"\n*Hours logged this week:* {int(weekly_minutes/60)} hours and {weekly_minutes%60} minutes" + + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" - output = f"\n*Hours logged this year*: {int(yearly_minutes/60)} hours and {int(yearly_minutes%60)} minutes" - output += f"\n*Hours logged this week:* {int(weekly_minutes/60)} hours and {int(weekly_minutes%60)} minutes\n\n\n" - output += slack_table(f"Your {num_entries} most recent entries", table) respond(output) # Delete the last entry made by the user issuing the command @@ -249,15 +239,27 @@ def delete_last(ack, respond, body, command): respond("Last entry removed!") # Respond with last 30 hours entered by all users -@app.command("/allusertable") +@app.command("/lastentries") def log_database(ack, body, respond, command, logger): ack() if(is_admin(body['user_id'])): - sqlc = database.SQLConnection() - table = sqlc.timelog_table() - - logger.info("\n" + table) - respond(slack_table("Last 30 entries from all users", table)) + try: + num_entries = int(command['text']) if command['text'] != "" else 30 # Defaults to 30 entries + + sqlc = database.SQLConnection() + entries = sqlc.all_entries_list(num_entries) + + output = f"*Last {num_entries} entries from all users*" + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + + respond(output) + except: + logger.exception("Invalid user input, failed to fetch entries") + respond("*Invalid input!* Please try again! You can retrieve the last n entries from all users with `/myentries n`. If you leave n blank a default value of 30 will be used.") else: respond("You must be an admin to use this command!") diff --git a/blocks.py b/blocks.py index a348b9e..16b2028 100644 --- a/blocks.py +++ b/blocks.py @@ -1,10 +1,7 @@ # Slack block kit - https://api.slack.com/block-kit +# Each slack block used is stored here as a single dictionary object. These are then combined into a list of blocks for each message. -from datetime import datetime, timedelta - -# # Get current date for time logging form -# def currentDate(): -# return datetime.now() +from datetime import date, timedelta num_entries_block = { # Number of entries @@ -21,6 +18,10 @@ } } +divider = { + "type": "divider" +} + num_users_block = { # Number of entries "type": "input", @@ -122,16 +123,30 @@ } } -def date_select_block(): +def text_field_block(label, max_length): + return { + "type": "input", + "block_id": "text_field_block", + "element": { + "type": "plain_text_input", + "action_id": "text_input", + "max_length": max_length + }, + "label": { + "type": "plain_text", + "text": label + } + } + +# Getter functions are used when the block has dynamic content +def date_select_block(label, initial_date, id_modifier = None): + block_id = "date_select_block_" + id_modifier if id_modifier else "date_select_block" return { - # Date picker "type": "input", - "block_id": "date_select_block", + "block_id": block_id, "element": { "type": "datepicker", - # YYYY-MM-DD format needs to be used here because SQL doesn't have a date data type so these are stored as strings - # and in this format lexicographical order is identical to chronological order. - "initial_date": datetime.now().strftime("%Y-%m-%d"), + "initial_date": initial_date.strftime("%Y-%m-%d"), "placeholder": { "type": "plain_text", "text": "Select a date", @@ -140,42 +155,42 @@ def date_select_block(): }, "label": { "type": "plain_text", - "text": "Date to log", + "text": label, } } def submit_button_block(form_action): return { - # Submit button - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Confirm and submit" - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Submit", - }, - "value": "placeholder", - "action_id": form_action - } + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Confirm", + }, + "value": "confirm", + "action_id": form_action + } + ] } # Time logging form def timelog_form(): return [ - date_select_block(), + date_select_block("Date to log", date.today()), hours_input_block, + # 72 characters is the max length of a git commit message + text_field_block("Summary of work done", 70), submit_button_block("timelog_response") ] # User selection form for hour sum -def gethours_form(): +def getentries_form(): return [ user_select_block, - submit_button_block("gethours_response") + num_entries_block, + submit_button_block("getentries_response") ] def getusertables_form(): @@ -187,12 +202,13 @@ def getusertables_form(): def dateoverview_form(): return [ - date_select_block, + date_select_block("Date to view", date.today()), submit_button_block("dateoverview_response") ] def leaderboard_form(): return [ - date_constraint_block, + date_select_block("Start date", date.today() - timedelta(days=365), "start"), + date_select_block("End date", date.today(), "end"), submit_button_block("leaderboard_response") ] diff --git a/database.py b/database.py index 7563431..4a6246e 100644 --- a/database.py +++ b/database.py @@ -14,7 +14,7 @@ def create_log_table(): entry_date date NOT NULL, selected_date date NOT NULL, minutes INTEGER NOT NULL, - project TEXT, + summary TEXT, PRIMARY KEY (entry_num, user_id)) """) con.close() @@ -29,6 +29,11 @@ def create_user_table(): PRIMARY KEY (user_id)) """) +# Dates are stored in plain text (SQLite doesn't have a specific date type). This still works and is sortable as long as +# dates are stored in the YYYY-MM-DD format (highest to lowest weight) + +# SQLite3 documentation says placeholder question marks and a tuple of values should be used rather than formatted strings to prevent sql injection attacks +# Ot's probably not important in this project but there's no reason not to do it this way class SQLConnection: def __init__(self): @@ -59,13 +64,7 @@ def user_name(self, user_id): if user[1] != "": name += " ("+user[1]+")" return(name) - def insert_timelog_entry(self, user_id, selected_date, minutes): - # Dates are stored in plain text - SQLite doesn't have a specific date type. This still works and is sortable as long as - # dates are stored in the YYYY-MM-DD format (highest to lowest weight) - - # SQLite3 documentation says this format with placeholder question marks and a tuple of values should be used rather than formatted strings to prevent sql injection attacks - # This probably isn't necessary here but there's no good reason not to - + def insert_timelog_entry(self, user_id, selected_date, minutes, summary): today = datetime.date.today().strftime('%Y-%m-%d') # Get and increment the entry number @@ -73,12 +72,9 @@ def insert_timelog_entry(self, user_id, selected_date, minutes): FROM time_log WHERE user_id = ? """, (user_id,)) entry_num = res.fetchone()[0] - if (entry_num == None): - entry_num = 1 - else: - entry_num += 1 + entry_num = 1 if not entry_num else entry_num + 1 - self.cur.execute("INSERT INTO time_log VALUES (?,?,?,?,?, NULL) ", (entry_num, user_id, today, selected_date, minutes )) + self.cur.execute("INSERT INTO time_log VALUES (?,?,?,?,?,?)", (entry_num, user_id, today, selected_date, minutes, summary)) def remove_last_entry(self, user_id): self.cur.execute("""DELETE FROM time_log @@ -88,36 +84,36 @@ def remove_last_entry(self, user_id): ORDER BY entry_num DESC LIMIT 1) """, (user_id,)) # Get all entries by all users - def timelog_table(self): + def all_entries_list(self): res = self.cur.execute("""SELECT u.name, tl.entry_num, tl.entry_date, tl.selected_date, tl.minutes FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id LIMIT 30 """) - header = ["Name", "Entry Number", "Date Submitted", "Date of Log", "Minutes"] - return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) + return(res.fetchall()) - # Get the last n entries by user as a table - def last_entries_table(self, user_id, num_entries = 10): - res = self.cur.execute("""SELECT entry_num, entry_date, selected_date, minutes + # Get the last n entries by user + def last_entries_list(self, user_id, num_entries = 10): + res = self.cur.execute("""SELECT selected_date, minutes, entry_date, summary FROM time_log WHERE user_id = ? ORDER BY entry_num DESC LIMIT ? """, (user_id, num_entries)) - header = ["Entry Number", "Date Submitted", "Date of Log", "Minutes"] - return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) + return(res.fetchall()) # Get total minutes logged by user with given user_id - def time_sum(self, user_id, date_constraint = None): + def time_sum(self, user_id, start_date = None, end_date = None): # If the user has entries in the database return their total time logged, otherwise return 0 query = """SELECT SUM(minutes) FROM time_log WHERE user_id = ? """ params = [user_id] - if date_constraint: + if (start_date and end_date): query += "AND selected_date >= ? AND selected_date <= ? " - params.append(date_constraint.start_date.strftime('%Y-%m-%d')) - params.append(date_constraint.end_date.strftime('%Y-%m-%d')) + params.append(start_date.strftime('%Y-%m-%d')) + params.append(end_date.strftime('%Y-%m-%d')) + elif (start_date or end_date): + raise ValueError("Both start and end dates must be specified if one is specified") res = self.cur.execute(query, params) minutes = res.fetchone()[0] if (minutes != None): @@ -141,12 +137,11 @@ def leaderboard(self, date_constraint = None): res = self.cur.execute(query, params) return(res.fetchall()) - def entries_for_date_table(self, selected_date): + def entries_for_date_list(self, selected_date): # Get all entries by all users7 - res = self.cur.execute("""SELECT u.name, tl.minutes + res = self.cur.execute("""SELECT u.name, u.display_name, tl.minutes, tl.summary FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id WHERE tl.selected_date=? """, (selected_date,)) - header = ["Name", "Minutes"] - return(tabulate(res.fetchall(), header, tablefmt="simple_grid")) + return(res.fetchall()) From 610bc51894f785d6067a51b51b3a52999203ee78 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 14 Nov 2022 14:26:03 +1100 Subject: [PATCH 15/29] Improved error handling and corrected time constraint in leaderboard query --- app.py | 171 ++++++++++++++++++++++++++++++++-------------------- blocks.py | 8 +++ database.py | 22 +++---- 3 files changed, 122 insertions(+), 79 deletions(-) diff --git a/app.py b/app.py index 764d6d0..9683efe 100644 --- a/app.py +++ b/app.py @@ -54,26 +54,38 @@ def time_log(ack, respond, command): @app.action("timelog_response") def submit_timelog_form(ack, respond, body, logger): ack() - user_id = body['user']['id'] - # Get user-selected date, hours, and minutes from form - selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] - time_input = re.findall(r'\d+', body['state']['values']['hours_block']['hours_input']['value']) # creates list containing two strings (hours and minutes) - summary = body['state']['values']['text_field_block']['text_input']['value'] try: - minutes = int(time_input[0])*60 + int(time_input[1]) # user input (hours and minutes) stored as minutes only + user_id = body['user']['id'] - logger.info(f"New log entry of {time_input[0]} hours and {time_input[1]} minutes for {selected_date} by {user_id}") + selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] + time_input = body['state']['values']['hours_block']['hours_input']['value'] # String + summary = body['state']['values']['text_field_block']['text_input']['value'] + if not (select_date and time_input and summary): + raise ValueError("Missing required field") + + time_list = re.findall(r'\d+', time_input) # List with two ints + + print(time_list) + for i in time_list: + print (int(i)) + + + if (len(summary) > 70): + raise ValueError("Summary must be under 70 characters") + if not (all(i.isdigit() for i in time_list) and len(time_list) == 2): + raise ValueError("Time logged field must contain two numbers seperated by some characters (e.g. 3h 25m)") + + minutes = int(time_list[0])*60 + int(time_list[1]) # user input (hours and minutes) stored as minutes only + logger.info(f"New log entry of {time_list[0]} hours and {time_list[1]} minutes for {selected_date} by {user_id}") sqlc = database.SQLConnection() sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) + respond(f"Time logged: {time_list[0]} hours and {time_list[1]} minutes for date {selected_date}.") - respond(f"Time logged: {time_input[0]} hours and {time_input[1]} minutes for date {selected_date}.") - - except: - # Show the user an error if they input anything other than two integers separated by some character / characters - logger.exception("Invalid user input, failed to create time log entry.") - respond("*Invalid input!* Please try again! In the *Time logged* field enter two numbers separated by some characters (e.g. 3h 25m)") + except ValueError as e: + respond(f"*Invalid input, please try again!* {str(e)}.") + logger.error(e) # Get user-selection form (choose users to see their total hours logged) @app.command("/gethours") @@ -88,22 +100,30 @@ def get_user_hours_form(ack, respond, body, command): @app.action("gethours_response") def get_logged_hours(ack, body, respond, logger): ack() - # Get list of users submitted for query by Slack admin - users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] - # Open an SQL connection - sqlc = database.SQLConnection() - output = "" - # Add the time logged by each user to the output - for i in users: - name = user_name(i) - user_time = sqlc.time_sum(i) - if (user_time > 0): - output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" - else: - output += f"*{name}* has no logged hours\n" - # Send output to Slack chat and console - logger.info("\n" + output) - respond(output) + try: + users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] + start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + + if not (users and start_date and end_date): + raise ValueError("Missing required field") + + sqlc = database.SQLConnection() + output = "" + # Add the time logged by each user to the output + for i in users: + name = user_name(i) + user_time = sqlc.time_sum(i, start_date, end_date) + if (user_time > 0): + output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" + else: + output += f"*{name}* has no logged hours\n" + # Send output to Slack chat and console + logger.info("\n" + output) + respond(output) + + except Exception as e: + respond(f"*Invalid input, please try again!* {str(e)}.") # Get user-selection form (choose users to see tables for their logged hours per date) @app.command("/getentries") @@ -118,20 +138,26 @@ def get_user_hours_form(ack, respond, body, command): @app.action("getentries_response") def get_logged_hours(ack, body, respond, logger): ack() - users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] try: - num_entries = re.findall(r'\d+', body['state']['values']['num_entries_block']['num_entries_input']['value'])[0] - except: - respond('Invalid input! Please try again.') - sqlc = database.SQLConnection() - output = "" - for user in users: - output += f"\n\n\n*{user_name(user)}*" - entries = sqlc.last_entries_list(user, num_entries) - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" - respond(output) + users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] + num_entries_input = body['state']['values']['num_entries_block']['num_entries_input']['value'] + + if not (users and num_entries_input): + raise ValueError("Missing required field") + + num_entries = re.findall(r'\d+', num_entries_input)[0] + sqlc = database.SQLConnection() + output = "" + for user in users: + output += f"\n\n\n*{user_name(user)}*" + entries = sqlc.last_entries_list(user, num_entries) + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" + respond(output) + + except Exception as e: + respond(f"*Invalid input, please try again!* {str(e)}.") @app.command("/dateoverview") def get_date_overview_form(ack, respond, body): @@ -144,17 +170,23 @@ def get_date_overview_form(ack, respond, body): @app.action("dateoverview_response") def get_date_overview(ack, body, respond, logger): ack() - selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] - sqlc = database.SQLConnection() - entries = sqlc.entries_for_date_list(selected_date) - output = f"*Overview for {selected_date}*" - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" - respond(output) + try: + selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] + if not (selected_date): + raise ValueError("Missing required field") + sqlc = database.SQLConnection() + entries = sqlc.entries_for_date_list(selected_date) + output = f"*Overview for {selected_date}*" + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + respond(output) + + except Exception as e: + respond(f"*Invalid input, please try again!* {str(e)}.") # Get a leaderboard with the top 10 contributors and their hours logged @app.command("/leaderboard") @@ -168,20 +200,27 @@ def leaderboard(ack, body, respond): @app.action("leaderboard_response") def leaderboard_response(ack, body, respond, logger, command): ack() - start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] - end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] - sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(start_date, end_date) - if contributions(): - output = f"*All contributors between {start_date} and {end_date} ranked by hours logged*\n" - for i in contributions: - # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" - respond(output) - else: - respond(f"No hours logged between {start_date} and {end_date}!") + try: + start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + + if not (start_date and end_date): + raise ValueError("Both a start and end date must be specified") + + sqlc = database.SQLConnection() + contributions = sqlc.leaderboard(start_date, end_date) + if contributions(): + output = f"*All contributors between {start_date} and {end_date} ranked by hours logged*\n" + for i in contributions: + # Add custom display name if applicable + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + respond(output) + else: + respond(f"No hours logged between {start_date} and {end_date}!") + except Exception as e: + respond(f"*Invalid input, please try again!* {str(e)}.") ################################### Commands without forms ################################### diff --git a/blocks.py b/blocks.py index 16b2028..038c03f 100644 --- a/blocks.py +++ b/blocks.py @@ -186,6 +186,14 @@ def timelog_form(): ] # User selection form for hour sum +def gethours_form(): + return [ + user_select_block, + date_select_block("Start date", date.today() - timedelta(days=365), "start"), + date_select_block("End date", date.today(), "end"), + submit_button_block("gethours_response") + ] + def getentries_form(): return [ user_select_block, diff --git a/database.py b/database.py index 4a6246e..c0361ed 100644 --- a/database.py +++ b/database.py @@ -122,19 +122,15 @@ def time_sum(self, user_id, start_date = None, end_date = None): return(0) # Get the top 10 contributors - def leaderboard(self, date_constraint = None): - query = """SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes - FROM user_names u - INNER JOIN time_log tl - ON u.user_id=tl.user_id """ - params = [] - if date_constraint: - query += "WHERE selected_date >= ? AND selected_date <= ? " - params.append(date_constraint.start_date.strftime('%Y-%m-%d')) - params.append(date_constraint.end_date.strftime('%Y-%m-%d')) - query += """GROUP BY u.name, u.display_name - ORDER BY totalMinutes DESC """ - res = self.cur.execute(query, params) + def leaderboard(self, start_date = None, end_date = None): + res = self.cur.execute("""SELECT u.name, u.display_name, sum(tl.minutes) AS totalMinutes + FROM user_names u + INNER JOIN time_log tl + ON u.user_id=tl.user_id + WHERE selected_date >= ? + AND selected_date <= ? + GROUP BY u.name, u.display_name + ORDER BY totalMinutes DESC """, start_date, end_date) return(res.fetchall()) def entries_for_date_list(self, selected_date): From 7aabaa68a961fdb722bf3a2a8e1e34a53dfc60c7 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 14 Nov 2022 14:38:25 +1100 Subject: [PATCH 16/29] Improved error handling for all commands with forms --- app.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index 9683efe..c01531d 100644 --- a/app.py +++ b/app.py @@ -67,11 +67,6 @@ def submit_timelog_form(ack, respond, body, logger): time_list = re.findall(r'\d+', time_input) # List with two ints - print(time_list) - for i in time_list: - print (int(i)) - - if (len(summary) > 70): raise ValueError("Summary must be under 70 characters") if not (all(i.isdigit() for i in time_list) and len(time_list) == 2): @@ -85,7 +80,7 @@ def submit_timelog_form(ack, respond, body, logger): except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") - logger.error(e) + logger.warn(e) # Get user-selection form (choose users to see their total hours logged) @app.command("/gethours") @@ -118,12 +113,11 @@ def get_logged_hours(ack, body, respond, logger): output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" else: output += f"*{name}* has no logged hours\n" - # Send output to Slack chat and console - logger.info("\n" + output) respond(output) - except Exception as e: + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warn(e) # Get user-selection form (choose users to see tables for their logged hours per date) @app.command("/getentries") @@ -145,7 +139,9 @@ def get_logged_hours(ack, body, respond, logger): if not (users and num_entries_input): raise ValueError("Missing required field") - num_entries = re.findall(r'\d+', num_entries_input)[0] + try: num_entries = int(re.findall(r'\d+', num_entries_input)[0]) + except: raise ValueError("Number of entries must be an integer") + sqlc = database.SQLConnection() output = "" for user in users: @@ -156,8 +152,9 @@ def get_logged_hours(ack, body, respond, logger): output += f"_{i[3]}_" if i[3] else "No summary given" respond(output) - except Exception as e: + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warn(e) @app.command("/dateoverview") def get_date_overview_form(ack, respond, body): @@ -185,7 +182,7 @@ def get_date_overview(ack, body, respond, logger): output += f"_{i[3]}_" if i[3] else "No summary given" respond(output) - except Exception as e: + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") # Get a leaderboard with the top 10 contributors and their hours logged From 6f835264f22fdbf49ee26e3e444ccbcfba8d1ce7 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 14 Nov 2022 16:26:34 +1100 Subject: [PATCH 17/29] Improve error handling for commands without forms --- app.py | 191 +++++++++++++++++++++++++++++++--------------------- database.py | 2 +- 2 files changed, 116 insertions(+), 77 deletions(-) diff --git a/app.py b/app.py index c01531d..f15d4ae 100644 --- a/app.py +++ b/app.py @@ -72,16 +72,18 @@ def submit_timelog_form(ack, respond, body, logger): if not (all(i.isdigit() for i in time_list) and len(time_list) == 2): raise ValueError("Time logged field must contain two numbers seperated by some characters (e.g. 3h 25m)") - minutes = int(time_list[0])*60 + int(time_list[1]) # user input (hours and minutes) stored as minutes only - logger.info(f"New log entry of {time_list[0]} hours and {time_list[1]} minutes for {selected_date} by {user_id}") - sqlc = database.SQLConnection() - sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) - respond(f"Time logged: {time_list[0]} hours and {time_list[1]} minutes for date {selected_date}.") - except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warn(e) + minutes = int(time_list[0])*60 + int(time_list[1]) # user input (hours and minutes) stored as minutes only + logger.info(f"New log entry of {time_list[0]} hours and {time_list[1]} minutes for {selected_date} by {user_id}") + sqlc = database.SQLConnection() + sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) + respond(f"Time logged: {time_list[0]} hours and {time_list[1]} minutes for date {selected_date}.") + + + # Get user-selection form (choose users to see their total hours logged) @app.command("/gethours") def get_user_hours_form(ack, respond, body, command): @@ -103,22 +105,24 @@ def get_logged_hours(ack, body, respond, logger): if not (users and start_date and end_date): raise ValueError("Missing required field") - sqlc = database.SQLConnection() - output = "" - # Add the time logged by each user to the output - for i in users: - name = user_name(i) - user_time = sqlc.time_sum(i, start_date, end_date) - if (user_time > 0): - output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" - else: - output += f"*{name}* has no logged hours\n" - respond(output) - except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warn(e) + sqlc = database.SQLConnection() + output = "" + # Add the time logged by each user to the output + for i in users: + name = user_name(i) + user_time = sqlc.time_sum(i, start_date, end_date) + if (user_time > 0): + output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" + else: + output += f"*{name}* has no logged hours\n" + respond(output) + + + # Get user-selection form (choose users to see tables for their logged hours per date) @app.command("/getentries") def get_user_hours_form(ack, respond, body, command): @@ -141,21 +145,23 @@ def get_logged_hours(ack, body, respond, logger): try: num_entries = int(re.findall(r'\d+', num_entries_input)[0]) except: raise ValueError("Number of entries must be an integer") - - sqlc = database.SQLConnection() - output = "" - for user in users: - output += f"\n\n\n*{user_name(user)}*" - entries = sqlc.last_entries_list(user, num_entries) - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" - respond(output) - + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warn(e) + sqlc = database.SQLConnection() + output = "" + for user in users: + output += f"\n\n\n*{user_name(user)}*" + entries = sqlc.last_entries_list(user, num_entries) + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" + respond(output) + + + @app.command("/dateoverview") def get_date_overview_form(ack, respond, body): ack() @@ -172,19 +178,21 @@ def get_date_overview(ack, body, respond, logger): if not (selected_date): raise ValueError("Missing required field") - sqlc = database.SQLConnection() - entries = sqlc.entries_for_date_list(selected_date) - output = f"*Overview for {selected_date}*" - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" - respond(output) - except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") + sqlc = database.SQLConnection() + entries = sqlc.entries_for_date_list(selected_date) + output = f"*Overview for {selected_date}*" + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + respond(output) + + + # Get a leaderboard with the top 10 contributors and their hours logged @app.command("/leaderboard") def leaderboard(ack, body, respond): @@ -198,31 +206,51 @@ def leaderboard(ack, body, respond): def leaderboard_response(ack, body, respond, logger, command): ack() try: - start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] - end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + start_date = body['state']['values']['date_select_block_start']['date_select_input']['selected_date'] + end_date = body['state']['values']['date_select_block_end']['date_select_input']['selected_date'] + + print(start_date) + print(end_date) + + print(type(start_date)) + print(type(end_date)) + # The error response is sent for missing field but not start date > end date. I have no idea why. if not (start_date and end_date): raise ValueError("Both a start and end date must be specified") - sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(start_date, end_date) - if contributions(): - output = f"*All contributors between {start_date} and {end_date} ranked by hours logged*\n" - for i in contributions: - # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" - respond(output) - else: - respond(f"No hours logged between {start_date} and {end_date}!") - except Exception as e: + if (start_date > end_date): + print("works") + raise ValueError("Start date must be before end date") + + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warning(e) + + sqlc = database.SQLConnection() + contributions = sqlc.leaderboard(start_date, end_date) + + # Doing this means we are converting from date object to string, to date object, and then back to string + # I still think this is the best approach because it is clearer and easier to read than a string manipulation + au_start_date = datetime.strptime(start_date, "%Y-%m-%d").strftime("%d/%m/%y") + au_end_date = datetime.strptime(end_date, "%Y-%m-%d").strftime("%d/%m/%y") + + if contributions: + output = f"*All contributors between {au_start_date} and {au_end_date} ranked by hours logged*\n" + for i in contributions: + # Add custom display name if applicable + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + respond(output) + else: + respond(f"No hours logged between {au_start_date} and {au_end_date}!") + ################################### Commands without forms ################################### @app.command("/help") -def help(ack, respond, body, command): +def help(ack, respond, body): ack() output = """ \n*User Commands:* @@ -245,11 +273,16 @@ def user_entries(ack, respond, body, command, logger): ack() try: user_id = body['user_id'] - name = user_name(user_id) - num_entries = int(command['text']) if command['text'] != "" else 10 # Defaults to 10 entries - except: - logger.exception("Invalid user input, failed to create time log entry") - respond("*Invalid input!* Please try again! You can generate a table with your last n entries with `/myentries n`. If you leave n blank a default value of 5 will be used.") + if command['text'].isdigit(): + num_entries = int(command['text']) + elif not command['text']: + num_entries = 10 + else: + raise ValueError("If a number of entries is provided it must be a positive integer") + + except ValueError as e: + logger.warning(str(e)) + respond(f"*Invalid input!* Please try again! {str(e)}.") sqlc = database.SQLConnection() entries = sqlc.last_entries_list(user_id, num_entries) @@ -280,22 +313,28 @@ def log_database(ack, body, respond, command, logger): ack() if(is_admin(body['user_id'])): try: - num_entries = int(command['text']) if command['text'] != "" else 30 # Defaults to 30 entries - - sqlc = database.SQLConnection() - entries = sqlc.all_entries_list(num_entries) - - output = f"*Last {num_entries} entries from all users*" - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" - - respond(output) - except: - logger.exception("Invalid user input, failed to fetch entries") - respond("*Invalid input!* Please try again! You can retrieve the last n entries from all users with `/myentries n`. If you leave n blank a default value of 30 will be used.") + if command['text'].isdigit(): + num_entries = int(command['text']) + elif not command['text']: + num_entries = 30 + else: + raise ValueError("If a number of users is provided it must be a positive integer") + + except ValueError as e: + logger.warning(str(e)) + respond(f"*Invalid input!* {str(e)}") + + sqlc = database.SQLConnection() + entries = sqlc.all_entries_list(num_entries) + + output = f"*Last {num_entries} entries from all users*" + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + + respond(output) else: respond("You must be an admin to use this command!") diff --git a/database.py b/database.py index c0361ed..3e8dacb 100644 --- a/database.py +++ b/database.py @@ -130,7 +130,7 @@ def leaderboard(self, start_date = None, end_date = None): WHERE selected_date >= ? AND selected_date <= ? GROUP BY u.name, u.display_name - ORDER BY totalMinutes DESC """, start_date, end_date) + ORDER BY totalMinutes DESC """, (start_date, end_date)) return(res.fetchall()) def entries_for_date_list(self, selected_date): From 7bd1b536fa0cc8933a01d2b74442413b297d66cc Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 14 Nov 2022 17:31:45 +1100 Subject: [PATCH 18/29] fixed problem with errors not exiting function, updated help command, fixed responses giving the wrong information --- app.py | 88 +++++++++++++++++++++++++++++++---------------------- database.py | 14 ++++----- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/app.py b/app.py index f15d4ae..2cd02d5 100644 --- a/app.py +++ b/app.py @@ -74,7 +74,8 @@ def submit_timelog_form(ack, respond, body, logger): except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") - logger.warn(e) + logger.warning(e) + return minutes = int(time_list[0])*60 + int(time_list[1]) # user input (hours and minutes) stored as minutes only logger.info(f"New log entry of {time_list[0]} hours and {time_list[1]} minutes for {selected_date} by {user_id}") @@ -99,15 +100,16 @@ def get_logged_hours(ack, body, respond, logger): ack() try: users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] - start_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] - end_date = body['state']['values']['date_select_block']['date_select_input_end']['selected_date'] + start_date = body['state']['values']['date_select_block_start']['date_select_input']['selected_date'] + end_date = body['state']['values']['date_select_block_end']['date_select_input']['selected_date'] if not (users and start_date and end_date): raise ValueError("Missing required field") except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") - logger.warn(e) + logger.warning(e) + return sqlc = database.SQLConnection() output = "" @@ -148,16 +150,20 @@ def get_logged_hours(ack, body, respond, logger): except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") - logger.warn(e) + logger.warning(e) + return sqlc = database.SQLConnection() output = "" for user in users: output += f"\n\n\n*{user_name(user)}*" - entries = sqlc.last_entries_list(user, num_entries) - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" + entries = sqlc.given_user_entries_list(user, num_entries) + if entries: + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" + else: + output += "\n\n • No entries" respond(output) @@ -180,15 +186,20 @@ def get_date_overview(ack, body, respond, logger): except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warning(e) + return sqlc = database.SQLConnection() entries = sqlc.entries_for_date_list(selected_date) output = f"*Overview for {selected_date}*" - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" + if entries: + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " + output += f"_{i[3]}_" if i[3] else "No summary given" + else: + output += "\n\n • No entries" respond(output) @@ -215,17 +226,13 @@ def leaderboard_response(ack, body, respond, logger, command): print(type(start_date)) print(type(end_date)) - # The error response is sent for missing field but not start date > end date. I have no idea why. if not (start_date and end_date): raise ValueError("Both a start and end date must be specified") - if (start_date > end_date): - print("works") - raise ValueError("Start date must be before end date") - except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warning(e) + return sqlc = database.SQLConnection() contributions = sqlc.leaderboard(start_date, end_date) @@ -254,17 +261,17 @@ def help(ack, respond, body): ack() output = """ \n*User Commands:* - */timelog* Opens a time logging form + */timelog* Open a time logging form */deletelast* Delete your last entry */myentries n* Get your last n entries (defaults to 5)""" if(is_admin(body['user_id'])): output += """ \n*Admin Commands:* - */gethours* Select users and get their total hours logged + */gethours* Select users and to see their total hours logged */getentries* Select users to see their most recent entries - */lastentries n* Responds with the last 30 entries from all users - */leaderboard n* Responds with the top n contributors and their total time logged (defaults to 10) - */dateoverview* Responds with all entries for a given date""" + */lastentries n* See the last 30 entries from all users + */leaderboard* See everyone who has made contributions in the date range ranked by hours logged + */dateoverview* See all entries for a given date""" respond(output) # Respond with a table showing the last n entries made by the user issuing the command @@ -281,11 +288,12 @@ def user_entries(ack, respond, body, command, logger): raise ValueError("If a number of entries is provided it must be a positive integer") except ValueError as e: - logger.warning(str(e)) respond(f"*Invalid input!* Please try again! {str(e)}.") + logger.warning(e) + return sqlc = database.SQLConnection() - entries = sqlc.last_entries_list(user_id, num_entries) + entries = sqlc.given_user_entries_list(user_id, num_entries) today = datetime.today() yearly_minutes = sqlc.time_sum(user_id, today - timedelta(days=365), today) weekly_minutes = sqlc.time_sum(user_id, today - timedelta(days=today.weekday()), today) @@ -293,10 +301,12 @@ def user_entries(ack, respond, body, command, logger): output = f"\n*Hours logged in the last 365 days*: {int(yearly_minutes/60)} hours and {yearly_minutes%60} minutes" output += f"\n*Hours logged this week:* {int(weekly_minutes/60)} hours and {weekly_minutes%60} minutes" - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" - + if entries: + for i in entries: + output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " + output += f"_{i[3]}_" if i[3] else "No summary given" + else: + output += "\n\n • No entries" respond(output) # Delete the last entry made by the user issuing the command @@ -321,18 +331,22 @@ def log_database(ack, body, respond, command, logger): raise ValueError("If a number of users is provided it must be a positive integer") except ValueError as e: - logger.warning(str(e)) respond(f"*Invalid input!* {str(e)}") + logger.warning(e) + return sqlc = database.SQLConnection() - entries = sqlc.all_entries_list(num_entries) + entries = sqlc.all_user_entries_list(num_entries) output = f"*Last {num_entries} entries from all users*" - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" + if entries: + for i in entries: + name = i[0] + if i[1] != "": name += " ("+i[1]+")" + output += f"\n\n • {name} / {i[2]} / {int(i[3]/60):2} hours and {i[3]%60:2} minutes / Submitted {i[4]} / " + output += f"_{i[5]}_" if i[5] else "No summary given" + else: + output += "\n\n • No entries" respond(output) else: diff --git a/database.py b/database.py index 3e8dacb..3064152 100644 --- a/database.py +++ b/database.py @@ -83,17 +83,17 @@ def remove_last_entry(self, user_id): WHERE user_id = ? ORDER BY entry_num DESC LIMIT 1) """, (user_id,)) - # Get all entries by all users - def all_entries_list(self): - res = self.cur.execute("""SELECT u.name, tl.entry_num, tl.entry_date, tl.selected_date, tl.minutes + # Get last entries by all users + def all_user_entries_list(self, num_entries): + res = self.cur.execute("""SELECT u.name, u.display_name, tl.selected_date, tl.minutes, tl.entry_date, tl.summary FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id - LIMIT 30 """) + LIMIT ? """, (num_entries,)) return(res.fetchall()) # Get the last n entries by user - def last_entries_list(self, user_id, num_entries = 10): + def given_user_entries_list(self, user_id, num_entries = 10): res = self.cur.execute("""SELECT selected_date, minutes, entry_date, summary FROM time_log WHERE user_id = ? @@ -110,8 +110,8 @@ def time_sum(self, user_id, start_date = None, end_date = None): params = [user_id] if (start_date and end_date): query += "AND selected_date >= ? AND selected_date <= ? " - params.append(start_date.strftime('%Y-%m-%d')) - params.append(end_date.strftime('%Y-%m-%d')) + params.append(start_date) + params.append(end_date) elif (start_date or end_date): raise ValueError("Both start and end dates must be specified if one is specified") res = self.cur.execute(query, params) From 74113459cd0430fa23b6a296157988ebc408046a Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 14 Nov 2022 18:15:13 +1100 Subject: [PATCH 19/29] Fixed weekly summary for /myentries and standardized date / string conversions between modules --- app.py | 9 +++------ blocks.py | 2 ++ database.py | 28 ++++++++++++---------------- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/app.py b/app.py index 2cd02d5..7b703e5 100644 --- a/app.py +++ b/app.py @@ -106,6 +106,9 @@ def get_logged_hours(ack, body, respond, logger): if not (users and start_date and end_date): raise ValueError("Missing required field") + start_date = datetime.strptime(start_date, "%Y-%m-%d") + end_date = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warning(e) @@ -220,12 +223,6 @@ def leaderboard_response(ack, body, respond, logger, command): start_date = body['state']['values']['date_select_block_start']['date_select_input']['selected_date'] end_date = body['state']['values']['date_select_block_end']['date_select_input']['selected_date'] - print(start_date) - print(end_date) - - print(type(start_date)) - print(type(end_date)) - if not (start_date and end_date): raise ValueError("Both a start and end date must be specified") diff --git a/blocks.py b/blocks.py index 038c03f..9429b6a 100644 --- a/blocks.py +++ b/blocks.py @@ -1,6 +1,8 @@ # Slack block kit - https://api.slack.com/block-kit # Each slack block used is stored here as a single dictionary object. These are then combined into a list of blocks for each message. +# Clicking the submit button for a form will send a block_action payload with the user-input content from the form stored in a dictionary. +# All inputs are stored as strings, integers, and booleans. Dates are stored as strings in the YYYY-MM-DD format. from datetime import date, timedelta num_entries_block = { diff --git a/database.py b/database.py index 3064152..256f005 100644 --- a/database.py +++ b/database.py @@ -35,6 +35,9 @@ def create_user_table(): # SQLite3 documentation says placeholder question marks and a tuple of values should be used rather than formatted strings to prevent sql injection attacks # Ot's probably not important in this project but there's no reason not to do it this way +# All public methods in this class assume date is being given as a date object, not a string. This means multiple conversions are needed sometimes but it +# keeps things consistent and makes the whole program easier to maintain. + class SQLConnection: def __init__(self): # Open SQL connection @@ -104,22 +107,15 @@ def given_user_entries_list(self, user_id, num_entries = 10): # Get total minutes logged by user with given user_id def time_sum(self, user_id, start_date = None, end_date = None): # If the user has entries in the database return their total time logged, otherwise return 0 - query = """SELECT SUM(minutes) - FROM time_log - WHERE user_id = ? """ - params = [user_id] - if (start_date and end_date): - query += "AND selected_date >= ? AND selected_date <= ? " - params.append(start_date) - params.append(end_date) - elif (start_date or end_date): - raise ValueError("Both start and end dates must be specified if one is specified") - res = self.cur.execute(query, params) + start_date = start_date.strftime('%Y-%m-%d') + end_date = end_date.strftime('%Y-%m-%d') + res = self.cur.execute("""SELECT SUM(minutes) + FROM time_log + WHERE user_id = ? + AND selected_date >= ? + AND selected_date <= ? """, (user_id, start_date, end_date)) minutes = res.fetchone()[0] - if (minutes != None): - return(minutes) - else: - return(0) + return minutes if minutes else 0 # Get the top 10 contributors def leaderboard(self, start_date = None, end_date = None): @@ -134,7 +130,7 @@ def leaderboard(self, start_date = None, end_date = None): return(res.fetchall()) def entries_for_date_list(self, selected_date): - # Get all entries by all users7 + # Get all entries by all users res = self.cur.execute("""SELECT u.name, u.display_name, tl.minutes, tl.summary FROM time_log tl INNER JOIN user_names u From 066435562d65fc8b5dc9fbaf514fef4ff3ef0e8e Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 15 Nov 2022 09:57:20 +1100 Subject: [PATCH 20/29] Update command lists to match changes --- README.md | 18 +++++++++++------- app.py | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c02719a..6f42df4 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,22 @@ Run app.py to launch Timelord #### User Commands: - `/help` Get this list of commands -- `/timelog` Opens a time logging form +- `/timelog` Open a time logging form - `/deletelast` Delete your last entry -- `/myentries n` Get a table with your last n entries (defaults to 5) +- `/myentries n` Get your last n entries (defaults to 5)""" #### Admin Commands: -- `/gethours` Select users and get their total hours logged -- `/allusersums` Get the total hours logged by all users -- `/getusertables` Select users to see their last few entries -- `/allusertable` Responds with the last 30 entries from all users -- `/leaderboard n` Responds with the top n contributors and their total time logged (defaults to 10) +- `/timelog` Open a time logging form +- `/deletelast` Delete your last entry +- `/myentries n` Get your last n entries (defaults to 30)""" +- `/gethours` Select users and see their total hours logged +- `/getentries` Select users and see their most recent entries +- `/lastentries n` See the last n entries from all users in one list (defaults to 30) +- `/leaderboard` Select a date range and rank all users by hours logged in that range +- `/dateoverview` See all entries for a given date""" ## Autostart Change the paths in `timelord.service` to match where you have put it, then copy `timelord.serivce` into `/etc/systemd/system` and finally run `sudo systemctl enable timelord` and `sudo systemctl start timelord`. Use `sudo systemctl status timelord` to view the program's status. + diff --git a/app.py b/app.py index 7b703e5..7f587a8 100644 --- a/app.py +++ b/app.py @@ -264,10 +264,10 @@ def help(ack, respond, body): if(is_admin(body['user_id'])): output += """ \n*Admin Commands:* - */gethours* Select users and to see their total hours logged - */getentries* Select users to see their most recent entries - */lastentries n* See the last 30 entries from all users - */leaderboard* See everyone who has made contributions in the date range ranked by hours logged + */gethours* Select users and see their total hours logged + */getentries* Select users and see their most recent entries + */lastentries n* See the last n entries from all users in one list (defaults to 30) + */leaderboard* Select a date range and rank all users by hours logged in that range */dateoverview* See all entries for a given date""" respond(output) From 42af0566864910fbdaeba65ea4463f9374528056 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Tue, 15 Nov 2022 11:09:10 +1100 Subject: [PATCH 21/29] Removed now unnecessary package imports and version requirements --- app.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app.py b/app.py index 7f587a8..63bc07b 100644 --- a/app.py +++ b/app.py @@ -2,15 +2,9 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError from datetime import datetime, timedelta from dotenv import load_dotenv from pathlib import Path -from collections import namedtuple - -import sys -if not sys.version_info >= (3, 10): - raise Exception("Requires Python 3.10 or higher!") dotenv_path = Path(".env") if dotenv_path.exists(): From 2a0934707d05f746e3de1eb85cf1e39dbe6f5c78 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Thu, 17 Nov 2022 17:08:30 +1100 Subject: [PATCH 22/29] Removed unused date constraint dropdown block --- blocks.py | 55 ------------------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/blocks.py b/blocks.py index 9429b6a..3222757 100644 --- a/blocks.py +++ b/blocks.py @@ -71,60 +71,6 @@ } } -date_constraint_block = { - "block_id": "date_constraint_block", - "type": "input", - "element": { - "type": "static_select", - "initial_option": { - "text": { - "type": "plain_text", - "text": "This year", - }, - "value": "this year" - }, - "placeholder": { - "type": "plain_text", - "text": "Select an item", - }, - "options": [ - { - "text": { - "type": "plain_text", - "text": "This year", - }, - "value": "this year" - }, - { - "text": { - "type": "plain_text", - "text": "Today", - }, - "value": "today" - }, - { - "text": { - "type": "plain_text", - "text": "This week", - }, - "value": "this week" - }, - { - "text": { - "type": "plain_text", - "text": "This month", - }, - "value": "this month" - } - ], - "action_id": "date_constraint_input" - }, - "label": { - "type": "plain_text", - "text": "Selection range", - } -} - def text_field_block(label, max_length): return { "type": "input", @@ -182,7 +128,6 @@ def timelog_form(): return [ date_select_block("Date to log", date.today()), hours_input_block, - # 72 characters is the max length of a git commit message text_field_block("Summary of work done", 70), submit_button_block("timelog_response") ] From e5bfc9e279bff2001330fe0fa879c7ecd966c7dc Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 08:43:37 +1100 Subject: [PATCH 23/29] General readability improvements, changed standard date input format for database module from date object to string --- app.py | 74 ++++++++++++++++++++++++++--------------------------- database.py | 14 ++++------ 2 files changed, 41 insertions(+), 47 deletions(-) diff --git a/app.py b/app.py index 63bc07b..471846f 100644 --- a/app.py +++ b/app.py @@ -59,11 +59,11 @@ def submit_timelog_form(ack, respond, body, logger): if not (select_date and time_input and summary): raise ValueError("Missing required field") - time_list = re.findall(r'\d+', time_input) # List with two ints + time_inputs = re.findall(r'\d+', time_input) # List with two ints if (len(summary) > 70): raise ValueError("Summary must be under 70 characters") - if not (all(i.isdigit() for i in time_list) and len(time_list) == 2): + if not (all(time_input.isdigit() for time_input in time_inputs) and len(time_inputs) == 2): raise ValueError("Time logged field must contain two numbers seperated by some characters (e.g. 3h 25m)") except ValueError as e: @@ -71,11 +71,11 @@ def submit_timelog_form(ack, respond, body, logger): logger.warning(e) return - minutes = int(time_list[0])*60 + int(time_list[1]) # user input (hours and minutes) stored as minutes only - logger.info(f"New log entry of {time_list[0]} hours and {time_list[1]} minutes for {selected_date} by {user_id}") + minutes = int(time_inputs[0])*60 + int(time_inputs[1]) # user input (hours and minutes) stored as minutes only + logger.info(f"New log entry of {time_inputs[0]} hours and {time_inputs[1]} minutes for {selected_date} by {user_id}") sqlc = database.SQLConnection() sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) - respond(f"Time logged: {time_list[0]} hours and {time_list[1]} minutes for date {selected_date}.") + respond(f"Time logged: {time_inputs[0]} hours and {time_inputs[1]} minutes for date {selected_date}.") @@ -100,9 +100,6 @@ def get_logged_hours(ack, body, respond, logger): if not (users and start_date and end_date): raise ValueError("Missing required field") - start_date = datetime.strptime(start_date, "%Y-%m-%d") - end_date = datetime.strptime(end_date, "%Y-%m-%d") - except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.") logger.warning(e) @@ -111,11 +108,11 @@ def get_logged_hours(ack, body, respond, logger): sqlc = database.SQLConnection() output = "" # Add the time logged by each user to the output - for i in users: - name = user_name(i) - user_time = sqlc.time_sum(i, start_date, end_date) + for user in users: + name = user_name(user) + user_time = sqlc.time_sum(user, start_date, end_date) if (user_time > 0): - output += f"*{name}*: {int(user_time/60)} hours and {int(user_time%60)} minutes\n" + output += f"*{name}*: {user_time//60} hours and {user_time%60} minutes\n" else: output += f"*{name}* has no logged hours\n" respond(output) @@ -156,9 +153,9 @@ def get_logged_hours(ack, body, respond, logger): output += f"\n\n\n*{user_name(user)}*" entries = sqlc.given_user_entries_list(user, num_entries) if entries: - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" + for entry in entries: + output += f"\n\n • {entry[0]} / {(entry[1]//60):2} hours and {(entry[1]%60):2} minutes / Submitted {entry[2]} / " + output += f"_{entry[3]}_" if entry[3] else "No summary given" else: output += "\n\n • No entries" respond(output) @@ -190,11 +187,11 @@ def get_date_overview(ack, body, respond, logger): entries = sqlc.entries_for_date_list(selected_date) output = f"*Overview for {selected_date}*" if entries: - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {int(i[2]/60):2} hours and {i[2]%60:2} minutes / " - output += f"_{i[3]}_" if i[3] else "No summary given" + for entry in entries: + name = entry[0] + if entry[1] != "": name += " ("+entry[1]+")" + output += f"\n\n • {name} / {(entry[2]//60):2} hours and {(entry[2]%60):2} minutes / " + output += f"_{entry[3]}_" if entry[3] else "No summary given" else: output += "\n\n • No entries" respond(output) @@ -226,20 +223,20 @@ def leaderboard_response(ack, body, respond, logger, command): return sqlc = database.SQLConnection() - contributions = sqlc.leaderboard(start_date, end_date) + contributors = sqlc.leaderboard(start_date, end_date) # Doing this means we are converting from date object to string, to date object, and then back to string # I still think this is the best approach because it is clearer and easier to read than a string manipulation au_start_date = datetime.strptime(start_date, "%Y-%m-%d").strftime("%d/%m/%y") au_end_date = datetime.strptime(end_date, "%Y-%m-%d").strftime("%d/%m/%y") - if contributions: + if contributors: output = f"*All contributors between {au_start_date} and {au_end_date} ranked by hours logged*\n" - for i in contributions: + for contributor in contributors: + name = contributor[0] # Add custom display name if applicable - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"{name}: {int(i[2]/60)} hours and {int(i[2]%60)} minutes\n" + if contributor[1] != "": name += f" ({contributor[1]})" + output += f"{name}: {(contributor[2]//60)} hours and {contributor[2]%60} minutes\n" respond(output) else: respond(f"No hours logged between {au_start_date} and {au_end_date}!") @@ -286,16 +283,17 @@ def user_entries(ack, respond, body, command, logger): sqlc = database.SQLConnection() entries = sqlc.given_user_entries_list(user_id, num_entries) today = datetime.today() - yearly_minutes = sqlc.time_sum(user_id, today - timedelta(days=365), today) - weekly_minutes = sqlc.time_sum(user_id, today - timedelta(days=today.weekday()), today) + yearly_minutes = sqlc.time_sum(user_id, (today - timedelta(days=365)).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) + weekly_minutes = sqlc.time_sum(user_id, (today - timedelta(days=today.weekday())).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')) - output = f"\n*Hours logged in the last 365 days*: {int(yearly_minutes/60)} hours and {yearly_minutes%60} minutes" - output += f"\n*Hours logged this week:* {int(weekly_minutes/60)} hours and {weekly_minutes%60} minutes" + output = f"\n*Hours logged in the last 365 days*: {yearly_minutes//60} hours and {yearly_minutes%60} minutes" + output += f"\n*Hours logged this week:* {weekly_minutes//60} hours and {weekly_minutes%60} minutes" if entries: - for i in entries: - output += f"\n\n • {i[0]} / {int(i[1]/60):2} hours and {i[1]%60:2} minutes / Submitted {i[2]} / " - output += f"_{i[3]}_" if i[3] else "No summary given" + for entry in entries: + # restricting hours and minutes to 2 characters makes the list look nicer + output += f"\n\n • {entry[0]} / {(entry[1]//60):2} hours and {(entry[1]%60):2} minutes / Submitted {entry[2]} / " + output += f"_{entry[3]}_" if entry[3] else "No summary given" else: output += "\n\n • No entries" respond(output) @@ -331,11 +329,11 @@ def log_database(ack, body, respond, command, logger): output = f"*Last {num_entries} entries from all users*" if entries: - for i in entries: - name = i[0] - if i[1] != "": name += " ("+i[1]+")" - output += f"\n\n • {name} / {i[2]} / {int(i[3]/60):2} hours and {i[3]%60:2} minutes / Submitted {i[4]} / " - output += f"_{i[5]}_" if i[5] else "No summary given" + for entry in entries: + name = entry[0] + if entry[1] != "": name += f" {(entry[1])}" + output += f"\n\n • {name} / {entry[2]} / {(entry[3]//60):2} hours and {(entry[3]%60):2} minutes / Submitted {entry[4]} / " + output += f"_{entry[5]}_" if entry[5] else "No summary given" else: output += "\n\n • No entries" diff --git a/database.py b/database.py index 256f005..8f122eb 100644 --- a/database.py +++ b/database.py @@ -35,8 +35,7 @@ def create_user_table(): # SQLite3 documentation says placeholder question marks and a tuple of values should be used rather than formatted strings to prevent sql injection attacks # Ot's probably not important in this project but there's no reason not to do it this way -# All public methods in this class assume date is being given as a date object, not a string. This means multiple conversions are needed sometimes but it -# keeps things consistent and makes the whole program easier to maintain. +# All public methods in this class assume date is being given as a string in the YYYY-MM-DD format. This is because the Slack block forms return dates as strings and SQLite stores dates as strings. class SQLConnection: def __init__(self): @@ -106,9 +105,6 @@ def given_user_entries_list(self, user_id, num_entries = 10): # Get total minutes logged by user with given user_id def time_sum(self, user_id, start_date = None, end_date = None): - # If the user has entries in the database return their total time logged, otherwise return 0 - start_date = start_date.strftime('%Y-%m-%d') - end_date = end_date.strftime('%Y-%m-%d') res = self.cur.execute("""SELECT SUM(minutes) FROM time_log WHERE user_id = ? @@ -132,8 +128,8 @@ def leaderboard(self, start_date = None, end_date = None): def entries_for_date_list(self, selected_date): # Get all entries by all users res = self.cur.execute("""SELECT u.name, u.display_name, tl.minutes, tl.summary - FROM time_log tl - INNER JOIN user_names u - ON tl.user_id=u.user_id - WHERE tl.selected_date=? """, (selected_date,)) + FROM time_log tl + INNER JOIN user_names u + ON tl.user_id=u.user_id + WHERE tl.selected_date=? """, (selected_date,)) return(res.fetchall()) From 2af38ce85270c4aaa3136d7047897ca84e3f1f7f Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 12:51:44 +1100 Subject: [PATCH 24/29] Add row_factory to sqlite cursor to improve readability --- app.py | 43 ++++++++++++++++++++----------------------- database.py | 1 + 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index 471846f..6203b60 100644 --- a/app.py +++ b/app.py @@ -53,13 +53,13 @@ def submit_timelog_form(ack, respond, body, logger): user_id = body['user']['id'] selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] - time_input = body['state']['values']['hours_block']['hours_input']['value'] # String + time_input_text = body['state']['values']['hours_block']['hours_input']['value'] summary = body['state']['values']['text_field_block']['text_input']['value'] - if not (select_date and time_input and summary): + if not (select_date and time_input_text and summary): raise ValueError("Missing required field") - time_inputs = re.findall(r'\d+', time_input) # List with two ints + time_inputs = re.findall(r'\d+', time_input_text) # List with two integers: hours and minutes if (len(summary) > 70): raise ValueError("Summary must be under 70 characters") @@ -71,7 +71,7 @@ def submit_timelog_form(ack, respond, body, logger): logger.warning(e) return - minutes = int(time_inputs[0])*60 + int(time_inputs[1]) # user input (hours and minutes) stored as minutes only + minutes = int(time_inputs[0])*60 + int(time_inputs[1]) logger.info(f"New log entry of {time_inputs[0]} hours and {time_inputs[1]} minutes for {selected_date} by {user_id}") sqlc = database.SQLConnection() sqlc.insert_timelog_entry(user_id, selected_date, minutes, summary) @@ -154,8 +154,8 @@ def get_logged_hours(ack, body, respond, logger): entries = sqlc.given_user_entries_list(user, num_entries) if entries: for entry in entries: - output += f"\n\n • {entry[0]} / {(entry[1]//60):2} hours and {(entry[1]%60):2} minutes / Submitted {entry[2]} / " - output += f"_{entry[3]}_" if entry[3] else "No summary given" + output += f"\n\n • {entry['selected_date']} / {(entry['minutes']//60):2} hours and {(entry['minutes']%60):2} minutes / Submitted {entry['entry_date']} / " + output += f"_{entry['summary']}_" if entry['summary'] else "No summary given" else: output += "\n\n • No entries" respond(output) @@ -188,10 +188,10 @@ def get_date_overview(ack, body, respond, logger): output = f"*Overview for {selected_date}*" if entries: for entry in entries: - name = entry[0] - if entry[1] != "": name += " ("+entry[1]+")" - output += f"\n\n • {name} / {(entry[2]//60):2} hours and {(entry[2]%60):2} minutes / " - output += f"_{entry[3]}_" if entry[3] else "No summary given" + name = entry['name'] + if entry['display_name'] != "": name += f" ({entry['display_name']})" + output += f"\n\n • {name} / {(entry['minutes']//60):2} hours and {(entry['minutes']%60):2} minutes / " + output += f"_{entry['summary']}_" if entry['summary'] else "No summary given" else: output += "\n\n • No entries" respond(output) @@ -225,8 +225,7 @@ def leaderboard_response(ack, body, respond, logger, command): sqlc = database.SQLConnection() contributors = sqlc.leaderboard(start_date, end_date) - # Doing this means we are converting from date object to string, to date object, and then back to string - # I still think this is the best approach because it is clearer and easier to read than a string manipulation + # Convert to the australian standard date format for slack output au_start_date = datetime.strptime(start_date, "%Y-%m-%d").strftime("%d/%m/%y") au_end_date = datetime.strptime(end_date, "%Y-%m-%d").strftime("%d/%m/%y") @@ -236,7 +235,7 @@ def leaderboard_response(ack, body, respond, logger, command): name = contributor[0] # Add custom display name if applicable if contributor[1] != "": name += f" ({contributor[1]})" - output += f"{name}: {(contributor[2]//60)} hours and {contributor[2]%60} minutes\n" + output += f"{name}: {(contributor['minutes']//60)} hours and {contributor['minutes']%60} minutes\n" respond(output) else: respond(f"No hours logged between {au_start_date} and {au_end_date}!") @@ -292,13 +291,12 @@ def user_entries(ack, respond, body, command, logger): if entries: for entry in entries: # restricting hours and minutes to 2 characters makes the list look nicer - output += f"\n\n • {entry[0]} / {(entry[1]//60):2} hours and {(entry[1]%60):2} minutes / Submitted {entry[2]} / " - output += f"_{entry[3]}_" if entry[3] else "No summary given" + output += f"\n\n • {entry['selected_date']} / {(entry['minutes']//60):2} hours and {(entry['minutes']%60):2} minutes / Submitted {entry['entry_date']} / " + output += f"_{entry['summary']}_" if entry['summary'] else "No summary given" else: output += "\n\n • No entries" respond(output) -# Delete the last entry made by the user issuing the command @app.command("/deletelast") def delete_last(ack, respond, body, command): ack() @@ -306,7 +304,6 @@ def delete_last(ack, respond, body, command): sqlc.remove_last_entry(body['user_id']) respond("Last entry removed!") -# Respond with last 30 hours entered by all users @app.command("/lastentries") def log_database(ack, body, respond, command, logger): ack() @@ -330,10 +327,10 @@ def log_database(ack, body, respond, command, logger): output = f"*Last {num_entries} entries from all users*" if entries: for entry in entries: - name = entry[0] - if entry[1] != "": name += f" {(entry[1])}" - output += f"\n\n • {name} / {entry[2]} / {(entry[3]//60):2} hours and {(entry[3]%60):2} minutes / Submitted {entry[4]} / " - output += f"_{entry[5]}_" if entry[5] else "No summary given" + name = entry['name'] + if entry['display_name'] != "": name += f" {(entry['display_name'])}" + output += f"\n\n • {name} / {entry['selected_date']} / {(entry['minutes']//60):2} hours and {(entry['minutes']%60):2} minutes / Submitted {entry['entry_date']} / " + output += f"_{entry['summary']}_" if entry['summary'] else "No summary given" else: output += "\n\n • No entries" @@ -343,14 +340,14 @@ def log_database(ack, body, respond, command, logger): ################################### Other events to be handled ################################### -# Update users real name and custom display name in database when a user changes this info through slack +# Update user info in the database to match slack user info @app.event("user_change") def update_user_info(event, logger): sqlc = database.SQLConnection() sqlc.validate_user(event["user"]["id"], event["user"]["profile"]["real_name"], event["user"]["profile"]["display_name"]) logger.info("Updated name for " + event["user"]["profile"]["real_name"]) -# Update users real name and custom display name in database when a new user joins the slack workspace +# Add users to the database when they join the workspace @app.event("team_join") def add_user(event, logger): sqlc = database.SQLConnection() diff --git a/database.py b/database.py index 8f122eb..82600d6 100644 --- a/database.py +++ b/database.py @@ -42,6 +42,7 @@ def __init__(self): # Open SQL connection self.con = sqlite3.connect(db_file) self.cur = self.con.cursor() + self.cur.row_factory = sqlite3.Row def __del__(self): # Close SQL connection (saving changes to file) From 2912b959c6e113653b8f6c5fc2f86e48ac660ef9 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 13:05:15 +1100 Subject: [PATCH 25/29] Update leaderboard response function to use dictionary key for readability --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 6203b60..cdbcb94 100644 --- a/app.py +++ b/app.py @@ -232,9 +232,9 @@ def leaderboard_response(ack, body, respond, logger, command): if contributors: output = f"*All contributors between {au_start_date} and {au_end_date} ranked by hours logged*\n" for contributor in contributors: - name = contributor[0] + name = contributor['name'] # Add custom display name if applicable - if contributor[1] != "": name += f" ({contributor[1]})" + if contributor[['display_name']] != "": name += f" ({contributor[['display_name']]})" output += f"{name}: {(contributor['minutes']//60)} hours and {contributor['minutes']%60} minutes\n" respond(output) else: From 1364ed34613494b85ecaa38350ae2c5204e85b9c Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 13:08:59 +1100 Subject: [PATCH 26/29] Fix invalid syntax --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index cdbcb94..9acc6e7 100644 --- a/app.py +++ b/app.py @@ -234,7 +234,7 @@ def leaderboard_response(ack, body, respond, logger, command): for contributor in contributors: name = contributor['name'] # Add custom display name if applicable - if contributor[['display_name']] != "": name += f" ({contributor[['display_name']]})" + if contributor['display_name'] != "": name += f" ({contributor['display_name']})" output += f"{name}: {(contributor['minutes']//60)} hours and {contributor['minutes']%60} minutes\n" respond(output) else: From ddc0d68be632d2d30a204cde61e8ab45cf40724f Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 13:16:56 +1100 Subject: [PATCH 27/29] Fixed invalid key and ordered /lastentries by date --- app.py | 2 +- database.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 9acc6e7..5992b3e 100644 --- a/app.py +++ b/app.py @@ -235,7 +235,7 @@ def leaderboard_response(ack, body, respond, logger, command): name = contributor['name'] # Add custom display name if applicable if contributor['display_name'] != "": name += f" ({contributor['display_name']})" - output += f"{name}: {(contributor['minutes']//60)} hours and {contributor['minutes']%60} minutes\n" + output += f"{name}: {(contributor['totalMinutes']//60)} hours and {contributor['totalMinutes']%60} minutes\n" respond(output) else: respond(f"No hours logged between {au_start_date} and {au_end_date}!") diff --git a/database.py b/database.py index 82600d6..4064d31 100644 --- a/database.py +++ b/database.py @@ -92,6 +92,7 @@ def all_user_entries_list(self, num_entries): FROM time_log tl INNER JOIN user_names u ON tl.user_id=u.user_id + ORDER BY tl.selected_date DESC, tl.entry_num DESC LIMIT ? """, (num_entries,)) return(res.fetchall()) From d04e127e49c459dc3eb68b502002141c28366f17 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Fri, 18 Nov 2022 13:26:41 +1100 Subject: [PATCH 28/29] Removed unnecessary parentheses --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 5992b3e..b5e74de 100644 --- a/app.py +++ b/app.py @@ -235,7 +235,7 @@ def leaderboard_response(ack, body, respond, logger, command): name = contributor['name'] # Add custom display name if applicable if contributor['display_name'] != "": name += f" ({contributor['display_name']})" - output += f"{name}: {(contributor['totalMinutes']//60)} hours and {contributor['totalMinutes']%60} minutes\n" + output += f"{name}: {contributor['totalMinutes']//60} hours and {contributor['totalMinutes']%60} minutes\n" respond(output) else: respond(f"No hours logged between {au_start_date} and {au_end_date}!") @@ -328,7 +328,7 @@ def log_database(ack, body, respond, command, logger): if entries: for entry in entries: name = entry['name'] - if entry['display_name'] != "": name += f" {(entry['display_name'])}" + if entry['display_name'] != "": name += f" ({entry['display_name']})" output += f"\n\n • {name} / {entry['selected_date']} / {(entry['minutes']//60):2} hours and {(entry['minutes']%60):2} minutes / Submitted {entry['entry_date']} / " output += f"_{entry['summary']}_" if entry['summary'] else "No summary given" else: From 298ee56c2b4328862c5e4dbdcadd6d1c9fb28838 Mon Sep 17 00:00:00 2001 From: Jesse Williamson Date: Mon, 12 Dec 2022 18:27:40 +1100 Subject: [PATCH 29/29] Remove nested try statement and unnecessary quotation marks in readme --- README.md | 6 +++--- app.py | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f42df4..2b8d22e 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@ Run app.py to launch Timelord - `/help` Get this list of commands - `/timelog` Open a time logging form - `/deletelast` Delete your last entry -- `/myentries n` Get your last n entries (defaults to 5)""" +- `/myentries n` Get your last n entries (defaults to 5) #### Admin Commands: - `/timelog` Open a time logging form - `/deletelast` Delete your last entry -- `/myentries n` Get your last n entries (defaults to 30)""" +- `/myentries n` Get your last n entries (defaults to 30) - `/gethours` Select users and see their total hours logged - `/getentries` Select users and see their most recent entries - `/lastentries n` See the last n entries from all users in one list (defaults to 30) - `/leaderboard` Select a date range and rank all users by hours logged in that range -- `/dateoverview` See all entries for a given date""" +- `/dateoverview` See all entries for a given date ## Autostart Change the paths in `timelord.service` to match where you have put it, then copy `timelord.serivce` into diff --git a/app.py b/app.py index b5e74de..ca1c994 100644 --- a/app.py +++ b/app.py @@ -134,13 +134,16 @@ def get_logged_hours(ack, body, respond, logger): ack() try: users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] - num_entries_input = body['state']['values']['num_entries_block']['num_entries_input']['value'] + num_entries_input_text = body['state']['values']['num_entries_block']['num_entries_input']['value'] - if not (users and num_entries_input): + if not (users and num_entries_input_text): raise ValueError("Missing required field") - try: num_entries = int(re.findall(r'\d+', num_entries_input)[0]) - except: raise ValueError("Number of entries must be an integer") + num_entries_input = re.findall(r'\d+', num_entries_input_text) + if len(num_entries_input) == 1 and num_entries_input[0].isdigit(): + num_entries = int(num_entries_input[0]) + else: + raise ValueError("Number of entries must be a single positive integer") except ValueError as e: respond(f"*Invalid input, please try again!* {str(e)}.")