| @@ -54,19 +54,32 @@ class Task(BaseTask): | |||||
| self.db_access_lock = Lock() | self.db_access_lock = Lock() | ||||
| def run(self, **kwargs): | def run(self, **kwargs): | ||||
| """Entry point for a task event. | |||||
| Depending on the kwargs passed, we will either synchronize our local | |||||
| statistics database with the site (self.sync()) or save it to the wiki | |||||
| (self.save()). We will additionally create an SQL connection with our | |||||
| local database. | |||||
| """ | |||||
| self.site = wiki.get_site() | self.site = wiki.get_site() | ||||
| self.conn = oursql.connect(**self.conn_data) | self.conn = oursql.connect(**self.conn_data) | ||||
| action = kwargs.get("action") | action = kwargs.get("action") | ||||
| try: | try: | ||||
| if action == "save": | if action == "save": | ||||
| self.save() | |||||
| self.save(**kwargs) | |||||
| elif action == "sync": | elif action == "sync": | ||||
| self.sync() | |||||
| self.sync(**kwargs) | |||||
| finally: | finally: | ||||
| self.conn.close() | self.conn.close() | ||||
| def save(self, **kwargs): | def save(self, **kwargs): | ||||
| """Save our local statistics to the wiki. | |||||
| After checking for emergency shutoff, the statistics chart is compiled, | |||||
| and then saved to self.pagename using self.summary iff it has changed | |||||
| since last save. | |||||
| """ | |||||
| self.logger.info("Saving chart") | self.logger.info("Saving chart") | ||||
| if kwargs.get("fromIRC"): | if kwargs.get("fromIRC"): | ||||
| summary = " ".join((self.summary, "(!earwigbot)")) | summary = " ".join((self.summary, "(!earwigbot)")) | ||||
| @@ -92,6 +105,7 @@ class Task(BaseTask): | |||||
| self.logger.info("Chart saved to [[{0}]]".format(page.title())) | self.logger.info("Chart saved to [[{0}]]".format(page.title())) | ||||
| def compile_charts(self): | def compile_charts(self): | ||||
| """Compile and return all statistics information from our local db.""" | |||||
| stats = "" | stats = "" | ||||
| with self.conn.cursor() as cursor, self.db_access_lock: | with self.conn.cursor() as cursor, self.db_access_lock: | ||||
| cursor.execute("SELECT * FROM chart") | cursor.execute("SELECT * FROM chart") | ||||
| @@ -100,6 +114,7 @@ class Task(BaseTask): | |||||
| return stats[:-1] # Drop the last newline | return stats[:-1] # Drop the last newline | ||||
| def compile_chart(self, chart_info): | def compile_chart(self, chart_info): | ||||
| """Compile and return a single statistics chart.""" | |||||
| chart_id, chart_title, special_title = chart_info | chart_id, chart_title, special_title = chart_info | ||||
| chart = "|".join((self.tl_header, chart_title)) | chart = "|".join((self.tl_header, chart_title)) | ||||
| @@ -117,6 +132,11 @@ class Task(BaseTask): | |||||
| return chart | return chart | ||||
| def compile_chart_row(self, page): | def compile_chart_row(self, page): | ||||
| """Compile and return a single chart row. | |||||
| 'page' is a dict of page information, taken as a row from the page | |||||
| table, where keys are column names and values are their cell contents. | |||||
| """ | |||||
| row = "{0}|s={page_status}|t={page_title}|h={page_short}|z={page_size}|" | row = "{0}|s={page_status}|t={page_title}|h={page_short}|z={page_size}|" | ||||
| row += "cr={page_create_user}|cd={page_create_time}|ci={page_create_oldid}|" | row += "cr={page_create_user}|cd={page_create_time}|ci={page_create_oldid}|" | ||||
| row += "mr={page_modify_user}|md={page_modify_time}|mi={page_modify_oldid}|" | row += "mr={page_modify_user}|md={page_modify_time}|mi={page_modify_oldid}|" | ||||
| @@ -134,14 +154,27 @@ class Task(BaseTask): | |||||
| return "".join(("{{", row.format(self.tl_row, **page), "}}")) | return "".join(("{{", row.format(self.tl_row, **page), "}}")) | ||||
| def format_time(self, timestamp): | def format_time(self, timestamp): | ||||
| """Format a datetime into the standard MediaWiki timestamp format.""" | |||||
| return timestamp.strftime("%H:%M, %d %B %Y") | return timestamp.strftime("%H:%M, %d %B %Y") | ||||
| def sync(self, **kwargs): | def sync(self, **kwargs): | ||||
| """Synchronize our local statistics database with the site. | |||||
| Syncing involves, in order, updating tracked submissions that have | |||||
| been changed since last sync (self.update_tracked()), adding pending | |||||
| submissions that are not tracked (self.add_untracked()), and removing | |||||
| old submissions from the database (self.delete_old()). | |||||
| The sync will be canceled if SQL replication lag is greater than 600 | |||||
| seconds, because this will lead to potential problems and outdated | |||||
| data, not to mention putting demand on an already overloaded server. | |||||
| Giving sync the kwarg "ignore_replag" will go around this restriction. | |||||
| """ | |||||
| self.logger.info("Starting sync") | self.logger.info("Starting sync") | ||||
| replag = self.site.get_replag() | replag = self.site.get_replag() | ||||
| self.logger.debug("Server replag is {0}".format(replag)) | self.logger.debug("Server replag is {0}".format(replag)) | ||||
| if replag > 600: | |||||
| if replag > 600 and not kwargs.get("ignore_replag"): | |||||
| msg = "Sync canceled as replag ({0} secs) is greater than ten minutes." | msg = "Sync canceled as replag ({0} secs) is greater than ten minutes." | ||||
| self.logger.warn(msg.format(replag)) | self.logger.warn(msg.format(replag)) | ||||
| @@ -153,6 +186,16 @@ class Task(BaseTask): | |||||
| self.logger.info("Sync completed") | self.logger.info("Sync completed") | ||||
| def update_tracked(self, cursor): | def update_tracked(self, cursor): | ||||
| """Update tracked submissions that have been changed since last sync. | |||||
| This is done by iterating through every page in our database and | |||||
| comparing our stored latest revision ID with the actual latest revision | |||||
| ID from an SQL query. If they differ, we will update our information | |||||
| about the page (self.update_page()). | |||||
| If the page does not exist, we will remove it from our database with | |||||
| self.untrack_page(). | |||||
| """ | |||||
| self.logger.debug("Updating tracked submissions") | self.logger.debug("Updating tracked submissions") | ||||
| query1 = "SELECT page_id, page_title, page_modify_oldid FROM page" | query1 = "SELECT page_id, page_title, page_modify_oldid FROM page" | ||||
| query2 = """SELECT page_latest, page_title, page_namespace FROM page | query2 = """SELECT page_latest, page_title, page_namespace FROM page | ||||
| @@ -174,6 +217,13 @@ class Task(BaseTask): | |||||
| self.update_page(cursor, pageid, real_title) | self.update_page(cursor, pageid, real_title) | ||||
| def add_untracked(self, cursor): | def add_untracked(self, cursor): | ||||
| """Add pending submissions that are not yet tracked. | |||||
| This is done by compiling a list of all currently tracked submissions | |||||
| and iterating through all members of self.pending_cat via SQL. If a | |||||
| page in the pending category is not tracked and is not in | |||||
| self.ignore_list, we will track it with self.track_page(). | |||||
| """ | |||||
| self.logger.debug("Adding untracked pending submissions") | self.logger.debug("Adding untracked pending submissions") | ||||
| cursor.execute("SELECT page_id FROM page") | cursor.execute("SELECT page_id FROM page") | ||||
| tracked = [i[0] for i in cursor.fetchall()] | tracked = [i[0] for i in cursor.fetchall()] | ||||
| @@ -188,20 +238,31 @@ class Task(BaseTask): | |||||
| self.track_page(cursor, pageid, title) | self.track_page(cursor, pageid, title) | ||||
| def delete_old(self, cursor): | def delete_old(self, cursor): | ||||
| """Remove old submissions from the database. | |||||
| "Old" is defined as a submission that has been declined or accepted | |||||
| more than 36 hours ago. Pending submissions cannot be "old". | |||||
| """ | |||||
| self.logger.debug("Removing old submissions from chart") | self.logger.debug("Removing old submissions from chart") | ||||
| query = """DELETE FROM page, row USING page JOIN row | query = """DELETE FROM page, row USING page JOIN row | ||||
| ON page_id = row_id WHERE row_chart IN ? | ON page_id = row_id WHERE row_chart IN ? | ||||
| AND ADDTIME(page_special_time, '36:00:00') < NOW()""" | AND ADDTIME(page_special_time, '36:00:00') < NOW()""" | ||||
| cursor.execute(query, ((CHART_ACCEPT, CHART_DECLINE),)) | |||||
| old_charts = (CHART_ACCEPT, CHART_DECLINE) | |||||
| cursor.execute(query, (old_charts,)) | |||||
| def untrack_page(self, cursor, pageid): | def untrack_page(self, cursor, pageid): | ||||
| """Remove a page, given by ID, from our database.""" | |||||
| self.logger.debug("Untracking page (id: {0})".format(pageid)) | self.logger.debug("Untracking page (id: {0})".format(pageid)) | ||||
| query = """DELETE FROM page, row USING page JOIN row | query = """DELETE FROM page, row USING page JOIN row | ||||
| ON page_id = row_id WHERE page_id = ?""" | ON page_id = row_id WHERE page_id = ?""" | ||||
| cursor.execute(query, (pageid,)) | cursor.execute(query, (pageid,)) | ||||
| def track_page(self, cursor, pageid, title): | def track_page(self, cursor, pageid, title): | ||||
| """Update hook for when page is not in our database.""" | |||||
| """Update hook for when page is not in our database. | |||||
| A variety of SQL queries are used to gather information about the page, | |||||
| which are then saved to our database. | |||||
| """ | |||||
| msg = "Tracking page [[{0}]] (id: {1})".format(title, pageid) | msg = "Tracking page [[{0}]] (id: {1})".format(title, pageid) | ||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| @@ -227,7 +288,20 @@ class Task(BaseTask): | |||||
| s_user, s_time, s_id),)) | s_user, s_time, s_id),)) | ||||
| def update_page(self, cursor, pageid, title): | def update_page(self, cursor, pageid, title): | ||||
| """Update hook for when page is in our database.""" | |||||
| """Update hook for when page is already in our database. | |||||
| A variety of SQL queries are used to gather information about the page, | |||||
| which is compared against our stored information. Differing information | |||||
| is then updated. | |||||
| If our page is now a redirect, we will determine the namespace it was | |||||
| moved to. If it was moved to the mainspace or template space, we will | |||||
| set the sub's status as accepted. If it was to the Project: or Project | |||||
| talk: namespaces, we'll merely update our stored title (this is likely | |||||
| to occur if a submission was moved from the userspace to the project | |||||
| space). If it was moved to another namespace, something unusual has | |||||
| happened, and we'll untrack the submission. | |||||
| """ | |||||
| msg = "Updating page [[{0}]] (id: {1})".format(title, pageid) | msg = "Updating page [[{0}]] (id: {1})".format(title, pageid) | ||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| @@ -236,24 +310,27 @@ class Task(BaseTask): | |||||
| redirect_regex = wiki.Page.re_redirect | redirect_regex = wiki.Page.re_redirect | ||||
| target_title = re.findall(redirect_regex, content, flags=re.I)[0] | target_title = re.findall(redirect_regex, content, flags=re.I)[0] | ||||
| except IndexError: | except IndexError: | ||||
| pass | |||||
| status, chart = self.get_status_and_chart(content) | |||||
| if not status: | |||||
| self.untrack_page(cursor, pageid) | |||||
| return | |||||
| else: | else: | ||||
| target_ns = self.site.get_page(target_title).namespace() | target_ns = self.site.get_page(target_title).namespace() | ||||
| if target_ns == wiki.NS_MAIN: | |||||
| if target_ns in [wiki.NS_MAIN, wiki.NS_TEMPLATE]: | |||||
| status, chart = "accept", CHART_ACCEPT | status, chart = "accept", CHART_ACCEPT | ||||
| elif target_ns in [wiki.NS_PROJECT, wiki.NS_PROJECT_TALK]: | elif target_ns in [wiki.NS_PROJECT, wiki.NS_PROJECT_TALK]: | ||||
| title = target_title | title = target_title | ||||
| content = self.get_content(title) | content = self.get_content(title) | ||||
| status, chart = self.get_status_and_chart(content) | |||||
| if not status: | |||||
| self.untrack_page(cursor, pageid) | |||||
| return | |||||
| else: | else: | ||||
| msg = "Page has moved to namespace {0}".format(target_ns) | msg = "Page has moved to namespace {0}".format(target_ns) | ||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| self.untrack_page(cursor, pageid) | self.untrack_page(cursor, pageid) | ||||
| return | return | ||||
| status, chart = self.get_status_and_chart(content) | |||||
| if not status: | |||||
| self.untrack_page(cursor, pageid) | |||||
| size = len(content) | size = len(content) | ||||
| notes = self.get_notes(pageid) | notes = self.get_notes(pageid) | ||||
| m_user, m_time, m_id = self.get_modify(pageid) | m_user, m_time, m_id = self.get_modify(pageid) | ||||
| @@ -270,12 +347,13 @@ class Task(BaseTask): | |||||
| self.update_page_modify(cursor, result, pageid, size, m_user, m_time, m_id) | self.update_page_modify(cursor, result, pageid, size, m_user, m_time, m_id) | ||||
| if status != result["page_status"]: | if status != result["page_status"]: | ||||
| self.update_page_special(cursor, result, pageid, status, chart, page) | |||||
| self.update_page_status(cursor, result, pageid, status, chart, page) | |||||
| if notes != result["page_notes"]: | if notes != result["page_notes"]: | ||||
| self.update_page_notes(cursor, result, pageid, notes) | self.update_page_notes(cursor, result, pageid, notes) | ||||
| def update_page_title(self, cursor, result, pageid, title): | def update_page_title(self, cursor, result, pageid, title): | ||||
| """Update the title and short_title of a page in our database.""" | |||||
| query = "UPDATE page SET page_title = ?, page_short = ? WHERE page_id = ?" | query = "UPDATE page SET page_title = ?, page_short = ? WHERE page_id = ?" | ||||
| short = self.get_short_title(title) | short = self.get_short_title(title) | ||||
| cursor.execute(query, (title, short, pageid)) | cursor.execute(query, (title, short, pageid)) | ||||
| @@ -283,6 +361,7 @@ class Task(BaseTask): | |||||
| self.logger.debug(msg.format(pageid, result["page_title"], title)) | self.logger.debug(msg.format(pageid, result["page_title"], title)) | ||||
| def update_page_modify(self, cursor, result, pageid, size, m_user, m_time, m_id): | def update_page_modify(self, cursor, result, pageid, size, m_user, m_time, m_id): | ||||
| """Update the last modified information of a page in our database.""" | |||||
| query = """UPDATE page SET page_size = ?, page_modify_user = ?, | query = """UPDATE page SET page_size = ?, page_modify_user = ?, | ||||
| page_modify_time = ?, page_modify_oldid = ? | page_modify_time = ?, page_modify_oldid = ? | ||||
| WHERE page_id = ?""" | WHERE page_id = ?""" | ||||
| @@ -294,7 +373,8 @@ class Task(BaseTask): | |||||
| result["page_modify_oldid"], m_user, m_time, m_id) | result["page_modify_oldid"], m_user, m_time, m_id) | ||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| def update_page_special(self, cursor, result, pageid, status, chart, page): | |||||
| def update_page_status(self, cursor, result, pageid, status, chart, page): | |||||
| """Update the status and "specialed" information of a page.""" | |||||
| query1 = """UPDATE page JOIN row ON page_id = row_id | query1 = """UPDATE page JOIN row ON page_id = row_id | ||||
| SET page_status = ?, row_chart = ? WHERE page_id = ?""" | SET page_status = ?, row_chart = ? WHERE page_id = ?""" | ||||
| query2 = """UPDATE page SET page_special_user = ?, | query2 = """UPDATE page SET page_special_user = ?, | ||||
| @@ -317,12 +397,24 @@ class Task(BaseTask): | |||||
| self.logger.debug(msg) | self.logger.debug(msg) | ||||
| def update_page_notes(self, cursor, result, pageid, notes): | def update_page_notes(self, cursor, result, pageid, notes): | ||||
| """Update the notes (or warnings) of a page in our database.""" | |||||
| query = "UPDATE page SET page_notes = ? WHERE page_id = ?" | query = "UPDATE page SET page_notes = ? WHERE page_id = ?" | ||||
| cursor.execute(query, (notes, pageid)) | cursor.execute(query, (notes, pageid)) | ||||
| msg = "{0}: notes: {1} -> {2}" | msg = "{0}: notes: {1} -> {2}" | ||||
| self.logger.debug(msg.format(pageid, result["page_notes"], notes)) | self.logger.debug(msg.format(pageid, result["page_notes"], notes)) | ||||
| def get_content(self, title): | def get_content(self, title): | ||||
| """Get the current content of a page by title from SQL. | |||||
| The page's current revision ID is retrieved from SQL, and then | |||||
| site.get_revid_content() is called. | |||||
| The reason a more conventional method (i.e. site.get_page.get()) is | |||||
| avoided is that due to replication lag, a discrepancy between the live | |||||
| database (which the API uses) and the replicated database (which SQL | |||||
| uses) can lead to incorrect and very confusing data, such as missing | |||||
| pages that are supposed to exist, if both are used interchangeably. | |||||
| """ | |||||
| query = "SELECT page_latest FROM page WHERE page_title = ? AND page_namespace = ?" | query = "SELECT page_latest FROM page WHERE page_title = ? AND page_namespace = ?" | ||||
| namespace, base = title.split(":", 1) | namespace, base = title.split(":", 1) | ||||
| try: | try: | ||||
| @@ -336,6 +428,15 @@ class Task(BaseTask): | |||||
| return self.site.get_revid_content(revid) | return self.site.get_revid_content(revid) | ||||
| def get_status_and_chart(self, content): | def get_status_and_chart(self, content): | ||||
| """Determine the status and chart number of an AFC submission. | |||||
| The methodology used here is the same one I've been using for years | |||||
| (see also commands.afc_report), but with the new draft system taken | |||||
| into account. The order here is important: if there is more than one | |||||
| {{AFC submission}} template on a page, we need to know which one to | |||||
| use (revision history search to find the most recent isn't a viable | |||||
| idea :P). | |||||
| """ | |||||
| if re.search("\{\{afc submission\|r\|(.*?)\}\}", content, re.I): | if re.search("\{\{afc submission\|r\|(.*?)\}\}", content, re.I): | ||||
| return "review", CHART_REVIEW | return "review", CHART_REVIEW | ||||
| elif re.search("\{\{afc submission\|h\|(.*?)\}\}", content, re.I): | elif re.search("\{\{afc submission\|h\|(.*?)\}\}", content, re.I): | ||||
| @@ -349,12 +450,23 @@ class Task(BaseTask): | |||||
| return None, CHART_NONE | return None, CHART_NONE | ||||
| def get_short_title(self, title): | def get_short_title(self, title): | ||||
| """Shorten a title so we can display it in a chart using less space. | |||||
| Basically, this just means removing the "Wikipedia talk:Articles for | |||||
| creation" part from the beginning. If it is longer than 50 characters, | |||||
| we'll shorten it down to 47 and add an poor-man's ellipsis at the end. | |||||
| """ | |||||
| short = re.sub("Wikipedia(\s*talk)?\:Articles\sfor\screation\/", "", title) | short = re.sub("Wikipedia(\s*talk)?\:Articles\sfor\screation\/", "", title) | ||||
| if len(short) > 50: | if len(short) > 50: | ||||
| short = "".join((short[:47], "...")) | short = "".join((short[:47], "...")) | ||||
| return short | return short | ||||
| def get_create(self, pageid): | def get_create(self, pageid): | ||||
| """Return information about a page's first edit ("creation"). | |||||
| This consists of the page creator, creation time, and the earliest | |||||
| revision ID. | |||||
| """ | |||||
| query = """SELECT rev_user_text, rev_timestamp, rev_id | query = """SELECT rev_user_text, rev_timestamp, rev_id | ||||
| FROM revision WHERE rev_id = | FROM revision WHERE rev_id = | ||||
| (SELECT MIN(rev_id) FROM revision WHERE rev_page = ?)""" | (SELECT MIN(rev_id) FROM revision WHERE rev_page = ?)""" | ||||
| @@ -363,6 +475,11 @@ class Task(BaseTask): | |||||
| return c_user, datetime.strptime(c_time, "%Y%m%d%H%M%S"), c_id | return c_user, datetime.strptime(c_time, "%Y%m%d%H%M%S"), c_id | ||||
| def get_modify(self, pageid): | def get_modify(self, pageid): | ||||
| """Return information about a page's last edit ("modification"). | |||||
| This consists of the most recent editor, modification time, and the | |||||
| lastest revision ID. | |||||
| """ | |||||
| query = """SELECT rev_user_text, rev_timestamp, rev_id FROM revision | query = """SELECT rev_user_text, rev_timestamp, rev_id FROM revision | ||||
| JOIN page ON rev_id = page_latest WHERE page_id = ?""" | JOIN page ON rev_id = page_latest WHERE page_id = ?""" | ||||
| result = self.site.sql_query(query, (pageid,)) | result = self.site.sql_query(query, (pageid,)) | ||||
| @@ -370,7 +487,21 @@ class Task(BaseTask): | |||||
| return m_user, datetime.strptime(m_time, "%Y%m%d%H%M%S"), m_id | return m_user, datetime.strptime(m_time, "%Y%m%d%H%M%S"), m_id | ||||
| def get_special(self, pageid, chart): | def get_special(self, pageid, chart): | ||||
| if chart == CHART_PEND: | |||||
| """Return information about a page's "special" edit. | |||||
| I tend to use the term "special" as a verb a lot, which is bound to | |||||
| cause confusion. It is merely a short way of saying "the edit in which | |||||
| a declined submission was declined, an accepted submission was | |||||
| accepted, a submission in review was set as such, and a pending draft | |||||
| was submitted." | |||||
| This "information" consists of the special edit's editor, its time, and | |||||
| its revision ID. If the page's status is not something that involves | |||||
| "special"-ing, we will return None for all three. The same will be | |||||
| returned if we cannot determine when the page was "special"-ed, or if | |||||
| it was "special"-ed more than 100 edits ago. | |||||
| """ | |||||
| if chart in [CHART_NONE, CHART_PEND]: | |||||
| return None, None, None | return None, None, None | ||||
| elif chart == CHART_ACCEPT: | elif chart == CHART_ACCEPT: | ||||
| return self.get_create(pageid) | return self.get_create(pageid) | ||||
| @@ -397,4 +528,8 @@ class Task(BaseTask): | |||||
| return None, None, None | return None, None, None | ||||
| def get_notes(self, pageid): | def get_notes(self, pageid): | ||||
| """Return any special notes or warnings about this page. | |||||
| Currently unimplemented, so always returns None. | |||||
| """ | |||||
| return None | return None | ||||