diff --git a/README.md b/README.md index c02719a..2b8d22e 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 eb528d1..ca1c994 100644 --- a/app.py +++ b/app.py @@ -2,8 +2,7 @@ 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 +from datetime import datetime, timedelta from dotenv import load_dotenv from pathlib import Path @@ -18,11 +17,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}```") - - ################################### User validation ################################### # Get user's full name and custom display name (if applicable) from database @@ -54,26 +48,36 @@ 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 = datetime.strptime(body['state']['values']['date_select_block']['date_select_input']['selected_date'], "%Y-%m-%d").date() - time_input = re.findall(r'\d+', body['state']['values']['hours_block']['hours_input']['value']) # creates list containing two strings (hours and minutes) 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_text = body['state']['values']['hours_block']['hours_input']['value'] + summary = body['state']['values']['text_field_block']['text_input']['value'] - # Open an SQL connection and add entry to database containing user input - sqlc = database.SQLConnection() - sqlc.insert_timelog_entry(user_id, selected_date, minutes) + if not (select_date and time_input_text and summary): + raise ValueError("Missing required field") + + 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") + 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: + respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warning(e) + return + + 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) + respond(f"Time logged: {time_inputs[0]} hours and {time_inputs[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)") # Get user-selection form (choose users to see their total hours logged) @app.command("/gethours") @@ -88,68 +92,176 @@ 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 + try: + users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] + 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.warning(e) + return + 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) + 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" - # Send output to Slack chat and console - logger.info("\n" + output) 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'] 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.') + users = body['state']['values']['user_select_block']['user_select_input']['selected_users'] + num_entries_input_text = body['state']['values']['num_entries_block']['num_entries_input']['value'] + + if not (users and num_entries_input_text): + raise ValueError("Missing required field") + + 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)}.") + logger.warning(e) + return sqlc = database.SQLConnection() output = "" 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" + output += f"\n\n\n*{user_name(user)}*" + entries = sqlc.given_user_entries_list(user, num_entries) + if entries: + for entry in entries: + 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) + + +@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() + try: + selected_date = body['state']['values']['date_select_block']['date_select_input']['selected_date'] + if not (selected_date): + raise ValueError("Missing required field") + + 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}*" + if entries: + for entry in entries: + 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) + + + +# Get a leaderboard with the top 10 contributors and their hours logged +@app.command("/leaderboard") +def leaderboard(ack, body, respond): + ack() + if(is_admin(body['user_id'])): + 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): + ack() + try: + 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 (start_date and end_date): + raise ValueError("Both a start and end date must be specified") + + except ValueError as e: + respond(f"*Invalid input, please try again!* {str(e)}.") + logger.warning(e) + return + + sqlc = database.SQLConnection() + contributors = sqlc.leaderboard(start_date, end_date) + + # 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") + + 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['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" + 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:* - */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)""" if(is_admin(body['user_id'])): 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)""" + */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) # Respond with a table showing the last n entries made by the user issuing the command @@ -158,18 +270,36 @@ 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 5 # Defaults to 5 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") - sqlc = database.SQLConnection() - table = sqlc.last_entries_table(user_id, num_entries) + except ValueError as e: + respond(f"*Invalid input!* Please try again! {str(e)}.") + logger.warning(e) + return - respond(slack_table(f"{num_entries} most recent entries by {name}", table)) + 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)).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*: {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 entry in entries: + # restricting hours and minutes to 2 characters makes the list look nicer + 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() @@ -177,72 +307,50 @@ 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") +@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)) - 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 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.") + try: + 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: + respond(f"*Invalid input!* {str(e)}") + logger.warning(e) + return - 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" + entries = sqlc.all_user_entries_list(num_entries) + + output = f"*Last {num_entries} entries from all users*" + if entries: + for entry in entries: + 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" + respond(output) else: respond("You must be an admin to use this command!") ################################### 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() @@ -264,6 +372,11 @@ def select_date(ack, body, logger): ack() logger.debug(body) +@app.action("date_constraint_input") +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 dd97c09..3222757 100644 --- a/blocks.py +++ b/blocks.py @@ -1,149 +1,169 @@ # 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 +# 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 -# Get current date for time logging form -def currentDate(): - return datetime.now() +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", + } +} -# Time logging form blocks -def timelog_form(): - output = [ - { - # Horizontal line - "type": "divider" +divider = { + "type": "divider" +} + +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", + "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 - 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": { - "type": "plain_text", - "text": "Date to log", - } + "action_id": "user_select_input" + }, + "label": { + "type": "plain_text", + "text": "Select users", + } +} + +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 }, - { - # Hours input - "type": "input", - "block_id": "hours_block", - "element": { - "type": "plain_text_input", - "action_id": "hours_input" - }, - "label": { + "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 { + "type": "input", + "block_id": block_id, + "element": { + "type": "datepicker", + "initial_date": initial_date.strftime("%Y-%m-%d"), + "placeholder": { "type": "plain_text", - "text": "Time logged (e.g. 2h, 25m)", - } - }, - { - # Submit button - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Click to submit and log hours" + "text": "Select a date", }, - "accessory": { + "action_id": "date_select_input" + }, + "label": { + "type": "plain_text", + "text": label, + } + } + +def submit_button_block(form_action): + return { + "type": "actions", + "elements": [ + { "type": "button", "text": { "type": "plain_text", - "text": "Submit", + "text": "Confirm", }, - "value": "placeholder", - "action_id": "timelog_response" + "value": "confirm", + "action_id": form_action } - } + ] + } + +# Time logging form +def timelog_form(): + return [ + date_select_block("Date to log", date.today()), + hours_input_block, + text_field_block("Summary of work done", 70), + submit_button_block("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": { - "type": "plain_text", - "text": "Select users", - }, - "action_id": "user_select_input" - }, - "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 [ + 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, + num_entries_block, + submit_button_block("getentries_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": { - "type": "plain_text", - "text": "Select users", - }, - "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" - }, - "label": { - "type": "plain_text", - "text": "Number of entries to return for each user", - } - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Confirm Selection", - }, - "action_id": "getusertables_response" - }, - ] - } + return [ + user_select_block, + num_entries_block, + submit_button_block("getusertables_response") + ] + +def dateoverview_form(): + return [ + date_select_block("Date to view", date.today()), + submit_button_block("dateoverview_response") + ] + +def leaderboard_form(): + return [ + date_select_block("Start date", date.today() - timedelta(days=365), "start"), + date_select_block("End date", date.today(), "end"), + submit_button_block("leaderboard_response") ] - return output diff --git a/database.py b/database.py index bcaf77b..4064d31 100644 --- a/database.py +++ b/database.py @@ -14,9 +14,9 @@ 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));""") + PRIMARY KEY (entry_num, user_id)) """) con.close() def create_user_table(): @@ -27,14 +27,22 @@ def create_user_table(): name TEXT NOT NULL, display_name TEXT, - PRIMARY KEY (user_id));""") + 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 + +# 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): # 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) @@ -45,7 +53,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): @@ -59,83 +67,71 @@ 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 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 )) + entry_num = 1 if not entry_num else entry_num + 1 + + 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 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): - 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;""") - 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): - res = self.cur.execute("""SELECT entry_num, entry_date, selected_date, minutes - FROM time_log WHERE user_id = ? + ORDER BY tl.selected_date DESC, tl.entry_num DESC + LIMIT ? """, (num_entries,)) + return(res.fetchall()) + + # Get the last n entries by user + 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 = ? 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")) + LIMIT ? """, (user_id, num_entries)) + return(res.fetchall()) # Get total minutes logged by user with given user_id - def time_sum(self, user_id): - # If the user has entries in the database return their total time logged, otherwise return 0 + def time_sum(self, user_id, start_date = None, end_date = None): res = self.cur.execute("""SELECT SUM(minutes) - FROM time_log - WHERE user_id = ?;""", (user_id,)) + 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) - - # 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()) + return minutes if minutes else 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 + 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 - GROUP BY u.name, u.user_id, u.display_name - ORDER BY totalMinutes DESC - LIMIT ?;""", (num_users,)) + 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): + # 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,)) return(res.fetchall())