From 6ccee0569c60b5bacabe1f83b126c4a7deee976e Mon Sep 17 00:00:00 2001 From: Collin Rapp Date: Sun, 14 Feb 2021 20:14:46 -0800 Subject: [PATCH] Initial changes for multiple solutions per submission --- pointsbot/bot.py | 63 ++++----- pointsbot/config.py | 7 +- pointsbot/database.py | 299 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 308 insertions(+), 61 deletions(-) diff --git a/pointsbot/bot.py b/pointsbot/bot.py index 3bf8df8..38ab944 100644 --- a/pointsbot/bot.py +++ b/pointsbot/bot.py @@ -16,9 +16,8 @@ from . import config, database, level, reply USER_AGENT = 'PointsBot (by u/GlipGlorp7)' # The pattern that determines whether a post is marked as solved -# Could also use re.IGNORECASE flag instead -SOLVED_PATTERN = re.compile('![Ss]olved') -MOD_SOLVED_PATTERN = re.compile('/[Ss]olved') +SOLVED_PATTERN = re.compile('![Hh]elped') +MOD_SOLVED_PATTERN = re.compile('/[Hh]elped') MOD_REMOVE_PATTERN = re.compile('/[Rr]emove[Pp]oint') ### Main Function ### @@ -51,19 +50,11 @@ def run(): logging.info('Connected to Reddit as %s', reddit.user.me()) access_type = 'read-only' if reddit.read_only else 'write' logging.info(f'Has {access_type} access to Reddit') - # if not reddit.read_only: - # logging.info('Has write access to Reddit') - # else: - # logging.warning('Has read-only access to Reddit') subreddit = reddit.subreddit(cfg.subreddit) logging.info('Watching subreddit %s', subreddit.title) is_mod = subreddit.moderator(redditor=reddit.user.me()) logging.info(f'Is {"" if is_mod else "NOT "} moderator for subreddit') - # if subreddit.moderator(redditor=reddit.user.me()): - # logging.info('Is moderator for monitored subreddit') - # else: - # logging.warning('Is NOT moderator for monitored subreddit') monitor_comments(reddit, subreddit, db, levels, cfg) @@ -94,8 +85,6 @@ def monitor_comments(reddit, subreddit, db, levels, cfg): continue logging.info('Found comment') - - # TODO more debug info about comment logging.debug('Comment author: "%s"', comm.author.name) logging.debug('Comment text: "%s"', comm.body) @@ -111,21 +100,29 @@ def monitor_comments(reddit, subreddit, db, levels, cfg): if is_mod_command: logging.info('Comment was submitted by mod') - elif is_valid_tag(comm, cfg.tags) and is_valid_flair(comm): - logging.info('Comment has a valid tag and is not already marked as solved') - else: - # Skip this "!solved" comment - logging.info('Comment is NOT the first to mark the issue as solved') - continue + elif is_valid_tag(comm, cfg.tags): + logging.info('Comment has a valid tag') + # elif is_valid_tag(comm, cfg.tags) and is_valid_flair(comm): + # logging.info('Comment has a valid tag and is not already marked as solved') + # else: + # # Skip this "!solved" comment + # logging.info('Comment is NOT the first to mark the issue as solved') + # continue if not remove_point: log_solution_info(comm) - solver = find_solver(comm) + # solver = find_solver(comm) + solver, solution_comment = find_solver_and_comment(comm) if remove_point: - db.remove_point(solver) + # db.remove_point(solver) + # db.remove_point_for_solution(submission, solver, solution_comment, remover, removed_by_comment) + # db.remove_point_for_solution(comm.submission, ) + db.soft_remove_point_for_solution(comm.submission, solver, comm.author, comm) logging.info('Removed point for user "%s"', solver.name) else: - db.add_point(solver) + # db.add_point(solver) + # db.add_point_for_solution(submission, solver, solution_comment, chooser, chosen_by_comment) + db.add_point_for_solution(comm.submission, solver, solution_comment, comm.author, comm) logging.info('Added point for user "%s"', solver.name) points = db.get_points(solver) @@ -149,10 +146,12 @@ def monitor_comments(reddit, subreddit, db, levels, cfg): except praw.exceptions.APIException as e: logging.error('Unable to reply to comment: %s', e) if remove_point: - db.add_point(solver) + # db.add_point(solver) + db.add_back_point_for_solution(comm.submission, solver) logging.error('Re-added point that was just removed from user "%s"', solver.name) else: - db.remove_point(solver) + # db.remove_point(solver) + db.remove_point_and_delete_solution(comm.submission, solver) logging.error('Removed point that was just awarded to user "%s"', solver.name) logging.error('Skipping comment') continue @@ -244,7 +243,6 @@ MOD_REMOVE_RULES = [ 'author is mod', 'Comment author is a mod', 'Comment author is not a mod', - # functions lambda c: is_mod_comment(c), ), ] @@ -300,17 +298,20 @@ def is_valid_tag(solved_comment, valid_tags): return False -def is_valid_flair(solved_comment): - """Return True if this comment's post doesn't already have the Solved flair, False otherwise.""" - submission = solved_comment.submission +# def is_valid_flair(solved_comment): +# """Return True if this comment's post doesn't already have the Solved flair, False otherwise.""" +# submission = solved_comment.submission - return submission.link_flair_text.lower() != "solved" +# return submission.link_flair_text.lower() != "solved" -def find_solver(solved_comment): +# def find_solver(solved_comment): +def find_solver_and_comment(solved_comment) """Determine the redditor responsible for solving the question.""" # TODO plz make this better someday - return solved_comment.parent().author + # return solved_comment.parent().author + solution_comment = solved_comment.parent() + return solution_comment.author, solution_comment ### Print & Logging Functions ### diff --git a/pointsbot/config.py b/pointsbot/config.py index 15a8233..21a8479 100644 --- a/pointsbot/config.py +++ b/pointsbot/config.py @@ -14,9 +14,9 @@ CONFIGPATH = os.path.join(DATADIR, 'pointsbot.toml') # Path to the sample config file # Unused for now -SAMPLEPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), - '..', - 'pointsbot.sample.toml')) +# SAMPLEPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), +# '..', +# 'pointsbot.sample.toml')) ### Primary Functions ### @@ -115,7 +115,6 @@ class Config: def save(self): obj = deepcopy(vars(self)) orig_levels, obj['levels'] = obj['levels'], [] - # obj['levels'] = [] for level in orig_levels: obj['levels'].append({ 'name': level.name, diff --git a/pointsbot/database.py b/pointsbot/database.py index 95d90d4..c07b9d1 100644 --- a/pointsbot/database.py +++ b/pointsbot/database.py @@ -1,11 +1,16 @@ +import datetime import functools import os.path import sqlite3 as sqlite +from collections import namedtuple ### Decorators ### def transaction(func): + """Use this decorator on any methods that needs to query the database to + ensure that connections are properly opened and closed. + """ @functools.wraps(func) def newfunc(self, *args, **kwargs): created_conn = False @@ -31,15 +36,68 @@ def transaction(func): ### Classes ### +DatabaseVersion = namedtuple('DatabaseVersion', 'major minor patch pre_release_name pre_release_number') + class Database: + VERSION = DatabaseVersion(0, 2, 0, None, None) + SCHEMA = ''' + --------------------------- + -- Schema version: 0.1.0 -- + --------------------------- + CREATE TABLE IF NOT EXISTS redditor_points ( id TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL, points INTEGER DEFAULT 0 - ) + ); + + --------------------------- + -- Schema version: 0.2.0 -- + --------------------------- + + -- Tracking bot/db version for potential future use in migrations et al. + CREATE TABLE IF NOT EXISTS bot_version ( + major INTEGER NOT NULL, + minor INTEGER NOT NULL, + patch INTEGER NOT NULL, + pre_release_name TEXT, + pre_release_number INTEGER + ); + INSERT OR IGNORE INTO bot_version (major, minor, patch) VALUES (0, 2, 0); + + ALTER TABLE redditor_points RENAME TO redditor; + -- TODO rename "id" columns to "reddit_id" for consistency/clarity? + -- ALTER TABLE redditor RENAME COLUMN id TO reddit_id; + + CREATE TABLE IF NOT EXISTS submission ( + id TEXT UNIQUE NOT NULL, + author_id TEXT UNIQUE NOT NULL + ); + + CREATE TABLE IF NOT EXISTS comment ( + id TEXT UNIQUE NOT NULL, + author_id TEXT NOT NULL, + author_rowid INTEGER, -- May be NULL **for now** + created_at_datetime TEXT NOT NULL, + FOREIGN KEY (author_rowid) REFERENCES redditor (rowid) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS solution ( + submission_rowid INTEGER NOT NULL, + author_rowid INTEGER NOT NULL, + comment_rowid INTEGER NOT NULL, + chosen_by_comment_rowid INTEGER NOT NULL, + removed_by_comment_rowid INTEGER, + FOREIGN KEY (submission_rowid) REFERENCES submission (rowid) ON DELETE CASCADE, + FOREIGN KEY (author_rowid) REFERENCES redditor (rowid) ON DELETE CASCADE, + FOREIGN KEY (comment_rowid) REFERENCES comment (rowid) ON DELETE CASCADE, + FOREIGN KEY (chosen_by_comment_rowid) REFERENCES comment (rowid) ON DELETE SET NULL, + FOREIGN KEY (removed_by_comment_rowid) REFERENCES comment (rowid) ON DELETE SET NULL, + PRIMARY KEY (submission_rowid, author_rowid) ON DELETE CASCADE + ); ''' def __init__(self, dbpath): @@ -49,15 +107,34 @@ class Database: if not os.path.exists(self.path): self._create() + else: + self._migrate_if_necessary() @transaction def _create(self): self.cursor.execute(self.SCHEMA) + @transaction + def _migrate_if_necessary(self): + self.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'bot_version'") + has_version_table = (self.cursor.rowcount == 1) + has_outdated_version = False + if has_version_table: + self.cursor.execute('SELECT major, minor, patch, pre_release_name, pre_release_number FROM bot_version') + row = self.cursor.fetchone() + pre_release_number = int(row['pre_release_number']) if row['pre_release_number'] else None + current_version = DatabaseVersion(int(row['major']), int(row['minor']), int(row['patch']), row['pre_release_name'], pre_release_number) + has_outdated_version = (current_version == self.VERSION) + + if not has_version_table or has_outdated_version: + self.cursor.execute(self.SCHEMA) + + ### Public Methods ### + @transaction def add_redditor(self, redditor): insert_stmt = ''' - INSERT OR IGNORE INTO redditor_points (id, name) + INSERT OR IGNORE INTO redditor (id, name) VALUES (:id, :name) ''' self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name}) @@ -66,18 +143,201 @@ class Database: @transaction def remove_redditor(self, redditor): insert_stmt = ''' - DELETE FROM redditor_points + DELETE FROM redditor WHERE id = :id AND name = :name ''' self.cursor.execute(insert_stmt, {'id': redditor.id, 'name': redditor.name}) return self.cursor.rowcount - def add_point(self, redditor): - return self._update_points(redditor, 1) + @transaction + # def has_already_solved_once(self, solver, submission): + def has_already_solved_once(self, submission, solver): + # author_id = self._get_rowid_from_reddit_id('redditor', solver) + select_stmt = ''' + SELECT count(solution.rowid) AS num_solutions + FROM solution + JOIN submission ON (solution.submission_rowid = submission.rowid) + JOIN redditor ON (solution.author_rowid = redditor.rowid) + -- JOIN comment ON (solution.comment_rowid = comment.rowid) + WHERE submission.id = :submission_id + AND redditor.id = :author_id + -- AND comment.author_id = :author_id + ''' + self.cursor.execute(select_stmt, {'submission_id': submission.id, 'author_id': solver.id}) + row = self.cursor.fetchone() + return row and row['num_solutions'] > 0 + + def add_point_for_solution(self, submission, solver, solution_comment, chooser, chosen_by_comment): + self._add_submission(submission) + self._add_comment(solution_comment, solver) + self._add_comment(chosen_by_comment, chooser) - def remove_point(self, redditor): - return self._update_points(redditor, -1) + self._update_points(solver, 1) + # rowcount = self._add_solution(submission, solution_comment, chosen_by_comment) + rowcount = self._add_solution(submission, solver, solution_comment, chosen_by_comment) + if rowcount == 0: + # Was not able to add solution, probably because user has already solved this submission + self._update_points(solver, -1) + # if rowcount > 0: + # rowcount = self._update_points(solver, 1) + # # TODO update author_rowid for comment? + return rowcount + + # def remove_point_for_solution(self, submission, solver, solution_comment, remover, removed_by_comment): + # def remove_point_for_solution(self, submission, solver, remover, removed_by_comment): + def soft_remove_point_for_solution(self, submission, solver, remover, removed_by_comment): + # submission = removed_by_comment.submission + self._add_comment(removed_by_comment, remover) + # rowcount = self._soft_remove_solution(submission, solution_comment, removed_by_comment) + rowcount = self._soft_remove_solution(submission, solver, removed_by_comment) + if rowcount > 0: + rowcount = self._update_points(solver, -1) + # TODO move "remove redditor" logic here since it doesn't need to be considered when adding points? + return rowcount + + @transaction + def add_back_point_for_solution(self, submission, solver): + self._update_points(solver, 1) + # submission_rowid = self._get_submission_rowid(submission) + submission_rowid = self._get_rowid_from_reddit_id('submission', submission) + author_rowid = self._get_rowid_from_reddit_id('redditor', solver) + params = {'submission_rowid': submission_rowid, 'author_rowid': author_rowid} + update_stmt = ''' + UPDATE solution + SET removed_by_comment_rowid = NULL + WHERE submission_rowid = :submission_rowid + AND author_rowid = :author_rowid + ''' + return self.cursor.execute(update_stmt, params) + + @transaction + def remove_point_and_delete_solution(self, submission, solver): + params = { + 'submission_rowid': self._get_rowid_from_reddit_id('submission', submission), + 'author_rowid': self._get_rowid_from_reddit_id('redditor', solver) + } + delete_stmt = ''' + DELETE FROM solution + WHERE submission_rowid = :submission_rowid + AND author_rowid = :author_rowid + ''' + self.cursor.execute(delete_stmt, params) + return self._update_points(solver, -1) + + @transaction + def get_points(self, redditor, add_if_none=False): + params = {'id': redditor.id, 'name': redditor.name} + select_stmt = ''' + SELECT points + FROM redditor + WHERE id = :id AND name = :name + ''' + self.cursor.execute(select_stmt, params) + row = self.cursor.fetchone() + points = 0 + if row: + points = row['points'] + elif add_if_none: + self.add_redditor(redditor) + + return points + + ### Private Methods ### + + @transaction + def _get_rowid_from_reddit_id(self, table_name, reddit_object): + params = {'table_name': table_name, 'reddit_id': reddit_object.id} + self.cursor.execute('SELECT rowid FROM :table_name WHERE id = :reddit_id', params) + row = self.cursor.fetchone() + return row['rowid'] if row else None + + @transaction + def _add_comment(self, comment, author): + params = { + 'id': comment.id, + 'author_id': author.id, + 'created_at_datetime': reddit_datetime_to_iso(comment.created_utc) + } + insert_stmt = ''' + INSERT INTO comment (id, author_id, created_at_datetime) + VALUES (:id, :author_id, :created_at_datetime) + ''' + self.cursor.execute(insert_stmt, params) + return self.cursor.rowcount + + # @transaction + # def _get_comment_rowid(self, comment): + # self.cursor.execute('SELECT rowid FROM comment WHERE id = :id', {'id': comment.id}) + # row = self.cursor.fetchone() + # return row['rowid'] if row else None + + @transaction + def _add_submission(self, submission): + insert_stmt = ''' + INSERT OR IGNORE INTO submission (id, author_id) + VALUES (:id, :author_id) + ''' + self.cursor.execute(insert_stmt, {'id': submission.id, 'author_id': submission.author.id}) + return self.cursor.rowcount + + # @transaction + # def _get_submission_rowid(self, submission): + # self.cursor.execute('SELECT rowid FROM submission WHERE id = :id', {'id': submission.id}) + # row = self.cursor.fetchone() + # return row['rowid'] if row else None + + @transaction + def _add_solution(self, submission, solver, comment, chosen_by_comment): + # submission_rowid = self._get_submission_rowid(submission) + # comment_rowid = self._get_comment_rowid(comment) + # chosen_by_comment_rowid = self._get_comment_rowid(chosen_by_comment) + submission_rowid = self._get_rowid_from_reddit_id('submission', submission) + author_rowid = self._get_rowid_from_reddit_id('redditor', solver) + comment_rowid = self._get_rowid_from_reddit_id('comment', comment) + chosen_by_comment_rowid = self._get_rowid_from_reddit_id('comment', chosen_by_comment) + params = { + 'submission_rowid': submission_rowid, + 'author_rowid': author_rowid, + 'comment_rowid': comment_rowid, + 'chosen_by_comment_rowid': chosen_by_comment_rowid + } + insert_stmt = ''' + INSERT INTO solution (submission_rowid, author_rowid, comment_rowid, chosen_by_comment_rowid) + VALUES (:submission_rowid, :author_rowid, :comment_rowid, :chosen_by_comment_rowid) + ''' + self.cursor.execute(insert_stmt, params) + return self.cursor.rowcount + + @transaction + # def _soft_remove_solution(self, submission, comment, removed_by_comment): + def _soft_remove_solution(self, submission, solver, removed_by_comment): + # submission_rowid = self._get_submission_rowid(submission) + # comment_rowid = self._get_comment_rowid(comment) + # removed_by_comment_rowid = self._get_comment_rowid(removed_by_comment) + submission_rowid = self._get_rowid_from_reddit_id('submission', submission) + author_rowid = self._get_rowid_from_reddit_id('redditor', solver) + # comment_rowid = self._get_rowid_from_reddit_id('comment', comment) + removed_by_comment_rowid = self._get_rowid_from_reddit_id('comment', removed_by_comment) + params = { + 'submission_rowid': submission_rowid, + # 'comment_rowid': comment_rowid, + 'author_rowid': author_rowid, + 'removed_by_comment_rowid': removed_by_comment_rowid, + } + update_stmt = ''' + UPDATE solution + SET removed_by_comment_rowid = :removed_by_comment_rowid + WHERE submission_rowid = :submission_rowid AND author_rowid = :author_rowid + ''' + self.cursor.execute(update_stmt, params) + return self.cursor.rowcount + + # @transaction + # def _get_redditor_rowid(self, redditor): + # self.cursor.execute('SELECT rowid FROM redditor WHERE id = :id', {'id': redditor.id}) + # row = self.cursor.fetchone() + # return row['rowid'] if row else None @transaction def _update_points(self, redditor, points_modifier): @@ -92,30 +352,17 @@ class Database: 'points': points + points_modifier, } update_stmt = ''' - UPDATE redditor_points + UPDATE redditor SET points = :points WHERE id = :id AND name = :name ''' self.cursor.execute(update_stmt, params) return self.cursor.rowcount - @transaction - def get_points(self, redditor, add_if_none=False): - params = {'id': redditor.id, 'name': redditor.name} - select_stmt = ''' - SELECT points - FROM redditor_points - WHERE id = :id AND name = :name - ''' - self.cursor.execute(select_stmt, params) - row = self.cursor.fetchone() - if row: - points = row['points'] - elif add_if_none: - points = 0 - self.add_redditor(redditor) - else: - points = 0 - return points +### Utility ### + + +def reddit_datetime_to_iso(datetime): + return datetime.datetime.utcfromtimestamp(datetime).isoformat()