@@ -0,0 +1,6 @@ | |||||
*.pyc | |||||
*.egg | |||||
*.egg-info | |||||
.DS_Store | |||||
build | |||||
docs/_build |
@@ -0,0 +1,34 @@ | |||||
# -*- 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 earwigbot.commands import Command | |||||
class AFCPending(Command): | |||||
"""Link the user to the pending AFC submissions page and category.""" | |||||
name = "pending" | |||||
commands = ["pending", "pend"] | |||||
def process(self, data): | |||||
msg1 = "Pending submissions status page: http://enwp.org/WP:AFC/ST" | |||||
msg2 = "Pending submissions category: http://enwp.org/CAT:PEND" | |||||
self.reply(data, msg1) | |||||
self.reply(data, msg2) |
@@ -0,0 +1,113 @@ | |||||
# -*- 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 earwigbot import wiki | |||||
from earwigbot.commands import Command | |||||
class AFCReport(Command): | |||||
"""Get information about an AFC submission by name.""" | |||||
name = "report" | |||||
def process(self, data): | |||||
self.site = self.bot.wiki.get_site() | |||||
self.data = data | |||||
try: | |||||
self.statistics = self.bot.tasks.get("afc_statistics") | |||||
except KeyError: | |||||
e = "Cannot run command: requires afc_statistics task (from earwigbot_plugins)" | |||||
self.logger.error(e) | |||||
msg = "command requires afc_statistics task (from earwigbot_plugins)" | |||||
self.reply(data, msg) | |||||
return | |||||
if not data.args: | |||||
msg = "What submission do you want me to give information about?" | |||||
self.reply(data, msg) | |||||
return | |||||
title = " ".join(data.args) | |||||
title = title.replace("http://en.wikipedia.org/wiki/", "") | |||||
title = title.replace("http://enwp.org/", "").strip() | |||||
# Given '!report Foo', first try [[Foo]]: | |||||
page = self.get_page(title) | |||||
if page: | |||||
return self.report(page) | |||||
# Then try [[Wikipedia:Articles for creation/Foo]]: | |||||
newtitle = "/".join(("Wikipedia:Articles for creation", title)) | |||||
page = self.get_page(newtitle) | |||||
if page: | |||||
return self.report(page) | |||||
# Then try [[Wikipedia talk:Articles for creation/Foo]]: | |||||
newtitle = "/".join(("Wikipedia talk:Articles for creation", title)) | |||||
page = self.get_page(newtitle) | |||||
if page: | |||||
return self.report(page) | |||||
self.reply(data, "Submission \x0302{0}\x0F not found.".format(title)) | |||||
def get_page(self, title): | |||||
page = self.site.get_page(title, follow_redirects=False) | |||||
if page.exists == page.PAGE_EXISTS: | |||||
return page | |||||
def report(self, page): | |||||
url = page.url.encode("utf8") | |||||
url = url.replace("en.wikipedia.org/wiki", "enwp.org") | |||||
short = self.statistics.get_short_title(page.title) | |||||
status = self.get_status(page) | |||||
user = page.get_creator() | |||||
user_name = user.name | |||||
user_url = user.get_talkpage().url.encode("utf8") | |||||
msg1 = "AfC submission report for \x0302{0}\x0F ({1}):" | |||||
msg2 = "Status: \x0303{0}\x0F" | |||||
msg3 = "Submitted by \x0302{0}\x0F ({1})" | |||||
if status == "accepted": | |||||
msg3 = "Reviewed by \x0302{0}\x0F ({1})" | |||||
self.reply(self.data, msg1.format(short, url)) | |||||
self.say(self.data.chan, msg2.format(status)) | |||||
self.say(self.data.chan, msg3.format(user_name, user_url)) | |||||
def get_status(self, page): | |||||
if page.is_redirect: | |||||
target = page.get_redirect_target() | |||||
if self.site.get_page(target).namespace == wiki.NS_MAIN: | |||||
return "accepted" | |||||
return "redirect" | |||||
statuses = self.statistics.get_statuses(page.get()) | |||||
if "R" in statuses: | |||||
return "being reviewed" | |||||
elif "H" in statuses: | |||||
return "pending draft" | |||||
elif "P" in statuses: | |||||
return "pending submission" | |||||
elif "T" in statuses: | |||||
return "unsubmitted draft" | |||||
elif "D" in statuses: | |||||
return "declined" | |||||
return "unkown" |
@@ -0,0 +1,162 @@ | |||||
# -*- 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. | |||||
import re | |||||
from earwigbot.commands import Command | |||||
class AFCStatus(Command): | |||||
"""Get the number of pending AfC submissions, open redirect requests, and | |||||
open file upload requests.""" | |||||
name = "status" | |||||
commands = ["status", "count", "num", "number"] | |||||
hooks = ["join", "msg"] | |||||
def check(self, data): | |||||
if data.is_command and data.command in self.commands: | |||||
return True | |||||
try: | |||||
if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": | |||||
if data.nick != self.config.irc["frontend"]["nick"]: | |||||
return True | |||||
except IndexError: | |||||
pass | |||||
return False | |||||
def process(self, data): | |||||
self.site = self.bot.wiki.get_site() | |||||
if data.line[1] == "JOIN": | |||||
status = " ".join(("\x02Current status:\x0F", self.get_status())) | |||||
self.notice(data.nick, status) | |||||
return | |||||
if data.args: | |||||
action = data.args[0].lower() | |||||
if action.startswith("sub") or action == "s": | |||||
subs = self.count_submissions() | |||||
msg = "There are \x0305{0}\x0F pending AfC submissions (\x0302WP:AFC\x0F)." | |||||
self.reply(data, msg.format(subs)) | |||||
elif action.startswith("redir") or action == "r": | |||||
redirs = self.count_redirects() | |||||
msg = "There are \x0305{0}\x0F open redirect requests (\x0302WP:AFC/R\x0F)." | |||||
self.reply(data, msg.format(redirs)) | |||||
elif action.startswith("file") or action == "f": | |||||
files = self.count_redirects() | |||||
msg = "There are \x0305{0}\x0F open file upload requests (\x0302WP:FFU\x0F)." | |||||
self.reply(data, msg.format(files)) | |||||
elif action.startswith("agg") or action == "a": | |||||
try: | |||||
agg_num = int(data.args[1]) | |||||
except IndexError: | |||||
agg_data = (self.count_submissions(), | |||||
self.count_redirects(), self.count_files()) | |||||
agg_num = self.get_aggregate_number(agg_data) | |||||
except ValueError: | |||||
msg = "\x0303{0}\x0F isn't a number!" | |||||
self.reply(data, msg.format(data.args[1])) | |||||
return | |||||
aggregate = self.get_aggregate(agg_num) | |||||
msg = "Aggregate is \x0305{0}\x0F (AfC {1})." | |||||
self.reply(data, msg.format(agg_num, aggregate)) | |||||
elif action.startswith("nocolor") or action == "n": | |||||
self.reply(data, self.get_status(color=False)) | |||||
else: | |||||
msg = "Unknown argument: \x0303{0}\x0F. Valid args are 'subs', 'redirs', 'files', 'agg', 'nocolor'." | |||||
self.reply(data, msg.format(data.args[0])) | |||||
else: | |||||
self.reply(data, self.get_status()) | |||||
def get_status(self, color=True): | |||||
subs = self.count_submissions() | |||||
redirs = self.count_redirects() | |||||
files = self.count_files() | |||||
agg_num = self.get_aggregate_number((subs, redirs, files)) | |||||
aggregate = self.get_aggregate(agg_num) | |||||
if color: | |||||
msg = "Articles for creation {0} (\x0302AFC\x0F: \x0305{1}\x0F; \x0302AFC/R\x0F: \x0305{2}\x0F; \x0302FFU\x0F: \x0305{3}\x0F)." | |||||
else: | |||||
msg = "Articles for creation {0} (AFC: {1}; AFC/R: {2}; FFU: {3})." | |||||
return msg.format(aggregate, subs, redirs, files) | |||||
def count_submissions(self): | |||||
"""Returns the number of open AFC submissions (count of CAT:PEND).""" | |||||
# Subtract two for [[Wikipedia:Articles for creation/Redirects]] and | |||||
# [[Wikipedia:Files for upload]], which aren't real submissions: | |||||
return self.site.get_category("Pending AfC submissions").pages - 2 | |||||
def count_redirects(self): | |||||
"""Returns the number of open redirect submissions. Calculated as the | |||||
total number of submissions minus the closed ones.""" | |||||
title = "Wikipedia:Articles for creation/Redirects" | |||||
content = self.site.get_page(title).get() | |||||
total = len(re.findall("^\s*==(.*?)==\s*$", content, re.MULTILINE)) | |||||
closed = content.lower().count("{{afc-c|b}}") | |||||
redirs = total - closed | |||||
return redirs | |||||
def count_files(self): | |||||
"""Returns the number of open WP:FFU (Files For Upload) requests. | |||||
Calculated as the total number of requests minus the closed ones.""" | |||||
content = self.site.get_page("Wikipedia:Files for upload").get() | |||||
total = len(re.findall("^\s*==(.*?)==\s*$", content, re.MULTILINE)) | |||||
closed = content.lower().count("{{ifu-c|b}}") | |||||
files = total - closed | |||||
return files | |||||
def get_aggregate(self, num): | |||||
"""Returns a human-readable AFC status based on the number of pending | |||||
AFC submissions, open redirect requests, and open FFU requests. This | |||||
does not match {{AFC status}} directly because the algorithm factors in | |||||
WP:AFC/R and WP:FFU while the template only looks at the main | |||||
submissions. The reasoning is that AFC/R and FFU are still part of | |||||
the project, so even if there are no pending submissions, a backlog at | |||||
FFU (for example) indicates that our work is *not* done and the | |||||
project-wide backlog is most certainly *not* clear.""" | |||||
if num == 0: | |||||
return "is \x02\x0303clear\x0F" | |||||
elif num <= 200: | |||||
return "is \x0303almost clear\x0F" | |||||
elif num <= 400: | |||||
return "is \x0312normal\x0F" | |||||
elif num <= 600: | |||||
return "is \x0307lightly backlogged\x0F" | |||||
elif num <= 900: | |||||
return "is \x0304backlogged\x0F" | |||||
elif num <= 1200: | |||||
return "is \x02\x0304heavily backlogged\x0F" | |||||
else: | |||||
return "is \x02\x1F\x0304severely backlogged\x0F" | |||||
def get_aggregate_number(self, (subs, redirs, files)): | |||||
"""Returns an 'aggregate number' based on the real number of pending | |||||
submissions in CAT:PEND (subs), open redirect submissions in WP:AFC/R | |||||
(redirs), and open files-for-upload requests in WP:FFU (files).""" | |||||
num = subs + (redirs / 2) + (files / 2) | |||||
return num |
@@ -0,0 +1,59 @@ | |||||
# -*- 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 earwigbot.commands import Command | |||||
class AFCSubmissions(Command): | |||||
"""Link the user directly to some pending AFC submissions.""" | |||||
name = "submissions" | |||||
commands = ["submissions", "subs"] | |||||
def setup(self): | |||||
try: | |||||
self.ignore_list = self.config.commands[self.name]["ignoreList"] | |||||
except KeyError: | |||||
try: | |||||
ignores = self.config.tasks["afc_statistics"]["ignoreList"] | |||||
self.ignore_list = ignores | |||||
except KeyError: | |||||
self.ignore_list = [] | |||||
def process(self, data): | |||||
if data.args: | |||||
try: | |||||
number = int(data.args[0]) | |||||
except ValueError: | |||||
self.reply(data, "Argument must be a number.") | |||||
return | |||||
if number > 5: | |||||
msg = "Cannot get more than five submissions at a time." | |||||
self.reply(data, msg) | |||||
return | |||||
else: | |||||
number = 3 | |||||
site = self.bot.wiki.get_site() | |||||
category = site.get_category("Pending AfC submissions") | |||||
members = category.get_members(limit=number + len(self.ignore_list)) | |||||
urls = [member.url.encode("utf8") for member in members if member.title not in self.ignore_list] | |||||
pages = ", ".join(urls[:number]) | |||||
self.reply(data, "{0} pending AfC subs: {1}".format(number, pages)) |
@@ -0,0 +1,74 @@ | |||||
# -*- 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. | |||||
import json | |||||
import urllib2 | |||||
from earwigbot.commands import Command | |||||
class Geolocate(Command): | |||||
"""Geolocate an IP address (via http://ipinfodb.com/).""" | |||||
name = "geolocate" | |||||
commands = ["geolocate", "locate", "geo", "ip"] | |||||
def setup(self): | |||||
self.config.decrypt(self.config.commands, self.name, "apiKey") | |||||
try: | |||||
self.key = self.config.commands[self.name]["apiKey"] | |||||
except KeyError: | |||||
self.key = None | |||||
log = 'Cannot use without an API key for http://ipinfodb.com/ stored as config.commands["{0}"]["apiKey"]' | |||||
self.logger.warn(log.format(self.name)) | |||||
def process(self, data): | |||||
if not data.args: | |||||
self.reply(data, "Please specify an IP to lookup.") | |||||
return | |||||
if not self.key: | |||||
msg = 'I need an API key for http://ipinfodb.com/ stored as \x0303config.commands["{0}"]["apiKey"]\x0F.' | |||||
log = 'Need an API key for http://ipinfodb.com/ stored as config.commands["{0}"]["apiKey"]' | |||||
self.reply(data, msg.format(self.name) + ".") | |||||
self.logger.error(log.format(self.name)) | |||||
return | |||||
address = data.args[0] | |||||
url = "http://api.ipinfodb.com/v3/ip-city/?key={0}&ip={1}&format=json" | |||||
query = urllib2.urlopen(url.format(self.key, address)).read() | |||||
res = json.loads(query) | |||||
country = res["countryName"].title() | |||||
region = res["regionName"].title() | |||||
city = res["cityName"].title() | |||||
latitude = res["latitude"] | |||||
longitude = res["longitude"] | |||||
utcoffset = res["timeZone"] | |||||
if not country and not region and not city: | |||||
self.reply(data, "IP \x0302{0}\x0F not found.".format(address)) | |||||
return | |||||
if country == "-" and region == "-" and city == "-": | |||||
self.reply(data, "IP \x0302{0}\x0F is reserved.".format(address)) | |||||
return | |||||
msg = "{0}, {1}, {2} ({3}, {4}), UTC {5}" | |||||
geo = msg.format(country, region, city, latitude, longitude, utcoffset) | |||||
self.reply(data, geo) |
@@ -0,0 +1,240 @@ | |||||
# -*- 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. | |||||
import time | |||||
import git | |||||
from earwigbot.commands import Command | |||||
class Git(Command): | |||||
"""Commands to interface with the bot's git repository; use '!git' for a | |||||
sub-command list.""" | |||||
name = "git" | |||||
def setup(self): | |||||
try: | |||||
self.repos = self.config.commands[self.name]["repos"] | |||||
except KeyError: | |||||
self.repos = None | |||||
def process(self, data): | |||||
self.data = data | |||||
if not self.config.irc["permissions"].is_owner(data): | |||||
msg = "You must be a bot owner to use this command." | |||||
self.reply(data, msg) | |||||
return | |||||
if not data.args or data.args[0] == "help": | |||||
self.do_help() | |||||
return | |||||
if not self.repos: | |||||
self.reply(data, "No repos are specified in the config file.") | |||||
return | |||||
command = data.args[0] | |||||
try: | |||||
repo_name = data.args[1] | |||||
except IndexError: | |||||
repos = self.get_repos() | |||||
msg = "Which repo do you want to work with (options are {0})?" | |||||
self.reply(data, msg.format(repos)) | |||||
return | |||||
if repo_name not in self.repos: | |||||
repos = self.get_repos() | |||||
msg = "Repository must be one of the following: {0}." | |||||
self.reply(data, msg.format(repos)) | |||||
return | |||||
self.repo = git.Repo(self.repos[repo_name]) | |||||
if command == "branch": | |||||
self.do_branch() | |||||
elif command == "branches": | |||||
self.do_branches() | |||||
elif command == "checkout": | |||||
self.do_checkout() | |||||
elif command == "delete": | |||||
self.do_delete() | |||||
elif command == "pull": | |||||
self.do_pull() | |||||
elif command == "status": | |||||
self.do_status() | |||||
else: # They asked us to do something we don't know | |||||
msg = "Unknown argument: \x0303{0}\x0F.".format(data.args[0]) | |||||
self.reply(data, msg) | |||||
def get_repos(self): | |||||
data = self.repos.iteritems() | |||||
repos = ["\x0302{0}\x0F ({1})".format(k, v) for k, v in data] | |||||
return ", ".join(repos) | |||||
def get_remote(self): | |||||
try: | |||||
remote_name = self.data.args[2] | |||||
except IndexError: | |||||
remote_name = "origin" | |||||
try: | |||||
return getattr(self.repo.remotes, remote_name) | |||||
except AttributeError: | |||||
msg = "Unknown remote: \x0302{0}\x0F.".format(remote_name) | |||||
self.reply(self.data, msg) | |||||
def get_time_since(self, date): | |||||
diff = time.mktime(time.gmtime()) - date | |||||
if diff < 60: | |||||
return "{0} seconds".format(int(diff)) | |||||
if diff < 60 * 60: | |||||
return "{0} minutes".format(int(diff / 60)) | |||||
if diff < 60 * 60 * 24: | |||||
return "{0} hours".format(int(diff / 60 / 60)) | |||||
return "{0} days".format(int(diff / 60 / 60 / 24)) | |||||
def do_help(self): | |||||
"""Display all commands.""" | |||||
help = { | |||||
"branch": "get current branch", | |||||
"branches": "get all branches", | |||||
"checkout": "switch branches", | |||||
"delete": "delete an old branch", | |||||
"pull": "update everything from the remote server", | |||||
"status": "check if we are up-to-date", | |||||
} | |||||
subcommands = "" | |||||
for key in sorted(help.keys()): | |||||
subcommands += "\x0303{0}\x0F ({1}), ".format(key, help[key]) | |||||
subcommands = subcommands[:-2] # Trim last comma and space | |||||
msg = "Sub-commands are: {0}; repos are: {1}. Syntax: !git \x0303subcommand\x0F \x0302repo\x0F." | |||||
self.reply(self.data, msg.format(subcommands, self.get_repos())) | |||||
def do_branch(self): | |||||
"""Get our current branch.""" | |||||
branch = self.repo.active_branch.name | |||||
msg = "Currently on branch \x0302{0}\x0F.".format(branch) | |||||
self.reply(self.data, msg) | |||||
def do_branches(self): | |||||
"""Get a list of branches.""" | |||||
branches = [branch.name for branch in self.repo.branches] | |||||
msg = "Branches: \x0302{0}\x0F.".format(", ".join(branches)) | |||||
self.reply(self.data, msg) | |||||
def do_checkout(self): | |||||
"""Switch branches.""" | |||||
try: | |||||
target = self.data.args[2] | |||||
except IndexError: # No branch name provided | |||||
self.reply(self.data, "Wwitch to which branch?") | |||||
return | |||||
current_branch = self.repo.active_branch.name | |||||
if target == current_branch: | |||||
msg = "Already on \x0302{0}\x0F!".format(target) | |||||
self.reply(self.data, msg) | |||||
return | |||||
try: | |||||
ref = getattr(self.repo.branches, target) | |||||
except AttributeError: | |||||
msg = "Branch \x0302{0}\x0F doesn't exist!".format(target) | |||||
self.reply(self.data, msg) | |||||
else: | |||||
ref.checkout() | |||||
ms = "Switched from branch \x0302{0}\x0F to \x0302{1}\x0F." | |||||
msg = ms.format(current_branch, target) | |||||
self.reply(self.data, msg) | |||||
log = "{0} checked out branch {1} of {2}" | |||||
logmsg = log.format(self.data.nick, target, self.repo.working_dir) | |||||
self.logger.info(logmsg) | |||||
def do_delete(self): | |||||
"""Delete a branch, while making sure that we are not already on it.""" | |||||
try: | |||||
target = self.data.args[2] | |||||
except IndexError: # No branch name provided | |||||
self.reply(self.data, "Delete which branch?") | |||||
return | |||||
current_branch = self.repo.active_branch.name | |||||
if current_branch == target: | |||||
msg = "You're currently on this branch; please checkout to a different branch before deleting." | |||||
self.reply(self.data, msg) | |||||
return | |||||
try: | |||||
ref = getattr(self.repo.branches, target) | |||||
except AttributeError: | |||||
msg = "Branch \x0302{0}\x0F doesn't exist!".format(target) | |||||
self.reply(self.data, msg) | |||||
else: | |||||
self.repo.git.branch("-d", ref) | |||||
msg = "Branch \x0302{0}\x0F has been deleted locally." | |||||
self.reply(self.data, msg.format(target)) | |||||
log = "{0} deleted branch {1} of {2}" | |||||
logmsg = log.format(self.data.nick, target, self.repo.working_dir) | |||||
self.logger.info(logmsg) | |||||
def do_pull(self): | |||||
"""Pull from our remote repository.""" | |||||
branch = self.repo.active_branch.name | |||||
msg = "Pulling from remote (currently on \x0302{0}\x0F)..." | |||||
self.reply(self.data, msg.format(branch)) | |||||
remote = self.get_remote() | |||||
if not remote: | |||||
return | |||||
result = remote.pull() | |||||
updated = [info for info in result if info.flags != info.HEAD_UPTODATE] | |||||
if updated: | |||||
branches = ", ".join([info.ref.remote_head for info in updated]) | |||||
msg = "Done; updates to \x0302{0}\x0F (from {1})." | |||||
self.reply(self.data, msg.format(branches, remote.url)) | |||||
log = "{0} pulled {1} of {2} (updates to {3})" | |||||
self.logger.info(log.format(self.data.nick, remote.name, | |||||
self.repo.working_dir, branches)) | |||||
else: | |||||
self.reply(self.data, "Done; no new changes.") | |||||
log = "{0} pulled {1} of {2} (no updates)" | |||||
self.logger.info(log.format(self.data.nick, remote.name, | |||||
self.repo.working_dir)) | |||||
def do_status(self): | |||||
"""Check if we have anything to pull.""" | |||||
remote = self.get_remote() | |||||
if not remote: | |||||
return | |||||
since = self.get_time_since(self.repo.head.object.committed_date) | |||||
result = remote.fetch(dry_run=True) | |||||
updated = [info for info in result if info.flags != info.HEAD_UPTODATE] | |||||
if updated: | |||||
branches = ", ".join([info.ref.remote_head for info in updated]) | |||||
msg = "Last local commit was \x02{0}\x0F ago; updates to \x0302{1}\x0F." | |||||
self.reply(self.data, msg.format(since, branches)) | |||||
log = "{0} got status of {1} of {2} (updates to {3})" | |||||
self.logger.info(log.format(self.data.nick, remote.name, | |||||
self.repo.working_dir, branches)) | |||||
else: | |||||
msg = "Last commit was \x02{0}\x0F ago. Local copy is up-to-date with remote." | |||||
self.reply(self.data, msg.format(since)) | |||||
log = "{0} pulled {1} of {2} (no updates)" | |||||
self.logger.info(log.format(self.data.nick, remote.name, | |||||
self.repo.working_dir)) |
@@ -0,0 +1,48 @@ | |||||
# -*- 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 earwigbot.commands import Command | |||||
class Praise(Command): | |||||
"""Praise people!""" | |||||
name = "praise" | |||||
def setup(self): | |||||
try: | |||||
self.praises = self.config.commands[self.name]["praises"] | |||||
except KeyError: | |||||
self.praises = [] | |||||
def check(self, data): | |||||
check = data.command == "praise" or data.command in self.praises | |||||
return data.is_command and check | |||||
def process(self, data): | |||||
if data.command in self.praises: | |||||
msg = self.praises[data.command] | |||||
self.say(data.chan, msg) | |||||
return | |||||
if not data.args: | |||||
msg = "You use this command to praise certain people. Who they are is a secret." | |||||
else: | |||||
msg = "You're doing it wrong." | |||||
self.reply(data, msg) |
@@ -0,0 +1,34 @@ | |||||
# -*- 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 earwigbot.tasks import Task | |||||
class AFCCatDelink(Task): | |||||
"""A task to delink mainspace categories in declined [[WP:AFC]] | |||||
submissions.""" | |||||
name = "afc_catdelink" | |||||
def setup(self): | |||||
pass | |||||
def run(self, **kwargs): | |||||
pass |
@@ -0,0 +1,164 @@ | |||||
# -*- 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 hashlib import sha256 | |||||
from os.path import expanduser | |||||
from threading import Lock | |||||
from urllib import quote | |||||
import oursql | |||||
from earwigbot.tasks import Task | |||||
class AFCCopyvios(Task): | |||||
"""A task to check newly-edited [[WP:AFC]] submissions for copyright | |||||
violations.""" | |||||
name = "afc_copyvios" | |||||
number = 1 | |||||
def setup(self): | |||||
cfg = self.config.tasks.get(self.name, {}) | |||||
self.template = cfg.get("template", "AfC suspected copyvio") | |||||
self.ignore_list = cfg.get("ignoreList", []) | |||||
self.min_confidence = cfg.get("minConfidence", 0.5) | |||||
self.max_queries = cfg.get("maxQueries", 10) | |||||
self.cache_results = cfg.get("cacheResults", False) | |||||
default_summary = "Tagging suspected [[WP:COPYVIO|copyright violation]] of {url}." | |||||
self.summary = self.make_summary(cfg.get("summary", default_summary)) | |||||
# 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 = Lock() | |||||
def run(self, **kwargs): | |||||
"""Entry point for the bot task. | |||||
Takes a page title in kwargs and checks it for copyvios, adding | |||||
{{self.template}} at the top if a copyvio has been detected. A page is | |||||
only checked once (processed pages are stored by page_id in an SQL | |||||
database). | |||||
""" | |||||
if self.shutoff_enabled(): | |||||
return | |||||
title = kwargs["page"] | |||||
page = self.bot.wiki.get_site().get_page(title) | |||||
with self.db_access_lock: | |||||
self.conn = oursql.connect(**self.conn_data) | |||||
self.process(page) | |||||
def process(self, page): | |||||
"""Detect copyvios in 'page' and add a note if any are found.""" | |||||
title = page.title | |||||
if title in self.ignore_list: | |||||
msg = u"Skipping page in ignore list: [[{0}]]" | |||||
self.logger.info(msg.format(title)) | |||||
return | |||||
pageid = page.pageid | |||||
if self.has_been_processed(pageid): | |||||
msg = u"Skipping check on already processed page [[{0}]]" | |||||
self.logger.info(msg.format(title)) | |||||
return | |||||
self.logger.info(u"Checking [[{0}]]".format(title)) | |||||
result = page.copyvio_check(self.min_confidence, self.max_queries) | |||||
url = result.url | |||||
orig_conf = "{0}%".format(round(result.confidence * 100, 2)) | |||||
if result.violation: | |||||
# Things can change in the minute that it takes to do a check. | |||||
# Confirm that a violation still holds true: | |||||
page.load() | |||||
confirm = page.copyvio_compare(url, self.min_confidence) | |||||
new_conf = "{0}%".format(round(confirm.confidence * 100, 2)) | |||||
if not confirm.violation: | |||||
msg = u"A violation was detected in [[{0}]], but couldn't be confirmed." | |||||
msg += u" It may have just been edited (best: {1} at {2} -> {3} confidence)" | |||||
self.logger.info(msg.format(title, url, orig_conf, new_conf)) | |||||
safeurl = quote(url.encode("utf8"), safe="/:").decode("utf8") | |||||
content = page.get() | |||||
template = u"\{\{{0}|url={1}|confidence={2}\}\}\n" | |||||
template = template.format(self.template, safeurl, new_conf) | |||||
newtext = template + content | |||||
if "{url}" in self.summary: | |||||
page.edit(newtext, self.summary.format(url=url)) | |||||
else: | |||||
page.edit(newtext, self.summary) | |||||
msg = u"Found violation: [[{0}]] -> {1} ({2} confidence)" | |||||
self.logger.info(msg.format(title, url, new_conf)) | |||||
else: | |||||
msg = u"No violations detected in [[{0}]] (best: {1} at {2} confidence)" | |||||
self.logger.info(msg.format(title, url, orig_conf)) | |||||
self.log_processed(pageid) | |||||
if self.cache_results: | |||||
self.cache_result(page, result) | |||||
def has_been_processed(self, pageid): | |||||
"""Returns True if pageid was processed before, otherwise False.""" | |||||
query = "SELECT 1 FROM processed WHERE page_id = ?" | |||||
with self.conn.cursor() as cursor: | |||||
cursor.execute(query, (pageid,)) | |||||
results = cursor.fetchall() | |||||
return True if results else False | |||||
def log_processed(self, pageid): | |||||
"""Adds pageid to our database of processed pages. | |||||
Raises an exception if the page has already been processed. | |||||
""" | |||||
query = "INSERT INTO processed VALUES (?)" | |||||
with self.conn.cursor() as cursor: | |||||
cursor.execute(query, (pageid,)) | |||||
def cache_result(self, page, result): | |||||
"""Store the check's result in a cache table temporarily. | |||||
The cache contains the page's ID, a hash of its content, the URL of the | |||||
best match, the time of caching, and the number of queries used. It | |||||
will replace any existing cache entries for that page. | |||||
The cache is intended for EarwigBot's complementary Toolserver web | |||||
interface, in which copyvio checks can be done separately from the bot. | |||||
The cache saves time and money by saving the result of the web search | |||||
but neither the result of the comparison nor any actual text (which | |||||
could violate data retention policy). Cache entries are (intended to | |||||
be) retained for three days; this task does not remove old entries | |||||
(that is handled by the Toolserver component). | |||||
This will only be called if ``cache_results == True`` in the task's | |||||
config, which is ``False`` by default. | |||||
""" | |||||
pageid = page.pageid | |||||
hash = sha256(page.get()).hexdigest() | |||||
query1 = "SELECT 1 FROM cache WHERE cache_id = ?" | |||||
query2 = "DELETE FROM cache WHERE cache_id = ?" | |||||
query3 = "INSERT INTO cache VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?)" | |||||
with self.conn.cursor() as cursor: | |||||
cursor.execute(query1, (pageid,)) | |||||
if cursor.fetchall(): | |||||
cursor.execute(query2, (pageid,)) | |||||
args = (pageid, hash, result.url, result.queries, 0) | |||||
cursor.execute(query3, args) |
@@ -0,0 +1,34 @@ | |||||
# -*- 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 earwigbot.tasks import Task | |||||
class AFCDailyCats(Task): | |||||
"""A task to create daily categories for [[WP:AFC]].""" | |||||
name = "afc_dailycats" | |||||
number = 3 | |||||
def setup(self): | |||||
pass | |||||
def run(self, **kwargs): | |||||
pass |
@@ -0,0 +1,227 @@ | |||||
# -*- 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 collections import OrderedDict | |||||
from datetime import datetime, timedelta | |||||
from itertools import count | |||||
from os.path import expanduser | |||||
from threading import Lock | |||||
from time import sleep | |||||
from matplotlib import pyplot as plt | |||||
from numpy import arange | |||||
import oursql | |||||
from earwigbot import wiki | |||||
from earwigbot.tasks import Task | |||||
class AFCHistory(Task): | |||||
"""A task to generate charts about AfC submissions over time. | |||||
The main function of the task is to work through the "AfC submissions by | |||||
date" categories (e.g. [[Category:AfC submissions by date/12 July 2011]]) | |||||
and determine the number of declined, accepted, and currently pending | |||||
submissions every day. | |||||
This information is saved to a MySQL database ("u_earwig_afc_history") and | |||||
used to generate a graph showing the number of AfC submissions by date | |||||
with matplotlib and numpy. The chart is saved as a PNG to | |||||
config.tasks["afc_history"]["graph"]["dest"], which defaults to | |||||
"afc_history.png". | |||||
""" | |||||
name = "afc_history" | |||||
# Valid submission statuses: | |||||
STATUS_NONE = 0 | |||||
STATUS_PEND = 1 | |||||
STATUS_DECLINE = 2 | |||||
STATUS_ACCEPT = 3 | |||||
def setup(self): | |||||
cfg = self.config.tasks.get(self.name, {}) | |||||
self.num_days = cfg.get("days", 90) | |||||
self.categories = cfg.get("categories", {}) | |||||
# Graph stuff: | |||||
self.graph = cfg.get("graph", {}) | |||||
self.destination = self.graph.get("dest", "afc_history.png") | |||||
# 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 = Lock() | |||||
def run(self, **kwargs): | |||||
self.site = self.bot.wiki.get_site() | |||||
with self.db_access_lock: | |||||
self.conn = oursql.connect(**self.conn_data) | |||||
action = kwargs.get("action") | |||||
try: | |||||
num_days = int(kwargs.get("days", self.num_days)) | |||||
if action == "update": | |||||
self.update(num_days) | |||||
elif action == "generate": | |||||
self.generate(num_days) | |||||
finally: | |||||
self.conn.close() | |||||
def update(self, num_days): | |||||
self.logger.info("Updating past {0} days".format(num_days)) | |||||
generator = self.backwards_cat_iterator() | |||||
for i in xrange(num_days): | |||||
category = generator.next() | |||||
date = category.title.split("/")[-1] | |||||
self.update_date(date, category) | |||||
sleep(10) | |||||
self.logger.info("Update complete") | |||||
def generate(self, num_days): | |||||
self.logger.info("Generating chart for past {0} days".format(num_days)) | |||||
data = OrderedDict() | |||||
generator = self.backwards_cat_iterator() | |||||
for i in xrange(num_days): | |||||
category = generator.next() | |||||
date = category.title.split("/")[-1] | |||||
data[date] = self.get_date_counts(date) | |||||
data = OrderedDict(reversed(data.items())) # Oldest to most recent | |||||
self.generate_chart(data) | |||||
dest = expanduser(self.destination) | |||||
plt.savefig(dest) | |||||
self.logger.info("Chart saved to {0}".format(dest)) | |||||
def backwards_cat_iterator(self): | |||||
date_base = self.categories["dateBase"] | |||||
current = datetime.utcnow() | |||||
while 1: | |||||
subcat = current.strftime("%d %B %Y") | |||||
title = "/".join((date_base, subcat)) | |||||
yield self.site.get_category(title) | |||||
current -= timedelta(1) # Subtract one day from date | |||||
def update_date(self, date, category): | |||||
msg = "Updating {0} ([[{1}]])".format(date, category.title) | |||||
self.logger.debug(msg) | |||||
q_select = "SELECT page_date, page_status FROM page WHERE page_id = ?" | |||||
q_delete = "DELETE FROM page WHERE page_id = ?" | |||||
q_update = "UPDATE page SET page_date = ?, page_status = ? WHERE page_id = ?" | |||||
q_insert = "INSERT INTO page VALUES (?, ?, ?)" | |||||
members = category.get_members() | |||||
with self.conn.cursor() as cursor: | |||||
for title, pageid in members: | |||||
cursor.execute(q_select, (pageid,)) | |||||
stored = cursor.fetchall() | |||||
status = self.get_status(title, pageid) | |||||
if status == self.STATUS_NONE: | |||||
if stored: | |||||
cursor.execute(q_delete, (pageid,)) | |||||
continue | |||||
if stored: | |||||
stored_date, stored_status = list(stored)[0] | |||||
if date != stored_date or status != stored_status: | |||||
cursor.execute(q_update, (date, status, pageid)) | |||||
else: | |||||
cursor.execute(q_insert, (pageid, date, status)) | |||||
def get_status(self, title, pageid): | |||||
page = self.site.get_page(title) | |||||
ns = page.namespace | |||||
if ns == wiki.NS_FILE_TALK: # Ignore accepted FFU requests | |||||
return self.STATUS_NONE | |||||
if ns == wiki.NS_TALK: | |||||
new_page = page.toggle_talk() | |||||
sleep(2) | |||||
if new_page.is_redirect: | |||||
return self.STATUS_NONE # Ignore accepted AFC/R requests | |||||
return self.STATUS_ACCEPT | |||||
cats = self.categories | |||||
sq = self.site.sql_query | |||||
query = "SELECT 1 FROM categorylinks WHERE cl_to = ? AND cl_from = ?" | |||||
match = lambda cat: list(sq(query, (cat.replace(" ", "_"), pageid))) | |||||
if match(cats["pending"]): | |||||
return self.STATUS_PEND | |||||
elif match(cats["unsubmitted"]): | |||||
return self.STATUS_NONE | |||||
elif match(cats["declined"]): | |||||
return self.STATUS_DECLINE | |||||
return self.STATUS_NONE | |||||
def get_date_counts(self, date): | |||||
query = "SELECT COUNT(*) FROM page WHERE page_date = ? AND page_status = ?" | |||||
statuses = [self.STATUS_PEND, self.STATUS_DECLINE, self.STATUS_ACCEPT] | |||||
counts = {} | |||||
with self.conn.cursor() as cursor: | |||||
for status in statuses: | |||||
cursor.execute(query, (date, status)) | |||||
count = cursor.fetchall()[0][0] | |||||
counts[status] = count | |||||
return counts | |||||
def generate_chart(self, data): | |||||
plt.title(self.graph.get("title", "AfC submissions by date")) | |||||
plt.xlabel(self.graph.get("xaxis", "Date")) | |||||
plt.ylabel(self.graph.get("yaxis", "Submissions")) | |||||
pends = [d[self.STATUS_PEND] for d in data.itervalues()] | |||||
declines = [d[self.STATUS_DECLINE] for d in data.itervalues()] | |||||
accepts = [d[self.STATUS_ACCEPT] for d in data.itervalues()] | |||||
pends_declines = [p + d for p, d in zip(pends, declines)] | |||||
ind = arange(len(data)) | |||||
xsize = self.graph.get("xsize", 1200) | |||||
ysize = self.graph.get("ysize", 900) | |||||
width = self.graph.get("width", 1) | |||||
xstep = self.graph.get("xAxisStep", 6) | |||||
pcolor = self.graph.get("pendingColor", "#f0e460") | |||||
dcolor = self.graph.get("declinedColor", "#f291a6") | |||||
acolor = self.graph.get("acceptedColor", "#81fc4c") | |||||
p1 = plt.bar(ind, pends, width, color=pcolor) | |||||
p2 = plt.bar(ind, declines, width, color=dcolor, bottom=pends) | |||||
p3 = plt.bar(ind, accepts, width, color=acolor, bottom=pends_declines) | |||||
xticks = arange(xstep-1, ind.size+xstep-1, xstep) + width/2.0 | |||||
xlabels = [d for c, d in zip(count(1), data.keys()) if not c % xstep] | |||||
plt.xticks(xticks, xlabels) | |||||
plt.yticks(arange(0, plt.ylim()[1], 10)) | |||||
plt.tick_params(direction="out") | |||||
leg = plt.legend((p1[0], p2[0], p3[0]), ("Pending", "Declined", | |||||
"Accepted"), loc="upper left", fancybox=True) | |||||
leg.get_frame().set_alpha(0.5) | |||||
fig = plt.gcf() | |||||
fig.set_size_inches(xsize/100, ysize/100) | |||||
fig.autofmt_xdate() | |||||
ax = plt.gca() | |||||
ax.yaxis.grid(True) |
@@ -0,0 +1,739 @@ | |||||
# -*- 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 | |||||
import re | |||||
from os.path import expanduser | |||||
from threading import Lock | |||||
from time import sleep | |||||
import oursql | |||||
from earwigbot import exceptions | |||||
from earwigbot import wiki | |||||
from earwigbot.tasks import Task | |||||
class AFCStatistics(Task): | |||||
"""A task to generate statistics for WikiProject Articles for Creation. | |||||
Statistics are stored in a MySQL database ("u_earwig_afc_statistics") | |||||
accessed with oursql. Statistics are synchronied with the live database | |||||
every four minutes and saved once an hour, on the hour, to self.pagename. | |||||
In the live bot, this is "Template:AFC statistics". | |||||
""" | |||||
name = "afc_statistics" | |||||
number = 2 | |||||
# Chart status number constants: | |||||
CHART_NONE = 0 | |||||
CHART_PEND = 1 | |||||
CHART_DRAFT = 2 | |||||
CHART_REVIEW = 3 | |||||
CHART_ACCEPT = 4 | |||||
CHART_DECLINE = 5 | |||||
CHART_MISPLACE = 6 | |||||
def setup(self): | |||||
self.cfg = cfg = self.config.tasks.get(self.name, {}) | |||||
# Set some wiki-related attributes: | |||||
self.pagename = cfg.get("page", "Template:AFC statistics") | |||||
self.pending_cat = cfg.get("pending", "Pending AfC submissions") | |||||
self.ignore_list = cfg.get("ignoreList", []) | |||||
default_summary = "Updating statistics for [[WP:WPAFC|WikiProject Articles for creation]]." | |||||
self.summary = self.make_summary(cfg.get("summary", default_summary)) | |||||
# Templates used in chart generation: | |||||
templates = cfg.get("templates", {}) | |||||
self.tl_header = templates.get("header", "AFC statistics/header") | |||||
self.tl_row = templates.get("row", "AFC statistics/row") | |||||
self.tl_footer = templates.get("footer", "AFC statistics/footer") | |||||
# 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 = Lock() | |||||
def run(self, **kwargs): | |||||
"""Entry point for a task event. | |||||
Depending on the kwargs passed, we will either synchronize our local | |||||
statistics database with the site (self.sync()) or save it to the wiki | |||||
(self.save()). We will additionally create an SQL connection with our | |||||
local database. | |||||
""" | |||||
action = kwargs.get("action") | |||||
if not self.db_access_lock.acquire(False): # Non-blocking | |||||
if action == "sync": | |||||
self.logger.info("A sync is already ongoing; aborting") | |||||
return | |||||
self.logger.info("Waiting for database access lock") | |||||
self.db_access_lock.acquire() | |||||
try: | |||||
self.site = self.bot.wiki.get_site() | |||||
self.conn = oursql.connect(**self.conn_data) | |||||
try: | |||||
if action == "save": | |||||
self.save(kwargs) | |||||
elif action == "sync": | |||||
self.sync(kwargs) | |||||
elif action == "update": | |||||
self.update(kwargs) | |||||
finally: | |||||
self.conn.close() | |||||
finally: | |||||
self.db_access_lock.release() | |||||
def save(self, kwargs): | |||||
"""Save our local statistics to the wiki. | |||||
After checking for emergency shutoff, the statistics chart is compiled, | |||||
and then saved to self.pagename using self.summary iff it has changed | |||||
since last save. | |||||
""" | |||||
self.logger.info("Saving chart") | |||||
if kwargs.get("fromIRC"): | |||||
summary = self.summary + " (!earwigbot)" | |||||
else: | |||||
if self.shutoff_enabled(): | |||||
return | |||||
summary = self.summary | |||||
statistics = self.compile_charts() | |||||
page = self.site.get_page(self.pagename) | |||||
text = page.get() | |||||
newtext = re.sub(u"<!-- stat begin -->(.*?)<!-- stat end -->", | |||||
"<!-- stat begin -->\n" + statistics + "\n<!-- stat end -->", | |||||
text, flags=re.DOTALL) | |||||
if newtext == text: | |||||
self.logger.info("Chart unchanged; not saving") | |||||
return # Don't edit the page if we're not adding anything | |||||
newtext = re.sub("<!-- sig begin -->(.*?)<!-- sig end -->", | |||||
"<!-- sig begin -->~~~ at ~~~~~<!-- sig end -->", | |||||
newtext) | |||||
page.edit(newtext, summary, minor=True, bot=True) | |||||
self.logger.info(u"Chart saved to [[{0}]]".format(page.title)) | |||||
def compile_charts(self): | |||||
"""Compile and return all statistics information from our local db.""" | |||||
stats = "" | |||||
with self.conn.cursor() as cursor: | |||||
cursor.execute("SELECT * FROM chart") | |||||
for chart in cursor: | |||||
stats += self.compile_chart(chart) + "\n" | |||||
return stats[:-1] # Drop the last newline | |||||
def compile_chart(self, chart_info): | |||||
"""Compile and return a single statistics chart.""" | |||||
chart_id, chart_title, special_title = chart_info | |||||
chart = self.tl_header + "|" + chart_title | |||||
if special_title: | |||||
chart += "|" + special_title | |||||
chart = "{{" + chart + "}}" | |||||
query = "SELECT * FROM page JOIN row ON page_id = row_id WHERE row_chart = ?" | |||||
with self.conn.cursor(oursql.DictCursor) as cursor: | |||||
cursor.execute(query, (chart_id,)) | |||||
for page in cursor: | |||||
chart += "\n" + self.compile_chart_row(page) | |||||
chart += "\n{{" + self.tl_footer + "}}" | |||||
return chart | |||||
def compile_chart_row(self, page): | |||||
"""Compile and return a single chart row. | |||||
'page' is a dict of page information, taken as a row from the page | |||||
table, where keys are column names and values are their cell contents. | |||||
""" | |||||
row = u"{0}|s={page_status}|t={page_title}|h={page_short}|z={page_size}|" | |||||
if page["page_special_oldid"]: | |||||
row += "sr={page_special_user}|sd={page_special_time}|si={page_special_oldid}|" | |||||
row += "mr={page_modify_user}|md={page_modify_time}|mi={page_modify_oldid}" | |||||
page["page_special_time"] = self.format_time(page["page_special_time"]) | |||||
page["page_modify_time"] = self.format_time(page["page_modify_time"]) | |||||
if page["page_notes"]: | |||||
row += "|n=1{page_notes}" | |||||
return "{{" + row.format(self.tl_row, **page) + "}}" | |||||
def format_time(self, dt): | |||||
"""Format a datetime into the standard MediaWiki timestamp format.""" | |||||
return dt.strftime("%H:%M, %d %b %Y") | |||||
def sync(self, kwargs): | |||||
"""Synchronize our local statistics database with the site. | |||||
Syncing involves, in order, updating tracked submissions that have | |||||
been changed since last sync (self.update_tracked()), adding pending | |||||
submissions that are not tracked (self.add_untracked()), and removing | |||||
old submissions from the database (self.delete_old()). | |||||
The sync will be canceled if SQL replication lag is greater than 600 | |||||
seconds, because this will lead to potential problems and outdated | |||||
data, not to mention putting demand on an already overloaded server. | |||||
Giving sync the kwarg "ignore_replag" will go around this restriction. | |||||
""" | |||||
self.logger.info("Starting sync") | |||||
replag = self.site.get_replag() | |||||
self.logger.debug("Server replag is {0}".format(replag)) | |||||
if replag > 600 and not kwargs.get("ignore_replag"): | |||||
msg = "Sync canceled as replag ({0} secs) is greater than ten minutes" | |||||
self.logger.warn(msg.format(replag)) | |||||
return | |||||
with self.conn.cursor() as cursor: | |||||
self.update_tracked(cursor) | |||||
self.add_untracked(cursor) | |||||
self.delete_old(cursor) | |||||
self.logger.info("Sync completed") | |||||
def update_tracked(self, cursor): | |||||
"""Update tracked submissions that have been changed since last sync. | |||||
This is done by iterating through every page in our database and | |||||
comparing our stored latest revision ID with the actual latest revision | |||||
ID from an SQL query. If they differ, we will update our information | |||||
about the page (self.update_page()). | |||||
If the page does not exist, we will remove it from our database with | |||||
self.untrack_page(). | |||||
""" | |||||
self.logger.debug("Updating tracked submissions") | |||||
query1 = "SELECT page_id, page_title, page_modify_oldid FROM page" | |||||
query2 = """SELECT page_latest, page_title, page_namespace FROM page | |||||
WHERE page_id = ?""" | |||||
cursor.execute(query1) | |||||
for pageid, title, oldid in cursor: | |||||
result = list(self.site.sql_query(query2, (pageid,))) | |||||
if not result: | |||||
self.untrack_page(cursor, pageid) | |||||
continue | |||||
real_oldid = result[0][0] | |||||
if oldid != real_oldid: | |||||
msg = u"Updating page [[{0}]] (id: {1}) @ {2}" | |||||
self.logger.debug(msg.format(title, pageid, oldid)) | |||||
self.logger.debug(" {0} -> {1}".format(oldid, real_oldid)) | |||||
base = result[0][1].decode("utf8").replace("_", " ") | |||||
ns = self.site.namespace_id_to_name(result[0][2]) | |||||
if ns: | |||||
real_title = u":".join((ns, base)) | |||||
else: | |||||
real_title = base | |||||
try: | |||||
self.update_page(cursor, pageid, real_title) | |||||
except Exception: | |||||
e = u"Error updating page [[{0}]] (id: {1})" | |||||
self.logger.exception(e.format(real_title, pageid)) | |||||
def add_untracked(self, cursor): | |||||
"""Add pending submissions that are not yet tracked. | |||||
This is done by compiling a list of all currently tracked submissions | |||||
and iterating through all members of self.pending_cat via SQL. If a | |||||
page in the pending category is not tracked and is not in | |||||
self.ignore_list, we will track it with self.track_page(). | |||||
""" | |||||
self.logger.debug("Adding untracked pending submissions") | |||||
cursor.execute("SELECT page_id FROM page") | |||||
tracked = [i[0] for i in cursor.fetchall()] | |||||
category = self.site.get_category(self.pending_cat) | |||||
for page in category.get_members(): | |||||
title, pageid = page.title, page.pageid | |||||
if title in self.ignore_list: | |||||
continue | |||||
if pageid not in tracked: | |||||
msg = u"Tracking page [[{0}]] (id: {1})".format(title, pageid) | |||||
self.logger.debug(msg) | |||||
try: | |||||
self.track_page(cursor, pageid, title) | |||||
except Exception: | |||||
e = u"Error tracking page [[{0}]] (id: {1})" | |||||
self.logger.exception(e.format(title, pageid)) | |||||
def delete_old(self, cursor): | |||||
"""Remove old submissions from the database. | |||||
"Old" is defined as a submission that has been declined or accepted | |||||
more than 36 hours ago. Pending submissions cannot be "old". | |||||
""" | |||||
self.logger.debug("Removing old submissions from chart") | |||||
query = """DELETE FROM page, row USING page JOIN row | |||||
ON page_id = row_id WHERE row_chart IN (?, ?) | |||||
AND ADDTIME(page_special_time, '36:00:00') < NOW()""" | |||||
cursor.execute(query, (self.CHART_ACCEPT, self.CHART_DECLINE)) | |||||
def update(self, kwargs): | |||||
"""Update a page by name, regardless of whether anything has changed. | |||||
Mainly intended as a command to be used via IRC, e.g.: | |||||
!tasks start afc_statistics action=update page=Foobar | |||||
""" | |||||
title = kwargs.get("page") | |||||
if not title: | |||||
return | |||||
title = title.replace("_", " ").decode("utf8") | |||||
query = "SELECT page_id, page_modify_oldid FROM page WHERE page_title = ?" | |||||
with self.conn.cursor() as cursor: | |||||
cursor.execute(query, (title,)) | |||||
try: | |||||
pageid, oldid = cursor.fetchall()[0] | |||||
except IndexError: | |||||
msg = u"Page [[{0}]] not found in database".format(title) | |||||
self.logger.error(msg) | |||||
msg = u"Updating page [[{0}]] (id: {1}) @ {2}" | |||||
self.logger.info(msg.format(title, pageid, oldid)) | |||||
self.update_page(cursor, pageid, title) | |||||
def untrack_page(self, cursor, pageid): | |||||
"""Remove a page, given by ID, from our database.""" | |||||
self.logger.debug("Untracking page (id: {0})".format(pageid)) | |||||
query = """DELETE FROM page, row USING page JOIN row | |||||
ON page_id = row_id WHERE page_id = ?""" | |||||
cursor.execute(query, (pageid,)) | |||||
def track_page(self, cursor, pageid, title): | |||||
"""Update hook for when page is not in our database. | |||||
A variety of SQL queries are used to gather information about the page, | |||||
which is then saved to our database. | |||||
""" | |||||
content = self.get_content(title) | |||||
if content is None: | |||||
msg = u"Could not get page content for [[{0}]]".format(title) | |||||
self.logger.error(msg) | |||||
return | |||||
namespace = self.site.get_page(title).namespace | |||||
status, chart = self.get_status_and_chart(content, namespace) | |||||
if chart == self.CHART_NONE: | |||||
msg = u"Could not find a status for [[{0}]]".format(title) | |||||
self.logger.warn(msg) | |||||
return | |||||
short = self.get_short_title(title) | |||||
size = self.get_size(content) | |||||
m_user, m_time, m_id = self.get_modify(pageid) | |||||
s_user, s_time, s_id = self.get_special(pageid, chart) | |||||
notes = self.get_notes(chart, content, m_time, s_user) | |||||
query1 = "INSERT INTO row VALUES (?, ?)" | |||||
query2 = "INSERT INTO page VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" | |||||
cursor.execute(query1, (pageid, chart)) | |||||
cursor.execute(query2, (pageid, status, title, short, size, notes, | |||||
m_user, m_time, m_id, s_user, s_time, s_id)) | |||||
def update_page(self, cursor, pageid, title): | |||||
"""Update hook for when page is already in our database. | |||||
A variety of SQL queries are used to gather information about the page, | |||||
which is compared against our stored information. Differing information | |||||
is then updated. | |||||
""" | |||||
content = self.get_content(title) | |||||
if content is None: | |||||
msg = u"Could not get page content for [[{0}]]".format(title) | |||||
self.logger.error(msg) | |||||
return | |||||
namespace = self.site.get_page(title).namespace | |||||
status, chart = self.get_status_and_chart(content, namespace) | |||||
if chart == self.CHART_NONE: | |||||
self.untrack_page(cursor, pageid) | |||||
return | |||||
query = "SELECT * FROM page JOIN row ON page_id = row_id WHERE page_id = ?" | |||||
with self.conn.cursor(oursql.DictCursor) as dict_cursor: | |||||
dict_cursor.execute(query, (pageid,)) | |||||
result = dict_cursor.fetchall()[0] | |||||
size = self.get_size(content) | |||||
m_user, m_time, m_id = self.get_modify(pageid) | |||||
if title != result["page_title"]: | |||||
self.update_page_title(cursor, result, pageid, title) | |||||
if m_id != result["page_modify_oldid"]: | |||||
self.update_page_modify(cursor, result, pageid, size, m_user, | |||||
m_time, m_id) | |||||
if status != result["page_status"]: | |||||
special = self.update_page_status(cursor, result, pageid, status, | |||||
chart) | |||||
s_user = special[0] | |||||
else: | |||||
s_user = result["page_special_user"] | |||||
notes = self.get_notes(chart, content, m_time, s_user) | |||||
if notes != result["page_notes"]: | |||||
self.update_page_notes(cursor, result, pageid, notes) | |||||
def update_page_title(self, cursor, result, pageid, title): | |||||
"""Update the title and short_title of a page in our database.""" | |||||
query = "UPDATE page SET page_title = ?, page_short = ? WHERE page_id = ?" | |||||
short = self.get_short_title(title) | |||||
cursor.execute(query, (title, short, pageid)) | |||||
msg = u" {0}: title: {1} -> {2}" | |||||
self.logger.debug(msg.format(pageid, result["page_title"], title)) | |||||
def update_page_modify(self, cursor, result, pageid, size, m_user, m_time, m_id): | |||||
"""Update the last modified information of a page in our database.""" | |||||
query = """UPDATE page SET page_size = ?, page_modify_user = ?, | |||||
page_modify_time = ?, page_modify_oldid = ? | |||||
WHERE page_id = ?""" | |||||
cursor.execute(query, (size, m_user, m_time, m_id, pageid)) | |||||
msg = u" {0}: modify: {1} / {2} / {3} -> {4} / {5} / {6}" | |||||
msg = msg.format(pageid, result["page_modify_user"], | |||||
result["page_modify_time"], | |||||
result["page_modify_oldid"], m_user, m_time, m_id) | |||||
self.logger.debug(msg) | |||||
def update_page_status(self, cursor, result, pageid, status, chart): | |||||
"""Update the status and "specialed" information of a page.""" | |||||
query1 = """UPDATE page JOIN row ON page_id = row_id | |||||
SET page_status = ?, row_chart = ? WHERE page_id = ?""" | |||||
query2 = """UPDATE page SET page_special_user = ?, | |||||
page_special_time = ?, page_special_oldid = ? | |||||
WHERE page_id = ?""" | |||||
cursor.execute(query1, (status, chart, pageid)) | |||||
msg = " {0}: status: {1} ({2}) -> {3} ({4})" | |||||
self.logger.debug(msg.format(pageid, result["page_status"], | |||||
result["row_chart"], status, chart)) | |||||
s_user, s_time, s_id = self.get_special(pageid, chart) | |||||
if s_id != result["page_special_oldid"]: | |||||
cursor.execute(query2, (s_user, s_time, s_id, pageid)) | |||||
msg = u"{0}: special: {1} / {2} / {3} -> {4} / {5} / {6}" | |||||
msg = msg.format(pageid, result["page_special_user"], | |||||
result["page_special_time"], | |||||
result["page_special_oldid"], s_user, s_time, s_id) | |||||
self.logger.debug(msg) | |||||
return s_user, s_time, s_id | |||||
def update_page_notes(self, cursor, result, pageid, notes): | |||||
"""Update the notes (or warnings) of a page in our database.""" | |||||
query = "UPDATE page SET page_notes = ? WHERE page_id = ?" | |||||
cursor.execute(query, (notes, pageid)) | |||||
msg = " {0}: notes: {1} -> {2}" | |||||
self.logger.debug(msg.format(pageid, result["page_notes"], notes)) | |||||
def get_content(self, title): | |||||
"""Get the current content of a page by title from the API. | |||||
The page's current revision ID is retrieved from SQL, and then | |||||
an API query is made to get its content. This is the only API query | |||||
used in the task's code. | |||||
""" | |||||
query = "SELECT page_latest FROM page WHERE page_title = ? AND page_namespace = ?" | |||||
try: | |||||
namespace, base = title.split(":", 1) | |||||
except ValueError: | |||||
base = title | |||||
ns = wiki.NS_MAIN | |||||
else: | |||||
try: | |||||
ns = self.site.namespace_name_to_id(namespace) | |||||
except exceptions.NamespaceNotFoundError: | |||||
base = title | |||||
ns = wiki.NS_MAIN | |||||
result = self.site.sql_query(query, (base.replace(" ", "_"), ns)) | |||||
try: | |||||
revid = int(list(result)[0][0]) | |||||
except IndexError: | |||||
return None | |||||
return self.get_revision_content(revid) | |||||
def get_revision_content(self, revid, tries=1): | |||||
"""Get the content of a revision by ID from the API.""" | |||||
res = self.site.api_query(action="query", prop="revisions", | |||||
revids=revid, rvprop="content") | |||||
try: | |||||
return res["query"]["pages"].values()[0]["revisions"][0]["*"] | |||||
except KeyError: | |||||
if tries > 0: | |||||
sleep(5) | |||||
return self.get_revision_content(revid, tries=tries - 1) | |||||
def get_status_and_chart(self, content, namespace): | |||||
"""Determine the status and chart number of an AFC submission. | |||||
The methodology used here is the same one I've been using for years | |||||
(see also commands.afc_report), but with the new draft system taken | |||||
into account. The order here is important: if there is more than one | |||||
{{AFC submission}} template on a page, we need to know which one to | |||||
use (revision history search to find the most recent isn't a viable | |||||
idea :P). | |||||
""" | |||||
statuses = self.get_statuses(content) | |||||
if "R" in statuses: | |||||
status, chart = "r", self.CHART_REVIEW | |||||
elif "H" in statuses: | |||||
status, chart = "p", self.CHART_DRAFT | |||||
elif "P" in statuses: | |||||
status, chart = "p", self.CHART_PEND | |||||
elif "T" in statuses: | |||||
status, chart = None, self.CHART_NONE | |||||
elif "D" in statuses: | |||||
status, chart = "d", self.CHART_DECLINE | |||||
else: | |||||
status, chart = None, self.CHART_NONE | |||||
if namespace == wiki.NS_MAIN: | |||||
if not statuses: | |||||
status, chart = "a", self.CHART_ACCEPT | |||||
else: | |||||
status, chart = None, self.CHART_MISPLACE | |||||
return status, chart | |||||
def get_statuses(self, content): | |||||
"""Return a list of all AFC submission statuses in a page's text.""" | |||||
re_has_templates = "\{\{[aA][fF][cC] submission\s*(\}\}|\||/)" | |||||
re_template = "\{\{[aA][fF][cC] submission\s*(.*?)\}\}" | |||||
re_remove_embed = "(\{\{[aA][fF][cC] submission\s*(.*?))\{\{(.*?)\}\}(.*?)\}\}" | |||||
valid = ["R", "H", "P", "T", "D"] | |||||
subtemps = { | |||||
"/reviewing": "R", | |||||
"/onhold": "H", | |||||
"/pending": "P", | |||||
"/draft": "T", | |||||
"/declined": "D" | |||||
} | |||||
statuses = [] | |||||
while re.search(re_has_templates, content): | |||||
status = "P" | |||||
match = re.search(re_template, content, re.S) | |||||
if not match: | |||||
return statuses | |||||
temp = match.group(1) | |||||
limit = 0 | |||||
while "{{" in temp and limit < 50: | |||||
content = re.sub(re_remove_embed, "\\1\\4}}", content, 1, re.S) | |||||
match = re.search(re_template, content, re.S) | |||||
temp = match.group(1) | |||||
limit += 1 | |||||
params = temp.split("|") | |||||
try: | |||||
subtemp, params = params[0].strip(), params[1:] | |||||
except IndexError: | |||||
status = "P" | |||||
params = [] | |||||
else: | |||||
if subtemp: | |||||
status = subtemps.get(subtemp) | |||||
params = [] | |||||
for param in params: | |||||
param = param.strip().upper() | |||||
if "=" in param: | |||||
key, value = param.split("=", 1) | |||||
if key.strip() == "1": | |||||
status = value if value in valid else "P" | |||||
break | |||||
else: | |||||
status = param if param in valid else "P" | |||||
break | |||||
statuses.append(status) | |||||
content = re.sub(re_template, "", content, 1, re.S) | |||||
return statuses | |||||
def get_short_title(self, title): | |||||
"""Shorten a title so we can display it in a chart using less space. | |||||
Basically, this just means removing the "Wikipedia talk:Articles for | |||||
creation" part from the beginning. If it is longer than 50 characters, | |||||
we'll shorten it down to 47 and add an poor-man's ellipsis at the end. | |||||
""" | |||||
short = re.sub("Wikipedia(\s*talk)?\:Articles\sfor\screation\/", "", title) | |||||
if len(short) > 50: | |||||
short = short[:47] + "..." | |||||
return short | |||||
def get_size(self, content): | |||||
"""Return a page's size in a short, pretty format.""" | |||||
return "{0} kB".format(round(len(content) / 1000.0, 1)) | |||||
def get_modify(self, pageid): | |||||
"""Return information about a page's last edit ("modification"). | |||||
This consists of the most recent editor, modification time, and the | |||||
lastest revision ID. | |||||
""" | |||||
query = """SELECT rev_user_text, rev_timestamp, rev_id FROM revision | |||||
JOIN page ON rev_id = page_latest WHERE page_id = ?""" | |||||
result = self.site.sql_query(query, (pageid,)) | |||||
m_user, m_time, m_id = list(result)[0] | |||||
timestamp = datetime.strptime(m_time, "%Y%m%d%H%M%S") | |||||
return m_user.decode("utf8"), timestamp, m_id | |||||
def get_special(self, pageid, chart): | |||||
"""Return information about a page's "special" edit. | |||||
I tend to use the term "special" as a verb a lot, which is bound to | |||||
cause confusion. It is merely a short way of saying "the edit in which | |||||
a declined submission was declined, an accepted submission was | |||||
accepted, a submission in review was set as such, a pending submission | |||||
was submitted, and a "misplaced" submission was created." | |||||
This "information" consists of the special edit's editor, its time, and | |||||
its revision ID. If the page's status is not something that involves | |||||
"special"-ing, we will return None for all three. The same will be | |||||
returned if we cannot determine when the page was "special"-ed, or if | |||||
it was "special"-ed more than 100 edits ago. | |||||
""" | |||||
if chart == self.CHART_NONE: | |||||
return None, None, None | |||||
elif chart == self.CHART_MISPLACE: | |||||
return self.get_create(pageid) | |||||
elif chart == self.CHART_ACCEPT: | |||||
search_for = None | |||||
search_not = ["R", "H", "P", "T", "D"] | |||||
elif chart == self.CHART_DRAFT: | |||||
search_for = "H" | |||||
search_not = [] | |||||
elif chart == self.CHART_PEND: | |||||
search_for = "P" | |||||
search_not = [] | |||||
elif chart == self.CHART_REVIEW: | |||||
search_for = "R" | |||||
search_not = [] | |||||
elif chart == self.CHART_DECLINE: | |||||
search_for = "D" | |||||
search_not = ["R", "H", "P", "T"] | |||||
query = """SELECT rev_user_text, rev_timestamp, rev_id | |||||
FROM revision WHERE rev_page = ? ORDER BY rev_id DESC""" | |||||
result = self.site.sql_query(query, (pageid,)) | |||||
counter = 0 | |||||
last = (None, None, None) | |||||
for user, ts, revid in result: | |||||
counter += 1 | |||||
if counter > 50: | |||||
msg = "Exceeded 50 content lookups while determining special for page (id: {0}, chart: {1})" | |||||
self.logger.warn(msg.format(pageid, chart)) | |||||
return None, None, None | |||||
try: | |||||
content = self.get_revision_content(revid) | |||||
except exceptions.APIError: | |||||
msg = "API error interrupted SQL query in get_special() for page (id: {0}, chart: {1})" | |||||
self.logger.exception(msg.format(pageid, chart)) | |||||
return None, None, None | |||||
statuses = self.get_statuses(content) | |||||
matches = [s in statuses for s in search_not] | |||||
if search_for: | |||||
if search_for not in statuses or any(matches): | |||||
return last | |||||
else: | |||||
if any(matches): | |||||
return last | |||||
timestamp = datetime.strptime(ts, "%Y%m%d%H%M%S") | |||||
last = (user.decode("utf8"), timestamp, revid) | |||||
return last | |||||
def get_create(self, pageid): | |||||
"""Return information about a page's first edit ("creation"). | |||||
This consists of the page creator, creation time, and the earliest | |||||
revision ID. | |||||
""" | |||||
query = """SELECT rev_user_text, rev_timestamp, rev_id | |||||
FROM revision WHERE rev_id = | |||||
(SELECT MIN(rev_id) FROM revision WHERE rev_page = ?)""" | |||||
result = self.site.sql_query(query, (pageid,)) | |||||
c_user, c_time, c_id = list(result)[0] | |||||
timestamp = datetime.strptime(c_time, "%Y%m%d%H%M%S") | |||||
return c_user.decode("utf8"), timestamp, c_id | |||||
def get_notes(self, chart, content, m_time, s_user): | |||||
"""Return any special notes or warnings about this page. | |||||
copyvio: submission is a suspected copyright violation | |||||
unsourced: submission lacks references completely | |||||
no-inline: submission has no inline citations | |||||
short: submission is less than a kilobyte in length | |||||
resubmit: submission was resubmitted after a previous decline | |||||
old: submission has not been touched in > 4 days | |||||
blocked: submitter is currently blocked | |||||
""" | |||||
notes = "" | |||||
ignored_charts = [self.CHART_NONE, self.CHART_ACCEPT, self.CHART_DECLINE] | |||||
if chart in ignored_charts: | |||||
return notes | |||||
copyvios = self.config.tasks.get("afc_copyvios", {}) | |||||
regex = "\{\{\s*" + copyvios.get("template", "AfC suspected copyvio") | |||||
if re.search(regex, content): | |||||
notes += "|nc=1" # Submission is a suspected copyvio | |||||
if not re.search("\<ref\s*(.*?)\>(.*?)\</ref\>", content, re.I | re.S): | |||||
regex = "(https?:)|\[//(?!{0})([^ \]\\t\\n\\r\\f\\v]+?)" | |||||
sitedomain = re.escape(self.site.domain) | |||||
if re.search(regex.format(sitedomain), content, re.I | re.S): | |||||
notes += "|ni=1" # Submission has no inline citations | |||||
else: | |||||
notes += "|nu=1" # Submission is completely unsourced | |||||
if len(content) < 1000: | |||||
notes += "|ns=1" # Submission is short | |||||
statuses = self.get_statuses(content) | |||||
if "D" in statuses and chart != self.CHART_MISPLACE: | |||||
notes += "|nr=1" # Submission was resubmitted | |||||
time_since_modify = (datetime.utcnow() - m_time).total_seconds() | |||||
max_time = 4 * 24 * 60 * 60 | |||||
if time_since_modify > max_time: | |||||
notes += "|no=1" # Submission hasn't been touched in over 4 days | |||||
if chart in [self.CHART_PEND, self.CHART_DRAFT] and s_user: | |||||
submitter = self.site.get_user(s_user) | |||||
try: | |||||
if submitter.blockinfo: | |||||
notes += "|nb=1" # Submitter is blocked | |||||
except exceptions.UserNotFoundError: # Likely an IP | |||||
pass | |||||
return notes |
@@ -0,0 +1,33 @@ | |||||
# -*- 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 earwigbot.tasks import Task | |||||
class AFCUndated(Task): | |||||
"""A task to clear [[Category:Undated AfC submissions]].""" | |||||
name = "afc_undated" | |||||
def setup(self): | |||||
pass | |||||
def run(self, **kwargs): | |||||
pass |
@@ -0,0 +1,34 @@ | |||||
# -*- 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 earwigbot.tasks import Task | |||||
class BLPTag(Task): | |||||
"""A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used | |||||
along with ``{{WP Biography}}``.""" | |||||
name = "blp_tag" | |||||
def setup(self): | |||||
pass | |||||
def run(self, **kwargs): | |||||
pass |
@@ -0,0 +1,787 @@ | |||||
# -*- 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.path import expanduser | |||||
import re | |||||
from threading import RLock | |||||
from time import mktime, sleep, time | |||||
import oursql | |||||
from earwigbot import exceptions | |||||
from earwigbot.tasks import Task | |||||
from earwigbot.wiki import constants | |||||
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 | |||||
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.chart_title = cfg.get("chartTitle", "Template:DRN case status") | |||||
self.volunteer_title = cfg.get("volunteers", | |||||
"Wikipedia:Dispute resolution noticeboard/Volunteering") | |||||
self.very_old_title = cfg.get("veryOldTitle", "User talk:Szhang (WMF)") | |||||
clerk_summary = "Updating $3 case$4." | |||||
notify_summary = "Notifying user regarding [[WP:DRN|dispute resolution noticeboard]] case." | |||||
chart_summary = "Updating statistics for the [[WP:DRN|dispute resolution noticeboard]]." | |||||
self.clerk_summary = self.make_summary(cfg.get("clerkSummary", clerk_summary)) | |||||
self.notify_summary = self.make_summary(cfg.get("notifySummary", notify_summary)) | |||||
self.chart_summary = self.make_summary(cfg.get("chartSummary", chart_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") | |||||
self.tl_chart_header = templates.get("chartHeader", | |||||
"DRN case status/header") | |||||
self.tl_chart_row = templates.get("chartRow", "DRN case status/row") | |||||
self.tl_chart_footer = templates.get("chartFooter", | |||||
"DRN case status/footer") | |||||
# 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() | |||||
# Minimum size a MySQL TIMESTAMP field can hold: | |||||
self.min_ts = datetime(1970, 1, 1, 0, 0, 1) | |||||
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) | |||||
notices = self.clerk(conn, cases) | |||||
if self.shutoff_enabled(): | |||||
return | |||||
if not self.save(page, cases, kwargs, start): | |||||
return | |||||
self.send_notices(site, notices) | |||||
if action in ["all", "update_chart"]: | |||||
if self.shutoff_enabled(): | |||||
return | |||||
self.update_chart(conn, site) | |||||
if action in ["all", "purge"]: | |||||
self.purge_old_data(conn) | |||||
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)) | |||||
return | |||||
text = text.split(marker)[1] | |||||
additions = set() | |||||
for line in text.splitlines(): | |||||
user = re.search("\# \{\{User\|(.+?)\}\}", line) | |||||
if user: | |||||
uname = user.group(1).replace("_", " ").strip() | |||||
additions.add((uname[0].upper() + uname[1:],)) | |||||
removals = set() | |||||
query1 = "SELECT volunteer_username FROM volunteers" | |||||
query2 = "DELETE FROM volunteers WHERE volunteer_username = ?" | |||||
query3 = "INSERT INTO volunteers (volunteer_username) 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 = [] | |||||
query = "SELECT * FROM cases" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query) | |||||
for row in cursor: | |||||
case = _Case(*row) | |||||
cases.append(case) | |||||
log = "Read {0} cases from the database" | |||||
self.logger.debug(log.format(len(cases))) | |||||
return cases | |||||
def read_page(self, conn, cases, text): | |||||
"""Read the noticeboard content and update the list of _Cases.""" | |||||
nextid = self.select_next_id(conn) | |||||
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_ = int(re.search(re_id, body).group(1)) | |||||
case = [case for case in cases if case.id == id_][0] | |||||
except (AttributeError, IndexError, ValueError): | |||||
id_ = nextid | |||||
nextid += 1 | |||||
re_id2 = "(\{\{" + tl_status_esc | |||||
re_id2 += r"(.*?)\}\})(<!-- 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) | |||||
re_f = r"\{\{drn filing editor\|(.*?)\|" | |||||
re_f += r"(\d{2}:\d{2},\s\d{1,2}\s\w+\s\d{4}\s\(UTC\))\}\}" | |||||
match = re.search(re_f, body, re.U) | |||||
if match: | |||||
f_user = match.group(1).split("/", 1)[0].replace("_", " ") | |||||
f_user = f_user[0].upper() + f_user[1:] | |||||
strp = "%H:%M, %d %B %Y (UTC)" | |||||
f_time = datetime.strptime(match.group(2), strp) | |||||
else: | |||||
f_user, f_time = None, datetime.utcnow() | |||||
case = _Case(id_, title, status, self.STATUS_UNKNOWN, f_user, | |||||
f_time, f_user, f_time, "", self.min_ts, | |||||
self.min_ts, False, False, False, len(body), | |||||
new=True) | |||||
cases.append(case) | |||||
log = u"Added new case {0} ('{1}', status={2}, by {3})" | |||||
self.logger.debug(log.format(id_, title, status, f_user)) | |||||
else: | |||||
case.status = status | |||||
log = u"Read active case {0} ('{1}')".format(id_, title) | |||||
self.logger.debug(log) | |||||
if case.title != title: | |||||
self.update_case_title(conn, id_, title) | |||||
case.title = title | |||||
case.body, case.old = body, old | |||||
for case in cases[:]: | |||||
if case.body is None: | |||||
if case.original_status == self.STATUS_UNKNOWN: | |||||
cases.remove(case) # Ignore archived case | |||||
else: | |||||
case.status = self.STATUS_UNKNOWN | |||||
log = u"Dropped case {0} because it is no longer on the page ('{1}')" | |||||
self.logger.debug(log.format(case.id, case.title)) | |||||
self.logger.debug("Done reading cases from the noticeboard page") | |||||
def select_next_id(self, conn): | |||||
"""Return the next incremental ID for a case.""" | |||||
query = "SELECT MAX(case_id) FROM cases" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query) | |||||
current = cursor.fetchone()[0] | |||||
if current: | |||||
return int(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_NEW | |||||
for option, names in self.ALIASES.iteritems(): | |||||
if status.group(1).lower() in names: | |||||
return option | |||||
return self.STATUS_NEW | |||||
def update_case_title(self, conn, id_, title): | |||||
"""Update a case title in the database.""" | |||||
query = "UPDATE cases SET case_title = ? WHERE case_id = ?" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query, (title, id_)) | |||||
log = u"Updated title of case {0} to '{1}'".format(id_, title) | |||||
self.logger.debug(log) | |||||
def clerk(self, conn, cases): | |||||
"""Actually go through cases and modify those to be updated.""" | |||||
query = "SELECT volunteer_username FROM volunteers" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query) | |||||
volunteers = [name for (name,) in cursor.fetchall()] | |||||
notices = [] | |||||
for case in cases: | |||||
log = u"Clerking case {0} ('{1}')".format(case.id, case.title) | |||||
self.logger.debug(log) | |||||
if case.status == self.STATUS_UNKNOWN: | |||||
self.save_existing_case(conn, case) | |||||
else: | |||||
notices += self.clerk_case(conn, case, volunteers) | |||||
self.logger.debug("Done clerking cases") | |||||
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(case, signatures) | |||||
self.save_case_updates(conn, case, volunteers, signatures, storedsigs) | |||||
return notices | |||||
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, or "needassist" | |||||
if it increases by over 15,000 bytes or goes by without any volunteer | |||||
edits for two days. | |||||
""" | |||||
notices = self.notify_parties(case) | |||||
if any([editor in volunteers for (editor, timestamp) in signatures]): | |||||
self.update_status(case, self.STATUS_OPEN) | |||||
else: | |||||
age = (datetime.utcnow() - case.file_time).total_seconds() | |||||
if age > 60 * 60 * 24 * 2: | |||||
self.update_status(case, self.STATUS_NEEDASSIST) | |||||
elif len(case.body) - case.last_volunteer_size > 15000: | |||||
self.update_status(case, self.STATUS_NEEDASSIST) | |||||
return notices | |||||
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): | |||||
return [] | |||||
if len(case.body) - case.last_volunteer_size > 15000: | |||||
self.update_status(case, self.STATUS_NEEDASSIST) | |||||
timestamps = [timestamp for (editor, timestamp) in signatures] | |||||
if timestamps: | |||||
age = (datetime.utcnow() - max(timestamps)).total_seconds() | |||||
if age > 60 * 60 * 24 * 2: | |||||
self.update_status(case, self.STATUS_STALE) | |||||
return [] | |||||
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): | |||||
return [] | |||||
if any([editor in volunteers for (editor, timestamp) in newsigs]): | |||||
self.update_status(case, self.STATUS_OPEN) | |||||
return [] | |||||
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): | |||||
return [] | |||||
if newsigs: | |||||
self.update_status(case, self.STATUS_OPEN) | |||||
return [] | |||||
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 | |||||
five days. | |||||
""" | |||||
age = (datetime.utcnow() - case.file_time).total_seconds() | |||||
if age > 60 * 60 * 24 * 5: | |||||
if not case.very_old_notified: | |||||
tmpl = self.tl_notify_stale | |||||
title = case.title.replace("|", "|") | |||||
template = "{{subst:" + tmpl + "|" + title + "}}" | |||||
miss = "<!-- Template:DRN stale notice | {0} -->".format(title) | |||||
notice = _Notice(self.very_old_title, template, miss) | |||||
case.very_old_notified = True | |||||
msg = u" {0}: will notify [[{1}]] with '{2}'" | |||||
log = msg.format(case.id, self.very_old_title, template) | |||||
self.logger.debug(log) | |||||
return [notice] | |||||
return [] | |||||
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: | |||||
case.close_time = datetime.utcnow() | |||||
if case.archived: | |||||
return | |||||
timestamps = [timestamp for (editor, timestamp) in signatures] | |||||
closed_age = (datetime.utcnow() - case.close_time).total_seconds() | |||||
if timestamps: | |||||
modify_age = (datetime.utcnow() - max(timestamps)).total_seconds() | |||||
else: | |||||
modify_age = 0 | |||||
if closed_age > 60 * 60 * 24 and modify_age > 60 * 60 * 24: | |||||
arch_top = self.tl_archive_top | |||||
arch_bottom = self.tl_archive_bottom | |||||
reg = "<!-- \[\[User:DoNotArchiveUntil\]\] .*? -->(<!-- .*? -->)?" | |||||
if re.search(reg, case.body): | |||||
case.body = re.sub("\{\{" + arch_top + "\}\}", "", case.body) | |||||
case.body = re.sub(reg, "{{" + arch_top + "}}", case.body) | |||||
if not re.search(arch_bottom + "\s*\}\}\s*\Z", case.body): | |||||
case.body += "\n{{" + arch_bottom + "}}" | |||||
case.archived = True | |||||
self.logger.debug(u" {0}: archived case".format(case.id)) | |||||
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() | |||||
if age > 60 * 60 * 24 * 4: | |||||
self.update_status(case, self.STATUS_REVIEW) | |||||
return True | |||||
return False | |||||
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() | |||||
new_n = self.ALIASES[new][0].upper() | |||||
old_n = "NEW" if not old_n else old_n | |||||
new_n = "NEW" if not new_n else new_n | |||||
if case.last_action != new: | |||||
case.status = new | |||||
log = u" {0}: {1} -> {2}" | |||||
self.logger.debug(log.format(case.id, old_n, new_n)) | |||||
return | |||||
log = u"Avoiding {0} {1} -> {2} because we already did this ('{3}')" | |||||
self.logger.info(log.format(case.id, old_n, new_n, case.title)) | |||||
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"([^\n\[\]|]{,256}?)(?:\||\]\])" | |||||
regex += r"(?!.*?(?:User(?:\stalk)?\:|Special\:Contributions\/).*?)" | |||||
regex += r".{,256}?(\d{2}:\d{2},\s\d{1,2}\s\w+\s\d{4}\s\(UTC\))" | |||||
matches = re.findall(regex, text, re.U|re.I) | |||||
signatures = [] | |||||
for userlink, stamp in matches: | |||||
username = userlink.split("/", 1)[0].replace("_", " ").strip() | |||||
username = username[0].upper() + username[1:] | |||||
if username == "DoNotArchiveUntil": | |||||
continue | |||||
stamp = stamp.strip() | |||||
timestamp = datetime.strptime(stamp, "%H:%M, %d %B %Y (UTC)") | |||||
signatures.append((username, timestamp)) | |||||
return signatures | |||||
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 = ?" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query, (case.id,)) | |||||
return cursor.fetchall() | |||||
def notify_parties(self, case): | |||||
"""Schedule notices to be sent to all parties of a 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.body, re.S|re.U) | |||||
for line in text.group(1).splitlines(): | |||||
user = re.search("[:*#]{,5} \{\{User\|(.*?)\}\}", line) | |||||
if user: | |||||
party = user.group(1).replace("_", " ").strip() | |||||
if party: | |||||
party = party[0].upper() + party[1:] | |||||
if party == case.file_user: | |||||
continue | |||||
notice = _Notice("User talk:" + party, template, too_late) | |||||
notices.append(notice) | |||||
case.parties_notified = True | |||||
log = u" {0}: will try to notify {1} parties with '{2}'" | |||||
self.logger.debug(log.format(case.id, len(notices), template)) | |||||
return notices | |||||
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: | |||||
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 sigs: | |||||
newest_ts = max([stamp for (user, stamp) in sigs]) | |||||
newest_user = [usr for (usr, stamp) in sigs if stamp == newest_ts][0] | |||||
case.modify_time = newest_ts | |||||
case.modify_user = newest_user | |||||
if any([usr in volunteers for (usr, stamp) in sigs]): | |||||
newest_vts = max([stamp for (usr, stamp) in sigs if usr in volunteers]) | |||||
newest_vuser = [usr for (usr, stamp) in sigs if stamp == newest_vts][0] | |||||
case.volunteer_time = newest_vts | |||||
case.volunteer_user = newest_vuser | |||||
if case.new: | |||||
self.save_new_case(conn, case) | |||||
else: | |||||
self.save_existing_case(conn, case) | |||||
with conn.cursor() as cursor: | |||||
query1 = "DELETE FROM signatures WHERE signature_case = ? AND signature_username = ? AND signature_timestamp = ?" | |||||
query2 = "INSERT INTO signatures (signature_case, signature_username, signature_timestamp) VALUES (?, ?, ?)" | |||||
removals = set(storedsigs) - set(sigs) | |||||
additions = set(sigs) - set(storedsigs) | |||||
if removals: | |||||
args = [(case.id, name, stamp) for (name, stamp) in removals] | |||||
cursor.executemany(query1, args) | |||||
if additions: | |||||
args = [] | |||||
for name, stamp in additions: | |||||
args.append((case.id, name, stamp)) | |||||
cursor.executemany(query2, args) | |||||
msg = u" {0}: added {1} signatures and removed {2}" | |||||
log = msg.format(case.id, len(additions), len(removals)) | |||||
self.logger.debug(log) | |||||
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, | |||||
case.file_user, case.file_time, case.modify_user, | |||||
case.modify_time, case.volunteer_user, case.volunteer_time, | |||||
case.close_time, case.parties_notified, | |||||
case.very_old_notified, case.archived, | |||||
case.last_volunteer_size) | |||||
with conn.cursor() as cursor: | |||||
query = "INSERT INTO cases VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" | |||||
cursor.execute(query, args) | |||||
log = u" {0}: inserted new case into database".format(case.id) | |||||
self.logger.debug(log) | |||||
def save_existing_case(self, conn, case): | |||||
"""Save an existing case to the database, updating as necessary.""" | |||||
with conn.cursor(oursql.DictCursor) as cursor: | |||||
query = "SELECT * FROM cases 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_file_user", case.file_user), | |||||
("case_file_time", case.file_time), | |||||
("case_modify_user", case.modify_user), | |||||
("case_modify_time", case.modify_time), | |||||
("case_volunteer_user", case.volunteer_user), | |||||
("case_volunteer_time", case.volunteer_time), | |||||
("case_close_time", case.close_time), | |||||
("case_parties_notified", case.parties_notified), | |||||
("case_very_old_notified", case.very_old_notified), | |||||
("case_archived", case.archived), | |||||
("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) | |||||
msg = u" {0}: will alter {1} ('{2}' -> '{3}')" | |||||
log = msg.format(case.id, column, stored[column], data) | |||||
self.logger.debug(log) | |||||
if changes: | |||||
changes = ", ".join(changes) | |||||
args.append(case.id) | |||||
query = "UPDATE cases SET {0} WHERE case_id = ?".format(changes) | |||||
cursor.execute(query, args) | |||||
else: | |||||
log = u" {0}: no changes to commit".format(case.id) | |||||
self.logger.debug(log) | |||||
def save(self, page, cases, kwargs, start): | |||||
"""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 True | |||||
worktime = time() - start | |||||
if worktime < 60: | |||||
log = "Waiting {0} seconds to avoid edit conflicts" | |||||
self.logger.debug(log.format(int(60 - worktime))) | |||||
sleep(60 - worktime) | |||||
page.reload() | |||||
if page.get() != text: | |||||
log = "Someone has edited the page while we were working; restarting" | |||||
self.logger.warn(log) | |||||
self.run(**kwargs) | |||||
return False | |||||
summary = self.clerk_summary.replace("$3", str(counter)) | |||||
summary = summary.replace("$4", "" if counter == 1 else "s") | |||||
page.edit(newtext, summary, minor=True, bot=True) | |||||
log = u"Saved page [[{0}]] ({1} updates)" | |||||
self.logger.info(log.format(page.title, counter)) | |||||
return True | |||||
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") | |||||
return | |||||
for notice in notices: | |||||
target, template = notice.target, notice.template | |||||
log = u"Trying to notify [[{0}]] with '{1}'" | |||||
self.logger.debug(log.format(target, template)) | |||||
page = site.get_page(target) | |||||
if page.namespace == constants.NS_USER_TALK: | |||||
user = site.get_user(target.split(":", 1)[1]) | |||||
if not user.exists and not user.is_ip: | |||||
log = u"Skipping [[{0}]]; user does not exist and is not an IP" | |||||
self.logger.info(log.format(target)) | |||||
continue | |||||
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 with '{1}'" | |||||
self.logger.info(log.format(page.title, template)) | |||||
continue | |||||
text += ("\n" if text else "") + template | |||||
try: | |||||
page.edit(text, self.notify_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)) | |||||
else: | |||||
log = u"Notified [[{0}]] with '{1}'" | |||||
self.logger.info(log.format(page.title, template)) | |||||
self.logger.debug("Done sending notices") | |||||
def update_chart(self, conn, site): | |||||
"""Update the chart of open or recently closed cases.""" | |||||
page = site.get_page(self.chart_title) | |||||
self.logger.info(u"Updating case status at [[{0}]]".format(page.title)) | |||||
statuses = self.compile_chart(conn) | |||||
text = page.get() | |||||
newtext = re.sub(u"<!-- status begin -->(.*?)<!-- status end -->", | |||||
"<!-- status begin -->\n" + statuses + "\n<!-- status end -->", | |||||
text, flags=re.DOTALL) | |||||
if newtext == text: | |||||
self.logger.info("Chart unchanged; not saving") | |||||
return | |||||
newtext = re.sub("<!-- sig begin -->(.*?)<!-- sig end -->", | |||||
"<!-- sig begin -->~~~ at ~~~~~<!-- sig end -->", | |||||
newtext) | |||||
page.edit(newtext, self.chart_summary, minor=True, bot=True) | |||||
self.logger.info(u"Chart saved to [[{0}]]".format(page.title)) | |||||
def compile_chart(self, conn): | |||||
"""Actually generate the chart from the database.""" | |||||
chart = "{{" + self.tl_chart_header + "|small={{{small|}}}}}\n" | |||||
query = "SELECT * FROM cases WHERE case_status != ?" | |||||
with conn.cursor(oursql.DictCursor) as cursor: | |||||
cursor.execute(query, (self.STATUS_UNKNOWN,)) | |||||
for case in cursor: | |||||
chart += self.compile_row(case) | |||||
chart += "{{" + self.tl_chart_footer + "|small={{{small|}}}}}" | |||||
return chart | |||||
def compile_row(self, case): | |||||
"""Generate a single row of the chart from a dict via the database.""" | |||||
data = u"|t={case_title}|d={title}|s={case_status}" | |||||
data += "|cu={case_file_user}|cs={file_sortkey}|ct={file_time}" | |||||
if case["case_volunteer_user"]: | |||||
data += "|vu={case_volunteer_user}|vs={volunteer_sortkey}|vt={volunteer_time}" | |||||
case["volunteer_time"] = self.format_time(case["case_volunteer_time"]) | |||||
case["volunteer_sortkey"] = int(mktime(case["case_volunteer_time"].timetuple())) | |||||
data += "|mu={case_modify_user}|ms={modify_sortkey}|mt={modify_time}" | |||||
title = case["case_title"].replace("_", " ").replace("|", "|") | |||||
case["title"] = title[:47] + "..." if len(title) > 50 else title | |||||
case["file_time"] = self.format_time(case["case_file_time"]) | |||||
case["file_sortkey"] = int(mktime(case["case_file_time"].timetuple())) | |||||
case["modify_time"] = self.format_time(case["case_modify_time"]) | |||||
case["modify_sortkey"] = int(mktime(case["case_modify_time"].timetuple())) | |||||
row = "{{" + self.tl_chart_row + data.format(**case) | |||||
return row + "|sm={{{small|}}}}}\n" | |||||
def format_time(self, dt): | |||||
"""Return a string telling the time since datetime occured.""" | |||||
parts = [("year", 31536000), ("day", 86400), ("hour", 3600)] | |||||
seconds = int((datetime.utcnow() - dt).total_seconds()) | |||||
msg = [] | |||||
for name, size in parts: | |||||
num = seconds // size | |||||
seconds -= num * size | |||||
if num: | |||||
chunk = "{0} {1}".format(num, name if num == 1 else name + "s") | |||||
msg.append(chunk) | |||||
return ", ".join(msg) + " ago" if msg else "0 hours ago" | |||||
def purge_old_data(self, conn): | |||||
"""Delete old cases (> six months) from the database.""" | |||||
log = "Purging closed cases older than six months from the database" | |||||
self.logger.info(log) | |||||
query = """DELETE cases, signatures | |||||
FROM cases JOIN signatures ON case_id = signature_case | |||||
WHERE case_status = ? | |||||
AND case_file_time < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 180 DAY) | |||||
AND case_modify_time < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 180 DAY) | |||||
""" | |||||
with conn.cursor() as cursor: | |||||
cursor.execute(query, (self.STATUS_UNKNOWN,)) | |||||
class _Case(object): | |||||
"""A object representing a dispute resolution case.""" | |||||
def __init__(self, id_, title, status, last_action, file_user, file_time, | |||||
modify_user, modify_time, volunteer_user, volunteer_time, | |||||
close_time, parties_notified, archived, very_old_notified, | |||||
last_volunteer_size, new=False): | |||||
self.id = id_ | |||||
self.title = title | |||||
self.status = status | |||||
self.last_action = last_action | |||||
self.file_user = file_user | |||||
self.file_time = file_time | |||||
self.modify_user = modify_user | |||||
self.modify_time = modify_time | |||||
self.volunteer_user = volunteer_user | |||||
self.volunteer_time = volunteer_time | |||||
self.close_time = close_time | |||||
self.parties_notified = parties_notified | |||||
self.very_old_notified = very_old_notified | |||||
self.archived = archived | |||||
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 |
@@ -0,0 +1,33 @@ | |||||
# -*- 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 earwigbot.tasks import Task | |||||
class ImageDisplayResize(Task): | |||||
"""A task to resize upscaled portraits in infoboxes.""" | |||||
name = "image_display_resize" | |||||
def setup(self): | |||||
pass | |||||
def run(self, **kwargs): | |||||
pass |
@@ -0,0 +1,36 @@ | |||||
-- MySQL dump 10.13 Distrib 5.5.12, for solaris10 (i386) | |||||
-- | |||||
-- Host: sql Database: u_earwig_afc_copyvios | |||||
-- ------------------------------------------------------ | |||||
-- Server version 5.1.59 | |||||
CREATE DATABASE `u_earwig_afc_copyvios` | |||||
DEFAULT CHARACTER SET utf8 | |||||
DEFAULT COLLATE utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `cache` | |||||
-- | |||||
DROP TABLE IF EXISTS `cache`; | |||||
CREATE TABLE `cache` ( | |||||
`cache_id` int(10) unsigned NOT NULL, | |||||
`cache_hash` char(64) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`cache_url` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`cache_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`cache_queries` int(4) DEFAULT NULL, | |||||
`cache_process_time` float DEFAULT NULL, | |||||
PRIMARY KEY (`cache_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `processed` | |||||
-- | |||||
DROP TABLE IF EXISTS `processed`; | |||||
CREATE TABLE `processed` ( | |||||
`page_id` int(10) unsigned NOT NULL, | |||||
PRIMARY KEY (`page_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- Dump completed on 2012-07-20 20:21:00 |
@@ -0,0 +1,23 @@ | |||||
-- MySQL dump 10.13 Distrib 5.5.12, for solaris10 (i386) | |||||
-- | |||||
-- Host: sql Database: u_earwig_afc_history | |||||
-- ------------------------------------------------------ | |||||
-- Server version 5.1.59 | |||||
CREATE DATABASE `u_earwig_afc_history` | |||||
DEFAULT CHARACTER SET utf8 | |||||
DEFAULT COLLATE utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `page` | |||||
-- | |||||
DROP TABLE IF EXISTS `page`; | |||||
CREATE TABLE `page` ( | |||||
`page_id` int(10) unsigned NOT NULL, | |||||
`page_date` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_status` tinyint(3) unsigned DEFAULT NULL, | |||||
PRIMARY KEY (`page_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- Dump completed on 2012-07-20 20:20:39 |
@@ -0,0 +1,68 @@ | |||||
-- MySQL dump 10.13 Distrib 5.5.12, for solaris10 (i386) | |||||
-- | |||||
-- Host: sql Database: u_earwig_afc_statistics | |||||
-- ------------------------------------------------------ | |||||
-- Server version 5.1.59 | |||||
CREATE DATABASE `u_earwig_afc_statistics` | |||||
DEFAULT CHARACTER SET utf8 | |||||
DEFAULT COLLATE utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `chart` | |||||
-- | |||||
DROP TABLE IF EXISTS `chart`; | |||||
CREATE TABLE `chart` ( | |||||
`chart_id` tinyint(3) unsigned NOT NULL AUTO_INCREMENT, | |||||
`chart_title` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`chart_special_title` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
PRIMARY KEY (`chart_id`) | |||||
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- | |||||
-- Dumping data for table `chart` | |||||
-- | |||||
LOCK TABLES `chart` WRITE; | |||||
INSERT INTO `chart` VALUES | |||||
(1,'Pending submissions','Submitted'), | |||||
(3,'Being reviewed','Reviewer'), | |||||
(4,'Recently accepted','Accepted'), | |||||
(5,'Recently declined','Declined'), | |||||
(6,'Misplaced submissions','Created'); | |||||
UNLOCK TABLES; | |||||
-- | |||||
-- Table structure for table `row` | |||||
-- | |||||
DROP TABLE IF EXISTS `row`; | |||||
CREATE TABLE `row` ( | |||||
`row_id` int(10) unsigned NOT NULL, | |||||
`row_chart` tinyint(3) unsigned DEFAULT NULL, | |||||
PRIMARY KEY (`row_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `page` | |||||
-- | |||||
DROP TABLE IF EXISTS `page`; | |||||
CREATE TABLE `page` ( | |||||
`page_id` int(10) unsigned NOT NULL, | |||||
`page_status` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_title` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_short` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_size` varchar(16) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_notes` tinytext COLLATE utf8_unicode_ci, | |||||
`page_modify_user` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_modify_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`page_modify_oldid` int(10) unsigned DEFAULT NULL, | |||||
`page_special_user` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`page_special_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`page_special_oldid` int(10) unsigned DEFAULT NULL, | |||||
PRIMARY KEY (`page_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- Dump completed on 2012-07-20 20:25:10 |
@@ -0,0 +1,59 @@ | |||||
-- MySQL dump 10.13 Distrib 5.5.12, for solaris10 (i386) | |||||
-- | |||||
-- Host: sql Database: u_earwig_drn_clerkbot | |||||
-- ------------------------------------------------------ | |||||
-- Server version 5.1.59 | |||||
CREATE DATABASE `u_earwig_drn_clerkbot` | |||||
DEFAULT CHARACTER SET utf8 | |||||
DEFAULT COLLATE utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `case` | |||||
-- | |||||
DROP TABLE IF EXISTS `cases`; | |||||
CREATE TABLE `cases` ( | |||||
`case_id` int(10) unsigned NOT NULL, | |||||
`case_title` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`case_status` int(2) unsigned DEFAULT NULL, | |||||
`case_last_action` int(2) unsigned DEFAULT NULL, | |||||
`case_file_user` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`case_file_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`case_modify_user` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`case_modify_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`case_volunteer_user` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`case_volunteer_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`case_close_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
`case_parties_notified` tinyint(1) unsigned DEFAULT NULL, | |||||
`case_very_old_notified` tinyint(1) unsigned DEFAULT NULL, | |||||
`case_archived` tinyint(1) unsigned DEFAULT NULL, | |||||
`case_last_volunteer_size` int(9) unsigned DEFAULT NULL, | |||||
PRIMARY KEY (`case_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `signature` | |||||
-- | |||||
DROP TABLE IF EXISTS `signatures`; | |||||
CREATE TABLE `signatures` ( | |||||
`signature_id` int(10) unsigned NOT NULL AUTO_INCREMENT, | |||||
`signature_case` int(10) unsigned NOT NULL, | |||||
`signature_username` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
`signature_timestamp` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', | |||||
PRIMARY KEY (`signature_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- | |||||
-- Table structure for table `volunteer` | |||||
-- | |||||
DROP TABLE IF EXISTS `volunteers`; | |||||
CREATE TABLE `volunteers` ( | |||||
`volunteer_id` int(10) unsigned NOT NULL AUTO_INCREMENT, | |||||
`volunteer_username` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL, | |||||
PRIMARY KEY (`volunteer_id`) | |||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; | |||||
-- Dump completed on 2012-07-31 1:34:28 |