|
- # -*- coding: utf-8 -*-
- #
- # Copyright (C) 2009-2012 Ben Kurtovic <ben.kurtovic@verizon.net>
- #
- # Permission is hereby granted, free of charge, to any person obtaining a copy
- # of this software and associated documentation files (the "Software"), to deal
- # in the Software without restriction, including without limitation the rights
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- # copies of the Software, and to permit persons to whom the Software is
- # furnished to do so, subject to the following conditions:
- #
- # The above copyright notice and this permission notice shall be included in
- # all copies or substantial portions of the Software.
- #
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- # SOFTWARE.
-
- from datetime import datetime
- from os import expanduser
- import re
- from threading import RLock
- from time import sleep, time
-
- import oursql
-
- from earwigbot import exceptions
- from earwigbot.tasks import Task
-
- class DRNClerkBot(Task):
- """A task to clerk for [[WP:DRN]]."""
- name = "drn_clerkbot"
- number = 19
-
- # Case status:
- STATUS_UNKNOWN = 0
- STATUS_NEW = 1
- STATUS_OPEN = 2
- STATUS_STALE = 3
- STATUS_NEEDASSIST = 4
- STATUS_REVIEW = 5
- STATUS_RESOLVED = 6
- STATUS_CLOSED = 7
- STATUS_ARCHIVE = 8
-
- ALIASES = {
- STATUS_NEW: ("",),
- STATUS_OPEN: ("open", "active", "inprogress"),
- STATUS_STALE: ("stale",),
- STATUS_NEEDASSIST: ("needassist", "relist", "relisted"),
- STATUS_REVIEW: ("review",),
- STATUS_RESOLVED: ("resolved", "resolve"),
- STATUS_CLOSED: ("closed", "close"),
- }
-
- def setup(self):
- """Hook called immediately after the task is loaded."""
- cfg = self.config.tasks.get(self.name, {})
-
- # Set some wiki-related attributes:
- self.title = cfg.get("title", "Wikipedia:Dispute resolution noticeboard")
- self.talk = cfg.get("talk", "Wikipedia talk:Dispute resolution noticeboard")
- self.volunteer_title = cfg.get("volunteers", "Wikipedia:Dispute resolution noticeboard/Volunteering")
- self.very_old_title = cfg.get("veryOldTitle", "User talk:Szhang (WMF)")
- default_summary = "Updating $3 cases for the [[WP:DRN|dispute resolution noticeboard]]."
- self.summary = self.make_summary(cfg.get("summary", default_summary))
-
- # Templates used:
- templates = cfg.get("templates", {})
- self.tl_status = templates.get("status", "DR case status")
- self.tl_notify_party = templates.get("notifyParty", "DRN-notice")
- self.tl_notify_stale = templates.get("notifyStale", "DRN stale notice")
- self.tl_archive_top = templates.get("archiveTop", "DRN archive top")
- self.tl_archive_bottom = templates.get("archiveBottom",
- "DRN archive bottom")
-
- # Connection data for our SQL database:
- kwargs = cfg.get("sql", {})
- kwargs["read_default_file"] = expanduser("~/.my.cnf")
- self.conn_data = kwargs
- self.db_access_lock = RLock()
-
- def run(self, **kwargs):
- """Entry point for a task event."""
- if not self.db_access_lock.acquire(False): # Non-blocking
- self.logger.info("A job is already ongoing; aborting")
- return
- action = kwargs.get("action", "all")
- try:
- start = time()
- conn = oursql.connect(**self.conn_data)
- site = self.bot.wiki.get_site()
- if action in ["all", "update_volunteers"]:
- self.update_volunteers(conn, site)
- if action in ["all", "clerk"]:
- log = u"Starting update to [[{0}]]".format(self.title)
- self.logger.info(log)
- cases = self.read_database(conn)
- page = site.get_page(self.title)
- text = page.get()
- self.read_page(conn, cases, text)
- noticies = self.clerk(conn, cases)
- self.save(page, cases, kwargs)
- self.send_notices(site, notices)
- finally:
- self.db_access_lock.release()
-
- def update_volunteers(self, conn, site):
- """Updates and stores the list of dispute resolution volunteers."""
- log = u"Updating volunteer list from [[{0}]]"
- self.logger.info(log.format(self.volunteer_title))
- page = site.get_page(self.volunteer_title)
- try:
- text = page.get()
- except exceptions.PageNotFoundError:
- text = ""
- marker = "<!-- please don't remove this comment (used by EarwigBot) -->"
- if marker not in text:
- log = u"The marker ({0}) wasn't found in the volunteer list at [[{1}]]!"
- self.logger.error(log.format(marker, page.title))
- text = text.split(marker)[1]
- additions = set()
- for line in text.splitlines():
- user = re.search("\# \{\{User\|(.*?)\}\}", line)
- if user:
- additions.add((user.group(1)))
-
- removals = set()
- query1 = "SELECT volunteer_username FROM volunteer"
- query2 = "DELETE FROM volunteer WHERE volunteer_username = ?"
- query3 = "INSERT INTO volunteer VALUES (?)"
- with conn.cursor() as cursor:
- cursor.execute(query1)
- for row in cursor:
- if row in additions:
- additions.remove(row)
- else:
- removals.add(row)
- if removals:
- cursor.executemany(query2, removals)
- if additions:
- cursor.executemany(query3, additions)
-
- def read_database(self, conn):
- """Return a list of _Cases from the database."""
- cases = []
- with conn.cursor() as cursor:
- cursor.execute("SELECT * FROM case")
- for row in cursor:
- case = _Case(*row)
- cases.append(case)
- return cases
-
- def read_page(self, conn, cases, text):
- """Read the noticeboard content and update the list of _Cases."""
- tl_status_esc = re.escape(self.tl_status)
- split = re.split("(^==\s*[^=]+?\s*==$)", text, flags=re.M|re.U)
- for i in xrange(len(split)):
- if i + 1 == len(split):
- break
- if not split[i].startswith("=="):
- continue
- title = split[i][2:-2].strip()
- body = old = split[i + 1]
- if not re.search("\s*\{\{" + tl_status_esc, body, re.U):
- continue
- status = self.read_status(body)
- re_id = "<!-- Bot Case ID \(please don't modify\): (.*?) -->"
- try:
- id_ = re.search(re_id, body).group(1)
- case = [case for case in cases if case.id == id_][0]
- except (AttributeError, IndexError):
- id_ = self.select_next_id(conn, "case_id", "case")
- re_id2 = "(\{\{" + tl_status_esc
- re_id2 += "(.*?)\}\})(<!-- Bot Case ID \(please don't modify\): .*? -->)?"
- repl = ur"\1 <!-- Bot Case ID (please don't modify): {0} -->"
- body = re.sub(re_id2, repl.format(id_), body)
- case = _Case(id_, title, status, self.STATUS_UNKNOWN, time(),
- 0, False, False, 0, new=True)
- cases.append(case)
- else:
- case.status = status
- if case.title != title:
- self.update_case_title(conn, id_, title)
- case.title = title
- case.body, case.old = body, old
-
- def select_next_id(self, conn, column, table):
- """Return the next incremental ID for a case."""
- query = "SELECT MAX(?) FROM ?"
- with conn.cursor() as cursor:
- cursor.execute(query, (column, table))
- current = cursor.fetchone()[0]
- if current:
- return current + 1
- return 1
-
- def read_status(self, body):
- """Parse the current status from a case body."""
- templ = re.escape(self.tl_status)
- status = re.search("\{\{" + templ + "\|?(.*?)\}\}", body, re.S|re.U)
- if not status:
- return self.STATUS_UNKNOWN
- for option, names in self.ALIASES.iteritems():
- if status.group(1).lower() in names:
- return option
- return self.STATUS_UNKNOWN
-
- def update_case_title(self, conn, id_, title):
- """Update a case title in the database."""
- query = "UPDATE case SET case_title = ? WHERE case_id = ?"
- with conn.cursor() as cursor:
- cursor.execute(query, (title, id_))
-
- def clerk(self, conn, cases):
- """Actually go through cases and modify those to be updated."""
- query = "SELECT volunteer_username FROM volunteer"
- with conn.cursor() as cursor:
- cursor.execute(query)
- volunteers = [name for (name,) in cursor.fetchall()]
- notices = []
- for case in cases:
- notices += self.clerk_case(conn, case, volunteers)
- return notices
-
- def clerk_case(self, conn, case, volunteers):
- """Clerk a particular case and return a list of any notices to send."""
- notices = []
- signatures = self.read_signatures(case.body)
- storedsigs = self.get_signatures_from_db(conn, case)
- newsigs = set(signatures) - set(storedsigs)
- if any([editor in volunteers for (editor, timestamp) in newsigs]):
- case.last_volunteer_size = len(case.body)
-
- if case.status == self.STATUS_NEW:
- notices = self.clerk_new_case(case, volunteers, signatures)
- elif case.status == self.STATUS_OPEN:
- notices = self.clerk_open_case(case, signatures)
- elif case.status == self.STATUS_NEEDASSIST:
- notices = self.clerk_needassist_case(case, volunteers, newsigs)
- elif case.status == self.STATUS_STALE:
- notices = self.clerk_stale_case(case, newsigs)
- elif case.status == self.STATUS_REVIEW:
- notices = self.clerk_review_case(case)
- elif case.status in [self.STATUS_RESOLVED, self.STATUS_CLOSED]:
- self.clerk_closed_case(conn, signatures)
- else:
- log = u"Unsure of how to deal with case {0} (title: {1})"
- self.logger.error(log.format(case.id, case.title))
- return notices
- self.save_case_updates(conn, case, signatures, storedsigs)
- return notices
-
- def clerk_new_case(self, case, volunteers, signatures):
- notices = self.notify_parties(case)
- if any([editor in volunteers for (editor, timestamp) in signatures]):
- if case.last_action != self.STATUS_OPEN:
- case.status = self.STATUS_OPEN
- return notices
-
- def clerk_open_case(self, case, signatures):
- self.check_for_review(case):
- if len(case.body) - case.last_volunteer_size > 15000:
- if case.last_action != self.STATUS_NEEDASSIST:
- case.status = self.STATUS_NEEDASSIST
- timestamps = [timestamp for (editor, timestamp) in signatures]
- if time() - max(timestamps) > 60 * 60 * 24 * 2:
- if case.last_action != self.STATUS_STALE:
- case.status = self.STATUS_STALE
- return []
-
- def clerk_needassist_case(self, case, volunteers, newsigs):
- self.check_for_review(case):
- if any([editor in volunteers for (editor, timestamp) in newsigs]):
- if case.last_action != self.STATUS_OPEN:
- case.status = self.STATUS_OPEN
- return []
-
- def clerk_stale_case(self, case, newsigs):
- self.check_for_review(case):
- if newsigs:
- if case.last_action != self.STATUS_OPEN:
- case.status = self.STATUS_OPEN
- return []
-
- def clerk_review_case(self, case):
- if time() - case.file_time > 60 * 60 * 24 * 7:
- if not case.very_old_notified:
- template = "{{subst:" + self.tl_notify_stale
- template += case.title.replace("|", "|") + "}}"
- notice = _Notice(self.very_old_title, template)
- case.very_old_notified = True
- return [notice]
- return []
-
- def clerk_closed_case(self, case, signatures):
- if not case.close_time:
- case.close_time = time()
- timestamps = [timestamp for (editor, timestamp) in signatures]
- closed_long_ago = time() - case.close_time > 60 * 60 * 24
- modified_long_ago = time() - max(timestamps) > 60 * 60 * 24
- if closed_long_ago and modified_long_ago:
- case.status = self.STATUS_ARCHIVE
- case.body = "{{" + self.tl_archive_top + "}}\n" + case.body
- case.body += "\n{{" + self.tl_archive_bottom + "}}"
- reg = "<!-- \[\[User:DoNotArchiveUntil\]\] .*? -->(<!-- .*? -->)?"
- case.body = re.sub(reg, "", case.body)
-
- def check_for_review(self, case):
- if time() - case.file_time > 60 * 60 * 24 * 4:
- if case.last_action != self.STATUS_REVIEW:
- case.status = self.STATUS_REVIEW
-
- def read_signatures(self, text):
- regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)(.*?)(?:\||\]\]).{,256}?(\d{2}:\d{2},\s\d{2}\s\w+\s\d{4}\s\(UTC\))"
- matches = re.findall(regex, text, re.U)
- signatures = []
- for userlink, stamp in matches:
- username = userlink.split("/", 1)[0].replace("_", " ")
- timestamp = datetime.strptime("%H:%M, %d %B %Y (UTC)", stamp)
- signatures.append((username, timestamp))
- return signatures
-
- def get_signatures_from_db(self, conn, case):
- query = "SELECT signature_username, signature_timestamp FROM signature WHERE signature_case = ?"
- with conn.cursor() as cursor:
- cursor.execute(query, (case.id,))
- return cursor.fetchall()
-
- def notify_parties(self, case):
- if case.parties_notified:
- return []
-
- notices = []
- template = "{{subst:" + self.tl_notify_party
- template += "|thread=" + case.title + "}} ~~~~"
- too_late = "<!--Template:DRN-notice-->"
-
- re_parties = "<span.*?>'''Users involved'''</span>(.*?)<span.*?>"
- text = re.search(re_parties, case, re.S|re.U)
- for line in text.group(1).splitlines():
- user = re.search("\# \{\{User\|(.*?)\}\}", line)
- if user:
- party = user.group(1).strip()
- notice = _Notice("User talk:" + party, template, too_late)
-
- case.parties_notified = True
- return notices
-
- def save_case_updates(self, conn, case, signatures, storedsigs):
- 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.new:
- self.save_new_case(conn, case)
- else:
- self.save_existing_case(conn, case)
-
- with conn.cursor() as cursor:
- query1 = "DELETE FROM signature WHERE signature_case = ? AND signature_username = ? AND signature_timestamp = ?"
- query2 = "INSERT INTO signature VALUES (?, ?, ?, ?)"
- removals = set(storedsigs) - set(signatures)
- additions = set(signatures) - set(storedsigs)
- if removals:
- args = [(case.id, name, stamp) for (name, stamp) in removals]
- cursor.execute(query1, args)
- if additions:
- nextid = self.select_next_id(conn, "signature_id", "signature")
- args = []
- for name, stamp in additions:
- args.append((nextid, case.id, name, stamp))
- nextid += 1
- cursor.execute(query2, args)
-
- def save_new_case(self, conn, case):
- args = (case.id, case.title, case.status, case.last_action,
- case.file_time, case.close_time, case.parties_notified,
- case.very_old_notified)
- with conn.cursor() as cursor:
- query = "INSERT INTO case VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
- cursor.execute(query, args)
-
- def save_existing_case(self, conn, case):
- with conn.cursor(oursql.DictCursor) as cursor:
- query = "SELECT * FROM case WHERE case_id = ?"
- cursor.execute(query, (case.id,))
- stored = cursor.fetchone()
-
- with conn.cursor() as cursor:
- changes, args = [], []
- fields_to_check = [
- ("case_status", case.status),
- ("case_last_action", case.last_action),
- ("case_close_time", case.close_time),
- ("case_parties_notified", case.parties_notified),
- ("case_very_old_notified", case.very_old_notified),
- ("case_last_volunteer_size", case.last_volunteer_size)
- ]
- for column, data in fields_to_check:
- if data != stored[column]:
- changes.append(column + " = ?")
- args.append(data)
- if changes:
- changes = ", ".join(changes)
- args.append(case.id)
- query = "UPDATE case SET {0} WHERE case_id = ?".format(changes)
- cursor.execute(query, args)
-
- def save(self, page, cases, kwargs):
- """Save any changes to the noticeboard."""
- newtext = text = page.get()
- counter = 0
- for case in cases:
- if case.old != case.body:
- newtext = newtext.replace(case.old, case.body)
- counter += 1
- if newtext == text:
- self.logger.info(u"Nothing to edit on [[{0}]]".format(page.title))
- return
-
- worktime = time() - start
- if worktime < 60:
- sleep(60 - worktime)
- page.reload()
- if page.get() != text:
- log = "Someone has edited the page while we were working; restarting"
- self.logger.warn(log)
- return self.run(**kwargs)
- summary = self.summary.replace("$3", str(counter))
- page.edit(text, summary, minor=True, bot=True)
- self.logger.info(u"Saved page [[{0}]]".format(page.title))
-
- def send_notices(self, site, notices):
- """Send out any templated notices to users or pages."""
- if not notices:
- self.logger.info("No notices to send; finishing")
- return
- for notice in notices:
- target, template = notice.target, notice.template
- log = u"Notifying [[{0}]] with {1}".format(target, template)
- self.logger.info(log)
- page = site.get_page(target)
- try:
- text = page.get()
- except exceptions.PageNotFoundError:
- text = ""
- if notice.too_late and notice.too_late in text:
- log = u"Skipping [[{0}]]; was already notified".format(target)
- self.logger.info(log)
- text += ("\n" if text else "") + template
- try:
- page.edit(text, summary, minor=False, bot=True)
- except exceptions.EditError as error:
- name, msg = type(error).name, error.message
- log = u"Couldn't leave notice on {0} because of {1}: {2}"
- self.logger.error(log.format(page.title, name, msg))
-
- self.logger.info("Done sending notices")
-
-
- class _Case(object):
- """A object representing a dispute resolution case."""
- def __init__(self, id_, title, status, last_action, file_time, close_time,
- parties_notified, very_old_notified, last_volunteer_size,
- new=False):
- self.id = id_
- self.title = title
- self.status = status
- self.last_action = last_action
- self.file_time = file_time
- self.close_time = close_time
- self.parties_notified = parties_notified
- self.very_old_notified = very_old_notified
- self.last_volunteer_size = last_volunteer_size
- self.new = new
-
- self.original_status = status
- self.body = None
- self.old = None
-
-
- class _Notice(object):
- """An object representing a notice to be sent to a user or a page."""
- def __init__(self, target, template, too_late=None):
- self.target = target
- self.template = template
- self.too_late = too_late
|