Additional IRC commands and bot tasks for EarwigBot https://en.wikipedia.org/wiki/User:EarwigBot
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.

drn_clerkbot.py 36 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2014 Ben Kurtovic <ben.kurtovic@gmail.com>
  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. from mwparserfromhell import parse as mw_parse
  28. import oursql
  29. from earwigbot import exceptions
  30. from earwigbot.tasks import Task
  31. from earwigbot.wiki import constants
  32. class DRNClerkBot(Task):
  33. """A task to clerk for [[WP:DRN]]."""
  34. name = "drn_clerkbot"
  35. number = 19
  36. # Case status:
  37. STATUS_UNKNOWN = 0
  38. STATUS_NEW = 1
  39. STATUS_OPEN = 2
  40. STATUS_STALE = 3
  41. STATUS_NEEDASSIST = 4
  42. STATUS_RESOLVED = 6
  43. STATUS_CLOSED = 7
  44. STATUS_FAILED = 8
  45. ALIASES = {
  46. STATUS_NEW: ("",),
  47. STATUS_OPEN: ("open", "active", "inprogress"),
  48. STATUS_STALE: ("stale",),
  49. STATUS_NEEDASSIST: ("needassist", "review", "relist", "relisted"),
  50. STATUS_RESOLVED: ("resolved", "resolve"),
  51. STATUS_CLOSED: ("closed", "close"),
  52. STATUS_FAILED: ("failed", "fail"),
  53. }
  54. def setup(self):
  55. """Hook called immediately after the task is loaded."""
  56. cfg = self.config.tasks.get(self.name, {})
  57. # Set some wiki-related attributes:
  58. self.title = cfg.get("title",
  59. "Wikipedia:Dispute resolution noticeboard")
  60. self.chart_title = cfg.get("chartTitle", "Template:DRN case status")
  61. self.volunteer_title = cfg.get("volunteers",
  62. "Wikipedia:Dispute resolution noticeboard/Volunteering")
  63. self.very_old_title = cfg.get("veryOldTitle", "User talk:Szhang (WMF)")
  64. self.notify_stale_cases = cfg.get("notifyStaleCases", False)
  65. clerk_summary = "Updating $3 case$4."
  66. notify_summary = "Notifying user regarding [[WP:DRN|dispute resolution noticeboard]] case."
  67. chart_summary = "Updating statistics for the [[WP:DRN|dispute resolution noticeboard]]."
  68. self.clerk_summary = self.make_summary(cfg.get("clerkSummary", clerk_summary))
  69. self.notify_summary = self.make_summary(cfg.get("notifySummary", notify_summary))
  70. self.chart_summary = self.make_summary(cfg.get("chartSummary", chart_summary))
  71. # Templates used:
  72. templates = cfg.get("templates", {})
  73. self.tl_status = templates.get("status", "DR case status")
  74. self.tl_notify_party = templates.get("notifyParty", "DRN-notice")
  75. self.tl_notify_stale = templates.get("notifyStale", "DRN stale notice")
  76. self.tl_archive_top = templates.get("archiveTop", "DRN archive top")
  77. self.tl_archive_bottom = templates.get("archiveBottom",
  78. "DRN archive bottom")
  79. self.tl_chart_header = templates.get("chartHeader",
  80. "DRN case status/header")
  81. self.tl_chart_row = templates.get("chartRow", "DRN case status/row")
  82. self.tl_chart_footer = templates.get("chartFooter",
  83. "DRN case status/footer")
  84. # Connection data for our SQL database:
  85. kwargs = cfg.get("sql", {})
  86. kwargs["read_default_file"] = expanduser("~/.my.cnf")
  87. self.conn_data = kwargs
  88. self.db_access_lock = RLock()
  89. # Minimum size a MySQL TIMESTAMP field can hold:
  90. self.min_ts = datetime(1970, 1, 1, 0, 0, 1)
  91. def run(self, **kwargs):
  92. """Entry point for a task event."""
  93. if not self.db_access_lock.acquire(False): # Non-blocking
  94. self.logger.info("A job is already ongoing; aborting")
  95. return
  96. action = kwargs.get("action", "all")
  97. try:
  98. start = time()
  99. conn = oursql.connect(**self.conn_data)
  100. site = self.bot.wiki.get_site()
  101. if action in ["all", "update_volunteers"]:
  102. self.update_volunteers(conn, site)
  103. if action in ["all", "clerk"]:
  104. log = u"Starting update to [[{0}]]".format(self.title)
  105. self.logger.info(log)
  106. cases = self.read_database(conn)
  107. page = site.get_page(self.title)
  108. text = page.get()
  109. self.read_page(conn, cases, text)
  110. notices = self.clerk(conn, cases)
  111. if self.shutoff_enabled():
  112. return
  113. if not self.save(page, cases, kwargs, start):
  114. return
  115. self.send_notices(site, notices)
  116. if action in ["all", "update_chart"]:
  117. if self.shutoff_enabled():
  118. return
  119. self.update_chart(conn, site)
  120. if action in ["all", "purge"]:
  121. self.purge_old_data(conn)
  122. finally:
  123. self.db_access_lock.release()
  124. def update_volunteers(self, conn, site):
  125. """Updates and stores the list of dispute resolution volunteers."""
  126. log = u"Updating volunteer list from [[{0}]]"
  127. self.logger.info(log.format(self.volunteer_title))
  128. page = site.get_page(self.volunteer_title)
  129. try:
  130. text = page.get()
  131. except exceptions.PageNotFoundError:
  132. text = ""
  133. marker = "<!-- please don't remove this comment (used by EarwigBot) -->"
  134. if marker not in text:
  135. log = u"The marker ({0}) wasn't found in the volunteer list at [[{1}]]!"
  136. self.logger.error(log.format(marker, page.title))
  137. return
  138. text = text.split(marker)[1]
  139. additions = set()
  140. for line in text.splitlines():
  141. user = re.search("\# \{\{User\|(.+?)\}\}", line)
  142. if user:
  143. uname = user.group(1).replace("_", " ").strip()
  144. additions.add((uname[0].upper() + uname[1:],))
  145. removals = set()
  146. query1 = "SELECT volunteer_username FROM volunteers"
  147. query2 = "DELETE FROM volunteers WHERE volunteer_username = ?"
  148. query3 = "INSERT INTO volunteers (volunteer_username) VALUES (?)"
  149. with conn.cursor() as cursor:
  150. cursor.execute(query1)
  151. for row in cursor:
  152. if row in additions:
  153. additions.remove(row)
  154. else:
  155. removals.add(row)
  156. if removals:
  157. cursor.executemany(query2, removals)
  158. if additions:
  159. cursor.executemany(query3, additions)
  160. def read_database(self, conn):
  161. """Return a list of _Cases from the database."""
  162. cases = []
  163. query = "SELECT * FROM cases"
  164. with conn.cursor() as cursor:
  165. cursor.execute(query)
  166. for row in cursor:
  167. case = _Case(*row)
  168. cases.append(case)
  169. log = "Read {0} cases from the database"
  170. self.logger.debug(log.format(len(cases)))
  171. return cases
  172. def read_page(self, conn, cases, text):
  173. """Read the noticeboard content and update the list of _Cases."""
  174. nextid = self.select_next_id(conn)
  175. tl_status_esc = re.escape(self.tl_status)
  176. split = re.split("(^==\s*[^=]+?\s*==$)", text, flags=re.M|re.U)
  177. for i in xrange(len(split)):
  178. if i + 1 == len(split):
  179. break
  180. if not split[i].startswith("=="):
  181. continue
  182. title = split[i][2:-2].strip()
  183. body = old = split[i + 1]
  184. if not re.search("\s*\{\{" + tl_status_esc, body, re.U):
  185. continue
  186. status = self.read_status(body)
  187. re_id = "<!-- Bot Case ID \(please don't modify\): (.*?) -->"
  188. try:
  189. id_ = int(re.search(re_id, body).group(1))
  190. case = [case for case in cases if case.id == id_][0]
  191. except (AttributeError, IndexError, ValueError):
  192. id_ = nextid
  193. nextid += 1
  194. re_id2 = "(\{\{" + tl_status_esc
  195. re_id2 += r"(.*?)\}\})(<!-- Bot Case ID \(please don't modify\): .*? -->)?"
  196. repl = ur"\1 <!-- Bot Case ID (please don't modify): {0} -->"
  197. body = re.sub(re_id2, repl.format(id_), body)
  198. re_f = r"\{\{drn filing editor\|(.*?)\|"
  199. re_f += r"(\d{2}:\d{2},\s\d{1,2}\s\w+\s\d{4}\s\(UTC\))\}\}"
  200. match = re.search(re_f, body, re.U)
  201. if match:
  202. f_user = match.group(1).split("/", 1)[0].replace("_", " ")
  203. f_user = f_user[0].upper() + f_user[1:]
  204. strp = "%H:%M, %d %B %Y (UTC)"
  205. f_time = datetime.strptime(match.group(2), strp)
  206. else:
  207. f_user, f_time = None, datetime.utcnow()
  208. case = _Case(id_, title, status, self.STATUS_UNKNOWN, f_user,
  209. f_time, f_user, f_time, "", self.min_ts,
  210. self.min_ts, False, False, False, len(body),
  211. new=True)
  212. cases.append(case)
  213. log = u"Added new case {0} ('{1}', status={2}, by {3})"
  214. self.logger.debug(log.format(id_, title, status, f_user))
  215. else:
  216. case.status = status
  217. log = u"Read active case {0} ('{1}')".format(id_, title)
  218. self.logger.debug(log)
  219. if case.title != title:
  220. self.update_case_title(conn, id_, title)
  221. case.title = title
  222. case.body, case.old = body, old
  223. for case in cases[:]:
  224. if case.body is None:
  225. if case.original_status == self.STATUS_UNKNOWN:
  226. cases.remove(case) # Ignore archived case
  227. else:
  228. case.status = self.STATUS_UNKNOWN
  229. log = u"Dropped case {0} because it is no longer on the page ('{1}')"
  230. self.logger.debug(log.format(case.id, case.title))
  231. self.logger.debug("Done reading cases from the noticeboard page")
  232. def select_next_id(self, conn):
  233. """Return the next incremental ID for a case."""
  234. query = "SELECT MAX(case_id) FROM cases"
  235. with conn.cursor() as cursor:
  236. cursor.execute(query)
  237. current = cursor.fetchone()[0]
  238. if current:
  239. return int(current) + 1
  240. return 1
  241. def read_status(self, body):
  242. """Parse the current status from a case body."""
  243. templ = re.escape(self.tl_status)
  244. status = re.search("\{\{" + templ + "\|?(.*?)\}\}", body, re.S|re.U)
  245. if not status:
  246. return self.STATUS_NEW
  247. for option, names in self.ALIASES.iteritems():
  248. if status.group(1).lower() in names:
  249. return option
  250. return self.STATUS_NEW
  251. def update_case_title(self, conn, id_, title):
  252. """Update a case title in the database."""
  253. query = "UPDATE cases SET case_title = ? WHERE case_id = ?"
  254. with conn.cursor() as cursor:
  255. cursor.execute(query, (title, id_))
  256. log = u"Updated title of case {0} to '{1}'".format(id_, title)
  257. self.logger.debug(log)
  258. def clerk(self, conn, cases):
  259. """Actually go through cases and modify those to be updated."""
  260. query = "SELECT volunteer_username FROM volunteers"
  261. with conn.cursor() as cursor:
  262. cursor.execute(query)
  263. volunteers = [name for (name,) in cursor.fetchall()]
  264. notices = []
  265. for case in cases:
  266. log = u"Clerking case {0} ('{1}')".format(case.id, case.title)
  267. self.logger.debug(log)
  268. if case.status == self.STATUS_UNKNOWN:
  269. self.save_existing_case(conn, case)
  270. else:
  271. notices += self.clerk_case(conn, case, volunteers)
  272. self.logger.debug("Done clerking cases")
  273. return notices
  274. def clerk_case(self, conn, case, volunteers):
  275. """Clerk a particular case and return a list of any notices to send."""
  276. notices = []
  277. signatures = self.read_signatures(case.body)
  278. storedsigs = self.get_signatures_from_db(conn, case)
  279. newsigs = set(signatures) - set(storedsigs)
  280. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  281. case.last_volunteer_size = len(case.body)
  282. if case.status == self.STATUS_NEW:
  283. notices = self.clerk_new_case(case, volunteers, signatures)
  284. elif case.status == self.STATUS_OPEN:
  285. notices = self.clerk_open_case(case, signatures)
  286. elif case.status == self.STATUS_NEEDASSIST:
  287. notices = self.clerk_needassist_case(case, volunteers, newsigs)
  288. elif case.status == self.STATUS_STALE:
  289. notices = self.clerk_stale_case(case, newsigs)
  290. elif case.status in [self.STATUS_RESOLVED, self.STATUS_CLOSED,
  291. self.STATUS_FAILED]:
  292. self.clerk_closed_case(case, signatures)
  293. self.add_missing_reflist(case)
  294. self.save_case_updates(conn, case, volunteers, signatures, storedsigs)
  295. return notices
  296. def clerk_new_case(self, case, volunteers, signatures):
  297. """Clerk a case in the "brand new" state.
  298. The case will be set to "open" if a volunteer edits it, or "needassist"
  299. if it increases by over 15,000 bytes or goes by without any volunteer
  300. edits for two days.
  301. """
  302. notices = self.notify_parties(case)
  303. if any([editor in volunteers for (editor, timestamp) in signatures]):
  304. self.update_status(case, self.STATUS_OPEN)
  305. else:
  306. age = (datetime.utcnow() - case.file_time).total_seconds()
  307. if age > 60 * 60 * 24 * 2:
  308. self.update_status(case, self.STATUS_NEEDASSIST)
  309. elif len(case.body) - case.last_volunteer_size > 15000:
  310. self.update_status(case, self.STATUS_NEEDASSIST)
  311. return notices
  312. def clerk_open_case(self, case, signatures):
  313. """Clerk an open case (has been edited by a reviewer).
  314. The case will be set to "needassist" if 15,000 bytes have been added
  315. since a volunteer last edited or if it has been open for over seven
  316. days, or "stale" if no edits at all have occurred in two days.
  317. """
  318. if self.check_for_needassist(case):
  319. return []
  320. if len(case.body) - case.last_volunteer_size > 15000:
  321. self.update_status(case, self.STATUS_NEEDASSIST)
  322. timestamps = [timestamp for (editor, timestamp) in signatures]
  323. if timestamps:
  324. age = (datetime.utcnow() - max(timestamps)).total_seconds()
  325. if age > 60 * 60 * 24 * 2:
  326. self.update_status(case, self.STATUS_STALE)
  327. return []
  328. def clerk_needassist_case(self, case, volunteers, newsigs):
  329. """Clerk a "needassist" case (no volunteers in 15kb or >= 7 days old).
  330. The case will be set to "open" if a volunteer edits and the case is
  331. less than a week old. A message will be set to the "very old notifiee",
  332. which is generally [[User talk:Szhang (WMF)]], if the case has been
  333. open for more than ten days.
  334. """
  335. age = (datetime.utcnow() - case.file_time).total_seconds()
  336. if age <= 60 * 60 * 24 * 7:
  337. if any([editor in volunteers for (editor, timestamp) in newsigs]):
  338. self.update_status(case, self.STATUS_OPEN)
  339. elif age > 60 * 60 * 24 * 10:
  340. if not case.very_old_notified and self.notify_stale_cases:
  341. tmpl = self.tl_notify_stale
  342. title = case.title.replace("|", "&#124;")
  343. template = "{{subst:" + tmpl + "|" + title + "}}"
  344. miss = "<!-- Template:DRN stale notice | {0} -->".format(title)
  345. notice = _Notice(self.very_old_title, template, miss)
  346. case.very_old_notified = True
  347. msg = u" {0}: will notify [[{1}]] with '{2}'"
  348. log = msg.format(case.id, self.very_old_title, template)
  349. self.logger.debug(log)
  350. return [notice]
  351. return []
  352. def clerk_stale_case(self, case, newsigs):
  353. """Clerk a stale case (no edits in two days).
  354. The case will be set to "open" if anyone edits, or "needassist" if it
  355. has been open for over seven days.
  356. """
  357. if self.check_for_needassist(case):
  358. return []
  359. if newsigs:
  360. self.update_status(case, self.STATUS_OPEN)
  361. return []
  362. def clerk_closed_case(self, case, signatures):
  363. """Clerk a closed or resolved case.
  364. The case will be archived if it has been closed/resolved for more than
  365. one day and no edits have been made in the meantime. "Archiving" is
  366. the process of adding {{DRN archive top}}, {{DRN archive bottom}}, and
  367. removing the [[User:DoNotArchiveUntil]] comment.
  368. """
  369. if case.close_time == self.min_ts:
  370. case.close_time = datetime.utcnow()
  371. if case.archived:
  372. return
  373. timestamps = [timestamp for (editor, timestamp) in signatures]
  374. closed_age = (datetime.utcnow() - case.close_time).total_seconds()
  375. if timestamps:
  376. modify_age = (datetime.utcnow() - max(timestamps)).total_seconds()
  377. else:
  378. modify_age = 0
  379. if closed_age > 60 * 60 * 24 and modify_age > 60 * 60 * 24:
  380. arch_top = self.tl_archive_top
  381. arch_bottom = self.tl_archive_bottom
  382. reg = r"<!-- \[\[User:DoNotArchiveUntil\]\] .*? -->(<!-- .*? -->)?"
  383. if re.search(r"\{\{\s*" + arch_top, case.body):
  384. if re.search(reg, case.body):
  385. case.body = re.sub(reg, "", case.body)
  386. else:
  387. if re.search(reg, case.body):
  388. case.body = re.sub(r"\{\{" + arch_top + r"\}\}", "", case.body)
  389. case.body = re.sub(reg, "{{" + arch_top + "}}", case.body)
  390. if not re.search(arch_bottom + r"\s*\}\}\s*\Z", case.body):
  391. case.body += "\n{{" + arch_bottom + "}}"
  392. case.archived = True
  393. self.logger.debug(u" {0}: archived case".format(case.id))
  394. def check_for_needassist(self, case):
  395. """Check whether a case is old enough to be set to "needassist"."""
  396. age = (datetime.utcnow() - case.file_time).total_seconds()
  397. if age > 60 * 60 * 24 * 7:
  398. self.update_status(case, self.STATUS_NEEDASSIST)
  399. return True
  400. return False
  401. def update_status(self, case, new):
  402. """Safely update the status of a case, so we don't edit war."""
  403. old_n = self.ALIASES[case.status][0].upper()
  404. new_n = self.ALIASES[new][0].upper()
  405. old_n = "NEW" if not old_n else old_n
  406. new_n = "NEW" if not new_n else new_n
  407. if case.last_action != new:
  408. case.status = new
  409. log = u" {0}: {1} -> {2}"
  410. self.logger.debug(log.format(case.id, old_n, new_n))
  411. return
  412. log = u"Avoiding {0} {1} -> {2} because we already did this ('{3}')"
  413. self.logger.info(log.format(case.id, old_n, new_n, case.title))
  414. def read_signatures(self, text):
  415. """Return a list of all parseable signatures in the body of a case.
  416. Signatures are returned as tuples of (editor, timestamp as datetime).
  417. """
  418. regex = r"\[\[(?:User(?:\stalk)?\:|Special\:Contributions\/)"
  419. regex += r"([^\n\[\]|]{,256}?)(?:\||\]\])"
  420. regex += r"(?!.*?(?:User(?:\stalk)?\:|Special\:Contributions\/).*?)"
  421. regex += r".{,256}?(\d{2}:\d{2},\s\d{1,2}\s\w+\s\d{4}\s\(UTC\))"
  422. matches = re.findall(regex, text, re.U|re.I)
  423. signatures = []
  424. for userlink, stamp in matches:
  425. username = userlink.split("/", 1)[0].replace("_", " ").strip()
  426. username = username[0].upper() + username[1:]
  427. if username == "DoNotArchiveUntil":
  428. continue
  429. stamp = stamp.strip()
  430. timestamp = datetime.strptime(stamp, "%H:%M, %d %B %Y (UTC)")
  431. signatures.append((username, timestamp))
  432. return signatures
  433. def get_signatures_from_db(self, conn, case):
  434. """Return a list of signatures in a case from the database.
  435. The return type is the same as read_signatures().
  436. """
  437. query = "SELECT signature_username, signature_timestamp FROM signatures WHERE signature_case = ?"
  438. with conn.cursor() as cursor:
  439. cursor.execute(query, (case.id,))
  440. return cursor.fetchall()
  441. def notify_parties(self, case):
  442. """Schedule notices to be sent to all parties of a case."""
  443. if case.parties_notified:
  444. return []
  445. notices = []
  446. template = "{{subst:" + self.tl_notify_party
  447. template += "|thread=" + case.title + "}} ~~~~"
  448. too_late = "<!--Template:DRN-notice-->"
  449. re_parties = "<span.*?>'''Users involved'''</span>(.*?)<span.*?>"
  450. text = re.search(re_parties, case.body, re.S|re.U)
  451. for line in text.group(1).splitlines():
  452. user = re.search("[:*#]{,5} \{\{User\|(.*?)\}\}", line)
  453. if user:
  454. party = user.group(1).replace("_", " ").strip()
  455. if party.startswith("User:"):
  456. party = party[len("User:"):]
  457. if party:
  458. party = party[0].upper() + party[1:]
  459. if party == case.file_user:
  460. continue
  461. notice = _Notice("User talk:" + party, template, too_late)
  462. notices.append(notice)
  463. case.parties_notified = True
  464. log = u" {0}: will try to notify {1} parties with '{2}'"
  465. self.logger.debug(log.format(case.id, len(notices), template))
  466. return notices
  467. def add_missing_reflist(self, case):
  468. """Add {{reflist-talk}} to a case if it has <ref>s and no reflist."""
  469. code = mw_parse(case.body)
  470. if code.filter_tags(matches=lambda t: t.name.lower() == "ref"):
  471. if any(s in case.body.lower() for s in ("reflist", "<references")):
  472. return
  473. case.body += "\n===References===\n{{reflist-talk|close=1}}\n"
  474. def save_case_updates(self, conn, case, volunteers, sigs, storedsigs):
  475. """Save any updates made to a case and signatures in the database."""
  476. if case.status != case.original_status:
  477. case.last_action = case.status
  478. new = self.ALIASES[case.status][0]
  479. tl_status_esc = re.escape(self.tl_status)
  480. search = "\{\{" + tl_status_esc + "(\|?.*?)\}\}"
  481. repl = "{{" + self.tl_status + "|" + new + "}}"
  482. case.body = re.sub(search, repl, case.body)
  483. if sigs:
  484. newest_ts = max([stamp for (user, stamp) in sigs])
  485. newest_user = [usr for (usr, stamp) in sigs if stamp == newest_ts][0]
  486. case.modify_time = newest_ts
  487. case.modify_user = newest_user
  488. if any([usr in volunteers for (usr, stamp) in sigs]):
  489. newest_vts = max([stamp for (usr, stamp) in sigs if usr in volunteers])
  490. newest_vuser = [usr for (usr, stamp) in sigs if stamp == newest_vts][0]
  491. case.volunteer_time = newest_vts
  492. case.volunteer_user = newest_vuser
  493. if case.new:
  494. self.save_new_case(conn, case)
  495. else:
  496. self.save_existing_case(conn, case)
  497. with conn.cursor() as cursor:
  498. query1 = "DELETE FROM signatures WHERE signature_case = ? AND signature_username = ? AND signature_timestamp = ?"
  499. query2 = "INSERT INTO signatures (signature_case, signature_username, signature_timestamp) VALUES (?, ?, ?)"
  500. removals = set(storedsigs) - set(sigs)
  501. additions = set(sigs) - set(storedsigs)
  502. if removals:
  503. args = [(case.id, name, stamp) for (name, stamp) in removals]
  504. cursor.executemany(query1, args)
  505. if additions:
  506. args = []
  507. for name, stamp in additions:
  508. args.append((case.id, name, stamp))
  509. cursor.executemany(query2, args)
  510. msg = u" {0}: added {1} signatures and removed {2}"
  511. log = msg.format(case.id, len(additions), len(removals))
  512. self.logger.debug(log)
  513. def save_new_case(self, conn, case):
  514. """Save a brand new case to the database."""
  515. args = (case.id, case.title, case.status, case.last_action,
  516. case.file_user, case.file_time, case.modify_user,
  517. case.modify_time, case.volunteer_user, case.volunteer_time,
  518. case.close_time, case.parties_notified,
  519. case.very_old_notified, case.archived,
  520. case.last_volunteer_size)
  521. with conn.cursor() as cursor:
  522. query = "INSERT INTO cases VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
  523. cursor.execute(query, args)
  524. log = u" {0}: inserted new case into database".format(case.id)
  525. self.logger.debug(log)
  526. def save_existing_case(self, conn, case):
  527. """Save an existing case to the database, updating as necessary."""
  528. with conn.cursor(oursql.DictCursor) as cursor:
  529. query = "SELECT * FROM cases WHERE case_id = ?"
  530. cursor.execute(query, (case.id,))
  531. stored = cursor.fetchone()
  532. with conn.cursor() as cursor:
  533. changes, args = [], []
  534. fields_to_check = [
  535. ("case_status", case.status),
  536. ("case_last_action", case.last_action),
  537. ("case_file_user", case.file_user),
  538. ("case_file_time", case.file_time),
  539. ("case_modify_user", case.modify_user),
  540. ("case_modify_time", case.modify_time),
  541. ("case_volunteer_user", case.volunteer_user),
  542. ("case_volunteer_time", case.volunteer_time),
  543. ("case_close_time", case.close_time),
  544. ("case_parties_notified", case.parties_notified),
  545. ("case_very_old_notified", case.very_old_notified),
  546. ("case_archived", case.archived),
  547. ("case_last_volunteer_size", case.last_volunteer_size)
  548. ]
  549. for column, data in fields_to_check:
  550. if data != stored[column]:
  551. changes.append(column + " = ?")
  552. args.append(data)
  553. msg = u" {0}: will alter {1} ('{2}' -> '{3}')"
  554. log = msg.format(case.id, column, stored[column], data)
  555. self.logger.debug(log)
  556. if changes:
  557. changes = ", ".join(changes)
  558. args.append(case.id)
  559. query = "UPDATE cases SET {0} WHERE case_id = ?".format(changes)
  560. cursor.execute(query, args)
  561. else:
  562. log = u" {0}: no changes to commit".format(case.id)
  563. self.logger.debug(log)
  564. def save(self, page, cases, kwargs, start):
  565. """Save any changes to the noticeboard."""
  566. newtext = text = page.get()
  567. counter = 0
  568. for case in cases:
  569. if case.old != case.body:
  570. newtext = newtext.replace(case.old, case.body)
  571. counter += 1
  572. if newtext == text:
  573. self.logger.info(u"Nothing to edit on [[{0}]]".format(page.title))
  574. return True
  575. worktime = time() - start
  576. if worktime < 60:
  577. log = "Waiting {0} seconds to avoid edit conflicts"
  578. self.logger.debug(log.format(int(60 - worktime)))
  579. sleep(60 - worktime)
  580. page.reload()
  581. if page.get() != text:
  582. log = "Someone has edited the page while we were working; restarting"
  583. self.logger.warn(log)
  584. self.run(**kwargs)
  585. return False
  586. summary = self.clerk_summary.replace("$3", str(counter))
  587. summary = summary.replace("$4", "" if counter == 1 else "s")
  588. page.edit(newtext, summary, minor=True, bot=True)
  589. log = u"Saved page [[{0}]] ({1} updates)"
  590. self.logger.info(log.format(page.title, counter))
  591. return True
  592. def send_notices(self, site, notices):
  593. """Send out any templated notices to users or pages."""
  594. if not notices:
  595. self.logger.info("No notices to send")
  596. return
  597. for notice in notices:
  598. target, template = notice.target, notice.template
  599. log = u"Trying to notify [[{0}]] with '{1}'"
  600. self.logger.debug(log.format(target, template))
  601. page = site.get_page(target, follow_redirects=True)
  602. if page.namespace == constants.NS_USER_TALK:
  603. user = site.get_user(target.split(":", 1)[1])
  604. if not user.exists and not user.is_ip:
  605. log = u"Skipping [[{0}]]; user does not exist and is not an IP"
  606. self.logger.info(log.format(target))
  607. continue
  608. try:
  609. text = page.get()
  610. except exceptions.PageNotFoundError:
  611. text = ""
  612. if notice.too_late and notice.too_late in text:
  613. log = u"Skipping [[{0}]]; was already notified with '{1}'"
  614. self.logger.info(log.format(page.title, template))
  615. continue
  616. text += ("\n" if text else "") + template
  617. try:
  618. page.edit(text, self.notify_summary, minor=False, bot=True)
  619. except exceptions.EditError as error:
  620. name, msg = type(error).name, error.message
  621. log = u"Couldn't leave notice on [[{0}]] because of {1}: {2}"
  622. self.logger.error(log.format(page.title, name, msg))
  623. else:
  624. log = u"Notified [[{0}]] with '{1}'"
  625. self.logger.info(log.format(page.title, template))
  626. self.logger.debug("Done sending notices")
  627. def update_chart(self, conn, site):
  628. """Update the chart of open or recently closed cases."""
  629. page = site.get_page(self.chart_title)
  630. self.logger.info(u"Updating case status at [[{0}]]".format(page.title))
  631. statuses = self.compile_chart(conn)
  632. text = page.get()
  633. newtext = re.sub(u"<!-- status begin -->(.*?)<!-- status end -->",
  634. "<!-- status begin -->\n" + statuses + "\n<!-- status end -->",
  635. text, flags=re.DOTALL)
  636. if newtext == text:
  637. self.logger.info("Chart unchanged; not saving")
  638. return
  639. newtext = re.sub("<!-- sig begin -->(.*?)<!-- sig end -->",
  640. "<!-- sig begin -->~~~ at ~~~~~<!-- sig end -->",
  641. newtext)
  642. page.edit(newtext, self.chart_summary, minor=True, bot=True)
  643. self.logger.info(u"Chart saved to [[{0}]]".format(page.title))
  644. def compile_chart(self, conn):
  645. """Actually generate the chart from the database."""
  646. chart = "{{" + self.tl_chart_header + "|small={{{small|}}}|collapsed={{{collapsed|}}}}}\n"
  647. query = "SELECT * FROM cases WHERE case_status != ?"
  648. with conn.cursor(oursql.DictCursor) as cursor:
  649. cursor.execute(query, (self.STATUS_UNKNOWN,))
  650. for case in cursor:
  651. chart += self.compile_row(case)
  652. chart += "{{" + self.tl_chart_footer + "|small={{{small|}}}}}"
  653. return chart
  654. def compile_row(self, case):
  655. """Generate a single row of the chart from a dict via the database."""
  656. data = u"|t={case_title}|d={title}|s={case_status}"
  657. data += "|cu={case_file_user}|cs={file_sortkey}|ct={file_time}"
  658. if case["case_volunteer_user"]:
  659. data += "|vu={case_volunteer_user}|vs={volunteer_sortkey}|vt={volunteer_time}"
  660. case["volunteer_time"] = self.format_time(case["case_volunteer_time"])
  661. case["volunteer_sortkey"] = int(mktime(case["case_volunteer_time"].timetuple()))
  662. data += "|mu={case_modify_user}|ms={modify_sortkey}|mt={modify_time}"
  663. case["case_title"] = mw_parse(case["case_title"]).strip_code()
  664. title = case["case_title"].replace("_", " ").replace("|", "&#124;")
  665. case["title"] = title[:47] + "..." if len(title) > 50 else title
  666. case["file_time"] = self.format_time(case["case_file_time"])
  667. case["file_sortkey"] = int(mktime(case["case_file_time"].timetuple()))
  668. case["modify_time"] = self.format_time(case["case_modify_time"])
  669. case["modify_sortkey"] = int(mktime(case["case_modify_time"].timetuple()))
  670. row = "{{" + self.tl_chart_row + data.format(**case)
  671. return row + "|sm={{{small|}}}}}\n"
  672. def format_time(self, dt):
  673. """Return a string telling the time since *dt* occurred."""
  674. parts = [("year", 31536000), ("day", 86400), ("hour", 3600)]
  675. seconds = int((datetime.utcnow() - dt).total_seconds())
  676. if seconds < 0:
  677. return "Invalid future time"
  678. msg = []
  679. for name, size in parts:
  680. num = seconds // size
  681. seconds -= num * size
  682. if num:
  683. chunk = "{0} {1}".format(num, name if num == 1 else name + "s")
  684. msg.append(chunk)
  685. return ", ".join(msg) + " ago" if msg else "0 hours ago"
  686. def purge_old_data(self, conn):
  687. """Delete old cases (> six months) from the database."""
  688. log = "Purging closed cases older than six months from the database"
  689. self.logger.info(log)
  690. query = """DELETE cases, signatures
  691. FROM cases JOIN signatures ON case_id = signature_case
  692. WHERE case_status = ?
  693. AND case_file_time < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 180 DAY)
  694. AND case_modify_time < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 180 DAY)
  695. """
  696. with conn.cursor() as cursor:
  697. cursor.execute(query, (self.STATUS_UNKNOWN,))
  698. class _Case(object):
  699. """A object representing a dispute resolution case."""
  700. def __init__(self, id_, title, status, last_action, file_user, file_time,
  701. modify_user, modify_time, volunteer_user, volunteer_time,
  702. close_time, parties_notified, archived, very_old_notified,
  703. last_volunteer_size, new=False):
  704. self.id = id_
  705. self.title = title
  706. self.status = status
  707. self.last_action = last_action
  708. self.file_user = file_user
  709. self.file_time = file_time
  710. self.modify_user = modify_user
  711. self.modify_time = modify_time
  712. self.volunteer_user = volunteer_user
  713. self.volunteer_time = volunteer_time
  714. self.close_time = close_time
  715. self.parties_notified = parties_notified
  716. self.very_old_notified = very_old_notified
  717. self.archived = archived
  718. self.last_volunteer_size = last_volunteer_size
  719. self.new = new
  720. self.original_status = status
  721. self.body = None
  722. self.old = None
  723. class _Notice(object):
  724. """An object representing a notice to be sent to a user or a page."""
  725. def __init__(self, target, template, too_late=None):
  726. self.target = target
  727. self.template = template
  728. self.too_late = too_late