| @@ -310,12 +310,22 @@ class DRNClerkBot(Task): | |||||
| return notices | return notices | ||||
| def clerk_new_case(self, case, volunteers, signatures): | def clerk_new_case(self, case, volunteers, signatures): | ||||
| """Clerk a case in the "brand new" state. | |||||
| The case will be set to "open" if a volunteer edits it. | |||||
| """ | |||||
| notices = self.notify_parties(case) | notices = self.notify_parties(case) | ||||
| if any([editor in volunteers for (editor, timestamp) in signatures]): | if any([editor in volunteers for (editor, timestamp) in signatures]): | ||||
| self.update_status(case, self.STATUS_OPEN) | self.update_status(case, self.STATUS_OPEN) | ||||
| return notices | return notices | ||||
| def clerk_open_case(self, case, signatures): | def clerk_open_case(self, case, signatures): | ||||
| """Clerk an open case (has been edited by a reviewer). | |||||
| The case will be set to "needassist" if 15,000 bytes have been added | |||||
| since a volunteer last edited, "stale" if no edits have occured in two | |||||
| days, or "review" if it has been open for over four days. | |||||
| """ | |||||
| if self.check_for_review(case): | if self.check_for_review(case): | ||||
| return [] | return [] | ||||
| if len(case.body) - case.last_volunteer_size > 15000: | if len(case.body) - case.last_volunteer_size > 15000: | ||||
| @@ -327,6 +337,11 @@ class DRNClerkBot(Task): | |||||
| return [] | return [] | ||||
| def clerk_needassist_case(self, case, volunteers, newsigs): | def clerk_needassist_case(self, case, volunteers, newsigs): | ||||
| """Clerk a "needassist" case (no volunteer edits in 15,000 bytes). | |||||
| The case will be set to "open" if a volunteer edits, or "review" if it | |||||
| has been open for over four days. | |||||
| """ | |||||
| if self.check_for_review(case): | if self.check_for_review(case): | ||||
| return [] | return [] | ||||
| if any([editor in volunteers for (editor, timestamp) in newsigs]): | if any([editor in volunteers for (editor, timestamp) in newsigs]): | ||||
| @@ -334,6 +349,11 @@ class DRNClerkBot(Task): | |||||
| return [] | return [] | ||||
| def clerk_stale_case(self, case, newsigs): | def clerk_stale_case(self, case, newsigs): | ||||
| """Clerk a stale case (no edits in two days). | |||||
| The case will be set to "open" if anyone edits, or "review" if it has | |||||
| been open for over four days. | |||||
| """ | |||||
| if self.check_for_review(case): | if self.check_for_review(case): | ||||
| return [] | return [] | ||||
| if newsigs: | if newsigs: | ||||
| @@ -341,6 +361,12 @@ class DRNClerkBot(Task): | |||||
| return [] | return [] | ||||
| def clerk_review_case(self, case): | def clerk_review_case(self, case): | ||||
| """Clerk a "review" case (open for more than four days). | |||||
| A message will be set to the "very old notifiee", which is generally | |||||
| [[User talk:Szhang (WMF)]], if the case has been open for more than | |||||
| seven days. | |||||
| """ | |||||
| age = (datetime.utcnow() - case.file_time).total_seconds() | age = (datetime.utcnow() - case.file_time).total_seconds() | ||||
| if age > 60 * 60 * 24 * 7: | if age > 60 * 60 * 24 * 7: | ||||
| if not case.very_old_notified: | if not case.very_old_notified: | ||||
| @@ -355,6 +381,13 @@ class DRNClerkBot(Task): | |||||
| return [] | return [] | ||||
| def clerk_closed_case(self, case, signatures): | def clerk_closed_case(self, case, signatures): | ||||
| """Clerk a closed or resolved case. | |||||
| The case will be archived if it has been closed/resolved for more than | |||||
| one day and no edits have been made in the meantime. "Archiving" is | |||||
| the process of adding {{DRN archive top}}, {{DRN archive bottom}}, and | |||||
| removing the [[User:DoNotArchiveUntil]] comment. | |||||
| """ | |||||
| if case.close_time == self.min_ts: | if case.close_time == self.min_ts: | ||||
| case.close_time = datetime.utcnow() | case.close_time = datetime.utcnow() | ||||
| timestamps = [timestamp for (editor, timestamp) in signatures] | timestamps = [timestamp for (editor, timestamp) in signatures] | ||||
| @@ -373,18 +406,26 @@ class DRNClerkBot(Task): | |||||
| self.logger.debug(u" {0}: archived case".format(case.id)) | self.logger.debug(u" {0}: archived case".format(case.id)) | ||||
| def clerk_unknown_case(self, conn, case): | def clerk_unknown_case(self, conn, case): | ||||
| """Clerk a case with "unknown" status. | |||||
| This will generally be either closed, archived, and off the page, or | |||||
| with a status we can't identify. We won't do anything to the case other | |||||
| than update it in the database. | |||||
| """ | |||||
| if case.new: | if case.new: | ||||
| self.save_new_case(conn, case) | self.save_new_case(conn, case) | ||||
| else: | else: | ||||
| self.save_existing_case(conn, case) | self.save_existing_case(conn, case) | ||||
| def check_for_review(self, case): | def check_for_review(self, case): | ||||
| """Check whether a case is old enough to be set to "review".""" | |||||
| age = (datetime.utcnow() - case.file_time).total_seconds() | age = (datetime.utcnow() - case.file_time).total_seconds() | ||||
| if age > 60 * 60 * 24 * 4: | if age > 60 * 60 * 24 * 4: | ||||
| return self.update_status(case, self.STATUS_REVIEW) | return self.update_status(case, self.STATUS_REVIEW) | ||||
| return False | return False | ||||
| def update_status(self, case, new): | def update_status(self, case, new): | ||||
| """Safely update the status of a case, so we don't edit war.""" | |||||
| old_n = self.ALIASES[case.status][0].upper() | old_n = self.ALIASES[case.status][0].upper() | ||||
| new_n = self.ALIASES[new][0].upper() | new_n = self.ALIASES[new][0].upper() | ||||
| old_n = "NEW" if not old_n else old_n | old_n = "NEW" if not old_n else old_n | ||||
| @@ -399,6 +440,10 @@ class DRNClerkBot(Task): | |||||
| return False | return False | ||||
| def read_signatures(self, text): | def read_signatures(self, text): | ||||
| """Return a list of all parseable signatures in the body of a case. | |||||
| Signatures are returned as tuples of (editor, timestamp as datetime). | |||||
| """ | |||||
| regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)" | regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)" | ||||
| regex += r"([^\n\[\]|]{,256}?)(?:\||\]\])" | regex += r"([^\n\[\]|]{,256}?)(?:\||\]\])" | ||||
| regex += r"(?!.*?(?:User(?:\stalk)?\:|Special\:Contributions\/).*?)" | regex += r"(?!.*?(?:User(?:\stalk)?\:|Special\:Contributions\/).*?)" | ||||
| @@ -415,12 +460,17 @@ class DRNClerkBot(Task): | |||||
| return signatures | return signatures | ||||
| def get_signatures_from_db(self, conn, case): | def get_signatures_from_db(self, conn, case): | ||||
| """Return a list of signatures in a case from the database. | |||||
| The return type is the same as read_signatures(). | |||||
| """ | |||||
| query = "SELECT signature_username, signature_timestamp FROM signatures WHERE signature_case = ?" | query = "SELECT signature_username, signature_timestamp FROM signatures WHERE signature_case = ?" | ||||
| with conn.cursor() as cursor: | with conn.cursor() as cursor: | ||||
| cursor.execute(query, (case.id,)) | cursor.execute(query, (case.id,)) | ||||
| return cursor.fetchall() | return cursor.fetchall() | ||||
| def notify_parties(self, case): | def notify_parties(self, case): | ||||
| """Schedule notices to be sent to all parties of a case.""" | |||||
| if case.parties_notified: | if case.parties_notified: | ||||
| return [] | return [] | ||||
| @@ -444,13 +494,15 @@ class DRNClerkBot(Task): | |||||
| return notices | return notices | ||||
| def save_case_updates(self, conn, case, volunteers, sigs, storedsigs): | def save_case_updates(self, conn, case, volunteers, sigs, storedsigs): | ||||
| """Save any updates made to a case and signatures in the database.""" | |||||
| if case.status != case.original_status: | if case.status != case.original_status: | ||||
| case.last_action = case.status | |||||
| new = self.ALIASES[case.status][0] | |||||
| tl_status_esc = re.escape(self.tl_status) | |||||
| search = "\{\{" + tl_status_esc + "(\|?.*?)\}\}" | |||||
| repl = "{{" + self.tl_status + "|" + new + "}}" | |||||
| case.body = re.sub(search, repl, case.body) | |||||
| if case.status != self.STATUS_UNKNOWN: | |||||
| case.last_action = case.status | |||||
| new = self.ALIASES[case.status][0] | |||||
| tl_status_esc = re.escape(self.tl_status) | |||||
| search = "\{\{" + tl_status_esc + "(\|?.*?)\}\}" | |||||
| repl = "{{" + self.tl_status + "|" + new + "}}" | |||||
| case.body = re.sub(search, repl, case.body) | |||||
| newest_ts = max([stamp for (user, stamp) in sigs]) | newest_ts = max([stamp for (user, stamp) in sigs]) | ||||
| newest_user = [usr for (usr, stamp) in sigs if stamp == newest_ts][0] | newest_user = [usr for (usr, stamp) in sigs if stamp == newest_ts][0] | ||||
| @@ -485,6 +537,7 @@ class DRNClerkBot(Task): | |||||
| self.logger.debug(log) | self.logger.debug(log) | ||||
| def save_new_case(self, conn, case): | def save_new_case(self, conn, case): | ||||
| """Save a brand new case to the database.""" | |||||
| args = (case.id, case.title, case.status, case.last_action, | args = (case.id, case.title, case.status, case.last_action, | ||||
| case.file_user, case.file_time, case.modify_user, | case.file_user, case.file_time, case.modify_user, | ||||
| case.modify_time, case.volunteer_user, case.volunteer_time, | case.modify_time, case.volunteer_user, case.volunteer_time, | ||||
| @@ -497,6 +550,7 @@ class DRNClerkBot(Task): | |||||
| self.logger.debug(log) | self.logger.debug(log) | ||||
| def save_existing_case(self, conn, case): | def save_existing_case(self, conn, case): | ||||
| """Save an existing case to the database, updating as necessary.""" | |||||
| with conn.cursor(oursql.DictCursor) as cursor: | with conn.cursor(oursql.DictCursor) as cursor: | ||||
| query = "SELECT * FROM cases WHERE case_id = ?" | query = "SELECT * FROM cases WHERE case_id = ?" | ||||
| cursor.execute(query, (case.id,)) | cursor.execute(query, (case.id,)) | ||||
| @@ -593,6 +647,7 @@ class DRNClerkBot(Task): | |||||
| self.logger.debug("Done sending notices") | self.logger.debug("Done sending notices") | ||||
| def update_chart(self, conn, site): | def update_chart(self, conn, site): | ||||
| """Update the chart of open or recently closed cases.""" | |||||
| page = site.get_page(self.chart_title) | page = site.get_page(self.chart_title) | ||||
| self.logger.info(u"Updating case status at [[{0}]]".format(page.title)) | self.logger.info(u"Updating case status at [[{0}]]".format(page.title)) | ||||
| statuses = self.compile_chart(conn) | statuses = self.compile_chart(conn) | ||||
| @@ -611,6 +666,7 @@ class DRNClerkBot(Task): | |||||
| self.logger.info(u"Chart saved to [[{0}]]".format(page.title)) | self.logger.info(u"Chart saved to [[{0}]]".format(page.title)) | ||||
| def compile_chart(self, conn): | def compile_chart(self, conn): | ||||
| """Actually generate the chart from the database.""" | |||||
| chart = "{{" + self.tl_chart_header + "}}\n" | chart = "{{" + self.tl_chart_header + "}}\n" | ||||
| query = "SELECT * FROM cases" | query = "SELECT * FROM cases" | ||||
| with conn.cursor(oursql.DictCursor) as cursor: | with conn.cursor(oursql.DictCursor) as cursor: | ||||
| @@ -622,6 +678,7 @@ class DRNClerkBot(Task): | |||||
| return chart | return chart | ||||
| def compile_row(self, case): | def compile_row(self, case): | ||||
| """Generate a single row of the chart from a dict via the database.""" | |||||
| data = "|t={title}|s={case_status}" | data = "|t={title}|s={case_status}" | ||||
| data += "|cu={case_file_user}|cs={file_sortkey}|ct={file_time}" | data += "|cu={case_file_user}|cs={file_sortkey}|ct={file_time}" | ||||
| if case["case_volunteer_user"]: | if case["case_volunteer_user"]: | ||||
| @@ -644,6 +701,7 @@ class DRNClerkBot(Task): | |||||
| return dt.strftime("%H:%M, %d %b %Y") | return dt.strftime("%H:%M, %d %b %Y") | ||||
| def format_time_since(self, dt): | def format_time_since(self, dt): | ||||
| """Return a string telling the time since datetime occured.""" | |||||
| parts = [("year", 31536000), ("day", 86400), ("hour", 3600)] | parts = [("year", 31536000), ("day", 86400), ("hour", 3600)] | ||||
| seconds = int((datetime.utcnow() - dt).total_seconds()) | seconds = int((datetime.utcnow() - dt).total_seconds()) | ||||
| msg = [] | msg = [] | ||||
| @@ -656,6 +714,7 @@ class DRNClerkBot(Task): | |||||
| return ", ".join(msg) + " ago" if msg else "0 hours ago" | return ", ".join(msg) + " ago" if msg else "0 hours ago" | ||||
| def purge_old_data(self, conn): | def purge_old_data(self, conn): | ||||
| """Delete old cases (> one month) from the database.""" | |||||
| log = "Purging closed cases older than a month from the database" | log = "Purging closed cases older than a month from the database" | ||||
| self.logger.info(log) | self.logger.info(log) | ||||
| query = """DELETE cases, signatures | query = """DELETE cases, signatures | ||||