| @@ -20,208 +20,14 @@ | |||
| # 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". | |||
| """ | |||
| """A task to generate information about AfC submissions over time.""" | |||
| 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() | |||
| pass | |||
| 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) | |||
| pass | |||