Skip to content
Newer
Older
100644 277 lines (233 sloc) 9.96 KB
a062f55 @jkomoros Made it so that we can load up from different client_config.json file…
jkomoros authored May 18, 2012
1 import webapp2, logging, json, urllib, os
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
2
3 from PIL import Image, ImageFont, ImageDraw
4
5 from google.appengine.ext import db
6 from google.appengine.ext.webapp import template
7 from google.appengine.api import urlfetch
8 from google.appengine.api import memcache
9
a062f55 @jkomoros Made it so that we can load up from different client_config.json file…
jkomoros authored May 18, 2012
10 LOCAL_DEVELOPMENT_MODE = os.environ['SERVER_SOFTWARE'].startswith('Dev')
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
11
12 GH_COOKIE_NAME = "gh_a"
13
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
14 ORGANIZATION_NAME = "robohornet"
15 PROJECT_NAME = "robohornet"
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
16 PERFORMANCE_ISSUE_LABEL = "Performance"
17
18 #We load up the client_ID and the client_secret from a configuration file that is not open source.
19 try:
a062f55 @jkomoros Made it so that we can load up from different client_config.json file…
jkomoros authored May 18, 2012
20 config = json.load(open("client_config.json" if LOCAL_DEVELOPMENT_MODE else "client_config_production.json"))
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
21 except IOError:
22 config = {}
23
24 CLIENT_ID = str(config.get("CLIENT_ID", "NO_ID"))
25 CLIENT_SECRET = str(config.get("CLIENT_SECRET", "NO_SECRET"))
26
27 BADGE_HEIGHT = 70
28 HALF_BADGE_HEIGHT = BADGE_HEIGHT / 2
29 PADDING = 20
30
31 VERY_LIGHT_YELLOW = (254, 241, 202, 255)
32 YELLOW = (252, 207, 77, 255)
33 LIGHT_ORANGE = (246, 148, 61, 255)
34
35 LATO = ImageFont.truetype("static/fonts/Lato-Regular.ttf", 46)
36 LATO_BLACK = ImageFont.truetype("static/fonts/Lato-Black.ttf", 46)
37
38 #TODO: express the resizing in terms of constants
39 LOGO = Image.open("static/robohornet.png").resize((46,46))
40
41 def draw_invalid_issue_image():
42 message = "No such issue"
43 text_width = LATO.getsize(message)[0]
44 # End caps + width + padding plus logo overage
45 width = BADGE_HEIGHT + text_width + 20 + PADDING
46
47 im = Image.new("RGBA", (width, BADGE_HEIGHT))
48 draw = ImageDraw.Draw(im)
49
50 #First cap
51 draw.ellipse([0,0, BADGE_HEIGHT, BADGE_HEIGHT], fill = LIGHT_ORANGE)
52
53 draw.rectangle([HALF_BADGE_HEIGHT, 0, width - HALF_BADGE_HEIGHT, BADGE_HEIGHT], fill = LIGHT_ORANGE)
54
55 draw.ellipse([width - BADGE_HEIGHT, 0, width, BADGE_HEIGHT], fill = LIGHT_ORANGE)
56
57 im.paste(LOGO, (10, 10, 56, 56), LOGO)
58
59 draw.text((HALF_BADGE_HEIGHT + PADDING + 20, 7), message, font = LATO, fill=VERY_LIGHT_YELLOW)
60
61 return im
62
63 NO_ISSUE_IMAGE = draw_invalid_issue_image()
64
65 def draw_image(number, vote_count):
66 #TODO: vote_count string should include commas.
67 #calculate how wide this image will be.
68 number_width = LATO.getsize("#" + str(number))[0]
69 vote_count_width = LATO_BLACK.getsize(str(vote_count))[0]
70 # End caps + cap in the middle + text + padding betweeen number text and surroundings + overage from the logo
71 width = BADGE_HEIGHT + (BADGE_HEIGHT / 2) + number_width + vote_count_width + (PADDING * 2) + 20
72
73 im = Image.new("RGBA", (width, BADGE_HEIGHT))
74 draw = ImageDraw.Draw(im)
75
76 #First cap
77 draw.ellipse([0,0, BADGE_HEIGHT, BADGE_HEIGHT], fill = VERY_LIGHT_YELLOW)
78
79 draw.rectangle([HALF_BADGE_HEIGHT, 0, width - HALF_BADGE_HEIGHT, BADGE_HEIGHT], fill = VERY_LIGHT_YELLOW)
80
81 vote_count_right_edge = width - HALF_BADGE_HEIGHT
82 vote_count_left_edge = vote_count_right_edge - vote_count_width
83
84 #End cap
85 draw.ellipse([width - BADGE_HEIGHT, 0, width, BADGE_HEIGHT], fill = LIGHT_ORANGE)
86
87 #inner cap
88 draw.ellipse([vote_count_left_edge - HALF_BADGE_HEIGHT, 0, vote_count_left_edge + HALF_BADGE_HEIGHT, BADGE_HEIGHT], fill = LIGHT_ORANGE)
89
90 draw.rectangle([vote_count_left_edge, 0, width - HALF_BADGE_HEIGHT, BADGE_HEIGHT], fill = LIGHT_ORANGE)
91
92 im.paste(LOGO, (10, 10, 56, 56), LOGO)
93
94 #Draw the issue number
95 draw.text((vote_count_left_edge - HALF_BADGE_HEIGHT - PADDING - number_width, 7), str("#" + str(number)), font=LATO, fill = YELLOW)
96
97 draw.text((vote_count_left_edge, 7), str(vote_count), font = LATO_BLACK, fill = VERY_LIGHT_YELLOW)
98 return im, width
99
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
100 def get_or_create_issue(number, access_token=""):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
101 number = int(number)
102 issue = Issue.all().filter('number =', number).get()
103 if not issue:
104 #Check if GitHub thinks this should exist.
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
105 url = "https://api.github.com/repos/%s/%s/issues/%s" % (ORGANIZATION_NAME, PROJECT_NAME, str(number))
106 response = urlfetch.fetch(url + "?access_token=" + access_token if access_token else url)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
107 #TODO: error handle
108 data = json.loads(response.content)
109 if data.get("number") != number:
110 return None
111 #Make sure it has the "Performance" label
cd9199d @jkomoros Switch to being case insenstive when checking to make sure a given is…
jkomoros authored May 18, 2012
112 if not any(item.get("name", "").lower() == "performance" for item in data.get("labels", [])):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
113 return None
114 #TODO: in the future we should check every so often if this issue is still valid.
115 issue = Issue(number = number)
116 issue.put()
117 return issue
118
0f38522 @jkomoros Make it so that if the badge image is already in memcache we don't ne…
jkomoros authored May 11, 2012
119 def get_cached_issue_image(issue_number):
120 memcache_key = "issue_%d_image" % issue_number
121 cached_values = memcache.get_multi([memcache_key, memcache_key + "_width"])
122 image_data = cached_values.get(memcache_key)
123 width = cached_values.get(memcache_key + "_width")
124 if image_data and width:
125 return Image.fromstring("RGBA", (width, BADGE_HEIGHT), image_data)
126 return None
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
127
128 class Issue(db.Model):
129 number = db.IntegerProperty(required=True)
130 vote_count = db.IntegerProperty(default = 0)
131 def get_image(self):
7160628 Minor fix to handle the rare case where memcache expired one of the i…
komoroske@google.com authored May 7, 2012
132 #TODO: support multiple badge sizes
0f38522 @jkomoros Make it so that if the badge image is already in memcache we don't ne…
jkomoros authored May 12, 2012
133 im = get_cached_issue_image(self.number)
134 if not im:
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
135 im, width = draw_image(self.number, self.vote_count)
0f38522 @jkomoros Make it so that if the badge image is already in memcache we don't ne…
jkomoros authored May 12, 2012
136 memcache_key = "issue_%d_image" % self.number
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
137 memcache.set_multi({memcache_key: im.tostring(), memcache_key + "_width" : width})
138 return im
139 def has_vote(self, username):
140 return bool(self.get_vote(username))
141 def get_vote(self, username):
142 return Vote.all().filter('user =', username).filter('issue =', self).get()
143 def _reset_image_cache(self):
144 memcache_key = "issue_%d_image" % self.number
145 memcache.delete_multi([memcache_key, memcache_key + "_width"])
146 def vote(self, username):
147 if self.get_vote(username):
148 return False
149 try:
150 db.run_in_transaction(do_vote, self.key(), username)
151 except:
152 return False
153 #ensure that later reads will get the updated version.
154 self._reset_image_cache()
155 return True
156 def unvote(self, username):
157 vote = self.get_vote(username)
158 if not vote:
159 return False
160 try:
161 db.run_in_transaction(do_unvote, self.key(), vote.key())
162 except:
163 return False
164 #ensure that later reads will get the updated version.
165 self._reset_image_cache()
166 return True
167 def toggle_vote(self, username):
168 return self.unvote(username) if self.has_vote(username) else self.vote(username)
169
170 def do_vote(issue_key, username):
171 issue = db.get(issue_key)
172 vote = Vote(parent = issue, user = username, issue = issue)
173 issue.vote_count = issue.vote_count + 1
174 vote.put()
175 issue.put()
176
177 def do_unvote(issue_key, vote_key):
178 issue = db.get(issue_key)
179 vote = db.get(vote_key)
180 issue.vote_count = issue.vote_count - 1
181 vote.delete()
182 issue.put()
183
184 def get_username(access_token):
185 memcache_key = access_token + "_username"
186 username = memcache.get(memcache_key)
187 if not username:
188 response = urlfetch.fetch("https://api.github.com/user?access_token=%s" % access_token)
189 #TODO: error handle
190 data = json.loads(response.content)
191 username = data.get("login", "")
192 if username:
193 memcache.set(memcache_key, username)
194 return username
195
196 class Vote(db.Model):
197 user = db.StringProperty(required = True)
198 issue = db.ReferenceProperty(Issue, required = True)
199 timestamp = db.DateTimeProperty(auto_now_add = True)
200
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
201 class RoboHornetVotingPage(webapp2.RequestHandler):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
202 def initialize(self, request, response):
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
203 super(RoboHornetVotingPage, self).initialize(request, response)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
204 self.access = self.request.cookies.get(GH_COOKIE_NAME, "")
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
205
206 class VotePage(RoboHornetVotingPage):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
207 def get(self, issue_number):
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
208 issue = get_or_create_issue(issue_number, self.access)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
209 if not self.access:
210 if self.request.get("code", ""):
211 #The user said we're okay to get access. Now we need to get the access token.
212 payload = {
213 "client_id" : CLIENT_ID,
214 "client_secret" : CLIENT_SECRET,
215 "code" : self.request.get("code", ""),
216 "redirect_uri" : self.request.url
217 }
218 #TODO: error handle
219 response = urlfetch.fetch("https://github.com/login/oauth/access_token", urllib.urlencode(payload), "POST")
220 #TODO: parse in a more resilient way
221 access_token = response.content.split("&")[0].split("=")[1]
222 self.access = access_token
223 self.response.headers['Set-Cookie'] = "%s=%s; expires=Thu, 31-Dec-2020 23:59:59 GMT; path=/" % (GH_COOKIE_NAME, access_token)
224 #TODO: remove the ?code= from the URL
225 else:
226 #Okay, this is the first request
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
227 #TODO: once this is public, we no longer need the repo scope to access the issues on RoboHornet.
228 self.redirect("https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=repo" % (CLIENT_ID, self.request.url), False)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
229 return
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
230 if not issue:
231 self.render_template(issue, {"error" : "That issue either doesn't exist or isn't a performance issue."})
232 return
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
233 self.render_template(issue)
234 def post(self, issue_number):
235 issue = get_or_create_issue(issue_number)
236 if not issue:
237 self.render_template(issue, {"error" : "That issue either doesn't exist or isn't a performance issue."})
238 return
239 if not self.access:
240 self.render_template(issue, {'error': "You didn't authenticate with GitHub."})
241 return
242 username = get_username(self.access)
243 if not username:
244 self.render_template(issue, {"error" : "There was no username associated with your login."})
245 return
246 if not issue.toggle_vote(username):
247 self.render_template(issue, {"error" : "Your action could not be registered. Please try again."})
248 return
249 self.render_template(issue)
250 def render_template(self, issue, args = None):
251 if not args:
252 args = {}
253 args['issue_number'] = issue.number if issue else ""
254 username = get_username(self.access)
255 args['username'] = username
256 args['has_vote'] = issue.has_vote(username) if issue else False
257 self.response.out.write(template.render("vote.html", args))
258
259
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
260 class BadgePage(RoboHornetVotingPage):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
261 def get(self, issue_number):
262 issue_number = int(issue_number)
0f38522 @jkomoros Make it so that if the badge image is already in memcache we don't ne…
jkomoros authored May 12, 2012
263 im = get_cached_issue_image(issue_number)
264 if not im:
799e321 @jkomoros Switch to actually using the issues hosted in the real RoboHornet rep…
jkomoros authored May 18, 2012
265 issue = get_or_create_issue(issue_number, self.access)
0f38522 @jkomoros Make it so that if the badge image is already in memcache we don't ne…
jkomoros authored May 12, 2012
266 im = issue.get_image() if issue else NO_ISSUE_IMAGE
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored May 4, 2012
267 self.response.headers['Content-Type'] = "image/png"
268 im.save(self.response.out, "PNG")
269
270 class NoSuchPage(webapp2.RequestHandler):
271 def get(self, path):
272 self.response.set_status(404)
273 self.response.out.write("Invalid URL")
274
275 app = webapp2.WSGIApplication([('/issue/(\d+)/badge/?', BadgePage),
276 ('/issue/(\d+)/?', VotePage),
277 ('(.*)', NoSuchPage)], debug = True)
Something went wrong with that request. Please try again.