@@ -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 |