At the end of Week 2 of CSSI, you completed project ideation and wrote your design document.
Let’s pretend we have a design document for a simple application with these highlights:
/loc
then the content will be replaced with a Google Map centered on the location appearing after loc
. If the note starts with /gif
then the post will be replaced with a G-rated image from Giphy (using the text after gif
as a search string).Handler | Description | Page |
---|---|---|
GET / | Show the “home page” with the most recent 20 notes and a link to add a note. | main.html |
GET /notes | Show a page with a submit-a-note form. | note_entry.html |
POST /notes | Accepts the submitted note, writes it to the data store, and redirects to the home page where the notes are displayed. | — |
Before you do any programming, set up your project at GitHub, and get your whole team on board.
My project here is called rtoal/notes. Your project name will of course be different, so please read this writeup with that in mind, and make the necessary adjustments on your own.
.gitignore
and MIT License
for the license. Create the repository.
.gitignore
, LICENSE
, and README.md
). You should also see that you have one commit.
$ git clone git@github.com:rtoal/notes.git $ cd notes $ ls -aF ./ ../ .git/ .gitignore LICENSE README.md
We’ve already gone through all the details for setting up AppEngine projects already, so we’ll just jump right in. We will create folders and files with this structure:
. ├── app.yaml ├── main.py ├── scripts │ └── notes.js ├── styles │ └── notes.css └── templates ├── main.html └── note_entry.html
Let’s go.
We know we want to use Jinja and some local stylesheets and scripts, so let’s start with our complete app.yaml:
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: "/styles"
static_dir: styles
- url: "/scripts"
static_dir: scripts
- url: "/images"
static_dir: images
- url: ".*"
script: main.app
libraries:
- name: jinja2
version: "latest"
And our initial main.py:
import webapp2
import jinja2
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates'),
extensions=['jinja2.ext.autoescape'],
autoescape=True)
class MainHandler(webapp2.RequestHandler):
def get(self):
template = jinja_env.get_template('main.html')
self.response.write(template.render())
class NoteHandler(webapp2.RequestHandler):
def get(self):
template = jinja_env.get_template('note_entry.html')
self.response.write(template.render())
def post(self):
note = self.request.get('note')
print('Received: {}\nWill write to datastore later'.format(note))
self.redirect('/')
app = webapp2.WSGIApplication([
('/', MainHandler),
('/notes', NoteHandler),
], debug=True)
And our placeholder templates:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Notes</h1>
<p>Notes will go here</p>
<p><a href="/notes">Add a new note</a></p>
<script src="/scripts/notes.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Add a Note</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Add a Note</h1>
<form method="post" action="/notes">
<p><textarea name="note" rows="5" cols="50"></textarea></p>
<p><input type="submit" value="Add"></p>
</form>
</body>
</html>
We don’t care yet about scripts and stylesheets, but it is nice to make sure they are wired in. Here’s all we need for now:
body {
margin: 0;
padding: 20px;
height: 100vh;
background-image: radial-gradient(gold, skyblue)
}
console.log('Real scripts coming soon')
$ dev_appserver.py app.yaml
We have the basic app in place, so let’s commit.
git status
:
$ git status
$ git add app.yaml index.yaml $ git commit -m 'Add initial AppEngine files' $ git push
git status
to make sure everything is clean
gcloud app deploy
as we learned how to do earlier. Test that the app works on appspot.com.We want anyone to be able to read notes (on the home page) but only allow logged in users to post notes. This makes sense, since we want to track the author of each note, and what better way to track the author of the note than to require authors to login?
Update app.yaml so that the /notes
handler requires login. (Note this app does not need a login page! Instead, whenever an authenticated handler is hit, Google App Engine will put up the login prompt automatically.
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: "/styles"
static_dir: styles
- url: "/scripts"
static_dir: scripts
- url: "/images"
static_dir: images
- url: "/notes"
script: main.app
login: required
- url: ".*"
script: main.app
libraries:
- name: jinja2
version: "latest"
Update main.py so that if a user is logged in, we will send their nickname and a logout url to the template. Upon logout, we will direct the user to the home page. Good thing that the Google Users API has that create_logout_url
function.
import webapp2
import jinja2
from google.appengine.api import users
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates'),
extensions=['jinja2.ext.autoescape'],
autoescape=True)
class MainHandler(webapp2.RequestHandler):
def get(self):
notes = ['Fake note 1', 'Fake note 2']
user = users.get_current_user()
logout_url = users.create_logout_url('/') if user else None
template = jinja_env.get_template('main.html')
self.response.write(template.render({
'notes': notes,
'nickname' : user.nickname() if user else None,
'logout_url': logout_url }))
class NoteHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
template = jinja_env.get_template('note_entry.html')
self.response.write(template.render({
'nickname' : user.nickname(),
'logout_url': users.create_logout_url('/')}))
def post(self):
note = self.request.get('note')
print('Received: {}\nWill write to datastore later'.format(note))
self.redirect('/')
app = webapp2.WSGIApplication([
('/', MainHandler),
('/notes', NoteHandler),
], debug=True)
Update the templates, so that if a user is logged in, we render their nickname and a logout button. (Again, this app is not using a login button, just a logout button. Login is triggered by trying to post a note, rather than with an explicit login link.)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Notes</h1>
{% if nickname %}
<p>Welcome, {{ nickname }} | <a href="{{ logout_url }}">Logout</a></p>
{% endif %}
<section>{{ notes }}</section>
<p><a href="/notes">Add a new note</a></p>
<script src="/scripts/notes.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Add a Note</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Add a Note</h1>
{% if nickname %}
<p>Welcome, {{ nickname }} | <a href="{{ logout_url }}">Logout</a></p>
{% endif %}
<form method="post" action="/notes">
<p><textarea name="note" rows="5" cols="50"></textarea></p>
<p><input type="submit" value="Add"></p>
</form>
</body>
</html>
dev_appserver.py
, you get a simulated login widget that comes up when needed. When running on GAE on the cloud, you get a real Google authentication page.
gcloud app deploy
if you wish. It’s more important to commit frequently than it is to deploy frequently.When working on large projects with large teams, connecting to a datastore comes late in the process, and we tend to develop applications with fake data. Sometimes even the choice of a datastore is postponed until very late in the game, too. Since CSSI project week is very short and projects are done in groups of only three, and we know we will be using Google Cloud Datastore, it’s fine to build our datastore models right away.
Create the file models.py in the project folder, with one model, for our notes. Our note authors will simply be logged-in users that have Google Accounts; the Users API exposes a unique id which we will use. The note content will be a string, and we will keep track of the timestamp at which the note was created. Look at how DataStore can do this for us automatically:
from google.appengine.ext import ndb
class Note(ndb.Model):
user_id = ndb.StringProperty()
content = ndb.StringProperty()
timestamp = ndb.DateTimeProperty(auto_now_add=True)
Update main.py to use the model. We need to both fetch models on the main page, and put a new object into the datastore in the post handler.
import webapp2
import jinja2
from google.appengine.api import users
from models import Note
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates'),
extensions=['jinja2.ext.autoescape'],
autoescape=True)
class MainHandler(webapp2.RequestHandler):
def get(self):
notes = Note.query().order(-Note.timestamp).fetch(limit=20)
user = users.get_current_user()
logout_url = users.create_logout_url('/') if user else None
template = jinja_env.get_template('main.html')
self.response.write(template.render({
'notes': notes,
'nickname' : user.nickname() if user else None,
'logout_url': logout_url }))
class NoteHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
template = jinja_env.get_template('note_entry.html')
self.response.write(template.render({
'nickname' : user.nickname(),
'logout_url': users.create_logout_url('/')}))
def post(self):
note = self.request.get('note')
user = users.get_current_user()
Note(user_id=user.user_id(), content=note).put()
self.redirect('/')
app = webapp2.WSGIApplication([
('/', MainHandler),
('/notes', NoteHandler),
], debug=True)
Update the template pages so the notes are shown. And while we’re growing our app, let’s touch up the styles as well.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Notes</h1>
{% if nickname %}
<div>Welcome, {{ nickname }} | <a href="{{ logout_url }}">Logout</a></div>
{% endif %}
<section>
{% for note in notes %}
<article>
{{ note.timestamp }} ({{note.user_id}}) {{ note.content }}
</article>
{% endfor %}
</section>
<p id="addbutton"><a href="/notes">Add a new note</a></p>
<script src="/scripts/notes.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Add a Note</title>
<link rel="stylesheet" href="/styles/notes.css">
</head>
<body>
<h1>Add a Note</h1>
{% if nickname %}
<div>Welcome, {{ nickname }} | <a href="{{ logout_url }}">Logout</a></div>
{% endif %}
<form method="post" action="/notes">
<p><textarea name="note" rows="5" cols="50"></textarea></p>
<p><input type="submit" value="Add"></p>
</form>
</body>
</html>
body {
margin: 0;
padding: 0;
height: 100vh;
background-image: radial-gradient(gold, skyblue);
font-family: Arial;
}
h1 {
background-color: purple;
color: white;
font-size: 32px;
margin: 0;
padding: 20px 0;
text-align: center;
}
div {
background-color: #faf;
padding: 10px;
}
article {
font-size: 18px;
background-color: lightsteelblue;
border: 1px solid red;
border-radius: 5px;
margin: 20px;
padding: 8px;
}
p#addbutton {
margin-left: 20px;
}
p#addbutton a {
font-size: 16px;
color: white;
background-color: blue;
padding: 8px;
text-decoration: none;
}
textarea, input {
margin-left: 20px;
}
$ dev_appserver.py app.yaml --clear_datastore=yes
Okay so the reason you did not see the post right away is that it takes some time for a put
to the datastore to actually land there. When we say .put()
, AppEngine does not wait for the put to complete. Instead .put()
only initiates the process of putting while the code keeps running. In our app, we started the put process but we redirected to the home page and read the most recent notes, and this happened before the .put()
finished. When we (as a slow human) waited a couple seconds and refreshed the page, it was finally there. This can be fixed by adding a new handler that returns the timestamp of the most recent note. A bit of JavaScript on the notes page calls this handler 1.5 seconds after the page is loaded. If the timestamp from the database does not match the timestamp of the first note on the page, the page gets reloaded. By that time, the new note will have made it to the database. And if not, well, the user can manually reload.
import webapp2
import jinja2
from google.appengine.api import users
from models import Note
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates'),
extensions=['jinja2.ext.autoescape'],
autoescape=True)
class MainHandler(webapp2.RequestHandler):
def get(self):
notes = Note.query().order(-Note.timestamp).fetch(limit=20)
user = users.get_current_user()
logout_url = users.create_logout_url('/') if user else None
template = jinja_env.get_template('main.html')
self.response.write(template.render({
'notes': notes,
'nickname' : user.nickname() if user else None,
'logout_url': logout_url }))
class NoteHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
template = jinja_env.get_template('note_entry.html')
self.response.write(template.render({
'nickname' : user.nickname(),
'logout_url': users.create_logout_url('/')}))
def post(self):
note = self.request.get('note')
user = users.get_current_user()
Note(user_id=user.user_id(), content=note).put()
self.redirect('/')
class MostRecentNoteHandler(webapp2.RequestHandler):
def get(self):
note = Note.query().order(-Note.timestamp).get()
self.response.headers['Content-Type'] = 'text-plain'
self.response.write(str(note.timestamp if note else ''))
app = webapp2.WSGIApplication([
('/', MainHandler),
('/notes', NoteHandler),
('/most-recent-note', MostRecentNoteHandler),
], debug=True)
window.addEventListener('load', event => {
setTimeout(() => {
fetch('/most-recent-note').then(r => r.text()).then(most_recent => {
let displayed = document.querySelector('article');
displayed = displayed ? displayed.textContent.trim() : '';
if (!displayed.startsWith(most_recent)) {
window.location.reload();
}
});
}, 1500);
});
We want to replace all notes that begin with /gif
with an actual gif from Giphy. We just need to add one statement (it’s a long one) to the end of our script:
window.addEventListener('load', event => {
setTimeout(() => {
fetch('/most-recent-note').then(r => r.text()).then(most_recent => {
let displayed = document.querySelector('article');
displayed = displayed ? displayed.textContent.trim() : '';
if (!displayed.startsWith(most_recent)) {
window.location.reload();
}
});
}, 1500);
});
document.querySelectorAll('article div').forEach(div => {
if (div.textContent && div.textContent.trim().startsWith('/gif ')) {
const q = encodeURIComponent(div.textContent.slice(5));
const baseUrl = 'https://api.giphy.com/v1/gifs/search';
const url = `${baseUrl}?api_key=dc6zaTOxFJmzC&limit=1&rating=g&q=${q}`;
fetch(url).then(r => r.json()).then(result => {
const imageUrl = result.data[0].images.original.url;
div.innerHTML = `<img src=${imageUrl}>`;
});
}
});
Before deploying, we should clean the code. Things to consider:
I extended the app a bit. You can see the code on GitHub.