Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Newer
Older
100644 283 lines (238 sloc) 10.273 kB
a062f55 Made it so that we can load up from different client_config.json file…
Alex Komoroske authored
1 import webapp2, logging, json, urllib, os
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
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 Made it so that we can load up from different client_config.json file…
Alex Komoroske authored
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
11
12 GH_COOKIE_NAME = "gh_a"
13
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
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
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 Made it so that we can load up from different client_config.json file…
Alex Komoroske authored
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
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
b79ff4b Fixed a problem where the fonts used to generate the images weren't a…
Alex Komoroske authored
35 LATO = ImageFont.truetype("fonts/Lato-Regular.ttf", 46)
36 LATO_BLACK = ImageFont.truetype("fonts/Lato-Black.ttf", 46)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
37
38 #TODO: express the resizing in terms of constants
ad0a823 Made static serving work by making the upload: statement only upload …
Alex Komoroske authored
39 LOGO = Image.open("robohornet.png").resize((46,46))
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
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 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
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
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 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
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
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 Switch to being case insenstive when checking to make sure a given is…
Alex Komoroske authored
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
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 Make it so that if the badge image is already in memcache we don't ne…
Alex Komoroske authored
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
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
132 #TODO: support multiple badge sizes
0f38522 Make it so that if the badge image is already in memcache we don't ne…
Alex Komoroske authored
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
135 im, width = draw_image(self.number, self.vote_count)
0f38522 Make it so that if the badge image is already in memcache we don't ne…
Alex Komoroske authored
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
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 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
201 class RoboHornetVotingPage(webapp2.RequestHandler):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
202 def initialize(self, request, response):
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
203 super(RoboHornetVotingPage, self).initialize(request, response)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
204 self.access = self.request.cookies.get(GH_COOKIE_NAME, "")
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
205
b360ad8 Changed how the OAuth flow works so that the Code parameter is never …
Alex Komoroske authored
206 class AuthPage(RoboHornetVotingPage):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
207 def get(self, issue_number):
208 if not self.access:
209 if self.request.get("code", ""):
210 #The user said we're okay to get access. Now we need to get the access token.
211 payload = {
212 "client_id" : CLIENT_ID,
213 "client_secret" : CLIENT_SECRET,
214 "code" : self.request.get("code", ""),
215 "redirect_uri" : self.request.url
216 }
217 #TODO: error handle
218 response = urlfetch.fetch("https://github.com/login/oauth/access_token", urllib.urlencode(payload), "POST")
219 #TODO: parse in a more resilient way
220 access_token = response.content.split("&")[0].split("=")[1]
221 self.access = access_token
222 self.response.headers['Set-Cookie'] = "%s=%s; expires=Thu, 31-Dec-2020 23:59:59 GMT; path=/" % (GH_COOKIE_NAME, access_token)
223 else:
224 #Okay, this is the first request
3688f91 Removing the acls for the public announcement. DOES NOT WORK; mixing …
Alex Komoroske authored
225 self.redirect("https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s" % (CLIENT_ID, self.request.url), False)
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
226 return
b360ad8 Changed how the OAuth flow works so that the Code parameter is never …
Alex Komoroske authored
227 self.redirect("/issue/%s/" % issue_number)
228
229 class VotePage(RoboHornetVotingPage):
230 def get(self, issue_number):
231 if not self.access:
232 self.redirect("/issue/%s/auth" % issue_number)
233 return
234 issue = get_or_create_issue(issue_number, self.access)
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
235 if not issue:
236 self.render_template(issue, {"error" : "That issue either doesn't exist or isn't a performance issue."})
237 return
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
238 self.render_template(issue)
239 def post(self, issue_number):
b360ad8 Changed how the OAuth flow works so that the Code parameter is never …
Alex Komoroske authored
240 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
241 if not issue:
242 self.render_template(issue, {"error" : "That issue either doesn't exist or isn't a performance issue."})
243 return
244 if not self.access:
245 self.render_template(issue, {'error': "You didn't authenticate with GitHub."})
246 return
247 username = get_username(self.access)
248 if not username:
249 self.render_template(issue, {"error" : "There was no username associated with your login."})
250 return
251 if not issue.toggle_vote(username):
252 self.render_template(issue, {"error" : "Your action could not be registered. Please try again."})
253 return
254 self.render_template(issue)
255 def render_template(self, issue, args = None):
256 if not args:
257 args = {}
258 args['issue_number'] = issue.number if issue else ""
259 username = get_username(self.access)
260 args['username'] = username
261 args['has_vote'] = issue.has_vote(username) if issue else False
262 self.response.out.write(template.render("vote.html", args))
263
264
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
265 class BadgePage(RoboHornetVotingPage):
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
266 def get(self, issue_number):
267 issue_number = int(issue_number)
0f38522 Make it so that if the badge image is already in memcache we don't ne…
Alex Komoroske authored
268 im = get_cached_issue_image(issue_number)
269 if not im:
799e321 Switch to actually using the issues hosted in the real RoboHornet rep…
Alex Komoroske authored
270 issue = get_or_create_issue(issue_number, self.access)
0f38522 Make it so that if the badge image is already in memcache we don't ne…
Alex Komoroske authored
271 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
272 self.response.headers['Content-Type'] = "image/png"
273 im.save(self.response.out, "PNG")
274
275 class NoSuchPage(webapp2.RequestHandler):
276 def get(self, path):
277 self.response.set_status(404)
278 self.response.out.write("Invalid URL")
279
b360ad8 Changed how the OAuth flow works so that the Code parameter is never …
Alex Komoroske authored
280 app = webapp2.WSGIApplication([('/issue/(\d+)/auth/?', AuthPage),
281 ('/issue/(\d+)/badge/?', BadgePage),
811c4de Created a badge/vote server in App Engine that authenticates to GitHu…
komoroske@google.com authored
282 ('/issue/(\d+)/?', VotePage),
283 ('(.*)', NoSuchPage)], debug = True)
Something went wrong with that request. Please try again.