A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

627 lines
27 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2012 Ben Kurtovic <ben.kurtovic@verizon.net>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. from datetime import datetime
  23. from os.path import expanduser
  24. import re
  25. from threading import RLock
  26. from time import mktime, sleep, time
  27. import oursql
  28. from earwigbot import exceptions
  29. from earwigbot.tasks import Task
  30. class DRNClerkBot(Task):
  31. """A task to clerk for [[WP:DRN]]."""
  32. name = "drn_clerkbot"
  33. number = 19
  34. # Case status:
  35. STATUS_UNKNOWN = 0
  36. STATUS_NEW = 1
  37. STATUS_OPEN = 2
  38. STATUS_STALE = 3
  39. STATUS_NEEDASSIST = 4
  40. STATUS_REVIEW = 5
  41. STATUS_RESOLVED = 6
  42. STATUS_CLOSED = 7
  43. ALIASES = {
  44. STATUS_NEW: ("",),
  45. STATUS_OPEN: ("open", "active", "inprogress"),
  46. STATUS_STALE: ("stale",),
  47. STATUS_NEEDASSIST: ("needassist", "relist", "relisted"),
  48. STATUS_REVIEW: ("review",),
  49. STATUS_RESOLVED: ("resolved", "resolve"),
  50. STATUS_CLOSED: ("closed", "close"),
  51. }
  52. def setup(self):
  53. """Hook called immediately after the task is loaded."""
  54. cfg = self.config.tasks.get(self.name, {})
  55. # Set some wiki-related attributes:
  56. self.title = cfg.get("title",
  57. "Wikipedia:Dispute resolution noticeboard")
  58. self.chart_title = cfg.get("chartTitle", "Template:DRN case status")
  59. self.volunteer_title = cfg.get("volunteers",
  60. "Wikipedia:Dispute resolution noticeboard/Volunteering")
  61. self.very_old_title = cfg.get("veryOldTitle", "User talk:Szhang (WMF)")
  62. clerk_summary = "Updating $3 case$4."
  63. notify_summary = "Notifying user regarding [[WP:DRN|dispute resolution notieboard]] case."
  64. chart_summary = "Updating statistics for the [[WP:DRN|dispute resolution notieboard]]."
  65. self.clerk_summary = self.make_summary(cfg.get("clerkSummary", clerk_summary))
  66. self.notify_summary = self.make_summary(cfg.get("notifySummary", notify_summary))
  67. self.chart_summary = self.make_summary(cfg.get("chartSummary", chart_summary))
  68. # Templates used:
  69. templates = cfg.get("templates", {})
  70. self.tl_status = templates.get("status", "DR case status")
  71. self.tl_notify_party = templates.get("notifyParty", "DRN-notice")
  72. self.tl_notify_stale = templates.get("notifyStale", "DRN stale notice")
  73. self.tl_archive_top = templates.get("archiveTop", "DRN archive top")
  74. self.tl_archive_bottom = templates.get("archiveBottom",
  75. "DRN archive bottom")
  76. self.tl_chart_header = templates.get("chartHeader",
  77. "DRN case status/header")
  78. self.tl_chart_row = templates.get("chartRow", "DRN case status/row")
  79. self.tl_chart_footer = templates.get("chartFooter",
  80. "DRN case status/footer")
  81. # Connection data for our SQL database:
  82. kwargs = cfg.get("sql", {})
  83. kwargs["read_default_file"] = expanduser("~/.my.cnf")
  84. self.conn_data = kwargs
  85. self.db_access_lock = RLock()
  86. # Minimum size a MySQL TIMESTAMP field can hold:
  87. self.min_ts = datetime(1970, 1, 1, 0, 0, 1)
  88. def run(self, **kwargs):
  89. """Entry point for a task event."""
  90. if not self.db_access_lock.acquire(False): # Non-blocking
  91. self.logger.info("A job is already ongoing; aborting")
  92. return
  93. action = kwargs.get("action", "all")
  94. try:
  95. start = time()
  96. conn = oursql.connect(**self.conn_data)
  97. site = self.bot.wiki.get_site()
  98. if action in ["all", "update_volunteers"]:
  99. self.update_volunteers(conn, site)
  100. if action in ["all", "clerk"]:
  101. log = u"Starting update to [[{0}]]".format(self.title)
  102. self.logger.info(log)
  103. cases = self.read_database(conn)
  104. page = site.get_page(self.title)
  105. text = page.get()
  106. self.read_page(conn, cases, text)
  107. notices = self.clerk(conn, cases)
  108. self.save(page, cases, kwargs, start)
  109. self.send_notices(site, notices)
  110. if action in ["all", "update_chart"]:
  111. self.update_chart(conn, site)
  112. finally:
  113. self.db_access_lock.release()
  114. def update_volunteers(self, conn, site):
  115. """Updates and stores the list of dispute resolution volunteers."""
  116. log = u"Updating volunteer list from [[{0}]]"
  117. self.logger.info(log.format(self.volunteer_title))
  118. page = site.get_page(self.volunteer_title)
  119. try:
  120. text = page.get()
  121. except exceptions.PageNotFoundError:
  122. text = ""
  123. marker = "<!-- please don't remove this comment (used by EarwigBot) -->"
  124. if marker not in text:
  125. log = u"The marker ({0}) wasn't found in the volunteer list at [[{1}]]!"
  126. self.logger.error(log.format(marker, page.title))
  127. return
  128. text = text.split(marker)[1]
  129. additions = set()
  130. for line in text.splitlines():
  131. user = re.search("\# \{\{User\|(.*?)\}\}", line)
  132. if user:
  133. additions.add((user.group(1).replace("_", " ").strip(),))
  134. removals = set()
  135. query1 = "SELECT volunteer_username FROM volunteers"
  136. query2 = "DELETE FROM volunteers WHERE volunteer_username = ?"
  137. query3 = "INSERT INTO volunteers (volunteer_username) VALUES (?)"
  138. with conn.cursor() as cursor:
  139. cursor.execute(query1)
  140. for row in cursor:
  141. if row in additions:
  142. additions.remove(row)
  143. else:
  144. removals.add(row)
  145. if removals:
  146. cursor.executemany(query2, removals)
  147. if additions:
  148. cursor.executemany(query3, additions)
  149. def read_database(self, conn):
  150. """Return a list of _Cases from the database."""
  151. cases = []
  152. query = "SELECT * FROM cases WHERE case_status != ?"
  153. with conn.cursor() as cursor:
  154. cursor.execute(query, (self.STATUS_UNKNOWN,))
  155. for row in cursor:
  156. case = _Case(*row)
  157. cases.append(case)
  158. return cases
  159. def read_page(self, conn, cases, text):
  160. """Read the noticeboard content and update the list of _Cases."""
  161. nextid = self.select_next_id(conn)
  162. tl_status_esc = re.escape(self.tl_status)
  163. split = re.split("(^==\s*[^=]+?\s*==$)", text, flags=re.M|re.U)
  164. for i in xrange(len(split)):
  165. if i + 1 == len(split):
  166. break
  167. if not split[i].startswith("=="):
  168. continue
  169. title = split[i][2:-2].strip()
  170. body = old = split[i + 1]
  171. if not re.search("\s*\{\{" + tl_status_esc, body, re.U):
  172. continue
  173. status = self.read_status(body)
  174. re_id = "<!-- Bot Case ID \(please don't modify\): (.*?) -->"
  175. try:
  176. id_ = int(re.search(re_id, body).group(1))
  177. case = [case for case in cases if case.id == id_][0]
  178. except (AttributeError, IndexError, ValueError):
  179. id_ = nextid
  180. nextid += 1
  181. re_id2 = "(\{\{" + tl_status_esc
  182. re_id2 += r"(.*?)\}\})(<!-- Bot Case ID \(please don't modify\): .*? -->)?"
  183. repl = ur"\1 <!-- Bot Case ID (please don't modify): {0} -->"
  184. body = re.sub(re_id2, repl.format(id_), body)
  185. re_f = r"\{\{drn filing editor\|(.*?)\|(\d{2}:\d{2},\s\d{2}\s\w+\s\d{4}\s\(UTC\))\}\}"
  186. match = re.search(re_f, body, re.U)
  187. if match:
  188. f_user = match.group(1).split("/", 1)[0].replace("_", " ")
  189. strp = "%H:%M, %d %B %Y (UTC)"
  190. f_time = datetime.strptime(match.group(2), strp)
  191. else:
  192. f_user, f_time = None, datetime.utcnow()
  193. case = _Case(id_, title, status, self.STATUS_UNKNOWN, f_user,
  194. f_time, "", self.min_ts, "", self.min_ts,
  195. self.min_ts, False, False, 0, new=True)
  196. cases.append(case)
  197. else:
  198. case.status = status
  199. if case.title != title:
  200. self.update_case_title(conn, id_, title)
  201. case.title = title
  202. case.body, case.old = body, old
  203. # Ignore cases loaded from the DB that weren't found in the page:
  204. for case in cases:
  205. if case.body is None:
  206. case.status = self.STATUS_UNKNOWN
  207. def select_next_id(self, conn):
  208. """Return the next incremental ID for a case."""
  209. query = "SELECT MAX(case_id) FROM cases"
  210. with conn.cursor() as cursor:
  211. cursor.execute(query)
  212. current = cursor.fetchone()[0]
  213. if current:
  214. return int(current) + 1
  215. return 1
  216. def read_status(self, body):
  217. """Parse the current status from a case body."""
  218. templ = re.escape(self.tl_status)
  219. status = re.search("\{\{" + templ + "\|?(.*?)\}\}", body, re.S|re.U)
  220. if not status:
  221. return self.STATUS_UNKNOWN
  222. for option, names in self.ALIASES.iteritems():
  223. if status.group(1).lower() in names:
  224. return option
  225. return self.STATUS_UNKNOWN
  226. def update_case_title(self, conn, id_, title):
  227. """Update a case title in the database."""
  228. query = "UPDATE cases SET case_title = ? WHERE case_id = ?"
  229. with conn.cursor() as cursor:
  230. cursor.execute(query, (title, id_))
  231. def clerk(self, conn, cases):
  232. """Actually go through cases and modify those to be updated."""
  233. query = "SELECT volunteer_username FROM volunteers"
  234. with conn.cursor() as cursor:
  235. cursor.execute(query)
  236. volunteers = [name for (name,) in cursor.fetchall()]
  237. notices = []
  238. for case in cases:
  239. if case.status != self.STATUS_UNKNOWN:
  240. notices += self.clerk_case(conn, case, volunteers)
  241. return notices
  242. def clerk_case(self, conn, case, volunteers):
  243. """Clerk a particular case and return a list of any notices to send."""
  244. notices = []
  245. signatures = self.read_signatures(case.body)
  246. storedsigs = self.get_signatures_from_db(conn, case)
  247. newsigs = set(signatures) - set(storedsigs)
  248. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  249. case.last_volunteer_size = len(case.body)
  250. if case.status == self.STATUS_NEW:
  251. notices = self.clerk_new_case(case, volunteers, signatures)
  252. elif case.status == self.STATUS_OPEN:
  253. notices = self.clerk_open_case(case, signatures)
  254. elif case.status == self.STATUS_NEEDASSIST:
  255. notices = self.clerk_needassist_case(case, volunteers, newsigs)
  256. elif case.status == self.STATUS_STALE:
  257. notices = self.clerk_stale_case(case, newsigs)
  258. elif case.status == self.STATUS_REVIEW:
  259. notices = self.clerk_review_case(case)
  260. elif case.status in [self.STATUS_RESOLVED, self.STATUS_CLOSED]:
  261. self.clerk_closed_case(case, signatures)
  262. else:
  263. log = u"Unsure of how to deal with case {0} (title: {1})"
  264. self.logger.error(log.format(case.id, case.title))
  265. return notices
  266. self.save_case_updates(conn, case, volunteers, signatures, storedsigs)
  267. return notices
  268. def clerk_new_case(self, case, volunteers, signatures):
  269. notices = self.notify_parties(case)
  270. if any([editor in volunteers for (editor, timestamp) in signatures]):
  271. if case.last_action != self.STATUS_OPEN:
  272. case.status = self.STATUS_OPEN
  273. return notices
  274. def clerk_open_case(self, case, signatures):
  275. if self.check_for_review(case):
  276. return []
  277. if len(case.body) - case.last_volunteer_size > 15000:
  278. if case.last_action != self.STATUS_NEEDASSIST:
  279. case.status = self.STATUS_NEEDASSIST
  280. timestamps = [timestamp for (editor, timestamp) in signatures]
  281. age = (datetime.utcnow() - max(timestamps)).total_seconds()
  282. if age > 60 * 60 * 24 * 2:
  283. if case.last_action != self.STATUS_STALE:
  284. case.status = self.STATUS_STALE
  285. return []
  286. def clerk_needassist_case(self, case, volunteers, newsigs):
  287. if self.check_for_review(case):
  288. return []
  289. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  290. if case.last_action != self.STATUS_OPEN:
  291. case.status = self.STATUS_OPEN
  292. return []
  293. def clerk_stale_case(self, case, newsigs):
  294. if self.check_for_review(case):
  295. return []
  296. if newsigs:
  297. if case.last_action != self.STATUS_OPEN:
  298. case.status = self.STATUS_OPEN
  299. return []
  300. def clerk_review_case(self, case):
  301. age = (datetime.utcnow() - case.file_time).total_seconds()
  302. if age > 60 * 60 * 24 * 7:
  303. if not case.very_old_notified:
  304. template = "{{subst:" + self.tl_notify_stale + "|"
  305. template += case.title.replace("|", "&#124;") + "}}"
  306. notice = _Notice(self.very_old_title, template)
  307. case.very_old_notified = True
  308. return [notice]
  309. return []
  310. def clerk_closed_case(self, case, signatures):
  311. if case.close_time == self.min_ts:
  312. case.close_time = datetime.utcnow()
  313. timestamps = [timestamp for (editor, timestamp) in signatures]
  314. closed_age = (datetime.utcnow() - case.close_time).total_seconds()
  315. modify_age = (datetime.utcnow() - max(timestamps)).total_seconds()
  316. if closed_age > 60 * 60 * 24 and modify_age > 60 * 60 * 24:
  317. arch_top = self.tl_archive_top
  318. arch_bottom = self.tl_archive_bottom
  319. reg = "<!-- \[\[User:DoNotArchiveUntil\]\] .*? -->(<!-- .*? -->)?"
  320. if re.search(reg, case.body):
  321. case.body = re.sub("\{\{" + arch_top + "\}\}", "", case.body)
  322. case.body = re.sub(reg, "{{" + arch_top + "}}", case.body)
  323. if not re.search(arch_bottom + "\s*\}\}\s*\Z", case.body):
  324. case.body += "\n{{" + arch_bottom + "}}"
  325. case.status = self.STATUS_UNKNOWN
  326. def check_for_review(self, case):
  327. age = (datetime.utcnow() - case.file_time).total_seconds()
  328. if age > 60 * 60 * 24 * 4:
  329. if case.last_action != self.STATUS_REVIEW:
  330. case.status = self.STATUS_REVIEW
  331. return True
  332. return False
  333. def read_signatures(self, text):
  334. regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)"
  335. regex += r"([^\n\[\]|]{,256}?)(?:\||\]\])"
  336. regex += r"(?!.*?(?:User(?:\stalk)?\:|Special\:Contributions\/).*?)"
  337. regex += r".{,256}?(\d{2}:\d{2},\s\d{2}\s\w+\s\d{4}\s\(UTC\))"
  338. matches = re.findall(regex, text, re.U)
  339. signatures = []
  340. for userlink, stamp in matches:
  341. username = userlink.split("/", 1)[0].replace("_", " ").strip()
  342. if username == "DoNotArchiveUntil":
  343. continue
  344. stamp = stamp.strip()
  345. timestamp = datetime.strptime(stamp, "%H:%M, %d %B %Y (UTC)")
  346. signatures.append((username, timestamp))
  347. return signatures
  348. def get_signatures_from_db(self, conn, case):
  349. query = "SELECT signature_username, signature_timestamp FROM signatures WHERE signature_case = ?"
  350. with conn.cursor() as cursor:
  351. cursor.execute(query, (case.id,))
  352. return cursor.fetchall()
  353. def notify_parties(self, case):
  354. if case.parties_notified:
  355. return []
  356. notices = []
  357. template = "{{subst:" + self.tl_notify_party
  358. template += "|thread=" + case.title + "}} ~~~~"
  359. too_late = "<!--Template:DRN-notice-->"
  360. re_parties = "<span.*?>'''Users involved'''</span>(.*?)<span.*?>"
  361. text = re.search(re_parties, case.body, re.S|re.U)
  362. for line in text.group(1).splitlines():
  363. user = re.search("[:*#]{,5} \{\{User\|(.*?)\}\}", line)
  364. if user:
  365. party = user.group(1).replace("_", " ").strip()
  366. notice = _Notice("User talk:" + party, template, too_late)
  367. notices.append(notice)
  368. case.parties_notified = True
  369. return notices
  370. def save_case_updates(self, conn, case, volunteers, sigs, storedsigs):
  371. if case.status != case.original_status:
  372. case.last_action = case.status
  373. new = self.ALIASES[case.status][0]
  374. tl_status_esc = re.escape(self.tl_status)
  375. search = "\{\{" + tl_status_esc + "(\|?.*?)\}\}"
  376. repl = "{{" + self.tl_status + "|" + new + "}}"
  377. case.body = re.sub(search, repl, case.body)
  378. newest_ts = max([stamp for (user, stamp) in sigs])
  379. newest_user = [usr for (usr, stamp) in sigs if stamp == newest_ts][0]
  380. case.modify_time = newest_ts
  381. case.modify_user = newest_user
  382. newest_vts = max([stamp for (usr, stamp) in sigs if usr in volunteers])
  383. newest_vuser = [usr for (usr, stamp) in sigs if stamp == newest_vts][0]
  384. case.volunteer_time = newest_vts
  385. case.volunteer_user = newest_vuser
  386. if case.new:
  387. self.save_new_case(conn, case)
  388. else:
  389. self.save_existing_case(conn, case)
  390. with conn.cursor() as cursor:
  391. query1 = "DELETE FROM signatures WHERE signature_case = ? AND signature_username = ? AND signature_timestamp = ?"
  392. query2 = "INSERT INTO signatures (signature_case, signature_username, signature_timestamp) VALUES (?, ?, ?)"
  393. removals = set(storedsigs) - set(sigs)
  394. additions = set(sigs) - set(storedsigs)
  395. if removals:
  396. args = [(case.id, name, stamp) for (name, stamp) in removals]
  397. cursor.executemany(query1, args)
  398. if additions:
  399. args = []
  400. for name, stamp in additions:
  401. args.append((case.id, name, stamp))
  402. cursor.executemany(query2, args)
  403. def save_new_case(self, conn, case):
  404. args = (case.id, case.title, case.status, case.last_action,
  405. case.file_user, case.file_time, case.modify_user,
  406. case.modify_time, case.volunteer_user, case.volunteer_time,
  407. case.close_time, case.parties_notified,
  408. case.very_old_notified, case.last_volunteer_size)
  409. with conn.cursor() as cursor:
  410. query = "INSERT INTO cases VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
  411. cursor.execute(query, args)
  412. def save_existing_case(self, conn, case):
  413. with conn.cursor(oursql.DictCursor) as cursor:
  414. query = "SELECT * FROM cases WHERE case_id = ?"
  415. cursor.execute(query, (case.id,))
  416. stored = cursor.fetchone()
  417. with conn.cursor() as cursor:
  418. changes, args = [], []
  419. fields_to_check = [
  420. ("case_status", case.status),
  421. ("case_last_action", case.last_action),
  422. ("case_file_user", case.file_user),
  423. ("case_file_time", case.file_time),
  424. ("case_modify_user", case.modify_user),
  425. ("case_modify_time", case.modify_time),
  426. ("case_volunteer_user", case.volunteer_user),
  427. ("case_volunteer_time", case.volunteer_time),
  428. ("case_close_time", case.close_time),
  429. ("case_parties_notified", case.parties_notified),
  430. ("case_very_old_notified", case.very_old_notified),
  431. ("case_last_volunteer_size", case.last_volunteer_size)
  432. ]
  433. for column, data in fields_to_check:
  434. if data != stored[column]:
  435. changes.append(column + " = ?")
  436. args.append(data)
  437. if changes:
  438. changes = ", ".join(changes)
  439. args.append(case.id)
  440. query = "UPDATE cases SET {0} WHERE case_id = ?".format(changes)
  441. cursor.execute(query, args)
  442. def save(self, page, cases, kwargs, start):
  443. """Save any changes to the noticeboard."""
  444. newtext = text = page.get()
  445. counter = 0
  446. for case in cases:
  447. if case.old != case.body:
  448. newtext = newtext.replace(case.old, case.body)
  449. counter += 1
  450. if newtext == text:
  451. self.logger.info(u"Nothing to edit on [[{0}]]".format(page.title))
  452. return
  453. worktime = time() - start
  454. if worktime < 60:
  455. log = "Waiting {0} seconds to make sure we don't edit conflict"
  456. self.logger.debug(log.format(int(60 - worktime)))
  457. sleep(60 - worktime)
  458. page.reload()
  459. if page.get() != text:
  460. log = "Someone has edited the page while we were working; restarting"
  461. self.logger.warn(log)
  462. return self.run(**kwargs)
  463. summary = self.clerk_summary.replace("$3", str(counter))
  464. summary = summary.replace("$4", "" if counter == 1 else "s")
  465. page.edit(newtext, summary, minor=True, bot=True)
  466. self.logger.info(u"Saved page [[{0}]]".format(page.title))
  467. def send_notices(self, site, notices):
  468. """Send out any templated notices to users or pages."""
  469. if not notices:
  470. self.logger.info("No notices to send")
  471. return
  472. for notice in notices:
  473. target, template = notice.target, notice.template
  474. log = u"Notifying [[{0}]] with {1}".format(target, template)
  475. self.logger.info(log)
  476. page = site.get_page(target)
  477. try:
  478. text = page.get()
  479. except exceptions.PageNotFoundError:
  480. text = ""
  481. if notice.too_late and notice.too_late in text:
  482. log = u"Skipping [[{0}]]; was already notified".format(target)
  483. self.logger.info(log)
  484. text += ("\n" if text else "") + template
  485. try:
  486. page.edit(text, self.notify_summary, minor=False, bot=True)
  487. except exceptions.EditError as error:
  488. name, msg = type(error).name, error.message
  489. log = u"Couldn't leave notice on {0} because of {1}: {2}"
  490. self.logger.error(log.format(page.title, name, msg))
  491. self.logger.info("Done sending notices")
  492. def update_chart(self, conn, site):
  493. page = site.get_page(self.chart_title)
  494. self.logger.info(u"Updating case status at [[{0}]]".format(page.title))
  495. statuses = self.compile_chart(conn)
  496. text = page.get()
  497. newtext = re.sub(u"<!-- status begin -->(.*?)<!-- status end -->",
  498. "<!-- status begin -->\n" + statuses + "\n<!-- status end -->",
  499. text, flags=re.DOTALL)
  500. if newtext == text:
  501. self.logger.info("Chart unchanged; not saving")
  502. return
  503. newtext = re.sub("<!-- sig begin -->(.*?)<!-- sig end -->",
  504. "<!-- sig begin -->~~~ at ~~~~~<!-- sig end -->",
  505. newtext)
  506. page.edit(newtext, self.chart_summary, minor=True, bot=True)
  507. self.logger.info(u"Chart saved to [[{0}]]".format(page.title))
  508. def compile_chart(self, conn):
  509. chart = "{{" + self.tl_chart_header + "}}\n"
  510. query = "SELECT * FROM cases"
  511. with conn.cursor(oursql.DictCursor) as cursor:
  512. cursor.execute(query)
  513. for case in cursor:
  514. if case["case_status"] != self.STATUS_UNKNOWN:
  515. chart += self.compile_row(case)
  516. chart += "{{" + self.tl_chart_footer + "}}"
  517. return chart
  518. def compile_row(self, case):
  519. data = "|t={title}|s={case_status}"
  520. data += "|cu={case_file_user}|cs={file_sortkey}|ct={file_time}"
  521. if case["case_volunteer_user"]:
  522. data += "|vu={case_volunteer_user}|vs={volunteer_sortkey}|vt={volunteer_time}"
  523. case["volunteer_time"] = self.format_time(case["case_volunteer_time"])
  524. case["volunteer_sortkey"] = int(mktime(case["case_volunteer_time"].timetuple()))
  525. data += "|mu={case_modify_user}|ms={modify_sortkey}|mt={modify_time}"
  526. title = case["case_title"].replace("_", " ").replace("|", "&#124;")
  527. case["title"] = title[:47] + "..." if len(title) > 50 else title
  528. case["file_time"] = self.format_time(case["case_file_time"])
  529. case["file_sortkey"] = int(mktime(case["case_file_time"].timetuple()))
  530. case["modify_time"] = self.format_time(case["case_modify_time"])
  531. case["modify_sortkey"] = int(mktime(case["case_modify_time"].timetuple()))
  532. row = "{{" + self.tl_chart_row + data.format(**case) + "}}\n"
  533. return row
  534. def format_time(self, dt):
  535. """Format a datetime into the standard MediaWiki timestamp format."""
  536. return dt.strftime("%H:%M, %d %b %Y")
  537. class _Case(object):
  538. """A object representing a dispute resolution case."""
  539. def __init__(self, id_, title, status, last_action, file_user, file_time,
  540. modify_user, modify_time, volunteer_user, volunteer_time,
  541. close_time, parties_notified, very_old_notified,
  542. last_volunteer_size, new=False):
  543. self.id = id_
  544. self.title = title
  545. self.status = status
  546. self.last_action = last_action
  547. self.file_user = file_user
  548. self.file_time = file_time
  549. self.modify_user = modify_user
  550. self.modify_time = modify_time
  551. self.volunteer_user = volunteer_user
  552. self.volunteer_time = volunteer_time
  553. self.close_time = close_time
  554. self.parties_notified = parties_notified
  555. self.very_old_notified = very_old_notified
  556. self.last_volunteer_size = last_volunteer_size
  557. self.new = new
  558. self.original_status = status
  559. self.body = None
  560. self.old = None
  561. class _Notice(object):
  562. """An object representing a notice to be sent to a user or a page."""
  563. def __init__(self, target, template, too_late=None):
  564. self.target = target
  565. self.template = template
  566. self.too_late = too_late