diff --git a/.gitignore b/.gitignore index 663f268d..7b225ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ bin build include lib +share cast-offs develop-eggs development @@ -41,3 +42,10 @@ development .mr.developer.cfg outline_improvements.txt src +html +slides +new_mash +.buildinfo +pip-selfcheck.json +.ipynb_checkpoints +testenvs diff --git a/Makefile b/Makefile index 20506467..84b07bcc 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,9 @@ # # You can set these variables from the command line. +BINDIR = ./bin SPHINXOPTS = -SPHINXBUILD = ./bin/sphinx-build +SPHINXBUILD = $(BINDIR)/sphinx-build PAPER = BUILDDIR = build diff --git a/README.rst b/README.rst index d79a8a4e..e7769775 100644 --- a/README.rst +++ b/README.rst @@ -10,35 +10,78 @@ This package provides the source for all lecture materials used for the `Internet Programming in Python`_ section of the `Certificate in Python Programming`_ offered by the `University of Washington Professional & Continuing Education`_ program. This version of the documentation is used for -the Winter 2015 instance of the course, Taught by `Cris Ewing`_ +the Winter 2016 instance of the course, Taught by `Cris Ewing`_ -.. _Internet Programming in Python: http://www.pce.uw.edu/courses/internet-programming-python/downtown-seattle-winter-2015/ +.. _Internet Programming in Python: http://www.pce.uw.edu/courses/internet-programming-python/downtown-seattle-winter-2016/ .. _Certificate in Python Programming: http://www.pce.uw.edu/certificates/python-programming.html .. _University of Washington Professional & Continuing Education: http://www.pce.uw.edu/ .. _Cris Ewing: http://www.linkedin.com/profile/view?id=19741495 +This course is taught using Python 3. + +This documentation builds both an HTML version of the course lectures (for the +students) and a set of slides (for the instructor). It uses the Python-based +documentation tool `Sphinx`_ and the `hieroglyph`_ sphinx extension. Shell +examples use `iPython` and tests are written for `pytest`. The build +environment is managed using `virtualenv` and `pip` + +.. _iPython: http://ipython.org/ +.. _Sphinx: http://sphinx-doc.org/ +.. _hieroglyph: http://docs.hieroglyph.io/en/latest/ +.. _pytest: http://pytest.org/latest/ +.. _virtualenv: https://virtualenv.pypa.io/en/latest/ +.. _pip: https://pip.pypa.io/en/stable + Building The Documentation -------------------------- -This documentation is built using docutils and Sphinx. The package uses -`zc.buildout` to manage setup and dependencies. This package uses the version -2 `bootstrap.py` script. This version of the script will attempt to use -setuptools 0.7 or better. If you have an earlier version of setuptools -installed, please upgrade prior to bootstrapping this buildout. +To build the documentation locally, begin by cloning the project to your +machine: + +.. code-block:: bash + + $ git clone https://github.com/cewing/training.python_web.git + +Change directories into the repository, then create a virtualenv using Python +3: + +.. code-block:: bash + + $ cd training.python_web + $ virtualenv --python /path/to/bin/python3.5 . + Running virtualenv with interpreter /path/to/bin/python3.5 + New python executable in training.python_web/bin/python3.5 + Also creating executable in training.python_web/bin/python + Installing setuptools, pip...done. + +Install the requirements for the documentation using pip: + +.. code-block:: bash + + $ bin/pip install -r requirements.pip + ... + + Successfully installed Babel-2.0 Jinja2-2.8 MarkupSafe-0.23 Pygments-2.0.2 Sphinx-1.3.1 alabaster-0.7.6 appnope-0.1.0 decorator-4.0.2 docutils-0.12 gnureadline-6.3.3 hieroglyph-0.7.1 ipython-4.0.0 ipython-genutils-0.1.0 path.py-8.1 pexpect-3.3 pickleshare-0.5 py-1.4.30 pytest-2.7.2 pytz-2015.4 simplegeneric-0.8.1 six-1.9.0 snowballstemmer-1.2.0 sphinx-rtd-theme-0.1.8 traitlets-4.0.0 + +Once that has successfully completed, you should be able to build both the html +documentation and the slides using the included Makefile. + +.. code-block:: bash + + $ make html + ... + + Build finished. The HTML pages are in build/html. + + (webdocs)$ make slides + ... -After cloning this package from the repository, do the following:: + Build finished. The HTML slides are in build/slides. - $ cd training.python_web # the location of your local copy - $ python bootstrap.py # must be Python 2.6 or 2.7 - $ bin/buildout - $ bin/sphinx # to build the main documentation and course outline - $ bin/build_s5 # to build the class session presentations +.. note:: If you prefer to build your virtualenvs in other ways, you will need + to adjust the `BINDIR` variable in `Makefile` to fit your reality. -At the end of a successful build, you will find a ``build/html`` directory, -containing the completed documentation and presentations. -.. _zc.buildout: https://pypi.python.org/pypi/zc.buildout/ -.. _bootstrap.py: http://downloads.buildout.org/2/bootstrap.py Reading The Documentation ------------------------- diff --git a/assignments/session01/echo_client.py b/assignments/session01/echo_client.py deleted file mode 100644 index 61616c36..00000000 --- a/assignments/session01/echo_client.py +++ /dev/null @@ -1,42 +0,0 @@ -import socket -import sys - - -def client(msg, log_buffer=sys.stderr): - server_address = ('localhost', 10000) - # TODO: Replace the following line with your code which will instantiate - # a TCP socket with IPv4 Addressing, call the socket you make 'sock' - sock = None - print >>log_buffer, 'connecting to {0} port {1}'.format(*server_address) - # TODO: connect your socket to the server here. - - # this try/finally block exists purely to allow us to close the socket - # when we are finished with it - try: - print >>log_buffer, 'sending "{0}"'.format(msg) - # TODO: send your message to the server here. - - # TODO: the server should be sending you back your message as a series - # of 16-byte chunks. You will want to log them as you receive - # each one. You will also need to check to make sure that - # you have received the entire message you sent __before__ - # closing the socket. - # - # Make sure that you log each chunk you receive. Use the print - # statement below to do it. (The tests expect this log format) - chunk = '' - print >>log_buffer, 'received "{0}"'.format(chunk) - finally: - # TODO: after you break out of the loop receiving echoed chunks from - # the server you will want to close your client socket. - print >>log_buffer, 'closing socket' - - -if __name__ == '__main__': - if len(sys.argv) != 2: - usg = '\nusage: python echo_client.py "this is my message"\n' - print >>sys.stderr, usg - sys.exit(1) - - msg = sys.argv[1] - client(msg) \ No newline at end of file diff --git a/assignments/session01/tests.py b/assignments/session01/tests.py deleted file mode 100644 index d0d4005a..00000000 --- a/assignments/session01/tests.py +++ /dev/null @@ -1,123 +0,0 @@ -from cStringIO import StringIO -from echo_client import client -import socket -import unittest - - -def make_buffers(string, buffsize=16): - for start in range(0, len(string), buffsize): - yield string[start:start+buffsize] - - -class EchoTestCase(unittest.TestCase): - """tests for the echo server and client""" - connection_msg = 'connecting to localhost port 10000' - sending_msg = 'sending "{0}"' - received_msg = 'received "{0}"' - closing_msg = 'closing socket' - - def setUp(self): - """set up our tests""" - if not hasattr(self, 'buff'): - # ensure we have a buffer for the client to write to - self.log = StringIO() - else: - # ensure that the buffer is set to the start for the next test - self.log.seek(0) - - def tearDown(self): - """clean up after ourselves""" - if hasattr(self, 'buff'): - # clear our buffer for the next test - self.log.seek(0) - self.log.truncate() - - def send_message(self, message): - """Attempt to send a message using the client and the test buffer - - In case of a socket error, fail and report the problem - """ - try: - client(message, self.log) - except socket.error, e: - if e.errno == 61: - msg = "Error: {0}, is the server running?" - self.fail(msg.format(e.strerror)) - else: - self.fail("Unexpected Error: {0}".format(str(e))) - - def process_log(self): - """process the buffer used by the client for logging - - The first and last lines of output will be checked to ensure that the - client started and terminated in the expected way - - The 'sending' message will be separated from the echoed message - returned from the server. - - Finally, the sending message, and the list of returned buffer lines - will be returned - """ - if self.log.tell() == 0: - self.fail("No bytes written to buffer") - - self.log.seek(0) - client_output = self.log.read() - lines = client_output.strip().split('\n') - first_line = lines.pop(0) - self.assertEqual(first_line, self.connection_msg, - "Unexpected connection message") - send_msg = lines.pop(0) - last_line = lines.pop() - self.assertEqual(last_line, self.closing_msg, - "Unexpected closing message") - return send_msg, lines - - def test_short_message_echo(self): - """test that a message short than 16 bytes echoes cleanly""" - short_message = "short message" - self.send_message(short_message) - actual_sent, actual_reply = self.process_log() - expected_sent = self.sending_msg.format(short_message) - self.assertEqual( - expected_sent, - actual_sent, - "expected {0}, got {1}".format(expected_sent, actual_sent) - ) - - self.assertEqual(len(actual_reply), 1, - "Short message was split unexpectedly") - - actual_line = actual_reply[0] - expected_line = self.received_msg.format(short_message) - self.assertEqual( - expected_line, - actual_line, - "expected {0} got {1}".format(expected_line, actual_line)) - - def test_long_message_echo(self): - """test that a message longer than 16 bytes echoes in 16-byte chunks""" - long_message = "Four score and seven years ago our fathers did stuff" - self.send_message(long_message) - actual_sent, actual_reply = self.process_log() - - expected_sent = self.sending_msg.format(long_message) - self.assertEqual( - expected_sent, - actual_sent, - "expected {0}, got {1}".format(expected_sent, actual_sent) - ) - - expected_buffers = make_buffers(long_message, 16) - for line_num, buff in enumerate(expected_buffers): - expected_line = self.received_msg.format(buff) - actual_line = actual_reply[line_num] - self.assertEqual( - expected_line, - actual_line, - "expected {0}, got {1}".format(expected_line, actual_line) - ) - - -if __name__ == '__main__': - unittest.main() diff --git a/assignments/session02/completed_http_server.py b/assignments/session02/completed_http_server.py deleted file mode 100644 index 0b096011..00000000 --- a/assignments/session02/completed_http_server.py +++ /dev/null @@ -1,102 +0,0 @@ -import socket -import sys -import os -import mimetypes - - -def response_ok(body, mimetype): - """returns a basic HTTP response""" - resp = [] - resp.append("HTTP/1.1 200 OK") - resp.append("Content-Type: %s" % mimetype) - resp.append("") - resp.append(body) - return "\r\n".join(resp) - - -def response_method_not_allowed(): - """returns a 405 Method Not Allowed response""" - resp = [] - resp.append("HTTP/1.1 405 Method Not Allowed") - resp.append("") - return "\r\n".join(resp) - - -def response_not_found(): - """return a 404 Not Found response""" - resp = [] - resp.append("HTTP/1.1 404 Not Found") - resp.append("") - return "\r\n".join(resp) - - -def parse_request(request): - first_line = request.split("\r\n", 1)[0] - method, uri, protocol = first_line.split() - if method != "GET": - raise NotImplementedError("We only accept GET") - print >>sys.stderr, 'serving request for %s' % uri - return uri - - -def resolve_uri(uri): - """return the filesystem resources identified by 'uri'""" - home = 'webroot' # this is relative to the location of - # the server script, could be a full path - filename = os.path.join(home, uri.lstrip('/')) - if os.path.isfile(filename): - ext = os.path.splitext(filename)[1] - mimetype = mimetypes.types_map.get(ext, 'text/plain') - contents = open(filename, 'rb').read() - return contents, mimetype - elif os.path.isdir(filename): - listing = "\n".join(os.listdir(filename)) - return listing, 'text/plain' - else: - raise ValueError("Not Found") - - -def server(): - address = ('127.0.0.1', 10000) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - print >>sys.stderr, "making a server on %s:%s" % address - sock.bind(address) - sock.listen(1) - - try: - while True: - print >>sys.stderr, 'waiting for a connection' - conn, addr = sock.accept() # blocking - try: - print >>sys.stderr, 'connection - %s:%s' % addr - request = "" - while True: - data = conn.recv(1024) - request += data - if len(data) < 1024 or not data: - break - - try: - uri = parse_request(request) - content, mimetype = resolve_uri(uri) - except NotImplementedError: - response = response_method_not_allowed() - except ValueError: - response = response_not_found() - else: - response = response_ok(content, mimetype) - - print >>sys.stderr, 'sending response' - conn.sendall(response) - finally: - conn.close() - - except KeyboardInterrupt: - sock.close() - return - - -if __name__ == '__main__': - server() - sys.exit(0) diff --git a/assignments/session02/http_server.py b/assignments/session02/http_server.py deleted file mode 100644 index 12cbfeba..00000000 --- a/assignments/session02/http_server.py +++ /dev/null @@ -1,71 +0,0 @@ -import socket -import sys - - -def response_ok(): - """returns a basic HTTP response""" - resp = [] - resp.append("HTTP/1.1 200 OK") - resp.append("Content-Type: text/plain") - resp.append("") - resp.append("this is a pretty minimal response") - return "\r\n".join(resp) - - -def response_method_not_allowed(): - """returns a 405 Method Not Allowed response""" - resp = [] - resp.append("HTTP/1.1 405 Method Not Allowed") - resp.append("") - return "\r\n".join(resp) - - -def parse_request(request): - first_line = request.split("\r\n", 1)[0] - method, uri, protocol = first_line.split() - if method != "GET": - raise NotImplementedError("We only accept GET") - print >>sys.stderr, 'request is okay' - - -def server(): - address = ('127.0.0.1', 10000) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - print >>sys.stderr, "making a server on %s:%s" % address - sock.bind(address) - sock.listen(1) - - try: - while True: - print >>sys.stderr, 'waiting for a connection' - conn, addr = sock.accept() # blocking - try: - print >>sys.stderr, 'connection - %s:%s' % addr - request = "" - while True: - data = conn.recv(1024) - request += data - if len(data) < 1024 or not data: - break - - try: - parse_request(request) - except NotImplementedError: - response = response_method_not_allowed() - else: - response = response_ok() - - print >>sys.stderr, 'sending response' - conn.sendall(response) - finally: - conn.close() - - except KeyboardInterrupt: - sock.close() - return - - -if __name__ == '__main__': - server() - sys.exit(0) diff --git a/assignments/session02/tasks.txt b/assignments/session02/tasks.txt deleted file mode 100644 index fcf3e582..00000000 --- a/assignments/session02/tasks.txt +++ /dev/null @@ -1,82 +0,0 @@ -Session 2 Homework -================== - -Required Tasks: ---------------- - -in assignments/session02/http_server.py: - -* Update the parse_request function to return the URI it parses from the - request. - -* Update the response_ok function so that it accepts a body and mimetype - argument and properly includes these in the response it generates. - -* Write a new function resolve_uri that handles looking up resources on - disk using the URI. - - * It should take a URI as the sole argument - - * It should map the pathname represented by the URI to a filesystem - location. - - * It should have a 'home directory', and look only in that location. - - * If the URI is a directory, it should return a plain-text listing and the - mimetype ``text/plain``. - - * If the URI is a file, it should return the contents of that file and its - correct mimetype. - - * If the URI does not map to a real location, it should raise an exception - that the server can catch to return a 404 response. - -* Write a new function response_not_found that returns a 404 response if the - resource does not exist. - -* Update the code in the server loop to use the new and changed functions you - completed for the tasks above. - -When you have successfully completed these tasks as described, all the tests -in assignments/session02/tests.py will pass as written. If you have to update -the tests to get them to pass, think again about how you are implementing the -feature under test. - -To run the tests: - -* Open one terminal while in this folder and execute this command: - - $ python http_server.py - -* Open a second terminal in this same folder and execute this command: - - $ python tests.py - -Make sure to run the tests early and often during your work. Remember, TDD -means that as soon as a test passes you are finished working. - - -Optional Tasks: ---------------- - -* Update all error responses so that they return something that can be seen in - a web browser. - -* Format directory listings as HTML, so you can link to files. Update the - mimetype appropriately. - -* Add a GMT ``Date:`` header in the proper format (RFC-1123) to responses. - *hint: see email.utils.formatdate in the python standard library* - -* Add a ``Content-Length:`` header for ``OK`` responses that provides a - correct value. - -* Protect your server against errors by providing, and using, a function that - returns a ``500 Internal Server Error`` response. - -* Instead of returning the python script in ``webroot`` as plain text, execute - the file and return the results as HTML. - -If you choose to take on any of these optional tasks, try start by writing -tests in tests.py that demostrate what the task should accomplish. Then write -code that makes the tests pass. diff --git a/assignments/session03/tasks.txt b/assignments/session03/tasks.txt deleted file mode 100644 index 03c92b3f..00000000 --- a/assignments/session03/tasks.txt +++ /dev/null @@ -1,38 +0,0 @@ -Session 3 Homework -================== - -Required Tasks: ---------------- - -Using what you've learned this week, create a more complex mashup of some data -that interests you. Map the locations of the breweries near your house. Chart -a multi-axial graph of the popularity of various cities across several -categories. Visualize the most effective legislators in Congress. You have -interests, the Web has tools. Put them together to make something. - -Use the API directory at http://www.programmableweb.com/apis/directory to get -some ideas of data sources and what you might do with them. - -Place the following in the assignments/session03 directory and make a pull -request: - -A textual description of your mashup. - What data sources did you scan, what tools did you use, what is the - outcome you wanted to create? - -Your source code. - Give me an executable python script that I can run to get output. - -Any instructions I need. - If I need instructions beyond 'python myscript.py' to get the right - output, let me know. - -The data you produce need not be pretty, or even particularly visible. In -class we only produced a simple dictionary of values for each listing. Focus -on getting data sources combined rather than on what the output looks like. - - -Optional Tasks: ---------------- - -Write unit tests supporting the functions of your mashup script. diff --git a/assignments/session04/flask_walkthrough-plain.html b/assignments/session04/flask_walkthrough-plain.html deleted file mode 100644 index 00050a4d..00000000 --- a/assignments/session04/flask_walkthrough-plain.html +++ /dev/null @@ -1,580 +0,0 @@ - - - - - - -A Quick Flask Walkthrough - - - -
-

A Quick Flask Walkthrough

- -

If you've already set up your virtualenv and installed flask, you can simply -activate it and skip down to Kicking the Tires

-

If not...

-
-

Practice Safe Development

-

We are going to install Flask, and the packages it requires, into a -virtualenv.

-

This will ensure that it is isolated from everything else we do in class (and -vice versa)

-
-

Remember the basic format for creating a virtualenv:

-
-$ python virtualenv.py [options] <ENV>
-<or>
-$ virtualenv [options] <ENV>
-
-
-
-
-

Set Up a VirtualEnv

-

Start by creating your virtualenv:

-
-$ python virtualenv.py flaskenv
-<or>
-$ virtualenv flaskenv
-...
-
-
-

Then, activate it:

-
-$ source flaskenv/bin/activate
-<or>
-C:\> flaskenv\Scripts\activate
-
-
-
-
-

Install Flask

-

Finally, install Flask using setuptools or pip:

-
-(flaskenv)$ pip install flask
-Downloading/unpacking flask
-  Downloading Flask-0.10.1.tar.gz (544kB): 544kB downloaded
-...
-Installing collected packages: flask, Werkzeug, Jinja2,
-  itsdangerous, markupsafe
-...
-Successfully installed flask Werkzeug Jinja2 itsdangerous
-  markupsafe
-
-
-
-

Kicking the Tires

-

We've installed the Flask microframework and all of its dependencies.

-

Now, let's see what it can do

-

With your flaskenv activated, create a file called flask_intro.py and -open it in your text editor.

-
-
-

Flask

-

Getting started with Flask is pretty straightforward. Here's a complete, -simple app. Type it into flask_intro.py:

-
-from flask import Flask
-app = Flask(__name__)
-
-@app.route('/')
-def hello_world():
-    return 'Hello World!'
-
-if __name__ == '__main__':
-    app.run()
-
-
-
-

Running our App

-

As you might expect by now, the last block in our flask_intro.py file -allows us to run this as a python program. Save your file, and in your -terminal try this:

-
-(flaskenv)$ python flask_intro.py
-
-

Load http://localhost:5000 in your browser to see it in action.

-
-
-

Debugging our App

-

Last week, cgitb provided us with useful feedback when building an app. -Flask has similar functionality. Make the following changes to your -flask_intro.py file:

-
-def hello_world():
-    bar = 1 / 0
-    return 'Hello World!'
-
-if __name__ == '__main__':
-    app.run(debug=True)
-
-

Restart your app and then reload your browser to see what happens.

-

Click in the stack trace that appears in your browser. Notice anything fun?

-

(clean up the error when you're done playing).

-
-
-

Your work so far

- -

Let's take a look at how that last bit works for a moment...

-
-
-

URL Routing

-

Remember our bookdb exercise? How did you end up solving the problem of -mapping an HTTP request to the right function?

-

Flask solves this problem by using the route decorator from your app.

-

A 'route' takes a URL rule (more on that in a minute) and maps it to an -endpoint and a function.

-

When a request arrives at a URL that matches a known rule, the function is -called.

-
-
-

URL Rules

-

URL Rules are strings that represent what environ['PATH_INFO'] will look like.

-

They are added to a mapping on the Flask object called the url_map

-

You can call app.add_url_rule() to add a new one

-

Or you can use what we've used, the app.route() decorator

-
-
-

Function or Decorator

-
-def index():
-    """some function that returns something"""
-    # ...
-
-app.add_url_rule('/', 'homepage', index)
-
-
-

is identical to

-
-@app.route('/', 'homepage')
-def index():
-    """some function that returns something"""
-    # ...
-
-
-
-
-

Routes Can Be Dynamic

-

A placeholder in a URL rule becomes a named arg to your function (add these -to flask_intro.py):

-
-@app.route('/profile/<username>')
-def show_profile(username):
-    return "My username is %s" % username
-
-

And converters ensure the incoming argument is of the correct type.

-
-@app.route('/div/<float:val>/')
-def divide(val):
-    return "%0.2f divided by 2 is %0.2f" % (val, val / 2)
-
-
-
-

Routes Can Be Filtered

-

You can also determine which HTTP methods a given route will accept:

-
-@app.route('/blog/entry/<int:id>/', methods=['GET',])
-def read_entry(id):
-    return "reading entry %d" % id
-
-@app.route('/blog/entry/<int:id>/', methods=['POST', ])
-def write_entry(id):
-    return 'writing entry %d' % id
-
-

After adding that to flask_intro.py and saving, try loading -http://localhost:5000/blog/entry/23/ into your browser. Which was called?

-
-
-

Routes Can Be Reversed

-

Reversing a URL means the ability to generate the url that would result in a -given endpoint being called.

-

This means you don't have to hard-code your URLs when building links

-

That means you can change the URLs for your app without changing code or -templates

-

This is called decoupling and it is a good thing

-
-
-

Reversing URLs in Flask

-

In Flask, you reverse a url with the url_for function.

- -
-from flask import url_for
-with app.test_request_context():
-  print url_for('endpoint', **kwargs)
-
-
-
-

Reversing in Action

-

Quit your Flask app with ^C. Then start a python interpreter in that same -terminal and import your flask_intro.py module:

-
->>> from flask_intro import app
->>> from flask import url_for
->>> with app.test_request_context():
-...     print url_for('show_profile', username="cris")
-...     print url_for('divide', val=23.7)
-...
-'/profile/cris/'
-'/div/23.7/'
->>>
-
-
-
-

Enough for Now

-

That will give you plenty to think about before class. We'll put this all to -good use building a real flask app in our next session.

-
-
- - - diff --git a/assignments/session04/sql/createdb.py b/assignments/session04/sql/createdb.py deleted file mode 100644 index 429bbdbf..00000000 --- a/assignments/session04/sql/createdb.py +++ /dev/null @@ -1,11 +0,0 @@ -import os -import sqlite3 - -DB_FILENAME = 'books.db' -DB_IS_NEW = not os.path.exists(DB_FILENAME) - -def main(): - pass - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/assignments/session04/sql/ddl.sql b/assignments/session04/sql/ddl.sql deleted file mode 100644 index a41b6e9d..00000000 --- a/assignments/session04/sql/ddl.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Schema for a simple book database - --- Author table - -CREATE TABLE author( - authorid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - name TEXT -); - -CREATE TABLE book( - bookid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - title TEXT, - author INTEGER NOT NULL, - FOREIGN KEY(author) REFERENCES author(authorid) -); diff --git a/assignments/session04/sql/populatedb.py b/assignments/session04/sql/populatedb.py deleted file mode 100644 index 92baff4d..00000000 --- a/assignments/session04/sql/populatedb.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import sys -import sqlite3 -from utils import AUTHORS_BOOKS - -DB_FILENAME = 'books.db' -DB_IS_NEW = not os.path.exists(DB_FILENAME) - -author_insert = "INSERT INTO author (name) VALUES(?);" -author_query = "SELECT * FROM author;" -book_query = "SELECT * FROM book;" -book_insert = """ -INSERT INTO book (title, author) VALUES(?, ( - SELECT authorid FROM author WHERE name=? )); -""" - - -def show_query_results(conn, query): - cur = conn.cursor() - cur.execute(query) - had_rows = False - for row in cur.fetchall(): - print row - had_rows = True - if not had_rows: - print "no rows returned" - - -def show_authors(conn): - query = author_query - show_query_results(conn, query) - - -def show_books(conn): - query = book_query - show_query_results(conn, query) - - -if __name__ == '__main__': - if DB_IS_NEW: - print "Database does not yet exist, please import `createdb` first" - sys.exit(1) - - print "Do something cool here" diff --git a/assignments/session04/sql/utils.py b/assignments/session04/sql/utils.py deleted file mode 100644 index 750669b1..00000000 --- a/assignments/session04/sql/utils.py +++ /dev/null @@ -1,31 +0,0 @@ -TABLEPRAGMA = "PRAGMA table_info(%s);" - - -def print_table_metadata(cursor): - tmpl = "%-10s |" - rowdata = cursor.description - results = cursor.fetchall() - for col in rowdata: - print tmpl % col[0], - print '\n' + '-----------+-'*len(rowdata) - for row in results: - for value in row: - print tmpl % value, - print '\n' + '-----------+-'*len(rowdata) - print '\n' - - -def show_table_metadata(cursor, tablename): - stmt = TABLEPRAGMA % tablename - cursor.execute(stmt) - print "Table Metadata for '%s':" % tablename - print_table_metadata(cursor) - - -AUTHORS_BOOKS = { - 'China Mieville': ["Perdido Street Station", "The Scar", "King Rat"], - 'Frank Herbert': ["Dune", "Hellstrom's Hive"], - 'J.R.R. Tolkien': ["The Hobbit", "The Silmarillion"], - 'Susan Cooper': ["The Dark is Rising", ["The Greenwitch"]], - 'Madeline L\'Engle': ["A Wrinkle in Time", "A Swiftly Tilting Planet"] -} diff --git a/assignments/session04/sql_persistence_tutorial-plain.rst b/assignments/session04/sql_persistence_tutorial-plain.rst deleted file mode 100644 index cff12fa1..00000000 --- a/assignments/session04/sql_persistence_tutorial-plain.rst +++ /dev/null @@ -1,798 +0,0 @@ - - - - - - -SQL Persistence in Python - - - -
-

SQL Persistence in Python

- -

In this tutorial, you'll walk through some basic concepts of data persistence -using the Python stdlib implementation of DB API 2, sqlite3

-
-

Data Persistence

-

There are many models for persistance of data.

- -

It's also one of the most contentious issues in app design.

-

For this reason, it's one of the things that most Small Frameworks leave -undecided.

-
-
-

Simple SQL

-

PEP 249 describes a -common API for database connections called DB-API 2.

-
-

The goal was to

-
-

achieve a consistency leading to more easily understood modules, code -that is generally more portable across databases, and a broader reach -of database connectivity from Python

-

source: http://www.python.org/dev/peps/pep-0248/

-
-
-
-
-

A Note on DB API

-

It is important to remember that PEP 249 is only a specification.

-

There is no code or package for DB-API 2 on it's own.

-

Since 2.5, the Python Standard Library has provided a reference -implementation of the api -based on SQLite3

-

Before Python 2.5, this package was available as pysqlite

-
-
-

Using DB API

-

To use the DB API with any database other than SQLite3, you must have an -underlying API package available.

-
-

Implementations are available for:

-
    -
  • PostgreSQL (psycopg2, txpostgres, ...)
  • -
  • MySQL (mysql-python, PyMySQL, ...)
  • -
  • MS SQL Server (adodbapi, pymssql, mxODBC, pyodbc, ...)
  • -
  • Oracle (cx_Oracle, mxODBC, pyodbc, ...)
  • -
  • and many more...
  • -
-

source: http://wiki.python.org/moin/DatabaseInterfaces

-
-
-
-

Installing API Packages

-

Most db api packages can be installed using typical Pythonic methods:

-
-$ easy_install psycopg2
-$ pip install mysql-python
-...
-
-

Most api packages will require that the development headers for the underlying -database system be available. Without these, the C symbols required for -communication with the db are not present and the wrapper cannot work.

-
-
-

Not Today

-

We don't want to spend the next hour getting a package installed, so let's use -sqlite3 instead.

-

I do not recommend using sqlite3 for production web applications, there are -too many ways in which it falls short

-

But it will provide a solid learning tool

-
-
-

Getting Started

-

In the class resources folder, you'll find an sql directory. Copy that to -your working directory.

-

Open the file createdb.py in your text editor. Edit main like so:

-
-def main():
-    conn =  sqlite3.connect(DB_FILENAME)
-    if DB_IS_NEW:
-        print 'Need to create database and schema'
-    else:
-        print 'Database exists, assume schema does, too.'
-    conn.close()
-
-
-
-

Try It Out

-

Run the createdb.py script to see it in effect:

-
-$ python createdb.py
-Need to create database and schema
-$ python createdb.py
-Database exists, assume schema does, too.
-$ ls
-books.db
-...
-
-

Sqlite3 will automatically create a new database when you connect for the -first time, if one does not exist.

-
-
-

Set Up A Schema

-

Make the following changes to createdb.py:

-
-DB_FILENAME = 'books.db'
-SCHEMA_FILENAME = 'ddl.sql' # <- this is new
-DB_IS_NEW = not os.path.exists(DB_FILENAME)
-
-def main():
-    with sqlite3.connect(DB_FILENAME) as conn: # <- context mgr
-        if DB_IS_NEW: # A whole new if clause:
-            print 'Creating schema'
-            with open(SCHEMA_FILENAME, 'rt') as f:
-                schema = f.read()
-            conn.executescript(schema)
-        else:
-            print 'Database exists, assume schema does, too.'
-    # delete the `conn.close()` that was here.
-
-
-
-

Verify Your Work

-

Quit your python interpreter and delete the file books.db

-
-

Then run the script from the command line again to try it out:

-
-$ python createdb.py
-Creating schema
-$ python createdb.py
-Database exists, assume schema does, too.
-
-
-
-
-

Introspect the Database

-

Add the following to createdb.py:

-
-# in the imports, add this line:
-from utils import show_table_metadata
-
-else:
-    # in the else clause, replace the print statement with this:
-    print "Database exists, introspecting:"
-    tablenames = ['author', 'book']
-    cursor = conn.cursor()
-    for name in tablenames:
-        print "\n"
-        show_table_metadata(cursor, name)
-
-

Then try running python createdb.py again

-
-
-

My Results

-
-$ python createdb.py
-Table Metadata for 'author':
-cid        | name       | type       | notnull    | dflt_value | pk         |
------------+------------+------------+------------+------------+------------+-
-0          | authorid   | INTEGER    | 1          | None       | 1          |
------------+------------+------------+------------+------------+------------+-
-1          | name       | TEXT       | 0          | None       | 0          |
------------+------------+------------+------------+------------+------------+-
-
-
-Table Metadata for 'book':
-cid        | name       | type       | notnull    | dflt_value | pk         |
------------+------------+------------+------------+------------+------------+-
-0          | bookid     | INTEGER    | 1          | None       | 1          |
------------+------------+------------+------------+------------+------------+-
-1          | title      | TEXT       | 0          | None       | 0          |
------------+------------+------------+------------+------------+------------+-
-2          | author     | INTEGER    | 1          | None       | 0          |
------------+------------+------------+------------+------------+------------+-
-
-
-
-

Inserting Data

-

Let's load up some data. Fire up your interpreter and type:

-
->>> import sqlite3
->>> insert = """
-... INSERT INTO author (name) VALUES("Iain M. Banks");"""
->>> with sqlite3.connect("books.db") as conn:
-...     cur = conn.cursor()
-...     cur.execute(insert)
-...     cur.rowcount
-...     cur.close()
-...
-<sqlite3.Cursor object at 0x10046e880>
-1
->>>
-
-

Did that work?

-
-
-

Querying Data

-

Let's query our database to find out:

-
->>> query = """
-... SELECT * from author;"""
->>> with sqlite3.connect("books.db") as conn:
-...     cur = conn.cursor()
-...     cur.execute(query)
-...     rows = cur.fetchall()
-...     for row in rows:
-...         print row
-...
-<sqlite3.Cursor object at 0x10046e8f0>
-(1, u'Iain M. Banks')
-
-

Alright! We've got data in there. Let's make it more efficient

-
-
-

Parameterized Statements

-

Try this:

-
->>> insert = """
-... INSERT INTO author (name) VALUES(?);"""
->>> authors = [["China Mieville"], ["Frank Herbert"],
-... ["J.R.R. Tolkien"], ["Susan Cooper"], ["Madeline L'Engle"]]
->>> with sqlite3.connect("books.db") as conn:
-...     cur = conn.cursor()
-...     cur.executemany(insert, authors)
-...     print cur.rowcount
-...     cur.close()
-...
-<sqlite3.Cursor object at 0x10046e8f0>
-5
-
-
-
-

Check Your Work

-

Again, query the database:

-
->>> query = """
-... SELECT * from author;"""
->>> with sqlite3.connect("books.db") as conn:
-...     cur = conn.cursor()
-...     cur.execute(query)
-...     rows = cur.fetchall()
-...     for row in rows:
-...         print row
-...
-<sqlite3.Cursor object at 0x10046e8f0>
-(1, u'Iain M. Banks')
-...
-(4, u'J.R.R. Tolkien')
-(5, u'Susan Cooper')
-(6, u"Madeline L'Engle")
-
-
-
-

Transactions

-

Transactions group operations together, allowing you to verify them before -the results hit the database.

-

In SQLite3, data-altering statements require an explicit commit unless -auto-commit has been enabled.

-

The with statements we've used take care of committing when the context -manager closes.

-

Let's change that so we can see what happens explicitly

-
-
-

Populating the Database

-

Let's start by seeing what happens when you try to look for newly added data -before the insert transaction is committed.

-

Begin by quitting your interpreter and deleting books.db.

-
-

Then re-create the database, empty:

-
-$ python createdb.py
-Creating schema
-
-
-
-
-

Setting Up the Test

-

Open populatedb.py in your editor, replace the final print:

-
-conn1 = sqlite3.connect(DB_FILENAME)
-conn2 = sqlite3.connect(DB_FILENAME)
-print "\nOn conn1, before insert:"
-show_authors(conn1)
-authors = ([author] for author in AUTHORS_BOOKS.keys())
-cur = conn1.cursor()
-cur.executemany(author_insert, authors)
-print "\nOn conn1, after insert:"
-show_authors(conn1)
-print "\nOn conn2, before commit:"
-show_authors(conn2)
-conn1.commit()
-print "\nOn conn2, after commit:"
-show_authors(conn2)
-conn1.close()
-conn2.close()
-
-
-
-

Running the Test

-

Quit your python interpreter and run the populatedb.py script:

-
-On conn1, before insert:
-no rows returned
-On conn1, after insert:
-(1, u'China Mieville')
-(2, u'Frank Herbert')
-(3, u'Susan Cooper')
-(4, u'J.R.R. Tolkien')
-(5, u"Madeline L'Engle")
-
-On conn2, before commit:
-no rows returned
-On conn2, after commit:
-(1, u'China Mieville')
-(2, u'Frank Herbert')
-(3, u'Susan Cooper')
-(4, u'J.R.R. Tolkien')
-(5, u"Madeline L'Engle")
-
-
-
-

Rollback

-

That's all well and good, but what happens if an error occurs?

-

Transactions can be rolled back in order to wipe out partially completed work.

-

Like with commit, using connect as a context manager in a with -statement will automatically rollback for exceptions.

-

Let's rewrite our populatedb script so it explicitly commits or rolls back a -transaction depending on exceptions occurring

-
-
-

Edit populatedb.py (slide 1)

-

First, add the following function above the if __name__ == '__main__' -block:

-
-def populate_db(conn):
-    authors = ([author] for author in AUTHORS_BOOKS.keys())
-    cur = conn.cursor()
-    cur.executemany(author_insert, authors)
-
-    for author in AUTHORS_BOOKS.keys():
-        params = ([book, author] for book in AUTHORS_BOOKS[author])
-        cur.executemany(book_insert, params)
-
-
-
-

Edit populatedb.py (slide 2)

-

Then, in the runner:

-
-with sqlite3.connect(DB_FILENAME) as conn1:
-    with sqlite3.connect(DB_FILENAME) as conn2:
-        try:
-            populate_db(conn1)
-            print "\nauthors and books on conn2 before commit:"
-            show_authors(conn2)
-            show_books(conn2)
-        except sqlite3.Error:
-            conn1.rollback()
-            print "\nauthors and books on conn2 after rollback:"
-            show_authors(conn2)
-            show_books(conn2)
-            raise
-        else:
-            conn1.commit()
-            print "\nauthors and books on conn2 after commit:"
-            show_authors(conn2)
-            show_books(conn2)
-
-
-
-

Try it Out

-

Remove books.db and recrete the database, then run our script:

-
-$ rm books.db
-$ python createdb.py
-Creating schema
-$ python populatedb.py
-
-
-authors and books on conn2 after rollback:
-no rows returned
-no rows returned
-Traceback (most recent call last):
-  File "populatedb.py", line 57, in <module>
-    populate_db(conn1)
-  File "populatedb.py", line 46, in populate_db
-    cur.executemany(book_insert, params)
-sqlite3.InterfaceError: Error binding parameter 0 - probably unsupported type.
-
-
-
-

Oooops, Fix It

-

Okay, we got an error, and the transaction was rolled back correctly.

-
-

Open utils.py and find this:

-
-'Susan Cooper': ["The Dark is Rising", ["The Greenwitch"]],
-
-
-
-

Fix it like so:

-
-'Susan Cooper': ["The Dark is Rising", "The Greenwitch"],
-
-
-

It appears that we were attempting to bind a list as a parameter. Ooops.

-
-
-

Try It Again

-
-

Now that the error in our data is repaired, let's try again:

-
-$ python populatedb.py
-
-
-
-Reporting authors and books on conn2 before commit:
-no rows returned
-no rows returned
-Reporting authors and books on conn2 after commit:
-(1, u'China Mieville')
-(2, u'Frank Herbert')
-(3, u'Susan Cooper')
-(4, u'J.R.R. Tolkien')
-(5, u"Madeline L'Engle")
-(1, u'Perdido Street Station', 1)
-(2, u'The Scar', 1)
-(3, u'King Rat', 1)
-(4, u'Dune', 2)
-(5, u"Hellstrom's Hive", 2)
-(6, u'The Dark is Rising', 3)
-(7, u'The Greenwitch', 3)
-(8, u'The Hobbit', 4)
-(9, u'The Silmarillion', 4)
-(10, u'A Wrinkle in Time', 5)
-(11, u'A Swiftly Tilting Planet', 5)
-
-
-
-

Congratulations

-

You've just created a small database of books and authors. The transactional -protections you've used let you rest comfortable, knowing that so long as the -process completed, you've got the data you sent.

-

We'll see more of this when we build our flask app.

-
-
- - - diff --git a/assignments/session04/tasks.txt b/assignments/session04/tasks.txt deleted file mode 100644 index fb84f316..00000000 --- a/assignments/session04/tasks.txt +++ /dev/null @@ -1,33 +0,0 @@ -Session 4 Homework -================== - -Required Tasks: ---------------- - -The Mashup you created for the last session produces interesting data. This -session we learned how to build simple WSGI applications that expose data -using information from the request. - -For your homework this week, I want you to combine the two. Using your mashup -as a data source, build a simple WSGI application that shows that data to the -user. Make it minimally interactive. A user should be able to click links or -provide input via simple HTML forms. - -Place the following in the assignments/session04 directory and make a pull -request: - -Instructions - If any extra stuff needs to be installed or executed for your application - to work, make sure I know about it. - -Your source code. - Give me an executable python script that I can run to start a WSGI server - serving your application. Use the standard library wsgiref module. - -Your tests. - Last session, tests were optional. This week they are not. Write at least - two tests that prove some of your code works as intended. - -Your application should produce HTML output that I can view in a browser. That -output does not need to be attractive. Continue to focus on the mechanics of -making this work, rather than the aesthetics of making it pretty. diff --git a/assignments/session04/template_tutorial-plain.html b/assignments/session04/template_tutorial-plain.html deleted file mode 100644 index 999b16a4..00000000 --- a/assignments/session04/template_tutorial-plain.html +++ /dev/null @@ -1,514 +0,0 @@ - - - - - - -Jinja2 Template Introduction - - - -
-

Jinja2 Template Introduction

- -

When you installed flask into your virtualenv, along with it came a -Python-based templating engine called Jinja2.

-

In this walkthrough, you'll see some basics about how templates work, and get -to know what sorts of options they provide you for creating HTML from a Python -process.

-
-

Generating HTML

-

"I enjoy writing HTML in Python"

-

-- nobody, ever

-
-
-

Templating

-

A good framework will provide some way of generating HTML with a templating -system.

-

There are nearly as many templating systems as there are frameworks

-

Each has advantages and disadvantages

-

Flask includes the Jinja2 templating system (perhaps because it's built by -the same folks)

-
-
-

Jinja2 Template Basics

-

Let's start with the absolute basics.

-
-

Fire up a Python interpreter, using your flask virtualenv:

-
-(flaskenv)$ python
->>> from jinja2 import Template
-
-
-
-

A template is built of a simple string:

-
->>> t1 = Template("Hello {{ name }}, how are you?")
-
-
-
-
-

Rendering a Template

-

Call the render method, providing some context:

-
->>> t1.render(name="Freddy")
-u'Hello Freddy, how are you?'
->>> t1.render({'name': "Roberto"})
-u'Hello Roberto, how are you?'
->>>
-
-

Context can either be keyword arguments, or a dictionary

-
-
-

Dictionaries in Context

-

Dictionaries passed in as part of the context can be addressed with either -subscript or dotted notation:

-
->>> person = {'first_name': 'Frank',
-...           'last_name': 'Herbert'}
->>> t2 = Template("{{ person.last_name }}, {{ person['first_name'] }}")
->>> t2.render(person=person)
-u'Herbert, Frank'
-
- -
-
-

Objects in Context

-

The exact same is true of objects passed in as part of context:

-
->>> t3 = Template("{{ obj.x }} + {{ obj['y'] }} = Fun!")
->>> class Game(object):
-...   x = 'babies'
-...   y = 'bubbles'
-...
->>> bathtime = Game()
->>> t3.render(obj=bathtime)
-u'babies + bubbles = Fun!'
-
-

This means your templates can be a bit agnostic as to the nature of the things -in context

-
-
-

Filtering values in Templates

-

You can apply filters to the data passed in context with the pipe ('|') -operator:

-
-t4 = Template("shouted: {{ phrase|upper }}")
->>> t4.render(phrase="this is very important")
-u'shouted: THIS IS VERY IMPORTANT'
-
-
-

You can also chain filters together:

-
-t5 = Template("confusing: {{ phrase|upper|reverse }}")
->>> t5.render(phrase="howdy doody")
-u'confusing: YDOOD YDWOH'
-
-
-
-
-

Control Flow

-

Logical control structures are also available:

-
-tmpl = """
-... {% for item in list %}{{ item }}, {% endfor %}
-... """
->>> t6 = Template(tmpl)
->>> t6.render(list=[1,2,3,4,5,6])
-u'\n1, 2, 3, 4, 5, 6, '
-
-

Any control structure introduced in a template must be paired with an -explicit closing tag ({% for %}...{% endfor %})

-
-
-

Template Tests

-

There are a number of specialized tests available for use with the -if...elif...else control structure:

-
->>> tmpl = """
-... {% if phrase is upper %}
-...   {{ phrase|lower }}
-... {% elif phrase is lower %}
-...   {{ phrase|upper }}
-... {% else %}{{ phrase }}{% endif %}"""
->>> t7 = Template(tmpl)
->>> t7.render(phrase="FOO")
-u'\n\n  foo\n'
->>> t7.render(phrase="bar")
-u'\n\n  BAR\n'
->>> t7.render(phrase="This should print as-is")
-u'\nThis should print as-is'
-
-
-
-

Basic Python Expressions

-

Basic Python expressions are also supported:

-
-tmpl = """
-... {% set sum = 0 %}
-... {% for val in values %}
-... {{ val }}: {{ sum + val }}
-...   {% set sum = sum + val %}
-... {% endfor %}
-... """
->>> t8 = Template(tmpl)
->>> t8.render(values=range(1,11))
-u'\n\n\n1: 1\n  \n\n2: 3\n  \n\n3: 6\n  \n\n4: 10\n
-  \n\n5: 15\n  \n\n6: 21\n  \n\n7: 28\n  \n\n8: 36\n
-  \n\n9: 45\n  \n\n10: 55\n  \n'
-
-
-
-

Much, Much More

-

There's more that Jinja2 templates can do, and you'll see more in class -when we write templates for our Flask app.

-
-

Make sure that you bookmark the Jinja2 documentation for later use:

-
-http://jinja.pocoo.org/docs/templates/
-
-
-
-
- - - diff --git a/assignments/session05/microblog/microblog.cfg b/assignments/session05/microblog/microblog.cfg deleted file mode 100644 index fda11a39..00000000 --- a/assignments/session05/microblog/microblog.cfg +++ /dev/null @@ -1,2 +0,0 @@ -# application configuration for a Flask microblog -DATABASE = 'microblog.db' diff --git a/assignments/session05/microblog/microblog.py b/assignments/session05/microblog/microblog.py deleted file mode 100644 index fae4888a..00000000 --- a/assignments/session05/microblog/microblog.py +++ /dev/null @@ -1,70 +0,0 @@ -from flask import Flask -from flask import g -from flask import render_template -from flask import abort -from flask import request -from flask import url_for -from flask import redirect -import sqlite3 -from contextlib import closing - -app = Flask(__name__) - -app.config.from_pyfile('microblog.cfg') - - -def connect_db(): - return sqlite3.connect(app.config['DATABASE']) - - -def init_db(): - with closing(connect_db()) as db: - with app.open_resource('schema.sql') as f: - db.cursor().executescript(f.read()) - db.commit() - - -def get_database_connection(): - db = getattr(g, 'db', None) - if db is None: - g.db = db = connect_db() - return db - - -@app.teardown_request -def teardown_request(exception): - db = getattr(g, 'db', None) - if db is not None: - db.close() - - -def write_entry(title, text): - con = get_database_connection() - con.execute('insert into entries (title, text) values (?, ?)', - [title, text]) - con.commit() - - -def get_all_entries(): - con = get_database_connection() - cur = con.execute('SELECT title, text FROM entries ORDER BY id DESC') - return [dict(title=row[0], text=row[1]) for row in cur.fetchall()] - - -@app.route('/') -def show_entries(): - entries = get_all_entries() - return render_template('show_entries.html', entries=entries) - - -@app.route('/add', methods=['POST']) -def add_entry(): - try: - write_entry(request.form['title'], request.form['text']) - except sqlite3.Error: - abort(500) - return redirect(url_for('show_entries')) - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/assignments/session05/microblog/microblog_tests.py b/assignments/session05/microblog/microblog_tests.py deleted file mode 100644 index 57a64f37..00000000 --- a/assignments/session05/microblog/microblog_tests.py +++ /dev/null @@ -1,77 +0,0 @@ -import os -import tempfile -import unittest -import microblog - - -class MicroblogTestCase(unittest.TestCase): - - def setUp(self): - db_fd = tempfile.mkstemp() - self.db_fd, microblog.app.config['DATABASE'] = db_fd - microblog.app.config['TESTING'] = True - self.client = microblog.app.test_client() - self.app = microblog.app - microblog.init_db() - - def tearDown(self): - os.close(self.db_fd) - os.unlink(microblog.app.config['DATABASE']) - - def test_database_setup(self): - con = microblog.connect_db() - cur = con.execute('PRAGMA table_info(entries);') - rows = cur.fetchall() - self.assertEquals(len(rows), 3) - - def test_write_entry(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - con = microblog.connect_db() - cur = con.execute("select * from entries;") - rows = cur.fetchall() - self.assertEquals(len(rows), 1) - for val in expected: - self.assertTrue(val in rows[0]) - - def test_get_all_entries_empty(self): - with self.app.test_request_context('/'): - entries = microblog.get_all_entries() - self.assertEquals(len(entries), 0) - - def test_get_all_entries(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - entries = microblog.get_all_entries() - self.assertEquals(len(entries), 1) - for entry in entries: - self.assertEquals(expected[0], entry['title']) - self.assertEquals(expected[1], entry['text']) - - def test_empty_listing(self): - actual = self.client.get('/').data - expected = 'No entries here so far' - self.assertTrue(expected in actual) - - def test_listing(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - actual = self.client.get('/').data - for value in expected: - self.assertTrue(value in actual) - - def test_add_entries(self): - actual = self.client.post('/add', data=dict( - title='Hello', - text='This is a post' - ), follow_redirects=True).data - self.assertFalse('No entries here so far' in actual) - self.assertTrue('Hello' in actual) - self.assertTrue('This is a post' in actual) - - -if __name__ == '__main__': - unittest.main() diff --git a/assignments/session05/microblog/schema.sql b/assignments/session05/microblog/schema.sql deleted file mode 100644 index 71fe0588..00000000 --- a/assignments/session05/microblog/schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -drop table if exists entries; -create table entries ( - id integer primary key autoincrement, - title string not null, - text string not null -); diff --git a/assignments/session05/microblog/static/style.css b/assignments/session05/microblog/static/style.css deleted file mode 100644 index 80218c4f..00000000 --- a/assignments/session05/microblog/static/style.css +++ /dev/null @@ -1,20 +0,0 @@ -body { font-family: 'Helvetica', sans-serif; background: #eaeced; } -a, h1, h2 { color: #1E727F; } -h1, h2 { font-family: 'Helvetica', sans-serif; margin: 0; } -h1 { font-size: 2em; border-bottom: 2px solid #1E727F; padding-bottom: 0.25em; margin-bottom: 0.5em;} -h2 { font-size: 1.4em; } -.page { margin: 2em auto; width: 35em; border: 5px solid #1E727F; - padding: 0.8em; background: white; } -.entries { list-style: none; margin: 0; padding: 0; } -.entries li { margin: 0.8em 1.2em; } -.entries li h2 { margin-left: -1em; } -.add_entry { float: right; clear: right; width: 50%; font-size: 0.9em; - border: 1px solid #1E727F; padding: 1em; background: #fafafa;} -.add_entry dl { font-weight: bold; } -.add_entry label {display: block; } -.add_entry .field {margin-bottom: 0.25em;} -.metanav { text-align: left; font-size: 0.8em; padding: 0.3em; - margin-bottom: 1em; background: #fafafa; border: 1px solid #1E727F} -.flash { width: 30%; background: #00B0CC; padding: 1em; - border: 1px solid #1E727F; margin-bottom: 1em;} -.error { background: #F0D6D6; padding: 0.5em; } diff --git a/assignments/session05/microblog/templates/layout.html b/assignments/session05/microblog/templates/layout.html deleted file mode 100644 index e13b1287..00000000 --- a/assignments/session05/microblog/templates/layout.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - Microblog! - - - -

My Microblog

-
- {% block body %}{% endblock %} -
- - diff --git a/assignments/session05/microblog/templates/show_entries.html b/assignments/session05/microblog/templates/show_entries.html deleted file mode 100644 index 07738258..00000000 --- a/assignments/session05/microblog/templates/show_entries.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -
-
- - -
-
- - -
-
- -
-
-

Posts

- -{% endblock %} diff --git a/assignments/session05/tasks.txt b/assignments/session05/tasks.txt deleted file mode 100644 index ea269d25..00000000 --- a/assignments/session05/tasks.txt +++ /dev/null @@ -1,64 +0,0 @@ -Session 5 Homework -================== - -Required Tasks --------------- - -1. Add authentication so a user can log in and log out. -2. Add flash messaging so the app can inform a user about events that happen - - -Authentication Specifications ------------------------------ - -Writing new entries should be restricted to users who have logged in. This -means that: - -.. class:: incremental - -* The form to create a new entry should only be visible to logged in users -* There should be a visible link to allow a user to log in -* This link should display a login form that expects a username and password -* If the user provides incorrect login information, this form should tell her - so. -* If the user provides correct login information, she should end up at the - list page -* Once logged in, the user should see a link to log out. -* Upon clicking that link, the system should no longer show the entry form and - the log in link should re-appear. - - -Flash Messaging Specifications ------------------------------- - -A flask app provides a method called `flash` that allows passing messages from -a view function into a template context so that they can be viewed by a user. - -.. class:: incremental - -Use this method to provide the following messages to users: - -.. class:: incremental - -* Upon a successful login, display the message "You are logged in" -* Upon a successful logout, display the message "You have logged out" -* Upon posting a successful new entry, display the message "New entry posted" -* If adding an entry causes an error, instead of returning a 500 response, - alert the user to the error by displaying the error message to the user. - - -Resources to Use ----------------- - -The microblog we created today comes from the tutorial on the `flask` website. -I've modified that tutorial to omit authentication and flash messaging. You can -refer to the tutorial and to the flask api documentation to learn what you need -to accomplish these tasks. - -`The Flask Tutorial `_ - -`Flask API Documentation `_ - -Both features depend on *sessions*, so you will want to pay particular -attention to how a session is enabled and what you can do with it once it -exists. diff --git a/assignments/session06/django_intro-plain.html b/assignments/session06/django_intro-plain.html deleted file mode 100644 index ca60c56a..00000000 --- a/assignments/session06/django_intro-plain.html +++ /dev/null @@ -1,1246 +0,0 @@ - - - - - - -An Introduction To Django - - - -
-

An Introduction To Django

- -

In this tutorial, you'll walk through creating a very simple microblog -application using Django.

-
-

Practice Safe Development

-

We'll install Django and any other packages we use with it in a virtualenv.

-

This will ensure that it is isolated from everything else we do in class (and -vice versa)

-
-

Remember the basic format for creating a virtualenv:

-
-$ python virtualenv.py [options] <ENV>
-<or>
-$ virtualenv [options] <ENV>
-
-
-
-
-

Set Up a VirtualEnv

-

Start by creating your virtualenv:

-
-$ python virtualenv.py djangoenv
-<or>
-$ virtualenv djangoenv
-...
-
-
-

Then, activate it:

-
-$ source djangoenv/bin/activate
-<or>
-C:\> djangoenv\Scripts\activate
-
-
-
-
-

Install Django

-

Finally, install Django 1.6.2 using pip:

-
-(djangoenv)$ pip install Django==1.6.2
-Downloading/unpacking Django==1.5.2
-  Downloading Django-1.6.2.tar.gz (8.0MB): 8.0MB downloaded
-  Running setup.py egg_info for package Django
-     changing mode of /path/to/djangoenv/bin/django-admin.py to 755
-Successfully installed Django
-Cleaning up...
-(djangoenv)$
-
-
-
-

Starting a Project

-

Everything in Django stems from the project

-

To get started learning, we'll create one

-

We'll use a script installed by Django, django-admin.py:

-
-(djangoenv)$ django-admin.py startproject mysite
-
-

This will create a folder called 'mysite'. Let's take a look at it:

-
-
-

Project Layout

-

The folder created by django-admin.py contains the following structure:

-
-mysite
-├── manage.py
-└── mysite
-    ├── __init__.py
-    ├── settings.py
-    ├── urls.py
-    └── wsgi.py
-
-

If what you see doesn't match that, you're using an older version of Django. -Make sure you've installed 1.6.2.

-
-
-

What Got Created

-
    -
  • outer *mysite* folder: this is just a container and can be renamed or -moved at will
  • -
  • inner *mysite* folder: this is your project directory. It should not be -renamed.
  • -
  • __init__.py: magic file that makes mysite a python package.
  • -
  • settings.py: file which holds configuration for your project, more soon.
  • -
  • urls.py: file which holds top-level URL configuration for your project, -more soon.
  • -
  • wsgi.py: binds a wsgi application created from your project to the -symbol application
  • -
  • manage.py: a management control script.
  • -
-
-
-

django-admin.py and manage.py

-

django-admin.py provides a hook for administrative tasks and abilities:

-
    -
  • creating a new project or app
  • -
  • running the development server
  • -
  • executing tests
  • -
  • entering a python interpreter
  • -
  • entering a database shell session with your database
  • -
  • much much more (run django-admin.py without an argument)
  • -
-

manage.py wraps this functionality, adding the full environment of your -project.

-
-
-

How manage.py Works

-

Look in the manage.py script Django created for you. You'll see this:

-
-#!/usr/bin/env python
-import os
-import sys
-
-if __name__ == "__main__":
-    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
-    ...
-
-

The environmental var DJANGO_SETTINGS_MODULE is how the manage.py -script is made aware of your project's environment. This is why you shouldn't -rename the project package.

-
-
-

Development Server

-

At this point, you should be ready to use the development server:

-
-(djangoenv)$ cd mysite
-(djangoenv)$ python manage.py runserver
-...
-
-

Load http://localhost:8000 in your browser.

-
-
-

A Blank Slate

-

You should see this:

-img/django-start.png -

Do you?

-
-
-

Connecting A Database

-

Django supplies its own ORM (Object-Relational Mapper)

-

This ORM sits on top of the DB-API implementation you choose.

-

You must provide connection information through Django configuration.

-

All Django configuration takes place in settings.py in your project -folder.

-
-
-

Your Database Settings

-

Edit your settings.py to match:

-
-DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': 'mysite.db',
-    }
-}
-
-

There are other database settings, but they are not used with sqlite3, we'll -ignore them for now.

-
-
-

Django and Your Database

-

Django's ORM provides a layer of abstraction between you and SQL

-

You write Python classes called models describing the object that make up -your system.

-

The ORM handles converting data from these objects into SQL statements (and -back)

-

We'll learn much more about this in a bit

-
-
-

Django Organization

-

We've created a Django project. In Django a project represents a whole -website:

-
    -
  • global configuration settings
  • -
  • inclusion points for additional functionality
  • -
  • master list of URL endpoints
  • -
-

A Django app encapsulates a unit of functionality:

-
    -
  • A blog section
  • -
  • A discussion forum
  • -
  • A content tagging system
  • -
-
-
-

Apps Make Up a Project

-

One project can (and likely will) consist of many apps

-
-
-

Core Django Apps

-

Django already includes some apps for you.

-
-

They're in settings.py in the INSTALLED_APPS setting:

-
-INSTALLED_APPS = (
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.sites',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-    # Uncomment the next line to enable the admin:
-    # 'django.contrib.admin',
-    # Uncomment the next line to enable admin documentation:
-    # 'django.contrib.admindocs',
-)
-
-
-
-
-

Creating the Database

-

These apps define models of their own, tables must be created.

-
-

You make them by running the syncdb management command:

-
-(djangoenv)$ python manage.py syncdb
-Creating tables ...
-Creating table auth_permission
-Creating table auth_group_permissions
-Creating table auth_group
-...
-You just installed Django's auth system, ...
-Would you like to create one now? (yes/no):
-
-
-

Add your first user at this prompt. I strongly suggest you use the username -'admin' and give it the password 'admin'. If you don't, make sure you remember -the values you use.

-
-
-

Our Class App

-

We are going to build an app to add to our project. To start with our app -will be a lot like the Flask app we finished last time.

-

As stated above, an app represents a unit within a system, the project. We -have a project, we need to create an app

-
-
-

Create an App

-

This is accomplished using manage.py.

-

In your terminal, make sure you are in the outer mysite directory, where the -file manage.py is located. Then:

-
-(djangoenv)$ python manage.py startapp myblog
-
-
-
-

What is Created

-

This should leave you with the following structure:

-
-mysite
-├── manage.py
-├── myblog
-│   ├── __init__.py
-│   ├── admin.py
-│   ├── models.py
-│   ├── tests.py
-│   └── views.py
-└── mysite
-    ├── __init__.py
-    ...
-
-

We'll start by defining the main Python class for our blog system, a Post.

-
-
-

Django Models

-

Any Python class in Django that is meant to be persisted must inherit from -the Django Model class.

-

This base class hooks in to the ORM functionality converting Python code to -SQL.

-

You can override methods from the base Model class to alter how this works -or write new methods to add functionality.

-

Learn more about models

-
-
-

Our Post Model

-

Open the models.py file created in our myblog package. Add the -following:

-
-from django.db import models #<-- This is already in the file
-from django.contrib.auth.models import User
-
-class Post(models.Model):
-    title = models.CharField(max_length=128)
-    text = models.TextField(blank=True)
-    author = models.ForeignKey(User)
-    created_date = models.DateTimeField(auto_now_add=True)
-    modified_date = models.DateTimeField(auto_now=True)
-    published_date = models.DateTimeField(blank=True, null=True)
-
-
-
-

Model Fields

-

We've created a subclass of the Django Model class and added a bunch of -attributes.

-
    -
  • These attributes are all instances of Field classes defined in Django
  • -
  • Field attributes on a model map to columns in a database table
  • -
  • The arguments you provide to each Field customize how it works
      -
    • This means both how it operates in Django and how it is defined in SQL
    • -
    -
  • -
  • There are arguments shared by all Field types
  • -
  • There are also arguments specific to individual types
  • -
-

You can read much more about Model Fields and options

-
-
-

Field Details

-

There are some features of our fields worth mentioning in specific:

-

Notice we have no field that is designated as the primary key

-
    -
  • You can make a field the primary key by adding primary_key=True in the -arguments
  • -
  • If you do not, Django will automatically create one. This field is always -called id
  • -
  • No matter what the primary key field is called, its value is always -available on a model instance as the pk attribute.
  • -
-
-
-

Field Details

-
-title = models.CharField(max_length=128)
-
-

The required max_length argument is specific to CharField fields.

-

It affects both the Python and SQL behavior of a field.

-

In python, it is used to validate supplied values during model validation

-

In SQL it is used in the column definition: VARCHAR(128)

-
-
-

Field Details

-
-author = models.ForeignKey(User)
-
-

Django also models SQL relationships as specific field types.

-

The required positional argument is the class of the related Model.

-

By default, the reverse relation is implemented as the attribute -<fieldname>_set.

-

You can override this by providing the related_name argument.

-
-
-

Field Details

-
-created_date = models.DateTimeField(auto_now_add=True)
-modified_date = models.DateTimeField(auto_now=True)
-
-

auto_now_add is available on all date and time fields. It sets the value -of the field to now when an instance is first saved.

-

auto_now is similar, but sets the value anew each time an instance is -saved.

-

Setting either of these will cause the editable attribute of a field to be -set to False.

-
-
-

Field Details

-
-text = models.TextField(blank=True)
-# ...
-published_date = models.DateTimeField(blank=True, null=True)
-
-

The argument blank is shared across all field types. The default is -False

-

This argument affects only the Python behavior of a field, determining if the -field is required

-

The related null argument affects the SQL definition of a field: is the -column NULL or NOT NULL

-
-
-

Hooking it Up

-

In order to use our new model, we need Django to know about our app

-

This is accomplished by configuration in the settings.py file.

-

Open that file now, in your editor, and find the INSTALLED_APPS setting.

-
-
-

Installing Apps

-

You extend Django functionality by installing apps. This is pretty simple:

-
-INSTALLED_APPS = (
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.sites',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-    'myblog', # <- YOU ADD THIS PART
-)
-
-
-
-

Setting Up the Database

-

You know what the next step will be:

-
-(djangoenv)$ python manage.py syncdb
-Creating tables ...
-Creating table myblog_post
-Installing custom SQL ...
-Installing indexes ...
-Installed 0 object(s) from 0 fixture(s)
-
-

Django has now created a table for our model.

-

Notice that the table name is a combination of the name of our app and the -name of our model.

-
-
-

The Django Shell

-

Django provides a management command shell:

-
    -
  • Shares the same sys.path as your project, so all installed python -packages are present.
  • -
  • Imports the settings.py file from your project, and so shares all -installed apps and other settings.
  • -
  • Handles connections to your database, so you can interact with live data -directly.
  • -
-

Let's explore the Model Instance API directly using this shell:

-
-(djangoenv)$ python manage.py shell
-
-
-
-

Creating Instances

-

Instances of our model can be created by simple instantiation:

-
->>> from myblog.models import Post
->>> p1 = Post(title="My first post",
-...           text="This is the first post I've written")
->>> p1
-<Post: Post object>
-
-
-

We can also validate that our new object is okay before we try to save it:

-
->>> p1.full_clean()
-Traceback (most recent call last):
-  ...
-ValidationError: {'author': [u'This field cannot be null.']}
-
-
-
-
-

Django Model Managers

-

We have to hook our Post to an author, which must be a User.

-

To do this, we need to have an instance of the User class.

-

We can use the User model manager to run table-level operations like -SELECT:

-

All Django models have a manager. By default it is accessed through the -objects class attribute.

-
-
-

Making a ForeignKey Relation

-

Let's use the manager to get an instance of the User class:

-
->>> from django.contrib.auth.models import User
->>> all_users = User.objects.all()
->>> all_users
-[<User: cewing>]
->>> u1 = all_users[0]
->>> p1.author = u1
-
-
-

And now our instance should validate properly:

-
->>> p1.full_clean()
->>>
-
-
-
-
-

Saving New Objects

-

Our model has three date fields, two of which are supposed to be -auto-populated:

-
->>> print(p1.created_date)
-None
->>> print(p1.modified_date)
-None
-
-
-

When we save our post, these fields will get values assigned:

-
->>> p1.save()
->>> p1.created_date
-datetime.datetime(2013, 7, 26, 20, 2, 38, 104217, tzinfo=<UTC>)
->>> p1.modified_date
-datetime.datetime(2013, 7, 26, 20, 2, 38, 104826, tzinfo=<UTC>)
-
-
-
-
-

Updating An Instance

-

Models operate much like 'normal' python objects.

-
-

To change the value of a field, simply set the instance attribute to a new -value. Call save() to persist the change:

-
->>> p1.title = p1.title + " (updated)"
->>> p1.save()
->>> p1.title
-'My first post (updated)'
-
-
-
-
-

Create a Few Posts

-

Let's create a few more posts so we can explore the Django model manager query -API:

-
->>> p2 = Post(title="Another post",
-...           text="The second one created",
-...           author=u1).save()
->>> p3 = Post(title="The third one",
-...           text="With the word 'heffalump'",
-...           author=u1).save()
->>> p4 = Post(title="Posters are great decoration",
-...           text="When you are a poor college student",
-...           author=u1).save()
->>> Post.objects.count()
-4
-
-
-
-

The Django Query API

-

The manager on each model class supports a full-featured query API.

-

API methods take keyword arguments, where the keywords are special -constructions combining field names with field lookups:

-
    -
  • title__exact="The exact title"
  • -
  • text__contains="decoration"
  • -
  • id__in=range(1,4)
  • -
  • published_date__lte=datetime.datetime.now()
  • -
-

Each keyword argument generates an SQL clause.

-
-
-

QuerySets

-

API methods can be divided into two basic groups: methods that return -QuerySets and those that do not.

-

The former may be chained without hitting the database:

-
->>> a = Post.objects.all() #<-- no query yet
->>> b = a.filter(title__icontains="post") #<-- not yet
->>> c = b.exclude(text__contains="created") #<-- nope
->>> [(p.title, p.text) for p in c] #<-- This will issue the query
-
-
-

Conversely, the latter will issue an SQL query when executed.

-
->>> a.count() # immediately executes an SQL query
-
-
-
-
-

QuerySets and SQL

-

If you are curious, you can see the SQL that a given QuerySet will use:

-
->>> print(c.query)
-SELECT "myblog_post"."id", "myblog_post"."title",
-    "myblog_post"."text", "myblog_post"."author_id",
-    "myblog_post"."created_date", "myblog_post"."modified_date",
-    "myblog_post"."published_date"
-FROM "myblog_post"
-WHERE ("myblog_post"."title" LIKE %post% ESCAPE '\'
-       AND NOT ("myblog_post"."text" LIKE %created% ESCAPE '\' )
-)
-
-

The SQL will vary depending on which DBAPI backend you use (yay ORM!!!)

-
-
-

Exploring the QuerySet API

-

See https://docs.djangoproject.com/en/1.6/ref/models/querysets

-
->>> [p.pk for p in Post.objects.all().order_by('created_date')]
-[1, 2, 3, 4]
->>> [p.pk for p in Post.objects.all().order_by('-created_date')]
-[4, 3, 2, 1]
->>> [p.pk for p in Post.objects.filter(title__contains='post')]
-[1, 2, 4]
->>> [p.pk for p in Post.objects.exclude(title__contains='post')]
-[3]
->>> qs = Post.objects.exclude(title__contains='post')
->>> qs = qs.exclude(id__exact=3)
->>> [p.pk for p in qs]
-[]
->>> qs = Post.objects.exclude(title__contains='post', id__exact=3)
->>> [p.pk for p in qs]
-[1, 2, 3, 4]
-
-
-
-

Updating via QuerySets

-

You can update all selected objects at the same time.

-

Changes are persisted without needing to call save.

-
->>> qs = Post.objects.all()
->>> [p.published_date for p in qs]
-[None, None, None, None]
->>> from datetime import datetime
->>> from django.utils.timezone import UTC
->>> utc = UTC()
->>> now = datetime.now(utc)
->>> qs.update(published_date=now)
-4
->>> [p.published_date for p in qs]
-[datetime.datetime(2013, 7, 27, 1, 20, 30, 505307, tzinfo=<UTC>),
- ...]
-
-
-
-

Testing Our Model

-

As with any project, we want to test our work. Django provides a testing -framework to allow this.

-

Django supports both unit tests and doctests. I strongly suggest using -unit tests.

-

You add tests for your app to the file tests.py, which should be at the -same package level as models.py.

-

Locate and open this file in your editor.

-
-
-

Django TestCase Classes

-

SimpleTestCase is for basic unit testing with no ORM requirements

-

TransactionTestCase is useful if you need to test transactional -actions (commit and rollback) in the ORM

-

TestCase is used when you require ORM access and a test client

-

LiveServerTestCase launches the django server during test runs for -front-end acceptance tests.

-
-
-

Testing Data

-

Sometimes testing requires base data to be present. We need a User for ours.

-

Django provides fixtures to handle this need.

-

Create a directory called fixtures inside your myblog app directory.

-

Copy the file myblog_test_fixture.json from the class resources into this -directory, it contains users for our tests.

-
-
-

Setting Up Tests

-

Now that we have a fixture, we need to instruct our tests to use it.

-
-

Edit tests.py (which comes with one test already) to look like this:

-
-from django.test import TestCase
-from django.contrib.auth.models import User
-
-class PostTestCase(TestCase):
-    fixtures = ['myblog_test_fixture.json', ]
-
-    def setUp(self):
-        self.user = User.objects.get(pk=1)
-
-
-
-
-

Our First Enhancement

-

Look at the way our Post represents itself in the Django shell:

-
->>> [p for p in Post.objects.all()]
-[<Post: Post object>, <Post: Post object>,
- <Post: Post object>, <Post: Post object>]
-
-

Wouldn't it be nice if the posts showed their titles instead?

-

In Django, the __unicode__ method is used to determine how a Model -instance represents itself.

-

Then, calling unicode(instance) gives the desired result.

-
-
-

Write The Test

-

Let's write a test that demonstrates our desired outcome:

-
-# add this import at the top
-from myblog.models import Post
-
-# and this test method to the PostTestCase
-def test_unicode(self):
-    expected = "This is a title"
-    p1 = Post(title=expected)
-    actual = unicode(p1)
-    self.assertEqual(expected, actual)
-
-
-
-

Run The Test

-

To run tests, use the test management command

-

Without arguments, it will run all TestCases it finds in all installed apps

-

You can pass the name of a single app to focus on those tests

-

Quit your Django shell and in your terminal run the test we wrote:

-
-(djangoenv)$ python manage.py test myblog
-
-
-
-

The Result

-

We have yet to implement this enhancement, so our test should fail:

-
-Creating test database for alias 'default'...
-F
-======================================================================
-FAIL: test_unicode (myblog.tests.PostTestCase)
-----------------------------------------------------------------------
-Traceback (most recent call last):
-  File "/Users/cewing/projects/training/uw_pce/training.python_web/scripts/session07/mysite/myblog/tests.py", line 15, in test_unicode
-    self.assertEqual(expected, actual)
-AssertionError: 'This is a title' != u'Post object'
-
-----------------------------------------------------------------------
-Ran 1 test in 0.007s
-
-FAILED (failures=1)
-Destroying test database for alias 'default'...
-
-
-
-

Make it Pass

-

Let's add an appropriate __unicode__ method to our Post class

-

It will take self as its only argument

-

And it should return its own title as the result

-

Go ahead and take a stab at this in models.py

-
-class Post(models.Model):
-    #...
-
-    def __unicode__(self):
-        return self.title
-
-
-
-

Did It Work?

-

Re-run the tests to see:

-
-(djangoenv)$ python manage.py test myblog
-Creating test database for alias 'default'...
-.
-----------------------------------------------------------------------
-Ran 1 test in 0.007s
-
-OK
-Destroying test database for alias 'default'...
-
-

YIPEEEE!

-
-
-

What to Test

-

In any framework, the question arises of what to test. Much of your app's -functionality is provided by framework tools. Does that need testing?

-

I usually don't write tests covering features provided directly by the -framework.

-

I do write tests for functionality I add, and for places where I make -changes to how the default functionality works.

-

This is largely a matter of style and taste (and of budget).

-
-
-

More Later

-

We've only begun to test our blog app.

-

We'll be adding many more tests later

-

In between, you might want to take a look at the Django testing documentation:

-

https://docs.djangoproject.com/en/1.6/topics/testing/

-
-
-

The Django Admin

-

There are some who believe that Django has been Python's killer app

-

And without doubt the Django Admin is a killer feature for Django.

-

To demonstrate this, we are going to set up the admin for our blog

-
-
-

Using the Admin

-

The Django Admin is, itself, an app, installed by default (as of 1.6).

-

Open the settings.py file from our mysite project package and -verify that you see it in the list:

-
-INSTALLED_APPS = (
-    'django.contrib.admin', # <- already present
-    # ...
-    'django.contrib.staticfiles', # <- already present
-    'myblog', # <- already present
-)
-
-
-
-

Accessing the Admin

-

What we need now is to allow the admin to be seen through a web browser.

-

To do that, we'll have to add some URLs to our project.

-
-
-

Django URL Resolution

-

Django too has a system for dispatching requests to code: the urlconf.

-
    -
  • A urlconf is a an iterable of calls to the django.conf.urls.url function
  • -
  • This function takes:
      -
    • a regexp rule, representing the URL
    • -
    • a callable to be invoked (or a name identifying one)
    • -
    • an optional name kwarg, used to reverse the URL
    • -
    • other optional arguments we will skip for now
    • -
    -
  • -
  • The function returns a resolver that matches the request path to the -callable
  • -
-
-
-

urlpatterns

-

I said above that a urlconf is an iterable.

-

That iterable is generally built by calling the django.conf.urls.patterns -function.

-

It's best to build it that way, but in reality, any iterable will do.

-

However, the name you give this iterable is not flexible.

-

Django will load the urlconf named urlpatterns that it finds in the file -named in settings.ROOT_URLCONF.

-
-
-

Including URLs

-

Many Django add-on apps, like the Django Admin, come with their own urlconf

-

It is standard to include these urlconfs by rooting them at some path in your -site.

-
-

You can do this by using the django.conf.urls.include function as the -callable in a url call:

-
-url(r'^forum/', include('random.forum.app.urls'))
-
-
-
-
-

Including the Admin

-

We can use this to add all the URLs provided by the Django admin in one -stroke.

-
-

verify the following lines in urls.py:

-
-from django.contrib import admin #<- make sure these two are
-admin.autodiscover()             #<- present and uncommented
-
-urlpatterns = patterns('',
-    ...
-    url(r'^admin/', include(admin.site.urls)), #<- and this
-)
-
-
-
-
-

Using the Development Server

-

We can now view the admin. We'll use the Django development server.

-

In your terminal, use the runserver management command to start the -development server:

-
-(djangoenv)$ python manage.py runserver
-Validating models...
-
-0 errors found
-Django version 1.4.3, using settings 'mysite.settings'
-Development server is running at http://127.0.0.1:8000/
-Quit the server with CONTROL-C.
-
-
-
-

Viewing the Admin

-

Load http://localhost:8000/admin/. You should see this:

-img/django-admin-login.png -

Login with the name and password you created before.

-
-
-

The Admin Index

-

The index will provide a list of all the installed apps and each model -registered. You should see this:

-img/admin_index.png -

Click on Users. Find yourself? Edit yourself, but don't uncheck -superuser.

-
-
-

Add Posts to the Admin

-

Okay, let's add our app model to the admin.

-

Find the admin.py file in the myblog package. Open it, add the -following and save the file:

-
-from django.contrib import admin # <- this is already there.
-from myblog.models import Post
-
-admin.site.register(Post)
-
-

Reload the admin index page.

-
-
-

Play A Bit

-

Visit the admin page for Posts. You should see the posts we created earlier in -the Django shell.

-

Look at the listing of Posts. Because of our __unicode__ method we see a -nice title.

-

Are there other fields you'd like to see listed?

-

Click on a Post, note what is and is not shown.

-
-
-

Next Steps

-

We've learned a great deal about Django's ORM and Models.

-

We've also spent some time getting to know the Query API provided by model -managers and QuerySets.

-

We've also hooked up the Django Admin and noted some shortcomings.

-

In class we'll learn how to put a front end on this, add new models, and -customize the admin experience.

-
-
- - - diff --git a/assignments/session06/img/admin_index.png b/assignments/session06/img/admin_index.png deleted file mode 100644 index ae7a19f9..00000000 Binary files a/assignments/session06/img/admin_index.png and /dev/null differ diff --git a/assignments/session06/img/django-admin-login.png b/assignments/session06/img/django-admin-login.png deleted file mode 100644 index 4ceb5f54..00000000 Binary files a/assignments/session06/img/django-admin-login.png and /dev/null differ diff --git a/assignments/session06/img/django-start.png b/assignments/session06/img/django-start.png deleted file mode 100644 index 42017110..00000000 Binary files a/assignments/session06/img/django-start.png and /dev/null differ diff --git a/assignments/session06/tasks.txt b/assignments/session06/tasks.txt deleted file mode 100644 index 9cb9b211..00000000 --- a/assignments/session06/tasks.txt +++ /dev/null @@ -1,7 +0,0 @@ -Session 6 Homework -================== - -For your homework this week, walk through the Introduction to Django tutorial. - -Make sure to save your work and bring it to class. We'll be using it as a -starting point for our work in Session 7. diff --git a/assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json b/assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json deleted file mode 100644 index 592dea17..00000000 --- a/assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json +++ /dev/null @@ -1,38 +0,0 @@ -[ -{ - "pk": 1, - "model": "auth.user", - "fields": { - "username": "admin", - "first_name": "Mr.", - "last_name": "Administrator", - "is_active": true, - "is_superuser": true, - "is_staff": true, - "last_login": "2013-05-24T05:35:58.628Z", - "groups": [], - "user_permissions": [], - "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", - "email": "admin@example.com", - "date_joined": "2013-05-24T05:35:58.628Z" - } -}, -{ - "pk": 2, - "model": "auth.user", - "fields": { - "username": "noname", - "first_name": "", - "last_name": "", - "is_active": true, - "is_superuser": true, - "is_staff": true, - "last_login": "2013-05-24T05:35:58.628Z", - "groups": [], - "user_permissions": [], - "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", - "email": "noname@example.com", - "date_joined": "2013-05-24T05:35:58.628Z" - } -} -] diff --git a/assignments/session07/mysite/myblog/migrations/0001_initial.py b/assignments/session07/mysite/myblog/migrations/0001_initial.py deleted file mode 100644 index 4e7a9de9..00000000 --- a/assignments/session07/mysite/myblog/migrations/0001_initial.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -from south.utils import datetime_utils as datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'Post' - db.create_table(u'myblog_post', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('title', self.gf('django.db.models.fields.CharField')(max_length=128)), - ('text', self.gf('django.db.models.fields.TextField')(blank=True)), - ('author', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('created_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), - ('modified_date', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), - ('published_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), - )) - db.send_create_signal(u'myblog', ['Post']) - - - def backwards(self, orm): - # Deleting model 'Post' - db.delete_table(u'myblog_post') - - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'myblog.post': { - 'Meta': {'object_name': 'Post'}, - 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), - 'created_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'modified_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), - 'published_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'text': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '128'}) - } - } - - complete_apps = ['myblog'] \ No newline at end of file diff --git a/assignments/session07/mysite/myblog/migrations/0002_auto__add_category.py b/assignments/session07/mysite/myblog/migrations/0002_auto__add_category.py deleted file mode 100644 index 1ecf7fcc..00000000 --- a/assignments/session07/mysite/myblog/migrations/0002_auto__add_category.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- -from south.utils import datetime_utils as datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'Category' - db.create_table(u'myblog_category', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('name', self.gf('django.db.models.fields.CharField')(max_length=128)), - ('description', self.gf('django.db.models.fields.TextField')(blank=True)), - )) - db.send_create_signal(u'myblog', ['Category']) - - # Adding M2M table for field posts on 'Category' - m2m_table_name = db.shorten_name(u'myblog_category_posts') - db.create_table(m2m_table_name, ( - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), - ('category', models.ForeignKey(orm[u'myblog.category'], null=False)), - ('post', models.ForeignKey(orm[u'myblog.post'], null=False)) - )) - db.create_unique(m2m_table_name, ['category_id', 'post_id']) - - - def backwards(self, orm): - # Deleting model 'Category' - db.delete_table(u'myblog_category') - - # Removing M2M table for field posts on 'Category' - db.delete_table(db.shorten_name(u'myblog_category_posts')) - - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'myblog.category': { - 'Meta': {'object_name': 'Category'}, - 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'posts': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'categories'", 'null': 'True', 'symmetrical': 'False', 'to': u"orm['myblog.Post']"}) - }, - u'myblog.post': { - 'Meta': {'object_name': 'Post'}, - 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"}), - 'created_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'modified_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), - 'published_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'text': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '128'}) - } - } - - complete_apps = ['myblog'] \ No newline at end of file diff --git a/assignments/session07/mysite/myblog/models.py b/assignments/session07/mysite/myblog/models.py deleted file mode 100644 index 29b851c7..00000000 --- a/assignments/session07/mysite/myblog/models.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.db import models -from django.contrib.auth.models import User - -class Post(models.Model): - title = models.CharField(max_length=128) - text = models.TextField(blank=True) - author = models.ForeignKey(User) - created_date = models.DateTimeField(auto_now_add=True) - modified_date = models.DateTimeField(auto_now=True) - published_date = models.DateTimeField(blank=True, null=True) - - def __unicode__(self): - return self.title - -class Category(models.Model): - name = models.CharField(max_length=128) - description = models.TextField(blank=True) - posts = models.ManyToManyField(Post, blank=True, null=True, - related_name='categories') - - def __unicode__(self): - return self.name \ No newline at end of file diff --git a/assignments/session07/mysite/myblog/tests.py b/assignments/session07/mysite/myblog/tests.py deleted file mode 100644 index 413b2131..00000000 --- a/assignments/session07/mysite/myblog/tests.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.test import TestCase -from django.contrib.auth.models import User -from myblog.models import Post -from myblog.models import Category -import datetime -from django.utils.timezone import utc - -class PostTestCase(TestCase): - fixtures = ['myblog_test_fixture.json', ] - - def setUp(self): - self.user = User.objects.get(pk=1) - - def test_unicode(self): - expected = "This is a title" - p1 = Post(title=expected) - actual = unicode(p1) - self.assertEqual(expected, actual) - -class CategoryTestCase(TestCase): - - def test_unicode(self): - expected = "A Category" - c1 = Category(name=expected) - actual = unicode(c1) - self.assertEqual(expected, actual) - -class FrontEndTestCase(TestCase): - """test views provided in the front-end""" - fixtures = ['myblog_test_fixture.json', ] - - def setUp(self): - self.now = datetime.datetime.utcnow().replace(tzinfo=utc) - self.timedelta = datetime.timedelta(15) - author = User.objects.get(pk=1) - for count in range(1,11): - post = Post(title="Post %d Title" % count, - text="foo", - author=author) - if count < 6: - # publish the first five posts - pubdate = self.now - self.timedelta * count - post.published_date = pubdate - post.save() - - def test_list_only_published(self): - resp = self.client.get('/') - self.assertTrue("Recent Posts" in resp.content) - for count in range(1,11): - title = "Post %d Title" % count - if count < 6: - self.assertContains(resp, title, count=1) - else: - self.assertNotContains(resp, title) - - def test_details_only_published(self): - for count in range(1,11): - title = "Post %d Title" % count - post = Post.objects.get(title=title) - resp = self.client.get('/posts/%d/' % post.pk) - if count < 6: - self.assertEqual(resp.status_code, 200) - self.assertContains(resp, title) - else: - self.assertEqual(resp.status_code, 404) diff --git a/assignments/session07/mysite/mysite.db b/assignments/session07/mysite/mysite.db deleted file mode 100644 index 63c9e9a5..00000000 Binary files a/assignments/session07/mysite/mysite.db and /dev/null differ diff --git a/assignments/session07/tasks.txt b/assignments/session07/tasks.txt deleted file mode 100644 index b3df2917..00000000 --- a/assignments/session07/tasks.txt +++ /dev/null @@ -1,49 +0,0 @@ -Session 7 Homework -================== - -We noted in class that it is awkward to have to add a post to a category, -instead of being able to designate a category for a post when authoring the -post. You will update your blog admin so that this is fixed. - -Required Tasks --------------- - -Take the following steps: - -1. Read the documentation about the Django admin. -2. You'll need to create a customized ModelAdmin class for the Post and - Category models. -3. And you'll need to create an InlineModelAdmin to represent Categories on the - Post admin view. -4. Finally, you'll need to suppress the display of the 'posts' field on your - Category admin view. - -resources: - -https://docs.djangoproject.com/en/1.6/ref/contrib/admin/ -https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#modeladmin-objects -https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#inlinemodeladmin-objects -https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#modeladmin-options - - -Optional Tasks --------------- - -If you complete the above in less than 3-4 hours of work, consider looking into -other ways of customizing the admin. - -Tasks you might consider: - -* Change the admin index to say 'Categories' instead of 'Categorys'. -* Add columns for the date fields to the list display of Posts. -* Display the created and modified dates for your posts when viewing them in - the admin. -* Add a column to the list display of Posts that shows the author. For more - fun, make this a link that takes you to the admin page for that user. -* For the biggest challenge, look into `admin actions`_ and add an action to - the Post admin that allows you to bulk publish posts from the Post list - display - -resources: - -https://docs.djangoproject.com/en/1.6/ref/contrib/admin/actions/ diff --git a/bootstrap.py b/bootstrap.py deleted file mode 100644 index 4d7aa17b..00000000 --- a/bootstrap.py +++ /dev/null @@ -1,170 +0,0 @@ -############################################################################## -# -# Copyright (c) 2006 Zope Foundation and Contributors. -# All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE. -# -############################################################################## -"""Bootstrap a buildout-based project - -Simply run this script in a directory containing a buildout.cfg. -The script accepts buildout command-line options, so you can -use the -c option to specify an alternate configuration file. -""" - -import os -import shutil -import sys -import tempfile - -from optparse import OptionParser - -tmpeggs = tempfile.mkdtemp() - -usage = '''\ -[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options] - -Bootstraps a buildout-based project. - -Simply run this script in a directory containing a buildout.cfg, using the -Python that you want bin/buildout to use. - -Note that by using --find-links to point to local resources, you can keep -this script from going over the network. -''' - -parser = OptionParser(usage=usage) -parser.add_option("-v", "--version", help="use a specific zc.buildout version") - -parser.add_option("-t", "--accept-buildout-test-releases", - dest='accept_buildout_test_releases', - action="store_true", default=False, - help=("Normally, if you do not specify a --version, the " - "bootstrap script and buildout gets the newest " - "*final* versions of zc.buildout and its recipes and " - "extensions for you. If you use this flag, " - "bootstrap and buildout will get the newest releases " - "even if they are alphas or betas.")) -parser.add_option("-c", "--config-file", - help=("Specify the path to the buildout configuration " - "file to be used.")) -parser.add_option("-f", "--find-links", - help=("Specify a URL to search for buildout releases")) - - -options, args = parser.parse_args() - -###################################################################### -# load/install setuptools - -to_reload = False -try: - import pkg_resources - import setuptools -except ImportError: - ez = {} - - try: - from urllib.request import urlopen - except ImportError: - from urllib2 import urlopen - - # XXX use a more permanent ez_setup.py URL when available. - exec(urlopen('https://bitbucket.org/pypa/setuptools/raw/0.7.2/ez_setup.py' - ).read(), ez) - setup_args = dict(to_dir=tmpeggs, download_delay=0) - ez['use_setuptools'](**setup_args) - - if to_reload: - reload(pkg_resources) - import pkg_resources - # This does not (always?) update the default working set. We will - # do it. - for path in sys.path: - if path not in pkg_resources.working_set.entries: - pkg_resources.working_set.add_entry(path) - -###################################################################### -# Install buildout - -ws = pkg_resources.working_set - -cmd = [sys.executable, '-c', - 'from setuptools.command.easy_install import main; main()', - '-mZqNxd', tmpeggs] - -find_links = os.environ.get( - 'bootstrap-testing-find-links', - options.find_links or - ('http://downloads.buildout.org/' - if options.accept_buildout_test_releases else None) - ) -if find_links: - cmd.extend(['-f', find_links]) - -setuptools_path = ws.find( - pkg_resources.Requirement.parse('setuptools')).location - -requirement = 'zc.buildout' -version = options.version -if version is None and not options.accept_buildout_test_releases: - # Figure out the most recent final version of zc.buildout. - import setuptools.package_index - _final_parts = '*final-', '*final' - - def _final_version(parsed_version): - for part in parsed_version: - if (part[:1] == '*') and (part not in _final_parts): - return False - return True - index = setuptools.package_index.PackageIndex( - search_path=[setuptools_path]) - if find_links: - index.add_find_links((find_links,)) - req = pkg_resources.Requirement.parse(requirement) - if index.obtain(req) is not None: - best = [] - bestv = None - for dist in index[req.project_name]: - distv = dist.parsed_version - if _final_version(distv): - if bestv is None or distv > bestv: - best = [dist] - bestv = distv - elif distv == bestv: - best.append(dist) - if best: - best.sort() - version = best[-1].version -if version: - requirement = '=='.join((requirement, version)) -cmd.append(requirement) - -import subprocess -if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0: - raise Exception( - "Failed to execute command:\n%s", - repr(cmd)[1:-1]) - -###################################################################### -# Import and run buildout - -ws.add_entry(tmpeggs) -ws.require(requirement) -import zc.buildout.buildout - -if not [a for a in args if '=' not in a]: - args.append('bootstrap') - -# if -c was provided, we push it back into args for buildout' main function -if options.config_file is not None: - args[0:0] = ['-c', options.config_file] - -zc.buildout.buildout.main(args) -shutil.rmtree(tmpeggs) \ No newline at end of file diff --git a/buildout.cfg b/buildout.cfg deleted file mode 100644 index ed81b6b9..00000000 --- a/buildout.cfg +++ /dev/null @@ -1,57 +0,0 @@ -# -# Buildout to set-up Sphinx -# -[buildout] -parts = - sphinx - -extensions = mr.developer -auto-checkout = * -always-checkout = force - -allow-picked-versions = true -show-picked-versions = true - -versions = versions - -script-in = ${buildout:directory}/commands/build.in - -[sphinx] -recipe = collective.recipe.sphinxbuilder -outputs = - html -source = ${buildout:directory}/source/main -build = ${buildout:directory}/build -eggs = - Sphinx - docutils - Pygments - hieroglyph - ipython - -[versions] -# pin versions for continued sanity -Jinja2 = 2.7.2 -Pygments = 1.6 -Sphinx = 1.2.2 -collective.recipe.sphinxbuilder = 0.8.2 - -#Required by: -#collective.recipe.sphinxbuilder 0.7.1 -docutils = 0.11 - -#Required by: -#collective.recipe.sphinxbuilder 0.7.1 -zc.buildout = 1.5.2 - -#Required by: -#collective.recipe.sphinxbuilder 0.7.1 -zc.recipe.egg = 1.3.2 - -#Required by: -#rjm.recipe.venv 0.8 -virtualenv = 1.10 - - -[sources] -hieroglyph = git https://github.com/nyergler/hieroglyph.git diff --git a/downloads/.gitignore b/downloads/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/downloads/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/notebooks/Networking & Sockets.ipynb b/notebooks/Networking & Sockets.ipynb new file mode 100644 index 00000000..09d64583 --- /dev/null +++ b/notebooks/Networking & Sockets.ipynb @@ -0,0 +1,309 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "import socket" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def get_constants(prefix):\n", + " \"\"\"mapping of socket module constants to their names\"\"\"\n", + " return {getattr(socket, n): n\n", + " for n in dir(socket)\n", + " if n.startswith(prefix)\n", + " }" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "families = get_constants('AF_')\n", + "types = get_constants('SOCK_')\n", + "protocols = get_constants('IPPROTO_')" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{: 'AF_UNSPEC',\n", + " : 'AF_UNIX',\n", + " : 'AF_INET',\n", + " : 'AF_SNA',\n", + " 12: 'AF_DECnet',\n", + " : 'AF_APPLETALK',\n", + " : 'AF_ROUTE',\n", + " : 'AF_LINK',\n", + " : 'AF_IPX',\n", + " : 'AF_INET6',\n", + " : 'AF_SYSTEM'}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "families" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{: 'SOCK_STREAM',\n", + " : 'SOCK_DGRAM',\n", + " : 'SOCK_RAW',\n", + " : 'SOCK_RDM',\n", + " : 'SOCK_SEQPACKET'}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "types" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{0: 'IPPROTO_IP',\n", + " 1: 'IPPROTO_ICMP',\n", + " 2: 'IPPROTO_IGMP',\n", + " 3: 'IPPROTO_GGP',\n", + " 4: 'IPPROTO_IPV4',\n", + " 6: 'IPPROTO_TCP',\n", + " 8: 'IPPROTO_EGP',\n", + " 12: 'IPPROTO_PUP',\n", + " 17: 'IPPROTO_UDP',\n", + " 22: 'IPPROTO_IDP',\n", + " 29: 'IPPROTO_TP',\n", + " 36: 'IPPROTO_XTP',\n", + " 41: 'IPPROTO_IPV6',\n", + " 43: 'IPPROTO_ROUTING',\n", + " 44: 'IPPROTO_FRAGMENT',\n", + " 46: 'IPPROTO_RSVP',\n", + " 47: 'IPPROTO_GRE',\n", + " 50: 'IPPROTO_ESP',\n", + " 51: 'IPPROTO_AH',\n", + " 58: 'IPPROTO_ICMPV6',\n", + " 59: 'IPPROTO_NONE',\n", + " 60: 'IPPROTO_DSTOPTS',\n", + " 63: 'IPPROTO_HELLO',\n", + " 77: 'IPPROTO_ND',\n", + " 80: 'IPPROTO_EON',\n", + " 103: 'IPPROTO_PIM',\n", + " 108: 'IPPROTO_IPCOMP',\n", + " 132: 'IPPROTO_SCTP',\n", + " 255: 'IPPROTO_RAW',\n", + " 256: 'IPPROTO_MAX'}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "protocols" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "default_socket = socket.socket()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'AF_INET'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "families[default_socket.family]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'SOCK_STREAM'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "types[default_socket.type]" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'IPPROTO_IP'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "protocols[default_socket.proto]" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def get_address_info(host, port):\n", + " for response in socket.getaddrinfo(host, port):\n", + " fam, typ, pro, nam, add = response\n", + " print('family: {}'.format(families[fam]))\n", + " print('type: {}'.format(types[typ]))\n", + " print('protocol: {}'.format(protocols[pro]))\n", + " print('canonical name: {}'.format(nam))\n", + " print('socket address: {}'.format(add))\n", + " print('')" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "family: AF_INET\n", + "type: SOCK_DGRAM\n", + "protocol: IPPROTO_UDP\n", + "canonical name: \n", + "socket address: ('127.0.0.1', 80)\n", + "\n", + "family: AF_INET\n", + "type: SOCK_STREAM\n", + "protocol: IPPROTO_TCP\n", + "canonical name: \n", + "socket address: ('127.0.0.1', 80)\n", + "\n" + ] + } + ], + "source": [ + "get_address_info(socket.gethostname(), 'http')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.4.3" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/requirements.pip b/requirements.pip new file mode 100644 index 00000000..7539549c --- /dev/null +++ b/requirements.pip @@ -0,0 +1,24 @@ +alabaster==0.7.6 +appnope==0.1.0 +Babel==2.0 +decorator==4.0.2 +docutils==0.12 +gnureadline==6.3.3 +hieroglyph==0.7.1 +ipython==4.0.0 +ipython-genutils==0.1.0 +Jinja2==2.8 +MarkupSafe==0.23 +path.py==8.1 +pexpect==3.3 +pickleshare==0.5 +py==1.4.30 +Pygments==2.0.2 +pytest==2.7.2 +pytz==2015.4 +simplegeneric==0.8.1 +six==1.9.0 +snowballstemmer==1.2.0 +Sphinx==1.3.1 +sphinx-rtd-theme==0.1.8 +traitlets==4.0.0 diff --git a/resources/session01/echo_client.py b/resources/session01/echo_client.py new file mode 100644 index 00000000..6b2f0472 --- /dev/null +++ b/resources/session01/echo_client.py @@ -0,0 +1,48 @@ +import socket +import sys + + +def client(msg, log_buffer=sys.stderr): + server_address = ('localhost', 10000) + # TODO: Replace the following line with your code which will instantiate + # a TCP socket with IPv4 Addressing, call the socket you make 'sock' + sock = None + print('connecting to {0} port {1}'.format(*server_address), file=log_buffer) + # TODO: connect your socket to the server here. + + # you can use this variable to accumulate the entire message received back + # from the server + received_message = '' + + # this try/finally block exists purely to allow us to close the socket + # when we are finished with it + try: + print('sending "{0}"'.format(msg), file=log_buffer) + # TODO: send your message to the server here. + + # TODO: the server should be sending you back your message as a series + # of 16-byte chunks. Accumulate the chunks you get to build the + # entire reply from the server. Make sure that you have received + # the entire message and then you can break the loop. + # + # Log each chunk you receive. Use the print statement below to + # do it. This will help in debugging problems + chunk = '' + print('received "{0}"'.format(chunk.decode('utf8')), file=log_buffer) + finally: + # TODO: after you break out of the loop receiving echoed chunks from + # the server you will want to close your client socket. + print('closing socket', file=log_buffer) + + # TODO: when all is said and done, you should return the entire reply + # you received from the server as the return value of this function. + + +if __name__ == '__main__': + if len(sys.argv) != 2: + usage = '\nusage: python echo_client.py "this is my message"\n' + print(usage, file=sys.stderr) + sys.exit(1) + + msg = sys.argv[1] + client(msg) diff --git a/assignments/session01/echo_server.py b/resources/session01/echo_server.py similarity index 54% rename from assignments/session01/echo_server.py rename to resources/session01/echo_server.py index 217380fb..4103ac6a 100644 --- a/assignments/session01/echo_server.py +++ b/resources/session01/echo_server.py @@ -5,65 +5,72 @@ def server(log_buffer=sys.stderr): # set an address for our server address = ('127.0.0.1', 10000) - # TODO: Replace the following line with your code which will instantiate + # TODO: Replace the following line with your code which will instantiate # a TCP socket with IPv4 Addressing, call the socket you make 'sock' sock = None - # TODO: Set an option to allow the socket address to be reused immediately - # see the end of http://docs.python.org/2/library/socket.html - + # TODO: You may find that if you repeatedly run the server script it fails, + # claiming that the port is already used. You can set an option on + # your socket that will fix this problem. We DID NOT talk about this + # in class. Find the correct option by reading the very end of the + # socket library documentation: + # http://docs.python.org/3/library/socket.html#example + # log that we are building a server - print >>log_buffer, "making a server on {0}:{1}".format(*address) - + print("making a server on {0}:{1}".format(*address), file=log_buffer) + # TODO: bind your new sock 'sock' to the address above and begin to listen # for incoming connections - + try: # the outer loop controls the creation of new connection sockets. The # server will handle each incoming connection one at a time. while True: - print >>log_buffer, 'waiting for a connection' + print('waiting for a connection', file=log_buffer) # TODO: make a new socket when a client connects, call it 'conn', - # at the same time you should be able to get the address of - # the client so we can report it below. Replace the + # at the same time you should be able to get the address of + # the client so we can report it below. Replace the # following line with your code. It is only here to prevent # syntax errors addr = ('bar', 'baz') try: - print >>log_buffer, 'connection - {0}:{1}'.format(*addr) + print('connection - {0}:{1}'.format(*addr), file=log_buffer) - # the inner loop will receive messages sent by the client in - # buffers. When a complete message has been received, the + # the inner loop will receive messages sent by the client in + # buffers. When a complete message has been received, the # loop will exit while True: # TODO: receive 16 bytes of data from the client. Store - # the data you receive as 'data'. Replace the + # the data you receive as 'data'. Replace the # following line with your code. It's only here as - # a placeholder to prevent an error in string + # a placeholder to prevent an error in string # formatting - data = '' - print >>log_buffer, 'received "{0}"'.format(data) - # TODO: you will need to check here to see if any data was - # received. If so, send the data you got back to - # the client. If not, exit the inner loop and wait - # for a new connection from a client + data = b'' + print('received "{0}"'.format(data.decode('utf8'))) + # TODO: Send the data you received back to the client, log + # the fact using the print statement here. It will help in + # debugging problems. + print('sent "{0}"'.format(data.decode('utf8'))) + # TODO: Check here to see if the message you've received is + # complete. If it is, break out of this inner loop. finally: # TODO: When the inner loop exits, this 'finally' clause will # be hit. Use that opportunity to close the socket you - # created above when a client connected. Replace the - # call to `pass` below, which is only there to prevent - # syntax problems - pass - + # created above when a client connected. + print( + 'echo complete, client connection closed', file=log_buffer + ) + except KeyboardInterrupt: - # TODO: Use the python KeyboardIntterupt exception as a signal to - # close the server socket and exit from the server function. - # Replace the call to `pass` below, which is only there to + # TODO: Use the python KeyboardInterrupt exception as a signal to + # close the server socket and exit from the server function. + # Replace the call to `pass` below, which is only there to # prevent syntax problems pass + print('quitting echo server', file=log_buffer) if __name__ == '__main__': server() - sys.exit(0) \ No newline at end of file + sys.exit(0) diff --git a/resources/session01/socket_tools.py b/resources/session01/socket_tools.py new file mode 100644 index 00000000..d2bc1e18 --- /dev/null +++ b/resources/session01/socket_tools.py @@ -0,0 +1,21 @@ +import socket + + +def get_constants(prefix): + return {getattr(socket, n): n for n in dir(socket) if n.startswith(prefix)} + + +families = get_constants('AF_') +types = get_constants('SOCK_') +protocols = get_constants('IPPROTO_') + + +def get_address_info(host, port): + for response in socket.getaddrinfo(host, port): + fam, typ, pro, nam, add = response + print('family: {}'.format(families[fam])) + print('type: {}'.format(types[typ])) + print('protocol: {}'.format(protocols[pro])) + print('canonical name: {}'.format(nam)) + print('socket address: {}'.format(add)) + print() diff --git a/assignments/session01/tasks.txt b/resources/session01/tasks.txt similarity index 92% rename from assignments/session01/tasks.txt rename to resources/session01/tasks.txt index 9352a45f..8fdab003 100644 --- a/assignments/session01/tasks.txt +++ b/resources/session01/tasks.txt @@ -1,4 +1,4 @@ -Session 1 Homework +Session 4 Homework ================== Required Tasks: @@ -28,6 +28,8 @@ To run the tests: Optional Tasks: --------------- +Simple: + * Write a python function that lists the services provided by a given range of ports. @@ -35,6 +37,8 @@ Optional Tasks: * provide sensible defaults * Ensure that it only accepts valid port numbers (0-65535) +Challenging: + * The echo server as outlined will only process a connection from one client at a time. If a second client were to attempt a connection, it would have to wait until the first message was fully echoed before it could be dealt with. @@ -44,6 +48,6 @@ Optional Tasks: our echo server to handle more than one incoming connection in "parallel". Read the documentation about the `select` module - (http://docs.python.org/2/library/select.html) and attempt to write a second - version of the echo server that can handle multiple client connections in + (http://docs.python.org/3/library/select.html) and attempt to write a second + version of the echo server that can handle multiple client connections in "parallel". You do not need to invoke threading of any kind to do this. diff --git a/resources/session01/tests.py b/resources/session01/tests.py new file mode 100644 index 00000000..d4c6c791 --- /dev/null +++ b/resources/session01/tests.py @@ -0,0 +1,46 @@ +from echo_client import client +import socket +import unittest + + +class EchoTestCase(unittest.TestCase): + """tests for the echo server and client""" + + def send_message(self, message): + """Attempt to send a message using the client + + In case of a socket error, fail and report the problem + """ + try: + reply = client(message) + except socket.error as e: + if e.errno == 61: + msg = "Error: {0}, is the server running?" + self.fail(msg.format(e.strerror)) + else: + self.fail("Unexpected Error: {0}".format(str(e))) + return reply + + def test_short_message_echo(self): + """test that a message short than 16 bytes echoes cleanly""" + expected = "short message" + actual = self.send_message(expected) + self.assertEqual( + expected, + actual, + "expected {0}, got {1}".format(expected, actual) + ) + + def test_long_message_echo(self): + """test that a message longer than 16 bytes echoes in 16-byte chunks""" + expected = "Four score and seven years ago our fathers did stuff" + actual = self.send_message(expected) + self.assertEqual( + expected, + actual, + "expected {0}, got {1}".format(expected, actual) + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/resources/session02/homework/http_server.py b/resources/session02/homework/http_server.py new file mode 100644 index 00000000..84ceffe1 --- /dev/null +++ b/resources/session02/homework/http_server.py @@ -0,0 +1,86 @@ +import socket +import sys + + +def response_ok(body=b"this is a pretty minimal response", mimetype=b"text/plain"): + """returns a basic HTTP response""" + resp = [] + resp.append(b"HTTP/1.1 200 OK") + resp.append(b"Content-Type: text/plain") + resp.append(b"") + resp.append(b"this is a pretty minimal response") + return b"\r\n".join(resp) + + +def response_method_not_allowed(): + """returns a 405 Method Not Allowed response""" + resp = [] + resp.append("HTTP/1.1 405 Method Not Allowed") + resp.append("") + return "\r\n".join(resp).encode('utf8') + + +def response_not_found(): + """returns a 404 Not Found response""" + return b"" + + +def parse_request(request): + first_line = request.split("\r\n", 1)[0] + method, uri, protocol = first_line.split() + if method != "GET": + raise NotImplementedError("We only accept GET") + return uri + + +def resolve_uri(uri): + """This method should return appropriate content and a mime type""" + return b"still broken", b"text/plain" + + +def server(log_buffer=sys.stderr): + address = ('127.0.0.1', 10000) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + print("making a server on {0}:{1}".format(*address), file=log_buffer) + sock.bind(address) + sock.listen(1) + + try: + while True: + print('waiting for a connection', file=log_buffer) + conn, addr = sock.accept() # blocking + try: + print('connection - {0}:{1}'.format(*addr), file=log_buffer) + request = '' + while True: + data = conn.recv(1024) + request += data.decode('utf8') + if len(data) < 1024: + break + + try: + uri = parse_request(request) + except NotImplementedError: + response = response_method_not_allowed() + else: + try: + content, mime_type = resolve_uri(uri) + except NameError: + response = response_not_found() + else: + response = response_ok(content, mime_type) + + print('sending response', file=log_buffer) + conn.sendall(response) + finally: + conn.close() + + except KeyboardInterrupt: + sock.close() + return + + +if __name__ == '__main__': + server() + sys.exit(0) diff --git a/resources/session02/homework/simple_client.py b/resources/session02/homework/simple_client.py new file mode 100644 index 00000000..2c9ed4cd --- /dev/null +++ b/resources/session02/homework/simple_client.py @@ -0,0 +1,44 @@ +import socket +import sys + + +def bytes_client(msg): + server_address = ('localhost', 10000) + sock = socket.socket( + socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP + ) + print( + 'connecting to {0} port {1}'.format(*server_address), + file=sys.stderr + ) + sock.connect(server_address) + response = b'' + done = False + bufsize = 1024 + try: + print('sending "{0}"'.format(msg), file=sys.stderr) + sock.sendall(msg.encode('utf8')) + while not done: + chunk = sock.recv(bufsize) + if len(chunk) < bufsize: + done = True + response += chunk + print('received "{0}"'.format(response), file=sys.stderr) + finally: + print('closing socket', file=sys.stderr) + sock.close() + return response + + +def client(msg): + return bytes_client(msg).decode('utf8') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + usg = '\nusage: python echo_client.py "this is my message"\n' + print(usg, file=sys.stderr) + sys.exit(1) + + msg = sys.argv[1] + client(msg) diff --git a/assignments/session02/tests.py b/resources/session02/homework/tests.py similarity index 65% rename from assignments/session02/tests.py rename to resources/session02/homework/tests.py index a74fe150..45007311 100644 --- a/assignments/session02/tests.py +++ b/resources/session02/homework/tests.py @@ -1,11 +1,31 @@ import mimetypes import os +import pathlib import socket import unittest CRLF = '\r\n' -KNOWN_TYPES = set(mimetypes.types_map.values()) +CRLF_BYTES = CRLF.encode('utf8') +KNOWN_TYPES = set( + map(lambda x: x.encode('utf8'), mimetypes.types_map.values()) +) + + +def extract_response_code(response): + return response.split(CRLF_BYTES, 1)[0].split(b' ', 1)[1].strip() + + +def extract_response_protocol(response): + return response.split(CRLF_BYTES, 1)[0].split(b' ', 1)[0].strip() + + +def extract_headers(response): + return response.split(CRLF_BYTES*2, 1)[0].split(CRLF_BYTES)[1:] + + +def extract_body(response): + return response.split(CRLF_BYTES*2, 1)[1] class ResponseOkTestCase(unittest.TestCase): @@ -15,7 +35,7 @@ class ResponseOkTestCase(unittest.TestCase): running. """ - def call_function_under_test(self, body="", mimetype="text/plain"): + def call_function_under_test(self, body=b"", mimetype=b"text/plain"): """call the `response_ok` function from our http_server module""" from http_server import response_ok return response_ok(body=body, mimetype=mimetype) @@ -23,22 +43,22 @@ def call_function_under_test(self, body="", mimetype="text/plain"): def test_response_code(self): ok = self.call_function_under_test() expected = "200 OK" - actual = ok.split(CRLF)[0].split(' ', 1)[1].strip() - self.assertEqual(expected, actual) + actual = extract_response_code(ok) + self.assertEqual(expected.encode('utf8'), actual) - def test_response_method(self): + def test_response_protocol(self): ok = self.call_function_under_test() expected = 'HTTP/1.1' - actual = ok.split(CRLF)[0].split(' ', 1)[0].strip() - self.assertEqual(expected, actual) + actual = extract_response_protocol(ok) + self.assertEqual(expected.encode('utf8'), actual) def test_response_has_content_type_header(self): ok = self.call_function_under_test() - headers = ok.split(CRLF+CRLF, 1)[0].split(CRLF)[1:] - expected_name = 'content-type' + headers = extract_headers(ok) + expected_name = 'content-type'.encode('utf8') has_header = False for header in headers: - name, value = header.split(':') + name, value = header.split(b':') actual_name = name.strip().lower() if actual_name == expected_name: has_header = True @@ -47,10 +67,10 @@ def test_response_has_content_type_header(self): def test_response_has_legitimate_content_type(self): ok = self.call_function_under_test() - headers = ok.split(CRLF+CRLF, 1)[0].split(CRLF)[1:] - expected_name = 'content-type' + headers = extract_headers(ok) + expected_name = 'content-type'.encode('utf8') for header in headers: - name, value = header.split(':') + name, value = header.split(b':') actual_name = name.strip().lower() if actual_name == expected_name: self.assertTrue(value.strip() in KNOWN_TYPES) @@ -59,14 +79,14 @@ def test_response_has_legitimate_content_type(self): def test_passed_mimetype_in_response(self): mimetypes = [ - 'image/jpeg', 'text/html', 'text/x-python', + b'image/jpeg', b'text/html', b'text/x-python', ] - header_name = 'content-type' + header_name = b'content-type' for expected in mimetypes: ok = self.call_function_under_test(mimetype=expected) - headers = ok.split(CRLF+CRLF, 1)[0].split(CRLF)[1:] + headers = extract_headers(ok) for header in headers: - name, value = header.split(':') + name, value = header.split(b':') if header_name == name.strip().lower(): actual = value.strip() self.assertEqual( @@ -77,13 +97,13 @@ def test_passed_mimetype_in_response(self): def test_passed_body_in_response(self): bodies = [ - "a body", - "a longer body\nwith two lines", - open("webroot/sample.txt", 'r').read(), + b"a body", + b"a longer body\nwith two lines", + pathlib.Path("webroot/sample.txt").read_bytes(), ] for expected in bodies: ok = self.call_function_under_test(body=expected) - actual = ok.split(CRLF+CRLF, 1)[1] + actual = extract_body(ok) self.assertEqual( expected, actual, @@ -101,14 +121,14 @@ def call_function_under_test(self): def test_response_code(self): resp = self.call_function_under_test() expected = "405 Method Not Allowed" - actual = resp.split(CRLF)[0].split(' ', 1)[1].strip() - self.assertEqual(expected, actual) + actual = extract_response_code(resp) + self.assertEqual(expected.encode('utf8'), actual) def test_response_method(self): resp = self.call_function_under_test() expected = 'HTTP/1.1' - actual = resp.split(CRLF)[0].split(' ', 1)[0].strip() - self.assertEqual(expected, actual) + actual = extract_response_protocol(resp) + self.assertEqual(expected.encode('utf8'), actual) class ResponseNotFoundTestCase(unittest.TestCase): @@ -122,14 +142,14 @@ def call_function_under_test(self): def test_response_code(self): resp = self.call_function_under_test() expected = "404 Not Found" - actual = resp.split(CRLF)[0].split(' ', 1)[1].strip() - self.assertEqual(expected, actual) + actual = extract_response_code(resp) + self.assertEqual(expected.encode('utf8'), actual) def test_response_method(self): resp = self.call_function_under_test() expected = 'HTTP/1.1' - actual = resp.split(CRLF)[0].split(' ', 1)[0].strip() - self.assertEqual(expected, actual) + actual = extract_response_protocol(resp) + self.assertEqual(expected.encode('utf8'), actual) class ParseRequestTestCase(unittest.TestCase): @@ -145,7 +165,7 @@ def test_get_method(self): request = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" try: self.call_function_under_test(request) - except (NotImplementedError, Exception), e: + except (NotImplementedError, Exception) as e: self.fail('GET method raises an error {0}'.format(str(e))) def test_bad_http_methods(self): @@ -180,7 +200,8 @@ class ResolveURITestCase(unittest.TestCase): def call_function_under_test(self, uri): """call the resolve_uri function""" from http_server import resolve_uri - return resolve_uri(uri) + content, mime_type = resolve_uri(uri) + return content, mime_type.decode('utf8') def test_directory_resource(self): uri = '/' @@ -194,6 +215,7 @@ def test_directory_resource(self): actual_mimetype, 'expected {0} got {1}'.format(expected_mimetype, actual_mimetype) ) + actual_body = actual_body.decode('utf8') for expected in expected_names: self.assertTrue( expected in actual_body, @@ -207,8 +229,8 @@ def test_file_resource(self): '/sample.txt': 'text/plain', } for uri, expected_mimetype in uris_types.items(): - path = "webroot{0}".format(uri) - expected_body = open(path, 'rb').read() + path = pathlib.Path("webroot{0}".format(uri)) + expected_body = path.read_bytes() actual_body, actual_mimetype = self.call_function_under_test(uri) self.assertEqual( expected_mimetype, @@ -232,8 +254,8 @@ def test_image_resource(self): } for filename, expected_mimetype in names_types.items(): uri = "/images/{0}".format(filename) - path = "webroot{0}".format(uri) - expected_body = open(path, 'rb').read() + path = pathlib.Path("webroot{0}".format(uri)) + expected_body = path.read_bytes() actual_body, actual_mimetype = self.call_function_under_test(uri) self.assertEqual( expected_mimetype, @@ -252,7 +274,7 @@ def test_image_resource(self): def test_missing_resource(self): uri = "/missing.html" - self.assertRaises(ValueError, self.call_function_under_test, uri) + self.assertRaises(NameError, self.call_function_under_test, uri) class HTTPServerFunctionalTestCase(unittest.TestCase): @@ -262,16 +284,20 @@ class HTTPServerFunctionalTestCase(unittest.TestCase): be running in order for the tests to pass """ - def send_message(self, message): + def send_message(self, message, use_bytes=False): """Attempt to send a message using the client and the test buffer In case of a socket error, fail and report the problem """ - from simple_client import client - response = '' + response = '' + if not use_bytes: + from simple_client import client + else: + from simple_client import bytes_client as client + try: response = client(message) - except socket.error, e: + except socket.error as e: if e.errno == 61: msg = "Error: {0}, is the server running?" self.fail(msg.format(e.strerror)) @@ -298,7 +324,7 @@ def test_post_request(self): def test_webroot_directory_resources(self): """verify that directory uris are properly served""" message_tmpl = CRLF.join(['GET {0} HTTP/1.1', 'Host: example.com', '']) - root = "webroot" + root = "webroot/" for directory, directories, files in os.walk(root): directory_uri = "/{0}".format(directory[len(root):]) message = message_tmpl.format(directory_uri) @@ -316,44 +342,75 @@ def test_webroot_directory_resources(self): def test_webroot_file_uris(self): """verify that file uris are properly served""" message_tmpl = CRLF.join(['GET {0} HTTP/1.1', 'Host: example.com', '']) - root = "webroot" - for directory, directories, files in os.walk(root): - directory_uri = "/{0}".format(directory[len(root):]) - # verify that all files are delivered correctly - for filename in files: - # file as local resource and as web URI - file_path = os.path.sep.join([directory, filename]) - if directory_uri != '/': - file_uri = '/'.join([directory_uri, filename]) - else: - file_uri = '/{0}'.format(filename) - # set up expectations for this file - expected_body = open(file_path, 'rb').read() - expected_mimetype = mimetypes.types_map[ - os.path.splitext(filename)[1] - ] - # make a request for this file as a uri - message = message_tmpl.format(file_uri) - actual = self.send_message(message) - # verify that request is OK - self.assertTrue( - "200 OK" in actual, - "request for {0} did not result in OK".format( - directory_uri - ) + root = pathlib.Path("webroot") + for file_path in root.iterdir(): + # set up expectations for this file + if file_path.is_dir(): + continue + expected_body = file_path.read_bytes().decode('utf8') + expected_mimetype = mimetypes.types_map[ + os.path.splitext(str(file_path))[1] + ] + file_uri = str(file_path)[len(str(root)):] + message = message_tmpl.format(file_uri) + actual = self.send_message(message) + self.assertTrue( + "200 OK" in actual, + "request for {0} did not result in OK".format( + file_uri ) - self.assertTrue( - expected_mimetype in actual, - "mimetype {0} not in response for {1}".format( - expected_mimetype, file_uri - ) + ) + self.assertTrue( + expected_mimetype in actual, + "mimetype {0} not in response for {1}".format( + expected_mimetype, file_uri ) - self.assertTrue( - expected_body in actual, - "body of {0} not in response for {1}".format( - file_path, file_uri - ) + ) + self.assertTrue( + expected_body in actual, + "body of {0} not in response for {1}".format( + file_path, file_uri ) + ) + + def test_webroot_image_uris(self): + """verify that image uris are properly served + + requires using a client that does not attempt to decode the response + body + """ + message_tmpl = CRLF.join(['GET {0} HTTP/1.1', 'Host: example.com', '']) + root = pathlib.Path("webroot") + images_path = root / 'images' + for file_path in images_path.iterdir(): + # set up expectations for this file + if file_path.is_dir(): + continue + expected_body = file_path.read_bytes() + expected_mimetype = mimetypes.types_map[ + os.path.splitext(str(file_path))[1] + ] + file_uri = str(file_path)[len(str(root)):] + message = message_tmpl.format(file_uri) + actual = self.send_message(message, use_bytes=True) + self.assertTrue( + b"200 OK" in actual, + "request for {0} did not result in OK".format( + file_uri + ) + ) + self.assertTrue( + expected_mimetype.encode('utf8') in actual, + "mimetype {0} not in response for {1}".format( + expected_mimetype, file_uri + ) + ) + self.assertTrue( + expected_body in actual, + "body of {0} not in response for {1}".format( + file_path, file_uri + ) + ) def test_missing_resource(self): message = CRLF.join( diff --git a/assignments/session02/webroot/a_web_page.html b/resources/session02/homework/webroot/a_web_page.html similarity index 100% rename from assignments/session02/webroot/a_web_page.html rename to resources/session02/homework/webroot/a_web_page.html diff --git a/assignments/session02/webroot/images/JPEG_example.jpg b/resources/session02/homework/webroot/images/JPEG_example.jpg similarity index 100% rename from assignments/session02/webroot/images/JPEG_example.jpg rename to resources/session02/homework/webroot/images/JPEG_example.jpg diff --git a/assignments/session02/webroot/images/Sample_Scene_Balls.jpg b/resources/session02/homework/webroot/images/Sample_Scene_Balls.jpg similarity index 100% rename from assignments/session02/webroot/images/Sample_Scene_Balls.jpg rename to resources/session02/homework/webroot/images/Sample_Scene_Balls.jpg diff --git a/assignments/session02/webroot/images/sample_1.png b/resources/session02/homework/webroot/images/sample_1.png similarity index 100% rename from assignments/session02/webroot/images/sample_1.png rename to resources/session02/homework/webroot/images/sample_1.png diff --git a/assignments/session02/webroot/make_time.py b/resources/session02/homework/webroot/make_time.py similarity index 89% rename from assignments/session02/webroot/make_time.py rename to resources/session02/homework/webroot/make_time.py index d3064dd2..b69acf38 100644 --- a/assignments/session02/webroot/make_time.py +++ b/resources/session02/homework/webroot/make_time.py @@ -17,9 +17,6 @@

%s

-"""% time_str - -print html - - +""" % time_str +print(html) diff --git a/assignments/session02/webroot/sample.txt b/resources/session02/homework/webroot/sample.txt similarity index 100% rename from assignments/session02/webroot/sample.txt rename to resources/session02/homework/webroot/sample.txt diff --git a/resources/session02/http_server.py b/resources/session02/http_server.py new file mode 100644 index 00000000..d5aaf480 --- /dev/null +++ b/resources/session02/http_server.py @@ -0,0 +1,39 @@ +import socket +import sys + + +def server(log_buffer=sys.stderr): + address = ('127.0.0.1', 10000) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + print("making a server on {0}:{1}".format(*address), file=log_buffer) + sock.bind(address) + sock.listen(1) + + try: + while True: + print('waiting for a connection', file=log_buffer) + conn, addr = sock.accept() # blocking + try: + print('connection - {0}:{1}'.format(*addr), file=log_buffer) + while True: + data = conn.recv(16) + print('received "{0}"'.format(data), file=log_buffer) + if data: + print('sending data back to client', file=log_buffer) + conn.sendall(data) + else: + msg = 'no more data from {0}:{1}'.format(*addr) + print(msg, log_buffer) + break + finally: + conn.close() + + except KeyboardInterrupt: + sock.close() + return + + +if __name__ == '__main__': + server() + sys.exit(0) diff --git a/assignments/session02/simple_client.py b/resources/session02/simple_client.py similarity index 60% rename from assignments/session02/simple_client.py rename to resources/session02/simple_client.py index c0f0d6e2..74523a2a 100644 --- a/assignments/session02/simple_client.py +++ b/resources/session02/simple_client.py @@ -7,22 +7,25 @@ def client(msg): sock = socket.socket( socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP ) - print >>sys.stderr, 'connecting to {0} port {1}'.format(*server_address) + print( + 'connecting to {0} port {1}'.format(*server_address), + file=sys.stderr + ) sock.connect(server_address) response = '' done = False bufsize = 1024 try: - print >>sys.stderr, 'sending "{0}"'.format(msg) - sock.sendall(msg) + print('sending "{0}"'.format(msg), file=sys.stderr) + sock.sendall(msg.encode('utf8')) while not done: chunk = sock.recv(bufsize) if len(chunk) < bufsize: done = True - response += chunk - print >>sys.stderr, 'received "{0}"'.format(response) + response += chunk.decode('utf8') + print('received "{0}"'.format(response), file=sys.stderr) finally: - print >>sys.stderr, 'closing socket' + print('closing socket', file=sys.stderr) sock.close() return response @@ -30,8 +33,8 @@ def client(msg): if __name__ == '__main__': if len(sys.argv) != 2: usg = '\nusage: python echo_client.py "this is my message"\n' - print >>sys.stderr, usg + print(usg, file=sys.stderr) sys.exit(1) - + msg = sys.argv[1] - client(msg) \ No newline at end of file + client(msg) diff --git a/resources/session02/tests.py b/resources/session02/tests.py new file mode 100644 index 00000000..a4da1793 --- /dev/null +++ b/resources/session02/tests.py @@ -0,0 +1,165 @@ +import mimetypes +import socket +import unittest + + +CRLF = '\r\n' +CRLF_BYTES = CRLF.encode('utf8') +KNOWN_TYPES = set( + map(lambda x: x.encode('utf8'), mimetypes.types_map.values()) +) + + +def extract_response_code(response): + return response.split(CRLF_BYTES, 1)[0].split(b' ', 1)[1].strip() + + +def extract_response_protocol(response): + return response.split(CRLF_BYTES, 1)[0].split(b' ', 1)[0].strip() + + +def extract_headers(response): + return response.split(CRLF_BYTES*2, 1)[0].split(CRLF_BYTES)[1:] + + +class ResponseOkTestCase(unittest.TestCase): + """unit tests for the response_ok method in our server + + Becase this is a unit test case, it does not require the server to be + running. + """ + + def call_function_under_test(self): + """call the `response_ok` function from our http_server module""" + from http_server import response_ok + return response_ok() + + def test_response_code(self): + ok = self.call_function_under_test() + expected = "200 OK" + actual = extract_response_code(ok) + self.assertEqual(expected.encode('utf8'), actual) + + def test_response_protocol(self): + ok = self.call_function_under_test() + expected = 'HTTP/1.1' + actual = extract_response_protocol(ok) + self.assertEqual(expected.encode('utf8'), actual) + + def test_response_has_content_type_header(self): + ok = self.call_function_under_test() + headers = extract_headers(ok) + expected_name = 'content-type'.encode('utf8') + has_header = False + for header in headers: + name, value = header.split(b':') + actual_name = name.strip().lower() + if actual_name == expected_name: + has_header = True + break + self.assertTrue(has_header) + + def test_response_has_legitimate_content_type(self): + ok = self.call_function_under_test() + headers = extract_headers(ok) + expected_name = 'content-type'.encode('utf8') + for header in headers: + name, value = header.split(b':') + actual_name = name.strip().lower() + if actual_name == expected_name: + self.assertTrue(value.strip() in KNOWN_TYPES) + return + self.fail('no content type header found') + + +class ResponseMethodNotAllowedTestCase(unittest.TestCase): + """unit tests for the response_method_not_allowed function""" + + def call_function_under_test(self): + """call the `response_method_not_allowed` function""" + from http_server import response_method_not_allowed + return response_method_not_allowed() + + def test_response_code(self): + resp = self.call_function_under_test() + expected = "405 Method Not Allowed" + actual = extract_response_code(resp) + self.assertEqual(expected.encode('utf8'), actual) + + def test_response_method(self): + resp = self.call_function_under_test() + expected = 'HTTP/1.1' + actual = extract_response_protocol(resp) + self.assertEqual(expected.encode('utf8'), actual) + + +class ParseRequestTestCase(unittest.TestCase): + """unit tests for the parse_request method""" + + def call_function_under_test(self, request): + """call the `parse_request` function""" + from http_server import parse_request + return parse_request(request) + + def test_get_method(self): + """verify that GET HTTP requests do not raise an error""" + request = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" + try: + self.call_function_under_test(request) + except (NotImplementedError, Exception) as e: + self.fail('GET method raises an error {0}'.format(str(e))) + + def test_bad_http_methods(self): + """verify that non-GET HTTP methods raise a NotImplementedError""" + methods = ['POST', 'PUT', 'DELETE', 'HEAD'] + request_template = "{0} / HTTP/1.1\r\nHost: example.com\r\n\r\n" + for method in methods: + request = request_template.format(method) + self.assertRaises( + NotImplementedError, self.call_function_under_test, request + ) + + +class HTTPServerFunctionalTestCase(unittest.TestCase): + """functional tests of the HTTP Server + + This test case interacts with the http server, and as such requires it to + be running in order for the tests to pass + """ + + def send_message(self, message): + """Attempt to send a message using the client and the test buffer + + In case of a socket error, fail and report the problem + """ + from simple_client import client + response = '' + try: + response = client(message) + except socket.error as e: + if e.errno == 61: + msg = "Error: {0}, is the server running?" + self.fail(msg.format(e.strerror)) + else: + self.fail("Unexpected Error: {0}".format(str(e))) + return response + + def test_get_request(self): + message = CRLF.join(['GET / HTTP/1.1', 'Host: example.com', '']) + expected = '200 OK' + actual = self.send_message(message) + self.assertTrue( + expected in actual, '"{0}" not in "{1}"'.format(expected, actual) + ) + + def test_post_request(self): + message = CRLF.join(['POST / HTTP/1.1', 'Host: example.com', '']) + expected = '405 Method Not Allowed' + actual = self.send_message(message) + self.assertTrue( + expected in actual, '"{0}" not in "{1}"'.format(expected, actual) + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/resources/session03/cgi/cgi-bin/cgi_1.py b/resources/session03/cgi/cgi-bin/cgi_1.py new file mode 100755 index 00000000..baa5c3e9 --- /dev/null +++ b/resources/session03/cgi/cgi-bin/cgi_1.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +import cgi + + +cgi.test() diff --git a/resources/session03/cgi/cgi-bin/cgi_2.py b/resources/session03/cgi/cgi-bin/cgi_2.py new file mode 100755 index 00000000..100ccded --- /dev/null +++ b/resources/session03/cgi/cgi-bin/cgi_2.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +import cgi +import cgitb +cgitb.enable() +import os +import datetime + + +default = "No Value Present" + + +print("Content-Type: text/html") +print() + +body = """ + +Lab 1 - CGI experiments + + +

Hey there, this page has been generated by {software}, running {script}

+

Today is {month} {date}, {year}.

+

This page was requested by IP Address {client_ip}

+ +""".format( + software=os.environ.get('SERVER_SOFTWARE', default), + script='aaaa', + month='bbbb', + date='cccc', + year='dddd', + client_ip='eeee' +) +print(body) diff --git a/resources/session03/cgi/cgi-bin/cgi_sums.py b/resources/session03/cgi/cgi-bin/cgi_sums.py new file mode 100755 index 00000000..feed2bc8 --- /dev/null +++ b/resources/session03/cgi/cgi-bin/cgi_sums.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +import cgi +import cgitb + +cgitb.enable() + +print("Content-type: text/plain") +print() +print("Your job is to make this work") diff --git a/resources/session03/cgi/index.html b/resources/session03/cgi/index.html new file mode 100644 index 00000000..d6725d8b --- /dev/null +++ b/resources/session03/cgi/index.html @@ -0,0 +1,17 @@ + + + + Python 200: Session 06 Lab Examples + + +

Python 200

+

Session 06: CGI, WSGI and Living Online

+

CGI Examples

+
    +
  1. CGI Test 1
  2. +
  3. Exercise One
  4. +
  5. CGI Sum Server
  6. +
+

WSGI Examples

+ + diff --git a/resources/session03/http_server.py b/resources/session03/http_server.py new file mode 100644 index 00000000..e42ea086 --- /dev/null +++ b/resources/session03/http_server.py @@ -0,0 +1,112 @@ +import mimetypes +import pathlib +import socket +import sys + + +def response_ok(body=b"this is a pretty minimal response", mimetype=b"text/plain"): + """returns a basic HTTP response as bytes""" + resp = [] + resp.append(b"HTTP/1.1 200 OK") + resp.append(b"Content-Type: " + mimetype) + resp.append(b"") + resp.append(body) + return b"\r\n".join(resp) + + +def response_method_not_allowed(): + """returns a 405 Method Not Allowed response as bytes""" + resp = [] + resp.append("HTTP/1.1 405 Method Not Allowed") + resp.append("") + return "\r\n".join(resp).encode('utf8') + + +def response_not_found(): + """returns a 404 Not Found response as bytes""" + resp = [] + resp.append("HTTP/1.1 404 Not Found") + resp.append("") + return "\r\n".join(resp).encode('utf8') + + +def parse_request(request): + first_line = request.split("\r\n", 1)[0] + method, uri, protocol = first_line.split() + if method != "GET": + raise NotImplementedError("We only accept GET") + return uri + + +def resolve_uri(uri): + """This method should return appropriate content and a mime type + + Both content and mime type should be expressed as bytes + """ + root_path = pathlib.Path('./webroot') + resource_path = root_path / uri.lstrip('/') + if resource_path.is_dir(): + # the resource is a directory, content type is text/html, produce a + # listing of the directory contents + item_template = '* {}' + listing = [] + for item_path in resource_path.iterdir(): + listing.append(item_template.format(str(item_path))) + content = "\n".join(listing).encode('utf8') + mime_type = 'text/plain'.encode('utf8') + elif resource_path.is_file(): + # the resource is a file, figure out its mime type and read + content = resource_path.read_bytes() + mime_type = mimetypes.guess_type(str(resource_path))[0].encode('utf8') + else: + raise NameError() + return content, mime_type + + + +def server(log_buffer=sys.stderr): + address = ('127.0.0.1', 10000) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + print("making a server on {0}:{1}".format(*address), file=log_buffer) + sock.bind(address) + sock.listen(1) + + try: + while True: + print('waiting for a connection', file=log_buffer) + conn, addr = sock.accept() # blocking + try: + print('connection - {0}:{1}'.format(*addr), file=log_buffer) + request = '' + while True: + data = conn.recv(1024) + request += data.decode('utf8') + if len(data) < 1024: + break + + try: + uri = parse_request(request) + except NotImplementedError: + response = response_method_not_allowed() + else: + try: + content, mime_type = resolve_uri(uri) + except NameError: + response = response_not_found() + else: + response = response_ok(content, mime_type) + + print('sending response', file=log_buffer) + conn.sendall(response) + finally: + conn.close() + + except KeyboardInterrupt: + sock.close() + return + + +if __name__ == '__main__': + server() + sys.exit(0) diff --git a/resources/session03/wsgi/bookapp.py b/resources/session03/wsgi/bookapp.py new file mode 100644 index 00000000..d2284c6f --- /dev/null +++ b/resources/session03/wsgi/bookapp.py @@ -0,0 +1,26 @@ +import re + +from bookdb import BookDB + +DB = BookDB() + + +def book(book_id): + return "

a book with id %s

" % book_id + + +def books(): + return "

a list of books

" + + +def application(environ, start_response): + status = "200 OK" + headers = [('Content-type', 'text/html')] + start_response(status, headers) + return ["

No Progress Yet

".encode('utf8')] + + +if __name__ == '__main__': + from wsgiref.simple_server import make_server + srv = make_server('localhost', 8080, application) + srv.serve_forever() diff --git a/resources/session03/wsgi/bookdb.py b/resources/session03/wsgi/bookdb.py new file mode 100644 index 00000000..f3a72414 --- /dev/null +++ b/resources/session03/wsgi/bookdb.py @@ -0,0 +1,45 @@ + +class BookDB(): + def titles(self): + titles = [ + dict(id=id, title=database[id]['title']) for id in database.keys() + ] + return titles + + def title_info(self, id): + return database.get(id, None) + + +# let's pretend we're getting this information from a database somewhere +database = { + 'id1': { + 'title': 'CherryPy Essentials: Rapid Python Web Application Development', + 'isbn': '978-1904811848', + 'publisher': 'Packt Publishing (March 31, 2007)', + 'author': 'Sylvain Hellegouarch', + }, + 'id2': { + 'title': 'Python for Software Design: How to Think Like a Computer Scientist', + 'isbn': '978-0521725965', + 'publisher': 'Cambridge University Press; 1 edition (March 16, 2009)', + 'author': 'Allen B. Downey', + }, + 'id3': { + 'title': 'Foundations of Python Network Programming', + 'isbn': '978-1430230038', + 'publisher': 'Apress; 2 edition (December 21, 2010)', + 'author': 'John Goerzen', + }, + 'id4': { + 'title': 'Python Cookbook, Second Edition', + 'isbn': '978-0-596-00797-3', + 'publisher': 'O''Reilly Media', + 'author': 'Alex Martelli, Anna Ravenscroft, David Ascher', + }, + 'id5': { + 'title': 'The Pragmatic Programmer: From Journeyman to Master', + 'isbn': '978-0201616224', + 'publisher': 'Addison-Wesley Professional (October 30, 1999)', + 'author': 'Andrew Hunt, David Thomas', + }, +} diff --git a/resources/session03/wsgi/tests.py b/resources/session03/wsgi/tests.py new file mode 100644 index 00000000..b92bc78d --- /dev/null +++ b/resources/session03/wsgi/tests.py @@ -0,0 +1,128 @@ +import unittest + + +class BookAppTestCase(unittest.TestCase): + """shared functionality""" + + def setUp(self): + from bookdb import database + self.db = database + + +class BookDBTestCase(BookAppTestCase): + """tests for the bookdb code""" + + def makeOne(self): + from bookdb import BookDB + return BookDB() + + def test_all_titles_returned(self): + actual_titles = self.makeOne().titles() + self.assertEqual(len(actual_titles), len(self.db)) + + def test_all_titles_correct(self): + actual_titles = self.makeOne().titles() + for actual_title in actual_titles: + self.assertTrue(actual_title['id'] in self.db) + actual = actual_title['title'] + expected = self.db[actual_title['id']]['title'] + self.assertEqual(actual, expected) + + def test_title_info_complete(self): + use_id, expected = self.db.items()[0] + actual = self.makeOne().title_info(use_id) + # demonstrate all actual keys are expected + for key in actual: + self.assertTrue(key in expected) + # demonstrate all expected keys are present in actual + for key in expected: + self.assertTrue(key in actual) + + def test_title_info_correct(self): + for book_id, expected in self.db.items(): + actual = self.makeOne().title_info(book_id) + self.assertEqual(actual, expected) + + +class ResolvePathTestCase(BookAppTestCase): + """tests for the resolve_path function""" + + def call_function_under_test(self, path): + from bookapp import resolve_path + return resolve_path(path) + + def test_root_returns_books_function(self): + """verify that the correct function is returned by the root path""" + from bookapp import books as expected + path = '/' + actual, args = self.call_function_under_test(path) + self.assertTrue(actual is expected) + + def test_root_returns_no_args(self): + """verify that no args are returned for the root path""" + path = '/' + func, actual = self.call_function_under_test(path) + self.assertTrue(not actual) + + def test_book_path_returns_book_function(self): + from bookapp import book as expected + book_id = self.db.keys()[0] + path = '/book/{0}'.format(book_id) + actual, args = self.call_function_under_test(path) + self.assertTrue(actual is expected) + + def test_book_path_returns_bookid_in_args(self): + expected = self.db.keys()[0] + path = '/book/{0}'.format(expected) + func, actual = self.call_function_under_test(path) + self.assertTrue(expected in actual) + + def test_bad_path_raises_name_error(self): + path = '/not/valid/path' + self.assertRaises(NameError, self.call_function_under_test, path) + + +class BooksTestCase(BookAppTestCase): + """tests for the books function""" + + def call_function_under_test(self): + from bookapp import books + return books() + + def test_all_book_titles_in_result(self): + actual = self.call_function_under_test() + for book_id, info in self.db.items(): + expected = info['title'] + self.assertTrue(expected in actual) + + def test_all_book_ids_in_result(self): + actual = self.call_function_under_test() + for expected in self.db: + self.assertTrue(expected in actual) + + +class BookTestCase(BookAppTestCase): + """tests for the book function""" + + def call_function_under_test(self, id): + from bookapp import book + return book(id) + + def test_all_ids_have_results(self): + for book_id in self.db: + actual = self.call_function_under_test(book_id) + self.assertTrue(actual) + + def test_id_returns_correct_results(self): + for book_id, book_info in self.db.items(): + actual = self.call_function_under_test(book_id) + for expected in book_info.values(): + self.assertTrue(expected in actual) + + def test_bad_id_raises_name_error(self): + bad_id = "sponge" + self.assertRaises(NameError, self.call_function_under_test, bad_id) + + +if __name__ == '__main__': + unittest.main() diff --git a/resources/session03/wsgi/wsgi_1.py b/resources/session03/wsgi/wsgi_1.py new file mode 100644 index 00000000..85498d13 --- /dev/null +++ b/resources/session03/wsgi/wsgi_1.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +import datetime + +default = "No Value Set" + +body = """ + +Lab 3 - WSGI experiments + + +

Hey there, this page has been generated by {software}, running at {path}

+

Today is {month} {date}, {year}.

+

This page was requested by IP Address {client_ip}

+ +""" + + +def application(environ, start_response): + import pprint + pprint.pprint(environ) + + response_body = body.format( + software=environ.get('SERVER_SOFTWARE', default), + path="aaaa", + month="bbbb", + date="cccc", + year="dddd", + client_ip="eeee" + ) + status = '200 OK' + + response_headers = [('Content-Type', 'text/html'), + ('Content-Length', str(len(response_body)))] + start_response(status, response_headers) + + return [response_body.encode('utf8')] + + +if __name__ == '__main__': + from wsgiref.simple_server import make_server + srv = make_server('localhost', 8080, application) + srv.serve_forever() diff --git a/resources/session04/mashup_1.py b/resources/session04/mashup_1.py new file mode 100644 index 00000000..0880e87c --- /dev/null +++ b/resources/session04/mashup_1.py @@ -0,0 +1,51 @@ +from bs4 import BeautifulSoup +import requests + + +INSPECTION_DOMAIN = 'http://info.kingcounty.gov' +INSPECTION_PATH = '/health/ehs/foodsafety/inspections/Results.aspx' +INSPECTION_PARAMS = { + 'Output': 'W', + 'Business_Name': '', + 'Business_Address': '', + 'Longitude': '', + 'Latitude': '', + 'City': '', + 'Zip_Code': '', + 'Inspection_Type': 'All', + 'Inspection_Start': '', + 'Inspection_End': '', + 'Inspection_Closed_Business': 'A', + 'Violation_Points': '', + 'Violation_Red_Points': '', + 'Violation_Descr': '', + 'Fuzzy_Search': 'N', + 'Sort': 'H' +} + + +def get_inspection_page(**kwargs): + url = INSPECTION_DOMAIN + INSPECTION_PATH + params = INSPECTION_PARAMS.copy() + for key, val in kwargs.items(): + if key in INSPECTION_PARAMS: + params[key] = val + resp = requests.get(url, params=params) + resp.raise_for_status() + return resp.text + + +def parse_source(html): + parsed = BeautifulSoup(html) + return parsed + + +if __name__ == '__main__': + use_params = { + 'Inspection_Start': '2/1/2014', + 'Inspection_End': '2/1/2016', + 'Zip_Code': '98101' + } + html = get_inspection_page(**use_params) + parsed = parse_source(html) + print(parsed.prettify()) diff --git a/resources/session04/mashup_2.py b/resources/session04/mashup_2.py new file mode 100644 index 00000000..6d4778c9 --- /dev/null +++ b/resources/session04/mashup_2.py @@ -0,0 +1,66 @@ +from bs4 import BeautifulSoup +import pathlib +import re +import requests + + +INSPECTION_DOMAIN = 'http://info.kingcounty.gov' +INSPECTION_PATH = '/health/ehs/foodsafety/inspections/Results.aspx' +INSPECTION_PARAMS = { + 'Output': 'W', + 'Business_Name': '', + 'Business_Address': '', + 'Longitude': '', + 'Latitude': '', + 'City': '', + 'Zip_Code': '', + 'Inspection_Type': 'All', + 'Inspection_Start': '', + 'Inspection_End': '', + 'Inspection_Closed_Business': 'A', + 'Violation_Points': '', + 'Violation_Red_Points': '', + 'Violation_Descr': '', + 'Fuzzy_Search': 'N', + 'Sort': 'H' +} + + +def get_inspection_page(**kwargs): + url = INSPECTION_DOMAIN + INSPECTION_PATH + params = INSPECTION_PARAMS.copy() + for key, val in kwargs.items(): + if key in INSPECTION_PARAMS: + params[key] = val + resp = requests.get(url, params=params) + resp.raise_for_status() + return resp.text + + +def parse_source(html): + parsed = BeautifulSoup(html) + return parsed + + +def load_inspection_page(name): + file_path = pathlib.Path(name) + return file_path.read_text(encoding='utf8') + + +def restaurant_data_generator(html): + id_finder = re.compile(r'PR[\d]+~') + return html.find_all('div', id=id_finder) + + +if __name__ == '__main__': + use_params = { + 'Inspection_Start': '2/1/2013', + 'Inspection_End': '2/1/2015', + 'Zip_Code': '98101' + } + # html = get_inspection_page(**use_params) + html = load_inspection_page('inspection_page.html') + parsed = parse_source(html) + content_col = parsed.find("td", id="contentcol") + data_list = restaurant_data_generator(content_col) + print(data_list[0].prettify()) diff --git a/resources/session04/mashup_3.py b/resources/session04/mashup_3.py new file mode 100644 index 00000000..79e46632 --- /dev/null +++ b/resources/session04/mashup_3.py @@ -0,0 +1,93 @@ +from bs4 import BeautifulSoup +import pathlib +import re +import requests + + +INSPECTION_DOMAIN = 'http://info.kingcounty.gov' +INSPECTION_PATH = '/health/ehs/foodsafety/inspections/Results.aspx' +INSPECTION_PARAMS = { + 'Output': 'W', + 'Business_Name': '', + 'Business_Address': '', + 'Longitude': '', + 'Latitude': '', + 'City': '', + 'Zip_Code': '', + 'Inspection_Type': 'All', + 'Inspection_Start': '', + 'Inspection_End': '', + 'Inspection_Closed_Business': 'A', + 'Violation_Points': '', + 'Violation_Red_Points': '', + 'Violation_Descr': '', + 'Fuzzy_Search': 'N', + 'Sort': 'H' +} + + +def get_inspection_page(**kwargs): + url = INSPECTION_DOMAIN + INSPECTION_PATH + params = INSPECTION_PARAMS.copy() + for key, val in kwargs.items(): + if key in INSPECTION_PARAMS: + params[key] = val + resp = requests.get(url, params=params) + resp.raise_for_status() + return resp.text + + +def parse_source(html): + parsed = BeautifulSoup(html) + return parsed + + +def load_inspection_page(name): + file_path = pathlib.Path(name) + return file_path.read_text(encoding='utf8') + + +def restaurant_data_generator(html): + id_finder = re.compile(r'PR[\d]+~') + return html.find_all('div', id=id_finder) + + +def has_two_tds(elem): + is_tr = elem.name == 'tr' + td_children = elem.find_all('td', recursive=False) + has_two = len(td_children) == 2 + return is_tr and has_two + + +def clean_data(td): + return td.text.strip(" \n:-") + + +def extract_restaurant_metadata(elem): + restaurant_data_rows = elem.find('tbody').find_all( + has_two_tds, recursive=False + ) + rdata = {} + current_label = '' + for data_row in restaurant_data_rows: + key_cell, val_cell = data_row.find_all('td', recursive=False) + new_label = clean_data(key_cell) + current_label = new_label if new_label else current_label + rdata.setdefault(current_label, []).append(clean_data(val_cell)) + return rdata + + +if __name__ == '__main__': + use_params = { + 'Inspection_Start': '2/1/2013', + 'Inspection_End': '2/1/2015', + 'Zip_Code': '98101' + } + # html = get_inspection_page(**use_params) + html = load_inspection_page('inspection_page.html') + parsed = parse_source(html) + content_col = parsed.find("td", id="contentcol") + data_list = restaurant_data_generator(content_col) + for data_div in data_list: + metadata = extract_restaurant_metadata(data_div) + print(metadata) diff --git a/resources/session04/mashup_4.py b/resources/session04/mashup_4.py new file mode 100644 index 00000000..ab5ebdd5 --- /dev/null +++ b/resources/session04/mashup_4.py @@ -0,0 +1,133 @@ +from bs4 import BeautifulSoup +import pathlib +import re +import requests + + +INSPECTION_DOMAIN = 'http://info.kingcounty.gov' +INSPECTION_PATH = '/health/ehs/foodsafety/inspections/Results.aspx' +INSPECTION_PARAMS = { + 'Output': 'W', + 'Business_Name': '', + 'Business_Address': '', + 'Longitude': '', + 'Latitude': '', + 'City': '', + 'Zip_Code': '', + 'Inspection_Type': 'All', + 'Inspection_Start': '', + 'Inspection_End': '', + 'Inspection_Closed_Business': 'A', + 'Violation_Points': '', + 'Violation_Red_Points': '', + 'Violation_Descr': '', + 'Fuzzy_Search': 'N', + 'Sort': 'H' +} + + +def get_inspection_page(**kwargs): + url = INSPECTION_DOMAIN + INSPECTION_PATH + params = INSPECTION_PARAMS.copy() + for key, val in kwargs.items(): + if key in INSPECTION_PARAMS: + params[key] = val + resp = requests.get(url, params=params) + resp.raise_for_status() + return resp.text + + +def parse_source(html): + parsed = BeautifulSoup(html) + return parsed + + +def load_inspection_page(name): + file_path = pathlib.Path(name) + return file_path.read_text(encoding='utf8') + + +def restaurant_data_generator(html): + id_finder = re.compile(r'PR[\d]+~') + return html.find_all('div', id=id_finder) + + +def has_two_tds(elem): + is_tr = elem.name == 'tr' + td_children = elem.find_all('td', recursive=False) + has_two = len(td_children) == 2 + return is_tr and has_two + + +def clean_data(td): + return td.text.strip(" \n:-") + + +def extract_restaurant_metadata(elem): + restaurant_data_rows = elem.find('tbody').find_all( + has_two_tds, recursive=False + ) + rdata = {} + current_label = '' + for data_row in restaurant_data_rows: + key_cell, val_cell = data_row.find_all('td', recursive=False) + new_label = clean_data(key_cell) + current_label = new_label if new_label else current_label + rdata.setdefault(current_label, []).append(clean_data(val_cell)) + return rdata + + +def is_inspection_data_row(elem): + is_tr = elem.name == 'tr' + if not is_tr: + return False + td_children = elem.find_all('td', recursive=False) + has_four = len(td_children) == 4 + this_text = clean_data(td_children[0]).lower() + contains_word = 'inspection' in this_text + does_not_start = not this_text.startswith('inspection') + return is_tr and has_four and contains_word and does_not_start + + +def get_score_data(elem): + inspection_rows = elem.find_all(is_inspection_data_row) + samples = len(inspection_rows) + total = 0 + high_score = 0 + average = 0 + for row in inspection_rows: + strval = clean_data(row.find_all('td')[2]) + try: + intval = int(strval) + except (ValueError, TypeError): + samples -= 1 + else: + total += intval + high_score = intval if intval > high_score else high_score + + if samples: + average = total/float(samples) + data = { + 'Average Score': average, + 'High Score': high_score, + 'Total Inspections': samples + } + return data + + +if __name__ == '__main__': + use_params = { + 'Inspection_Start': '2/1/2013', + 'Inspection_End': '2/1/2015', + 'Zip_Code': '98101' + } + # html = get_inspection_page(**use_params) + html = load_inspection_page('inspection_page.html') + parsed = parse_source(html) + content_col = parsed.find("td", id="contentcol") + data_list = restaurant_data_generator(content_col) + for data_div in data_list: + metadata = extract_restaurant_metadata(data_div) + inspection_data = get_score_data(data_div) + metadata.update(inspection_data) + print(metadata) diff --git a/resources/session04/mashup_5.py b/resources/session04/mashup_5.py new file mode 100644 index 00000000..20f62b49 --- /dev/null +++ b/resources/session04/mashup_5.py @@ -0,0 +1,164 @@ +from bs4 import BeautifulSoup +import geocoder +import json +import pathlib +import re +import requests + + +INSPECTION_DOMAIN = 'http://info.kingcounty.gov' +INSPECTION_PATH = '/health/ehs/foodsafety/inspections/Results.aspx' +INSPECTION_PARAMS = { + 'Output': 'W', + 'Business_Name': '', + 'Business_Address': '', + 'Longitude': '', + 'Latitude': '', + 'City': '', + 'Zip_Code': '', + 'Inspection_Type': 'All', + 'Inspection_Start': '', + 'Inspection_End': '', + 'Inspection_Closed_Business': 'A', + 'Violation_Points': '', + 'Violation_Red_Points': '', + 'Violation_Descr': '', + 'Fuzzy_Search': 'N', + 'Sort': 'H' +} + + +def get_inspection_page(**kwargs): + url = INSPECTION_DOMAIN + INSPECTION_PATH + params = INSPECTION_PARAMS.copy() + for key, val in kwargs.items(): + if key in INSPECTION_PARAMS: + params[key] = val + resp = requests.get(url, params=params) + resp.raise_for_status() + return resp.text + + +def parse_source(html): + parsed = BeautifulSoup(html) + return parsed + + +def load_inspection_page(name): + file_path = pathlib.Path(name) + return file_path.read_text(encoding='utf8') + + +def restaurant_data_generator(html): + id_finder = re.compile(r'PR[\d]+~') + return html.find_all('div', id=id_finder) + + +def has_two_tds(elem): + is_tr = elem.name == 'tr' + td_children = elem.find_all('td', recursive=False) + has_two = len(td_children) == 2 + return is_tr and has_two + + +def clean_data(td): + return td.text.strip(" \n:-") + + +def extract_restaurant_metadata(elem): + restaurant_data_rows = elem.find('tbody').find_all( + has_two_tds, recursive=False + ) + rdata = {} + current_label = '' + for data_row in restaurant_data_rows: + key_cell, val_cell = data_row.find_all('td', recursive=False) + new_label = clean_data(key_cell) + current_label = new_label if new_label else current_label + rdata.setdefault(current_label, []).append(clean_data(val_cell)) + return rdata + + +def is_inspection_data_row(elem): + is_tr = elem.name == 'tr' + if not is_tr: + return False + td_children = elem.find_all('td', recursive=False) + has_four = len(td_children) == 4 + this_text = clean_data(td_children[0]).lower() + contains_word = 'inspection' in this_text + does_not_start = not this_text.startswith('inspection') + return is_tr and has_four and contains_word and does_not_start + + +def get_score_data(elem): + inspection_rows = elem.find_all(is_inspection_data_row) + samples = len(inspection_rows) + total = 0 + high_score = 0 + average = 0 + for row in inspection_rows: + strval = clean_data(row.find_all('td')[2]) + try: + intval = int(strval) + except (ValueError, TypeError): + samples -= 1 + else: + total += intval + high_score = intval if intval > high_score else high_score + + if samples: + average = total/float(samples) + data = { + u'Average Score': average, + u'High Score': high_score, + u'Total Inspections': samples + } + return data + + +def result_generator(count): + use_params = { + 'Inspection_Start': '2/1/2013', + 'Inspection_End': '2/1/2015', + 'Zip_Code': '98101' + } + # html = get_inspection_page(**use_params) + html = load_inspection_page('inspection_page.html') + parsed = parse_source(html) + content_col = parsed.find("td", id="contentcol") + data_list = restaurant_data_generator(content_col) + for data_div in data_list[:count]: + metadata = extract_restaurant_metadata(data_div) + inspection_data = get_score_data(data_div) + metadata.update(inspection_data) + yield metadata + + +def get_geojson(result): + address = " ".join(result.get('Address', '')) + if not address: + return None + geocoded = geocoder.google(address) + geojson = geocoded.geojson + inspection_data = {} + use_keys = ( + 'Business Name', 'Average Score', 'Total Inspections', 'High Score' + ) + for key, val in result.items(): + if key not in use_keys: + continue + if isinstance(val, list): + val = " ".join(val) + inspection_data[key] = val + geojson['properties'] = inspection_data + return geojson + + +if __name__ == '__main__': + total_result = {'type': 'FeatureCollection', 'features': []} + for result in result_generator(10): + geojson = get_geojson(result) + total_result['features'].append(geojson) + with open('my_map.json', 'w') as fh: + json.dump(total_result, fh) diff --git a/resources/session06/__init__.py b/resources/session06/__init__.py new file mode 100644 index 00000000..32e9cc81 --- /dev/null +++ b/resources/session06/__init__.py @@ -0,0 +1,30 @@ +from pyramid.config import Configurator +from sqlalchemy import engine_from_config + +from .models import ( + DBSession, + Base, + ) + + +def create_session(settings): + from sqlalchemy.orm import sessionmaker + engine = engine_from_config(settings, 'sqlalchemy.') + Session = sessionmaker(bind=engine) + return Session() + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.bind = engine + config = Configurator(settings=settings) + config.include('pyramid_jinja2') + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') + config.add_route('detail', '/journal/{id:\d+}') + config.add_route('action', '/journal/{action}') + config.scan() + return config.make_wsgi_app() diff --git a/resources/session06/development.ini b/resources/session06/development.ini new file mode 100644 index 00000000..1139ff82 --- /dev/null +++ b/resources/session06/development.ini @@ -0,0 +1,76 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html +### + +[app:main] +use = egg:learning_journal + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/learning_journal.sqlite + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + + +[pshell] +create_session = learning_journal.create_session +Entry = learning_journal.models.Entry + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html +### + +[loggers] +keys = root, learning_journal, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_learning_journal] +level = DEBUG +handlers = +qualname = learning_journal + +[logger_sqlalchemy] +level = INFO +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/resources/session06/forms.py b/resources/session06/forms.py new file mode 100644 index 00000000..88d9d348 --- /dev/null +++ b/resources/session06/forms.py @@ -0,0 +1,21 @@ +from wtforms import ( + Form, + TextField, + TextAreaField, + validators, +) + +strip_filter = lambda x: x.strip() if x else None + + +class EntryCreateForm(Form): + title = TextField( + 'Entry title', + [validators.Length(min=1, max=255)], + filters=[strip_filter] + ) + body = TextAreaField( + 'Entry body', + [validators.Length(min=1)], + filters=[strip_filter] + ) diff --git a/resources/session06/layout.jinja2 b/resources/session06/layout.jinja2 new file mode 100644 index 00000000..8dbff846 --- /dev/null +++ b/resources/session06/layout.jinja2 @@ -0,0 +1,29 @@ + + + + + Python Learning Journal + + + + +
+ +
+
+

My Python Journal

+
+ {% block body %}{% endblock %} +
+
+
+

Created in the UW PCE Python Certificate Program

+
+ + diff --git a/resources/session06/learning_journal/.gitignore b/resources/session06/learning_journal/.gitignore new file mode 100644 index 00000000..c7332211 --- /dev/null +++ b/resources/session06/learning_journal/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.DS_Store +*.egg-info diff --git a/resources/session06/learning_journal/CHANGES.txt b/resources/session06/learning_journal/CHANGES.txt new file mode 100644 index 00000000..35a34f33 --- /dev/null +++ b/resources/session06/learning_journal/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version diff --git a/resources/session06/learning_journal/MANIFEST.in b/resources/session06/learning_journal/MANIFEST.in new file mode 100644 index 00000000..3a0de395 --- /dev/null +++ b/resources/session06/learning_journal/MANIFEST.in @@ -0,0 +1,2 @@ +include *.txt *.ini *.cfg *.rst +recursive-include learning_journal *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/resources/session06/learning_journal/README.txt b/resources/session06/learning_journal/README.txt new file mode 100644 index 00000000..f49a002c --- /dev/null +++ b/resources/session06/learning_journal/README.txt @@ -0,0 +1,14 @@ +learning_journal README +================== + +Getting Started +--------------- + +- cd + +- $VENV/bin/python setup.py develop + +- $VENV/bin/initialize_learning_journal_db development.ini + +- $VENV/bin/pserve development.ini + diff --git a/resources/session06/learning_journal/development.ini b/resources/session06/learning_journal/development.ini new file mode 100644 index 00000000..1139ff82 --- /dev/null +++ b/resources/session06/learning_journal/development.ini @@ -0,0 +1,76 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html +### + +[app:main] +use = egg:learning_journal + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/learning_journal.sqlite + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + + +[pshell] +create_session = learning_journal.create_session +Entry = learning_journal.models.Entry + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html +### + +[loggers] +keys = root, learning_journal, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_learning_journal] +level = DEBUG +handlers = +qualname = learning_journal + +[logger_sqlalchemy] +level = INFO +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/resources/session06/learning_journal/learning_journal/__init__.py b/resources/session06/learning_journal/learning_journal/__init__.py new file mode 100644 index 00000000..32e9cc81 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/__init__.py @@ -0,0 +1,30 @@ +from pyramid.config import Configurator +from sqlalchemy import engine_from_config + +from .models import ( + DBSession, + Base, + ) + + +def create_session(settings): + from sqlalchemy.orm import sessionmaker + engine = engine_from_config(settings, 'sqlalchemy.') + Session = sessionmaker(bind=engine) + return Session() + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.bind = engine + config = Configurator(settings=settings) + config.include('pyramid_jinja2') + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') + config.add_route('detail', '/journal/{id:\d+}') + config.add_route('action', '/journal/{action}') + config.scan() + return config.make_wsgi_app() diff --git a/resources/session06/learning_journal/learning_journal/forms.py b/resources/session06/learning_journal/learning_journal/forms.py new file mode 100644 index 00000000..88d9d348 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/forms.py @@ -0,0 +1,21 @@ +from wtforms import ( + Form, + TextField, + TextAreaField, + validators, +) + +strip_filter = lambda x: x.strip() if x else None + + +class EntryCreateForm(Form): + title = TextField( + 'Entry title', + [validators.Length(min=1, max=255)], + filters=[strip_filter] + ) + body = TextAreaField( + 'Entry body', + [validators.Length(min=1)], + filters=[strip_filter] + ) diff --git a/resources/session06/learning_journal/learning_journal/models.py b/resources/session06/learning_journal/learning_journal/models.py new file mode 100644 index 00000000..7afb0ddb --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/models.py @@ -0,0 +1,64 @@ +import datetime +from sqlalchemy import ( + Column, + DateTime, + Index, + Integer, + Text, + Unicode, + UnicodeText, + ) + +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +from sqlalchemy.orm import ( + scoped_session, + sessionmaker, + ) + +from zope.sqlalchemy import ZopeTransactionExtension + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) +Base = declarative_base() + + +class MyModel(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(Text) + value = Column(Integer) + +Index('my_index', MyModel.name, unique=True, mysql_length=255) + + +class Entry(Base): + __tablename__ = 'entries' + id = Column(Integer, primary_key=True) + title = Column(Unicode(255), unique=True, nullable=False) + body = Column(UnicodeText, default=u'') + created = Column(DateTime, default=datetime.datetime.utcnow) + edited = Column(DateTime, default=datetime.datetime.utcnow) + + @classmethod + def all(cls, session=None): + """return a query with all entries, ordered by creation date reversed + """ + if session is None: + session = DBSession + return session.query(cls).order_by(sa.desc(cls.created)).all() + + @classmethod + def by_id(cls, id, session=None): + """return a single entry identified by id + + If no entry exists with the provided id, return None + """ + if session is None: + session = DBSession + return session.query(cls).get(id) + + + + + diff --git a/resources/session06/learning_journal/learning_journal/scripts/__init__.py b/resources/session06/learning_journal/learning_journal/scripts/__init__.py new file mode 100644 index 00000000..5bb534f7 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/scripts/__init__.py @@ -0,0 +1 @@ +# package diff --git a/resources/session06/learning_journal/learning_journal/scripts/initializedb.py b/resources/session06/learning_journal/learning_journal/scripts/initializedb.py new file mode 100644 index 00000000..7dfdece1 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/scripts/initializedb.py @@ -0,0 +1,40 @@ +import os +import sys +import transaction + +from sqlalchemy import engine_from_config + +from pyramid.paster import ( + get_appsettings, + setup_logging, + ) + +from pyramid.scripts.common import parse_vars + +from ..models import ( + DBSession, + MyModel, + Base, + ) + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.create_all(engine) + with transaction.manager: + model = MyModel(name='one', value=1) + DBSession.add(model) diff --git a/resources/session06/learning_journal/learning_journal/static/pyramid-16x16.png b/resources/session06/learning_journal/learning_journal/static/pyramid-16x16.png new file mode 100644 index 00000000..97920311 Binary files /dev/null and b/resources/session06/learning_journal/learning_journal/static/pyramid-16x16.png differ diff --git a/resources/session06/learning_journal/learning_journal/static/pyramid.png b/resources/session06/learning_journal/learning_journal/static/pyramid.png new file mode 100644 index 00000000..4ab837be Binary files /dev/null and b/resources/session06/learning_journal/learning_journal/static/pyramid.png differ diff --git a/resources/session06/learning_journal/learning_journal/static/styles.css b/resources/session06/learning_journal/learning_journal/static/styles.css new file mode 100644 index 00000000..951ac84f --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/static/styles.css @@ -0,0 +1,73 @@ +body{ + color:#111; + padding:0; + margin:0; + background-color: #eee;} +header{ + margin:0; + padding:0 0.75em; + width:100%; + background: #222; + color: #ccc; + border-bottom: 3px solid #fff;} +header:after{ + content:""; + display:table; + clear:both;} +header a, +footer a{ + text-decoration:none} +header a:hover, +footer a:hover { + color:#fff; +} +header a:visited, +footer a:visited { + color:#eee; +} +header aside{ + float:right; + text-align:right; + padding-right:0.75em} +header ul{ + list-style:none; + list-style-type:none; + display:inline-block} +header ul li{ + margin:0 0.25em 0 0} +header ul li a{ + padding:0; + display:inline-block} +main{padding:0 0.75em 1em} +main:after{ + content:""; + display:table; + clear:both} +main article{ + margin-bottom:1em; + padding-left:0.5em} +main article h3{margin-top:0} +main article .entry_body{ + margin:0.5em} +main aside{float:right} +main aside .field{ + margin-bottom:1em} +main aside .field input, +main aside .field label, +main aside .field textarea{ + vertical-align:top} +main aside .field label{ + display:inline-block; + width:15%; + padding-top:2px} +main aside .field input, +main aside .field textarea{ + width:83%} +main aside .control_row input{ + margin-left:16%} +footer{ + padding: 1em 0.75em; + background: #222; + color: #ccc; + border-top: 3px solid #fff; + border-bottom: 3px solid #fff;} diff --git a/resources/session06/learning_journal/learning_journal/static/theme.css b/resources/session06/learning_journal/learning_journal/static/theme.css new file mode 100644 index 00000000..228768e2 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/static/theme.css @@ -0,0 +1,152 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700); +body { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; + color: #ffffff; + background: #bc2131; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; +} +p { + font-weight: 300; +} +.font-normal { + font-weight: 400; +} +.font-semi-bold { + font-weight: 600; +} +.font-bold { + font-weight: 700; +} +.starter-template { + margin-top: 25px; +} +.starter-template .content { + margin-left: 10px; +} +.starter-template .content h1 { + margin-top: 10px; + font-size: 60px; +} +.starter-template .content h1 .smaller { + font-size: 40px; + color: #f2b7bd; +} +.starter-template .content .lead { + font-size: 25px; + color: #f2b7bd; +} +.starter-template .content .lead .font-normal { + color: #ffffff; +} +.starter-template .links { + float: right; + right: 0; + margin-top: 125px; +} +.starter-template .links ul { + display: block; + padding: 0; + margin: 0; +} +.starter-template .links ul li { + list-style: none; + display: inline; + margin: 0 10px; +} +.starter-template .links ul li:first-child { + margin-left: 0; +} +.starter-template .links ul li:last-child { + margin-right: 0; +} +.starter-template .links ul li.current-version { + color: #f2b7bd; + font-weight: 400; +} +.starter-template .links ul li a { + color: #ffffff; +} +.starter-template .links ul li a:hover { + text-decoration: underline; +} +.starter-template .links ul li .icon-muted { + color: #eb8b95; + margin-right: 5px; +} +.starter-template .links ul li:hover .icon-muted { + color: #ffffff; +} +.starter-template .copyright { + margin-top: 10px; + font-size: 0.9em; + color: #f2b7bd; + text-transform: lowercase; + float: right; + right: 0; +} +@media (max-width: 1199px) { + .starter-template .content h1 { + font-size: 45px; + } + .starter-template .content h1 .smaller { + font-size: 30px; + } + .starter-template .content .lead { + font-size: 20px; + } +} +@media (max-width: 991px) { + .starter-template { + margin-top: 0; + } + .starter-template .logo { + margin: 40px auto; + } + .starter-template .content { + margin-left: 0; + text-align: center; + } + .starter-template .content h1 { + margin-bottom: 20px; + } + .starter-template .links { + float: none; + text-align: center; + margin-top: 60px; + } + .starter-template .copyright { + float: none; + text-align: center; + } +} +@media (max-width: 767px) { + .starter-template .content h1 .smaller { + font-size: 25px; + display: block; + } + .starter-template .content .lead { + font-size: 16px; + } + .starter-template .links { + margin-top: 40px; + } + .starter-template .links ul li { + display: block; + margin: 0; + } + .starter-template .links ul li .icon-muted { + display: none; + } + .starter-template .copyright { + margin-top: 20px; + } +} diff --git a/resources/session06/learning_journal/learning_journal/static/theme.min.css b/resources/session06/learning_journal/learning_journal/static/theme.min.css new file mode 100644 index 00000000..2f924bcc --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/static/theme.min.css @@ -0,0 +1 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);body{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;color:#fff;background:#bc2131}h1,h2,h3,h4,h5,h6{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300}p{font-weight:300}.font-normal{font-weight:400}.font-semi-bold{font-weight:600}.font-bold{font-weight:700}.starter-template{margin-top:250px}.starter-template .content{margin-left:10px}.starter-template .content h1{margin-top:10px;font-size:60px}.starter-template .content h1 .smaller{font-size:40px;color:#f2b7bd}.starter-template .content .lead{font-size:25px;color:#f2b7bd}.starter-template .content .lead .font-normal{color:#fff}.starter-template .links{float:right;right:0;margin-top:125px}.starter-template .links ul{display:block;padding:0;margin:0}.starter-template .links ul li{list-style:none;display:inline;margin:0 10px}.starter-template .links ul li:first-child{margin-left:0}.starter-template .links ul li:last-child{margin-right:0}.starter-template .links ul li.current-version{color:#f2b7bd;font-weight:400}.starter-template .links ul li a{color:#fff}.starter-template .links ul li a:hover{text-decoration:underline}.starter-template .links ul li .icon-muted{color:#eb8b95;margin-right:5px}.starter-template .links ul li:hover .icon-muted{color:#fff}.starter-template .copyright{margin-top:10px;font-size:.9em;color:#f2b7bd;text-transform:lowercase;float:right;right:0}@media (max-width:1199px){.starter-template .content h1{font-size:45px}.starter-template .content h1 .smaller{font-size:30px}.starter-template .content .lead{font-size:20px}}@media (max-width:991px){.starter-template{margin-top:0}.starter-template .logo{margin:40px auto}.starter-template .content{margin-left:0;text-align:center}.starter-template .content h1{margin-bottom:20px}.starter-template .links{float:none;text-align:center;margin-top:60px}.starter-template .copyright{float:none;text-align:center}}@media (max-width:767px){.starter-template .content h1 .smaller{font-size:25px;display:block}.starter-template .content .lead{font-size:16px}.starter-template .links{margin-top:40px}.starter-template .links ul li{display:block;margin:0}.starter-template .links ul li .icon-muted{display:none}.starter-template .copyright{margin-top:20px}} \ No newline at end of file diff --git a/resources/session06/learning_journal/learning_journal/templates/detail.jinja2 b/resources/session06/learning_journal/learning_journal/templates/detail.jinja2 new file mode 100644 index 00000000..29d736f5 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/templates/detail.jinja2 @@ -0,0 +1,11 @@ +{% extends "layout.jinja2" %} +{% block body %} +
+

{{ entry.title }}

+
+

{{ entry.body }}

+
+

Created {{entry.created}}

+
+

Go Back

+{% endblock %} diff --git a/resources/session06/learning_journal/learning_journal/templates/edit.jinja2 b/resources/session06/learning_journal/learning_journal/templates/edit.jinja2 new file mode 100644 index 00000000..ebe0f6b9 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/templates/edit.jinja2 @@ -0,0 +1,17 @@ +{% extends "templates/layout.jinja2" %} +{% block body %} +

Create a Journal Entry

+
+{% for field in form %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +

{{ field.label }}: {{ field }}

+{% endfor %} +

+
+{% endblock %} diff --git a/resources/session06/learning_journal/learning_journal/templates/layout.jinja2 b/resources/session06/learning_journal/learning_journal/templates/layout.jinja2 new file mode 100644 index 00000000..a8b27afd --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/templates/layout.jinja2 @@ -0,0 +1,29 @@ + + + + + Python Learning Journal + + + + +
+ +
+
+

My Python Journal

+
+ {% block body %}{% endblock %} +
+
+
+

Created in the UW PCE Python Certificate Program

+
+ + diff --git a/resources/session06/learning_journal/learning_journal/templates/list.jinja2 b/resources/session06/learning_journal/learning_journal/templates/list.jinja2 new file mode 100644 index 00000000..09c835a8 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/templates/list.jinja2 @@ -0,0 +1,16 @@ +{% extends "layout.jinja2" %} +{% block body %} +{% if entries %} +

Journal Entries

+ +{% else %} +

This journal is empty

+{% endif %} +

New Entry

+{% endblock %} diff --git a/resources/session06/learning_journal/learning_journal/templates/mytemplate.pt b/resources/session06/learning_journal/learning_journal/templates/mytemplate.pt new file mode 100644 index 00000000..9e88dc4b --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/templates/mytemplate.pt @@ -0,0 +1,66 @@ + + + + + + + + + + + Alchemy Scaffold for The Pyramid Web Framework + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

Pyramid Alchemy scaffold

+

Welcome to ${project}, an application generated by
the Pyramid Web Framework 1.5.2.

+
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + diff --git a/resources/session06/learning_journal/learning_journal/tests.py b/resources/session06/learning_journal/learning_journal/tests.py new file mode 100644 index 00000000..4fc444a6 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/tests.py @@ -0,0 +1,55 @@ +import unittest +import transaction + +from pyramid import testing + +from .models import DBSession + + +class TestMyViewSuccessCondition(unittest.TestCase): + def setUp(self): + self.config = testing.setUp() + from sqlalchemy import create_engine + engine = create_engine('sqlite://') + from .models import ( + Base, + MyModel, + ) + DBSession.configure(bind=engine) + Base.metadata.create_all(engine) + with transaction.manager: + model = MyModel(name='one', value=55) + DBSession.add(model) + + def tearDown(self): + DBSession.remove() + testing.tearDown() + + def test_passing_view(self): + from .views import my_view + request = testing.DummyRequest() + info = my_view(request) + self.assertEqual(info['one'].name, 'one') + self.assertEqual(info['project'], 'learning_journal') + + +class TestMyViewFailureCondition(unittest.TestCase): + def setUp(self): + self.config = testing.setUp() + from sqlalchemy import create_engine + engine = create_engine('sqlite://') + from .models import ( + Base, + MyModel, + ) + DBSession.configure(bind=engine) + + def tearDown(self): + DBSession.remove() + testing.tearDown() + + def test_failing_view(self): + from .views import my_view + request = testing.DummyRequest() + info = my_view(request) + self.assertEqual(info.status_int, 500) diff --git a/resources/session06/learning_journal/learning_journal/views.py b/resources/session06/learning_journal/learning_journal/views.py new file mode 100644 index 00000000..ad76afb5 --- /dev/null +++ b/resources/session06/learning_journal/learning_journal/views.py @@ -0,0 +1,43 @@ +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from pyramid.view import view_config + +from .models import ( + DBSession, + MyModel, + Entry, + ) + +from .forms import EntryCreateForm + + +@view_config(route_name='home', renderer='templates/list.jinja2') +def index_page(request): + entries = Entry.all() + return {'entries': entries} + + +@view_config(route_name='detail', renderer='templates/detail.jinja2') +def view(request): + this_id = request.matchdict.get('id', -1) + entry = Entry.by_id(this_id) + if not entry: + return HTTPNotFound() + return {'entry': entry} + + +@view_config(route_name='action', match_param='action=create', + renderer='templates/edit.jinja2') +def create(request): + entry = Entry() + form = EntryCreateForm(request.POST) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + DBSession.add(entry) + return HTTPFound(location=request.route_url('home')) + return {'form': form, 'action': request.matchdict.get('action')} + + +@view_config(route_name='action', match_param='action=edit', + renderer='string') +def update(request): + return 'edit page' diff --git a/resources/session06/learning_journal/production.ini b/resources/session06/learning_journal/production.ini new file mode 100644 index 00000000..1db7a630 --- /dev/null +++ b/resources/session06/learning_journal/production.ini @@ -0,0 +1,62 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html +### + +[app:main] +use = egg:learning_journal + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/learning_journal.sqlite + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html +### + +[loggers] +keys = root, learning_journal, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_learning_journal] +level = WARN +handlers = +qualname = learning_journal + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/resources/session06/learning_journal/setup.py b/resources/session06/learning_journal/setup.py new file mode 100644 index 00000000..e4bb0bcd --- /dev/null +++ b/resources/session06/learning_journal/setup.py @@ -0,0 +1,48 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, 'README.txt')) as f: + README = f.read() +with open(os.path.join(here, 'CHANGES.txt')) as f: + CHANGES = f.read() + +requires = [ + 'pyramid', + 'pyramid_jinja2', + 'pyramid_debugtoolbar', + 'pyramid_tm', + 'SQLAlchemy', + 'transaction', + 'zope.sqlalchemy', + 'waitress', + 'wtforms', + ] + +setup(name='learning_journal', + version='0.0', + description='learning_journal', + long_description=README + '\n\n' + CHANGES, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author='', + author_email='', + url='', + keywords='web wsgi bfg pylons pyramid', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + test_suite='learning_journal', + install_requires=requires, + entry_points="""\ + [paste.app_factory] + main = learning_journal:main + [console_scripts] + initialize_learning_journal_db = learning_journal.scripts.initializedb:main + """, + ) diff --git a/resources/session06/models.py b/resources/session06/models.py new file mode 100644 index 00000000..e87ac2c8 --- /dev/null +++ b/resources/session06/models.py @@ -0,0 +1,59 @@ +import datetime +from sqlalchemy import ( + Column, + DateTime, + Index, + Integer, + Text, + Unicode, + UnicodeText, + ) + +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +from sqlalchemy.orm import ( + scoped_session, + sessionmaker, + ) + +from zope.sqlalchemy import ZopeTransactionExtension + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) +Base = declarative_base() + + +class MyModel(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(Text) + value = Column(Integer) + +Index('my_index', MyModel.name, unique=True, mysql_length=255) + + +class Entry(Base): + __tablename__ = 'entries' + id = Column(Integer, primary_key=True) + title = Column(Unicode(255), unique=True, nullable=False) + body = Column(UnicodeText, default=u'') + created = Column(DateTime, default=datetime.datetime.utcnow) + edited = Column(DateTime, default=datetime.datetime.utcnow) + + @classmethod + def all(cls, session=None): + """return a query with all entries, ordered by creation date reversed + """ + if session is None: + session = DBSession + return session.query(cls).order_by(sa.desc(cls.created)).all() + + @classmethod + def by_id(cls, id, session=None): + """return a single entry identified by id + + If no entry exists with the provided id, return None + """ + if session is None: + session = DBSession + return session.query(cls).get(id) diff --git a/resources/session06/styles.css b/resources/session06/styles.css new file mode 100644 index 00000000..951ac84f --- /dev/null +++ b/resources/session06/styles.css @@ -0,0 +1,73 @@ +body{ + color:#111; + padding:0; + margin:0; + background-color: #eee;} +header{ + margin:0; + padding:0 0.75em; + width:100%; + background: #222; + color: #ccc; + border-bottom: 3px solid #fff;} +header:after{ + content:""; + display:table; + clear:both;} +header a, +footer a{ + text-decoration:none} +header a:hover, +footer a:hover { + color:#fff; +} +header a:visited, +footer a:visited { + color:#eee; +} +header aside{ + float:right; + text-align:right; + padding-right:0.75em} +header ul{ + list-style:none; + list-style-type:none; + display:inline-block} +header ul li{ + margin:0 0.25em 0 0} +header ul li a{ + padding:0; + display:inline-block} +main{padding:0 0.75em 1em} +main:after{ + content:""; + display:table; + clear:both} +main article{ + margin-bottom:1em; + padding-left:0.5em} +main article h3{margin-top:0} +main article .entry_body{ + margin:0.5em} +main aside{float:right} +main aside .field{ + margin-bottom:1em} +main aside .field input, +main aside .field label, +main aside .field textarea{ + vertical-align:top} +main aside .field label{ + display:inline-block; + width:15%; + padding-top:2px} +main aside .field input, +main aside .field textarea{ + width:83%} +main aside .control_row input{ + margin-left:16%} +footer{ + padding: 1em 0.75em; + background: #222; + color: #ccc; + border-top: 3px solid #fff; + border-bottom: 3px solid #fff;} diff --git a/resources/session07/detail.jinja2 b/resources/session07/detail.jinja2 new file mode 100644 index 00000000..f80810d3 --- /dev/null +++ b/resources/session07/detail.jinja2 @@ -0,0 +1,15 @@ +{% extends "layout.jinja2" %} +{% block body %} +
+

{{ entry.title }}

+
+

{{ entry.body }}

+
+

Created {{entry.created}}

+
+

+ Go Back :: + + Edit Entry +

+{% endblock %} diff --git a/resources/session07/forms.py b/resources/session07/forms.py new file mode 100644 index 00000000..fad71bd1 --- /dev/null +++ b/resources/session07/forms.py @@ -0,0 +1,26 @@ +from wtforms import ( + Form, + HiddenField, + TextField, + TextAreaField, + validators, +) + +strip_filter = lambda x: x.strip() if x else None + + +class EntryCreateForm(Form): + title = TextField( + 'Entry title', + [validators.Length(min=1, max=255)], + filters=[strip_filter] + ) + body = TextAreaField( + 'Entry body', + [validators.Length(min=1)], + filters=[strip_filter] + ) + + +class EntryEditForm(EntryCreateForm): + id = HiddenField() diff --git a/resources/session07/learning_journal/.gitignore b/resources/session07/learning_journal/.gitignore new file mode 100644 index 00000000..2ffa3242 --- /dev/null +++ b/resources/session07/learning_journal/.gitignore @@ -0,0 +1,4 @@ +*.pyc +.DS_Store +*.egg-info +*.sqlite diff --git a/resources/session07/learning_journal/CHANGES.txt b/resources/session07/learning_journal/CHANGES.txt new file mode 100644 index 00000000..35a34f33 --- /dev/null +++ b/resources/session07/learning_journal/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version diff --git a/resources/session07/learning_journal/MANIFEST.in b/resources/session07/learning_journal/MANIFEST.in new file mode 100644 index 00000000..3a0de395 --- /dev/null +++ b/resources/session07/learning_journal/MANIFEST.in @@ -0,0 +1,2 @@ +include *.txt *.ini *.cfg *.rst +recursive-include learning_journal *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/resources/session07/learning_journal/Procfile b/resources/session07/learning_journal/Procfile new file mode 100644 index 00000000..e6450506 --- /dev/null +++ b/resources/session07/learning_journal/Procfile @@ -0,0 +1 @@ +web: ./run diff --git a/resources/session07/learning_journal/README.txt b/resources/session07/learning_journal/README.txt new file mode 100644 index 00000000..f49a002c --- /dev/null +++ b/resources/session07/learning_journal/README.txt @@ -0,0 +1,14 @@ +learning_journal README +================== + +Getting Started +--------------- + +- cd + +- $VENV/bin/python setup.py develop + +- $VENV/bin/initialize_learning_journal_db development.ini + +- $VENV/bin/pserve development.ini + diff --git a/resources/session07/learning_journal/build_db b/resources/session07/learning_journal/build_db new file mode 100755 index 00000000..a912dc68 --- /dev/null +++ b/resources/session07/learning_journal/build_db @@ -0,0 +1,3 @@ +#!/bin/bash +python setup.py develop +initialize_learning_journal_db production.ini diff --git a/resources/session07/learning_journal/development.ini b/resources/session07/learning_journal/development.ini new file mode 100644 index 00000000..f52e811f --- /dev/null +++ b/resources/session07/learning_journal/development.ini @@ -0,0 +1,78 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html +### + +[app:main] +use = egg:learning_journal + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/learning_journal.sqlite + +jinja2.filters = + markdown = learning_journal.views.render_markdown + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +[pshell] +create_session = learning_journal.create_session +Entry = learning_journal.models.Entry + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html +### + +[loggers] +keys = root, learning_journal, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_learning_journal] +level = DEBUG +handlers = +qualname = learning_journal + +[logger_sqlalchemy] +level = INFO +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/resources/session07/learning_journal/learning_journal/__init__.py b/resources/session07/learning_journal/learning_journal/__init__.py new file mode 100644 index 00000000..6f51ba85 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/__init__.py @@ -0,0 +1,44 @@ +import os +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.config import Configurator +from sqlalchemy import engine_from_config + +from .models import ( + DBSession, + Base, + ) +from .security import EntryFactory + + +def create_session(settings): + from sqlalchemy.orm import sessionmaker + engine = engine_from_config(settings, 'sqlalchemy.') + Session = sessionmaker(bind=engine) + return Session() + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + if 'DATABASE_URL' in os.environ: + settings['sqlalchemy.url'] = os.environ['DATABASE_URL'] + engine = engine_from_config(settings, 'sqlalchemy.') + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.bind = engine + secret = os.environ.get('AUTH_SECRET', 'somesecret') + config = Configurator( + settings=settings, + authentication_policy=AuthTktAuthenticationPolicy(secret), + authorization_policy=ACLAuthorizationPolicy(), + default_permission='view' + ) + config.include('pyramid_jinja2') + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/', factory=EntryFactory) + config.add_route('detail', '/journal/{id:\d+}', factory=EntryFactory) + config.add_route('action', '/journal/{action}', factory=EntryFactory) + config.add_route('auth', '/sign/{action}', factory=EntryFactory) + config.scan() + return config.make_wsgi_app() diff --git a/resources/session07/learning_journal/learning_journal/forms.py b/resources/session07/learning_journal/learning_journal/forms.py new file mode 100644 index 00000000..652c286b --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/forms.py @@ -0,0 +1,32 @@ +from wtforms import ( + Form, + HiddenField, + PasswordField, + TextField, + TextAreaField, + validators, +) + +strip_filter = lambda x: x.strip() if x else None + + +class EntryCreateForm(Form): + title = TextField( + 'Entry title', + [validators.Length(min=1, max=255)], + filters=[strip_filter] + ) + body = TextAreaField( + 'Entry body', + [validators.Length(min=1)], + filters=[strip_filter] + ) + + +class EntryEditForm(EntryCreateForm): + id = HiddenField() + + +class LoginForm(Form): + username = TextField('Username', [validators.Length(min=1, max=255)]) + password = PasswordField('Password', [validators.Length(min=1, max=255)]) diff --git a/resources/session07/learning_journal/learning_journal/models.py b/resources/session07/learning_journal/learning_journal/models.py new file mode 100644 index 00000000..6f2290c2 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/models.py @@ -0,0 +1,79 @@ +import datetime +from passlib.context import CryptContext +from sqlalchemy import ( + Column, + DateTime, + Index, + Integer, + Text, + Unicode, + UnicodeText, + ) + +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +from sqlalchemy.orm import ( + scoped_session, + sessionmaker, + ) + +from zope.sqlalchemy import ZopeTransactionExtension + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) +Base = declarative_base() + + +password_context = CryptContext(schemes=['pbkdf2_sha512']) + + +class MyModel(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(Text) + value = Column(Integer) + +Index('my_index', MyModel.name, unique=True, mysql_length=255) + + +class Entry(Base): + __tablename__ = 'entries' + id = Column(Integer, primary_key=True) + title = Column(Unicode(255), unique=True, nullable=False) + body = Column(UnicodeText, default=u'') + created = Column(DateTime, default=datetime.datetime.utcnow) + edited = Column(DateTime, default=datetime.datetime.utcnow) + + @classmethod + def all(cls, session=None): + """return a query with all entries, ordered by creation date reversed + """ + if session is None: + session = DBSession + return session.query(cls).order_by(sa.desc(cls.created)).all() + + @classmethod + def by_id(cls, id, session=None): + """return a single entry identified by id + + If no entry exists with the provided id, return None + """ + if session is None: + session = DBSession + return session.query(cls).get(id) + + +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Unicode(255), unique=True, nullable=False) + password = Column(Unicode(255), nullable=False) + + @classmethod + def by_name(cls, name, session=None): + if session is None: + session = DBSession + return DBSession.query(cls).filter(cls.name == name).first() + + def verify_password(self, password): + return password_context.verify(password, self.password) diff --git a/resources/session07/learning_journal/learning_journal/scripts/__init__.py b/resources/session07/learning_journal/learning_journal/scripts/__init__.py new file mode 100644 index 00000000..5bb534f7 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/scripts/__init__.py @@ -0,0 +1 @@ +# package diff --git a/resources/session07/learning_journal/learning_journal/scripts/initializedb.py b/resources/session07/learning_journal/learning_journal/scripts/initializedb.py new file mode 100644 index 00000000..a5dc7b20 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/scripts/initializedb.py @@ -0,0 +1,46 @@ +import os +import sys +import transaction + +from sqlalchemy import engine_from_config + +from pyramid.paster import ( + get_appsettings, + setup_logging, + ) + +from pyramid.scripts.common import parse_vars + +from ..models import ( + DBSession, + MyModel, + Base, + User, + password_context + ) + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + if 'DATABASE_URL' in os.environ: + settings['sqlalchemy.url'] = os.environ['DATABASE_URL'] + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.create_all(engine) + with transaction.manager: + password = os.environ.get('ADMIN_PASSWORD', 'admin') + encrypted = password_context.encrypt(password) + admin = User(name=u'admin', password=encrypted) + DBSession.add(admin) diff --git a/resources/session07/learning_journal/learning_journal/security.py b/resources/session07/learning_journal/learning_journal/security.py new file mode 100644 index 00000000..2ee4d4eb --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/security.py @@ -0,0 +1,12 @@ +from pyramid.security import Allow, Everyone, Authenticated + + +class EntryFactory(object): + __acl__ = [ + (Allow, Everyone, 'view'), + (Allow, Authenticated, 'create'), + (Allow, Authenticated, 'edit'), + ] + + def __init__(self, request): + pass diff --git a/resources/session07/learning_journal/learning_journal/static/pyramid-16x16.png b/resources/session07/learning_journal/learning_journal/static/pyramid-16x16.png new file mode 100644 index 00000000..97920311 Binary files /dev/null and b/resources/session07/learning_journal/learning_journal/static/pyramid-16x16.png differ diff --git a/resources/session07/learning_journal/learning_journal/static/pyramid.png b/resources/session07/learning_journal/learning_journal/static/pyramid.png new file mode 100644 index 00000000..4ab837be Binary files /dev/null and b/resources/session07/learning_journal/learning_journal/static/pyramid.png differ diff --git a/resources/session07/learning_journal/learning_journal/static/styles.css b/resources/session07/learning_journal/learning_journal/static/styles.css new file mode 100644 index 00000000..e9299ebd --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/static/styles.css @@ -0,0 +1,138 @@ +body{ + color:#111; + padding:0; + margin:0; + background-color: #eee;} +header{ + margin:0; + padding:0 0.75em; + width:100%; + background: #222; + color: #ccc; + border-bottom: 3px solid #fff;} +header:after{ + content:""; + display:table; + clear:both;} +header a, +footer a{ + text-decoration:none} +header a:hover, +footer a:hover { + color:#fff; +} +header a:visited, +footer a:visited { + color:#eee; +} +header aside{ + float:right; + text-align:right; + padding-right:0.75em} +header ul{ + list-style:none; + list-style-type:none; + display:inline-block} +header ul li{ + margin:0 0.25em 0 0} +header ul li a{ + padding:0; + display:inline-block} +main{padding:0 0.75em 1em} +main:after{ + content:""; + display:table; + clear:both} +main article{ + margin-bottom:1em; + padding-left:0.5em} +main article h3{margin-top:0} +main article .entry_body{ + margin:0.5em} +main aside{float:right} +main aside .field{ + margin-bottom:1em} +main aside .field input, +main aside .field label, +main aside .field textarea{ + vertical-align:top} +main aside .field label{ + display:inline-block; + width:15%; + padding-top:2px} +main aside .field input, +main aside .field textarea{ + width:83%} +main aside .control_row input{ + margin-left:16%} +footer{ + padding: 1em 0.75em; + background: #222; + color: #ccc; + border-top: 3px solid #fff; + border-bottom: 3px solid #fff;} +.codehilite {padding: 0.25em 1em;} +/* Pygments code hilight styles */ +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #ffffff; } +.codehilite .c { color: #888888 } /* Comment */ +.codehilite .err { color: #FF0000; background-color: #FFAAAA } /* Error */ +.codehilite .k { color: #008800; font-weight: bold } /* Keyword */ +.codehilite .o { color: #333333 } /* Operator */ +.codehilite .cm { color: #888888 } /* Comment.Multiline */ +.codehilite .cp { color: #557799 } /* Comment.Preproc */ +.codehilite .c1 { color: #888888 } /* Comment.Single */ +.codehilite .cs { color: #cc0000; font-weight: bold } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #c65d09; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008800; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008800; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008800; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #003388; font-weight: bold } /* Keyword.Pseudo */ +.codehilite .kr { color: #008800; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #333399; font-weight: bold } /* Keyword.Type */ +.codehilite .m { color: #6600EE; font-weight: bold } /* Literal.Number */ +.codehilite .s { background-color: #fff0f0 } /* Literal.String */ +.codehilite .na { color: #0000CC } /* Name.Attribute */ +.codehilite .nb { color: #007020 } /* Name.Builtin */ +.codehilite .nc { color: #BB0066; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #003366; font-weight: bold } /* Name.Constant */ +.codehilite .nd { color: #555555; font-weight: bold } /* Name.Decorator */ +.codehilite .ni { color: #880000; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #FF0000; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0066BB; font-weight: bold } /* Name.Function */ +.codehilite .nl { color: #997700; font-weight: bold } /* Name.Label */ +.codehilite .nn { color: #0e84b5; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #007700 } /* Name.Tag */ +.codehilite .nv { color: #996633 } /* Name.Variable */ +.codehilite .ow { color: #000000; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #6600EE; font-weight: bold } /* Literal.Number.Bin */ +.codehilite .mf { color: #6600EE; font-weight: bold } /* Literal.Number.Float */ +.codehilite .mh { color: #005588; font-weight: bold } /* Literal.Number.Hex */ +.codehilite .mi { color: #0000DD; font-weight: bold } /* Literal.Number.Integer */ +.codehilite .mo { color: #4400EE; font-weight: bold } /* Literal.Number.Oct */ +.codehilite .sb { background-color: #fff0f0 } /* Literal.String.Backtick */ +.codehilite .sc { color: #0044DD } /* Literal.String.Char */ +.codehilite .sd { color: #DD4422 } /* Literal.String.Doc */ +.codehilite .s2 { background-color: #fff0f0 } /* Literal.String.Double */ +.codehilite .se { color: #666666; font-weight: bold; background-color: #fff0f0 } /* Literal.String.Escape */ +.codehilite .sh { background-color: #fff0f0 } /* Literal.String.Heredoc */ +.codehilite .si { background-color: #eeeeee } /* Literal.String.Interpol */ +.codehilite .sx { color: #DD2200; background-color: #fff0f0 } /* Literal.String.Other */ +.codehilite .sr { color: #000000; background-color: #fff0ff } /* Literal.String.Regex */ +.codehilite .s1 { background-color: #fff0f0 } /* Literal.String.Single */ +.codehilite .ss { color: #AA6600 } /* Literal.String.Symbol */ +.codehilite .bp { color: #007020 } /* Name.Builtin.Pseudo */ +.codehilite .vc { color: #336699 } /* Name.Variable.Class */ +.codehilite .vg { color: #dd7700; font-weight: bold } /* Name.Variable.Global */ +.codehilite .vi { color: #3333BB } /* Name.Variable.Instance */ +.codehilite .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */ diff --git a/resources/session07/learning_journal/learning_journal/static/theme.css b/resources/session07/learning_journal/learning_journal/static/theme.css new file mode 100644 index 00000000..be50ad42 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/static/theme.css @@ -0,0 +1,152 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700); +body { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; + color: #ffffff; + background: #bc2131; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; +} +p { + font-weight: 300; +} +.font-normal { + font-weight: 400; +} +.font-semi-bold { + font-weight: 600; +} +.font-bold { + font-weight: 700; +} +.starter-template { + margin-top: 250px; +} +.starter-template .content { + margin-left: 10px; +} +.starter-template .content h1 { + margin-top: 10px; + font-size: 60px; +} +.starter-template .content h1 .smaller { + font-size: 40px; + color: #f2b7bd; +} +.starter-template .content .lead { + font-size: 25px; + color: #f2b7bd; +} +.starter-template .content .lead .font-normal { + color: #ffffff; +} +.starter-template .links { + float: right; + right: 0; + margin-top: 125px; +} +.starter-template .links ul { + display: block; + padding: 0; + margin: 0; +} +.starter-template .links ul li { + list-style: none; + display: inline; + margin: 0 10px; +} +.starter-template .links ul li:first-child { + margin-left: 0; +} +.starter-template .links ul li:last-child { + margin-right: 0; +} +.starter-template .links ul li.current-version { + color: #f2b7bd; + font-weight: 400; +} +.starter-template .links ul li a { + color: #ffffff; +} +.starter-template .links ul li a:hover { + text-decoration: underline; +} +.starter-template .links ul li .icon-muted { + color: #eb8b95; + margin-right: 5px; +} +.starter-template .links ul li:hover .icon-muted { + color: #ffffff; +} +.starter-template .copyright { + margin-top: 10px; + font-size: 0.9em; + color: #f2b7bd; + text-transform: lowercase; + float: right; + right: 0; +} +@media (max-width: 1199px) { + .starter-template .content h1 { + font-size: 45px; + } + .starter-template .content h1 .smaller { + font-size: 30px; + } + .starter-template .content .lead { + font-size: 20px; + } +} +@media (max-width: 991px) { + .starter-template { + margin-top: 0; + } + .starter-template .logo { + margin: 40px auto; + } + .starter-template .content { + margin-left: 0; + text-align: center; + } + .starter-template .content h1 { + margin-bottom: 20px; + } + .starter-template .links { + float: none; + text-align: center; + margin-top: 60px; + } + .starter-template .copyright { + float: none; + text-align: center; + } +} +@media (max-width: 767px) { + .starter-template .content h1 .smaller { + font-size: 25px; + display: block; + } + .starter-template .content .lead { + font-size: 16px; + } + .starter-template .links { + margin-top: 40px; + } + .starter-template .links ul li { + display: block; + margin: 0; + } + .starter-template .links ul li .icon-muted { + display: none; + } + .starter-template .copyright { + margin-top: 20px; + } +} diff --git a/resources/session07/learning_journal/learning_journal/static/theme.min.css b/resources/session07/learning_journal/learning_journal/static/theme.min.css new file mode 100644 index 00000000..2f924bcc --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/static/theme.min.css @@ -0,0 +1 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);body{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;color:#fff;background:#bc2131}h1,h2,h3,h4,h5,h6{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300}p{font-weight:300}.font-normal{font-weight:400}.font-semi-bold{font-weight:600}.font-bold{font-weight:700}.starter-template{margin-top:250px}.starter-template .content{margin-left:10px}.starter-template .content h1{margin-top:10px;font-size:60px}.starter-template .content h1 .smaller{font-size:40px;color:#f2b7bd}.starter-template .content .lead{font-size:25px;color:#f2b7bd}.starter-template .content .lead .font-normal{color:#fff}.starter-template .links{float:right;right:0;margin-top:125px}.starter-template .links ul{display:block;padding:0;margin:0}.starter-template .links ul li{list-style:none;display:inline;margin:0 10px}.starter-template .links ul li:first-child{margin-left:0}.starter-template .links ul li:last-child{margin-right:0}.starter-template .links ul li.current-version{color:#f2b7bd;font-weight:400}.starter-template .links ul li a{color:#fff}.starter-template .links ul li a:hover{text-decoration:underline}.starter-template .links ul li .icon-muted{color:#eb8b95;margin-right:5px}.starter-template .links ul li:hover .icon-muted{color:#fff}.starter-template .copyright{margin-top:10px;font-size:.9em;color:#f2b7bd;text-transform:lowercase;float:right;right:0}@media (max-width:1199px){.starter-template .content h1{font-size:45px}.starter-template .content h1 .smaller{font-size:30px}.starter-template .content .lead{font-size:20px}}@media (max-width:991px){.starter-template{margin-top:0}.starter-template .logo{margin:40px auto}.starter-template .content{margin-left:0;text-align:center}.starter-template .content h1{margin-bottom:20px}.starter-template .links{float:none;text-align:center;margin-top:60px}.starter-template .copyright{float:none;text-align:center}}@media (max-width:767px){.starter-template .content h1 .smaller{font-size:25px;display:block}.starter-template .content .lead{font-size:16px}.starter-template .links{margin-top:40px}.starter-template .links ul li{display:block;margin:0}.starter-template .links ul li .icon-muted{display:none}.starter-template .copyright{margin-top:20px}} \ No newline at end of file diff --git a/resources/session07/learning_journal/learning_journal/templates/detail.jinja2 b/resources/session07/learning_journal/learning_journal/templates/detail.jinja2 new file mode 100644 index 00000000..8f966471 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/templates/detail.jinja2 @@ -0,0 +1,18 @@ +{% extends "layout.jinja2" %} +{% block body %} +
+

{{ entry.title }}

+
+

{{ entry.body|markdown }}

+
+

Created {{entry.created}}

+
+

+ Go Back + {% if logged_in %} + :: + + Edit Entry + {% endif %} +

+{% endblock %} diff --git a/resources/session07/learning_journal/learning_journal/templates/edit.jinja2 b/resources/session07/learning_journal/learning_journal/templates/edit.jinja2 new file mode 100644 index 00000000..ebe0f6b9 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/templates/edit.jinja2 @@ -0,0 +1,17 @@ +{% extends "templates/layout.jinja2" %} +{% block body %} +

Create a Journal Entry

+
+{% for field in form %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +

{{ field.label }}: {{ field }}

+{% endfor %} +

+
+{% endblock %} diff --git a/resources/session07/learning_journal/learning_journal/templates/layout.jinja2 b/resources/session07/learning_journal/learning_journal/templates/layout.jinja2 new file mode 100644 index 00000000..a8b27afd --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/templates/layout.jinja2 @@ -0,0 +1,29 @@ + + + + + Python Learning Journal + + + + +
+ +
+
+

My Python Journal

+
+ {% block body %}{% endblock %} +
+
+
+

Created in the UW PCE Python Certificate Program

+
+ + diff --git a/resources/session07/learning_journal/learning_journal/templates/list.jinja2 b/resources/session07/learning_journal/learning_journal/templates/list.jinja2 new file mode 100644 index 00000000..7f1e4795 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/templates/list.jinja2 @@ -0,0 +1,31 @@ +{% extends "layout.jinja2" %} +{% block body %} +{% if login_form %} + +{% endif %} +{% if entries %} +

Journal Entries

+ +{% else %} +

This journal is empty

+{% endif %} +{% if not login_form %} +

New Entry

+{% endif %} +{% endblock %} diff --git a/resources/session07/learning_journal/learning_journal/templates/mytemplate.pt b/resources/session07/learning_journal/learning_journal/templates/mytemplate.pt new file mode 100644 index 00000000..9e88dc4b --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/templates/mytemplate.pt @@ -0,0 +1,66 @@ + + + + + + + + + + + Alchemy Scaffold for The Pyramid Web Framework + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

Pyramid Alchemy scaffold

+

Welcome to ${project}, an application generated by
the Pyramid Web Framework 1.5.2.

+
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + diff --git a/resources/session07/learning_journal/learning_journal/tests.py b/resources/session07/learning_journal/learning_journal/tests.py new file mode 100644 index 00000000..4fc444a6 --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/tests.py @@ -0,0 +1,55 @@ +import unittest +import transaction + +from pyramid import testing + +from .models import DBSession + + +class TestMyViewSuccessCondition(unittest.TestCase): + def setUp(self): + self.config = testing.setUp() + from sqlalchemy import create_engine + engine = create_engine('sqlite://') + from .models import ( + Base, + MyModel, + ) + DBSession.configure(bind=engine) + Base.metadata.create_all(engine) + with transaction.manager: + model = MyModel(name='one', value=55) + DBSession.add(model) + + def tearDown(self): + DBSession.remove() + testing.tearDown() + + def test_passing_view(self): + from .views import my_view + request = testing.DummyRequest() + info = my_view(request) + self.assertEqual(info['one'].name, 'one') + self.assertEqual(info['project'], 'learning_journal') + + +class TestMyViewFailureCondition(unittest.TestCase): + def setUp(self): + self.config = testing.setUp() + from sqlalchemy import create_engine + engine = create_engine('sqlite://') + from .models import ( + Base, + MyModel, + ) + DBSession.configure(bind=engine) + + def tearDown(self): + DBSession.remove() + testing.tearDown() + + def test_failing_view(self): + from .views import my_view + request = testing.DummyRequest() + info = my_view(request) + self.assertEqual(info.status_int, 500) diff --git a/resources/session07/learning_journal/learning_journal/views.py b/resources/session07/learning_journal/learning_journal/views.py new file mode 100644 index 00000000..d6248a0b --- /dev/null +++ b/resources/session07/learning_journal/learning_journal/views.py @@ -0,0 +1,94 @@ +from jinja2 import Markup +import markdown +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from pyramid.security import forget, remember, authenticated_userid +from pyramid.view import view_config + +from .models import ( + DBSession, + MyModel, + Entry, + User + ) + +from .forms import ( + EntryCreateForm, + EntryEditForm, + LoginForm +) + + +@view_config(route_name='home', renderer='templates/list.jinja2') +def index_page(request): + entries = Entry.all() + form = None + if not authenticated_userid(request): + form = LoginForm() + return {'entries': entries, 'login_form': form} + + +@view_config(route_name='detail', renderer='templates/detail.jinja2') +def view(request): + this_id = request.matchdict.get('id', -1) + entry = Entry.by_id(this_id) + if not entry: + return HTTPNotFound() + logged_in = authenticated_userid(request) + return {'entry': entry, 'logged_in': logged_in} + + +@view_config(route_name='action', match_param='action=create', + renderer='templates/edit.jinja2', + permission='create') +def create(request): + entry = Entry() + form = EntryCreateForm(request.POST) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + DBSession.add(entry) + return HTTPFound(location=request.route_url('home')) + return {'form': form, 'action': request.matchdict.get('action')} + + +@view_config(route_name='action', match_param='action=edit', + renderer='templates/edit.jinja2', + permission='edit') +def update(request): + id = int(request.params.get('id', -1)) + entry = Entry.by_id(id) + if not entry: + return HTTPNotFound() + form = EntryEditForm(request.POST, entry) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + return HTTPFound(location=request.route_url('detail', id=entry.id)) + return {'form': form, 'action': request.matchdict.get('action')} + + +@view_config(route_name='auth', match_param='action=in', renderer='string', + request_method='POST') +@view_config(route_name='auth', match_param='action=out', renderer='string') +def sign_in(request): + login_form = None + if request.method == 'POST': + login_form = LoginForm(request.POST) + if login_form and login_form.validate(): + user = User.by_name(login_form.username.data) + if user and user.verify_password(login_form.password.data): + headers = remember(request, user.name) + else: + headers = forget(request) + else: + headers = forget(request) + return HTTPFound(location=request.route_url('home'), + headers=headers) + + +def render_markdown(content): + output = Markup( + markdown.markdown( + content, + extensions=['codehilite(pygments_style=colorful)', 'fenced_code'] + ) + ) + return output diff --git a/resources/session07/learning_journal/production.ini b/resources/session07/learning_journal/production.ini new file mode 100644 index 00000000..d203746a --- /dev/null +++ b/resources/session07/learning_journal/production.ini @@ -0,0 +1,66 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/environment.html +### + +[app:main] +use = egg:learning_journal + + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/learning_journal.sqlite + +jinja2.filters = + markdown = learning_journal.views.render_markdown + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html +### + +[loggers] +keys = root, learning_journal, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_learning_journal] +level = WARN +handlers = +qualname = learning_journal + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/resources/session07/learning_journal/requirements.txt b/resources/session07/learning_journal/requirements.txt new file mode 100644 index 00000000..871e054a --- /dev/null +++ b/resources/session07/learning_journal/requirements.txt @@ -0,0 +1,34 @@ +appnope==0.1.0 +decorator==4.0.6 +ipython==4.0.1 +ipython-genutils==0.1.0 +Jinja2==2.8 +Mako==1.0.3 +Markdown==2.6.5 +MarkupSafe==0.23 +passlib==1.6.5 +PasteDeploy==1.5.2 +path.py==8.1.2 +pexpect==4.0.1 +pickleshare==0.5 +psycopg2==2.6.1 +ptyprocess==0.5 +Pygments==2.0.2 +pyramid==1.5.7 +pyramid-debugtoolbar==2.4.2 +pyramid-jinja2==2.5 +pyramid-mako==1.0.2 +pyramid-tm==0.12.1 +repoze.lru==0.6 +simplegeneric==0.8.1 +SQLAlchemy==1.0.11 +traitlets==4.0.0 +transaction==1.4.4 +translationstring==1.3 +venusian==1.0 +waitress==0.8.10 +WebOb==1.5.1 +WTForms==2.1 +zope.deprecation==4.1.2 +zope.interface==4.1.3 +zope.sqlalchemy==0.7.6 diff --git a/resources/session07/learning_journal/run b/resources/session07/learning_journal/run new file mode 100755 index 00000000..3689ebe2 --- /dev/null +++ b/resources/session07/learning_journal/run @@ -0,0 +1,3 @@ +#!/bin/bash +python setup.py develop +python runapp.py diff --git a/resources/session07/learning_journal/runapp.py b/resources/session07/learning_journal/runapp.py new file mode 100644 index 00000000..df24540c --- /dev/null +++ b/resources/session07/learning_journal/runapp.py @@ -0,0 +1,10 @@ +import os + +from paste.deploy import loadapp +from waitress import serve + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app = loadapp('config:production.ini', relative_to='.') + + serve(app, host='0.0.0.0', port=port) diff --git a/resources/session07/learning_journal/runtime.txt b/resources/session07/learning_journal/runtime.txt new file mode 100644 index 00000000..294a23e9 --- /dev/null +++ b/resources/session07/learning_journal/runtime.txt @@ -0,0 +1 @@ +python-3.5.0 diff --git a/resources/session07/learning_journal/setup.py b/resources/session07/learning_journal/setup.py new file mode 100644 index 00000000..f681f70b --- /dev/null +++ b/resources/session07/learning_journal/setup.py @@ -0,0 +1,51 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, 'README.txt')) as f: + README = f.read() +with open(os.path.join(here, 'CHANGES.txt')) as f: + CHANGES = f.read() + +requires = [ + 'pyramid', + 'pyramid_jinja2', + 'pyramid_debugtoolbar', + 'pyramid_tm', + 'SQLAlchemy', + 'transaction', + 'zope.sqlalchemy', + 'waitress', + 'wtforms', + 'passlib', + 'markdown', + 'pygments', + ] + +setup(name='learning_journal', + version='0.0', + description='learning_journal', + long_description=README + '\n\n' + CHANGES, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author='', + author_email='', + url='', + keywords='web wsgi bfg pylons pyramid', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + test_suite='learning_journal', + install_requires=requires, + entry_points="""\ + [paste.app_factory] + main = learning_journal:main + [console_scripts] + initialize_learning_journal_db = learning_journal.scripts.initializedb:main + """, + ) diff --git a/resources/session07/models.py b/resources/session07/models.py new file mode 100644 index 00000000..a5625056 --- /dev/null +++ b/resources/session07/models.py @@ -0,0 +1,72 @@ +import datetime +from sqlalchemy import ( + Column, + DateTime, + Index, + Integer, + Text, + Unicode, + UnicodeText, + ) + +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +from sqlalchemy.orm import ( + scoped_session, + sessionmaker, + ) + +from zope.sqlalchemy import ZopeTransactionExtension + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) +Base = declarative_base() + + +class MyModel(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(Text) + value = Column(Integer) + +Index('my_index', MyModel.name, unique=True, mysql_length=255) + + +class Entry(Base): + __tablename__ = 'entries' + id = Column(Integer, primary_key=True) + title = Column(Unicode(255), unique=True, nullable=False) + body = Column(UnicodeText, default=u'') + created = Column(DateTime, default=datetime.datetime.utcnow) + edited = Column(DateTime, default=datetime.datetime.utcnow) + + @classmethod + def all(cls, session=None): + """return a query with all entries, ordered by creation date reversed + """ + if session is None: + session = DBSession + return session.query(cls).order_by(sa.desc(cls.created)).all() + + @classmethod + def by_id(cls, id, session=None): + """return a single entry identified by id + + If no entry exists with the provided id, return None + """ + if session is None: + session = DBSession + return session.query(cls).get(id) + + +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Unicode(255), unique=True, nullable=False) + password = Column(Unicode(255), nullable=False) + + @classmethod + def by_name(cls, name, session=None): + if session is None: + session = DBSession + return DBSession.query(cls).filter(cls.name == name).first() diff --git a/resources/session07/views.py b/resources/session07/views.py new file mode 100644 index 00000000..35e37963 --- /dev/null +++ b/resources/session07/views.py @@ -0,0 +1,54 @@ +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from pyramid.view import view_config + +from .models import ( + DBSession, + MyModel, + Entry, + ) + +from .forms import ( + EntryCreateForm, + EntryEditForm, +) + + +@view_config(route_name='home', renderer='templates/list.jinja2') +def index_page(request): + entries = Entry.all() + return {'entries': entries} + + +@view_config(route_name='detail', renderer='templates/detail.jinja2') +def view(request): + this_id = request.matchdict.get('id', -1) + entry = Entry.by_id(this_id) + if not entry: + return HTTPNotFound() + return {'entry': entry} + + +@view_config(route_name='action', match_param='action=create', + renderer='templates/edit.jinja2') +def create(request): + entry = Entry() + form = EntryCreateForm(request.POST) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + DBSession.add(entry) + return HTTPFound(location=request.route_url('home')) + return {'form': form, 'action': request.matchdict.get('action')} + + +@view_config(route_name='action', match_param='action=edit', + renderer='templates/edit.jinja2') +def update(request): + id = int(request.params.get('id', -1)) + entry = Entry.by_id(id) + if not entry: + return HTTPNotFound() + form = EntryEditForm(request.POST, entry) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + return HTTPFound(location=request.route_url('detail', id=entry.id)) + return {'form': form, 'action': request.matchdict.get('action')} diff --git a/assignments/session07/mysite/myblog/static/django_blog.css b/resources/session08/django_blog.css similarity index 99% rename from assignments/session07/mysite/myblog/static/django_blog.css rename to resources/session08/django_blog.css index 64560dc0..45a882de 100644 --- a/assignments/session07/mysite/myblog/static/django_blog.css +++ b/resources/session08/django_blog.css @@ -71,4 +71,4 @@ ul.categories { } ul.categories li { display: inline; -} \ No newline at end of file +} diff --git a/resources/session08/myblog_test_fixture.json b/resources/session08/myblog_test_fixture.json new file mode 100644 index 00000000..bf5269e9 --- /dev/null +++ b/resources/session08/myblog_test_fixture.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "admin", + "first_name": "Mr.", + "last_name": "Administrator", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "admin@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "noname", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "noname@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + } +] diff --git a/assignments/session07/mysite/manage.py b/resources/session08/mysite_stage_1/manage.py similarity index 100% rename from assignments/session07/mysite/manage.py rename to resources/session08/mysite_stage_1/manage.py diff --git a/assignments/session07/mysite/myblog/__init__.py b/resources/session08/mysite_stage_1/myblog/__init__.py similarity index 100% rename from assignments/session07/mysite/myblog/__init__.py rename to resources/session08/mysite_stage_1/myblog/__init__.py diff --git a/assignments/session07/mysite/myblog/admin.py b/resources/session08/mysite_stage_1/myblog/admin.py similarity index 98% rename from assignments/session07/mysite/myblog/admin.py rename to resources/session08/mysite_stage_1/myblog/admin.py index 67aec2d6..310e7294 100644 --- a/assignments/session07/mysite/myblog/admin.py +++ b/resources/session08/mysite_stage_1/myblog/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin -from myblog.models import Post + from myblog.models import Category +from myblog.models import Post + -admin.site.register(Post) admin.site.register(Category) +admin.site.register(Post) diff --git a/resources/session08/mysite_stage_1/myblog/apps.py b/resources/session08/mysite_stage_1/myblog/apps.py new file mode 100644 index 00000000..5e29c8d9 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MyblogConfig(AppConfig): + name = 'myblog' diff --git a/resources/session08/mysite_stage_1/myblog/fixtures/myblog_test_fixture.json b/resources/session08/mysite_stage_1/myblog/fixtures/myblog_test_fixture.json new file mode 100644 index 00000000..bf5269e9 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/fixtures/myblog_test_fixture.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "admin", + "first_name": "Mr.", + "last_name": "Administrator", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "admin@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "noname", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "noname@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + } +] diff --git a/resources/session08/mysite_stage_1/myblog/migrations/0001_initial.py b/resources/session08/mysite_stage_1/myblog/migrations/0001_initial.py new file mode 100644 index 00000000..18d659f1 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 19:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=128)), + ('text', models.TextField(blank=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('modified_date', models.DateTimeField(auto_now=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/resources/session08/mysite_stage_1/myblog/migrations/0002_category.py b/resources/session08/mysite_stage_1/myblog/migrations/0002_category.py new file mode 100644 index 00000000..218b1052 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/migrations/0002_category.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 21:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myblog', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('description', models.TextField(blank=True)), + ('posts', models.ManyToManyField(blank=True, related_name='categories', to='myblog.Post')), + ], + ), + ] diff --git a/assignments/session07/mysite/myblog/migrations/__init__.py b/resources/session08/mysite_stage_1/myblog/migrations/__init__.py similarity index 100% rename from assignments/session07/mysite/myblog/migrations/__init__.py rename to resources/session08/mysite_stage_1/myblog/migrations/__init__.py diff --git a/resources/session08/mysite_stage_1/myblog/models.py b/resources/session08/mysite_stage_1/myblog/models.py new file mode 100644 index 00000000..cef1beac --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Post(models.Model): + title = models.CharField(max_length=128) + text = models.TextField(blank=True) + author = models.ForeignKey(User) + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now=True) + published_date = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return self.title + + +class Category(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + posts = models.ManyToManyField( + Post, + blank=True, + related_name='categories' + ) + + def __str__(self): + return self.name diff --git a/resources/session08/mysite_stage_1/myblog/tests.py b/resources/session08/mysite_stage_1/myblog/tests.py new file mode 100644 index 00000000..308dd6f1 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/tests.py @@ -0,0 +1,27 @@ +from django.test import TestCase +from django.contrib.auth.models import User + +from myblog.models import Category +from myblog.models import Post + + +class PostTestCase(TestCase): + fixtures = ['myblog_test_fixture.json'] + + def setUp(self): + self.user = User.objects.get(pk=1) + + def test_string_representation(self): + expected = "This is a title" + p1 = Post(title=expected) + actual = str(p1) + self.assertEqual(expected, actual) + + +class CategoryTestCase(TestCase): + + def test_string_representation(self): + expected = "A Category" + c1 = Category(name=expected) + actual = str(c1) + self.assertEqual(expected, actual) diff --git a/resources/session08/mysite_stage_1/myblog/views.py b/resources/session08/mysite_stage_1/myblog/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/resources/session08/mysite_stage_1/myblog/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/assignments/session07/mysite/mysite/__init__.py b/resources/session08/mysite_stage_1/mysite/__init__.py similarity index 100% rename from assignments/session07/mysite/mysite/__init__.py rename to resources/session08/mysite_stage_1/mysite/__init__.py diff --git a/resources/session08/mysite_stage_1/mysite/settings.py b/resources/session08/mysite_stage_1/mysite/settings.py new file mode 100644 index 00000000..3753c661 --- /dev/null +++ b/resources/session08/mysite_stage_1/mysite/settings.py @@ -0,0 +1,122 @@ +""" +Django settings for mysite project. + +Generated by 'django-admin startproject' using Django 1.9. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'i=n^tc%@@gq#8ev6dlymy9+-%@^f!q54sjf0rvikt_k5bl(t1=' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'myblog', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'mysite.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'mysite.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/resources/session08/mysite_stage_1/mysite/urls.py b/resources/session08/mysite_stage_1/mysite/urls.py new file mode 100644 index 00000000..40cce1f4 --- /dev/null +++ b/resources/session08/mysite_stage_1/mysite/urls.py @@ -0,0 +1,22 @@ +"""mysite URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.9/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Add an import: from blog import urls as blog_urls + 2. Import the include() function: from django.conf.urls import url, include + 3. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) +""" +from django.conf.urls import url +from django.contrib import admin + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/resources/session08/mysite_stage_1/mysite/wsgi.py b/resources/session08/mysite_stage_1/mysite/wsgi.py new file mode 100644 index 00000000..328bae0f --- /dev/null +++ b/resources/session08/mysite_stage_1/mysite/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for mysite project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + +application = get_wsgi_application() diff --git a/resources/session08/mysite_stage_2/manage.py b/resources/session08/mysite_stage_2/manage.py new file mode 100755 index 00000000..8a50ec04 --- /dev/null +++ b/resources/session08/mysite_stage_2/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/resources/session08/mysite_stage_2/myblog/__init__.py b/resources/session08/mysite_stage_2/myblog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_2/myblog/admin.py b/resources/session08/mysite_stage_2/myblog/admin.py new file mode 100644 index 00000000..310e7294 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from myblog.models import Category +from myblog.models import Post + + +admin.site.register(Category) +admin.site.register(Post) diff --git a/resources/session08/mysite_stage_2/myblog/apps.py b/resources/session08/mysite_stage_2/myblog/apps.py new file mode 100644 index 00000000..5e29c8d9 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MyblogConfig(AppConfig): + name = 'myblog' diff --git a/resources/session08/mysite_stage_2/myblog/fixtures/myblog_test_fixture.json b/resources/session08/mysite_stage_2/myblog/fixtures/myblog_test_fixture.json new file mode 100644 index 00000000..bf5269e9 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/fixtures/myblog_test_fixture.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "admin", + "first_name": "Mr.", + "last_name": "Administrator", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "admin@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "noname", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "noname@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + } +] diff --git a/resources/session08/mysite_stage_2/myblog/migrations/0001_initial.py b/resources/session08/mysite_stage_2/myblog/migrations/0001_initial.py new file mode 100644 index 00000000..18d659f1 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 19:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=128)), + ('text', models.TextField(blank=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('modified_date', models.DateTimeField(auto_now=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/resources/session08/mysite_stage_2/myblog/migrations/0002_category.py b/resources/session08/mysite_stage_2/myblog/migrations/0002_category.py new file mode 100644 index 00000000..218b1052 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/migrations/0002_category.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 21:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myblog', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('description', models.TextField(blank=True)), + ('posts', models.ManyToManyField(blank=True, related_name='categories', to='myblog.Post')), + ], + ), + ] diff --git a/resources/session08/mysite_stage_2/myblog/migrations/__init__.py b/resources/session08/mysite_stage_2/myblog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_2/myblog/models.py b/resources/session08/mysite_stage_2/myblog/models.py new file mode 100644 index 00000000..cef1beac --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Post(models.Model): + title = models.CharField(max_length=128) + text = models.TextField(blank=True) + author = models.ForeignKey(User) + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now=True) + published_date = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return self.title + + +class Category(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + posts = models.ManyToManyField( + Post, + blank=True, + related_name='categories' + ) + + def __str__(self): + return self.name diff --git a/resources/session08/mysite_stage_2/myblog/templates/list.html b/resources/session08/mysite_stage_2/myblog/templates/list.html new file mode 100644 index 00000000..e21bc51c --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/templates/list.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block content %} +

Recent Posts

+ + {% comment %} here is where the query happens {% endcomment %} + {% for post in posts %} +
+

{{ post }}

+ +
+ {{ post.text }} +
+
    + {% for category in post.categories.all %} +
  • {{ category }}
  • + {% endfor %} +
+
+ {% endfor %} +{% endblock %} diff --git a/resources/session08/mysite_stage_2/myblog/tests.py b/resources/session08/mysite_stage_2/myblog/tests.py new file mode 100644 index 00000000..d9418619 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/tests.py @@ -0,0 +1,60 @@ +import datetime +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils.timezone import utc + +from myblog.models import Category +from myblog.models import Post + + +class PostTestCase(TestCase): + fixtures = ['myblog_test_fixture.json'] + + def setUp(self): + self.user = User.objects.get(pk=1) + + def test_string_representation(self): + expected = "This is a title" + p1 = Post(title=expected) + actual = str(p1) + self.assertEqual(expected, actual) + + +class CategoryTestCase(TestCase): + + def test_string_representation(self): + expected = "A Category" + c1 = Category(name=expected) + actual = str(c1) + self.assertEqual(expected, actual) + + +class FrontEndTestCase(TestCase): + """test views provided in the front-end""" + fixtures = ['myblog_test_fixture.json', ] + + def setUp(self): + self.now = datetime.datetime.utcnow().replace(tzinfo=utc) + self.timedelta = datetime.timedelta(15) + author = User.objects.get(pk=1) + for count in range(1, 11): + post = Post(title="Post %d Title" % count, + text="foo", + author=author) + if count < 6: + # publish the first five posts + pubdate = self.now - self.timedelta * count + post.published_date = pubdate + post.save() + + def test_list_only_published(self): + resp = self.client.get('/') + # the content of the rendered response is always a bytestring + resp_text = resp.content.decode(resp.charset) + self.assertTrue("Recent Posts" in resp_text) + for count in range(1, 11): + title = "Post %d Title" % count + if count < 6: + self.assertContains(resp, title, count=1) + else: + self.assertNotContains(resp, title) diff --git a/resources/session08/mysite_stage_2/myblog/urls.py b/resources/session08/mysite_stage_2/myblog/urls.py new file mode 100644 index 00000000..8428ffe4 --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import url + +from myblog.views import stub_view +from myblog.views import list_view + + +urlpatterns = [ + url(r'^$', + list_view, + name="blog_index"), + url(r'^posts/(?P\d+)/$', + stub_view, + name='blog_detail'), +] diff --git a/resources/session08/mysite_stage_2/myblog/views.py b/resources/session08/mysite_stage_2/myblog/views.py new file mode 100644 index 00000000..ab45c18b --- /dev/null +++ b/resources/session08/mysite_stage_2/myblog/views.py @@ -0,0 +1,23 @@ +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.shortcuts import render +from django.template import RequestContext, loader + +from myblog.models import Post + + +def stub_view(request, *args, **kwargs): + body = "Stub View\n\n" + if args: + body += "Args:\n" + body += "\n".join(["\t%s" % a for a in args]) + if kwargs: + body += "Kwargs:\n" + body += "\n".join(["\t%s: %s" % i for i in kwargs.items()]) + return HttpResponse(body, content_type="text/plain") + + +def list_view(request): + published = Post.objects.exclude(published_date__exact=None) + posts = published.order_by('-published_date') + context = {'posts': posts} + return render(request, 'list.html', context) diff --git a/resources/session08/mysite_stage_2/mysite/__init__.py b/resources/session08/mysite_stage_2/mysite/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_2/mysite/settings.py b/resources/session08/mysite_stage_2/mysite/settings.py new file mode 100644 index 00000000..e81fb826 --- /dev/null +++ b/resources/session08/mysite_stage_2/mysite/settings.py @@ -0,0 +1,122 @@ +""" +Django settings for mysite project. + +Generated by 'django-admin startproject' using Django 1.9. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'i=n^tc%@@gq#8ev6dlymy9+-%@^f!q54sjf0rvikt_k5bl(t1=' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'myblog', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'mysite.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'mysite/templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'mysite.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/resources/session08/mysite_stage_2/mysite/templates/base.html b/resources/session08/mysite_stage_2/mysite/templates/base.html new file mode 100644 index 00000000..2eacafa0 --- /dev/null +++ b/resources/session08/mysite_stage_2/mysite/templates/base.html @@ -0,0 +1,15 @@ + + + + My Django Blog + + +
+
+ {% block content %} + [content will go here] + {% endblock %} +
+
+ + diff --git a/resources/session08/mysite_stage_2/mysite/urls.py b/resources/session08/mysite_stage_2/mysite/urls.py new file mode 100644 index 00000000..bf31d5d5 --- /dev/null +++ b/resources/session08/mysite_stage_2/mysite/urls.py @@ -0,0 +1,24 @@ +"""mysite URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.9/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Add an import: from blog import urls as blog_urls + 2. Import the include() function: from django.conf.urls import url, include + 3. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) +""" +from django.conf.urls import url +from django.conf.urls import include +from django.contrib import admin + +urlpatterns = [ + url(r'^', include('myblog.urls')), + url(r'^admin/', admin.site.urls), +] diff --git a/resources/session08/mysite_stage_2/mysite/wsgi.py b/resources/session08/mysite_stage_2/mysite/wsgi.py new file mode 100644 index 00000000..328bae0f --- /dev/null +++ b/resources/session08/mysite_stage_2/mysite/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for mysite project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + +application = get_wsgi_application() diff --git a/resources/session08/mysite_stage_3/manage.py b/resources/session08/mysite_stage_3/manage.py new file mode 100755 index 00000000..8a50ec04 --- /dev/null +++ b/resources/session08/mysite_stage_3/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/resources/session08/mysite_stage_3/myblog/__init__.py b/resources/session08/mysite_stage_3/myblog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_3/myblog/admin.py b/resources/session08/mysite_stage_3/myblog/admin.py new file mode 100644 index 00000000..310e7294 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from myblog.models import Category +from myblog.models import Post + + +admin.site.register(Category) +admin.site.register(Post) diff --git a/resources/session08/mysite_stage_3/myblog/apps.py b/resources/session08/mysite_stage_3/myblog/apps.py new file mode 100644 index 00000000..5e29c8d9 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MyblogConfig(AppConfig): + name = 'myblog' diff --git a/resources/session08/mysite_stage_3/myblog/fixtures/myblog_test_fixture.json b/resources/session08/mysite_stage_3/myblog/fixtures/myblog_test_fixture.json new file mode 100644 index 00000000..bf5269e9 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/fixtures/myblog_test_fixture.json @@ -0,0 +1,38 @@ +[ + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "admin", + "first_name": "Mr.", + "last_name": "Administrator", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "admin@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "noname", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2013-05-24T05:35:58.628Z", + "groups": [], + "user_permissions": [], + "password": "pbkdf2_sha256$10000$1rQazFNdOfFt$6aw/uIrv2uASkZ7moXMTajSN+ySYuowBnbP6ILNQntE=", + "email": "noname@example.com", + "date_joined": "2013-05-24T05:35:58.628Z" + } + } +] diff --git a/resources/session08/mysite_stage_3/myblog/migrations/0001_initial.py b/resources/session08/mysite_stage_3/myblog/migrations/0001_initial.py new file mode 100644 index 00000000..18d659f1 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 19:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=128)), + ('text', models.TextField(blank=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('modified_date', models.DateTimeField(auto_now=True)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/resources/session08/mysite_stage_3/myblog/migrations/0002_category.py b/resources/session08/mysite_stage_3/myblog/migrations/0002_category.py new file mode 100644 index 00000000..218b1052 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/migrations/0002_category.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-31 21:40 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('myblog', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('description', models.TextField(blank=True)), + ('posts', models.ManyToManyField(blank=True, related_name='categories', to='myblog.Post')), + ], + ), + ] diff --git a/resources/session08/mysite_stage_3/myblog/migrations/__init__.py b/resources/session08/mysite_stage_3/myblog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_3/myblog/models.py b/resources/session08/mysite_stage_3/myblog/models.py new file mode 100644 index 00000000..cef1beac --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Post(models.Model): + title = models.CharField(max_length=128) + text = models.TextField(blank=True) + author = models.ForeignKey(User) + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now=True) + published_date = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return self.title + + +class Category(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + posts = models.ManyToManyField( + Post, + blank=True, + related_name='categories' + ) + + def __str__(self): + return self.name diff --git a/resources/session08/mysite_stage_3/myblog/static/django_blog.css b/resources/session08/mysite_stage_3/myblog/static/django_blog.css new file mode 100644 index 00000000..45a882de --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/static/django_blog.css @@ -0,0 +1,74 @@ +body { + background-color: #eee; + color: #111; + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + margin:0; + padding:0; +} +#container { + margin:0; + padding:0; + margin-top: 0px; +} +#header { + background-color: #333; + border-botton: 1px solid #111; + margin:0; + padding:0; +} +#control-bar { + margin: 0em 0em 1em; + list-style: none; + list-style-type: none; + text-align: right; + color: #eee; + font-size: 80%; + padding-bottom: 0.4em; +} +#control-bar li { + display: inline-block; +} +#control-bar li a { + color: #eee; + padding: 0.5em; + text-decoration: none; +} +#control-bar li a:hover { + color: #cce; +} +#content { + margin: 0em 1em 1em; +} + +ul#entries { + list-style: none; + list-style-type: none; +} +div.entry { + margin-right: 2em; + margin-top: 1em; + border-top: 1px solid #cecece; +} +ul#entries li:first-child div.entry { + border-top: none; + margin-top: 0em; +} +div.entry-body { + margin-left: 2em; +} +.notification { + float: right; + text-align: center; + width: 25%; + padding: 1em; +} +.info { + background-color: #aae; +} +ul.categories { + list-style: none; + list-style-type: none; +} +ul.categories li { + display: inline; +} diff --git a/assignments/session07/mysite/myblog/templates/detail.html b/resources/session08/mysite_stage_3/myblog/templates/detail.html similarity index 100% rename from assignments/session07/mysite/myblog/templates/detail.html rename to resources/session08/mysite_stage_3/myblog/templates/detail.html diff --git a/assignments/session07/mysite/myblog/templates/list.html b/resources/session08/mysite_stage_3/myblog/templates/list.html similarity index 100% rename from assignments/session07/mysite/myblog/templates/list.html rename to resources/session08/mysite_stage_3/myblog/templates/list.html diff --git a/resources/session08/mysite_stage_3/myblog/tests.py b/resources/session08/mysite_stage_3/myblog/tests.py new file mode 100644 index 00000000..c4f547bd --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/tests.py @@ -0,0 +1,71 @@ +import datetime +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils.timezone import utc + +from myblog.models import Category +from myblog.models import Post + + +class PostTestCase(TestCase): + fixtures = ['myblog_test_fixture.json'] + + def setUp(self): + self.user = User.objects.get(pk=1) + + def test_string_representation(self): + expected = "This is a title" + p1 = Post(title=expected) + actual = str(p1) + self.assertEqual(expected, actual) + + +class CategoryTestCase(TestCase): + + def test_string_representation(self): + expected = "A Category" + c1 = Category(name=expected) + actual = str(c1) + self.assertEqual(expected, actual) + + +class FrontEndTestCase(TestCase): + """test views provided in the front-end""" + fixtures = ['myblog_test_fixture.json', ] + + def setUp(self): + self.now = datetime.datetime.utcnow().replace(tzinfo=utc) + self.timedelta = datetime.timedelta(15) + author = User.objects.get(pk=1) + for count in range(1, 11): + post = Post(title="Post %d Title" % count, + text="foo", + author=author) + if count < 6: + # publish the first five posts + pubdate = self.now - self.timedelta * count + post.published_date = pubdate + post.save() + + def test_list_only_published(self): + resp = self.client.get('/') + # the content of the rendered response is always a bytestring + resp_text = resp.content.decode(resp.charset) + self.assertTrue("Recent Posts" in resp_text) + for count in range(1, 11): + title = "Post %d Title" % count + if count < 6: + self.assertContains(resp, title, count=1) + else: + self.assertNotContains(resp, title) + + def test_details_only_published(self): + for count in range(1, 11): + title = "Post %d Title" % count + post = Post.objects.get(title=title) + resp = self.client.get('/posts/%d/' % post.pk) + if count < 6: + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, title) + else: + self.assertEqual(resp.status_code, 404) diff --git a/resources/session08/mysite_stage_3/myblog/urls.py b/resources/session08/mysite_stage_3/myblog/urls.py new file mode 100644 index 00000000..5caacf17 --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import url + +from myblog.views import stub_view +from myblog.views import list_view +from myblog.views import detail_view + + +urlpatterns = [ + url(r'^$', + list_view, + name="blog_index"), + url(r'^posts/(?P\d+)/$', + detail_view, + name='blog_detail'), +] diff --git a/resources/session08/mysite_stage_3/myblog/views.py b/resources/session08/mysite_stage_3/myblog/views.py new file mode 100644 index 00000000..c1e8c41a --- /dev/null +++ b/resources/session08/mysite_stage_3/myblog/views.py @@ -0,0 +1,33 @@ +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.shortcuts import render +from django.template import RequestContext, loader + +from myblog.models import Post + + +def stub_view(request, *args, **kwargs): + body = "Stub View\n\n" + if args: + body += "Args:\n" + body += "\n".join(["\t%s" % a for a in args]) + if kwargs: + body += "Kwargs:\n" + body += "\n".join(["\t%s: %s" % i for i in kwargs.items()]) + return HttpResponse(body, content_type="text/plain") + + +def list_view(request): + published = Post.objects.exclude(published_date__exact=None) + posts = published.order_by('-published_date') + context = {'posts': posts} + return render(request, 'list.html', context) + + +def detail_view(request, post_id): + published = Post.objects.exclude(published_date__exact=None) + try: + post = published.get(pk=post_id) + except Post.DoesNotExist: + raise Http404 + context = {'post': post} + return render(request, 'detail.html', context) diff --git a/resources/session08/mysite_stage_3/mysite/__init__.py b/resources/session08/mysite_stage_3/mysite/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite_stage_3/mysite/settings.py b/resources/session08/mysite_stage_3/mysite/settings.py new file mode 100644 index 00000000..3fd7eb4c --- /dev/null +++ b/resources/session08/mysite_stage_3/mysite/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for mysite project. + +Generated by 'django-admin startproject' using Django 1.9. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'i=n^tc%@@gq#8ev6dlymy9+-%@^f!q54sjf0rvikt_k5bl(t1=' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'myblog', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'mysite.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'mysite/templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'mysite.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' + +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' diff --git a/assignments/session07/mysite/mysite/templates/base.html b/resources/session08/mysite_stage_3/mysite/templates/base.html similarity index 85% rename from assignments/session07/mysite/mysite/templates/base.html rename to resources/session08/mysite_stage_3/mysite/templates/base.html index 2a01d991..1529aead 100644 --- a/assignments/session07/mysite/mysite/templates/base.html +++ b/resources/session08/mysite_stage_3/mysite/templates/base.html @@ -1,8 +1,9 @@ +{% load staticfiles %} My Django Blog - + + + This code is available as ``/resources/session04/mashup_2.py`` + + +Parsing Restaurant Data +----------------------- + +Now that we have the records we want, we need to parse them. + +.. rst-class:: build +.. container:: + + We'll start by extracting information about the restaurants: + + .. rst-class:: build + + * Name + * Address + * Location + + How is this information contained in our records? + + +.. nextslide:: Complex Filtering + +Each record consists of a table with a series of *rows* (````). + +.. rst-class:: build +.. container:: + + The rows we want at this time all have two *cells* inside them. + + The first contains the *label* of the data, the second contains the *value* + + We'll need a function in ``mashup.py`` that: + + .. rst-class:: build + + * takes an HTML element as an argument + * verifies that it is a ```` element + * verifies that it has two immediate children that are ```` elements + + My solution: + + .. code-block:: python + + def has_two_tds(elem): + is_tr = elem.name == 'tr' + td_children = elem.find_all('td', recursive=False) + has_two = len(td_children) == 2 + return is_tr and has_two + +.. nextslide:: Test It Out + +Let's try this out in an interpreter: + +.. code-block:: ipython + + In [1]: from mashup_3 import load_inspection_page, parse_source, + restaurant_data_generator, has_two_tds + In [2]: html = load_inspection_page('inspection_page.html') + In [3]: parsed = parse_source(html) + ... + In [4]: content_col = parsed.find('td', id='contentcol') + In [5]: records = restaurant_data_generator(content_col) + In [6]: rec = records[4] + +.. nextslide:: Test It Out + +We'd like to find all table rows in that div that contain *two* cells + +.. rst-class:: build +.. container:: + + The table rows are all contained in a ```` tag. + + We only want the ones at the top of that tag (ones nested more deeply + contain other data) + + .. code-block:: ipython + + In [13]: data_rows = rec.find('tbody').find_all(has_two_tds, recursive=False) + In [14]: len(data_rows) + Out[14]: 7 + In [15]: print(data_rows[0].prettify()) + + + - Business Name + + + SPICE ORIENT + + + +.. nextslide:: Extracting Labels and Values + +Now we have a list of the rows that contain our data. + +.. rst-class:: build +.. container:: + + Next we have to collect the data they contain + + The *label/value* structure of this data should suggest the right container + to store the information. + + Let's start by trying to get at the first label + + .. code-block:: ipython + + In [18]: row1 = data_rows[0] + In [19]: cells = row1.find_all('td') + In [20]: cell1 = cells[0] + In [21]: cell1.text + Out[21]: '\n - Business Name\n ' + + That works well enough, but all that extra stuff is nasty + + We need a method to clean up the text we get from these cells + + It should strip extra whitespace, and characters like ``-`` and ``:`` we + don't want. + +.. nextslide:: My Solution + +Try writing such a function for yourself now in ``mashup.py`` + +.. rst-class:: build +.. container:: + + .. code-block:: python + + def clean_data(td): + return td.text.strip(" \n:-") + + Add it to your interpreter and test it out: + + .. code-block:: ipython + + In [25]: def clean_data(td): + ....: return td.text.strip(" \n:-") + ....: + In [26]: clean_data(cell1) + Out[26]: 'Business Name' + In [27]: + + Ahhh, much better + +.. nextslide:: The Complete Function + +So we can get a list of the rows that contain label/value pairs. + +.. rst-class:: build +.. container:: + + And we can extract clean values from the cells in these rows + + Now we need a function in ``mashup.py`` that will iterate through the rows + we find and build a dictionary of the pairs. + + We have to be cautious because some rows don't have a label. + + The values in these rows should go with the label from the previous row. + +.. nextslide:: My Solution + +Here's the version I came up with: + +.. code-block:: python + + def extract_restaurant_metadata(elem): + restaurant_data_rows = elem.find('tbody').find_all( + has_two_tds, recursive=False + ) + rdata = {} + current_label = '' + for data_row in restaurant_data_rows: + key_cell, val_cell = data_row.find_all('td', recursive=False) + new_label = clean_data(key_cell) + current_label = new_label if new_label else current_label + rdata.setdefault(current_label, []).append(clean_data(val_cell)) + return rdata + + +.. nextslide:: Testing It Out + +Add it to our script: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + # ... + data_list = restaurant_data_generator(content_col) + for data_div in data_list: + metadata = extract_restaurant_metadata(data_div) + print metadata + + And then try it out: + + .. code-block:: bash + + (soupenv)$ python mashup.py + ... + {u'Business Category': [u'Seating 0-12 - Risk Category III'], + u'Longitude': [u'122.3401786000'], u'Phone': [u'(206) 501-9554'], + u'Business Name': [u"ZACCAGNI'S"], u'Address': [u'97B PIKE ST', u'SEATTLE, WA 98101'], + u'Latitude': [u'47.6086651300']} + + This script is available as ``resources/session04/mashup_3.py`` + + +Extracting Inspection Data +-------------------------- + +The final step is to extract the inspection data for each restaurant. + +.. rst-class:: build +.. container:: + + We want to capture only the score from each inspection, details we can + leave behind. + + We'd like to calculate the average score for all known inspections. + + We'd also like to know how many inspections there were in total. + + Finally, we'd like to preserve the highest score of all inspections for a + restaurant. + + We'll add this information to our metadata about the restaurant. + + +.. nextslide:: Finding the Data + +Let's start by getting our bearings. Return to viewing the +``inspection_page.html`` you saved in a browser. + +.. rst-class:: build +.. container:: + + Find a restaurant that has had an inspection or two. + + What can you say about the HTML that contains the scores for these + inspections? + + I notice four characteristics that let us isolate the information we want: + + .. rst-class:: build + + * Inspection data is containd in ```` elements + * Rows with inspection data in them have four ```` children + * The text in the first cell contains the word "inspection" + * But the text does not *start* with the word "inspection" + + Let's try to write a filter function like the one above that will catch + these rows for us. + +.. nextslide:: The filter + +Add this new function ``is_inspection_data_row`` to ``mashup.py`` + +.. rst-class:: build +.. code-block:: python + + def is_inspection_data_row(elem): + is_tr = elem.name == 'tr' + if not is_tr: + return False + td_children = elem.find_all('td', recursive=False) + has_four = len(td_children) == 4 + this_text = clean_data(td_children[0]).lower() + contains_word = 'inspection' in this_text + does_not_start = not this_text.startswith('inspection') + return is_tr and has_four and contains_word and does_not_start + +.. nextslide:: Test It Out + +We can test this function by adding it into our script: + +.. code-block:: python + + for data_div in data_list: + metadata = extract_restaurant_metadata(data_div) + # UPDATE THIS BELOW HERE + inspection_rows = data_div.find_all(is_inspection_data_row) + print(metadata) + print(len(inspection_rows)) + print('*'*10) + +.. rst-class:: build +.. container:: + + And try running the script in your terminal: + + .. code-block:: bash + + (soupenv)$ python mashup.py + {u'Business Category': [u'Seating 0-12 - Risk Category III'], + u'Longitude': [u'122.3401786000'], u'Phone': [u'(206) 501-9554'], + u'Business Name': [u"ZACCAGNI'S"], u'Address': [u'97B PIKE ST', u'SEATTLE, WA 98101'], + u'Latitude': [u'47.6086651300']} + 0 + ********** + +.. nextslide:: Building Inspection Data + +Now we can isolate a list of the rows that contain inspection data. + +.. rst-class:: build +.. container:: + + Next we need to calculate the average score, total number and highest score + for each restaurant. + + Let's add a function to ``mashup.py`` that will: + + .. rst-class:: build + + * Take a div containing a restaurant record + * Extract the rows containing inspection data + * Keep track of the highest score recorded + * Sum the total of all inspections + * Count the number of inspections made + * Calculate the average score for inspections + * Return the three calculated values in a dictionary + +.. nextslide:: My Solution + +Try writing this routine yourself. + +.. code-block:: python + + def get_score_data(elem): + inspection_rows = elem.find_all(is_inspection_data_row) + samples = len(inspection_rows) + total = high_score = average = 0 + for row in inspection_rows: + strval = clean_data(row.find_all('td')[2]) + try: + intval = int(strval) + except (ValueError, TypeError): + samples -= 1 + else: + total += intval + high_score = intval if intval > high_score else high_score + if samples: + average = total/float(samples) + return {'Average Score': average, 'High Score': high_score, + 'Total Inspections': samples} + +.. nextslide:: Test It Out + +We can now incorporate this new routine into our ``mashup`` script. + +.. rst-class:: build +.. container:: + + We'll want to add the data it produces to the metadata we've already + extracted. + + .. code-block:: python + + for data_div in data_list: + metadata = extract_restaurant_metadata(data_div) + inspection_data = get_score_data(data_div) + metadata.update(inspection_data) + print metadata + + And test it out at the command line: + + .. code-block:: bash + + (soupenv)$ python mashup.py + ... + {u'Business Category': [u'Seating 0-12 - Risk Category III'], + u'Longitude': [u'122.3401786000'], u'High Score': 0, + u'Phone': [u'(206) 501-9554'], u'Business Name': [u"ZACCAGNI'S"], + u'Total Inspections': 0, u'Address': [u'97B PIKE ST', u'SEATTLE, WA 98101'], + u'Latitude': [u'47.6086651300'], u'Average Score': 0} + +Break Time +---------- + +Once you have this working, take a break. + +When we return, we'll try a saner approach to getting data from online + + + +Another Approach +================ + +.. rst-class:: left +.. container:: + + Scraping web pages is tedious and inherently brittle + + .. rst-class:: build + .. container:: + + The owner of the website updates their layout, your code breaks + + But there is another way to get information from the web in a more normalized + fashion + + .. rst-class:: centered + + **Web Services** + + +Web Services +------------ + +"a software system designed to support interoperable machine-to-machine +interaction over a network" - W3C + +.. rst-class:: build + +* provides a defined set of calls +* returns structured data + + +.. nextslide:: Early Web Services + +**RSS** is one of the earliest forms of Web Services + +.. rst-class:: build +.. container:: + + A single web-based *endpoint* provides a dynamically updated listing of + content + + Implemented in pure HTTP. Returns XML + + **Atom** is a competing, but similar standard + + There's a solid Python library for consuming RSS: `feedparser`_. + +.. _feedparser: https://pythonhosted.org/feedparser/ + +.. nextslide:: XML-RPC + +XML-RPC extended the essentially static nature of RSS by allowing users to call +procedures and pass arguments. + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * Calls are made via HTTP GET, by passing an XML document + * Returns from a call are sent to the client in XML + + In python, you can access XML-RPC services using `xmlrpc`_ from the + standard library. It has two libraries, ``xmlrpc.client`` and + ``xmlrpc.server`` + +.. _xmlrpc: https://docs.python.org/3.5/library/xmlrpc.html + +.. nextslide:: SOAP + +SOAP extends XML-RPC in a couple of useful ways: + +.. rst-class:: build + +* It uses Web Services Description Language (WSDL) to provide meta-data about + an entire service in a machine-readable format (Automatic introspection) + +* It establishes a method for extending available data types using XML + namespaces + +.. rst-class:: build +.. container:: + + There is no standard library module that supports SOAP directly. + + .. rst-class:: build + + * The best-known and best-supported module available is **Suds** + * The homepage is https://fedorahosted.org/suds/ + * It can be installed using ``easy_install`` or ``pip install`` + * A `fork of the library`_ compatible with Python 3 does exist + + **I HATE SOAP** + +.. _fork of the library: https://github.com/cackharot/suds-py3 + +.. nextslide:: What about WSDL? + +SOAP was invented in part to provide completely machine-readable +interoperability. + +.. rst-class:: build +.. container:: + + *Does that really work in real life?* + + .. rst-class:: centered + + **Hardly ever** + + Another reason was to provide extensibility via custom types + + *Does that really work in real life?* + + .. rst-class:: centered + + **Hardly ever** + +.. nextslide:: I have to write XML? + +In addition, XML is a pretty inefficient medium for transmitting data. There's +a lot of extra characters transmitted that lack any meaning. + +.. code-block:: xml + + + + + + + + IBM + + + + +.. nextslide:: Why Do All The Work? + +So, if neither of the original goals is really achieved by using SOAP + +.. rst-class:: build +.. container:: + + And if the transmission medium is too bloated to use + + why pay all the overhead required to use the protocol? + + Is there another way we could consider approaching the problem? + + .. rst-class:: centered + + **Enter REST** + + +REST +---- + +.. rst-class:: centered + +**Representational State Transfer** + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * Originally described by Roy T. Fielding (worth reading) + * Use HTTP for what it can do + * Read more in `RESTful Web Services `_\* + + \* Seriously. Buy it and read it + +.. nextslide:: A Comparison + +The XML-RCP/SOAP way: + +.. rst-class:: build + +* POST /getComment HTTP/1.1 +* POST /getComments HTTP/1.1 +* POST /addComment HTTP/1.1 +* POST /editComment HTTP/1.1 +* POST /deleteComment HTTP/1.1 + +.. rst-class:: build +.. container:: + + The RESTful way: + + .. rst-class:: build + + * GET /comment/ HTTP/1.1 + * GET /comment HTTP/1.1 + * POST /comment HTTP/1.1 + * PUT /comment/ HTTP/1.1 + * DELETE /comment/ HTTP/1.1 + + +.. nextslide:: ROA + +REST is a **Resource Oriented Architecture** + +.. rst-class:: build +.. container:: + + The URL represents the *resource* we are working with + + The HTTP Method indicates the ``action`` to be taken + + The HTTP Code returned tells us the ``result`` (whether success or failure) + +.. nextslide:: HTTP Codes Revisited + +.. rst-class:: build +.. container:: + + POST /comment HTTP/1.1 (creating a new comment): + + .. rst-class:: build + + * Success: ``HTTP/1.1 201 Created`` + * Failure (unauthorized): ``HTTP/1.1 401 Unauthorized`` + * Failure (NotImplemented): ``HTTP/1.1 405 Not Allowed`` + * Failure (ValueError): ``HTTP/1.1 406 Not Acceptable`` + + PUT /comment/ HTTP/1.1 (edit comment): + + .. rst-class:: build + + * Success: ``HTTP/1.1 200 OK`` + * Failure: ``HTTP/1.1 409 Conflict`` + + DELETE /comment/ HTTP/1.1 (delete comment): + + .. rst-class:: build + + * Success: ``HTTP/1.1 204 No Content`` + +REST uses JSON +-------------- + +JavaScript Object Notation: + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * a lightweight data-interchange format + * easy for humans to read and write + * easy for machines to parse and generate + + Based on Two Structures: + + * object: ``{ string: value, ...}`` + * array: ``[value, value, ]`` + + .. rst-class:: centered + + pythonic, no? + + +.. nextslide:: JSON Data Types + +JSON provides a few basic data types (see http://json.org/): + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * string: unicode, anything but ", \\ and control characters + * number: any number, but json does not use octal or hexadecimal + * object, array (we've seen these above) + * true + * false + * null + + .. rst-class:: centered + + **No date type? OMGWTF??!!1!1** + +.. nextslide:: Dates in JSON + +You have two options: + +.. rst-class:: build +.. container:: + + .. container:: + + Option 1 - Unix Epoch Time (number): + + .. code-block:: python + + >>> import time + >>> time.time() + 1358212616.7691269 + + .. container:: + + Option 2 - ISO 8661 (string): + + .. code-block:: python + + >>> import datetime + >>> datetime.datetime.now().isoformat() + '2013-01-14T17:18:10.727240' + + +JSON in Python +-------------- + +You can encode python to json, and decode json back to python: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + In [1]: import json + In [2]: array = [1, 2, 3] + In [3]: json.dumps(array) + Out[3]: '[1, 2, 3]' + In [4]: orig = {'foo': [1,2,3], 'bar': 'my resumé', 'baz': True} + In [5]: encoded = json.dumps(orig) + In [6]: encoded + Out[6]: '{"foo": [1, 2, 3], "bar": "my resum\\u00e9", "baz": true}' + In [7]: decoded = json.loads(encoded) + In [8]: decoded == orig + Out[8]: True + + Customizing the encoder or decoder class allows for specialized serializations + + +.. nextslide:: + +the json module also supports reading and writing to *file-like objects* via +``json.dump(fp)`` and ``json.load(fp)`` (note the missing 's') + +.. rst-class:: build +.. container:: + + Remember duck-typing. Anything with a ``.write`` and a ``.read`` method is + *file-like* + + This usage can be much more memory-friendly with large files/sources + + +Playing With REST +----------------- + +Let's take a moment to play with REST. + +.. rst-class:: build +.. container:: + + We'll use a common, public API provided by Google. + + .. rst-class:: centered + + **Geocoding** + +.. nextslide:: Geocoding with Google APIs + +https://developers.google.com/maps/documentation/geocoding + +.. rst-class:: build +.. container:: + + Open a python interpreter using our virtualenv:: + + (soupenv)$ python + + .. code-block:: ipython + + In [1]: import requests + In [2]: import json + In [3]: from pprint import pprint + In [4]: url = 'http://maps.googleapis.com/maps/api/geocode/json' + In [5]: addr = '1325 4th Ave, Seattle, 98101' + In [6]: parameters = {'address': addr, 'sensor': 'false'} + In [7]: resp = requests.get(url, params=parameters) + In [8]: data = resp.json() + + +.. nextslide:: Reverse Geocoding + +You can do the same thing in reverse, supply latitude and longitude and get +back address information: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [15]: if data['status'] == 'OK': + ....: pprint(data['results']) + ....: + [{'address_components': [{'long_name': '1325', + 'short_name': '1325', + ... + 'types': ['street_address']}] + + Notice that there may be a number of results returned, ordered from most + specific to least. + + +Mashing It Up +------------- + +Google's geocoding data is quite nice. + +.. rst-class:: build +.. container:: + + But it's not in a format we can use directly to create a map + + For that we need `geojson` + + Moreover, formatting the data for all those requests is going to get + tedious. + + Luckily, people create *wrappers* for popular REST apis like google's + geocoding service. + + Once such wrapper is `geocoder`_, which provides not only google's service, + but many others under a single umbrella. + +.. _geocoder: http://geocoder.readthedocs.org/en/latest/ +.. _geojson: http://geojson.org + +.. nextslide:: Install ``geocoder`` + +Install geocoder into your ``soupenv`` so that it's available to use: + +.. code-block:: bash + + (soupenv)$ pip install geocoder + +.. rst-class:: build +.. container:: + + Our final step for tonight will be to geocode the results we have scraped + from the inspection site. + + We'll then convert that to ``geojson``, insert our own properties and map + the results. + + Let's begin by converting our script so that what we have so far is + contained in a generator function + + We'll eventually sort our results and generate the top 10 or so for + geocoding. + + Open up ``mashup.py`` and copy everthing in the ``main`` block. + +.. nextslide:: Make a Generator Function + +Add a new function ``result_generator`` to the ``mashup.py`` script. Paste the +code you copied from the ``main`` block and then update it a bit: + +.. rst-class:: build +.. code-block:: python + + def result_generator(count): + use_params = { + 'Inspection_Start': '2/1/2013', + 'Inspection_End': '2/1/2015', + 'Zip_Code': '98101' + } + # html, encoding = get_inspection_page(**use_params) + html, encoding = load_inspection_page('inspection_page.html') + parsed = parse_source(html, encoding) + content_col = parsed.find("td", id="contentcol") + data_list = restaurant_data_generator(content_col) + for data_div in data_list[:count]: + metadata = extract_restaurant_metadata(data_div) + inspection_data = get_score_data(data_div) + metadata.update(inspection_data) + yield metadata + + +.. nextslide:: Test It Out + +Update the ``main`` block of your ``mashup.py`` script to use the new function: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + if __name__ == '__main__': + for result in result_generator(10): + print result + + Then run your script and verify that the only thing that has changed is the + number of results that print. + + .. code-block:: bash + + (soupenv)$ python mashup.py + # you should see 10 dictionaries print here. + +Add Geocoding +------------- + +The API for geocoding with ``geocoder`` is the same for all providers. + +.. rst-class:: build +.. container:: + + You give an address, it returns geocoded data. + + You provide latitude and longitude, it provides address data + + .. code-block:: ipython + + In [1]: response = geocoder.google(
) + In [2]: response.json + Out[2]: # json result data + In [3]: response.geojson + Out[3]: # geojson result data + +.. nextslide:: Adding The Function + +Let's add a new function ``get_geojson`` to ``mashup.py`` + +.. rst-class:: build +.. container:: + + It will + + .. rst-class:: build + + * Take a result from our search as it's input + * Get geocoding data from google using the address of the restaurant + * Return the geojson representation of that data + + Try to write this function on your own + + .. code-block:: python + + def get_geojson(result): + address = " ".join(result.get('Address', '')) + if not address: + return None + geocoded = geocoder.google(address) + return geocoded.geojson + +.. nextslide:: Testing It Out + +Next, update our ``main`` block to get the geojson for each result and print +it: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + if __name__ == '__main__': + for result in result_generator(10): + geojson = get_geojson(result) + print geojson + + Then test your results by running your script: + + .. code-block:: bash + + (soupenv)$ python mashup.py + {'geometry': {'type': 'Point', 'coordinates': [-122.3393005, 47.6134378]}, + 'type': 'Feature', 'properties': {'neighborhood': 'Belltown', + 'encoding': 'utf-8', 'county': 'King County', 'city_long': 'Seattle', + 'lng': -122.3393005, 'quality': u'street_address', 'city': 'Seattle', + 'confidence': 9, 'state': 'WA', 'location': u'1933 5TH AVE SEATTLE, WA 98101', + 'provider': 'google', 'housenumber': '1933', 'accuracy': 'ROOFTOP', + 'status': 'OK', 'state_long': 'Washington', + 'address': '1933 5th Avenue, Seattle, WA 98101, USA', 'lat': 47.6134378, + 'postal': '98101', 'ok': True, 'road_long': '5th Avenue', 'country': 'US', + 'country_long': 'United States', 'street': '5th Ave'}, + 'bbox': [-122.3406494802915, 47.6120888197085, -122.3379515197085, 47.6147867802915]} + +.. nextslide:: Update Geojson Properties + +The ``properties`` of our geojson records are filled with data we don't really +care about. + +.. rst-class:: build +.. container:: + + Let's replace that information with some of the metadata from our + inspection results. + + We'll update our ``get_geojson`` function so that it: + + .. rst-class:: build + + * Builds a dictionary containing only the values we want from our + inspection record. + * Converts list values to strings (geojson requires this) + * Replaces the 'properties' of our geojson with this new data + * Returns the modified geojson record + +.. nextslide:: Write the Function + +See if you can make the updates on your own. + +.. rst-class:: build +.. code-block:: python + + def get_geojson(result): + # ... + geocoded = geocoder.google(address) + geojson = geocoded.geojson + inspection_data = {} + use_keys = ( + 'Business Name', 'Average Score', 'Total Inspections', 'High Score' + ) + for key, val in result.items(): + if key not in use_keys: + continue + if isinstance(val, list): + val = " ".join(val) + inspection_data[key] = val + geojson['properties'] = inspection_data + return geojson + +.. nextslide:: Making Mappable Data + +We are now generating a series of ``geojson`` *Feature* objects. + +.. rst-class:: build +.. container:: + + To map these objects, we'll need to create a file which contains a + ``geojson`` *FeatureCollection*. + + The structure of such a collection looks like this: + + .. code-block:: json + + {'type': 'FeatureCollection', 'features': [...]} + + Let's update our ``main`` function to append each feature to such a + structure. + + Then we can dump the structure as ``json`` to a file. + +.. nextslide:: Update the Script + +In ``mashup.py`` update the ``main`` block like so: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + # add an import at the top: + import json + + if __name__ == '__main__': + total_result = {'type': 'FeatureCollection', 'features': []} + for result in result_generator(10): + geojson = get_geojson(result) + total_result['features'].append(geojson) + with open('my_map.json', 'w') as fh: + json.dump(total_result, fh) + + When you run the script nothing will print, but the new file will appear. + + .. code-block:: bash + + (soupenv)$ python mashup.py + + This script is available as ``resources/session04/mashup_5.py`` + +Display the Results +------------------- + +Once the new file is written you are ready to display your results. + +.. rst-class:: build +.. container:: + + Open your web browser and go to http://geojson.io + + Then drag and drop the new file you wrote onto the map you see there. + + .. figure:: /_static/geojson-io.png + :align: center + :width: 75% + +Wrap Up +------- + +We've built a simple mashup combining data from different sources. + +.. rst-class:: build +.. container:: + + We scraped health inspection data from the King County government site. + + We geocoded that data. + + And we've displayed the results on a map. + + What other sources of data might we choose to combine? + + Check out `programmable web `_ + to see some of the possibilities + + + + +Homework +======== + +.. rst-class:: left +.. container:: + + For your homework this week, you'll be polishing this mashup. + + .. rst-class:: build + .. container:: + + Begin by sorting the results of our search by the average score (can + you do this and still use a generator for getting the geojson?). + + Then, update your script to allow the user to choose how to sort, by + average, high score or most inspections:: + + (soupenv)$ python mashup.py highscore + + Next, allow the user to choose how many results to map:: + + (soupenv)$ python mashup.py highscore 25 + + Or allow them to reverse the results, showing the lowest scores first:: + + (soupenv)$ python mashup.py highscore 25 reverse + + If you're feeling particularly adventurous, see if you can use the + `argparse`_ module from the standard library to handle command line + arguments + +.. _argparse: https://docs.python.org/2/library/argparse.html#module-argparse + +More Fun +-------- + +Next, try adding a bit of information to your map by setting the +``marker-color`` property. This will display a marker with the provided +css-style color (``#FF0000``) + +.. rst-class:: build +.. container:: + + See if you can make the color change according to the values used for the + sorting of the list. Either vary the intensity of the color, or the hue. + + Finally, if you are feeling particularly frisky, you can update your script + to automatically open a browser window with your map loaded on + *geojson.io*. + + To do this, you'll want to read about the `webbrowser`_ module from the + standard library. + + In addition, you'll want to read up on using the URL parameters API for + *geojson.io*. Click on the **help** tab in the sidebar to view the + information. + + You will also need to learn about how to properly quote special characters + for a URL, using the `urllib.parse`_ ``quote`` function. + +.. _urllib.parse: https://docs.python.org/3/library/urllib.parse.html#urllib.parse.quote +.. _webbrowser: https://docs.python.org/3/library/webbrowser.html + +Submitting Your Work +-------------------- + +Create a github repository to contain your mashup work. Start by populating it +with the script as we finished it today (mashup_5.py). + +As you implement the above features, commit early and commit often. + +When you're ready for us to look it over, email a link to your repository to +Maria and I. + diff --git a/source/presentations/session04.rst.norender b/source/presentations/session04.rst.norender deleted file mode 100644 index 39b36076..00000000 --- a/source/presentations/session04.rst.norender +++ /dev/null @@ -1,1127 +0,0 @@ -Python Web Programming -====================== - -.. image:: img/python.png - :align: left - :width: 33% - -Session 1: Networking and Sockets - - -Computer Communications ------------------------ - -.. image:: img/network_topology.png - :align: left - :width: 40% - -.. class:: incremental - -* processes can communicate - -* inside one machine - -* between two machines - -* among many machines - -.. class:: image-credit - -image: http://en.wikipedia.org/wiki/Internet_Protocol_Suite - - -Computer Communications ------------------------ - -.. image:: img/data_in_tcpip_stack.png - :align: left - :width: 55% - -.. class:: incremental - -* Process divided into 'layers' - -* 'Layers' are mostly arbitrary - -* Different descriptions have different layers - -* Most common is the 'TCP/IP Stack' - -.. class:: image-credit - -image: http://en.wikipedia.org/wiki/Internet_Protocol_Suite - - -The TCP/IP Stack - Link ------------------------ - -The bottom layer is the 'Link Layer' - -.. class:: incremental - -* Deals with the physical connections between machines, 'the wire' - -* Packages data for physical transport - -* Executes transmission over a physical medium - - * what that medium is is arbitrary - -* Implemented in the Network Interface Card(s) (NIC) in your computer - - -The TCP/IP Stack - Internet ---------------------------- - -Moving up, we have the 'Internet Layer' - -.. class:: incremental - -* Deals with addressing and routing - - * Where are we going and how do we get there? - -* Agnostic as to physical medium (IP over Avian Carrier - IPoAC) - -* Makes no promises of reliability - -* Two addressing systems - - .. class:: incremental - - * IPv4 (current, limited '192.168.1.100') - - * IPv6 (future, 3.4 x 10^38 addresses, '2001:0db8:85a3:0042:0000:8a2e:0370:7334') - - -The TCP/IP Stack - Internet ---------------------------- - -.. class:: big-centered - -That's 4.3 x 10^28 addresses *per person alive today* - - -The TCP/IP Stack - Transport ----------------------------- - -Next up is the 'Transport Layer' - -.. class:: incremental - -* Deals with transmission and reception of data - - * error correction, flow control, congestion management - -* Common protocols include TCP & UDP - - * TCP: Tranmission Control Protocol - - * UDP: User Datagram Protocol - -* Not all Transport Protocols are 'reliable' - - .. class:: incremental - - * TCP ensures that dropped packets are resent - - * UDP makes no such assurance - - * Reliability is slow and expensive - - -The TCP/IP Stack - Transport ----------------------------- - -The 'Transport Layer' also establishes the concept of a **port** - -.. class:: incremental - -* IP Addresses designate a specific *machine* on the network - -* A **port** provides addressing for individual *applications* in a single host - -* 192.168.1.100:80 (the *:80* part is the **port**) - -* [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 (*:443* is the **port**) - -.. class:: incremental - -This means that you don't have to worry about information intended for your -web browser being accidentally read by your email client. - - -The TCP/IP Stack - Transport ----------------------------- - -There are certain **ports** which are commonly understood to belong to given -applications or protocols: - -.. class:: incremental - -* 80/443 - HTTP/HTTPS -* 20 - FTP -* 22 - SSH -* 23 - Telnet -* 25 - SMTP -* ... - -.. class:: incremental - -These ports are often referred to as **well-known ports** - -.. class:: small - -(see http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers) - - -The TCP/IP Stack - Transport ----------------------------- - -Ports are grouped into a few different classes - -.. class:: incremental - -* Ports numbered 0 - 1023 are *reserved* - -* Ports numbered 1024 - 65535 are *open* - -* Ports numbered 1024 - 49151 may be *registered* - -* Ports numbered 49152 - 65535 are called *ephemeral* - - -The TCP/IP Stack - Application ------------------------------- - -The topmost layer is the 'Application Layer' - -.. class:: incremental - -* Deals directly with data produced or consumed by an application - -* Reads or writes data using a set of understood, well-defined **protocols** - - * HTTP, SMTP, FTP etc. - -* Does not know (or need to know) about lower layer functionality - - * The exception to this rule is **endpoint** data (or IP:Port) - - -The TCP/IP Stack - Application ------------------------------- - -.. class:: big-centered - -this is where we live and work - - -Sockets -------- - -Think back for a second to what we just finished discussing, the TCP/IP stack. - -.. class:: incremental - -* The *Internet* layer gives us an **IP Address** - -* The *Transport* layer establishes the idea of a **port**. - -* The *Application* layer doesn't care about what happens below... - -* *Except for* **endpoint data** (IP:Port) - -.. class:: incremental - -A **Socket** is the software representation of that endpoint. - -.. class:: incremental - -Opening a **socket** creates a kind of transceiver that can send and/or -receive *bytes* at a given IP address and Port. - - -Sockets in Python ------------------ - -Python provides a standard library module which provides socket functionality. -It is called **socket**. - -.. class:: incremental - -The library is really just a very thin wrapper around the system -implementation of *BSD Sockets* - -.. class:: incremental - -Let's spend a few minutes getting to know this module. - -.. class:: incremental - -We're going to do this next part together, so open up a terminal and start a -python interpreter - - -Sockets in Python ------------------ - -The Python sockets library allows us to find out what port a *service* uses: - -.. class:: small - - >>> import socket - >>> socket.getservbyname('ssh') - 22 - -.. class:: incremental - -You can also do a *reverse lookup*, finding what service uses a given *port*: - -.. class:: incremental small - - >>> socket.getservbyport(80) - 'http' - - -Sockets in Python ------------------ - -The sockets library also provides tools for finding out information about -*hosts*. For example, you can find out about the hostname and IP address of -the machine you are currently using:: - - >>> socket.gethostname() - 'heffalump.local' - >>> socket.gethostbyname(socket.gethostname()) - '10.211.55.2' - - -Sockets in Python ------------------ - -You can also find out about machines that are located elsewhere, assuming you -know their hostname. For example:: - - >>> socket.gethostbyname('google.com') - '173.194.33.4' - >>> socket.gethostbyname('uw.edu') - '128.95.155.135' - >>> socket.gethostbyname('crisewing.com') - '108.59.11.99' - - -Sockets in Python ------------------ - -The ``gethostbyname_ex`` method of the ``socket`` library provides more -information about the machines we are exploring:: - - >>> socket.gethostbyname_ex('google.com') - ('google.com', [], ['173.194.33.9', '173.194.33.14', - ... - '173.194.33.6', '173.194.33.7', - '173.194.33.8']) - >>> socket.gethostbyname_ex('crisewing.com') - ('crisewing.com', [], ['108.59.11.99']) - >>> socket.gethostbyname_ex('www.rad.washington.edu') - ('elladan.rad.washington.edu', # <- canonical hostname - ['www.rad.washington.edu'], # <- any machine aliases - ['128.95.247.84']) # <- all active IP addresses - - -Sockets in Python ------------------ - -To create a socket, you use the **socket** method of the ``socket`` library. -It takes up to three optional positional arguments (here we use none to get -the default behavior):: - - >>> foo = socket.socket() - >>> foo - - - -Sockets in Python ------------------ - -A socket has some properties that are immediately important to us. These -include the *family*, *type* and *protocol* of the socket:: - - >>> foo.family - 2 - >>> foo.type - 1 - >>> foo.proto - 0 - -.. class:: incremental - -You might notice that the values for these properties are integers. In fact, -these integers are **constants** defined in the socket library. - - -A quick utility method ----------------------- - -Let's define a method in place to help us see these constants. It will take a -single argument, the shared prefix for a defined set of constants: - -.. class:: small - -:: - - >>> def get_constants(prefix): - ... """mapping of socket module constants to their names.""" - ... return dict( - ... (getattr(socket, n), n) - ... for n in dir(socket) - ... if n.startswith(prefix) - ... ) - ... - >>> - -.. class:: small - -(you can also find this in ``resources/session01/session1.py``) - - -Socket Families ---------------- - -Think back a moment to our discussion of the *Internet* layer of the TCP/IP -stack. There were a couple of different types of IP addresses: - -.. class:: incremental - -* IPv4 ('192.168.1.100') - -* IPv6 ('2001:0db8:85a3:0042:0000:8a2e:0370:7334') - -.. class:: incremental - -The **family** of a socket corresponds to the *addressing system* it uses for -connecting. - - -Socket Families ---------------- - -Families defined in the ``socket`` library are prefixed by ``AF_``:: - - >>> families = get_constants('AF_') - >>> families - {0: 'AF_UNSPEC', 1: 'AF_UNIX', 2: 'AF_INET', - 11: 'AF_SNA', 12: 'AF_DECnet', 16: 'AF_APPLETALK', - 17: 'AF_ROUTE', 23: 'AF_IPX', 30: 'AF_INET6'} - -.. class:: small incremental - -*Your results may vary* - -.. class:: incremental - -Of all of these, the ones we care most about are ``2`` (IPv4) and ``30`` (IPv6). - - -Unix Domain Sockets -------------------- - -When you are on a machine with an operating system that is Unix-like, you will -find another generally useful socket family: ``AF_UNIX``, or Unix Domain -Sockets. Sockets in this family: - -.. class:: incremental - -* connect processes **on the same machine** - -* are generally a bit slower than IPC connnections - -* have the benefit of allowing the same API for programs that might run on one - machine __or__ across the network - -* use an 'address' that looks like a pathname ('/tmp/foo.sock') - - -Test your skills ----------------- - -What is the *default* family for the socket we created just a moment ago? - -.. class:: incremental - -(remember we bound the socket to the symbol ``foo``) - -.. class:: incremental center - -How did you figure this out? - - -Socket Types ------------- - -The socket *type* determines the semantics of socket communications. - -Look up socket type constants with the ``SOCK_`` prefix:: - - >>> types = get_constants('SOCK_') - >>> types - {1: 'SOCK_STREAM', 2: 'SOCK_DGRAM', - ...} - -.. class:: incremental - -The most common are ``1`` (Stream communication (TCP)) and ``2`` (Datagram -communication (UDP)). - - -Test your skills ----------------- - -What is the *default* type for our generic socket, ``foo``? - - -Socket Protocols ----------------- - -A socket also has a designated *protocol*. The constants for these are -prefixed by ``IPPROTO_``:: - - >>> protocols = get_constants('IPPROTO_') - >>> protocols - {0: 'IPPROTO_IP', 1: 'IPPROTO_ICMP', - ..., - 255: 'IPPROTO_RAW'} - -.. class:: incremental - -The choice of which protocol to use for a socket is determined by the -*internet layer* protocol you intend to use. ``TCP``? ``UDP``? ``ICMP``? -``IGMP``? - - -Test your skills ----------------- - -What is the *default* protocol used by our generic socket, ``foo``? - - -Custom Sockets --------------- - -These three properties of a socket correspond to the three positional -arguments you may pass to the socket constructor. - -.. container:: incremental - - Using them allows you to create sockets with specific communications - profiles:: - - >>> bar = socket.socket(socket.AF_INET, - ... socket.SOCK_DGRAM, - ... socket.IPPROTO_UDP) - ... - >>> bar - - - -Break Time ----------- - -So far we have: - -.. class:: incremental - -* learned about the "layers" of the TCP/IP Stack -* discussed *families*, *types* and *protocols* in sockets -* learned how to create sockets with a specific communications profile. - -.. class:: incremental - -When we return we'll learn how to find the communcations profiles of remote -sockets, how to connect to them, and how to send and receive messages. - -.. class:: incremental - -Take a few minutes now to clear your head (do not quit your python -interpreter). - - -Address Information -------------------- - -When you are creating a socket to communicate with a remote service, the -remote socket will have a specific communications profile. - -.. class:: incremental - -The local socket you create must match that communications profile. - -.. class:: incremental - -How can you determine the *correct* values to use? - -.. class:: incremental center - -You ask. - - -Address Information -------------------- - -The function ``socket.getaddrinfo`` provides information about available -connections on a given host. - -.. code-block:: python - :class: small - - socket.getaddrinfo('127.0.0.1', 80) - -.. class:: incremental - -This provides all you need to make a proper connection to a socket on a remote -host. The value returned is a tuple of: - -.. class:: incremental - -* socket family -* socket type -* socket protocol -* canonical name (usually empty, unless requested by flag) -* socket address (tuple of IP and Port) - - -A quick utility method ----------------------- - -Again, let's create a utility method in-place so we can see this in action: - -.. class:: small - -:: - - >>> def get_address_info(host, port): - ... for response in socket.getaddrinfo(host, port): - ... fam, typ, pro, nam, add = response - ... print 'family: ', families[fam] - ... print 'type: ', types[typ] - ... print 'protocol: ', protocols[pro] - ... print 'canonical name: ', nam - ... print 'socket address: ', add - ... print - ... - >>> - -.. class:: small - -(you can also find this in ``resources/session01/session1.py``) - - -On Your Own Machine -------------------- - -Now, ask your own machine what possible connections are available for 'http':: - - >>> get_address_info(socket.gethostname(), 'http') - family: AF_INET - type: SOCK_DGRAM - protocol: IPPROTO_UDP - canonical name: - socket address: ('10.211.55.2', 80) - - family: AF_INET - ... - >>> - -.. class:: incremental - -What answers do you get? - - -On the Internet ---------------- - -:: - - >>> get_address_info('crisewing.com', 'http') - family: AF_INET - type: SOCK_DGRAM - ... - - family: AF_INET - type: SOCK_STREAM - ... - >>> - -.. class:: incremental - -Try a few other servers you know about. - - -First Steps ------------ - -.. class:: big-centered - -Let's put this to use - - -Construct a Socket ------------------- - -We've already made a socket ``foo`` using the generic constructor without any -arguments. We can make a better one now by using real address information from -a real server online [**do not type this yet**]: - -.. class:: small - -:: - - >>> streams = [info - ... for info in socket.getaddrinfo('crisewing.com', 'http') - ... if info[1] == socket.SOCK_STREAM] - >>> streams - [(2, 1, 6, '', ('108.59.11.99', 80))] - >>> info = streams[0] - >>> cewing_socket = socket.socket(*info[:3]) - - -Connecting a Socket -------------------- - -Once the socket is constructed with the appropriate *family*, *type* and -*protocol*, we can connect it to the address of our remote server:: - - >>> cewing_socket.connect(info[-1]) - >>> - -.. class:: incremental - -* a successful connection returns ``None`` - -* a failed connection raises an error - -* you can use the *type* of error returned to tell why the connection failed. - - -Sending a Message ------------------ - -Send a message to the server on the other end of our connection (we'll -learn in session 2 about the message we are sending):: - - >>> msg = "GET / HTTP/1.1\r\n" - >>> msg += "Host: crisewing.com\r\n\r\n" - >>> cewing_socket.sendall(msg) - >>> - -.. class:: incremental small - -* the transmission continues until all data is sent or an error occurs - -* success returns ``None`` - -* failure to send raises an error - -* you can use the type of error to figure out why the transmission failed - -* if an error occurs you **cannot** know how much, if any, of your data was - sent - - -Receiving a Reply ------------------ - -Whatever reply we get is received by the socket we created. We can read it -back out (again, **do not type this yet**):: - - >>> response = cewing_socket.recv(4096) - >>> response - 'HTTP/1.1 200 OK\r\nDate: Thu, 03 Jan 2013 05:56:53 - ... - -.. class:: incremental small - -* The sole required argument is ``buffer_size`` (an integer). It should be a - power of 2 and smallish (~4096) -* It returns a byte string of ``buffer_size`` (or smaller if less data was - received) -* If the response is longer than ``buffer size``, you can call the method - repeatedly. The last bunch will be less than ``buffer size``. - - -Cleaning Up ------------ - -When you are finished with a connection, you should always close it:: - - >>> cewing_socket.close() - - -Putting it all together ------------------------ - -First, connect and send a message: - -.. class:: small - -:: - - >>> streams = [info - ... for info in socket.getaddrinfo('crisewing.com', 'http') - ... if info[1] == socket.SOCK_STREAM] - >>> info = streams[0] - >>> cewing_socket = socket.socket(*info[:3]) - >>> cewing_socket.connect(info[-1]) - >>> msg = "GET / HTTP/1.1\r\n" - >>> msg += "Host: crisewing.com\r\n\r\n" - >>> cewing_socket.sendall(msg) - - -Putting it all together ------------------------ - -Then, receive a reply, iterating until it is complete: - -:: - - >>> buffsize = 4096 - >>> response = '' - >>> done = False - >>> while not done: - ... msg_part = cewing_socket.recv(buffsize) - ... if len(msg_part) < buffsize: - ... done = True - ... cewing_socket.close() - ... response += msg_part - ... - >>> len(response) - 19427 - - -Server Side ------------ - -.. class:: big-centered - -What about the other half of the equation? - -Construct a Socket ------------------- - -**For the moment, stop typing this into your interpreter.** - -.. container:: incremental - - Again, we begin by constructing a socket. Since we are actually the server - this time, we get to choose family, type and protocol:: - - >>> server_socket = socket.socket( - ... socket.AF_INET, - ... socket.SOCK_STREAM, - ... socket.IPPROTO_TCP) - ... - >>> server_socket - - - -Bind the Socket ---------------- - -Our server socket needs to be bound to an address. This is the IP Address and -Port to which clients must connect:: - - >>> address = ('127.0.0.1', 50000) - >>> server_socket.bind(address) - -.. class:: incremental - -**Terminology Note**: In a server/client relationship, the server *binds* to -an address and port. The client *connects* - - -Listen for Connections ----------------------- - -Once our socket is bound to an address, we can listen for attempted -connections:: - - >>> server_socket.listen(1) - -.. class:: incremental - -* The argument to ``listen`` is the *backlog* - -* The *backlog* is the **maximum** number of connection requests that the - socket will queue - -* Once the limit is reached, the socket refuses new connections. - - -Accept Incoming Messages ------------------------- - -When a socket is listening, it can receive incoming connection requests:: - - >>> connection, client_address = server_socket.accept() - ... # this blocks until a client connects - >>> connection.recv(16) - -.. class:: incremental - -* The ``connection`` returned by a call to ``accept`` is a **new socket**. - This new socket is used to communicate with the client - -* The ``client_address`` is a two-tuple of IP Address and Port for the client - socket - -* When a connection request is 'accepted', it is removed from the backlog - queue. - - -Send a Reply ------------- - -The same socket that received a message from the client may be used to return -a reply:: - - >>> connection.sendall("message received") - - -Clean Up --------- - -Once a transaction between the client and server is complete, the -``connection`` socket should be closed:: - - >>> connection.close() - -.. class:: incremental - -Note that the ``server_socket`` is *never* closed as long as the server -continues to run. - - -Getting the Flow ----------------- - -The flow of this interaction can be a bit confusing. Let's see it in action -step-by-step. - -.. class:: incremental - -Open a second python interpreter and place it next to your first so you can -see both of them at the same time. - - -Create a Server ---------------- - -In your first python interpreter, create a server socket and prepare it for -connections:: - - >>> server_socket = socket.socket( - ... socket.AF_INET, - ... socket.SOCK_STREAM, - ... socket.IPPROTO_IP) - >>> server_socket.bind(('127.0.0.1', 50000)) - >>> server_socket.listen(1) - >>> conn, addr = server_socket.accept() - -.. class:: incremental - -At this point, you should **not** get back a prompt. The server socket is -waiting for a connection to be made. - - -Create a Client ---------------- - -In your second interpreter, create a client socket and prepare to send a -message:: - - >>> import socket - >>> client_socket = socket.socket( - ... socket.AF_INET, - ... socket.SOCK_STREAM, - ... socket.IPPROTO_IP) - -.. container:: incremental - - Before connecting, keep your eye on the server interpreter:: - - >>> client_socket.connect(('127.0.0.1', 50000)) - - -Send a Message Client->Server ------------------------------ - -As soon as you made the connection above, you should have seen the prompt -return in your server interpreter. The ``accept`` method finally returned a -new connection socket. - -.. class:: incremental - -When you're ready, type the following in the *client* interpreter. - -.. class:: incremental - -:: - - >>> client_socket.sendall("Hey, can you hear me?") - - -Receive and Respond -------------------- - -Back in your server interpreter, go ahead and receive the message from your -client:: - - >>> conn.recv(32) - 'Hey, can you hear me?' - -Send a message back, and then close up your connection:: - - >>> conn.sendall("Yes, I hear you.") - >>> conn.close() - - -Finish Up ---------- - -Back in your client interpreter, take a look at the response to your message, -then be sure to close your client socket too:: - - >>> client_socket.recv(32) - 'Yes, I hear you.' - >>> client_socket.close() - -And now that we're done, we can close up the server too (back in the server -interpreter):: - - >>> server_socket.close() - - -Congratulations! ----------------- - -.. class:: big-centered - -You've run your first client-server interaction - - -Homework --------- - -Your homework assignment for this week is to take what you've learned here -and build a simple "echo" server. - -.. class:: incremental - -The server should automatically return to any client that connects *exactly* -what it receives (it should **echo** all messages). - -.. class:: incremental - -You will also write a python script that, when run, will send a message to the -server and receive the reply, printing it to ``stdout``. - -.. class:: incremental - -Finally, you'll do all of this so that it can be tested. - - -What You Have -------------- - -In our class repository, there is a folder ``assignments/session01``. - -.. class:: incremental - -Inside that folder, you should find: - -.. class:: incremental - -* A file ``tasks.txt`` that contains these instructions - -* A skeleton for your server in ``echo_server.py`` - -* A skeleton for your client script in ``echo_client.py`` - -* Some simple tests in ``tests.py`` - -.. class:: incremental - -Your task is to make the tests pass. - - -Running the tests ------------------ - -To run the tests, you'll have to set the server running in one terminal: - -.. class:: small - -:: - - $ python echo_server.py - -.. container:: incremental - - Then, in a second terminal, you will execute the tests: - - .. class:: small - - :: - - $ python tests.py - -.. container:: incremental - - You should see output like this: - - .. class:: small - - :: - - [...] - FAILED (failures=2) - - -Submitting Your Homework ------------------------- - -To submit your homework: - -.. class:: incremental - -* In github, make a fork of my repository into *your* account. - -* Clone your fork of my repository to your computer. - -* Do your work in the ``assignments/session01/`` folder on your computer and - commit your changes to your fork. - -* When you are finished and your tests are passing, you will open a pull - request in github.com from your fork to mine. - -.. class:: incremental - -I will review your work when I receive your pull requests, make comments on it -there, and then close the pull request. - - -Going Further -------------- - -In ``assignments/session01/tasks.txt`` you'll find a few extra problems to try. - -.. class:: incremental - -If you finish the first part of the homework in less than 3-4 hours give one -or more of these a whirl. - -.. class:: incremental - -They are not required, but if you include solutions in your pull request, I'll -review your work. diff --git a/source/presentations/session05.rst b/source/presentations/session05.rst new file mode 100644 index 00000000..4e153be2 --- /dev/null +++ b/source/presentations/session05.rst @@ -0,0 +1,1523 @@ +.. slideconf:: + :autoslides: True + +********** +Session 05 +********** + +.. image:: /_static/python.png + :align: center + :width: 43% + + + +MVC Applications +================ + +Wherin we learn about the Model View Controller approach to app design and +explore data persistence in Python. + +.. figure:: http://upload.wikimedia.org/wikipedia/commons/4/40/MVC_passive_view.png + :align: center + :width: 40% + + By Alan Evangelista (Own work) [CC0], via Wikimedia Commons + +Separation of Concerns +---------------------- + +.. rst-class:: build +.. container:: + + In the first part of this course, you were introduced to the concept of + *Object Oriented Programming* + + OOP was `first formalized`_ in the 1970s in *Smalltalk*, invented by Alan + Kay at *Xerox PARC* + + *Smalltalk* was also the first language which utilized the + `Model View Controller`_ design pattern. + + This pattern (like all `design patterns`_) seeks to provide a *way of + thinking* that helps to make software design easier. + + In this case, the goal is to help clarify the high-level *separation of + concerns* in a system. + +.. _first formalized: http://en.wikipedia.org/wiki/Object-oriented_programming#History +.. _Model View Controller: http://en.wikipedia.org/wiki/Model–view–controller +.. _design patterns: http://en.wikipedia.org/wiki/Software_design_pattern + +Three Components +---------------- + +The pattern divides the elements of a system into three parts: + +.. rst-class:: build + +Model: + This component represents the *data* that comprises the system, and the + *logic* used to manipulate that data. + +View: + This component can be any *representation* of the data to the outside world: + a chart, diagram, table, user interface, etc. + + It also includes representations of the *actions* available in the system. + +Controller: + This component coordinates the Model and the View in a system. + + It accepts input from a user and channels that input into the Model. + + It accepts information about the current state of the Model and transmits + that information to the View. + +On the Web +---------- + +This pattern has proven useful for thinking about the applications we build for +the web. + +.. rst-class:: build +.. container:: + + A web browser provides a convenient container for *views* of data. + + These *views* are created by *controller* software hosted on a server. + + This *controller* software accepts input from users via *HTTP requests*, + channeling it into a *data model*, often stored in some database. + + The *controller* returns information about the state of the *data model* to + the user via *HTTP responses* + +.. nextslide:: + +This approach is so common, that it has been formalized into any number of *web +frameworks* + +.. rst-class:: build +.. container:: + + *Web frameworks* abstract away the specifics of the *HTTP request/response + cycle*, leaving simple MVC components for the developer to use. + + *Web frameworks* exist in nearly all modern languages. + + Python has scores of them. + + Over the weeks to come, we'll learn about two of them, `Pyramid`_ and + `Django`_. + +.. _Pyramid: http://www.pylonsproject.org/projects/pyramid/about +.. _Django: https://www.djangoproject.com/ + +A Word About Terminology +------------------------ + +Although the MVC pattern is a useful abstraction, there are a few differences +in how things are named in Python web frameworks + +.. rst-class:: build centered +.. container:: + + model <--> model + + controller <--> view + + view <--> template (or even HTTP response) + + .. rst-class:: left + + For more on this difference, you can `read this`_ from the Pyramid design + documentation. + +.. _read this: http://docs.pylonsproject.org/projects/pyramid/en/latest/designdefense.html#pyramid-gets-its-terminology-wrong-mvc + +Our First Application +===================== + +.. rst-class:: left + +But enough abstract blabbering. + +.. rst-class:: build left +.. container:: + + There's no better way to make concepts like these concrete than to build + something using them. + + Let's make an application! + + We're going to build a Learning Journal. + + When we're done, you'll have a live, online application you can use to keep + note of the things you are learning about Python development. + + We'll use one of our Python web framework to do this: `Pyramid`_ + +Pyramid +------- + +First published in 2010, `Pyramid`_ is a powerful, flexible web framework. + +.. rst-class:: build +.. container:: + + You can create compelling one-page applications, much like in + microframeworks like Flask + + You can also create powerful, scalable applications using the full + power of Python + + Created by the combined powers of the teams behind Pylons and Zope + + It represents the first true second-generation web framework in + existence. + +Starting the Project +-------------------- + +The first step is to prepare for the project. + +.. rst-class:: build +.. container:: + + Begin by creating a location where you'll do your work. + + I generally put all my work in a folder called ``projects`` in my home + directory: + + .. code-block:: bash + + $ cd + $ mkdir projects + $ cd projects + $ mkdir learning-journal + $ cd learning-journal + $ pwd + /Users/cewing/project/learning-journal + +.. nextslide:: Creating an Environment + +We continue our preparations by creating the virtual environment we will use +for our project. + +.. rst-class:: build +.. container:: + + Again, this will help us to keep our work here isolated from anything else + we do. + + Remember how to make a new venv? + + .. code-block:: bash + + $ pyvenv ljenv + + .. code-block:: posh + + c:\Temp>python -m venv myenv + + And then, how to activate it? + + .. code-block:: bash + + $ source ljenv/bin/activate + (ljenv)$ + + .. code-block:: posh + + C:> ljenv/Scripts/activate.bat + +.. nextslide:: Installing Pyramid + +Next, we install the Pyramid web framework into our new virtualenv. + +.. rst-class:: build +.. container:: + + We can do this with the ``pip`` in our active ``ljenv``: + + .. code-block:: bash + + (ljenv)$ pip install pyramid + Collecting pyramid + Downloading pyramid-1.5.2-py2.py3-none-any.whl (545kB) + 100% |################################| 548kB 172kB/s + ... + Successfully installed PasteDeploy-1.5.2 WebOb-1.4 + pyramid-1.5.2 repoze.lru-0.6 translationstring-1.3 + venusian-1.0 zope.deprecation-4.1.1 zope.interface-4.1.2 + + Once that is complete, we are ready to create a *scaffold* for our project. + +Working with Pyramid +-------------------- + +Many web frameworks require at least a bit of *boilerplate* code to get +started. + +.. rst-class:: build +.. container:: + + Pyramid does not. + + However, our application will require a database and handling that does + require some. + + Pyramid provides a system for creating boilerplate called ``pcreate``. + + You use it to generate the skeleton for a project based on some pattern: + + .. code-block:: bash + + (ljenv)$ pcreate -s alchemy learning_journal + Creating directory /Users/cewing/projects/learning-journal/learning_journal + ... + Welcome to Pyramid. Sorry for the convenience. + =============================================================================== + + Let's take a quick look at what that did + +.. nextslide:: What You Get + +.. code-block:: bash + + ... + ├── development.ini + ├── learning_journal + │   ├── __init__.py + │   ├── models.py + │   ├── scripts + │   │   ├── __init__.py + │   │   └── initializedb.py + │   ├── static + ... + │   ├── templates + │   │   └── mytemplate.pt + │   ├── tests.py + │   └── views.py + ├── production.ini + └── setup.py + +.. nextslide:: Saving Your Work + +You've now created something worth saving. + +.. rst-class:: build +.. container:: + + Start by initializing a new git repository in the `learning_journal` folder + you just created: + + .. code-block:: bash + + (ljenv)$ cd learning_journal + (ljenv)$ git init + Initialized empty Git repository in + /Users/cewing/projects/learning-journal/learning_journal/.git/ + +.. nextslide:: Saving Your Work + +Check ``git status`` to see where things stand: + +.. code-block:: bash + + (ljenv)$ git status + On branch master + + Initial commit + + Untracked files: + (use "git add ..." to include in what will be committed) + + CHANGES.txt + MANIFEST.in + README.txt + development.ini + learning_journal/ + production.ini + setup.py + +.. nextslide:: Add the Project Code + +Add your work to this new repository: + +.. code-block:: bash + + (ljenv)$ git add . + (ljenv)$ git status + ... + Changes to be committed: + (use "git rm --cached ..." to unstage) + + new file: CHANGES.txt + new file: MANIFEST.in + ... + new file: production.ini + new file: setup.py + +.. nextslide:: Ignore Irrelevant Files + +Python creates ``.pyc`` files when it executes your code. + +.. rst-class:: build +.. container:: + + There are many other files you don't want or need in your repository + + You can ignore this in ``git`` with the ``.gitignore`` file. + + Create one now, in this same directory, and add the following basic lines:: + + *.pyc + .DS_Store + + Finally, add this new file to your repository, too. + + .. code-block:: bash + + (ljenv)$ git add .gitignore + +.. nextslide:: Make It Permanent + +To preserve all these changes, you'll need to commit what you've done: + +.. code-block:: bash + + (ljenv)$ git commit -m "initial commit of the Pyramid learning journal" + +.. rst-class:: build +.. container:: + + This will make a first commit here in this local repository. + + For homework, you'll put this into GitHub, but this is enough for now. + + Let's move on to learning about what we've built so far. + +.. nextslide:: Project Structure + +When you ran the ``pcreate`` command, a new folder was created: +``learning_journal``. + +.. rst-class:: build +.. container:: + + This folder contains your *project*. + + At the top level, you have *configuration* (.ini files) + + You also have a file called ``setup.py`` + + This file turns this collection of Python code and configuration into an + *installable Python distribution* + + Let's take a moment to look over the code in that file + +.. nextslide:: ``setup.py`` + +.. code-block:: python + + from setuptools import setup, find_packages + ... + requires = [ + 'pyramid', + ... # packages on which this software depends (dependencies) + ] + setup(name='learning_journal', + version='0.0', + ... # package metadata (used by PyPI) + install_requires=requires, + # Entry points are ways that we can run our code once installed + entry_points="""\ + [paste.app_factory] + main = learning_journal:main + [console_scripts] + initialize_learning_journal_db = learning_journal.scripts.initializedb:main + """, + ) + +Pyramid is Python +----------------- + +In the ``__init__.py`` file of your app *package*, you'll find a ``main`` +function: + +.. code-block:: python + + def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.bind = engine + config = Configurator(settings=settings) + config.include('pyramid_chameleon') + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') + config.scan() + return config.make_wsgi_app() + +Let's take a closer look at this, line by line. + +.. nextslide:: System Configuration + +.. code-block:: python + + def main(global_config, **settings): + +Configuration is passed in to an application after being read from the +``.ini`` file we saw above. + +.. rst-class:: build +.. container:: + + These files contain sections (``[app:main]``) containing ``name = value`` + pairs of *configuration data* + + This data is parsed with the Python + `ConfigParser `_ module. + + The result is a dict of values: + + .. code-block:: python + + {'app:main': {'pyramid.reload_templates': True, ...}, ...} + + The default section of the file is passed in as ``global_config``, the + section for *this app* as ``settings``. + +.. nextslide:: Database Configuration + +.. code-block:: python + + from sqlalchemy import engine_from_config + from .models import DBSession, Base + ... + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.bind = engine + +We will use a package called ``SQLAlchemy`` to interact with our database. + +.. rst-class:: build +.. container:: + + Our connection is set up using settings read from the ``.ini`` file. + + Can you find the settings for the database? + + The ``DBSession`` ensures that each *database transaction* is tied to HTTP + requests. + + The ``Base`` provides a parent class that will hook our *models* to the + database. + +.. nextslide:: App Configuration + +.. code-block:: python + + config = Configurator(settings=settings) + config.include('pyramid_chameleon') + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') + config.scan() + +Pyramid controlls application-level configuration using a ``Configurator`` class. + +.. rst-class:: build +.. container:: + + It uses app-specific settings passed in from the ``.ini`` file + + We can also ``include`` configuration from other add-on packages + + Additionally, we can configure *routes* and *views* needed to connect our + application to the outside world here (more on this next week). + + Finally, the ``Configurator`` instance performs a ``scan`` to ensure there + are no problems with what we've created. + +.. nextslide:: A Last Word on Configuration + +We will return to the configuration of our application repeatedly over the next +sessions. + +.. rst-class:: build +.. container:: + + Pyramid configuration is powerful and flexible. + + We'll use a few of its features + + But there's a lot more you could (and should) learn. + + Read about it in the `configuration chapter`_ of the Pyramid documentation. + +.. _configuration chapter: http://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html + +.. nextslide:: Break Time + +Let's take a moment to rest up and absorb what we've learned. + +When we return, we'll see how we can create *models* that will embody the data +for our Learning Journal application. + +.. rst-class:: centered + +**Pyramid Models** + + +Models in Pyramid +================= + +.. rst-class:: left +.. container:: + + The central component of MVC, the model, captures the behavior of the + application in terms of its problem domain, independent of the user + interface. The model directly manages the data, logic and rules of the + application + + -- from the Wikipedia article on `Model-view-controller`_ + +.. _Model-view-controller: http://en.wikipedia.org/wiki/Model–view–controller + +Models and ORMs +--------------- + +In an MVC application, we define the *problem domain* by creating one or more +*Models*. + +.. rst-class:: build +.. container:: + + These capture relevant details about the information we want to preserve + and how we want to interact with it. + + In Python-based MVC applications, these *Models* are implemented as Python + classes. + + The individual bits of data we want to know about are *attributes* of our + classes. + + The actions we want to take using that data are *methods* of our classes. + + Together, we can refer to this as the *API* of our system. + +.. nextslide:: Persistence + +It's all well and good to have a set of Python classes that represent your +system. + +.. rst-class:: build +.. container:: + + But what happens when you want to *save* information. + + What happens to a instance of a Python class when you quit the interprer? + + When your script stops running? + + The code in a website runs when an HTTP request comes in from a client. + + It stops running when an HTTP response goes back out to the client. + + So what happens to the data in your system in-between these moments? + + The data must be *persisted* + +.. nextslide:: Alternatives + +In the last class from part one of this series, you explored a number of +alternatives for persistence + +.. rst-class:: build + +* Python Literals +* Pickle/Shelf +* Interchange Files (CSV, XML, INI) +* Object Stores (ZODB, Durus) +* NoSQL Databases (MongoDB, CouchDB) +* SQL Databases (sqlite, MySQL, PostgreSQL, Oracle, SQLServer) + +.. rst-class:: build +.. container:: + + Any of these might be useful for certain types of applications. + + On the web, you tend to see two used the most: + + .. rst-class:: build + + * NoSQL + * SQL + +.. nextslide:: Choosing One + +How do you choose one over the other? + +.. rst-class:: build +.. container:: + + In general, the telling factor is going to be how you intend to use your + data. + + In systems where the dominant feature is viewing/interacting with + individual objects, a NoSQL storage solution might be the best way to go. + + In systems with objects that are related to eachother, SQL-based Relational + Databases are a better choice. + + Our system is more like this latter type (trust me on that one for now). + + We'll be using SQL (sqlite to start with). + + +.. nextslide:: Objects and Tables + +So we have a system where our data is captured in Python *objects* + +.. rst-class:: build +.. container:: + + And a storage system where our data must be rendered as database *tables* + + Python provides a specification for interacting directly with databases: + `dbapi2`_ + + And there are multiple Python packages that implement this specification + for various databases: + + .. rst-class:: build + + * sqlite3 + * python-mysql + * psycopg2 + * ... + + With these, you can write SQL to save your Python objects into your + database. + +.. _dbapi2: https://www.python.org/dev/peps/pep-0249/ + +.. nextslide:: ORMs + +But that's a pain. + +.. rst-class:: build +.. container:: + + SQL, while not impossible, is yet another language to learn. + + And there is a viable alternative in using an *Object Relational Manager* + (ORM) + + An ORM provides a layer of *abstraction* between you and SQL + + You instantiate Python objects and set attributes on them + + The ORM handles converting data from these objects into SQL statements (and + back) + +SQLAlchemy +---------- + +In our project we will be using the `SQLAlchemy`_ ORM. + +.. rst-class:: build +.. container:: + + You can find SQLAlchemy among the packages in ``requires`` in ``setup.py`` + in our new ``learning_journal`` package. + + However, we don't yet have that code installed. + + To do so, we will need to "install" our own package + + Make sure your ``ljenv`` virtualenv is active and then type the following: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + running develop + running egg_info + creating learning_journal.egg-info + ... + Finished processing dependencies for learning-journal==0.0 + +.. nextslide:: + +Once that is complete, all the *dependencies* listed in our ``setup.py`` will +be installed. + +.. rst-class:: build +.. container:: + + You can also install the package using ``python setup.py install`` + + But using ``develop`` allows us to continue developing our package without + needing to re-install it every time we change something. + + It is very similar to using the ``-e`` option to ``pip`` + + Now, we'll only need to re-run this command if we change ``setup.py`` + itself. + +.. nextslide:: + +We also need to adjust our ``.gitignore`` file: + +.. rst-class:: build +.. code-block:: bash + + (ljenv)$ git status + ... + Untracked files: + (use "git add ..." to include in what will be committed) + + learning_journal.egg-info/ + +.. rst-class:: build +.. container:: + + The ``egg-info`` directory that was just created is an artifact of + installing a Python egg. + + It should never be committed to a repository. + + Let's add ``*.egg-info`` to our ``.gitignore`` file and then commit that + change + + Remember how? + +.. nextslide:: Our First Model + +Our project skeleton contains up a first, basic model created for us: + +.. code-block:: python + + # in models.py + Base = declarative_base() + + class MyModel(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(Text) + value = Column(Integer) + Index('my_index', MyModel.name, unique=True, mysql_length=255) + +.. _SQLAlchemy: http://docs.sqlalchemy.org/en/rel_0_9/ + +.. rst-class:: build +.. container:: + + Our class inherits from ``Base`` + + We ran into ``Base`` earlier when discussing configuration. + + We were binding it to the database we wanted to use (the ``engine``) + +.. nextslide:: ``Base`` + +Any class we create that inherits from this ``Base`` becomes a *model* + +.. rst-class:: build +.. container:: + + It will be connected through the ORM to a table in our database. + + The name of the table is determined by the ``__tablename__`` special + attribute. + + Other aspects of table configuration can also be controlled through special + attributes + + Instances of the class, once saved, will become rows in the table. + + Attributes of the model that are instances of ``Column`` will become + columns in the table. + + You can learn much more in the `Declarative`_ chapter of the SQLAlchemy docs + +.. _Declarative: http://docs.sqlalchemy.org/en/rel_0_9/orm/extensions/declarative/ + +.. nextslide:: Columns + +Each attribute of your model that will be persisted must be an instance of +`Column`_. + +.. rst-class:: build +.. container:: + + Each instance requires *at least* a specific `data type`_ (such as + Integer). + + Additionally, you can control other aspects of the column such as it being + a primary key. + + In the *declarative* style we are using, the name of the column in the + database will default to the attribute name you assigned. + + If you wish, you may provide a name specifically. It must be the first + argument and must be a string. + +.. _Column: http://docs.sqlalchemy.org/en/rel_0_9/core/metadata.html#sqlalchemy.schema.Column +.. _data type: http://docs.sqlalchemy.org/en/rel_0_9/core/types.html + +Creating The Database +--------------------- + +We have a *model* which allows us to persist Python objects to an SQL database. + +.. rst-class:: build +.. container:: + + But we're still missing one ingredient here. + + We need to create our database, or there will be nowhere for our data to + go. + + Luckily, our ``pcreate`` scaffold also gave us a convenient way to handle + this: + + .. code-block:: python + + # in setup.py + entry_points="""\ + [paste.app_factory] + main = learning_journal:main + [console_scripts] + initialize_learning_journal_db = learning_journal.scripts.initializedb:main + """, + + The ``console_script`` set up as an entry point will help us. + +.. nextslide:: Initializing the Database + +Let's look at that code for a moment. + +.. code-block:: python + + # in scripts/intitalizedb.py + from ..models import DBSession, MyModel, Base + # ... + def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + engine = engine_from_config(settings, 'sqlalchemy.') + DBSession.configure(bind=engine) + Base.metadata.create_all(engine) + with transaction.manager: + model = MyModel(name='one', value=1) + DBSession.add(model) + +.. nextslide:: Console Scripts + +By connecting this function as a ``console script``, our Python package makes +this command available to us when we install it. + +.. rst-class:: build +.. container:: + + When we exectute the script at the command line, we will be running this + function. + + But before we try it out, let's update the name we use so we don't have to + type that whole big mess. + + In ``setup.py`` change ``initialize_learning_journal_db`` to ``setup_db``: + + .. code-block:: python + + entry_points="""\ + [paste.app_factory] + main = learning_journal:main + [console_scripts] + setup_db = learning_journal.scripts.initializedb:main + """, + + Then, as you have changed ``setup.py``, re-install your package: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + ... + +.. nextslide:: Running the Script + +Now that the script has been renamed, let's try it out. + +.. rst-class:: build +.. container:: + + We'll need to provide a configuration file name, let's use + ``development.ini``: + + .. code-block:: bash + + (ljenv)$ setup_db development.ini + 2015-01-05 18:59:55,426 INFO [sqlalchemy.engine.base.Engine][MainThread] SELECT CAST('test plain returns' AS VARCHAR(60)) AS anon_1 + ... + 2015-01-05 18:59:55,434 INFO [sqlalchemy.engine.base.Engine][MainThread] COMMIT + + The ``[loggers]`` configuration in our ``.ini`` file sends a stream of + INFO-level logging to sys.stdout as the console script runs. + +.. nextslide:: A Bit More Cleanup + +So what was the outcome of running that script? + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (ljenv)$ ls + ... + learning_journal.sqlite + ... + + We've now created an sqlite database. + + You'll need to add ``*.sqlite`` to ``.gitignore`` so you don't + inadvertently add that file to your repository. + + Once you've done so, commit the change to your repository + +Interacting with SQLA Models +---------------------------- + +It's pretty easy to play with your models from in an interpreter. + +.. rst-class:: build +.. container:: + + But before we do so, let's make a nicer interpreter available for our + project + + You've been using iPython in class, we can use it here too. + + Just install it with ``pip``: + + .. code-block:: bash + + (ljenv)$ pip install ipython pyramid_ipython + + Once that finishes, you'll be able to use iPython as your interpreter for + this project. + + And ``Pyramid`` provides a way to connect your interpreter to the + application code you are writing: + + The ``pshell`` command + +.. nextslide:: The ``pshell`` command + +Let's fire up ``pshell`` and explore for a moment to see what we have at our +disposal: + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (ljenv)$ pshell development.ini + Python 3.5.0 (default, Sep 16 2015, 10:42:55) + Type "copyright", "credits" or "license" for more information. + + IPython 4.0.1 -- An enhanced Interactive Python. + ? -> Introduction and overview of IPython's features. + %quickref -> Quick reference. + help -> Python's own help system. + object? -> Details about 'object', use 'object??' for extra details. + + Environment: + app The WSGI application. + registry Active Pyramid registry. + request Active request object. + root Root of the default resource tree. + root_factory Default root factory used to create `root`. + +.. nextslide:: + +The ``environment`` created by ``pshell`` provides us with a few useful tools. + +.. code-block:: bash + + app The WSGI application. + registry Active Pyramid registry. + request Active request object. + root Root of the default resource tree. + root_factory Default root factory used to create `root`. + +.. rst-class:: build + +* The ``app`` is our new learning journal application +* The ``registry`` provides us with access to settings and other useful + information +* The ``request`` is an artificial HTTP request we can use if we need to + pretend we are listening to clients +* ... + +.. nextslide:: + +Let's use this environment to build a database session and interact with our +data: + +.. code-block:: ipython + + In [1]: from sqlalchemy import engine_from_config + In [2]: engine = engine_from_config(registry.settings, 'sqlalchemy.') + In [3]: from sqlalchemy.orm import sessionmaker + In [4]: Session = sessionmaker(bind=engine) + In [5]: session = Session() + In [6]: from learning_journal.models import MyModel + In [7]: session.query(MyModel).all() + ... + 2015-12-21 18:06:05,179 INFO [sqlalchemy.engine.base.Engine][MainThread] SELECT models.id AS models_id, models.name AS models_name, models.value AS models_value + FROM models + 2015-12-21 18:06:05,179 INFO [sqlalchemy.engine.base.Engine][MainThread] () + Out[7]: [] + +We've stolen a lot of this from the ``initializedb.py`` script + +.. nextslide:: Basic Interactions + +Any interaction with the database requires a ``session``. + +.. rst-class:: build +.. container:: + + This object represents the connection to the database. + + All database queries are phrased as methods of the session. + + .. container:: + + .. code-block:: ipython + + In [8]: query = session.query(MyModel) + In [9]: type(query) + Out[9]: sqlalchemy.orm.query.Query + + The ``query`` method of the session object returns a ``Query`` object + + Arguments to the ``query`` method can be a *model* class or *columns* from + a model class. + +.. nextslide:: Queries are Iterators + +You can iterate over a query object. The result depends on the args you passed. + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [10]: q1 = session.query(MyModel) + In [11]: for row in q1: + ....: print(row) + ....: print(type(row)) + ....: + + + +.. nextslide:: Queries are Iterators + +You can iterate over a query object. The result depends on the args you passed. + + .. code-block:: ipython + + In [12]: q2 = session.query(MyModel.name, MyModel.id, MyModel.value) + In [13]: for name, id, val in q2: + ....: print(name) + ....: print(type(name)) + ....: print(id) + ....: print(type(id)) + ....: print(val) + ....: print(type(val)) + ....: + one + + 1 + + 1 + + +.. nextslide:: Queries have SQL + +You can view the SQL that your query will use: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [14]: str(q1) + Out[14]: 'SELECT models.id AS models_id, models.name AS models_name, models.value AS models_value \nFROM models' + + In [15]: str(q2) + Out[15]: 'SELECT models.name AS models_name, models.id AS models_id, models.value AS models_value \nFROM models' + + You can use this to check that the query the ORM is constructing looks like + you expect. + + It can be helpful in debugging. + +.. nextslide:: Methods of the Query Object + +The methods of the ``Query`` object fall into two rough categories + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + 1. Methods that return a new ``Query`` object + 2. Methods that return *scalar* values or *model* instances + + Let's start by looking quickly at a few methods from the second category + +.. nextslide:: ``query.get()`` + +A good example of this category of methods is ``get``, which returns one +instance only. + +.. rst-class:: build +.. container:: + + It takes a primary key as an argument: + + .. code-block:: ipython + + In [16]: session.query(MyModel).get(1) + Out[16]: + In [17]: session.query(MyModel).get(10) + In [18]: + + + If no item with that primary key is present, then the method returns + ``None`` + +.. nextslide:: ``query.all()`` + +Another example is one we've already seen. + +.. rst-class:: build +.. container:: + + ``query.all()`` returns a list of all rows returned by the database: + + .. code-block:: ipython + + In [18]: q1.all() + Out[18]: [] + + In [19]: type(q1.all()) + Out[19]: list + + ``query.count()`` returns the number of rows that would have been returned + by the query: + + .. code-block:: ipython + + In [20]: q1.count() + Out[20]: 1 + +.. nextslide:: Creating New Objects + +Before getting into the other category, let's learn how to create new objects. + +.. rst-class:: build +.. container:: + + .. container:: + + We can create new instances of our *model* just like normal Python + objects: + + .. code-block:: ipython + + In [21]: new_model = MyModel(name='fred', value=3) + In [22]: new_model + Out[22]: + + .. container:: + + In this state, the instance is *ephemeral*, our ``session`` knows + nothing about it: + + .. code-block:: pycon + + In [23]: session.new + Out[23]: IdentitySet([]) + +.. nextslide:: Adding Objects to the Session + +For the database to know about our new object, we must ``add`` it to the +session: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [24]: session.add(new_model) + In [25]: session.new + Out[25]: IdentitySet([]) + + We can even bulk-add new objects: + + .. code-block:: ipython + + In [26]: new = [] + In [27]: for name, val in [('bob', 34), ('tom', 13)]: + ....: new.append(MyModel(name=name, value=val)) + ....: + In [28]: session.add_all(new) + In [29]: session.new + Out[29]: IdentitySet([, + , + ]) + +.. nextslide:: Committing Changes + +Up until now, the changes you've made are not permanent. + +.. rst-class:: build +.. container:: + + In order for these new objects to be saved to the database, the session + must be ``committed``: + + .. code-block:: ipython + + In [30]: other_session = Session() + In [31]: other_session.query(MyModel).count() + Out[31]: 1 + In [32]: session.commit() + In [33]: other_session.query(MyModel).count() + Out[33]: 4 + + When you are using a ``scoped_session`` in Pyramid, this action is + automatically handled for you. + + The session that is bound to a particular HTTP request is committed when a + response is sent back. + + (don't worry if this seems confusing, more to come next week) + +.. nextslide:: Altering Objects + +You can edit objects that are already part of a session, or that are fetched by +a query. + +.. rst-class:: build +.. container:: + + Simply change the values of a persisted attribute, the session will know + it's been updated: + + .. code-block:: ipython + + In [34]: new_model + Out[34]: + In [35]: new_model.name + Out[35]: 'fred' + In [36]: new_model.name = 'larry' + In [37]: session.dirty + Out[37]: IdentitySet([]) + + Commit the session to persist the changes: + + .. code-block:: ipython + + In [38]: session.commit() + In [39]: [model.name for model in other_session.query(MyModel)] + Out[39]: ['one', 'larry', 'bob', 'tom'] + +.. nextslide:: Methods Returning Queries + +Returning to query methods, a good example of the second type is the ``filter`` +method. + +.. rst-class:: build +.. container:: + + This method allows you to reduce the number of results, based on criteria: + + .. code-block:: ipython + + In [40]: [(o.name, o.value) for o in session.query(MyModel).filter(MyModel.value < 20)] + Out[40]: [('one', 1), ('larry', 3), ('tom', 13)] + +.. nextslide:: ``order_by`` + +Another typical method in this category is ``order_by``: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [41]: [o.value for o in session.query(MyModel).order_by(MyModel.value)] + Out[41]: [1, 3, 13, 34] + + In [42]: [o.name for o in session.query(MyModel).order_by(MyModel.name)] + Out[42]: ['bob', 'larry', 'one', 'tom'] + +.. nextslide:: Method Chaining + +Since methods in this category return ``Query`` objects, they can be safely +*chained* to build more complex queries: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [43]: q1 = session.query(MyModel).filter(MyModel.value < 20) + In [44]: q1 = q1.order_by(MyModel.name) + In [45]: [(o.name, o.value) for o in q1] + Out[45]: [('larry', 3), ('one', 1), ('tom', 13)] + + Note that you can do this inline as well + (``s.query(Model).filter().order_by()``) + + Also note that when using chained queries like this, no query is actually + sent to the database until you require a result. + + +Cleaning Up After Ourselves +--------------------------- + +When you are experimenting with a new system, you often create data that is +messy or incomplete. + +.. rst-class:: build +.. container:: + + It's good to remember that none of the information we've persisted to our + database is vital to us. + + For homework this week we'll be making new models, and the data we have in + our current database will only get in the way. + + Until you have real production data it is always safe simply to delete the + database and start over: + + .. code-block:: bash + + $ rm learning_journal.sqlite + + You can always re-create it by executing ``setup_db`` + +Homework +======== + +.. rst-class:: left + +Okay, that's enough for the moment. + +.. rst-class:: build left +.. container:: + + You've learned quite a bit about how *models* work in SQLAlchemy + + It's time to put that knowledge to good use. + + For the first part of your assignment this week you will begin to define + the data model for our learning journal application. + + I'll provide a specification, you define the model required to do the job. + + I'll also ask you to define a few methods to complete the first part of our + API. + +The Model +--------- + +Our model will be called an ``Entry``. Here's what you need to know: + +* It should be stored in a database table called ``entries`` +* It should have a primary key field called ``id`` +* It should have a ``title`` field which accepts unicode text up to 255 characters in length +* The ``title`` should be unique and it should be impossible to save an + ``entry`` without a ``title``. +* It should have a ``body`` field which accepts unicode text of any length + (including none) +* It should have a ``created`` field which stores the date and time the object + was created. +* It should have an ``edited`` field which stores the date and time the object + was last edited. + +.. nextslide:: + +* Both the ``created`` and ``edited`` field should default to ``now`` if not + provided when a new instance is constructed. +* The ``entry`` class should support a classmethod ``all`` that returns all the + entries in the database, ordered so that the most recent entry is first. +* The ``entry`` class should support a classmethod ``by_id`` that returns a + single entry, given an ``id``. + +Remember that in order to have your new model table created, you will have to +re-run the ``initialize_learning_journal_db`` script after creating your model. + +.. nextslide:: Words of Advice + +Use the documentation linked in this presentation to assist you. SQLAlchemy +has fantastic documentation, but it can be a bit overwhelming. Everything you +require for this assignment is on one or more of the pages linked above. + +As you define this new model for our application, make frequent commits to your +github repository. Remember to write meaningful commit messages. + +Don't be afraid to start up a Python interpreter and play with your model. Try +things out. Learn how this all works by making mistakes. Remember the +``pshell`` command and how we set up a session once the shell is running. + +Errors at the SQL level can sometimes leave your session unusable. To restore +it, use the ``session.rollback()`` method. You'll lose uncommitted changes, +but you'll gain a session that can be used again. + +.. nextslide:: Submitting Your Work + +I want to be able to review your code (and you want to be able to share it). + +To submit this assignment, you'll need to add this learning_journal repository +to GitHub. + +On the GitHub website you can create a new repository. Set it up to be +completely empty. Name it ``learning_journal`` and give it any description you +like. + +When you've created an empty repository in GitHub, you should see a set of +directions for connecting it to a repository that you've already built. Follow +those instructions to connect your emtpy GitHub repository as the ``origin`` +remote to your ``learning_journal`` repository on your machine. + +Finally, push your ``master`` branch to your new ``origin`` remote on GitHub. + +When you are done, send me an email with the URL for your new repository. + +.. nextslide:: + +**Our work next week will assume that you have completed this assignment** + +Do not delay working on this until the last moment. + +Do not skip this assignment. + +Do ask questions frequently via email (use the `class google group`_). + +See you next week! + +.. _class google group: https://groups.google.com/forum/#!forum/programming-in-python diff --git a/source/presentations/session05.rst.norender b/source/presentations/session05.rst.norender deleted file mode 100644 index efb1775d..00000000 --- a/source/presentations/session05.rst.norender +++ /dev/null @@ -1,1653 +0,0 @@ -Python Web Programming -====================== - -.. image:: img/bike.jpg - :align: left - :width: 50% - -Session 5: Frameworks and Flask - -.. class:: intro-blurb right - -| "Reinventing the wheel is great -| if your goal is to learn more about the wheel" -| -| -- James Tauber, PyCon 2007 - -.. class:: image-credit - -image: Britanglishman http://www.flickr.com/photos/britanglishman/5999131365/ - CC-BY - - -A Moment to Reflect -------------------- - -We've been at this for a couple of days now. We've learned a great deal: - -.. class:: incremental - -* Sockets, the TCP/IP Stack and Basic Mechanics -* Web Protocols and the Importance of Clear Communication -* APIs and Consuming Data from The Web -* CGI and WSGI and Getting Information to Your Dynamic Applications - -.. class:: incremental - -Everything we do from here out will be based on tools built using these -*foundational technologies*. - - -From Now On ------------ - -Think of everything we do as sitting on top of WSGI - -.. class:: incremental - -This may not *actually* be true - -.. class:: incremental - -But we will always be working at that level of abstraction. - - -Frameworks ----------- - -From Wikipedia: - -.. class:: center incremental - -A web application framework (WAF) is a software framework that is designed to -support the development of dynamic websites, web applications and web -services. The framework aims to alleviate the overhead associated with common -activities performed in Web development. For example, many frameworks provide -libraries for database access, templating frameworks and session management, -and they often promote code reuse - - -What Does That *Mean*? ----------------------- - -You use a framework to build an application. - -.. class:: incremental - -A framework allows you to build different kinds of applications. - -.. class:: incremental - -A framework abstracts what needs to be abstracted, and allows control of the -rest. - -.. class:: incremental - -Think back over the last four sessions. What were your pain points? Which bits -do you wish you didn't have to think about? - - -Level of Abstraction --------------------- - -This last part is important when it comes to choosing a framework - -.. class:: incremental - -* abstraction ∝ 1/freedom -* The more they choose, the less you can -* *Every* framework makes choices in what to abstract -* *Every* framework makes *different* choices - - -Impedance Mismatch ------------------- - -.. class:: big-centered - -Don't Fight the Framework - - -Python Web Frameworks ---------------------- - -There are scores of 'em (this is a partial list). - -.. class:: incremental invisible small center - -========= ======== ======== ========== ============== -Django Grok Pylons TurboGears web2py -Zope CubicWeb Enamel Gizmo(QP) Glashammer -Karrigell Nagare notmm Porcupine QP -SkunkWeb Spyce Tipfy Tornado WebCore -web.py Webware Werkzeug WHIFF XPRESS -AppWsgi Bobo Bo7le CherryPy circuits.web -Paste PyWebLib WebStack Albatross Aquarium -Divmod Nevow Flask JOTWeb2 Python Servlet -Engine Pyramid Quixote Spiked weblayer -========= ======== ======== ========== ============== - - -Choosing a Framework --------------------- - -Many folks will tell you " is the **best** framework". - -.. class:: incremental - -In most cases, what they really mean is "I know how to use " - -.. class:: incremental - -In some cases, what they really mean is " fits my brain the best" - -.. class:: incremental - -What they usually forget is that everyone's brain (and everyone's use-case) is -different. - - -Cris' First Law of Frameworks ------------------------------ - -.. class:: center - -**Pick the Right Tool for the Job** - -.. class:: incremental - -First Corollary - -.. class:: incremental center - -The right tool is the tool that allows you to finish the job quickly and -correctly. - -.. class:: incremental center - -But how do you know which that one is? - - -Cris' Second Law of Frameworks ------------------------------- - -.. class:: big-centered - -You can't know unless you try - -.. class:: incremental center - -so let's try - - -From Your Homework ------------------- - -During the week, you walked through an introduction to the *Flask* web -framework. You wrote a file that looked like this: - -.. code-block:: python - :class: small - - from flask import Flask - app = Flask(__name__) - - @app.route('/') - def hello_world(): - return 'Hello World!' - - if __name__ == '__main__': - app.run() - - -The outcome ------------ - -When you ran this file, you should have seen something like this in your -browser: - -.. image:: img/flask_hello.png - :align: center - :width: 80% - - -What's Happening Here? ----------------------- - -Flask the framework provides a Python class called `Flask`. This class -functions as a single *application* in the WSGI sense. - -.. class:: incremental - -We know a WSGI application must be a *callable* that takes the arguments -*environ* and *start_response*. - -.. class:: incremental - -It has to call the *start_response* method, providing status and headers. - -.. class:: incremental - -And it has to return an *iterable* that represents the HTTP response body. - - -Under the Covers ----------------- - -In Python, an object is a *callable* if it has a ``__call__`` method. - -.. container:: incremental - - Here's the ``__call__`` method of the ``Flask`` class: - - .. code-block:: python - - def __call__(self, environ, start_response): - """Shortcut for :attr:`wsgi_app`.""" - return self.wsgi_app(environ, start_response) - -.. class:: incremental - -As you can see, it calls another method, called ``wsgi_app``. Let's follow -this down... - - -Flask.wsgi_app --------------- - -.. code-block:: python - :class: small - - def wsgi_app(self, environ, start_response): - """The actual WSGI application. - ... - """ - ctx = self.request_context(environ) - ctx.push() - error = None - try: - try: - response = self.full_dispatch_request() - except Exception as e: - error = e - response = self.make_response(self.handle_exception(e)) - return response(environ, start_response) - #... - -.. class:: incremental - -``response`` is another WSGI app. ``Flask`` is actually *middleware* - - -Abstraction Layers ------------------- - -Finally, way down in a package called *werkzeug*, we find this response object -and it's ``__call__`` method: - -.. code-block:: python - :class: small - - def __call__(self, environ, start_response): - """Process this response as WSGI application. - - :param environ: the WSGI environment. - :param start_response: the response callable provided by the WSGI - server. - :return: an application iterator - """ - app_iter, status, headers = self.get_wsgi_response(environ) - start_response(status, headers) - return app_iter - - -Common Threads --------------- - -All Python web frameworks that operate under the WSGI spec will do this same -sort of thing. - -.. class:: incremental - -They have to do it. - -.. class:: incremental - -And these layers of abstraction allow you, the developer to focus only on the -thing that really matters to you. - -.. class:: incremental - -Getting input from a request, and returning a response. - - -A Quick Reminder ----------------- - -Over the week, in addition to walking through a Flask intro you did two other -tasks: - -.. class:: incremental - -You walked through a tutorial on the Python DB API2, and learned how -to use ``sqlite3`` to store and retrieve data. - -.. class:: incremental - -You also read a bit about ``Jinja2``, the templating language Flask -uses out of the box, and ran some code to explore its abilities. - - -Moving On ---------- - -Now it is time to put all that together. - -.. class:: incremental - -We'll spend this session building a "microblog" application. - -.. class:: incremental - -Let's dive right in. - -.. class:: incremental - -Start by activating your Flask virtualenv - - -Our Database ------------- - -We need first to define what an *entry* for our microblog might look like. - -.. class:: incremental - -Let's keep it a simple as possible for now. - -.. class:: incremental - -Create a new directory ``microblog``, and open a new file in it: -``schema.sql`` - -.. code-block:: sql - :class: incremental small - - drop table if exists entries; - create table entries ( - id integer primary key autoincrement, - title string not null, - text string not null - ); - - -App Configuration ------------------ - -For any but the most trivial applications, you'll need some configuration. - -.. class:: incremental - -Flask provides a number of ways of loading configuration. We'll be using a -config file - -.. class:: incremental - -Create a new file ``microblog.cfg`` in the same directory. - -.. code-block:: python - :class: small incremental - - # application configuration for a Flask microblog - DATABASE = 'microblog.db' - - -Our App Skeleton ----------------- - -Finally, we'll need a basic app skeleton to work from. - -.. class:: incremental - -Create one more file ``microblog.py`` in the same directory, and enter the -following: - -.. code-block:: python - :class: small incremental - - from flask import Flask - - app = Flask(__name__) - - app.config.from_pyfile('microblog.cfg') - - if __name__ == '__main__': - app.run(debug=True) - - -Test Your Work --------------- - -This is enough to get us off the ground. - -.. container:: incremental - - From a terminal in the ``microblog`` directory, run the app: - - .. class:: small - - :: - - (flaskenv)$ python microblog.py - * Running on http://127.0.0.1:5000/ - * Restarting with reloader - -.. class:: incremental - -Then point your browser at http://localhost:5000/ - -.. class:: incremental - -What do you see in your browser? In the terminal? Why? - - -Creating the Database ---------------------- - -Quit the app with ``^C``. Then return to ``microblog.py`` and add the -following: - -.. code-block:: python - :class: incremental small - - # add this up at the top - import sqlite3 - - # add the rest of this below the app.config statement - def connect_db(): - return sqlite3.connect(app.config['DATABASE']) - -.. class:: incremental - -This should look familiar. What will happen? - -.. class:: incremental - -This convenience method allows us to write our very first test. - - -Tests and TDD -------------- - -.. class:: center - -**If it isn't tested, it's broken** - -.. class:: incremental - -We are going to write tests at every step of this exercise using the -``unittest`` module. - -.. class:: incremental - -In your ``microblog`` folder create a ``microblog_tests.py`` file. - -.. class:: incremental - -Open it in your editor. - - -Testing Setup -------------- - -Add the following to provide minimal test setup. - -.. code-block:: python - :class: small - - import os - import tempfile - import unittest - - import microblog - - class MicroblogTestCase(unittest.TestCase): - - def setUp(self): - db_fd = tempfile.mkstemp() - self.db_fd, microblog.app.config['DATABASE'] = db_fd - microblog.app.config['TESTING'] = True - self.client = microblog.app.test_client() - self.app = microblog.app - - -Testing Teardown ----------------- - -**Add** this method to your existing test case class to tear down after each -test: - -.. code-block:: python - - class MicroblogTestCase(unittest.TestCase): - # ... - - def tearDown(self): - os.close(self.db_fd) - os.unlink(microblog.app.config['DATABASE']) - - -Make Tests Runnable -------------------- - -Finally, we make our tests runnable by adding a ``main`` block: - -.. container:: incremental - - Add the following at the end of ``microblog_tests.py``: - - .. code-block:: python - :class: small - - if __name__ == '__main__': - unittest.main() - -.. class:: incremental - -Now, we're ready to add our first actual test.. - - -Test Database Setup -------------------- - -We'd like to test that our database is correctly initialized. The schema has -one table with three columns. Let's test that. - -.. container:: incremental - - **Add** the following method to your test class in ``microblog_tests.py``: - - .. code-block:: python - :class: small - - def test_database_setup(self): - con = microblog.connect_db() - cur = con.execute('PRAGMA table_info(entries);') - rows = cur.fetchall() - self.assertEquals(len(rows), 3) - - -Run the Tests -------------- - -We can now run our test module: - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - F - ====================================================================== - FAIL: test_database_setup (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 23, in test_database_setup - self.assertEquals(len(rows) == 3) - AssertionError: 0 != 3 - - ---------------------------------------------------------------------- - Ran 1 test in 0.011s - - FAILED (failures=1) - - -Make the Test Pass ------------------- - -This is an expected failure. Why? - -.. container:: incremental - - Let's add some code to ``microblog.py`` that will actually create our - database schema: - - .. code-block:: python - :class: small - - # add this import at the top - from contextlib import closing - - # add this function after the connect_db function - def init_db(): - with closing(connect_db()) as db: - with app.open_resource('schema.sql') as f: - db.cursor().executescript(f.read()) - db.commit() - - -Initialize the DB in Tests --------------------------- - -We also need to call that function in our ``microblog_tests.py`` to set up the -database schema for each test. - -.. container:: incremental - - Add the following line at the end of that ``setUp`` method: - - .. code-block:: python - :class: small - - def setUp(self): - # ... - microblog.init_db() # <- add this at the end - -.. class:: incremental - -:: - - (flaskenv)$ python microblog_tests.py - - -Success? --------- - -.. class:: big-centered incremental - - \\o/ Wahoooo! - - -Initialize the DB IRL ---------------------- - -Our test passed, so we have confidence that ``init_db`` does what it should - -.. class:: incremental - -We'll need to have a working database for our app, so let's go ahead and do -this "in real life" - -.. class:: incremental - - (flaskenv)$ python - -.. code-block:: python - :class: incremental - - >>> import microblog - >>> microblog.init_db() - >>> ^D - - -First Break ------------ - -After you quit the interpreter, you should see ``microblog.db`` in your -directory. - -.. class:: incremental - -Let's take a few minutes here to rest and consider what we've done. - -.. class:: incremental - -When we return, we'll start writing data to our database, and reading it back -out. - - -Reading and Writing Data ------------------------- - -Before the break, we created a function that would initialize our database. - -.. class:: incremental - -It's time now to think about writing and reading data for our blog. - -.. class:: incremental - -We'll start by writing tests. - -.. class:: incremental - -But first, a word or two about the circle of life. - - -The Request/Response Cycle --------------------------- - -Every interaction in HTTP is bounded by the interchange of one request and one -response. - -.. class:: incremental - -No HTTP application can do anything until some client makes a request. - -.. class:: incremental - -And no action by an application is complete until a response has been sent -back to the client. - -.. class:: incremental - -This is the lifecycle of an http web application. - - -Managing DB Connections ------------------------ - -It makes sense to bind the lifecycle of a database connection to this same -border. - -.. class:: incremental - -Flask does not dictate that we write an application that uses a database. - -.. class:: incremental - -Because of this, managing the lifecycle of database connection so that they -are connected to the request/response cycle is up to us. - -.. class:: incremental - -Happily, Flask *does* have a way to help us. - - -Request Boundary Decorators ---------------------------- - -The Flask *app* provides decorators we can use on our database lifecycle -functions: - -.. class:: incremental - -* ``@app.before_request``: any method decorated by this will be called before - the cycle begins - -* ``@app.after_request``: any method decorated by this will be called after - the cycle is complete. If an unhandled exception occurs, these functions are - skipped. - -* ``@app.teardown_request``: any method decorated by this will be called at - the end of the cycle, *even if* an unhandled exception occurs. - - -Managing our DB ---------------- - -Consider the following functions: - -.. code-block:: python - :class: small - - def get_database_connection(): - db = connect_db() - return db - - @app.teardown_request - def teardown_request(exception): - db.close() - -.. class:: incremental - -How does the ``db`` object get from one place to the other? - - -Global Context in Flask ------------------------ - -Our flask ``app`` is only really instantiated once - -.. class:: incremental - -This means that anything we tie to it will be shared across all requests. - -.. class:: incremental - -This is what we call ``global`` context. - -.. class:: incremental - -What happens if two clients make a request at the same time? - - -Local Context in Flask ----------------------- - -Flask provides something it calls a ``local global``: "g". - -.. class:: incremental - -This is an object that *looks* global (you can import it anywhere) - -.. class:: incremental - -But in reality, it is *local* to a single request. - -.. class:: incremental - -Resources tied to this object are *not* shared among requests. Perfect for -things like a database connection. - - -Working DB Functions --------------------- - -Add the following, working methods to ``microblog.py``: - -.. code-block:: python - :class: small - - # add this import at the top: - from flask import g - - # add these function after init_db - def get_database_connection(): - db = getattr(g, 'db', None) - if db is None: - g.db = db = connect_db() - return db - - @app.teardown_request - def teardown_request(exception): - db = getattr(g, 'db', None) - if db is not None: - db.close() - - -Writing Blog Entries --------------------- - -Our microblog will have *entries*. We've set up a simple database schema to -represent them. - -.. class:: incremental - -To write an entry, what would we need to do? - -.. class:: incremental - -* Provide a title -* Provide some body text -* Write them to a row in the database - -.. class:: incremental - -Let's write a test of a function that would do that. - - -Test Writing Entries --------------------- - -The database connection is bound by a request. We'll need to mock one (in -``microblog_tests.py``) - -.. container:: incremental - - Flask provides ``app.test_request_context`` to do just that - - .. code-block:: python - :class: small - - def test_write_entry(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - con = microblog.connect_db() - cur = con.execute("select * from entries;") - rows = cur.fetchall() - self.assertEquals(len(rows), 1) - for val in expected: - self.assertTrue(val in rows[0]) - - -Run Your Test -------------- - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - .E - ====================================================================== - ERROR: test_write_entry (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 30, in test_write_entry - microblog.write_entry(*expected) - AttributeError: 'module' object has no attribute 'write_entry' - - ---------------------------------------------------------------------- - Ran 2 tests in 0.018s - - FAILED (errors=1) - -.. class:: incremental - -Great. Two tests, one passing. - - -Make It Pass ------------- - -Now we are ready to write an entry to our database. Add this function to -``microblog.py``: - -.. code-block:: python - :class: small incremental - - def write_entry(title, text): - con = get_database_connection() - con.execute('insert into entries (title, text) values (?, ?)', - [title, text]) - con.commit() - -.. class:: incremental small - -:: - - (flaskenv)$ python microblog_tests.py - .. - ---------------------------------------------------------------------- - Ran 2 tests in 0.146s - - OK - - -Reading Entries ---------------- - -We'd also like to be able to read the entries in our blog - -.. container:: incremental - - We need a method that returns all of them for a listing page - - .. class:: incremental - - * The return value should be a list of entries - * If there are none, it should return an empty list - * Each entry in the list should be a dictionary of 'title' and 'text' - -.. class:: incremental - -Let's begin by writing tests. - - -Test Reading Entries --------------------- - -In ``microblog_tests.py``: - -.. code-block:: python - :class: small - - def test_get_all_entries_empty(self): - with self.app.test_request_context('/'): - entries = microblog.get_all_entries() - self.assertEquals(len(entries), 0) - - def test_get_all_entries(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - entries = microblog.get_all_entries() - self.assertEquals(len(entries), 1) - for entry in entries: - self.assertEquals(expected[0], entry['title']) - self.assertEquals(expected[1], entry['text']) - - -Run Your Tests --------------- - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - .EE. - ====================================================================== - ERROR: test_get_all_entries (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 47, in test_get_all_entries - entries = microblog.get_all_entries() - AttributeError: 'module' object has no attribute 'get_all_entries' - - ====================================================================== - ERROR: test_get_all_entries_empty (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 40, in test_get_all_entries_empty - entries = microblog.get_all_entries() - AttributeError: 'module' object has no attribute 'get_all_entries' - - ---------------------------------------------------------------------- - Ran 4 tests in 0.021s - - FAILED (errors=2) - -Make Them Pass --------------- - -Now we have 4 tests, and two fail. - -.. class:: incremental - -add the ``get_all_entries`` function to ``microblog.py``: - -.. code-block:: python - :class: small incremental - - def get_all_entries(): - con = get_database_connection() - cur = con.execute('SELECT title, text FROM entries ORDER BY id DESC') - return [dict(title=row[0], text=row[1]) for row in cur.fetchall()] - -.. container:: incremental - - And back in your terminal: - - .. class:: small - - :: - - (flaskenv)$ python microblog_tests.py - .... - ---------------------------------------------------------------------- - Ran 4 tests in 0.021s - - OK - - -Where We Stand --------------- - -We've moved quite a ways in implementing our microblog: - -.. class:: incremental - -* We've created code to initialize our database schema -* We've added functions to manage the lifecycle of our database connection -* We've put in place functions to write and read blog entries -* And, since it's tested, we are reasonably sure our code does what we think - it does. - -.. class:: incremental - -We're ready now to put a face on it, so we can see what we're doing! - - -Second Break ------------- - -But first, let's take a quick break to clear our heads. - - -Templates In Flask ------------------- - -We'll start with a detour into templates as they work in Flask - -.. container:: incremental - - Jinja2 templates use the concept of an *Environment* to: - - .. class:: incremental - - * Figure out where to look for templates - * Set configuration for the templating system - * Add some commonly used functionality to the template *context* - -.. class:: incremental - -Flask sets up a proper Jinja2 Environment when you instantiate your ``app``. - - -Flask Environment ------------------ - -Flask uses the value you pass to the ``app`` constructor to calculate the root -of your application on the filesystem. - -.. class:: incremental - -From that root, it expects to find templates in a directory name ``templates`` - -.. container:: incremental - - This allows you to use the ``render_template`` command from ``flask`` like - so: - - .. code-block:: python - :class: small - - from flask import render_template - page_html = render_template('hello_world.html', name="Cris") - - -Flask Context -------------- - -Keyword arguments you pass to ``render_template`` become the *context* passed -to the template for rendering. - -.. class:: incremental - -Flask will add a few things to this context. - -.. class:: incremental - -* **config**: contains the current configuration object -* **request**: contains the current request object -* **session**: any session data that might be available -* **g**: the request-local object to which global variables are bound -* **url_for**: so you can easily *reverse* urls from within your templates -* **get_flashed_messages**: a function that returns messages you flash to your - users (more on this later). - - -Setting Up Our Templates ------------------------- - -In your ``microblog`` directory, add a new ``templates`` directory - -.. container:: incremental - - In this directory create a new file ``layout.html`` - - .. code-block:: jinja - :class: small - - - - - Microblog! - - -

My Microblog

-
- {% block body %}{% endblock %} -
- - - -Template Inheritance --------------------- - -You can combine templates in a number of different ways. - -.. class:: incremental - -* you can make replaceable blocks in templates with blocks - - * ``{% block foo %}{% endblock %}`` - -* you can build on a template in a second template by extending - - * ``{% extends "layout.html" %}`` - * this *must* be the first text in the template - -* you can re-use common structure with *include*: - - * ``{% include "footer.html" %}`` - - -Displaying an Entries List --------------------------- - -Create a new file, ``show_entries.html`` in ``templates``: - -.. code-block:: jinja - :class: small - - {% extends "layout.html" %} - {% block body %} -

Posts

-
    - {% for entry in entries %} -
  • -

    {{ entry.title }}

    -
    - {{ entry.text|safe }} -
    -
  • - {% else %} -
  • No entries here so far
  • - {% endfor %} -
- {% endblock %} - - -Viewing Entries ---------------- - -We just need a Python function that will: - -.. class:: incremental - -* build a list of entries -* pass the list to our template to be rendered -* return the result to a client's browser - -.. class:: incremental - -As usual, we'll start by writing tests for this new function - - -Test Viewing Entries --------------------- - -Add the following two tests to ``microblog_tests.py``: - -.. code-block:: python - :class: small - - def test_empty_listing(self): - actual = self.client.get('/').data - expected = 'No entries here so far' - self.assertTrue(expected in actual) - - def test_listing(self): - expected = ("My Title", "My Text") - with self.app.test_request_context('/'): - microblog.write_entry(*expected) - actual = self.client.get('/').data - for value in expected: - self.assertTrue(value in actual) - -.. class:: incremental - -``app.test_client()`` creates a mock http client for us. - - -Run Your Tests --------------- - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - .F..F. - ====================================================================== - FAIL: test_empty_listing (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 55, in test_empty_listing - assert 'No entries here so far' in response.data - AssertionError - ====================================================================== - FAIL: test_listing (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 63, in test_listing - assert value in response.data - AssertionError - ---------------------------------------------------------------------- - Ran 6 tests in 0.138s - - FAILED (failures=2) - - -Make Them Pass --------------- - -In ``microblog.py``: - -.. code-block:: python - :class: small - - # at the top, import - from flask import render_template - - # and after our last functions: - @app.route('/') - def show_entries(): - entries = get_all_entries() - return render_template('show_entries.html', entries=entries) - -.. class:: incremental small - -:: - - (flaskenv)$ python microblog_tests.py - ...... - ---------------------------------------------------------------------- - Ran 6 tests in 0.100s - - OK - - -Creating Entries ----------------- - -We still lack a way to add an entry. We need a view that will: - -.. class:: incremental - -* Accept incoming form data from a request -* Get the data for ``title`` and ``text`` -* Create a new entry in the database -* Throw an appropriate HTTP error if that fails -* Show the user the list of entries when done. - -.. class:: incremental - -Again, first come the tests. - - -Testing Add an Entry --------------------- - -Add this to ``microblog_tests.py``: - -.. code-block:: python - :class: small - - def test_add_entries(self): - actual = self.client.post('/add', data=dict( - title='Hello', - text='This is a post' - ), follow_redirects=True).data - self.assertFalse('No entries here so far' in actual) - self.assertTrue('Hello' in actual) - self.assertTrue('This is a post' in actual) - - -Run Your Tests --------------- - -Verify that our test fails as expected: - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - F...... - ====================================================================== - FAIL: test_add_entries (__main__.MicroblogTestCase) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "microblog_tests.py", line 72, in test_add_entries - self.assertTrue('Hello' in actual) - AssertionError: False is not true - - ---------------------------------------------------------------------- - Ran 7 tests in 0.050s - - FAILED (failures=1) - - -Make Them Pass --------------- - -We have all we need to write entries, all we lack is an endpoint (in -``microblog.py``): - -.. code-block:: python - :class: small - - # add imports - from flask import abort - from flask import request - from flask import url_for - from flask import redirect - - @app.route('/add', methods=['POST']) - def add_entry(): - try: - write_entry(request.form['title'], request.form['text']) - except sqlite3.Error: - abort(500) - return redirect(url_for('show_entries')) - - -And...? -------- - -.. class:: small - -:: - - (flaskenv)$ python microblog_tests.py - ....... - ---------------------------------------------------------------------- - Ran 7 tests in 0.047s - - OK - -.. class:: incremental center - -**Hooray!** - - -Where do Entries Come From --------------------------- - -Finally, we're almost done. We can add entries and view them. But look at that -last view. Do you see a call to ``render_template`` in there at all? - -.. class:: incremental - -There isn't one. That's because that view is never meant to be be visible. -Look carefully at the logic. What happens? - -.. class:: incremental - -So where do the form values come from? - -.. class:: incremental - -Let's add a form to the main view. Open ``show_entries.html`` - - -Provide a Form --------------- - -.. code-block:: jinja - :class: small - - {% block body %} -
-
- - -
-
- - -
-
- -
-
-

Posts

- - -All Done --------- - -Okay. That's it. We've got an app all written. - -.. class:: incremental - -So far, we haven't actually touched our browsers at all, but we have -reasonable certainty that this works because of our tests. Let's try it. - - -.. class:: incremental - -In the terminal where you've been running tests, run our microblog app: - -.. class:: incremental - -:: - - (flaskenv)$ python microblog.py - * Running on http://127.0.0.1:5000/ - * Restarting with reloader - - -The Big Payoff --------------- - -Now load ``http://localhost:5000/`` in your browser and enjoy your reward. - - -Making It Pretty ----------------- - -What we've got here is pretty ugly. - -.. class:: incremental - -If you've fallen behind, or want to start fresh, you can find the finished -``microblog`` directory in the class resources. - -.. class:: incremental - -In that directory inside the ``static`` directory you will find -``styles.css``. Open it in your editor. It contains basic CSS for this app. - -.. class:: incremental - -We'll need to include this file in our ``layout.html``. - - -Static Files ------------- - -Like page templates, Flask locates static resources like images, css and -javascript by looking for a ``static`` directory relative to the app root. - -.. class:: incremental - -You can use the special url endpoint ``static`` to build urls that point here. -Open ``layout.html`` and add the following: - -.. code-block:: jinja - :class: small incremental - - - Flaskr - - - - -Reap the Rewards ----------------- - -Make sure that your `microblog` folder has a `static` folder inside it, and -that the `styles.css` file is in it. - -.. class:: incremental - -Then, reload your web browser and see the difference a bit of style can make. - -Homework --------- - -We've built a simple microblog application in the *Flask* web framework. - -.. class:: incremental - -For your homework this week I'd like you to add two features to this app. - -.. class:: incremental - -1. Authentication -2. Flash messaging - - -Authentication Specifications ------------------------------ - -Writing new entries should be restricted to users who have logged in. This -means that: - -.. class:: incremental - -* The form to create a new entry should only be visible to logged in users -* There should be a visible link to allow a user to log in -* This link should display a login form that expects a username and password -* If the user provides incorrect login information, this form should tell her - so. -* If the user provides correct login information, she should end up at the - list page -* Once logged in, the user should see a link to log out. -* Upon clicking that link, the system should no longer show the entry form and - the log in link should re-appear. - - -Flash Messaging Specifications ------------------------------- - -A flask app provides a method called `flash` that allows passing messages from -a view function into a template context so that they can be viewed by a user. - -.. class:: incremental - -Use this method to provide the following messages to users: - -.. class:: incremental - -* Upon a successful login, display the message "You are logged in" -* Upon a successful logout, display the message "You have logged out" -* Upon posting a successful new entry, display the message "New entry posted" -* If adding an entry causes an error, instead of returning a 500 response, - alert the user to the error by displaying the error message to the user. - - -Resources to Use ----------------- - -The microblog we created today comes from the tutorial on the `flask` website. -I've modified that tutorial to omit authentication and flash messaging. You can -refer to the tutorial and to the flask api documentation to learn what you need -to accomplish these tasks. - -`The Flask Tutorial `_ - -`Flask API Documentation `_ - -Both features depend on *sessions*, so you will want to pay particular -attention to how a session is enabled and what you can do with it once it -exists. - - -Next Week ---------- - -Next week we are going to mix things up a little and do something quite -different. - -.. class:: incremental - -We'll be starting from the app you have just built (with the additional -features you complete over the week). - -.. class:: incremental - -We will divide into pairs and each pair will select one feature from a list I -will provide. - -.. class:: incremental - -We'll spend the entire class implementing this feature, and at 8:15, each pair -will show their work to the class. - - -Wrap-Up -------- - -For educational purposes you might try taking a look at the source code for -Flask and Werkzeug. Neither is too large a package. - -.. class:: incremental - -In particular seeing how Werkzeug sets up a Request and Response--and how -these relate to the WSGI specification--can be very enlightening. diff --git a/source/presentations/session06.rst b/source/presentations/session06.rst new file mode 100644 index 00000000..652e1af8 --- /dev/null +++ b/source/presentations/session06.rst @@ -0,0 +1,1588 @@ +.. slideconf:: + :autoslides: True + +********** +Session 06 +********** + +.. image:: /_static/lj_entry.png + :width: 65% + :align: center + +Interacting with Data +===================== + +**Wherein we learn to display our data, and to create and edit it too!** + + +But First +--------- + +Last week we discussed the **model** part of the *MVC* application design +pattern. + +.. rst-class:: build +.. container:: + + We set up a project using the `Pyramid`_ web framework and the `SQLAlchemy`_ + library for persisting our data to a database. + + We looked at how to define a simple model by investigating the demo model + created on our behalf. + + And we went over, briefly, the way we can interact with this model at the + command line to make sure we've got it right. + + Finally, we defined what attributes a learning journal entry would have, + and a pair of methods we think we will need to make the model complete. + +.. _Pyramid: http://www.pylonsproject.org/projects/pyramid/about +.. _SQLAlchemy: http://docs.sqlalchemy.org/en/rel_0_9/ + +Our Data Model +-------------- + +Over the last week, your assignment was to create the new model. + +.. rst-class:: build +.. container:: + + Did you get that done? + + If not, what stopped you? + + Let's take a few minutes here to answer questions about this task so you + are more comfortable. + + Questions? + +.. nextslide:: A Complete Example + +I've added a working ``models.py`` file to our `class repository`_ in the +``resources/session06/`` folder. + +Let's review how it works. + +.. _class repository: https://github.com/UWPCE-PythonCert/training.python_web/tree/master/resources/session06 + +.. nextslide:: Demo Interaction + +I've also made a few small changes to make the ``pshell`` command a bit more +helpful. + +.. rst-class:: build +.. container:: + + In ``learning_journal/__init__.py`` I added the following function: + + .. code-block:: python + + def create_session(settings): + from sqlalchemy.orm import sessionmaker + engine = engine_from_config(settings, 'sqlalchemy.') + Session = sessionmaker(bind=engine) + return Session() + + Then, in ``development.ini`` I added the following configuration: + + .. code-block:: ini + + [pshell] + create_session = learning_journal.create_session + entry = learning_journal.models.Entry + +.. nextslide:: Using the new ``pshell`` + +Here's a demo interaction using ``pshell`` with these new features: + +.. rst-class:: build +.. container:: + + First ``cd`` to your project code, fire up your project virtualenv and + start python: + + .. code-block:: bash + + $ cd projects/learning-journal/learning_journal + $ source ../ljenv/bin/activate + (ljenv)$ pshell development.ini + Python 3.5.0 (default, Sep 16 2015, 10:42:55) + ... + Environment: + app The WSGI application. + ... + Custom Variables: + create_session learning_journal.create_session + entry learning_journal.models.Entry + + In [1]: session = create_session(registry.settings) + + [demo] + +The MVC Controller +================== + +.. rst-class:: left +.. container:: + + Let's go back to thinking for a bit about the *Model-View-Controller* + pattern. + + .. figure:: http://upload.wikimedia.org/wikipedia/commons/4/40/MVC_passive_view.png + :align: center + :width: 25% + + By Alan Evangelista (Own work) [CC0], via Wikimedia Commons + + .. rst-class:: build + .. container:: + + We talked last week (and today) about the *model* + + Today, we'll dig into *controllers* and *views* + + or as we will know them in Pyramid: *views* and *renderers* + + +HTTP Request/Response +--------------------- + +Internet software is driven by the HTTP Request/Response cycle. + +.. rst-class:: build +.. container:: + + A *client* (perhaps a user with a web browser) makes a **request** + + A *server* receives and handles that request and returns a **response** + + The *client* receives the response and views it, perhaps making a new + **request** + + And around and around it goes. + +.. nextslide:: URLs + +An HTTP request arrives at a server through the magic of a **URL** + +.. code-block:: bash + + http://uwpce-pythoncert.github.io/training.python_web/html/index.html + +.. rst-class:: build +.. container:: + + Let's break that up into its constituent parts: + + .. rst-class:: build + + \http://: + This part is the *protocol*, it determines how the request will be sent + + uwpce-pythoncert.github.io: + This is a *domain name*. It's the human-facing address for a server + somewhere. + + /training.python_web/html/index.html: + This part is the *path*. It serves as a locator for a resource *on the + server* + +.. nextslide:: Paths + +In a static website (like our documentation) the *path* identifies a **physical +location** in the server's filesystem. + +.. rst-class:: build +.. container:: + + Some directory on the server is the *home* for the web process, and the + *path* is looked up there. + + Whatever resource (a file, an image, whatever) is located there is returned + to the user as a response. + + If the path leads to a location that doesn't exist, the server responds + with a **404 Not Found** error. + + In the golden days of yore, this was the only way content was served via + HTTP. + +.. nextslide:: Paths in an MVC System + +In todays world we have dynamic systems, server-side web frameworks like +Pyramid. + +.. rst-class:: build +.. container:: + + The requests that you send to a server are handled by a software process + that assembles a response instead of looking up a physical location. + + But we still have URLs, with *protocol*, *domain* and *path*. + + What is the role for a path in a process that doesn't refer to a physical + file system? + + Most web frameworks now call the *path* a **route**. + + They provide a way of matching *routes* to the code that will be run to + handle requests. + +Routes in Pyramid +----------------- + +In Pyramid, routes are handled as *configuration* and are set up in the *main* +function in ``__init__.py``: + +.. code-block:: python + + # learning_journal/__init__.py + def main(global_config, **settings): + # ... + config.add_route('home', '/') + # ... + +.. rst-class:: build +.. container:: + + Our code template created a sample route for us, using the ``add_route`` + method of the ``Configurator`` class. + + The ``add_route`` method has two required arguments: a *name* and a + *pattern* + + In our sample route, the *name* is ``'home'`` + + In our sample route, the *pattern* is ``'/'`` + +.. nextslide:: + +When a request comes in to a Pyramid application, the framework looks at all +the *routes* that have been configured. + +.. rst-class:: build +.. container:: + + One by one, in order, it tries to match the *path* of the incoming request + against the *pattern* of the route. + + As soon as a *pattern* matches the *path* from the incoming request, that + route is used and no further matching is performed. + + If no route is found that matches, then the request will automatically get + a **404 Not Found** error response. + + In our sample app, we have one sample *route* named ``'home'``, with a + pattern of ``/``. + + This means that any request that comes in for ``/`` will be matched to this + route, and any other request will be **404**. + +.. nextslide:: Routes as API + +In a very real sense, the *routes* defined in an application *are* the public +API. + +.. rst-class:: build +.. container:: + + Any route that is present represents something the user can do. + + Any route that is not present is something the user cannot do. + + You can use the proper definition of routes to help conceptualize what your + app will do. + + What routes might we want for a learning journal application? + + What will our application do? + +.. nextslide:: Defining our Routes + +Let's add routes for our application. + +.. rst-class:: build +.. container:: + + Open ``learning_journal/__init__.py``. + + For our list page, the existing ``'home'`` route will do fine, leave it. + + Add the following two routes: + + .. code-block:: python + + config.add_route('home', '/') # already there + config.add_route('detail', '/journal/{id:\d+}') + config.add_route('action', '/journal/{action}') + + The ``'detail'`` route will serve a single journal entry, identified by an + ``id``. + + The ``action`` route will serve ``create`` and ``edit`` views, depending on + the ``action`` specified. + + In both cases, we want to capture a portion of the matched path to use + information it provides. + +.. nextslide:: Matching an ID + +In a pattern, you can capture a ``path segment`` *replacement +marker*, a valid Python symbol surrounded by curly braces: + +.. rst-class:: build +.. container:: + + :: + + /home/{foo}/ + + If you want to match a particular pattern, like digits only, add a + *regular expression*:: + + /journal/{id:\d+} + + Matched path segments are captured in a ``matchdict``:: + + # pattern # actual url # matchdict + /journal/{id:\d+} /journal/27 {'id': '27'} + + The ``matchdict`` is made available as an attribute of the *request object* + + (more on that soon) + + +.. nextslide:: Connecting Routes to Views + +In Pyramid, a *route* is connected by configuration to a *view*. + +.. rst-class:: build +.. container:: + + In our app, a sample view has been created for us, in ``views.py``: + + .. code-block:: python + + @view_config(route_name='home', renderer='templates/mytemplate.pt') + def my_view(request): + # ... + + The order in which *routes* are configured *is important*, so that must be + done in ``__init__.py``. + + The order in which views are connected to routes *is not important*, so the + *declarative* ``@view_config`` decorator can be used. + + When ``config.scan`` is called, all files in our application are searched + for such *declarative configuration* and it is added. + +The Pyramid View +---------------- + +Let's imagine that a *request* has come to our application for the path +``'/'``. + +.. rst-class:: build +.. container:: + + The framework made a match of that path to a *route* with the pattern ``'/'``. + + Configuration connected that route to a *view* in our application. + + Now, the view that was connected will be *called*, which brings us to the + nature of *views* + + .. rst-class:: centered + + --A Pyramid view is a *callable* that takes *request* as an argument-- + + Remember what a *callable* is? + +.. nextslide:: What the View Does + +So, a *view* is a callable that takes the *request* as an argument. + +.. rst-class:: build +.. container:: + + It can then use information from that request to build appropriate data, + perhaps using the application's *models*. + + Then, it returns the data it assembled, passing it on to a `renderer`_. + + Which *renderer* to use is determined, again, by configuration: + + .. code-block:: python + + @view_config(route_name='home', renderer='templates/mytemplate.pt') + def my_view(request): + # ... + + More about this in a moment. + + The *view* stands at the intersection of *input data*, the application + *model* and *renderers* that offer rendering of the results. + + It is the *Controller* in our MVC application. + +.. _renderer: http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/renderers.html + + +.. nextslide:: Adding Stub Views + +Add temporary views to our application in ``views.py`` (and comment out the +sample view): + +.. code-block:: python + + @view_config(route_name='home', renderer='string') + def index_page(request): + return 'list page' + + @view_config(route_name='detail', renderer='string') + def view(request): + return 'detail page' + + @view_config(route_name='action', match_param='action=create', renderer='string') + def create(request): + return 'create page' + + @view_config(route_name='action', match_param='action=edit', renderer='string') + def update(request): + return 'edit page' + +.. nextslide:: Testing Our Views + +Now we can verify that our view configuration has worked. + +.. rst-class:: build +.. container:: + + Make sure your virtualenv is properly activated, and start the web server: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84467. + serving on http://0.0.0.0:6543 + + Then try viewing some of the expected application urls: + + .. rst-class:: build + + * http://localhost:6543/ + * http://localhost:6543/journal/1 + * http://localhost:6543/journal/create + * http://localhost:6543/journal/edit + + What happens if you visit a URL that *isn't* in our configuration? + +.. nextslide:: Interacting With the Model + +Now that we've got temporary views that work, we can fix them to get +information from our database + +.. rst-class:: build +.. container:: + + We'll begin with the list view. + + We need some code that will fetch all the journal entries we've written, in + reverse order, and hand that collection back for rendering. + + .. code-block:: python + + from .models import ( + DBSession, + MyModel, + Entry, # <- Add this import + ) + + # and update this view function + def index_page(request): + entries = Entry.all() + return {'entries': entries} + +.. nextslide:: Using the ``matchdict`` + +Next, we want to write the view for a single entry. + +.. rst-class:: build +.. container:: + + We'll need to use the ``id`` value our route captures into the + ``matchdict``. + + Remember that the ``matchdict`` is an attribute of the request. + + We'll get the ``id`` from there, and use it to get the correct entry. + + .. code-block:: python + + # add this import at the top + from pyramid.httpexceptions import HTTPNotFound + + # and update this view function: + def view(request): + this_id = request.matchdict.get('id', -1) + entry = Entry.by_id(this_id) + if not entry: + return HTTPNotFound() + return {'entry': entry} + +.. nextslide:: Testing Our Views + +We can now verify that these views work correctly. + +.. rst-class:: build +.. container:: + + Make sure your virtualenv is properly activated, and start the web server: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84467. + serving on http://0.0.0.0:6543 + + Then try viewing the list page and an entry page: + + * http://localhost:6543 + * http://localhost:6543/journal/1 + + What happens when you request an entry with an id that isn't in the + database? + + * http://localhost:6543/journal/100 + +The MVC View +============ + +.. rst-class:: left +.. container:: + + Again, back to the *Model-View-Controller* pattern. + + .. figure:: http://upload.wikimedia.org/wikipedia/commons/4/40/MVC_passive_view.png + :align: center + :width: 25% + + By Alan Evangelista (Own work) [CC0], via Wikimedia Commons + + .. rst-class:: build + .. container:: + + We've built a *model* and we've created some *controllers* that use it. + + In Pyramid, we call *controllers* **views** and they are callables that + take *request* as an argument. + + Let's turn to the last piece of the *MVC* patter, the *view* + +Presenting Data +--------------- + +The job of the *view* in the *MVC* pattern is to present data in a format that +is readable to the user of the system. + +.. rst-class:: build +.. container:: + + There are many ways to present data. + + Some are readable by humans (tables, charts, graphs, HTML pages, text + files). + + Some are more for machines (xml files, csv, json). + + Which of these formats is the *right one* depends on your purpose. + + What is the purpose of our learning journal? + +Pyramid Renderers +----------------- + +In Pyramid, the job of presenting data is performed by a *renderer*. + +.. rst-class:: build +.. container:: + + So we can consider the Pyramid **renderer** to be the *view* in our *MVC* + app. + + We've already seen how we can connect a *renderer* to a Pyramid *view* with + configuration. + + In fact, we have already done so, using a built-in renderer called + ``'string'``. + + This renderer converts the return value of its *view* to a string and sends + that back to the client as an HTTP response. + + But the result isn't so nice looking. + +.. nextslide:: Template Renderers + +The `built-in renderers` (``'string'``, ``'json'``, ``'jsonp'``) in Pyramid are +not the only ones available. + +.. _built-in renderers: http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/renderers.html#built-in-renderers + +.. rst-class:: build +.. container:: + + There are add-ons to Pyramid that support using various *template + languages* as renderers. + + In fact, one of these was installed by default when you created this + project. + +.. nextslide:: Configuring a Template Renderer + +.. code-block:: python + + # in setup.py + requires = [ + # ... + 'pyramid_chameleon', + # ... + ] + + # in learning_journal/__init__.py + def main(global_config, **settings): + # ... + config.include('pyramid_chameleon') + +.. rst-class:: build +.. container:: + + The `pyramid_chameleon` package supports using the `chameleon` template + language. + + The language is quite nice and powerful, but not so easy to learn. + + Let's use a different one, *jinja2* + +.. nextslide:: Changing Template Renderers + +Change ``pyramid_chameleon`` to ``pyramid_jinja2`` in both of these files: + +.. code-block:: python + + # in setup.py + requires = [ + # ... + 'pyramid_jinja2', + # ... + ] + + # in learning_journal/__init__.py + def main(global_config, **settings): + # ... + config.include('pyramid_jinja2') + +.. nextslide:: Picking up the Changes + +We've changed the dependencies for our Pyramid project. + +.. rst-class:: build +.. container:: + + As a result, we will need to re-install it so the new dependencies are also + installed: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + ... + Finished processing dependencies for learning-journal==0.0 + (ljenv)$ + + Now, we can use *Jinja2* templates in our project. + + Let's learn a bit about how `Jinja2 templates`_ work. + +.. _Jinja2 templates: http://jinja.pocoo.org/docs/templates/ + +Jinja2 Template Basics +---------------------- + +We'll start with the absolute basics. + +.. rst-class:: build +.. container:: + + Fire up an iPython interpreter, using your `ljenv` virtualenv: + + .. code-block:: bash + + (ljenv)$ ipython + ... + In [1]: + + Then import the ``Template`` class from the ``jinja2`` package: + + .. code-block:: ipython + + In [1]: from jinja2 import Template + +.. nextslide:: Templates are Strings + +A template is constructed with a simple string: + +.. code-block:: ipython + + In [2]: t1 = Template("Hello {{ name }}, how are you") + +.. rst-class:: build +.. container:: + + Here, we've simply typed the string directly, but it is more common to + build a template from the contents of a *file*. + + Notice that our string has some odd stuff in it: ``{{ name }}``. + + This is called a *placeholder* and when the template is *rendered* it is + replaced. + +.. nextslide:: Rendering a Template + +Call the ``render`` method, providing *context*: + +.. code-block:: ipython + + In [3]: t1.render(name="Freddy") + Out[3]: 'Hello Freddy, how are you' + In [4]: t1.render(name="Gloria") + Out[4]: 'Hello Gloria, how are you' + +.. rst-class:: build +.. container:: + + *Context* can either be keyword arguments, or a dictionary + + Note the resemblance to something you've seen before: + + .. code-block:: python + + >>> "This is {owner}'s string".format(owner="Cris") + 'This is Cris's string' + + +.. nextslide:: Dictionaries in Context + +Dictionaries passed in as part of the *context* can be addressed with *either* +subscript or dotted notation: + +.. code-block:: ipython + + In [5]: person = {'first_name': 'Frank', + ...: 'last_name': 'Herbert'} + In [6]: t2 = Template("{{ person.last_name }}, {{ person['first_name'] }}") + In [7]: t2.render(person=person) + Out[7]: 'Herbert, Frank' + +.. rst-class:: build + +* Jinja2 will try the *correct* way first (attr for dotted, item for + subscript). +* If nothing is found, it will try the opposite. +* If nothing is found, it will return an *undefined* object. + + +.. nextslide:: Objects in Context + +The exact same is true of objects passed in as part of *context*: + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [8]: t3 = Template("{{ obj.x }} + {{ obj['y'] }} = Fun!") + In [9]: class Game(object): + ...: x = 'babies' + ...: y = 'bubbles' + ...: + In [10]: bathtime = Game() + In [11]: t3.render(obj=bathtime) + Out[11]: 'babies + bubbles = Fun!' + + This means your templates can be agnostic as to the nature of the + things found in *context* + +.. nextslide:: Filtering values in Templates + +You can apply `filters`_ to the data passed in *context* with the pipe ('|') +operator: + +.. _filters: http://jinja.pocoo.org/docs/dev/templates/#filters + +.. code-block:: ipython + + In [12]: t4 = Template("shouted: {{ phrase|upper }}") + In [13]: t4.render(phrase="this is very important") + Out[13]: 'shouted: THIS IS VERY IMPORTANT' + +.. rst-class:: build +.. container:: + + You can also chain filters together: + + .. code-block:: ipython + + In [14]: t5 = Template("confusing: {{ phrase|upper|reverse }}") + In [15]: t5.render(phrase="howdy doody") + Out[15]: 'confusing: YDOOD YDWOH' + +.. nextslide:: Control Flow + +Logical `control structures`_ are also available: + +.. _control structures: http://jinja.pocoo.org/docs/dev/templates/#list-of-control-structures + +.. rst-class:: build +.. container:: + + .. code-block:: ipython + + In [16]: tmpl = """ + ....: {% for item in list %}{{ item}}, {% endfor %} + ....: """ + In [17]: t6 = Template(tmpl) + In [18]: t6.render(list=['a', 'b', 'c', 'd', 'e']) + Out[18]: '\na, b, c, d, e, ' + + Any control structure introduced in a template **must** be paired with an + explicit closing tag (``{% for %}...{% endfor %}``) + + Remember, although template tags like ``{% for %}`` or ``{% if %}`` look a + lot like Python, *they are not*. + + The syntax is specific and must be followed correctly. + +.. nextslide:: Template Tests + +There are a number of specialized *tests* available for use with the +``if...elif...else`` control structure: + +.. code-block:: ipython + + In [19]: tmpl = """ + ....: {% if phrase is upper %} + ....: {{ phrase|lower }} + ....: {% elif phrase is lower %} + ....: {{ phrase|upper }} + ....: {% else %}{{ phrase }}{% endif %}""" + In [20]: t7 = Template(tmpl) + In [21]: t7.render(phrase="FOO") + Out[21]: '\n\n foo\n' + In [22]: t7.render(phrase='bar') + Out[22]: '\n\n BAR\n' + In [23]: t7.render(phrase='This should print as-is') + Out[23]: '\nThis should print as-is' + + +.. nextslide:: Basic Expressions + +Basic `Python-like expressions`_ are also supported: + +.. _Python-like expressions: http://jinja.pocoo.org/docs/dev/templates/#expressions + +.. code-block:: ipython + + In [24]: tmpl = """ + ....: {% set sum = 0 %} + ....: {% for val in values %} + ....: {{ val }}: {{ sum + val }} + ....: {% set sum = sum + val %} + ....: {% endfor %} + ....: """ + In [25]: t8 = Template(tmpl) + In [26]: t8.render(values=range(1, 11)) + Out[26]: '\n\n\n1: 1\n \n\n2: 3\n \n\n3: 6\n \n\n4: 10\n + \n\n5: 15\n \n\n6: 21\n \n\n7: 28\n \n\n8: 36\n + \n\n9: 45\n \n\n10: 55\n \n\n' + + +Our Templates +------------- + +There's more that Jinja2 templates can do, but it will be easier to introduce +you to that in the context of a working template. So let's make some. + +.. nextslide:: Detail Template + +We have a Pyramid view that returns a single entry. Let's create a template to +show it. + +.. rst-class:: build +.. container:: + + In ``learning_journal/templates`` create a new file ``detail.jinja2``: + + .. code-block:: jinja + +
+

{{ entry.title }}

+
+

{{ entry.body }}

+
+

Created {{entry.created}}

+
+ + Then wire it up to the detail view in ``views.py``: + + .. code-block:: ipython + + # views.py + @view_config(route_name='detail', renderer='templates/detail.jinja2') + def view(request): + # ... + +.. nextslide:: Try It Out + +Now we should be able to see some rendered HTML for our journal entry details. + +.. rst-class:: build +.. container:: + + Start up your server: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + + Then try viewing an individual journal entry + + * http://localhost:6543/journal/1 + +.. nextslide:: Listing Page + +The index page of our journal should show a list of journal entries, let's do +that next. + +.. rst-class:: build +.. container:: + + In ``learning_journal/templates`` create a new file ``list.jinja2``: + + .. code-block:: jinja + + {% if entries %} +

Journal Entries

+ + {% else %} +

This journal is empty

+ {% endif %} + +.. nextslide:: + +It's worth taking a look at a few specifics of this template. + +.. rst-class:: build +.. container:: + + .. code-block:: jinja + + {% for entry in entries %} + ... + {% endfor %} + + Jinja2 templates are rendered with a *context*. + + A Pyramid *view* returns a dictionary, which is passed to the renderer as + part of of that *context* + + This means we can access values we return from our *view* in the *renderer* + using the names we assigned to them. + +.. nextslide:: + +It's worth taking a look at a few specifics of this template. + + .. code-block:: jinja + + {{ entry.title }} + + The *request* object is also placed in the context by Pyramid. + + Request has a method ``route_url`` that will create a URL for a named + route. + + This allows you to include URLs in your template without needing to know + exactly what they will be. + + This process is called *reversing*, since it's a bit like a reverse phone + book lookup. + +.. nextslide:: + +Finally, you'll need to connect this new renderer to your listing view: + +.. code-block:: python + + @view_config(route_name='home', renderer='templates/list.jinja2') + def index_page(request): + # ... + +.. nextslide:: Try It Out + +We can now see our list page too. Let's try starting the server: + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + + Then try viewing the home page of your journal: + + * http://localhost:6543/ + + Click on the link to an entry, it should work. + +.. nextslide:: Sharing Structure + +These views are reasonable, if quite plain. + +.. rst-class:: build +.. container:: + + It'd be nice to put them into something that looks a bit more like a + website. + + Jinja2 allows you to combine templates using something called + `template inheritance`_. + + You can create a basic page structure, and then *inherit* that structure in + other templates. + + In our class resources I've added a page template ``layout.jinja2``. Copy + that page to your templates directory + +.. _template inheritance: http://jinja.pocoo.org/docs/dev/templates/#template-inheritance + +.. nextslide:: ``layout.jinja2`` + +.. code-block:: jinja + + + + + + Python Learning Journal + + + +
+ +
+
+

My Python Journal

+
{% block body %}{% endblock %}
+
+

Created in the UW PCE Python Certificate Program

+ + + +.. nextslide:: Template Blocks + +The important part here is the ``{% block body %}{% endblock %}`` expression. + +.. rst-class:: build +.. container:: + + This is a template **block** and it is a kind of placeholder. + + Other templates can inherit from this one, and fill that block with + additional HTML. + + Let's update our detail and list templates: + + .. code-block:: jinja + + {% extends "layout.jinja2" %} + {% block body %} + + {% endblock %} + +.. nextslide:: Try It Out + +Let's try starting the server so we can see the result: + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + + Then try viewing the home page of your journal: + + * http://localhost:6543/ + + Click on the link to an entry, it should work. + + And now you have shared page structure that is in both. + +Static Assets +------------- + +Although we have a shared structure, it isn't particularly nice to look at. + +.. rst-class:: build +.. container:: + + Aspects of how a website looks are controlled by CSS (*Cascading Style + Sheets*). + + Stylesheets are one of what we generally speak of as *static assets*. + + Other static assets include *images* that are part of the look and feel of + the site (logos, button images, etc) and the *JavaScript* files that add + client-side dynamic behavior to the site. + +.. nextslide:: Static Assets in Pyramid + +Serving static assets in Pyramid requires a *static view* to configuration. +Luckily, ``pcreate`` already handled that for us: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + # in learning_journal/__init__.py + def main(global_config, **settings): + # ... + config.add_static_view('static', 'static', cache_max_age=3600) + # ... + + The first argument to ``add_static_view`` is a *name* that will need to + appear in the path of URLs requesting assets. + + The second argument is a *path* that is relative to the package being + configured. + + Assets referenced by the *name* in a URL will be searched for in the + location defined by the *path* + + Additional keyword arguments control other aspects of how the view works. + +.. nextslide:: Static Assets in Templates + +Once you have a static view configured, you can use assets in that location in +templates. + +.. rst-class:: build +.. container:: + + The *request* object in Pyramid provides a ``static_path`` method that + will render an appropriate asset path for us. + + Add the following to our ``layout.jinja2`` template: + + .. code-block:: jinja + + + + + + + The one required argument to ``request.static_path`` is a *path* to an + asset. + + Note that because any package *might* define a static view, we have to + specify which package we want to look in. + + That's why we have ``learning_journal:static/styles.css`` in our call. + +.. nextslide:: Basic Styles + +I've created some very very basic styles for our learning journal. + +.. rst-class:: build +.. container:: + + You can find them in ``resources/session06/styles.css``. Go ahead and copy + that file. + + Add it to ``learning_journal/static``. + + Then restart your web server and see what a difference a little style + makes: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + +.. nextslide:: The Outcome + +Your site should look something like this: + +.. figure:: /_static/learning_journal_styled.png + :align: center + :width: 75% + + The learning journal with basic styles applied + +Getting Interactive +=================== + +.. rst-class:: left +.. container:: + + We have a site that allows us to view a list of journal entries. + + .. rst-class:: build + .. container:: + + We can also view the details of a single entry. + + But as yet, we don't really have any *interaction* in our site yet. + + We can't create new entries. + + Let's add that functionality next. + +User Input +---------- + +In HTML websites, the traditional way of getting input from users is via +`HTML forms`_. + +.. rst-class:: build +.. container:: + + Forms use *input elements* to allow users to enter data, pick from + drop-down lists, or choose items via checkbox or radio button. + + It is possible to create plain HTML forms in templates and use them with + Pyramid. + + It's a lot easier, however, to work with a *form library* to create forms, + render them in templates and interact with data sent by a client. + + We'll be using a form library called `WTForms`_ in our project + +.. _HTML forms: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Forms +.. _WTForms: http://wtforms.readthedocs.org/en/latest/ + +.. nextslide:: Installing WTForms + +The first step to working with this library is to install it. + +.. rst-class:: build +.. container:: + + Start by makin the library as a *dependency* of our package by adding it to + the *requires* list in ``setup.py``: + + .. code-block:: python + + requires = [ + # ... + 'wtforms', # <- add this to the list + ] + + Then, re-install our package to download and install the new dependency: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + ... + Finished processing dependencies for learning-journal==0.0 + +Using WTForms +------------- + +We'll want a form to allow a user to create a new Journal Entry. + +.. rst-class:: build +.. container:: + + Add a new file called ``forms.py`` in our learning_journal package, next to + ``models.py``: + + .. code-block:: python + + from wtforms import Form, TextField, TextAreaField, validators + + strip_filter = lambda x: x.strip() if x else None + + class EntryCreateForm(Form): + title = TextField( + 'Entry title', + [validators.Length(min=1, max=255)], + filters=[strip_filter]) + body = TextAreaField( + 'Entry body', + [validators.Length(min=1)], + filters=[strip_filter]) + +.. nextslide:: Using a Form in a View + +Next, we need to add a new view that uses this form to create a new entry. + +.. rst-class:: build +.. container:: + + Add this to ``views.py``: + + .. code-block:: python + + # add these imports + from pyramid.httpexceptions import HTTPFound + from .forms import EntryCreateForm + + # and update this view function + def create(request): + entry = Entry() + form = EntryCreateForm(request.POST) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + DBSession.add(entry) + return HTTPFound(location=request.route_url('home')) + return {'form': form, 'action': request.matchdict.get('action')} + +.. nextslide:: Testing the Route/View Connection + +We already have a route that connects here. Let's test it. + +.. rst-class:: build +.. container:: + + Start your server: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + + And then try connecting to the ``action`` route: + + * http://localhost:6543/journal/create + + You should see something like this:: + + {'action': u'create', 'form': } + +.. nextslide:: Rendering A Form + +Finally, we need to create a template that will render our form. + +.. rst-class:: build +.. container:: + + Add a new template called ``edit.jinja2`` in + ``learning_journal/templates``: + + .. code-block:: jinja + + {% extends "templates/layout.jinja2" %} + {% block body %} +
+ {% for field in form %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +

{{ field.label }}: {{ field }}

+ {% endfor %} +

+
+ {% endblock %} + +.. nextslide:: Connecting the Renderer + +You'll need to update the view configuration to use this new renderer. + +.. rst-class:: build +.. container:: + + Update the configuration in ``learning_journal/views.py``: + + .. code-block:: python + + @view_config(route_name='action', match_param='action=create', + renderer='templates/edit.jinja2') + def create(request): + # ... + + And then you should be able to start your server and test: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 90536. + serving on http://0.0.0.0:6543 + + * http://localhost:6543/journal/create + +.. nextslide:: Providing Access + +Great! Now you can add new entries to your journal. + +.. rst-class:: build +.. container:: + + But in order to do so, you have to hand-enter the url. + + You should add a new link in the UI somewhere that helps you get there more + easily. + + Add the following to ``list.jinja2``: + + .. code-block:: jinja + + {% extends "layout.jinja2" %} + {% block body %} + {% if entries %} + ... + {% else %} + ... + {% endif %} + +

New Entry

+ {% endblock %} + +Homework +======== + +.. rst-class:: left +.. container:: + + You have a website now that allows you to create, view and list journal + entries + + .. rst-class:: build + .. container:: + + However, there are still a few flaws in this system. + + You should be able to edit a journal entry that already exists, in case + you make a spelling error. + + It would also be nice to see a prettier site. + + Let's handle that for homework this week. + +Part 1: Add Editing +------------------- + +For part one of your assignment, add editing of existing entries. You will need: + +* A form that shows an existing entry (what is different about this form from + one for creating a new entry?) +* A pyramid view that handles that form. It should: + + * Show the form with the requested entry when the page is first loaded + * Accept edits only on POST + * Update an existing entry with new data from the form + * Show the view of the entry after editing so that the user can see the edits + saved correctly + * Show errors from form validation, if any are present + +* A link somewhere that leads to the editing page for a single entry (probably + on the view page for a entry) + +You'll need to update a bit of configuration, but not much. Use the create +form we did here in class as an example. + +Part 2: Make it Yours +--------------------- + +I've created for you a very bare-bones layout and stylesheet. + +You will certainly want to add a bit of your own style and panache. + +Spend a few hours this week playing with the styles and getting a site that +looks more like you want it to look. + +The Mozilla Developer Network has `some excellent resources`_ for learning CSS. + +In particular, the `Getting Started with CSS`_ tutorial is a thorough +introduction to the basics. + +You might also look at their `CSS 3 Demos`_ to help fire up your creative +juices. + +Here are a few more resources: + +* `A List Apart `_ offers outstanding articles. Their + `Topics list `_ is worth a browse. +* `Smashing Magazine `_ is another excellent + resource for articles on design. + +.. _some excellent resources: https://developer.mozilla.org/en-US/docs/Web/CSS +.. _Getting Started with CSS: https://developer.mozilla.org/en-US/docs/CSS/Getting_Started +.. _CSS 3 Demos: https://developer.mozilla.org/en-US/demos/tag/tech:css3 + + +Part 3: User Model +------------------ + +As it stands, our journal accepts entries from anyone who comes by. + +Next week we will add security to allow only logged-in users to create and edit +entries. + +To do so, we'll need a user model + +The model should have: + +* An ``id`` field that is a primary key +* A ``username`` field that is unicode, no more than 255 characters, not + nullable, unique and indexed. +* A ``password`` field that is unicode and not nullable + +In addition, the model should have a classmethod that retrieves a specific user +when given a username. + +Part 4: Preparation for Deployment +---------------------------------- + +At the end of class next week we will be deploying our application to Heroku. + +You will need to get a free account. + +Once you have your free account set up and you have logged in, run through the +`getting started with Python`_ tutorial. + +Be sure to at least complete the *set up* step. It will have you install the +Heroku Toolbelt, which you will need to have ready in class. + +.. _getting started with Python: https://devcenter.heroku.com/articles/getting-started-with-python#introduction + +Submitting Your Work +-------------------- + +As usual, submit your work by committing and pushing it to your project github +repository + +Commit early and commit often. + +Write yourself good commit messages explaining what you have done and why. + +When you are ready to have your work reviewed, email the link to your +repository to us, we'll take a look and make comments. diff --git a/source/presentations/session06.rst.norender b/source/presentations/session06.rst.norender deleted file mode 100644 index 3bc546f1..00000000 --- a/source/presentations/session06.rst.norender +++ /dev/null @@ -1,179 +0,0 @@ -Python Web Programming -====================== - -.. image:: img/flask_cover.png - :align: left - :width: 50% - -Session 6: Extending Flask - -.. class:: intro-blurb right - -| "Web Development, -| one drop at a time" - -.. class:: image-credit - -image: Flask Logo (http://flask.pocoo.org/community/logos/) - - -Last Week ---------- - -Last week, we created a nice, simple flask microblog application. - -.. class:: incremental - -Over the week, as your homework, you added in authentication and flash -messaging. - -.. class:: incremental - -There's still quite a bit more we can do to improve this application. - -.. class:: incremental - -And today, that's what we are going to do. - - -Pair Programming ----------------- - -`Pair programming `_ is a -technique used in agile development. - -.. class:: incremental - -The basic idea is that two heads are better than one. - -.. class:: incremental - -A pair of developers work together at one computer. One *drives* and the other -*navigates* - -.. class:: incremental - -The driver can focus on the tactics of completing a function, while the -navigator can catch typos, think strategically, and find answers to questions -that arise. - - -Pair Up -------- - -We are going to employ this technique for todays class. - -.. class:: incremental - -So take the next few minutes to find a partner and pair up. You must end up -sitting next to your partner, so get up and move. - -.. class:: incremental - -One of you will start as the driver, the other as the observer. - -.. class:: incremental - -About every 20-30 minutes, we will switch, so that each of you can take a turn -driving. - - -Preparation ------------ - -In order for this to work properly, we'll need to have a few things in place. - -.. container:: incremental small - - First, we'll start from a canonical copy of the microblog. Make a fork of - the following repository to your github account: - - .. code-block:: - :class: small - - https://github.com/UWPCE-PythonCert/training.sample-flask-app - -.. container:: incremental small - - Then, clone that repository to your local machine: - - .. code-block:: bash - :class: small - - $ git clone https://github.com//training.sample-flask-app.git - or - $ git clone git@github.com:/training.sample-flask-app.git - -Connect to Your Partner ------------------------ - -Finally, you'll want to connect to your partner's repository, so that you can -each work on your own laptop and still share the changes you make. - -.. container:: incremental small - - First, add your partner's repository as ``upstream`` to yours: - - .. code-block:: bash - :class: small - - $ git remote add upstream https://github.com//training.sample-flask-app.git - or - $ git remote add upstream git@github.com:/training.sample-flask-app.git - -.. container:: incremental small - - Then, fetch their copy so that you can easily merge their changes later: - - .. code-block:: bash - :class: small - - $ git fetch upstream - -While You Work --------------- - -.. class:: small - -Now, when you switch roles during your work, here's the workflow you can use: - -.. class:: small - -1. The current driver commits all changes and pushes to their repository: - -.. code-block:: bash - :class: small - - $ git commit -a -m "Time to switch roles" - $ git push origin master - -.. class:: small - -2. The new driver fetches and merges changes made upstream: - -.. code-block:: bash - :class: small - - $ git fetch upstream master - $ git branch -a - * master - remotes/origin/master - remotes/upstream/master - $ git merge upstream/master - -.. class:: small - -3. The new driver continues working from where their partner left off. - - -Homework --------- - -For this week, please read and complete the Introduction to Django tutorial -linked from the class website and from the course outline. - -You will be expected to have successfully completed that tutorial upon arrival -in class for our next session. - -We will begin our work starting from where it leaves off. - diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst new file mode 100644 index 00000000..787bf014 --- /dev/null +++ b/source/presentations/session07.rst @@ -0,0 +1,1889 @@ +********** +Session 07 +********** + +.. figure:: /_static/no_entry.jpg + :align: center + :width: 60% + + By `Joel Kramer via Flickr`_ + +.. _Joel Kramer via Flickr: https://www.flickr.com/photos/75001512@N00/2707796203 + +Security And Deployment +======================= + +.. rst-class:: left +.. container:: + + By the end of this session we'll have deployed our learning journal to a + public server. + + So we will need to add a bit of security to it. + + We'll get started on that in a moment + +But First +--------- + +.. rst-class:: large center + +Questions About the Homework? + +.. nextslide:: A Working Edit Form + +.. code-block:: python + + class EntryEditForm(EntryCreateForm): + id = HiddenField() + +`View the form online `_ + +.. nextslide:: A Working Edit View + +.. code-block:: python + + @view_config(route_name='action', match_param='action=edit', + renderer='templates/edit.jinja2') + def update(request): + id = int(request.params.get('id', -1)) + entry = Entry.by_id(id) + if not entry: + return HTTPNotFound() + form = EntryEditForm(request.POST, entry) + if request.method == 'POST' and form.validate(): + form.populate_obj(entry) + return HTTPFound(location=request.route_url('detail', id=entry.id)) + return {'form': form, 'action': request.matchdict.get('action')} + +`See this view online `_ + +.. nextslide:: Linking to the Edit Form + +.. code-block:: html+jinja + + {% extends "layout.jinja2" %} + {% block body %} +
+ +
+

+ Go Back :: + + Edit Entry +

+ {% endblock %} + + +`View this template online `_ + +.. nextslide:: A Working User Model + +.. code-block:: python + + class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Unicode(255), unique=True, nullable=False) + password = Column(Unicode(255), nullable=False) + + @classmethod + def by_name(cls, name): + return DBSession.query(cls).filter(cls.name == name).first() + +`View this model online `_ + +Securing An Application +======================= + +.. rst-class:: left +.. container:: + + We've got a solid start on our learning journal. + + .. rst-class:: build + .. container:: + + We can: + + .. rst-class:: build + + * view a list of entries + * view a single entry + * create a new entry + * edit existing entries + + But so can everyone who visits the journal. + + It's a recipe for **TOTAL CHAOS** + + Let's lock it down a bit. + + +AuthN and AuthZ +--------------- + +There are two aspects to the process of access control online. + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * **Authentication**: Verification of the identity of a *principal* + * **Authorization**: Enumeration of the rights of that *principal* in a + context. + + Think of them as **Who Am I** and **What Can I Do** + + All systems with access control involve both of these aspects. + + But many systems wire them together as one. + + +.. nextslide:: Pyramid Security + +In Pyramid these two aspects are handled by separate configuration settings: + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * ``config.set_authentication_policy(AuthnPolicy())`` + * ``config.set_authorization_policy(AuthzPolicy())`` + + If you set one, you must set the other. + + Pyramid comes with a few policy classes included. + + You can also roll your own, so long as they fulfill the requried interface. + + You can learn about the interfaces for `authentication`_ and + `authorization`_ in the Pyramid documentation + +.. _authentication: http://docs.pylonsproject.org/projects/pyramid/en/latest/api/interfaces.html#pyramid.interfaces.IAuthenticationPolicy +.. _authorization: http://docs.pylonsproject.org/projects/pyramid/en/latest/api/interfaces.html#pyramid.interfaces.IAuthorizationPolicy + +.. nextslide:: Our Journal Security + +We'll be using two built-in policies today: + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * ``AuthTktAuthenticationPolicy``: sets an expirable + `authentication ticket`_ cookie. + * ``ACLAuthorizationPolicy``: uses an `Access Control List`_ to grant + permissions to *principals* + + Our access control system will have the following properties: + + .. rst-class:: build + + * Everyone can view entries, and the list of all entries + * Users who log in may edit entries or create new ones + +.. _authentication ticket: http://docs.pylonsproject.org/docs/pyramid/en/latest/api/authentication.html#pyramid.authentication.AuthTktAuthenticationPolicy +.. _Access Control List: http://docs.pylonsproject.org/docs/pyramid/en/latest/api/authorization.html#pyramid.authorization.ACLAuthorizationPolicy + +.. nextslide:: Engaging Security + +By default, Pyramid uses no security. We enable it through configuration. + +.. rst-class:: build +.. container:: + + Open ``learning_journal/__init__.py`` and update it as follows: + + .. code-block:: python + + # add these imports + from pyramid.authentication import AuthTktAuthenticationPolicy + from pyramid.authorization import ACLAuthorizationPolicy + # and add this configuration: + def main(global_config, **settings): + # ... + # update building the configurator to pass in our policies + config = Configurator( + settings=settings, + authentication_policy=AuthTktAuthenticationPolicy('somesecret'), + authorization_policy=ACLAuthorizationPolicy(), + default_permission='view' + ) + # ... + +.. nextslide:: Verify It Worked + +We've now informed our application that we want to use security. + +.. rst-class:: build +.. container:: + + By default we require the 'view' permission to see anything. + + But we have yet to assign *any permissions to anyone* at all. + + Let's verify now that we are unable to see anything in the website. + + Start your application, and try to view any page (You should get a 403 + Forbidden error response): + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84467. + serving on http://0.0.0.0:6543 + + .. rst-class:: build + + * http://localhost:6543/ + * http://localhost:6543/journal/1 + * http://localhost:6543/journal/create + * http://localhost:6543/journal/edit?id=1 + +Implementing Authz +------------------ + +Next we have to grant some permissions to principals. + +.. rst-class:: build +.. container:: + + Pyramid authorization relies on a concept it calls "context". + + A *principal* can be granted rights in a particular *context* + + Context can be made as specific as a single persistent object + + Or it can be generalized to a *route* or *view* + + To have a context, we need a Python object called a *factory* that must + have an ``__acl__`` special attribute. + + The framework will use this object to determine what permissions a + *principal* has + + Let's create one + +.. nextslide:: Add ``security.py`` + +In the same folder where you have ``models.py`` and ``views.py``, add a new +file ``security.py`` + +.. rst-class:: build +.. container:: + + .. code-block:: python + + from pyramid.security import Allow, Everyone, Authenticated + + class EntryFactory(object): + __acl__ = [ + (Allow, Everyone, 'view'), + (Allow, Authenticated, 'create'), + (Allow, Authenticated, 'edit'), + ] + def __init__(self, request): + pass + + The ``__acl__`` attribute of this object contains a list of *ACE*\ s + + An *ACE* combines an *action* (Allow, Deny), a *principal* and a *permission* + +.. nextslide:: Using Our Context Factory + +Now that we have a factory that will provide context for permissions to work, +we can tell our configuration to use it. + +.. rst-class:: build +.. container:: + + Open ``learning_journal/__init__.py`` and update the route configuration + for our routes: + + .. code-block:: python + + # add an import at the top: + from .security import EntryFactory + # update the route configurations: + def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + # ... Add the factory keyword argument to our route configurations: + config.add_route('home', '/', factory=EntryFactory) + config.add_route('detail', '/journal/{id:\d+}', factory=EntryFactory) + config.add_route('action', '/journal/{action}', factory=EntryFactory) + +.. nextslide:: What We've Done + +We've now told our application we want a principal to have the *view* +permission by default. + +.. rst-class:: build +.. container:: + + And we've provided a factory to supply context and an ACL for each route. + + Check our ACL. Who can view the home page? The detail page? The action + pages? + + Pyramid allows us to set a *default_permission* for *all views*\ . + + But view configuration allows us to require a different permission for *a view*\ . + + Let's make our action views require appropriate permissions next + +.. nextslide:: Requiring Permissions for a View + +Open ``learning_journal/views.py``, and edit the ``@view_config`` for +``create`` and ``update``: + +.. code-block:: python + + @view_config(route_name='action', match_param='action=create', + renderer='templates/edit.jinja2', + permission='create') # <-- ADD THIS + def create(request): + # ... + + @view_config(route_name='action', match_param='action=edit', + renderer='templates/edit.jinja2', + permission='edit') # <-- ADD THIS + def update(request): + # ... + +.. nextslide:: Verify It Worked + +At this point, our "action" views should require permissions other than the +default ``view``. + +.. rst-class:: build +.. container:: + + Start your application and verify that it is true: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84467. + serving on http://0.0.0.0:6543 + + .. rst-class:: build + + * http://localhost:6543/ + * http://localhost:6543/journal/1 + * http://localhost:6543/journal/create + * http://localhost:6543/journal/edit?id=1 + + You should get a ``403 Forbidden`` for the action pages only. + +Implement AuthN +--------------- + +Now that we have authorization implemented, we need to add authentication. + +.. rst-class:: build +.. container:: + + By providing the system with an *authenticated user*, our ACEs for + ``Authenticated`` will apply. + + We'll need to have a way for a user to prove who they are to the + satisfaction of the system. + + The most common way of handling this is through a *username* and + *password*. + + A person provides both in an html form. + + When the form is submitted, the system seeks a user with that name, and + compares the passwords. + + If there is no such user, or the password does not match, authentication + fails. + +.. nextslide:: An Example + +Let's imagine that Alice wants to authenticate with our website. + +.. rst-class:: build +.. container:: + + Her username is ``alice`` and her password is ``s3cr3t``. + + She fills these out in a form on our website and submits the form. + + Our website looks for a ``User`` object in the database with the username + ``alice``. + + Let's imagine that there is one, so our site next compares the value she + sent for her *password* to the value stored in the database. + + If her stored password is also ``s3cr3t``, then she is who she says she is. + + All set, right? + +.. nextslide:: Encryption + +The problem here is that the value we've stored for her password is in ``plain +text``. + +.. rst-class:: build +.. container:: + + This means that anyone could potentially steal our database and have access + to all our users' passwords. + + Instead, we should *encrypt* her password with a strong one-way hash. + + Then we can store the hashed value. + + When she provides the plain text password to us, we *encrypt* it the same + way, and compare the result to the stored value. + + If they match, then we know the value she provided is the same we used to + create the stored hash. + +.. nextslide:: Adding Encryption + +Python provides a number of libraries for implementing strong encryption. + +.. rst-class:: build +.. container:: + + You should always use a well-known library for encryption. + + We'll use a good one called `Passlib`_. + + This library provides a number of different algorithms and a *context* that + implements a simple interface for each. + + .. code-block:: python + + from passlib.context import CryptContext + password_context = CryptContext(schemes=['pbkdf2_sha512']) + hashed = password_context.encrypt('password') + if password_context.verify('password', hashed): + print "It matched" + +.. _Passlib: https://pythonhosted.org/passlib/ + +.. nextslide:: Install Passlib + +To install a new package as a dependency, we add the package to our list in +``setup.py``. + +``Passlib`` provides a large number of different hashing schemes. Some (like +``bcrypt``) require underlying ``C`` extensions to be compiled. If you do not +have a ``C`` compiler, these extensions will be disabled. + +.. rst-class:: build +.. container:: + + .. code-block:: python + + requires = [ + ... + 'wtforms', + 'passlib', + ] + + Then, we re-install our package to pick up the new dependency: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + + *note* if you have a c compiler installed but not the Python dev headers, + this may not work. Let me know if you get errors. + +.. nextslide:: Using Passlib + +As noted above, the passlib library uses a ``context`` object to manage +passwords. + +.. rst-class:: build +.. container:: + + This object supports a lot of functionality, but the only API we care about + for this project is encrypting and verifying passwords. + + We'll create a single, global context to be used by our project. + + Since the ``User`` class is the component in our system that should have + the responsibility for password interactions, we'll create our context in + the same place it is defined. + + In ``learning_journal/models.py`` add the following code: + + .. code-block:: python + + # add an import at the top + from passlib.context import CryptContext + + # then lower down, make a context at module scope: + password_context = CryptContext(schemes=['pbkdf2_sha512']) + + +.. nextslide:: Comparing Passwords + +Now that we have a context object available, let's write an instance method for +our ``User`` class that uses it to verify a plaintext password: + +.. rst-class:: build +.. container:: + + Again, in ``learning_journal/models.py`` add the following to the ``User`` + class: + + .. code-block:: python + + # add this method to the User class: + class User(Base): + # ... + def verify_password(self, password): + return password_context.verify(password, self.password) + +.. nextslide:: Create a User + +We'll also need to have a user for our system. + +.. rst-class:: build +.. container:: + + We can use the database initialization script to create one for us. + + Open ``learning_journal/scripts/initialzedb.py``: + + .. code-block:: python + + from learning_journal.models import password_context + from learning_journal.models import User + # and update the main function like so: + def main(argv=sys.argv): + # ... + with transaction.manager: + # replace the code to create a MyModel instance + encrypted = password_context.encrypt('admin') + admin = User(name='admin', password=encrypted) + DBSession.add(admin) + +.. nextslide:: Rebuild the Database: + +In order to get our user created, we'll need to delete our database and +re-build it. + +.. rst-class:: build +.. container:: + + Make sure you are in the folder where ``setup.py`` appears. + + Then remove the sqlite database: + + .. code-block:: bash + + (ljenv)$ rm *.sqlite + + And re-initialize: + + .. code-block:: bash + + (ljenv)$ initialize_learning_journal_db development.ini + ... + 2015-01-17 16:43:55,237 INFO [sqlalchemy.engine.base.Engine][MainThread] + INSERT INTO users (name, password) VALUES (?, ?) + 2015-01-17 16:43:55,237 INFO [sqlalchemy.engine.base.Engine][MainThread] + ('admin', '$2a$10$4Z6RVNhTE21mPLJW5VeiVe0EG57gN/HOb7V7GUwIr4n1vE.wTTTzy') + +Providing Login UI +------------------ + +We now have a user in our database with a strongly encrypted password. + +.. rst-class:: build +.. container:: + + We also have a method on our user model that will verify a supplied + password against this encrypted version. + + We must now provide a view that lets us log in to our application. + + We start by adding a new *route* to our configuration in + ``learning_journal/__init__.py``: + + .. code-block:: python + + config.add_rount('action' ...) + # ADD THIS + config.add_route('auth', '/sign/{action}', factory=EntryFactory) + +.. nextslide:: A Login Form + +It would be nice to use the form library again to make a login form. + +.. rst-class:: build +.. container:: + + Open ``learning_journal/forms.py`` and add the following: + + .. code-block:: python + + # add an import: + from wtforms import PasswordField + # and a new form class + class LoginForm(Form): + username = TextField( + 'Username', [validators.Length(min=1, max=255)] + ) + password = PasswordField( + 'Password', [validators.Length(min=1, max=255)] + ) + + +.. nextslide:: Login View in ``learning_journal/views.py`` + +.. ifnotslides:: + + Next, we'll create a login view in ``learning_journal/views.py`` + +.. code-block:: python + + # new imports: + from pyramid.security import forget, remember + from .forms import LoginForm + from .models import User + # and a new view + @view_config(route_name='auth', match_param='action=in', renderer='string', + request_method='POST') + def sign_in(request): + login_form = None + if request.method == 'POST': + login_form = LoginForm(request.POST) + if login_form and login_form.validate(): + user = User.by_name(login_form.username.data) + if user and user.verify_password(login_form.password.data): + headers = remember(request, user.name) + else: + headers = forget(request) + else: + headers = forget(request) + return HTTPFound(location=request.route_url('home'), headers=headers) + +.. nextslide:: Where's the Renderer? + +Notice that this view doesn't render anything. No matter what, you end up +returning to the ``home`` route. + +.. rst-class:: build +.. container:: + + We have to incorporate our login form somewhere. + + The home page seems like a good place. + + But we don't want to show it all the time. + + Only when we aren't logged in already. + + Let's give that a whirl. + +.. nextslide:: Updating ``index_page`` + +Pyramid security provides a method that returns the id of the user who is +logged in, if any. + +.. rst-class:: build +.. container:: + + We can use that to update our home page in ``learning_journal/views.py``: + + .. code-block:: python + + # add an import: + from pyramid.security import authenticated_userid + + # and update the index_page view: + @view_config(...) + def index_page(request): + # ... get all entries here + form = None + if not authenticated_userid(request): + form = LoginForm() + return {'entries': entries, 'login_form': form} + +.. nextslide:: Update ``list.jinja2`` + +Now we have to update the template for the ``index_page`` to display the form, *if it is there* + +.. rst-class:: build +.. container:: + + .. code-block:: jinja + + {% block body %} + {% if login_form %} + + {% endif %} + {% if entries %} + ... + +.. nextslide:: Try It Out + +We should be ready at this point. + +.. rst-class:: build +.. container:: + + Fire up your application and see it in action: + + .. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84467. + serving on http://0.0.0.0:6543 + + Load the home page and see your login form: + + * http://localhost:6543/ + + Fill it in and submit the form, verify that you can add a new entry. + +.. nextslide:: Break Time + +That's enough for now. We have a working application. + +When we return, we'll deploy it. + + +Deploying An Application +======================== + +.. rst-class:: left +.. container:: + + Now that we have a working application, our next step is to deploy it. + + .. rst-class:: build + .. container:: + + This will allow us to interact with the application in a live setting. + + We will be able to see the application from any computer, and can share + it with friends and family. + + To do this, we'll be using one of the most popular platforms for + deploying web applications today, `Heroku`_. + +.. _Heroku: http://heroku.com + +Heroku +------ + +.. figure:: /_static/heroku-logo.png + :align: center + :width: 40% + +.. rst-class:: build +.. container:: + + Heroku provides all the infrastructure needed to run many types of + applications. + + It also provides `add-on services`_ that support everything from analytics + to payment processing. + + Elaborate applications deployed on Heroku can be quite expensive. + + But for simple applications like our learning journal, the price is just + right: **free** + +.. _add-on services: https://addons.heroku.com + +.. nextslide:: How Heroku Works + +Heroku is predicated on interaction with a git repository. + +.. rst-class:: build +.. container:: + + You initialize a new Heroku app in a repository on your machine. + + This adds Heroku as a *remote* to your repository. + + When you are ready to deploy your application, you ``git push heroku + master``. + + Adding a few special files to your repository allows Heroku to tell what + kind of application you are creating. + + It responds to your push by running an appropriate build process and then + starting your app with a command you provide. + +Preparing to Run Your App +------------------------- + +In order for Heroku to deploy your application, it has to have a command it can +run from a standard shell. + +.. rst-class:: build +.. container:: + + We could use the ``pserve`` command we've been using locally, but the + server it uses is designed for development. + + It's not really suitable for a public deployment. + + Instead we'll use a more robust, production-ready server that came as one + of our dependencies: `waitress`_. + + We'll start by creating a python file that can be executed to start the + ``waitress`` server. + +.. _waitress: http://waitress.readthedocs.org/en/latest/ + +.. nextslide:: Creating ``runapp.py`` + +At the very top level of your application project, in the same folder where you +find ``setup.py``, create a new file: ``runapp.py`` + +.. code-block:: python + + import os + from paste.deploy import loadapp + from waitress import serve + + if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app = loadapp('config:production.ini', relative_to='.') + + serve(app, host='0.0.0.0', port=port) + +.. rst-class:: build +.. container:: + + Once this exists, you can try running your app with it: + + .. code-block:: bash + + (ljenv)$ python runapp.py + serving on http://0.0.0.0:5000 + +.. nextslide:: Running Via Shell + +This would be enough, but we also want to *install* our application as a Python +package. + +.. rst-class:: build +.. container:: + + This will ensure that the dependencies for the application are installed. + + Add a new file called simply ``run`` in the same folder: + + .. code-block:: bash + + #!/bin/bash + python setup.py develop + python runapp.py + + The first line of this file will install our application and its + dependencies. + + The second line will execute the server script. + +.. nextslide:: Build the Database + +We'll need to do the same thing for initializing the database. + +.. rst-class:: build +.. container:: + + Create another new file called ``build_db`` in the same folder: + + .. code-block:: bash + + #!/bin/bash + python setup.py develop + initialize_learning_journal_db production.ini + + Now, add ``run``, ``build_db`` and ``runapp.py`` to your repository and + commit the changes. + +.. nextslide:: Make it Executable + +For Heroku to use them, ``run`` and ``build_db`` must be *executable* + +.. rst-class:: build +.. container:: + + For OSX and Linux users this is easy (do the same for ``run`` and + ``build_db``): + + .. code-block:: bash + + (ljenv)$ chmod 755 run + + Windows users, if you have ``git-bash``, you can do the same + + For the rest of you, try this (for both ``run`` and ``build_db``): + + .. code-block:: posh + + C:\views\myproject>git ls-tree HEAD + ... + 100644 blob 55c0287d4ef21f15b97eb1f107451b88b479bffe run + C:\views\myproject>git update-index --chmod=+x run + C:\views\myproject>git ls-tree HEAD + 100755 blob 3689ebe2a18a1c8ec858cf531d8c0ec34c8405b4 run + + Commit your changes to git to make them permanent. + + +.. nextslide:: Procfile + +Next, we have to inform Heroku that we will be using this script to run our +application online + +.. rst-class:: build +.. container:: + + Heroku uses a special file called ``Procfile`` to do this. + + Add that file now, in the same directory. + + .. code-block:: bash + + web: ./run + + This file tells Heroku that we have one ``web`` process to run, and that it + is the ``run`` script located right here. + + Providing the ``./`` at the start of the file name allows the shell to + execute scripts that are not on the system PATH. + + Add this new file to your repository and commit it. + + +.. nextslide:: Select a Python Version + +By default, Heroku uses the latest update of Python version 2.7 for any Python +app. + +.. rst-class:: build +.. container:: + + You can override this and specify any runtime version of Python + `available in Heroku`_. + + Just add a file called ``runtime.txt`` to your repository, with one line + only: + + .. code-block:: ini + + python-3.5.0 + + Create that file, add it to your repository, and commit the changes. + +.. _available in Heroku: https://devcenter.heroku.com/articles/python-runtimes#supported-python-runtimes + + +Set Up a Heroku App +------------------- + +The next step is to create a new app with heroku. + +.. rst-class:: build +.. container:: + + You installed the Heroku toolbelt prior to class. + + The toolbelt provides a command to create a new app. + + From the root of your project (where the ``setup.py`` file is) run: + + .. code-block:: bash + + (ljenv)$ heroku create + Creating rocky-atoll-9934... done, stack is cedar-14 + https://rocky-atoll-9934.herokuapp.com/ | https://git.heroku.com/rocky-atoll-9934.git + Git remote heroku added + + Note that a new *remote* called ``heroku`` has been added: + + .. code-block:: bash + + $ git remote -v + heroku https://git.heroku.com/rocky-atoll-9934.git (fetch) + heroku https://git.heroku.com/rocky-atoll-9934.git (push) + +.. nextslide:: Adding PostgreSQL + +Your application will require a database, but ``sqlite`` is not really +appropriate for production. + +.. rst-class:: build +.. container:: + + For the deployed app, you'll use `PostgreSQL`_, the best open-source + database. + + Heroku `provides an add-on`_ that supports PostgreSQL, and you'll need to + set it up. + + Again, use the Heroku Toolbelt: + + .. code-block:: bash + + $ heroku addons:create heroku-postgresql:hobby-dev + Creating postgresql-amorphous-6784... done, (free) + Adding postgresql-amorphous-6784 to rocky-atoll-9934... done + Setting DATABASE_URL and restarting rocky-atoll-9934... done, v3 + Database has been created and is available + ! This database is empty. If upgrading, you can transfer + ! data from another database with pg:copy + Use `heroku addons:docs heroku-postgresql` to view documentation. + +.. _PostgreSQL: http://www.postgresql.org +.. _provides an add-on: https://www.heroku.com/postgres + +.. nextslide:: PostgreSQL Settings + +You can get information about the status of your PostgreSQL service with the +toolbelt: + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (ljenv)$ heroku pg + === DATABASE_URL + Plan: Hobby-dev + ... + Data Size: 6.4 MB + Tables: 0 + Rows: 0/10000 (In compliance) + + And there is also information about the configuration for the database (and + your app): + + .. code-block:: bash + + (ljenv)$ heroku config + === rocky-atoll-9934 Config Vars + DATABASE_URL: postgres://:@:/ + +Configuration for Heroku +------------------------ + +Notice that the configuration for our application on Heroku provides a specific +database URL. + +.. rst-class:: build +.. container:: + + We could copy this value and paste it into our ``production.ini`` + configuration file. + + But if we do that, then we will be storing that value in GitHub, where + anyone at all can see it. + + That's not particularly secure. + + Luckily, Heroku provides configuration like the database URL in + *environment variables* that we can read in Python. + + In fact, we've already done this with our ``runapp.py`` script: + + .. code-block:: python + + port = int(os.environ.get("PORT", 5000)) + +.. nextslide:: Adjusting Our DB Configuration + +The Python standard library provides ``os.environ`` to allow access to +*environment variables* from Python code. + +.. rst-class:: build +.. container:: + + This attribute is a dictionary keyed by the name of the variable. + + We can use it to gain access to configuration provided by Heroku. + + Update ``learning_journal/__init__.py`` like so: + + .. code-block:: python + + # import the os module: + import os + # then look up the value we need for the database url + def main(global_config, **settings): + # ... + if 'DATABASE_URL' in os.environ: + settings['sqlalchemy.url'] = os.environ['DATABASE_URL'] + engine = engine_from_config(settings, 'sqlalchemy.') + # ... + +.. nextslide:: Adjust ``initializedb.py`` + +We'll need to make the same changes to +``learning_journal/scripts/initializedb.py``: + +.. code-block:: python + + def main(argv=sys.argv): + # ... + settings = get_appsettings(config_uri, options=options) + if 'DATABASE_URL' in os.environ: + settings['sqlalchemy.url'] = os.environ['DATABASE_URL'] + engine = engine_from_config(settings, 'sqlalchemy.') + # ... + +.. nextslide:: Additional Security + +This mechanism allows us to defer other sensitive values such as the password +for our initial user: + +.. rst-class:: build +.. container:: + + .. code-block:: python + + # in learning_journal/scripts/initializedb.py + with transaction.manager: + password = os.environ.get('ADMIN_PASSWORD', 'admin') + encrypted = password_context.encrypt(password) + admin = User(name=u'admin', password=encrypted) + DBSession.add(admin) + + And for the secret value for our AuthTktAuthenticationPolicy + + .. code-block:: python + + # in learning_journal/__init__.py + def main(global_config, **settings): + # ... + secret = os.environ.get('AUTH_SECRET', 'somesecret') + ... + authentication_policy=AuthTktAuthenticationPolicy(secret) + # ... + +.. nextslide:: Heroku Config + +We will now be looking for three values from the OS environment: + +.. rst-class:: build + +* DATABASE_URL +* ADMIN_PASSWORD +* AUTH_SECRET + +.. rst-class:: build +.. container:: + + The ``DATABASE_URL`` value is set for us by the PosgreSQL add-on. + + But the other two are not. We must set them ourselves using ``heroku + config:set``: + + .. code-block:: bash + + (ljenv)$ heroku config:set ADMIN_PASSWORD= + ... + (ljenv)$ heroku config:set AUTH_SECRET= + ... + +.. nextslide:: Checking Configuration + +You can see the values that you have set at any time using ``heroku config``: + +.. code-block:: bash + + (ljenv)$ heroku config + === rocky-atoll-9934 Config Vars + ADMIN_PASSWORD: + AUTH_SECRET: + DATABASE_URL: + +.. rst-class:: build +.. container:: + + These values are sent and received using secure transport. + + You do not need to worry about them being intercepted. + + This mechanism allows you to place important configuration values outside + the code for your application. + +.. nextslide:: Installing Dependencies + +We've been handling our application's dependencies by adding them to +``setup.py``. + +.. rst-class:: build +.. container:: + + It's a good idea to install all of these before attempting to run our app. + + The ``pip`` package manager allows us to dump a list of the packages we've + installed in a virtual environment using the ``freeze`` command: + + .. code-block:: bash + + (ljenv)$ pip freeze + ... + zope.interface==4.1.3 + zope.sqlalchemy==0.7.6 + + We can tell heroku to install these dependencies by creating a file called + ``requirements.txt`` at the root of our project repository: + + .. code-block:: bash + + (ljenv)$ pip freeze > requirements.txt + + Add this file to your repository and commit the changes. + + +.. nextslide:: Heroku-specific Dependencies + +But there is also a new dependency we've added that is only needed for Heroku. + +.. rst-class:: build +.. container:: + + Because we are using a PostgreSQL database, we need to install the + ``psycopg2`` package, which handles communicating with the database. + + We don't want to install this locally, though, where we use sqlite. + + Go ahead and add one more line to ``requirements.txt`` with the latest + version of the ``pyscopg2`` package: + + .. code-block:: bash + + psycopg2==2.6.1 + + Commit the change to your repository. + +Deployment +---------- + +We are now ready to deploy our application. + +.. rst-class:: build +.. container:: + + All we need to do is push our repository to the ``heroku`` master: + + .. code-block:: bash + + (ljenv)$ git push heroku master + ... + remote: Building source: + remote: + remote: -----> Python app detected + ... + remote: Verifying deploy... done. + To https://git.heroku.com/rocky-atoll-9934.git + b59b7c3..54f7e4d master -> master + +.. nextslide:: Using ``heroku run`` + +You can use the ``run`` command to execute arbitrary commands in the Heroku +environment. + +.. rst-class:: build +.. container:: + + You can use this to initialize the database, using the shell script you + created earlier: + + .. code-block:: bash + + (ljenv)$ heroku run ./build_db + ... + + This will install our application and then run the database initialization + script. + +.. nextslide:: Test Your Results + +At this point, you should be ready to view your application online. + +.. rst-class:: build +.. container:: + + Use the ``open`` command from heroku to open your website in a browser: + + .. code-block:: bash + + (ljenv)$ heroku open + + If you don't see your application, check to see if it is running: + + .. code-block:: bash + + (ljenv)$ heroku ps + === web (1X): `./run` + web.1: up 2015/01/18 16:44:37 (~ 31m ago) + + If you get no results, use the ``scale`` command to try turning on a web + *dyno*: + + .. code-block:: bash + + (ljenv)$ heroku scale web=1 + Scaling dynos... done, now running web at 1:1X. + +.. nextslide:: A Word About Scaling + +Heroku pricing is dependent on the number of *dynos* you are running. + +.. rst-class:: build +.. container:: + + So long as you only run one dyno per application, you will remain in the + free tier. + + Scaling above one dyno will begin to incur costs. + + **Pay attention to the number of dynos you have running**. + +.. nextslide:: Troubleshooting + +Troubleshooting problems with Heroku deployment can be challenging. + +.. rst-class:: build +.. container:: + + Your most powerful tool is the ``logs`` command: + + .. code-block:: bash + + (ljenv)$ heroku logs + ... + 2015-01-19T01:17:59.443720+00:00 app[web.1]: serving on http://0.0.0.0:53843 + 2015-01-19T01:17:59.505003+00:00 heroku[web.1]: State changed from starting to update + + This command will print the last 50 or so lines of logging from your + application. + + You can use the ``-t`` flag to *tail* the logs. + + This will continually update log entries to your terminal as you interact + with the application. + +.. nextslide:: Revel In Your Glory + +Try logging in to your application with the password you set up in Heroku +configuration. + +.. rst-class:: build +.. container:: + + Once you are logged in, try adding an entry or two. + + You are now off to the races! + + .. rst-class:: center + + **Congratulations** + +Adding Polish +============= + +.. rst-class:: left +.. container:: + + So we have now deployed a running application. + + .. rst-class:: build + .. container:: + + But there are a number of things we can do to make the application + better. + + Let's start by adding a way to log out. + + +Adding Logout +------------- + +Our ``login`` view is already set up to work for logout. + +.. rst-class:: build +.. container:: + + What is the logical path taken if that view is accessed via ``GET``? + + All we need to do is add a view_config that allows that. + + Open ``learning_journal/views.py`` and make these changes: + + .. code-block:: python + + @view_config(route_name='auth', match_param='action=in', renderer='string', + request_method='POST') # <-- THIS IS ALREADY THERE + # ADD THE FOLLOWING LINE + @view_config(route_name='auth', match_param='action=out', renderer='string') + # UPDATE THE VIEW FUNCTION NAME + def sign_in_out(request): + # ... + +.. nextslide:: Re-Deploy + +The chief advantage of Heroku is that we can re-deploy with a single command. + +.. rst-class:: build +.. container:: + + Add and commit your changes to git. + + Then re-deploy by pushing to the ``heroku master``: + + .. code-block:: bash + + (ljenv)$ git push heroku master + + Once that completes, you should be able to reload your application in the + browser. + + Visit the following URL path to test log out: + + * /sign/out + +Hide UI for Anonymous +--------------------- + +Another improvement we can make is to hide UI that is not available for users +who are not logged in. + +.. rst-class:: build +.. container:: + + The first step is to update our ``detail`` view to tell us if someone is + logged in: + + .. code-block:: python + + # learning_journal/views.py + @view_config(route_name='detail', renderer='templates/detail.jinja2') + def view(request): + # ... + logged_in = authenticated_userid(request) + return {'entry': entry, 'logged_in': logged_in} + + The ``authenticated_userid`` function returns the id of the logged in user, + if there is one, and ``None`` if there is not. + + We can use that. + +.. nextslide:: Hide "Create Entry" UI + +First we can hide the UI for creating a new entry: + +.. rst-class:: build +.. container:: + + Edit ``templates/list.jinja2``: + + .. code-block:: jinja + + {% extends "layout.jinja2" %} + {% block body %} + + {% if not login_form %} +

New Entry

+ {% endif %} + {% endblock %} + + This relies on the fact that the login form will only be present if there + is **not** an authenticated user. + +.. nextslide:: Hide "Edit Entry" UI + +Next, we can hide the UI for editing an existing entry: + +.. rst-class:: build +.. container:: + + Edit ``templates/detail.jinja2``: + + .. code-block:: jinja + + {% extends "layout.jinja2" %} + {% block body %} + +

+ Go Back + {% if logged_in %} + :: + + Edit Entry + {% endif %} +

+ {% endblock %} + +Format Entries +-------------- + +It would be nice if our journal entries could have HTML formatting. + +.. rst-class:: build +.. container:: + + We could write HTML by hand in the body field, but that'd be a pain. + + Instead, let's allow ourselves to write entries `in Markdown`_, a popular + markup syntax used by GitHub and many other websites. + + .. _in Markdown: http://daringfireball.net/projects/markdown/syntax + + Python provides several libraries that implement markdown formatting. + + They will take text that contains markdown formatting and convert it to + HTML. + + Let's use one. + +.. nextslide:: Adding the Dependency + +The first step, is to pick a package and add it to our dependencies. + +.. rst-class:: build +.. container:: + + My recommendation is the `markdown`_ python library. + + Open ``setup.py`` and add the package to the ``requires`` list: + + .. code-block:: python + + requires = [ + # ... + 'cryptacular', + 'markdown', # <-- ADD THIS + ] + + We'll test this locally first, so go ahead and re-install your app: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + ... + Finished processing dependencies for learning-journal==0.0 + +.. _markdown: https://pythonhosted.org/Markdown/ + +.. nextslide:: Jinja2 Filters + +We've seen before how Jinja2 provides a number of filters for values when +rendering templates. + +.. rst-class:: build +.. container:: + + A nice feature of the templating language is that it also allows you to + `create your own filters`_. + + Remember the template syntax for a filter: + + .. code-block:: jinja + + {{ value|filter(arg1, ..., argN) }} + + A filter is simply a function that takes the value to the left of the ``|`` + character as a first argument, and any supplied arguments as the second and + beyond: + + .. code-block:: python + + def filter(value, arg1, ..., argN): + # do something to value here + +.. _create your own filters: http://jinja.pocoo.org/docs/dev/api/#custom-filters + +.. nextslide:: Our Markdown Filter + +Creating a ``markdown`` filter will allow us to convert plain text stored in +the database to HTML at template rendering time. + +.. rst-class:: build +.. container:: + + Open ``learning_journal/views.py`` and add the following: + + .. code-block:: python + + # add two imports: + from jinja2 import Markup + import markdown + # and a function + def render_markdown(content): + output = Markup(markdown.markdown(content)) + return output + + The ``Markup`` class from jinja2 marks a string with HTML tags as "safe". + + This prevents the tags from being *escaped* when they are rendered into a + page. + +.. nextslide:: Register the Filter + +In order for ``Jinja2`` to be aware that our filter exists, we need to register +it. + +.. rst-class:: build +.. container:: + + In Pyramid, we do this in configuration. + + Open ``development.ini`` and edit it as follows: + + .. code-block:: ini + + [app:main] + ... + jinja2.filters = + markdown = learning_journal.views.render_markdown + + This informs the main app that we wish to register a jinja2 filter. + + We will call it ``markdown`` and it will be embodied by the function we + just wrote. + +.. nextslide:: Use Your Filter + +To see the results of our work, we'll need to use the filter in a template +somewhere. + +.. rst-class:: build +.. container:: + + I suggest using it in the ``learning_journal/templates/detail.jinja2`` + template: + + .. code-block:: jinja + + {% extends "layout.jinja2" %} + {% block body %} +
+ +

{{ entry.body|markdown }}

+ +
+

+ + {% endblock %} + +.. nextslide:: Test Your Results + +Start up your application, and create an entry using valid markdown formatting: + +.. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84331. + serving on http://0.0.0.0:6543 + +.. rst-class:: build +.. container:: + + Once you save your entry, you should be able to see it with actual + formatting: headers, bulleted lists, links, and so on. + + That makes quite a difference. + + Go ahead and add the same filter registration to ``production.ini`` + + Then commit your changes and redeploy: + + .. code-block:: bash + + (ljenv)$ git push heroku master + + +Syntax Highlighting +------------------- + +The purpose of this journal is to allow you to write entries about the things +you learn in this class and elsewhere. + +.. rst-class:: build +.. container:: + + Markdown formatting allows for "preformatted" blocks of text like code + samples. + + But there is nothing in markdown that handles *colorizing* code. + + Luckily, the markdown package allows for extensions, and one of these + supports `colorization`_. + + It requires the `pygments`_ library + + Let's set this up next. + +.. _colorization: https://pythonhosted.org/Markdown/extensions/code_hilite.html +.. _pygments: http://pygments.org + +.. nextslide:: Install the Dependency + +Again, we need to install our new dependency first. + +.. rst-class:: build +.. container:: + + Add the following to ``requires`` in ``setup.py``: + + .. code-block:: python + + requires = [ + # ... + 'markdown', + 'pygments', # <-- ADD THIS LINE + ] + + Then re-install your app to pick up the software: + + .. code-block:: bash + + (ljenv)$ python setup.py develop + ... + Finished processing dependencies for learning-journal==0.0 + +.. nextslide:: Add to Our Filter + +The next step is to extend our markdown filter in ``learning_journal/views.py`` +with this feature. + +.. rst-class:: build +.. container:: + + .. code-block:: python + + def render_markdown(content): + output = Markup( + markdown.markdown( + content, + extensions=['codehilite(pygments_style=colorful)', 'fenced_code'] + ) + ) + return output + + Now, you'll be able to make highlighted code blocks just like in GitHub: + + .. code-block:: text + + ```python + def foo(x, y): + return x**y + ``` + +.. nextslide:: Add CSS + +Code highlighting works by putting HTML ```` tags with special CSS +classes around bits of your code. + +.. rst-class:: build +.. container:: + + We need to generate and add the css to support this. + + You can use the ``pygmentize`` command from pygments to + `generate the css`_. + + Make sure you are in the directory with ``setup.py`` when you run this: + + .. code-block:: bash + + (ljenv)$ pygmentize -f html -S colorful -a .codehilite \ + >> learning_journal/static/styles.css + + The styles will be printed to standard out. + + The ``>>`` shell operator *appends* the output to the file named. + +.. _generate the css: http://pygments.org/docs/cmdline/#generating-styles + +.. nextslide:: Try It Out + +Go ahead and restart your application and see the difference a little style +makes: + +.. code-block:: bash + + (ljenv)$ pserve development.ini + Starting server in PID 84331. + serving on http://0.0.0.0:6543 + +.. rst-class:: build +.. container:: + + Try writing an entry with a little Python code in it. + + Python is not the only language available. + + Any syntax covered by `pygments lexers`_ is available, just use the + *shortname* from a lexer to get that type of style highlighting. + +.. _pygments lexers: http://pygments.org/docs/lexers/ + +.. nextslide:: Deploy Your Changes + +When you've got this working as you wish, go ahead and deploy it. + +.. rst-class:: build +.. container:: + + Add and commit all the changes you've made. + + Then push your results to the ``heroku master``: + + .. code-block:: bash + + (ljenv)$ git push heroku master + +Homework +======== + +.. rst-class:: left +.. container:: + + That's just about enough for now. + + .. rst-class:: build + .. container:: + + There's no homework for you to submit this week. You've worked hard enough. + + Take the week to review what we've done and make sure you have a solid + understanding of it. + + If you wish, play with HTML and CSS to make your journal more personalized. + + However, in preparation for our work with Django next week, I'd like you to + get started a bit ahead of time. + + Please read and follow along with this `basic intro to Django`_. + + .. rst-class:: centered + + **See You Then** + +.. _basic intro to Django: django_intro.html diff --git a/source/presentations/session07.rst.norender b/source/presentations/session07.rst.norender deleted file mode 100644 index c5a6c966..00000000 --- a/source/presentations/session07.rst.norender +++ /dev/null @@ -1,1738 +0,0 @@ -Python Web Programming -====================== - -.. image:: img/django-pony.png - :align: left - :width: 50% - -Session 7: A Django Application - -.. class:: intro-blurb right - -Wherein we build a simple blogging app. - -.. class:: image-credit - -image: http://djangopony.com/ - - -Full Stack Framework --------------------- - -Django comes with: - -.. class:: incremental - -* Persistence via the *Django ORM* -* CRUD content editing via the automatic *Django Admin* -* URL Mapping via *urlpatterns* -* Templating via the *Django Template Language* -* Caching with levels of configurability -* Internationalization via i18n hooks -* Form rendering and handling -* User authentication and authorization - -.. class:: incremental - -Pretty much everything you need to make a solid website quickly - - -What Sets it Apart? -------------------- - -Lots of frameworks offer some of these features, if not all. - -.. class:: incremental - -What is Django's *killer feature* - -.. class:: incremental center - -**The Django Admin** - - -The Django Admin ----------------- - -Works in concert with the Django ORM to provide automatic CRUD functionality - -.. class:: incremental - -You write the models, it provides the UI - -.. class:: incremental center - -You've seen this in action. Pretty neat, eh? - - -The Pareto Principle --------------------- - -The Django Admin is a great example of the Pareto Priciple, a.k.a. the 80/20 -rule: - -.. class:: incremental center - -**80% of the problems can be solved by 20% of the effort** - -.. class:: incremental - -The converse also holds true: - -.. class:: incremental center - -**Fixing the last 20% of the problems will take the remaining 80% of the -effort.** - - -Other Django Advantages ------------------------ - -Clearly the most popular full-stack Python web framework at this time - -.. class:: incremental - -Popularity translates into: - -.. class:: incremental - -* Active, present community -* Plethora of good examples to be found online -* Rich ecosystem of *apps* (encapsulated add-on functionality) - -.. class:: incremental center - -**Jobs** - - -Active Development ------------------- - -Django releases in the last 12+ months (a short list): - -.. class:: incremental - -* 1.6.2 (February 2014) -* 1.6.1 (December 2013) -* 1.6 (November 2013) -* 1.4.10 (Novermber 2013) -* 1.5.5 (October 2013) -* 1.5 (February 2013) -* 1.4 (March 2012) - - -Great Documentation -------------------- - -Thorough, readable, and discoverable. - -.. class:: incremental - -Led the way to better documentation for all Python - -.. class:: incremental - -`Read The Docs `_ - built in connection with -Django, sponsored by the Django Software Foundation. - -.. class:: incremental - -Write documentation as part of your python package, and render new versions of -that documentation for every commit - -.. class:: incremental center - -**this is awesome** - - -Where We Stand --------------- - -For your homework this week, you created a ``Post`` model to serve as the heart -of our blogging app. - -.. class:: incremental - -You also took some time to get familiar with the basic workings of the Django -ORM. - -.. class:: incremental - -You made a minor modification to our model class and wrote a test for it. - -.. class:: incremental - -And you installed the Django Admin site and added your app to it. - - -Going Further -------------- - -One of the most common features in a blog is the ability to categorize posts. - -.. class:: incremental - -Let's add this feature to our blog! - -.. class:: incremental - -To do so, we'll be adding a new model, and making some changes to existing code. - -.. class:: incremental - -This means that we'll need to *change our database schema*. - - -Changing a Database -------------------- - -You've seen how to add new tables to a database using the ``syncdb`` command. - -.. class:: incremental - -The ``syncdb`` management command only creates tables that *do not yet exist*. -It **does not update tables**. - -.. class:: incremental - -The ``sqlclear `` command will print the ``DROP TABLE`` statements to -remove the tables for your app. - -.. class:: incremental - -Or ``sql `` will show the ``CREATE TABLE`` statements, and you can work -out the differences and update manually. - -ACK!!! ------- - -That doesn't sound very nice, does it? - -.. class:: incremental - -Luckily, there is an app available for Django that helps with this: ``South`` - -.. class:: incremental - -South allows you to incrementally update your database in a simplified way. - -.. class:: incremental - -South supports forward, backward and data migrations. - -.. class:: incremental - - -Adding South ------------- - -South is so useful, that in Django 1.7 it will become part of the core -distribution of Django. - -.. class:: incremental - -But now it is not. We need to add it, and set up our project to use it. - -.. class:: incremental - -Activate your django virtualenv and install South: - -.. code-block:: bash - - $ source djagnoenv/bin/activate - (djangoenv)$ pip install south - ... - Successfully installed south - Cleaning up... - - -Installing South ----------------- - -Like other Django apps, South provides models of its own. We need to enable them. - -.. container:: incremental - - First, add ``south`` to your list of installed apps in ``settings.py``: - - .. code-block:: python - - INSTALLED_APPS = ( - ... - 'south', #< -add this line - 'myblog', - ) - - -Setting Up South ----------------- - -Then, run ``syncdb`` to pick up the tables it provides: - -.. code-block:: bash - - (djangoenv)$ python manage.py syncdb - Syncing... - Creating tables ... - Creating table south_migrationhistory - ... - - Synced: - ... - > south - > myblog - - Not synced (use migrations): - - - (use ./manage.py migrate to migrate these) - - -Hang On, What Just Happened? ----------------------------- - -You might have noticed that the output from ``syncdb`` looks a bit different -this time. - -.. class:: incremental - -This is because Django apps that use South do not use the normal ``syncdb`` -command to initialize their SQL. - -.. class:: incremental - -Instead they use a new command that South provides: ``migrate``. - -.. class:: incremental - -This command ensures that only incremental changes are made, rather than -creating all of the SQL for an app every time. - - -Adding South to an App ----------------------- - -If you notice, our ``myblog`` app is still in the ``sync`` list. We need to add -South to it. - -.. class:: incremental - -Adding South to an existing Django project is quite simple. The trick is to do -it **before** you make any new changes to your models. - -.. container:: incremental - - Simply use the ``convert_to_south`` management command, providing the name of - your app as an argument: - - .. code-block:: bash - - (djangoenv)$ python manage.py convert_to_south myblog - ... - - -What You Get ------------- - -After running this command, South will automatically create a first migration -for you that sets up tables looking exactly like what your app has now:: - - myblog/ - ├── __init__.py - ... - ├── migrations - │   ├── 0001_initial.py - │   ├── 0001_initial.pyc - │   ├── __init__.py - │   └── __init__.pyc - ├── models.py - ... - -.. class:: incremental - -South also automatically applies this first migration using the ``--fake`` -argument, since the database is already in the proposed state. - - -Adding a Model --------------- - -We want to add a new model to represent the categories our blog posts might -fall into. - -.. class:: incremental - -This model will need to have a name for the category, a longer description and -will need to be related to the Post model. - -.. code-block:: python - :class: small - - # in models.py - class Category(models.Model): - name = models.CharField(max_length=128) - description = models.TextField(blank=True) - posts = models.ManyToManyField(Post, blank=True, null=True, - related_name='categories') - - -Strange Relationships ---------------------- - -In our ``Post`` model, we used a ``ForeignKeyField`` field to match an author -to her posts. - -.. class:: incremental - -This models the situatin in which a single author can have many posts, while -each post has only one author. - -.. class:: incremental - -But any given ``Post`` might belong in more than one ``Category``. - -.. class:: incremental - -And it would be a waste to allow only one ``Post`` for each ``Category``. - -.. class:: incremental - -Enter the ManyToManyField - - -Add a Migration ---------------- - -To get these changes set up, we now have to add a migration. - -.. class:: incremental - -We use the ``schemamigration`` management command to do so: - -.. code-block:: bash - - (djangoenv)$ python manage.py schemamigration myblog --auto - + Added model myblog.Category - + Added M2M table for posts on myblog.Category - Created 0002_auto__add_category.py. You can now apply this - migration with: ./manage.py migrate myblog - - -Apply A Migration ------------------ - -And south, along with making the migration, helpfully tells us what to do next: - -.. code-block:: bash - - (djangoenv)$ python manage.py migrate myblog - Running migrations for myblog: - - Migrating forwards to 0002_auto__add_category. - > myblog:0002_auto__add_category - - Loading initial data for myblog. - Installed 0 object(s) from 0 fixture(s) - -.. class:: incremental - -You can even look at the migration file you just applied, -``myblog/migrations/0002.py`` to see what happened. - - -Make Categories Look Nice -------------------------- - -Let's make ``Category`` object look nice the same way we did with ``Post``. -Start with a test: - -.. container:: incremental - - add this to ``tests.py``: - - .. code-block:: python - :class: incremental - - # another import - from myblog.models import Category - - # and the test case and test - class CategoryTestCase(TestCase): - - def test_unicode(self): - expected = "A Category" - c1 = Category(name=expected) - actual = unicode(c1) - self.assertEqual(expected, actual) - -Make it Pass ------------- - -Do you remember how you made that change for a ``Post``? - -.. code-block:: python - :class: incremental - - class Category(models.Model): - #... - - def __unicode__(self): - return self.name - - -Admin for Categories --------------------- - -Adding our new model to the Django admin is equally simple. - -.. container:: incremental - - Simply add the following line to ``myblog/admin.py`` - - .. code-block:: python - - # a new import - from myblog.models import Category - - # and a new admin registration - admin.site.register(Category) - - -Test It Out ------------ - -Fire up the Django development server and see what you have in the admin: - -.. code-block:: bash - - (djangoenv)$ python manage.py runserver - Validating models... - ... - Starting development server at http://127.0.0.1:8000/ - Quit the server with CONTROL-C. - -.. class:: incremental - -Point your browser at ``http://localhost:8000/admin/``, log in and play. - -.. class:: incremental - -Add a few categories, put some posts in them. Visit your posts, add new ones -and then categorize them. - - -A Public Face -------------- - -Point your browser at http://localhost:8000/ - -.. class:: incremental - -What do you see? - -.. class:: incremental - -Why? - -.. class:: incremental - -We need to add some public pages for our blog. - -.. class:: incremental - -In Django, the code that builds a page that you can see is called a *view*. - -Django Views ------------- - -A *view* can be defined as a *callable* that takes a request and returns a -response. - -.. class:: incremental - -This should sound pretty familiar to you. - -.. class:: incremental - -Classically, Django views were functions. - -.. class:: incremental - -Version 1.3 added support for Class-based Views (a class with a ``__call__`` -method is a callable) - - -A Basic View ------------- - -Let's add a really simple view to our app. - -.. class:: incremental - -It will be a stub for our public UI. Add this to ``views.py`` in ``myblog`` - -.. code-block:: python - :class: small incremental - - from django.http import HttpResponse, HttpResponseRedirect, Http404 - - def stub_view(request, *args, **kwargs): - body = "Stub View\n\n" - if args: - body += "Args:\n" - body += "\n".join(["\t%s" % a for a in args]) - if kwargs: - body += "Kwargs:\n" - body += "\n".join(["\t%s: %s" % i for i in kwargs.items()]) - return HttpResponse(body, content_type="text/plain") - - -Hooking It Up -------------- - -In your homework tutorial, you learned about Django **urlconfs** - -.. class:: incremental - -We used our project urlconf to hook the Django admin into our project. - -.. class:: incremental - -We want to do the same thing for our new app. - -.. class:: incremental - -In general, an *app* that serves any sort of views should contain its own -urlconf. - -.. class:: incremental - -The project urlconf should mainly *include* these where possible. - - -Adding A Urlconf ----------------- - -Create a new file ``urls.py`` inside the ``myblog`` app package. - -.. container:: incremental - - Open it in your editor and add the following code: - - .. code-block:: python - :class: small - - from django.conf.urls import patterns, url - - urlpatterns = patterns('myblog.views', - url(r'^$', - 'stub_view', - name="blog_index"), - ) - - -A Word On Prefixes ------------------- - -The ``patterns`` function takes a first argument called the *prefix* - -.. class:: incremental - -When it is not empty, it is added to any view names in ``url()`` calls in the -same ``patterns``. - -.. class:: incremental - -In a root urlconf like the one in ``mysite``, this isn't too useful - -.. class:: incremental - -But in ``myblog.urls`` it lets us refer to views by simple function name - -.. class:: incremental - -No need to import every view. - - -Include Blog Urls ------------------ - -In order for our new urls to load, we'll need to include them in our project -urlconf - -.. container:: incremental - - Open ``urls.py`` from the ``mysite`` project package and add this: - - .. code-block:: python - :class: small - - urlpatterns = patterns('', - url(r'^', include('myblog.urls')), #<- add this - #... other included urls - ) - -.. class:: incremental - -Try reloading http://localhost:8000/ - -.. class:: incremental - -You should see some output now. - - -Project URL Space ------------------ - -A project is defined by the urls a user can visit. - -.. class:: incremental - -What should our users be able to see when they visit our blog? - -.. class:: incremental - -* A list view that shows blog posts, most recent first. -* An individual post view, showing a single post (a permalink). - -.. class:: incremental - -Let's add urls for each of these, use the stub view for now. - - -Our URLs --------- - -We've already got a good url for the list page: ``blog_index`` at '/' - -.. container:: incremental - - For the view of a single post, we'll need to capture the id of the post. - Add this to ``urlpatterns`` in ``myblog/urls.py``: - - .. code-block:: python - :class: small incremental - - url(r'^posts/(\d+)/$', - 'stub_view', - name="blog_detail"), - -.. class:: incremental - -``(\d+)`` captures one or more digits as the post_id. - -.. class:: incremental - -Load http://localhost:8000/posts/1234/ and see what you get. - - -A Word on Capture in URLs -------------------------- - -When you load the above url, you should see ``1234`` listed as an *arg* - -.. container:: incremental - - Try changing the route like so: - - .. code-block:: python - :class: small - - r'^posts/(?P\d+)/$' - -.. class:: incremental - -Reload the same url. Notice the change. - - -Regular Expression URLS ------------------------ - -Django, unlike Flask, uses Python regular expressions to build routes. - -.. class:: incremental - -When we built our WSGI book app, we did too. - -.. class:: incremental - -There we learned about regular expression *capture groups*. We just changed an -unnamed group to a named one. - -.. class:: incremental - -How you declare a capture group in your url pattern regexp influences how it -will be passed to the view callable. - - -Full Urlconf ------------- - -.. code-block:: python - :class: small - - from django.conf.urls import patterns, url - - urlpatterns = patterns('myblog.views', - url(r'^$', - 'stub_view', - name="blog_index"), - url(r'^posts/(?P\d+)/$', - 'stub_view', - name="blog_detail"), - ) - - -Testing Views -------------- - -Before we begin writing real views, we need to add some tests for the views we -are about to create. - -.. class:: incremental - -We'll need tests for a list view and a detail view - -.. container:: incremental - - add the following *imports* at the top of ``myblog/tests.py``: - - .. code-block:: python - - import datetime - from django.utils.timezone import utc - - -Add a Test Case ---------------- - -.. code-block:: python - :class: small - - class FrontEndTestCase(TestCase): - """test views provided in the front-end""" - fixtures = ['myblog_test_fixture.json', ] - - def setUp(self): - self.now = datetime.datetime.utcnow().replace(tzinfo=utc) - self.timedelta = datetime.timedelta(15) - author = User.objects.get(pk=1) - for count in range(1,11): - post = Post(title="Post %d Title" % count, - text="foo", - author=author) - if count < 6: - # publish the first five posts - pubdate = self.now - self.timedelta * count - post.published_date = pubdate - post.save() - - -Our List View -------------- - -We'd like our list view to show our posts. - -.. class:: incremental - -But in this blog, we have the ability to publish posts. - -.. class:: incremental - -Unpublished posts should not be seen in the front-end views. - -.. class:: incremental - -We set up our tests to have 5 published, and 5 unpublished posts - -.. class:: incremental - -Let's add a test to demonstrate that the right ones show up. - - -Testing the List View ---------------------- - -.. code-block:: python - - Class FrontEndTestCase(TestCase): # already here - # ... - def test_list_only_published(self): - resp = self.client.get('/') - self.assertTrue("Recent Posts" in resp.content) - for count in range(1,11): - title = "Post %d Title" % count - if count < 6: - self.assertContains(resp, title, count=1) - else: - self.assertNotContains(resp, title) - -.. class:: incremental - -Note that we also test to ensure that the unpublished posts are *not* visible. - - -Run Your Tests --------------- - -.. code-block:: bash - - (djangoenv)$ python manage.py test myblog - Creating test database for alias 'default'... - .F. - ====================================================================== - FAIL: test_list_only_published (myblog.tests.FrontEndTestCase) - ... - Ran 3 tests in 0.024s - - FAILED (failures=1) - Destroying test database for alias 'default'... - - -Now Fix That Test! ------------------- - -Add the view for listing blog posts to ``views.py``. - -.. code-block:: python - :class: small - - # add these imports - from django.template import RequestContext, loader - from myblog.models import Post - - # and this view - def list_view(request): - published = Post.objects.exclude(published_date__exact=None) - posts = published.order_by('-published_date') - template = loader.get_template('list.html') - context = RequestContext(request, { - 'posts': posts, - }) - body = template.render(context) - return HttpResponse(body, content_type="text/html") - - -Getting Posts -------------- - -.. code-block:: python - :class: small - - published = Post.objects.exclude(published_date__exact=None) - posts = published.order_by('-published_date') - -.. class:: incremental - -We begin by using the QuerySet API to fetch all the posts that have -``published_date`` set - -.. class:: incremental - -Using the chaining nature of the API we order these posts by -``published_date`` - -.. class:: incremental - -Remember, at this point, no query has actually been issued to the database. - - -Getting a Template ------------------- - -.. code-block:: python - :class: small - - template = loader.get_template('list.html') - -.. class:: incremental - -Django uses configuration to determine how to find templates. - -.. class:: incremental - -By default, Django looks in installed *apps* for a ``templates`` directory - -.. class:: incremental - -It also provides a place to list specific directories. - -.. class:: incremental - -Let's set that up in ``settings.py`` - - -Project Templates ------------------ - -In ``settings.py`` add ``TEMPLATE_DIRS`` and add the absolute path to your -``mysite`` project package: - -.. code-block:: python - :class: small - - TEMPLATE_DIRS = ('/absolute/path/to/mysite/mysite/templates', ) - -.. class:: incremental - -Then add a ``templates`` directory to your ``mysite`` project package - -.. class:: incremental - -Finally, in that directory add a new file ``base.html`` and populate it with -the following: - - -base.html ---------- - -.. code-block:: jinja - :class: small - - - - - My Django Blog - - -

-
- {% block content %} - [content will go here] - {% endblock %} -
-
- - - - -Templates in Django -------------------- - -Before we move on, a quick word about Django templates. - -.. class:: incremental - -We've seen Jinja2 which was "inspired by Django's templating system". - -.. class:: incremental - -Basically, you already know how to write Django templates. - -.. class:: incremental - -Django templates **do not** allow any python expressions. - -.. class:: incremental center small - -https://docs.djangoproject.com/en/1.5/ref/templates/builtins/ - - -Blog Templates --------------- - -Our view tries to load ``list.html``. - -.. class:: incremental - -This template is probably specific to the blog functionality of our site - -.. class:: incremental - -It is common to keep shared templates in your project directory and -specialized ones in app directories. - -.. class:: incremental - -Add a ``templates`` directory to your ``myblog`` app, too. - -.. class:: incremental - -In it, create a new file ``list.html`` and add this: - - -list.html ---------- - -.. code-block:: jinja - :class: tiny - - {% extends "base.html" %} - - {% block content %} -

Recent Posts

- - {% comment %} here is where the query happens {% endcomment %} - {% for post in posts %} -
-

{{ post }}

- -
- {{ post.text }} -
-
    - {% for category in post.categories.all %} -
  • {{ category }}
  • - {% endfor %} -
-
- {% endfor %} - {% endblock %} - - -Template Context ----------------- - -.. code-block:: python - :class: small - - context = RequestContext(request, { - 'posts': posts, - }) - body = template.render(context) - -.. class:: incremental - -Like Jinja2, django templates are rendered by passing in a *context* - -.. class:: incremental - -Django's RequestContext provides common bits, similar to the global context in -Flask - -.. class:: incremental - -We add our posts to that context so they can be used by the template. - - -Return a Response ------------------ - -.. code-block:: python - :class: small - - return HttpResponse(body, content_type="text/html") - -.. class:: incremental - -Finally, we build an HttpResponse and return it. - -.. class:: incremental - -This is, fundamentally, no different from the ``stub_view`` just above. - - -Fix URLs --------- - -We need to fix the url for our blog index page - -.. container:: incremental - - Update ``urls.py`` in ``myblog``: - - .. code-block:: python - :class: small - - url(r'^$', - 'list_view', - name="blog_index"), - -.. class:: incremental small - -:: - - (djangoenv)$ python manage.py test myblog - ... - Ran 3 tests in 0.033s - - OK - - -Common Patterns ---------------- - -This is a common pattern in Django views: - -.. class:: incremental - -* get a template from the loader -* build a context, usually using a RequestContext -* render the template -* return an HttpResponse - -.. class:: incremental - -So common in fact that Django provides two shortcuts for us to use: - -.. class:: incremental - -* ``render(request, template[, ctx][, ctx_instance])`` -* ``render_to_response(template[, ctx][, ctx_instance])`` - - -Shorten Our View ----------------- - -Let's replace most of our view with the ``render`` shortcut - -.. code-block:: python - :class: small - - from django.shortcuts import render # <- already there - - # rewrite our view - def list_view(request): - published = Post.objects.exclude(published_date__exact=None) - posts = published.order_by('-published_date') - context = {'posts': posts} - return render(request, 'list.html', context) - -.. class:: incremental - -Remember though, all we did manually before is still happening - - -Our Detail View ---------------- - -Next, let's add a view function for the detail view of a post - -.. class:: incremental - -It will need to get the ``id`` of the post to show as an argument - -.. class:: incremental - -Like the list view, it should only show published posts - -.. class:: incremental - -But unlike the list view, it will need to return *something* if an unpublished -post is requested. - -.. class:: incremental - -Let's start with the tests in ``views.py`` - - -Testing the Details -------------------- - -Add the following test to our ``FrontEndTestCase`` in ``myblog/tests.py``: - -.. code-block:: python - :class: small incremental - - def test_details_only_published(self): - for count in range(1,11): - title = "Post %d Title" % count - post = Post.objects.get(title=title) - resp = self.client.get('/posts/%d/' % post.pk) - if count < 6: - self.assertEqual(resp.status_code, 200) - self.assertContains(resp, title) - else: - self.assertEqual(resp.status_code, 404) - - -Run Your Tests --------------- - -.. code-block:: bash - - (djangoenv)$ python manage.py test myblog - Creating test database for alias 'default'... - .F.. - ====================================================================== - FAIL: test_details_only_published (myblog.tests.FrontEndTestCase) - ... - Ran 4 tests in 0.043s - - FAILED (failures=1) - Destroying test database for alias 'default'... - - -Let's Fix That Test -------------------- - -Now, add a new view to ``myblog/views.py``: - -.. code-block:: python - :class: incremental small - - def detail_view(request, post_id): - published = Post.objects.exclude(published_date__exact=None) - try: - post = published.get(pk=post_id) - except Post.DoesNotExist: - raise Http404 - context = {'post': post} - return render(request, 'detail.html', context) - - -Missing Content ---------------- - -One of the features of the Django ORM is that all models raise a DoesNotExist -exception if ``get`` returns nothing. - -.. class:: incremental - -This exception is actually an attribute of the Model you look for. There's also -an ``ObjectDoesNotExist`` for when you don't know which model you have. - -.. class:: incremental - -We can use that fact to raise a Not Found exception. - -.. class:: incremental - -Django will handle the rest for us. - - -Add the Template ----------------- - -We also need to add ``detail.html`` to ``myblog/templates``: - -.. code-block:: jinja - :class: tiny - - {% extends "base.html" %} - - {% block content %} - Home -

{{ post }}

- -
- {{ post.text }} -
-
    - {% for category in post.categories.all %} -
  • {{ category }}
  • - {% endfor %} -
- {% endblock %} - - -Hook it Up ----------- - -In order to view a single post, we'll need a link from the list view - -.. container:: incremental - - We can use the ``url`` template tag (like flask ``url_for``): - - .. code-block:: jinja - :class: small - - {% url '' arg1 arg2 %} - -.. class:: incremental - -In our ``list.html`` template, let's link the post titles: - -.. code-block:: jinja - :class: small incremental - - {% for post in posts %} -
-

- {{ post }} -

- ... - - -Fix URLs --------- - -Again, we need to insert our new view into the existing ``myblog/urls.py`` in -``myblog``: - -.. code-block:: python - :class: small - - url(r'^posts/(?P\d+)/$', - 'detail_view', - name="blog_detail"), - -.. class:: incremental small - -:: - - (djangoenv)$ python manage.py test myblog - ... - Ran 4 tests in 0.077s - - OK - - -A Moment To Play ----------------- - -We've got some good stuff to look at now. Fire up the server - -.. class:: incremental - -Reload your blog index page and click around a bit. - -.. class:: incremental - -You can now move back and forth between list and detail view. - -.. class:: incremental - -Try loading the detail view for a post that doesn't exist - - -Congratulations ---------------- - -You've got a functional Blog - -.. class:: incremental - -It's not very pretty, though. - -.. class:: incremental - -We can fix that by adding some css - -.. class:: incremental - -This gives us a chance to learn about Django's handling of *static files* - - -Static Files ------------- - -Like templates, Django expects to find static files in particular locations - -.. class:: incremental - -It will look for them in a directory named ``static`` in any installed apps. - -.. class:: incremental - -They will be served from the url path in the STATIC_URL setting. - -.. class:: incremental - -By default, this is ``/static/`` - - -Add CSS -------- - -I've prepared a css file for us to use. You can find it in the class resources - -.. class:: incremental - -Create a new directory ``static`` in the ``myblog`` app. - -.. class:: incremental - -Copy the ``django_blog.css`` file into that new directory. - -.. container:: incremental - - Then add this link to the of ``base.html``: - - .. code-block:: html - :class: small - - My Django Blog - - - -View Your Results ------------------ - -Reload http://localhost:8000/ and view the results of your work - -.. class:: incremental - -We now have a reasonable view of the posts of our blog on the front end - -.. class:: incremental - -And we have a way to create and categorize posts using the admin - -.. class:: incremental - -However, we lack a way to move between the two. - -.. class:: incremental - -Let's add that ability next. - - -Adding A Control Bar --------------------- - -We'll start by adding a control bar to our ``base.html`` template: - -.. code-block:: jinja - :class: small - - - ... - -
- ... - - -Request Context Revisited -------------------------- - -When we set up our views, we used the ``render`` shortcut, which provides a -``RequestContext`` - -.. class:: incremental - -This gives us access to ``user`` in our templates - -.. class:: incremental - -It provides access to methods about the state and rights of that user - -.. class:: incremental - -We can use these to conditionally display links or UI elements. Like only -showing the admin link to staff members. - - -Login/Logout ------------- - -Django also provides a reasonable set of views for login/logout. - -.. class:: incremental - -The first step to using them is to hook them into a urlconf. - -.. container:: incremental - - Add the following to ``mysite/urls.py``: - - .. code-block:: python - :class: small - - url(r'^', include('myblog.urls')), #<- already there - url(r'^login/$', - 'django.contrib.auth.views.login', - {'template_name': 'login.html'}, - name="login"), - url(r'^logout/$', - 'django.contrib.auth.views.logout', - {'next_page': '/'}, - name="logout"), - - -Login Template --------------- - -We need to create a new ``login.html`` template in ``mysite/templates``: - -.. code-block:: jinja - :class: small - - {% extends "base.html" %} - - {% block content %} -

My Blog Login

-
{% csrf_token %} - {{ form.as_p }} -

-
- {% endblock %} - - -Submitting Forms ----------------- - -In a web application, submitting forms is potentially hazardous - -.. class:: incremental - -Data is being sent to our application from some remote place - -.. class:: incremental - -If that data is going to alter the state of our application, we **must** use -POST - -.. class:: incremental - -Even so, we are vulnerable to Cross-Site Request Forgery, a common attack -vector. - - -Danger: CSRF ------------- - -Django provides a convenient system to fight this. - -.. class:: incremental - -In fact, for POST requests, it *requires* that you use it. - -.. class:: incremental - -The Django middleware that does this is enabled by default. - -.. class:: incremental - -All you need to do is include the ``{% csrf_token %}`` tag in your form. - - -Hooking It Up -------------- - -In ``base.html`` make the following updates: - -.. code-block:: jinja - :class: small - - - admin - - logout - - login - -.. container:: incremental - - Finally, in ``settings.py`` add the following: - - .. code-block:: python - :class: small - - LOGIN_URL = '/login/' - LOGIN_REDIRECT_URL = '/' - - -Forms In Django ---------------- - -In adding a login view, we've gotten a sneak peak at how forms work in Django. - -.. class:: incremental - -However, learning more about them is beyond what we can achieve in this -session. - -.. class:: incremental - -The form system in Django is quite nice, however. I urge you to `read more about it`_ - -.. _read more about it: https://docs.djangoproject.com/en/1.6/topics/forms/ - -.. class:: incremental - -In particular, you might want to pay attention to the documentation on `Model Forms` - -.. _Model Forms: https://docs.djangoproject.com/en/1.6/topics/forms/modelforms/ - - -Ta-Daaaaaa! ------------ - -So, that's it. We've created a workable, simple blog app in Django. - -.. class:: incremental - -There's much more we could do with this app. And for homework, you'll do some -of it. - -.. class:: incremental - -Then next session, we'll work as we did in session 6. - -.. class:: incremental - -We'll divide up into pairs, and implement a simple feature to extend our blog. - - -Homework --------- - -For your homework this week, we'll fix one glaring problem with our blog admin. - -.. class:: incremental - -As you created new categories and posts, and related them to each-other, how -did you feel about that work? - -.. class:: incremental - -Although from a data perspective, the category model is the right place for the -ManytoMany relationship to posts, this leads to awkward usage in the admin. - -.. class:: incremental - -It would be much easier if we could designate a category for a post *from the -Post admin*. - - -Your Assignment ---------------- - -You'll be reversing that relationship so that you can only add categories to posts - -Take the following steps: - -1. Read the documentation about the `Django admin.`_ -2. You'll need to create a customized `ModelAdmin`_ class for the ``Post`` and - ``Category`` models. -3. And you'll need to create an `InlineModelAdmin`_ to represent Categories on - the Post admin view. -4. Finally, you'll need to `suppress the display`_ of the 'posts' field on - your ``Category`` admin view. - - -.. _Django admin.: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/ -.. _ModelAdmin: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#modeladmin-objects -.. _InlineModelAdmin: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#inlinemodeladmin-objects -.. _suppress the display: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/#modeladmin-options - - -Pushing Further ---------------- - -All told, those changes should not require more than about 15 total lines of -code. - -The trick of course is reading and finding out which fifteen lines to write. - -If you complete that task in less than 3-4 hours of work, consider looking into -other ways of customizing the admin. - - -Tasks you might consider ------------------------- - -* Change the admin index to say 'Categories' instead of 'Categorys'. -* Add columns for the date fields to the list display of Posts. -* Display the created and modified dates for your posts when viewing them in - the admin. -* Add a column to the list display of Posts that shows the author. For more - fun, make this a link that takes you to the admin page for that user. - -* For the biggest challenge, look into `admin actions`_ and add an action to - the Post admin that allows you to bulk publish posts from the Post list - display - -.. _admin actions: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/actions/ diff --git a/source/presentations/session08.rst b/source/presentations/session08.rst new file mode 100644 index 00000000..76a08bb7 --- /dev/null +++ b/source/presentations/session08.rst @@ -0,0 +1,1522 @@ +********** +Session 08 +********** + +.. figure:: /_static/django-pony.png + :align: center + :width: 60% + + image: http://djangopony.com/ + +Building a Django Application +============================= + +.. rst-class:: large + +Wherein we build a simple blogging app. + + +A Full Stack Framework +---------------------- + +Django comes with: + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * Persistence via the *Django ORM* + * CRUD content editing via the automatic *Django Admin* + * URL Mapping via *urlpatterns* + * Templating via the *Django Template Language* + * Caching with levels of configurability + * Internationalization via i18n hooks + * Form rendering and handling + * User authentication and authorization + + Pretty much everything you need to make a solid website quickly + +.. nextslide:: What Sets it Apart? + +Lots of frameworks offer some of these features, if not all. + +.. rst-class:: build +.. container:: + + What is Django's *killer feature* + + .. rst-class:: centered + + **The Django Admin** + +.. nextslide:: The Django Admin + +Works in concert with the Django ORM to provide automatic CRUD functionality + +.. rst-class:: build +.. container:: + + You write the models, it provides the UI + + You've seen this in action. Pretty neat, eh? + +.. nextslide:: The Pareto Principle + +The Django Admin is a great example of the Pareto Priciple, a.k.a. the 80/20 +rule: + +.. rst-class:: build +.. container:: + + .. rst-class:: centered + + **80% of the problems can be solved by 20% of the effort** + + The converse also holds true: + + .. rst-class:: centered + + **Fixing the last 20% of the problems will take the remaining 80% of the + effort.** + +.. nextslide:: Other Django Advantages + +.. ifnotslides:: + + **Other Django Advantages** + +Clearly the most popular full-stack Python web framework at this time + +.. rst-class:: build +.. container:: + + Popularity translates into: + + .. rst-class:: build + + * Active, present community + * Plethora of good examples to be found online + * Rich ecosystem of *apps* (encapsulated add-on functionality) + + .. rst-class:: centered + + **Jobs** + +.. nextslide:: Active Development + +Django releases in the last 12+ months (a short list): + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * 1.9 (December 2015) + * 1.8.7 (November 2015) + * 1.7.11 (November 2015) + * 1.8.5 (October 2015) + * 1.7.10 (August 2015) + * 1.8.3 (July 2015) + * 1.8 (April 2015) + * 1.7.7 (March 2015) + * 1.7.4 (January 2014) + + Django 1.8 is the second *Long Term Support* version, with a guaranteed support + period of three years. + +.. nextslide:: Great Documentation + +Thorough, readable, and discoverable. + +.. rst-class:: build +.. container:: + + Led the way to better documentation for all Python + + `Read The Docs `_ - built in connection with + Django, sponsored by the Django Software Foundation. + + Write documentation as part of your python package. + + Render new versions of that documentation for every commit. + + .. rst-class:: centered + + **this is awesome** + + +Where We Stand +-------------- + +For your homework this week, you created a ``Post`` model to serve as the heart +of our blogging app. + +.. rst-class:: build +.. container:: + + You also took some time to get familiar with the basic workings of the + Django ORM. + + You made a minor modification to our model class and wrote a test for it. + + And you installed the Django Admin site and added your app to it. + + +Going Further +------------- + +One of the most common features in a blog is the ability to categorize posts. + +.. rst-class:: build +.. container:: + + Let's add this feature to our blog! + + To do so, we'll be adding a new model, and making some changes to existing + code. + + .. rst-class:: build + + This means that we'll need to *change our database schema*. + + +.. nextslide:: Changing a Database + +You've seen how to add new tables to a database using the ``migrate`` command. + +.. rst-class:: build +.. container:: + + And you've created your first migration in setting up the ``Post`` model. + + This is an example of altering the *database schema* using Python code. + + Starting in Django 1.7, this ability is available built-in to Django. + + Before verson 1.7 it was available in an add-on called `South`_. + +.. _South: http://south.readthedocs.org/en/latest + + +.. nextslide:: Adding a Model + +We want to add a new model to represent the categories our blog posts might +fall into. + +.. rst-class:: build +.. container:: + + This model will need to have: + + .. rst-class:: build + + * a name for the category + * a longer description + * a relationship to the Post model + + .. code-block:: python + + # in models.py + class Category(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + posts = models.ManyToManyField(Post, blank=True, + related_name='categories') + + +.. nextslide:: Strange Relationships + +In our ``Post`` model, we used a ``ForeignKeyField`` field to match an author +to her posts. + +.. rst-class:: build +.. container:: + + This models the situation in which a single author can have many posts, + while each post has only one author. + + We call this a *Many to One* relationship. + + But any given ``Post`` might belong in more than one ``Category``. + + And it would be a waste to allow only one ``Post`` for each ``Category``. + + Enter the ``ManyToManyField`` + +.. nextslide:: Add a Migration + +To get these changes set up, we now add a new migration. + +.. rst-class:: build +.. container:: + + We use the ``makemigrations`` management command to do so: + + .. code-block:: bash + + (djangoenv)$ ./manage.py makemigrations + Migrations for 'myblog': + 0002_category.py: + - Create model Category + +.. nextslide:: Apply A Migration + +Once the migration has been created, we can apply it with the ``migrate`` +management command. + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (djangoenv)$ ./manage.py migrate + Operations to perform: + Apply all migrations: sessions, contenttypes, admin, myblog, auth + Running migrations: + Rendering model states... DONE + Applying myblog.0002_category... OK + + You can even look at the migration file you just applied, + ``myblog/migrations/0002_category.py`` to see what happened. + + +.. nextslide:: Make Categories Look Nice + +Let's make ``Category`` object look nice the same way we did with ``Post``. +Start with a test: + +.. rst-class:: build +.. container:: + + add this to ``tests.py``: + + .. code-block:: python + + # another import + from myblog.models import Category + + # and the test case and test + class CategoryTestCase(TestCase): + + def test_string_representation(self): + expected = "A Category" + c1 = Category(name=expected) + actual = str(c1) + self.assertEqual(expected, actual) + +.. nextslide:: Make it Pass + +When you run your tests, you now have two, and one is failing because the +``Category`` object doesn't look right. + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + (djangoenv)$ ./manage.py test myblog + Creating test database for alias 'default'... + ... + + Ran 2 tests in 0.011s + + FAILED (failures=1) + + Do you remember how you made that change for a ``Post``? + + .. code-block:: python + + class Category(models.Model): + #... + + def __str__(self): + return self.name + + +.. nextslide:: Admin for Categories + +Adding our new model to the Django admin is equally simple. + +.. rst-class:: build +.. container:: + + Simply add the following line to ``myblog/admin.py`` + + .. code-block:: python + + # a new import + from myblog.models import Category + + # and a new admin registration + admin.site.register(Category) + + +.. nextslide:: Test It Out + +Fire up the Django development server and see what you have in the admin: + +.. code-block:: bash + + (djangoenv)$ ./manage.py runserver + Validating models... + ... + Starting development server at http://127.0.0.1:8000/ + Quit the server with CONTROL-C. + +.. rst-class:: build +.. container:: + + Point your browser at ``http://localhost:8000/admin/``, log in and play. + + Add a few categories, put some posts in them. Visit your posts, add new + ones and then categorize them. + + +BREAK TIME +---------- + +We've completed a data model for our application. + +And thanks to Django's easy-to-use admin, we have a reasonable CRUD application +where we can manage blog posts and the categories we put them in. + +When we return, we'll put a public face on our new creation. + +If you've fallen behind, the app as it stands now is in our class resources as +``mysite_stage_1`` + + +A Public Face +============= + +.. rst-class:: left + +Point your browser at http://localhost:8000/ + +.. rst-class:: build left +.. container:: + + What do you see? + + Why? + + We need to add some public pages for our blog. + + In Django, the code that builds a page that you can see is called a *view*. + + +Django Views +------------ + +A *view* can be defined as a *callable* that takes a request and returns a +response. + +.. rst-class:: build +.. container:: + + This should sound pretty familiar to you. + + Classically, Django views were functions. + + Version 1.3 added support for Class-based Views (a class with a + ``__call__`` method is a callable) + + +.. nextslide:: A Basic View + +Let's add a really simple view to our app. + +.. rst-class:: build +.. container:: + + It will be a stub for our public UI. Add this to ``views.py`` in + ``myblog`` + + .. code-block:: python + + from django.http import HttpResponse, HttpResponseRedirect, Http404 + + def stub_view(request, *args, **kwargs): + body = "Stub View\n\n" + if args: + body += "Args:\n" + body += "\n".join(["\t%s" % a for a in args]) + if kwargs: + body += "Kwargs:\n" + body += "\n".join(["\t%s: %s" % i for i in kwargs.items()]) + return HttpResponse(body, content_type="text/plain") + +.. nextslide:: Hooking It Up + +In your homework tutorial, you learned about Django **urlconfs** + +.. rst-class:: build +.. container:: + + We used our project urlconf to hook the Django admin into our project. + + We want to do the same thing for our new app. + + In general, an *app* that serves any sort of views should contain its own + urlconf. + + The project urlconf should mainly *include* these where possible. + + +.. nextslide:: Adding A Urlconf + +Create a new file ``urls.py`` inside the ``myblog`` app package. + +.. rst-class:: build +.. container:: + + Open it in your editor and add the following code: + + .. code-block:: python + + + from django.conf.urls import url + from myblog.views import stub_view + + urlpatterns = [ + url(r'^$', + stub_view, + name="blog_index"), + ] + + +.. nextslide:: Include Blog Urls + +In order for our new urls to load, we'll need to include them in our project +urlconf + +.. rst-class:: build +.. container:: + + Open ``urls.py`` from the ``mysite`` project package and add this: + + .. code-block:: python + + # add this new import + from django.conf.urls import include + + # then modify urlpatterns as follows: + urlpatterns = [ + url(r'^', include('myblog.urls')), #<- add this + #... other included urls + ] + + Try reloading http://localhost:8000/ + + You should see some output now. + + +Project URL Space +----------------- + +A project is defined by the urls a user can visit. + +.. rst-class:: build +.. container:: + + What should our users be able to see when they visit our blog? + + .. rst-class:: build + + * A list view that shows blog posts, most recent first. + * An individual post view, showing a single post (a permalink). + + Let's add urls for each of these. + + For now, we'll use the stub view we've created so we can concentrate on the + url routing. + +.. nextslide:: Our URLs + +We've already got a good url for the list page: ``blog_index`` at '/' + +.. rst-class:: build +.. container:: + + For the view of a single post, we'll need to capture the id of the post. + Add this to ``urlpatterns`` in ``myblog/urls.py``: + + .. code-block:: python + + url(r'^posts/(\d+)/$', + stub_view, + name="blog_detail"), + + ``(\d+)`` captures one or more digits as the post_id. + + Load http://localhost:8000/posts/1234/ and see what you get. + +.. nextslide:: A Word on Capture in URLs + +When you load the above url, you should see ``1234`` listed as an *arg* + +.. rst-class:: build +.. container:: + + Try changing the route like so: + + .. code-block:: python + + r'^posts/(?P\d+)/$' + + Reload the same url. + + Notice the change. + + What's going on there? + +.. nextslide:: Regular Expression URLS + +Like Pyramid, Django uses Python regular expressions to build routes. + +.. rst-class:: build +.. container:: + + Unlike Pyramid, Django *requires* regular expressions to capture segments + in a route. + + When we built our WSGI book app, we used this same appraoch. + + There we learned about regular expression *capture groups*. We just changed + an unnamed *capture group* to a named one. + + How you declare a capture group in your url pattern regexp influences how + it will be passed to the view callable. + + +.. nextslide:: Full Urlconf + +.. code-block:: python + + + from django.conf.urls import url + from myblog.views import stub_view + + urlpatterns = [ + url(r'^$', + stub_view, + name="blog_index"), + url(r'^posts/(?P\d+)/$', + stub_view, + name="blog_detail"), + ] + + +.. nextslide:: Testing Views + +Before we begin writing real views, we need to add some tests for the views we +are about to create. + +.. rst-class:: build +.. container:: + + We'll need tests for a list view and a detail view + + add the following *imports* at the top of ``myblog/tests.py``: + + .. code-block:: python + + import datetime + from django.utils.timezone import utc + + +.. nextslide:: Add a Test Case + +.. code-block:: python + + class FrontEndTestCase(TestCase): + """test views provided in the front-end""" + fixtures = ['myblog_test_fixture.json', ] + + def setUp(self): + self.now = datetime.datetime.utcnow().replace(tzinfo=utc) + self.timedelta = datetime.timedelta(15) + author = User.objects.get(pk=1) + for count in range(1, 11): + post = Post(title="Post %d Title" % count, + text="foo", + author=author) + if count < 6: + # publish the first five posts + pubdate = self.now - self.timedelta * count + post.published_date = pubdate + post.save() + + +Our List View +------------- + +We'd like our list view to show our posts. + +.. rst-class:: build +.. container:: + + But in this blog, we have the ability to publish posts. + + Unpublished posts should not be seen in the front-end views. + + We set up our tests to have 5 published, and 5 unpublished posts + + Let's add a test to demonstrate that the right ones show up. + +.. nextslide:: Testing the List View + +.. code-block:: python + + Class FrontEndTestCase(TestCase): # already here + # ... + def test_list_only_published(self): + resp = self.client.get('/') + # the content of the rendered response is always a bytestring + resp_text = resp.content.decode(resp.charset) + self.assertTrue("Recent Posts" in resp_text) + for count in range(1, 11): + title = "Post %d Title" % count + if count < 6: + self.assertContains(resp, title, count=1) + else: + self.assertNotContains(resp, title) + +.. rst-class:: build +.. container:: + + We test first to ensure that each published post is visible in our view. + + Note that we also test to ensure that the unpublished posts are *not* visible. + + +.. nextslide:: Run Your Tests + +.. code-block:: bash + + (djangoenv)$ ./manage.py test myblog + Creating test database for alias 'default'... + .F. + ====================================================================== + FAIL: test_list_only_published (myblog.tests.FrontEndTestCase) + ... + Ran 3 tests in 0.024s + + FAILED (failures=1) + Destroying test database for alias 'default'... + + +.. nextslide:: Now Fix That Test! + +Add the view for listing blog posts to ``views.py``. + +.. code-block:: python + + # add these imports + from django.template import RequestContext, loader + from myblog.models import Post + + # and this view + def list_view(request): + published = Post.objects.exclude(published_date__exact=None) + posts = published.order_by('-published_date') + template = loader.get_template('list.html') + context = RequestContext(request, { + 'posts': posts, + }) + body = template.render(context) + return HttpResponse(body, content_type="text/html") + + +.. nextslide:: Getting Posts + +.. code-block:: python + + published = Post.objects.exclude(published_date__exact=None) + posts = published.order_by('-published_date') + +.. rst-class:: build +.. container:: + + We begin by using the QuerySet API to fetch all the posts that have + ``published_date`` set + + Using the chaining nature of the API we order these posts by + ``published_date`` + + Remember, at this point, no query has actually been issued to the database. + + +.. nextslide:: Getting a Template + +.. code-block:: python + + template = loader.get_template('list.html') + +.. rst-class:: build +.. container:: + + Django uses configuration to determine how to find templates. + + By default, Django looks in installed *apps* for a ``templates`` directory + + It also provides a place to list specific directories. + + Let's set that up in ``settings.py`` + + +.. nextslide:: Project Templates + +Notice that ``settings.py`` already contains a ``BASE_DIR`` value which points +to the root of our project (where both the project and app packages are +located). + +.. rst-class:: build +.. container:: + + In that same file, you'll find a list bound to the symbol ``TEMPLATES``. + + That list contains one dict with an empty list at the key ``DIRS``. Update + that empty list as shown here: + + .. code-block:: python + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'mysite/templates')], + ... + }, + ] + + This will ensure that Django will look in your ``mysite`` project folder + for a directory containing templates. + +.. nextslide:: + +The ``mysite`` project folder does not contain a ``templates`` directory, add one. + +.. rst-class:: build +.. container:: + + Then, in that directory add a new file ``base.html`` and add the following: + + .. code-block:: jinja + + + + + My Django Blog + + +
+
+ {% block content %} + [content will go here] + {% endblock %} +
+
+ + + + +Templates in Django +------------------- + +Before we move on, a quick word about Django templates. + +.. rst-class:: build +.. container:: + + We've seen Jinja2 which was "inspired by Django's templating system". + + Basically, you already know how to write Django templates. + + Django templates **do not** allow any python expressions. + + https://docs.djangoproject.com/en/1.9/ref/templates/builtins/ + + +.. nextslide:: Blog Templates + +Our view tries to load ``list.html``. + +.. rst-class:: build +.. container:: + + This template is probably specific to the blog functionality of our site + + It is common to keep shared templates in your project directory and + specialized ones in app directories. + + Add a ``templates`` directory to your ``myblog`` app, too. + + In it, create a new file ``list.html`` and add this: + + +.. nextslide:: ``list.html`` + +.. code-block:: jinja + + {% extends "base.html" %}{% block content %} +

Recent Posts

+ {% comment %} here is where the query happens {% endcomment %} + {% for post in posts %} +
+

{{ post }}

+ +
+ {{ post.text }} +
+
    + {% for category in post.categories.all %} +
  • {{ category }}
  • + {% endfor %} +
+
+ {% endfor %} + {% endblock %} + + +.. nextslide:: Template Context + +.. code-block:: python + + context = RequestContext(request, { + 'posts': posts, + }) + body = template.render(context) + +.. rst-class:: build +.. container:: + + Like Jinja2, django templates are rendered by passing in a *context* + + Django's RequestContext provides common bits, similar to the context + provided automatically by Pyramid + + We add our posts to that context so they can be used by the template. + + +.. nextslide:: Return a Response + +.. code-block:: python + + return HttpResponse(body, content_type="text/html") + +.. rst-class:: build +.. container:: + + Finally, we build an HttpResponse and return it. + + This is, fundamentally, no different from the ``stub_view`` just above. + +.. nextslide:: Fix URLs + +We need to fix the url for our blog index page + +.. rst-class:: build +.. container:: + + Update ``urls.py`` in ``myblog``: + + .. code-block:: python + + # import the new view + from myblog.views import list_view + + # and then update the urlconf + url(r'^$', + list_view, #<-- Change this value from stub_view + name="blog_index"), + + Then run your tests again: + + .. code-block:: bash + + (djangoenv)$ ./manage.py test myblog + ... + Ran 3 tests in 0.033s + + OK + + +.. nextslide:: Common Patterns + +This is a common pattern in Django views: + +.. rst-class:: build + +* get a template from the loader +* build a context, usually using a RequestContext +* render the template +* return an HttpResponse + +.. rst-class:: build +.. container:: + + So common in fact that Django provides a shortcut for us to use: + + ``render(request, template[, ctx][, ctx_instance])`` + + +.. nextslide:: Shorten Our View + +Let's replace most of our view with the ``render`` shortcut + +.. code-block:: python + + from django.shortcuts import render # <- already there + + # rewrite our view + def list_view(request): + published = Post.objects.exclude(published_date__exact=None) + posts = published.order_by('-published_date') + context = {'posts': posts} + return render(request, 'list.html', context) + +.. rst-class:: build + +Remember though, all we did manually before is still happening + + +BREAK TIME +---------- + +We've got the front page for our application working great. + +Next, we'll need to provide a view of a detail page for a single post. + +Then we'll provide a way to log in and to navigate between the public part of +our application and the admin behind it. + +If you've fallen behind, the app as it stands now is in our class resources as +``mysite_stage_2`` + + +Our Detail View +--------------- + +Next, let's add a view function for the detail view of a post + +.. rst-class:: build +.. container:: + + It will need to get the ``id`` of the post to show as an argument + + Like the list view, it should only show published posts + + But unlike the list view, it will need to return *something* if an + unpublished post is requested. + + Let's start with the tests in ``views.py`` + + +.. nextslide:: Testing the Details + +Add the following test to our ``FrontEndTestCase`` in ``myblog/tests.py``: + +.. code-block:: python + + def test_details_only_published(self): + for count in range(1, 11): + title = "Post %d Title" % count + post = Post.objects.get(title=title) + resp = self.client.get('/posts/%d/' % post.pk) + if count < 6: + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, title) + else: + self.assertEqual(resp.status_code, 404) + + +.. nextslide:: Run Your Tests + +.. code-block:: bash + + (djangoenv)$ ./manage.py test myblog + Creating test database for alias 'default'... + .F.. + ====================================================================== + FAIL: test_details_only_published (myblog.tests.FrontEndTestCase) + ... + Ran 4 tests in 0.043s + + FAILED (failures=1) + Destroying test database for alias 'default'... + + +.. nextslide:: Let's Fix That Test + +Now, add a new view to ``myblog/views.py``: + +.. code-block:: python + + def detail_view(request, post_id): + published = Post.objects.exclude(published_date__exact=None) + try: + post = published.get(pk=post_id) + except Post.DoesNotExist: + raise Http404 + context = {'post': post} + return render(request, 'detail.html', context) + + +.. nextslide:: Missing Content + +.. code-block:: python + + try: + post = published.get(pk=post_id) + except Post.DoesNotExist: + raise Http404 + +One of the features of the Django ORM is that all models raise a DoesNotExist +exception if ``get`` returns nothing. + +.. rst-class:: build +.. container:: + + This exception is actually an attribute of the Model you look for. + + There's also an ``ObjectDoesNotExist`` for when you don't know which model + you have. + + We can use that fact to raise a Not Found exception. + + Django will handle the rest for us. + + +.. nextslide:: Add the Template + +We also need to add ``detail.html`` to ``myblog/templates``: + +.. code-block:: jinja + + {% extends "base.html" %} + + {% block content %} + Home +

{{ post }}

+ +
+ {{ post.text }} +
+
    + {% for category in post.categories.all %} +
  • {{ category }}
  • + {% endfor %} +
+ {% endblock %} + + +.. nextslide:: Hook it Up + +In order to view a single post, we'll need a link from the list view + +.. rst-class:: build +.. container:: + + We can use the ``url`` template tag (like Pyramid's ``request.route_url``): + + .. code-block:: jinja + + {% url '' arg1 arg2 %} + + In our ``list.html`` template, let's link the post titles: + + .. code-block:: jinja + + {% for post in posts %} +
+

+ {{ post }} +

+ ... + + +.. nextslide:: Fix URLs + +Again, we need to insert our new view into the existing ``myblog/urls.py`` in +``myblog``: + +.. code-block:: python + + # import the view + from myblog.views import detail_view + + url(r'^posts/(?P\d+)/$', + detail_view, #<-- Change this from stub_view + name="blog_detail"), + +.. rst-class:: build small + +:: + + (djangoenv)$ ./manage.py test myblog + ... + Ran 4 tests in 0.077s + + OK + + +.. nextslide:: A Moment To Play + +We've got some good stuff to look at now. Fire up the server + +.. rst-class:: build +.. container:: + + Reload your blog index page and click around a bit. + + You can now move back and forth between list and detail view. + + Try loading the detail view for a post that doesn't exist + + +.. nextslide:: Congratulations + +You've got a functional Blog + +.. rst-class:: build +.. container:: + + It's not very pretty, though. + + We can fix that by adding some css + + This gives us a chance to learn about Django's handling of *static files* + + +Static Files +------------ + +Like templates, Django expects to find static files in particular locations + +.. rst-class:: build +.. container:: + + It will look for them in a directory named ``static`` in any installed + apps. + + They will be served from the url path in the STATIC_URL setting. + + By default, this is ``/static/`` + + To allow Django to automatically build the correct urls for your static + files, you use a special *template tag*:: + + {% static %} + + +.. nextslide:: Add CSS + +I've prepared a css file for us to use. You can find it in the class resources + +.. rst-class:: build +.. container:: + + Create a new directory ``static`` in the ``myblog`` app. + + Copy the ``django_blog.css`` file into that new directory. + + .. container:: + + Next, load the static files template tag into ``base.html`` (this + **must** be on the *first line* of the template): + + .. code-block:: jinja + + {% load staticfiles %} + + .. container:: + + Finally, add a link to the stylesheet using the special template tag: + + .. code-block:: html + + My Django Blog + + + +.. nextslide:: View Your Results + +Reload http://localhost:8000/ and view the results of your work + +.. rst-class:: build +.. container:: + + We now have a reasonable view of the posts of our blog on the front end + + And we have a way to create and categorize posts using the admin + + However, we lack a way to move between the two. + + Let's add that ability next. + + +Global Navigation +----------------- + +We'll start by adding a control bar to our ``base.html`` template: + +.. code-block:: jinja + + + ... + +
+ ... + + +.. nextslide:: Request Context Revisited + +When we set up our views, we used the ``render`` shortcut, which provides a +``RequestContext`` + +.. rst-class:: build +.. container:: + + This gives us access to ``user`` in our templates + + It provides access to methods about the state and rights of that user + + We can use these to conditionally display links or UI elements. Like only + showing the admin link to staff members. + + +.. nextslide:: Login/Logout + +Django also provides a reasonable set of views for login/logout. + +.. rst-class:: build +.. container:: + + The first step to using them is to hook them into a urlconf. + + .. container:: + + Add the following to ``mysite/urls.py``: + + .. code-block:: python + + # add an import at the top + from django.contrib.auth.views import login, logout + + # and update the list of urlconfs + url(r'^', include('myblog.urls')), #<- already there + url(r'^login/$', + login, + {'template_name': 'login.html'}, + name="login"), + url(r'^logout/$', + logout, + {'next_page': '/'}, + name="logout"), + + +.. nextslide:: Login Template + +We need to create a new ``login.html`` template in ``mysite/templates``: + +.. code-block:: jinja + + {% extends "base.html" %} + + {% block content %} +

My Blog Login

+
{% csrf_token %} + {{ form.as_p }} +

+
+ {% endblock %} + + +.. nextslide:: Submitting Forms + +In a web application, submitting forms is potentially hazardous + +.. rst-class:: build +.. container:: + + Data is being sent to our application from some remote place + + If that data is going to alter the state of our application, we **must** + use POST + + Even so, we are vulnerable to Cross-Site Request Forgery, a common attack + vector. + + +.. nextslide:: Danger: CSRF + +Django provides a convenient system to fight this. + +.. rst-class:: build +.. container:: + + In fact, for POST requests, it *requires* that you use it. + + The Django middleware that does this is enabled by default. + + All you need to do is include the ``{% csrf_token %}`` tag in your form. + + +.. nextslide:: Hooking It Up + +In ``base.html`` make the following updates: + +.. rst-class:: build +.. container:: + + .. code-block:: jinja + + + admin + + logout + + login + + .. container:: + + Finally, in ``settings.py`` add the following: + + .. code-block:: python + + + LOGIN_URL = '/login/' + LOGIN_REDIRECT_URL = '/' + + +.. nextslide:: Forms In Django + +In adding a login view, we've gotten a sneak peak at how forms work in Django. + +.. rst-class:: build +.. container:: + + However, learning more about them is beyond what we can achieve in this + session. + + The form system in Django is quite nice, however. I urge you to + `read more about it`_ + + In particular, you might want to pay attention to the documentation on + `Model Forms`_ + + +.. _read more about it: https://docs.djangoproject.com/en/1.6/topics/forms/ +.. _Model Forms: https://docs.djangoproject.com/en/1.6/topics/forms/modelforms/ + + +Ta-Daaaaaa! +----------- + +So, that's it. We've created a workable, simple blog app in Django. + +.. rst-class:: build +.. container:: + + If you fell behind at some point, the app as it now stands is in our class + resources as ``mysite_stage_3``. + + There's much more we could do with this app. And for homework, you'll do + some of it. + + Then next session, we'll work together as pairs to implement a simple + feature to extend the blog + + +Homework +======== + +.. rst-class:: left + +For your homework this week, we'll fix one glaring problem with our blog admin. + +.. rst-class:: build left +.. container:: + + As you created new categories and posts, and related them to each-other, + how did you feel about that work? + + Although from a data perspective, the category model is the right place for + the ManytoMany relationship to posts, this leads to awkward usage in the + admin. + + It would be much easier if we could designate a category for a post *from + the Post admin*. + + +Your Assignment +--------------- + +You'll be reversing that relationship so that you can only add categories to +posts + +.. rst-class:: build +.. container:: + + Take the following steps: + + 1. Read the documentation about the `Django admin.`_ + 2. You'll need to create a customized `ModelAdmin`_ class for the ``Post`` + and ``Category`` models. + 3. And you'll need to create an `InlineModelAdmin`_ to represent Categories + on the Post admin view. + 4. Finally, you'll need to `exclude`_ the 'posts' field from the form in + your ``Category`` admin. + + +.. _Django admin.: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/ +.. _ModelAdmin: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/#modeladmin-objects +.. _InlineModelAdmin: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/#inlinemodeladmin-objects +.. _exclude: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/#django.contrib.admin.ModelAdmin.exclude + + +.. nextslide:: Pushing Further + +All told, those changes should not require more than about 15 total lines of +code. + +.. rst-class:: build +.. container:: + + The trick of course is reading and finding out which fifteen lines to + write. + + If you complete that task in less than 3-4 hours of work, consider looking + into other ways of customizing the admin. + + +.. nextslide:: Tasks you might consider + +.. rst-class:: build + +* Change the admin index to say 'Categories' instead of 'Categorys'. (hint, the + way to change this has nothing to do with the admin) +* Add columns for the date fields to the list display of Posts. +* Display the created and modified dates for your posts when viewing them in + the admin. +* Add a column to the list display of Posts that shows the author. For more + fun, make this a link that takes you to the admin page for that user. +* For the biggest challenge, look into `admin actions`_ and add an action to + the Post admin that allows you to publish posts in bulk from the Post list + display + +.. _admin actions: https://docs.djangoproject.com/en/1.6/ref/contrib/admin/actions/ diff --git a/source/presentations/session08.rst.norender b/source/presentations/session08.rst.norender deleted file mode 100644 index 2fdfc768..00000000 --- a/source/presentations/session08.rst.norender +++ /dev/null @@ -1,140 +0,0 @@ -Internet Programming with Python -================================ - -.. image:: img/django-pony.png - :align: left - :width: 50% - -Session 8: Extending Django - -.. class:: intro-blurb right - -Wherein we extend our Django blog app. - -.. class:: image-credit - -image: http://djangopony.com/ - - -Last Week ---------- - -Last week, we created a nice, simple Django microblog application. - -.. class:: incremental - -Over the week, as your homework, you made some modifications to improve how it -works. - -.. class:: incremental - -There's still quite a bit more we can do to improve this application. - -.. class:: incremental - -And today, that's what we are going to do. - - -Preparation ------------ - -In order for this to work properly, we'll need to have a few things in place. - -.. container:: incremental small - - First, we'll start from a canonical copy of the microblog. Make a fork of - the following repository to your github account: - - .. code-block:: - :class: small - - https://github.com/cewing/django-microblog - -.. container:: incremental small - - Then, clone that repository to your local machine: - - .. code-block:: bash - :class: small - - $ git clone https://github.com//django-microblog.git - or - $ git clone git@github.com:/django-microblog.git - - -Connect to Your Partner ------------------------ - -Finally, you'll want to connect to your partner's repository, so that you can -each work on your own laptop and still share the changes you make. - -.. container:: incremental small - - First, add your partner's repository as ``upstream`` to yours: - - .. code-block:: bash - :class: small - - $ git remote add upstream https://github.com//django-microblog.git - or - $ git remote add upstream git@github.com:/django-microblog.git - -.. container:: incremental small - - Then, fetch their copy so that you can easily merge their changes later: - - .. code-block:: bash - :class: small - - $ git fetch upstream - - -While You Work --------------- - -.. class:: small - -Now, when you switch roles during your work, here's the workflow you can use: - -.. class:: small - -1. The current driver commits all changes and pushes to their repository: - -.. code-block:: bash - :class: small - - $ git commit -a -m "Time to switch roles" - $ git push origin master - -.. class:: small - -2. The new driver fetches and merges changes made upstream: - -.. code-block:: bash - :class: small - - $ git fetch --all - $ git branch -a - * master - remotes/origin/master - remotes/upstream/master - $ git merge upstream/master - -.. class:: small - -3. The new driver continues working from where their partner left off. - - -Homework --------- - -For this week's homework, you will need to install the Zope Object Database -(ZODB) - -Instructions for this `may be found here`_. - -.. _may be found here: https://github.com/UWPCE-PythonCert/training.python_web/blob/master/resources/common/zodb-install-instructions.rst - -This is not trivial work. Please be sure to start early in the week so if -there is trouble, you'll be able to recover. - diff --git a/source/presentations/session09.rst b/source/presentations/session09.rst new file mode 100644 index 00000000..384738ec --- /dev/null +++ b/source/presentations/session09.rst @@ -0,0 +1,178 @@ +********** +Session 09 +********** + +.. figure:: /_static/django-pony.png + :align: center + :width: 60% + + image: http://djangopony.com/ + +Extending Django +================ + +.. rst-class:: large + +Wherein we extend our Django blog app. + + +Last Week +--------- + +Last week, we created a nice, simple Django microblog application. + +.. rst-class:: build +.. container:: + + Over the week, as your homework, you made some modifications to improve how + it works. + + There's still quite a bit more we can do to improve this application. + + And today, that's what we are going to do. + + +Preparation +----------- + +In order for this to work properly, we'll need to have a few things in place. + +.. rst-class:: build +.. container:: + + **For the time being, all these actions should only be taken by one + partner**. + + First, we'll start from a canonical copy of the microblog. Make a fork of + the following repository to your github account:: + + https://github.com/cewing/djangoblog_uwpce.git + + Then, clone that repository to your local machine: + + .. code-block:: bash + + $ git clone https://github.com//djangoblog_uwpce.git + + +Connect to Your Partner +----------------------- + +Finally, you'll need to add your partner as a collaborator for your new +repository. + +.. rst-class:: build +.. container:: + + Go to the *settings* for your repository. + + Click the *collaborators* tab on the left side of the window (you'll need + to enter your github password). + + Look up your partner by email address or github username. + + Add them. + + Then your partner can clone the repository to their desktop too. + +While You Work +-------------- + +Now, when you switch roles during your work, here's the workflow you can use: + +.. rst-class:: build +.. container:: + + .. container:: + + 1. The current driver commits all changes and pushes to their repository: + + .. code-block:: bash + + $ git commit -a -m "Time to switch roles" + $ git push origin master + + .. container:: + + 2. The new driver gets the changes: + + .. code-block:: bash + + $ git pull origin master + + 3. The new driver continues working from where their partner left off. + 4. PROFIT..... + +Homework +======== + +Next week, we will deploy your Django application to a server. + +.. rst-class:: build +.. container:: + + To help illustrate the full set of tools at our disposal, we'll go a bit + overboard for this. + + We'll be setting up an HTTP server, proxying to a WSGI server serving your + Django app. + + We'll do this all "In the cloud" using Amazon's `AWS`_ service. + + Before class starts, you'll need to accomplish a few non-programming tasks + +.. _AWS: http://aws.amazon.com/free + +Sign Up For AWS +--------------- + +Begin by going to the `AWS homepage`_ and clicking on the large, yellow button +that reads "Sign In to the Console". + +.. rst-class:: build +.. container:: + + On the sign-in page that appears, click the radio button for 'I am a new + user', fill in your email address, and then click through to begin the + sign-up process. + + You will be required to provide credit card information. + + If you are still eligible for the AWS free tier, you will not incur any + charges for work you do in this class. + +.. _AWS homepage: http://aws.amazon.com + + +Set Up an IAM User +------------------ + +Once you've signed up for an account take the following actions: + +* `Create an IAM user`_ and place them in a group with Power User access. (Search for PowerUser when selecting a policy for your group). + + * Set up Security Credentials for that IAM user. + * Save these Security Credentials in a safe place so you can use them for class. + +.. _Create an IAM user: http://docs.aws.amazon.com/IAM/latest/UserGuide/IAMBestPractices.html + +Prepare for Login +----------------- + +* `Create a Keypair`_ + + * Choose the 'US West (Oregon)' region since it's geographically closest to you. + * When you download your private key, save it to ~/.ssh/pk-aws.pem + * Make sure that the private key is secure and useable by doing the following command + + * ``$ chmod 400 ~/.ssh/pk-aws.pem`` + +* `Create a custom security group`_ + + * The security group should be named 'ssh-access' + * Add one custom TCP rule + * allow port 22 + * allow addresses 0.0.0.0/0 + +.. _Create a Keypair: http://docs.aws.amazon.com/gettingstarted/latest/wah/getting-started-create-key-pair.html +.. _Create a custom security group: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html diff --git a/source/presentations/session09.rst.norender b/source/presentations/session09.rst.norender deleted file mode 100644 index b79b0e88..00000000 --- a/source/presentations/session09.rst.norender +++ /dev/null @@ -1,1712 +0,0 @@ -Internet Programming with Python -================================ - -.. image:: img/pyramid-medium.png - :align: left - :width: 50% - -Session 9: Intro To Pyramid - -.. class:: intro-blurb right - -| The flexible framework. -| Totally not built by aliens. - - -What is Pyramid? ----------------- - -A Web Framework - -.. class:: incremental - -"Its primary job is to make it easier for a developer to create an arbitrary -web application" - -.. class:: incremental - -Makes as few decisions as possible for you. - -.. class:: incremental - -Allows *you* to make decisions, and provides tools to support you when you do - -.. class:: incremental - -"Pay only for what you eat" - - -Why is Pyramid? ---------------- - -Micro-frameworks are great for lightweight apps - -.. class:: incremental - -Micro-frameworks do not scale up or change specs easily - -.. class:: incremental - -Full-stack frameworks have lots of opinions. *Bending* them can be difficult. - -.. class:: incremental - -Pyramid can build a lightweight app easily, but it can also scale and bend - - -History - Zope and Repoze -------------------------- - -Many of the core developers of Pyramid started as Zope developers. - -.. class:: incremental - -Born in 1996, Zope was the first Python web framework, and possibly the first -in any language. - -.. class:: incremental - -After 14 years, the developers of Zope had seen and learned *a lot*. - -.. class:: incremental - -Repoze was a short-lived (2008-2010) framework intended to embody the lessons -learned from Zope. - - -History - Pylons ----------------- - -Pylons was released in 2005. - -.. class:: incremental - -It was among the first frameworks to fully embrace the WSGI specification. - -.. class:: incremental - -The creators of Pylons built WebOb (abstracted HTTP request and response -objects). - -.. class:: incremental - -This forms the foundation of Pylons much as Werkzeug is the foundation of -Flask. - - -History - 2010 --------------- - -In 2010, the authors of Repoze and Pylons got together and made an unusual -decision. - -.. class:: incremental - -Why duplicate efforts when there are already so many other frameworks? - -.. class:: incremental - -Repoze was re-named 'Pyramid' and the 'Pylons Project' was born to shepherd -this new combined project. - - -Implications ------------- - -Pylons was a framework predicated largely on relational persistence and URL -Dispatch. - -.. class:: incremental - -Zope/Repoze was based on the ZODB and Object Traversal. - -.. class:: incremental - -Each of these approaches has strengths and weaknesses. - -.. class:: incremental - -Pyramid supports using neither, both and even combinations of the two. - - -Relational DB / URL Dispatch ----------------------------- - -You've seen this before, both in Flask and Django - -.. class:: incremental - -SQLite3, the Django ORM, both are examples of relational persistence models - -.. class:: incremental - -Routes/urlpatterns, both are examples of URL Dispatch - -.. class:: incremental - -Pyramid can work this way too. SQLAlchemy, Route-based views. - -.. class:: incremental - -Been there, done that. Let's see something else. - - -ZODB ----- - -ORMs allow developers to pretend that Objects are like DB Tables. - -.. class:: incremental - -But Objects are *not* tables, so there's a `conceptual mismatch -`_ between -the two. - -.. class:: incremental - -The ZODB is an *object store*, rather than a relational database. - -.. class:: incremental - -If your data is best represented by *heterogenous* objects, it's a better -persistence solution. - - -Traversal ---------- - -In URL Dispatch, the ``PATH`` is a *virtual* construct. - -.. class:: incremental - -In our Django app ``/admin/myblog/post/13/`` doesn't map to any series of -*real* locations. - -.. class:: incremental - -This is unlike a filesystem where ``/usr/local/bin/python`` points to a *real* -location. - -.. class:: incremental - -When you use the ``cd`` command to move from place to place in a filesystem, -that is *traversal* - - -Object Graphs -------------- - -In Python, objects can *contain* other objects. - -.. class:: incremental - -Using *dict*-like structures, you can build a *graph* of objects: - -.. class:: incremental - -:: - - Family - ├── Parents - │ ├── Cris - │ ├── Kristina - ├── Children - │ ├── Kieran - │ ├── Finnian - - -We Got Both Directions ----------------------- - -``__getitem__`` allows movement from *container* to *contained* - -.. container:: incremental - - What if the *contained* can keep track of its *container*? - - .. code-block:: python - :class: small - - >>> class node(dict): - ... __parent__ = None - ... def __init__(self, parent=None): - ... self.__parent__ = parent - ... - >>> x = node() - >>> x['y'] = node(x) - >>> y = x['y'] - >>> y.__parent__ == x - True - - -Traversal - Path Lookup ------------------------ - -You can *traverse* across the object graph by treating a URL as a series of -*object names* - -.. class:: incremental small - -:: - - http://family/parents/cris -> family['parents']['cris'] - -.. class:: incremental - -If you have more names than objects, the remainder can be passed to the final -object as data: - -.. class:: incremental small - -:: - - http://family/parents/cris/edit -> subpath = /edit - http://family/parents/cris/next/steps -> subpath = /next/steps - -.. class:: incremental - -The subpath can be used to find object methods or views - -Preparation ------------ - -You should at this point have a virtualenv in which you have installed the -ZODB. - -.. class:: incremental - -Now, let's install pyramid too. - -.. container:: incremental - - In your terminal, change directories to where you build that virtualenv and - activate it: - - .. class:: small - - :: - - $ cd /path/to/right/place - $ source pyramidenv/bin/activate - - C:\> pyramidenv\Scripts\activate - - -Installation ------------- - -Next, install Pyramid and the extras we'll be using: - -.. class:: incremental small - -:: - - (pyramidenv)$ pip install pyramid - ... - (pyramidenv)$ pip install docutils nose coverage - ... - (pyramidenv)$ pip install pyramid_zodbconn pyramid_tm - ... - (pyramidenv)$ pip install pyramid_debugtoolbar - -.. class:: incremental - -These tools will allow us to manage ZODB connections, debug our app, and run -cool tests. - - -Required Setup --------------- - -In Django ``startproject`` and ``startapp`` gave us the boilerplate we needed. - -.. class:: incremental - -Pyramid uses what it calls *scaffolds* for the same purpose. - -.. class:: incremental - -When you installed it, a new ``pcreate`` command was generated in your -virtualenv. - -.. container:: incremental - - Let's use it: - - .. class:: small - - :: - - (pyramidenv)$ pcreate -s zodb wikitutorial - ... - - -Scaffolds and Opinions ----------------------- - -When you ran ``pcreate -s zodb wikitutorial`` you invoked the *zodb scaffold* - -.. class:: incremental - -Pyramid the framework is highly un-opinionated. - -.. class:: incremental - -*Scaffolds*, conversely, can be quite opinionated. The one we used has chosen -our persistence mechanism (ZODB) and how we will reach our code (Traversal). - -.. class:: incremental - -You do not have to use *scaffolds* to start a project, but it can help. - - -Project Layout --------------- - -Running ``pcreate`` has set up a file structure for us: - -.. class:: small - -:: - - wikitutorial/ - CHANGES.txt - development.ini - MANIFEST.in - production.ini - README.txt - setup.cfg - setup.py - wikitutorial/ - __init__.py - models.py - static/ - templates/ - tests.py - views.py - - -Similarities to Django ----------------------- - -Our project is organized with an outer *project* folder and an inner *package* -folder (see the ``__init__.py``?) - -.. class:: incremental - -The name of that outer directory is not really important. - -.. class:: incremental - -Our inner *package* folder has a models.py, tests.py and views.py module - -.. class:: incremental - -Our inner *package* folder has a ``static/`` and ``templates/`` directory - - -Differences from Django ------------------------ - -Our *outer* module has a ``setup.py`` file, which allows it to be installed -with ``pip`` or ``easy_install`` - -.. class:: incremental - -There is no ``manage.py`` file. Pyramid commands are console scripts (look in -*pyramidenv/bin*). - -.. class:: incremental - -There is nothing magical in Pyramid about the name of the ``models.py`` -module. - -.. class:: incremental - -There is nothing magical in Pyramid about the names of the ``static/`` or -``templates/`` directories. - - -Pyramid System Configuration ----------------------------- - -Pyramid keeps configuration intended for an entire installation in ``.ini`` -files at the top of a project. - -.. class:: incremental - -When you deploy an app to some wsgi server, you'll reference one of these files - -.. class:: incremental - -Settings there affect the environment of all apps that are running in that -wsgi server. - -.. class:: incremental - -Like Django's ``settings.py``, but **not** python. - - -INI format ----------- - -INI-style files have a particular format. - -.. class:: incremental - -Individual sections are marked by ``[SECTION_NAMES]`` in square brackets - -.. class:: incremental - -Each section will contain ``name = value`` pairs of settings. - -.. class:: incremental - -INI files are parsed using the Python `ConfigParser -`_ module. - -.. code-block:: python - :class: small incremental - - {'SECTION_NAME': {'name': 'value', ...}, ...} - - -Pyramid is Python ------------------ - -Running a Pyramid application is really just like running a Python module. In -the ``__init__.py`` file of your app *package*, you'll find a ``main`` -function: - -.. code-block:: python - :class: small incremental - - def main(global_config, **settings): - """ This function returns a Pyramid WSGI application. - """ - config = Configurator(root_factory=root_factory, - settings=settings) - config.add_static_view('static', 'static', cache_max_age=3600) - config.scan() - return config.make_wsgi_app() - -.. class:: incremental - -Let's take a closer look at this, line by line. - - -INI Configuration ------------------ - -.. code-block:: python - :class: small - - def main(global_config, **settings): - -.. class:: incremental - -Arguments passed to ``main`` are configuration from ``.ini``. - -.. class:: incremental - -``global_config`` is a dictionary of settings in [DEFAULT] - -.. class:: incremental - -``settings`` will be the name-value pairs for your app. - -.. container:: incremental - - ``[app:]`` sections are mapped to apps by the ``use`` setting - - .. code-block:: ini - :class: small - - [app:main] - use = egg:wikitutorial - - -App Configuration ------------------ - -.. code-block:: python - :class: small - - config = Configurator(root_factory=root_factory, - settings=settings) - config.add_static_view('static', 'static', cache_max_age=3600) - config.scan() - -.. class:: incremental - -Pyramid configuration is done by the ``Configurator`` class. - -.. class:: incremental - -Configuration can be *imperative* (function calls) or *declarative* -(decorators) - -.. class:: incremental - -Either way, ``.scan()`` sets it all up and reports errors. - -.. class:: incremental - -Read more in `the pyramid.config documentation -`_ - - -WSGI Hookup ------------ - -.. code-block:: python - :class: small - - return config.make_wsgi_app() - -.. class:: incremental - -Like Django and Flask, Pyramid runs in a WSGI world. - -.. class:: incremental - -``.make_wsgi_app()`` returns a ``Router`` object for your app. - -.. container:: incremental - - ``Router`` has the following ``__call__`` method: - - .. code-block:: python - :class: small - - def __call__(self, environ, start_response): - request = self.request_factory(environ) - response = self.invoke_subrequest(request, use_tweens=True) - return response(request.environ, start_response) - -.. class:: incremental - -Familiar, no? - - -The Application Root --------------------- - -The ``Configurator`` constructor takes a ``root_factory`` kwarg. - -.. class:: incremental - -This *callable* returns something to handle dispatching requests. - -.. class:: incremental - -The default root factory uses URL Dispatch. - -.. class:: incremental - -We want to use Traversal for our app, so we provide one. - - -Our Root Factory ----------------- - -.. code-block:: python - :class: small - - from pyramid_zodbconn import get_connection - from .models import appmaker - - def root_factory(request): - conn = get_connection(request) - return appmaker(conn.root()) - -.. class:: incremental - -``get_connection`` returns a connection to the ZODB. - -.. class:: incremental - -The ``root`` of this connection is then passed to ``appmaker`` - -.. class:: incremental - -This is another factory method that returns the app root. - -.. class:: incremental - -So what exactly does ``appmaker`` do? - - -The appmaker ------------- - -.. code-block:: python - :class: small - - def appmaker(zodb_root): - if not 'app_root' in zodb_root: - app_root = MyModel() - zodb_root['app_root'] = app_root - import transaction - transaction.commit() - return zodb_root['app_root'] - -.. class:: incremental - -Remember, the ZODB is an *object store*, dict-like. - -.. class:: incremental - -We look for an ``app_root`` inside this *container* - -.. class:: incremental - -If there is none, we build one and put it there. - -.. class:: incremental - -This simple Python object will manage *Traversal* for our app. - - -Install Our App ---------------- - -Our app is, in fact, a Python package. - -.. class:: incremental - -In order for us to use it, we must *install* it. - -.. class:: incremental - -``setup.py`` allows us to do this: ``python setup.py install`` **BUT** - -.. class:: incremental - -Install will make a copy of our code and use that. - -.. class:: incremental - -We don't want that, since updates we make here would not be picked up. - -*Develop* Installation ----------------------- - -We can use an alternate method called ``develop``. - -.. class:: incremental - -This will install a *pointer* to our package, but leave the code here. - -.. class:: incremental - -In a terminal, move to the ``wikitutorial`` *project* folder (find -``development.ini``) and ``develop`` the app: - -.. class:: small incremental - -:: - - (pyramidenv)$ cd wikitutorial - (pyramidenv)$ python setup.py develop - - -See It Live ------------ - -Use the ``pserve`` command installed by pyramid to serve our app: - -.. class:: small - -:: - - (pyramidenv)$ pserve development.ini - Starting server in PID 16698. - serving on http://0.0.0.0:6543 - -.. class:: incremental - -This brings up a new *wsgi server* provided by ``waitress`` serving our app. - -.. class:: incremental - -Load http://localhost:6543 and view your app root. - - -Why is it Pretty? ------------------ - -We should be looking at an instance of MyModel: - -.. code-block:: python - :class: small - - class MyModel(PersistentMapping): - __parent__ = __name__ = None - -.. class:: incremental - -What makes it look like this? - -.. class:: incremental - -The secret sauce lies in *view configuration* - - -Pyramid Views -------------- - -.. code-block:: python - :class: small - - from pyramid.view import view_config - from .models import MyModel - - @view_config(context=MyModel, renderer='templates/mytemplate.pt') - def my_view(request): - return {'project': 'wikitutorial'} - -.. class:: incremental - -Pyramid views can be configured with the ``@view_config()`` decorator. - -.. class:: incremental - -Or call ``config.add_view()`` method in your app ``main``. - -.. class:: incremental - -``config.scan()`` in ``main`` picks up all config decorators. - - -View Config - Predicates ------------------------- - -View configuration takes many arguments. Here we use two. - -.. class:: incremental - -``context`` determines the *type* of object to which this view can be applied - -.. class:: incremental - -It's an example of a *predicate* argument - -.. class:: incremental - -*Predicates* place restrictions on how and when a view is used. - -.. class:: incremental - -Read more about predicates in `view configuration -`_ - - -View Config - Renderers ------------------------ - -Pyramid separates the concerns of *view* and *renderer* - -.. class:: incremental - -So far, *views* prepare a data context **and** render it - -.. class:: incremental - -In Pyramid, the *view* only prepares the data to be rendered - -.. class:: incremental - -A ``renderer`` transforms this to something suitable for an HTTP response. - -.. class:: incremental - -In this case, ``renderer`` is a template that will return HTML - - -View Config - Summary ---------------------- - -In summary, then, our view configuration: - -.. class:: incremental - -* checks to see that we have traversed to an instance of ``MyModel`` -* calls the ``my_view`` function, which returns a simple dictionary -* passes that dictionary to the ``mytemplate`` template -* the template is rendered and returned as the body of an HTTP response. - -.. class:: incremental - -And that is how we end up looking at that very pretty page. - - -Break Time ----------- - -So far: - -.. class:: incremental - -* we've taken a look at where Pyramid comes from -* we've seen how it is like and unlike other frameworks we've seen. -* we've met the ZODB *object store* and talked about how it differs from a - database -* we've learned about *traversal* and how it differs from URL dispatch -* we've set up a Pyramid app using both -* we've looked at how the example code in that application works. - -.. class:: incremental - -Next, we'll start working on building our app, starting with Models. - - -Before We Begin ---------------- - -In your *package* directory you should see a file: ``Data.fs``. - -.. class:: incremental - -We are going to be starting over, so let's clear it. - -.. class:: incremental - -Make sure Pyramid is not running. - -.. class:: incremental - -Delete Data.fs. It will be re-created as needed. - -.. class:: incremental - -You can also delete Data.fs.* (.tmp, .index, .lock) - - -Wiki Models ------------ - -First, we want a *wiki* class to serve as our app *root*. - -.. class:: incremental - -We also need a *page* class representing a wiki page. - -.. class:: incremental - -This will be the type we view when we are looking at the wiki. - -.. class:: incremental - -These two classes will need to be stored in our ZODB - -.. class:: incremental - -This means we need to talk about *persistence*. - - -Persistence Magic ------------------ - -In SQL, data *about* an object is written to tables. - -.. class:: incremental - -In the ZODB, the *object itself* is saved in the database. - -.. class:: incremental - -The ZODB provides special classes that help us with this. - -.. class:: incremental - -Instances of these classes are able to know when they've been changed. - -.. class:: incremental - -When a ZODB transaction is committed, all changed objects are saved. - - -Persistent Base Classes ------------------------ - -We'll be using two of these classes in our wiki: - -.. class:: incremental - -* **Persistent** - automatically saves changes to class attributes - -* **PersistentMapping** - like a *dictionary*, saves changes to itself *and - its keys and values*. - -.. class:: incremental small - -Other structures like lists and B-Trees are also available, but we wont use -them here. - -.. class:: incremental - -By subclassing these, we automatically gain persistence. - - -Traversal Magic ---------------- - -Our wiki system will use *traversal* dispatch - -.. class:: incremental - -Two object attributes support *traversal*: - -.. class:: incremental - -* ``__name__`` (who am I) -* ``__parent__`` (where am I) - -.. class:: incremental - -Every object in a traversal-based system **must** provide these two -attributes. - -.. class:: incremental - -The *root* object will set these to ``None``. - - -The Wiki Class --------------- - -Open ``models.py`` from our ``wikitutorial`` *package* directory. - -.. class:: incremental - -First, delete the ``MyModel`` class. We won't need it. - -.. class:: incremental - -Add the following in its place: - -.. code-block:: python - :class: incremental - - class Wiki(PersistentMapping): - __name__ = None - __parent__ = None - - -The Page Class --------------- - -To that same file (models.py) add one import and a second class definition: - -.. code-block:: python - - from persistent import Persistent - - class Page(Persistent): - def __init__(self, data): - self.data = data - -.. class:: incremental - -What about ``__name__`` and ``__parent__``? - -.. class:: incremental - -We'll add those to each instance when we create it. - - -Update Appmaker ---------------- - -Update ``appmaker`` for our new models: - -.. code-block:: python - - def appmaker(zodb_root): - if not 'app_root' in zodb_root: - app_root = Wiki() - frontpage = Page('This is the front page') - app_root['FrontPage'] = frontpage - frontpage.__name__ = 'FrontPage' - frontpage.__parent__ = app_root - zodb_root['app_root'] = app_root - import transaction - transaction.commit() - return zodb_root['app_root'] - - -A Last Bit of Cleanup ---------------------- - -We've deleted the ``MyModel`` class. - -.. class:: incremental - -But we still have *views* that reference the class. - -.. container:: incremental - - Open ``views.py`` and delete everything except the first import - - .. code-block:: python - :class: small - - from pyramid.view import view_config - -.. class:: incremental - -Next come tests for our new models. - - -Test the Wiki Model -------------------- - -Open ``tests.py`` from the *package* directory. Delete the ``ViewTests`` -class and replace it with the following: - -.. code-block:: python - :class: small - - class WikiModelTests(unittest.TestCase): - - def _getTargetClass(self): - from wikitutorial.models import Wiki - return Wiki - - def _makeOne(self): - return self._getTargetClass()() - - def test_constructor(self): - wiki = self._makeOne() - self.assertEqual(wiki.__parent__, None) - self.assertEqual(wiki.__name__, None) - - -Test the Page Model -------------------- - -Add the following test class as well: - -.. code-block:: python - :class: small - - class PageModelTests(unittest.TestCase): - - def _getTargetClass(self): - from wikitutorial.models import Page - return Page - - def _makeOne(self, data=u'some data'): - return self._getTargetClass()(data=data) - - def test_constructor(self): - instance = self._makeOne() - self.assertEqual(instance.data, u'some data') - - -Test Appmaker -------------- - -One more test class: - -.. code-block:: python - :class: small - - class AppmakerTests(unittest.TestCase): - - def _callFUT(self, zodb_root): - from wikitutorial.models import appmaker - return appmaker(zodb_root) - - def test_initialization(self): - root = {} - self._callFUT(root) - self.assertEqual(root['app_root']['FrontPage'].data, - 'This is the front page') - - -A Side Note ------------ - -Note that there are *few* module level imports in ``tests.py`` - -.. class:: incremental - -Also note that each TestCase has a helper method to import the class it will -test. - -.. class:: incremental - -This is unusual, but it reflects Pyramid `testing best practices -`_ - -.. class:: incremental - -In short, the idea is to prevent import problems from breaking *all* your -tests. - - -Run our Tests -------------- - -Finally, let's run our tests:: - - (pyramidenv)$ python setup.py test - ... - Ran 3 tests in 0.000s - - OK - -.. class:: incremental - -We can also run tests to tell us our code-coverage: - -.. class:: incremental small - -:: - - (pyramidenv)$ nosetests --cover-package=tutorial --cover-erase\ - --with-coverage - - -Preparing for Views -------------------- - -The ``data`` attribute of our ``Page`` model holds the text of the page. - -.. class:: incremental - -We'll use ReStructuredText, which can be rendered to HTML - -.. class:: incremental - -Rendering is provided by a python package called ``docutils``. - -.. class:: incremental - -Our application is a python package, and can declare its own dependencies. - -.. class:: incremental - -We need to add the ``docutils`` package to this list. - - -Package Dependencies --------------------- - -Open the ``setup.py`` file from our *project* directory. Add ``docutils`` to -the list ``requires``: - -.. code-block:: python - - requires = [ - 'pyramid', - 'pyramid_zodbconn', - 'transaction', - 'pyramid_tm', - 'pyramid_debugtoolbar', - 'ZODB3', - 'waitress', - 'docutils', # <- ADD THIS - ] - - -Complete the Change -------------------- - -Changes to ``setup.py`` always require a re-install:: - - (pyramidenv)$ python setup.py develop - -.. class:: incremental - -You'll see a whole bunch of stuff flicker by. In it will be a reference to -``Searching for docutils``. - - -Adding Views ------------- - -We are ready to add views now. We'll need: - -.. class:: incremental - -* A view of the Wiki itself, which redirects to the front page. -* A view of an existing Page -* A view that allows us to *add* a new Page -* A view that allows us to *edit* an existing Page - -.. class:: incremental - -As we move forward, we'll be writing tests first, then building the code to -pass them. - - -Testing the Wiki View ---------------------- - -We want our wiki to automaticall redirect to ``FrontPage``. - -.. container:: incremental - - Add this new TestCase to ``tests.py``: - - .. code-block:: python - :class: small - - class WikiViewTests(unittest.TestCase): - - def test_redirect(self): - from wikitutorial.views import view_wiki - context = testing.DummyResource() - request = testing.DummyRequest() - response = view_wiki(context, request) - self.assertEqual(response.location, - 'http://example.com/FrontPage') - - -Run The Tests -------------- - -.. class:: small - -:: - - (pyramidenv)$ python setup.py test - ... - - ====================================================================== - ERROR: test_redirect (wikitutorial.tests.WikiViewTests) - ---------------------------------------------------------------------- - Traceback (most recent call last): - File "/path/to/wikitutorial/wikitutorial/tests.py", line 51, in test_redirect - from wikitutorial.views import view_wiki - ImportError: cannot import name view_wiki - - ---------------------------------------------------------------------- - Ran 4 tests in 0.001s - - FAILED (errors=1) - - -Adding view_wiki ----------------- - -Open ``views.py`` again. Add the following: - -.. code-block:: python - :class: small - - from pyramid.httpexceptions import HTTPFound - from pyramid.view import view_config # <- ALREADY THERE - - @view_config(context='.models.Wiki') - def view_wiki(context, request): - return HTTPFound(location=request.resource_url(context, - 'FrontPage')) - -.. container:: incremental - - And re-run tests: - - .. class:: small - - :: - - (pyramidenv)$ python setup.py test - ... - Ran 4 tests in 0.001s - OK - - -Some Notes ----------- - -Note that ``@view_config`` has no ``renderer`` argument. - -.. class:: incremental - -It will never be shown, so there's no need - -.. class:: incremental - -Instead, it returns ``HTTPFound``, (``302 Found``), which requires a -``location`` - -.. class:: incremental - -The ``.resource_url()`` method of a ``request`` object builds a URL for us. - - -A Page View ------------ - -Our view of a page will need to accomplish a few things: - -.. class:: incremental - -* convert the Page ``data`` to HTML -* make WikiWords in the HTML into appropriate links -* provide a url for editing itself - -.. class:: incremental - -Let's test and implement these features one at a time - - -Test HTML Rendering -------------------- - -Add the following new TestCase to ``tests.py`` - -.. code-block:: python - :class: small - - class PageViewTests(unittest.TestCase): - def _callFUT(self, context, request): - from wikitutorial.views import view_page - return view_page(context, request) - - def test_it(self): - wiki = testing.DummyResource() - context = testing.DummyResource(data='Hello CruelWorld IDoExist') - context.__parent__ = wiki - context.__name__ = 'thepage' - request = testing.DummyRequest() - info = self._callFUT(context, request) - self.assertTrue('
' in info['content']) - for word in context.data.split(): - self.assertTrue(word in info['content']) - - -Run The Tests -------------- - -Verify that you now have five, and that one is failing - -.. class:: incremental - -Our tests is relying on an artifact of how docutils builds HTML - -.. class:: incremental - -It makes it a weak tests, but okay for illustrative purposes. - -.. class:: incremental - -Now, let's get it passing - - -Start view_page ---------------- - -Add this code to ``views.py``: - -.. code-block:: python - :class: small - - # an import - from docutils.core import publish_parts - - # and a method - @view_config(context='.models.Page', renderer='templates/view.pt') - def view_page(context, request): - content = publish_parts( - context.data, writer_name='html')['html_body'] - return dict(page=context, content=content) - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - ... - Ran 5 tests in 0.143s - OK - - -Test Link Building ------------------- - -Add the following to our test: - -.. code-block:: python - :class: small - - def test_it(self): - wiki = testing.DummyResource() - wiki['IDoExist'] = testing.DummyResource() #<- add this - context = testing.DummyResource(data='Hello CruelWorld IDoExist') - #... - # Add the following loop and assertion - for url in (request.resource_url(wiki['IDoExist']), - request.resource_url(wiki, 'add_page', 'CruelWorld')): - self.assertTrue(url in info['content']) - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - Ran 5 tests in 0.108s - FAILED (failures=1) - - -Finding WikiWords ------------------ - -We'll use a regular expression to find WikiWords in our page data - -.. container:: incremental - - Add the following to ``views.py``: - - .. code-block:: python - :class: small - - # one import - import re - - # and one module constant - WIKIWORDS = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") - -.. class:: incremental - -Now, we use this to build a curried function that converts WikiWords to links - - -Converting WikiWords --------------------- - -.. code-block:: python - :class: small - - # in views.py - def view_page(context, request): - wiki = context.__parent__ - - def check(match): - word = match.group(1) - if word in wiki: - page = wiki[word] - view_url = request.resource_url(page) - return '%s' % (view_url, word) - else: - add_url = request.application_url + '/add_page/' + word - return '%s' % (add_url, word) - - content = publish_parts( - context.data, writer_name='html')['html_body'] - content = WIKIWORDS.sub(check, content) #<- add this line - return #... <- this already exists - - -Check Your Progress -------------------- - -Tests should now be five for five again. - - -Test Edit Link --------------- - -Finally, we need to verify that ``view_page`` also returns a link to edit -*this* page. - -.. container:: incremental - - Add this to our test: - - .. code-block:: python - :class: small - - def test_it(self): - #... - self.assertEqual(info['edit_url'], - 'http://example.com/thepage/edit_page') - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - Ran 5 tests in 0.110s - FAILED (errors=1) - - -Return Edit Link ----------------- - -Update ``view_page``: - -.. code-block:: python - :class: small - - def view_page(context, request): - #... - content = WIKIWORDS.sub(check, content) #<- already there - edit_url = request.resource_url(context, 'edit_page') #<- add - return dict(page=context, - content=content, - edit_url = edit_url) - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - Ran 5 tests in 0.110s - OK - - -What's in the ZODB? -------------------- - -We can inspect the database directly. - -.. class:: incremental - -Start an interactive session with: - -:: - - (pyramidenv)$ pshell development.ini - ... - >>> root - {'FrontPage': } - -.. class:: incremental small - -:: - - >>> root['FrontPage'].data - 'This is the front page' - -.. class:: incremental small - -:: - - >>> root['FrontPage'].__dict__ - {'__name__': 'FrontPage', 'data': 'This is the front page', '__parent__': {'FrontPage': }} - - - - -Adding Templates ----------------- - -What is the page template name for ``view_page``? - -.. class:: incremental - -Create ``view.pt`` in your ``templates`` directory. - -.. class:: incremental - -Also copy the file ``base.pt`` from the class resources. - -.. class:: incremental - -Pyramid can use a number of different templating engines. - -.. class:: incremental - -We'll be using Chameleon, which also supports extending other templates. - - -The view.pt Template --------------------- - -Type this code into your ``view.pt`` file: - -.. code-block:: xml - - - - -
- Page text goes here. -
-

- - Edit this page - -

-
-
- - -View Your Work --------------- - -We've created the following: - -.. class:: incremental small - -* A wiki view that redirects to the automatically-created FrontPage page -* A page view that will render the ``data`` from a page, along with a url for - editing that page -* A page template to show a wiki page. - -.. class:: incremental - -That's all we need to be able to see our work. Start Pyramid: - -.. class:: incremental small - -:: - - (pyramidenv)$ pserve development.ini - Starting server in PID 43925. - serving on http://0.0.0.0:6543 - -.. class:: incremental - -Load http://localhost:6543/ - - -What You Should See -------------------- - -.. image:: img/wiki_frontpage.png - :align: center - :width: 95% - - -Page Editing ------------- - -You'll notice that the page has a link to ``Edit This Page`` - -.. class:: incremental - -If you click it, you get a 404. We haven't created that view yet. - - -Next Steps ----------- - -We've learned a great deal about Pyramid so far. - -.. class:: incremental - -We've covered *traversal* and learned about object persistence with the ZODB. - -.. class:: incremental - -Finally, we've implemented the Data model for our wiki application and begun -to implement views. - -.. class:: incremental - -In our next session, we'll complete the wiki, adding page creation and editing -and an auth mechanism. - - -Break Time ----------- - -.. class:: big-centered - -See you back soon. diff --git a/source/presentations/session10.rst b/source/presentations/session10.rst new file mode 100644 index 00000000..abdd40ea --- /dev/null +++ b/source/presentations/session10.rst @@ -0,0 +1,666 @@ +********** +Session 10 +********** + +.. figure:: /_static/django-pony.png + :align: center + :width: 60% + + image: http://djangopony.com/ + + +Deploying Django +================ + +.. rst-class:: left +.. container:: + + Over the last two sessions you've built and extended a simple Django + application. + + .. rst-class:: build + .. container:: + + Now it is time to deploy that application to a server so the world can + see it. + + Previously, we used Heroku to deploy a simple Pyramid application. + + We could do the same with Django, but we won't. + + Instead, we'll deploy to **A**\ mazon **W**\ eb **S**\ ervices (AWS) + + +Choosing a Deployment Strategy +------------------------------ + +There are many many different ways to deploy a web application. + +.. rst-class:: build +.. container:: + + And there are many many services offering platforms for deployment. + + How do you choose the right one for you? + + In general there are a few rules of thumb to consider: + + .. rst-class:: build + + * The more convenient the service, the less configurable it is. + * The less you pay for a service, the more work you have to do yourself. + * With great power comes great responsibility. + +.. nextslide:: + +In choosing a service and a strategy, you'll want to ask yourself a few +questions: + +.. rst-class:: build +.. container:: + + .. rst-class:: build + + * What are the basic software components of my project? + * How much control or customization of each component do I require? + * What service supports all of my required components? + * What service allows my required customizations? + * If no single service does everything I need, which could be wired + together? + + The answers to these questions will help to determine the correct choice + for you. + +.. nextslide:: Our Choice for Today + +We are going to ignore all these questions, and simply ask one question. + +.. rst-class:: build +.. container:: + + Which service will allow us to set up each layer in a full web application + stack so that we can learn how the stack works from front to back? + + The simplest answer to that question is **AWS**. + + Therefore, that's the service we will use today. + +Preparing for AWS Deployment +---------------------------- + +You've started out this week by signing up for AWS. + +.. rst-class:: build +.. container:: + + You've created a security group and a key pair to help with accessing any + servers we create. + + You've also set up an IAM user and configured security credentials for that + user. + + If we were to be automating our work today, we'd use those credentials to + allow the `boto`_ library to connect to AWS as that IAM user. + + Then you could `create or destroy resources`_ using that library. + + Issues surrounding using that library on Windows prevent us from trying + that path tonight. + +.. nextslide:: + +Instead we'll be making a manual deployment using AWS. + +.. rst-class:: build +.. container:: + + This is always the first step to automation anyway, so this is an important + first step. + + We'll begin by converting some aspects of our application to better provide + for security + + In preparation for that we will need to add a new package to our django + virtual environment. + + .. code-block:: bash + + (djangoenv)$ pip install dj-database-url + + + +.. _boto: https://boto.readthedocs.org/ +.. _create or destroy resources: http://codefellows.github.io/python-dev-accelerator/lectures/day11/boto.html + + +.. nextslide:: 12-Factor + +This new package is an attempt to help Django get in line with a principle +called `12-factor`_. + +.. rst-class:: build +.. container:: + + The basic idea is that any data that your app uses for configuration that + is *external* to the app itself, should be separated from the app. + + The link about contains much more effective explanations, read it. + + We've already done this to some degree with our Pyramid application, by + putting some configuration values into *environment variables* + + ``dj-database-url`` allows us to do that with the configuration for our + database. + +.. _12-factor: http://12factor.net/ + + +.. nextslide:: Updating Settings + +Open ``settings.py`` and replace the current DATABASES dictionary with this: + +.. code-block:: python + + DATABASES = { + 'default': dj_database_url.config( + default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3') + ) + } + +.. rst-class:: build +.. container:: + + The default behavior of ``dj-database-url`` is to look for a + ``DATABASE_URL`` variable in the environment. + + If it doesn't find that, it uses the value you provide for *default*. + + It converts a `url-style`_ database connection string to the dictionary + Django expects. + + Here, we've set the default to be the same as what we had previously. + +.. _url-style: https://github.com/kennethreitz/dj-database-url#url-schema + +.. nextslide:: Repeatable Envs + +Another principle of the 12-factor philosophy is to keep the differences +between production and development to a minimum. + +.. rst-class:: build +.. container:: + + Again, in our Pyramid app we handled this with a ``requirements.txt`` file. + + Here we will do the same. + + At your command line, with the virtualenv active, run the following + command: + + .. code-block:: bash + + (djangoenv)$ pip freeze > requirements.txt + + Then, add that file to your repository and commit the changes. + + At this point, we're about ready to begin working directly with AWS + +Setting up An EC2 Instance +-------------------------- + +Our first step is to create an EC2 (Elastic Compute Cloud) instance for our +application. + +.. rst-class:: build +.. container:: + + Begin by opening the AWS homepage (http://aws.amazon.com) + + Then click on the big yellow "Sign in to the Console" button + + Fill in your email, check "I am a returning user..." and supply your + password. + + When the page loads, you are viewing the AWS Console. + + If you don't see a big list of services in that first page, click on + 'Services' in the black header. + + From the list of services, click on ``EC2``. + +.. nextslide:: + +The page that loads is the management console for EC2 resources. You used it +to create your security group and key pair. + +.. rst-class:: build +.. container:: + + Click the large blue "Launch Instance" button to start a new instance. + + You should see a list of types of operating system listed. + + If you don't click on *quick start* at the left. + + In the list, find "Ubuntu Server 14.04 LTS". + + Click on 'Select' to begin building an instance using that operating + system. + +.. nextslide:: + +The next page of the launch wizard allows you to choose how much CPU power and +RAM your machine will have. + +.. rst-class:: build +.. container:: + + There are only two types of instance that are in the free tier, and one is + now deprecated. + + Select the *t2.micro* instance by clicking the checkbox to the left of that + row (it may already be selected for you). + + Below the table of instance types, find and click on "Next: configure + instance details" + +.. nextslide:: + +Click through the next two steps until you reach "Configure Security Group" + +.. rst-class:: build +.. container:: + + Here, click the "select an existing security group" button, and pick your + ssh-access group. + + This group acts as a control for a *firewall* which restricts network + access to your new instance. + + You've configured that firewall to allow any machine to talk to your + instance, but only on port 22 (SSH). + + Finish by clicking "Review and Launch" + + Then click on "Launch" to start the instance. + +.. nextslide:: + +When you click "Launch" you are required to choose a key pair to control ssh +access to your new machine. + +.. rst-class:: build +.. container:: + + Without this key pair, you have no way to access the server, and you must + destroy it and create a new one. + + Select your ``pk-aws`` pair from the list of existing key pairs. + + Then, check the box that indicates you have the private key and click + "Launch Instance". + + It will take a few minutes for the new machine to initialize and be ready. + +Accessing Your Instance +----------------------- + +Once the machine indicates it is "running" you are ready to access that +machine. + +.. rst-class:: build +.. container:: + + ssh into that machine: + + .. code-block:: bash + + ssh -i ~/.ssh/pk-aws.pem ubuntu@ + + You will need to indicate that you trust this connection. + + You are now logged in to the server as the default user. + + AWS sets this user up with the ability to run commands using *sudo* + + You'll begin by updating the OS package manager so you are ensured of + having the latest versions of any software you install: + + .. code-block:: bash + + sudo apt-get update + +Deployment Layer 1: Web Server +------------------------------ + +In our deployment stack, the frontmost facing layer is the Web Server. + +.. rst-class:: build +.. container:: + + This software is responsible for receiving requests from clients' browsers. + + It will also handle serving static resources in order to relieve Django of + that burden. + + If you are using ``https``, it's also a good place to handle terminating an + SSL connection. + + Begin by using the Ubuntu package manager to install ``nginx``: + + .. code-block:: bash + + sudo apt-get install nginx + +.. nextslide:: Controlling ``nginx`` + +Like many other packages installed by ``apt-get``, nginx is set up as a +*service* + +You can check the status of the service: + +.. code-block:: bash + + sudo service nginx status + +You can start and stop the server: + +.. code-block:: bash + + sudo service nginx stop + sudo service nginx start + +.. nextslide:: Configuring Nginx + +Default configuration for nginx lives in ``/etc/nginx``. Let's look at three +files there in particular: + +* /etc/nginx/nginx.conf (controls behavior of the whole server) +* /etc/nginx/sites-available/default (controls a single 'site') +* /etc/nginx/sites-enabled/default (activates a single 'site') + + +.. nextslide:: Check Your results + +Check your results by loading your public DNS name in a browser + +.. rst-class:: build +.. container:: + + you should see this, do you? + + .. figure:: /_static/nginx_hello.png + :align: center + :width: 40% + + Add port 80 to your security group. Then reload. + +Deployment Layer 3: Database +---------------------------- + +In order to deploy our database, we'll need to install some more software + +.. rst-class:: build +.. container:: + + Use ``apt-get istall`` to add each of the following packages: + + * build-essential + * python-dev + * python-pip + * python-psycopg2 + * postgresql-client + * git + +.. nextslide:: RDS + +You *can* set up postgres directly on the machine you just built, but that's no fun. + +.. rst-class:: build +.. container:: + + Let's use RDS, the AWS service for providing databases. + + From 'services' in the header, select RDS. + + In the page that appears, click on 'Launch a DB Instance' + + From the selection of database types, choose PostgreSQL. + + Click **no** to indicate that you don't need a multi-AZ database. + +.. nextslide:: + +On the database details page, You have a bit of work to do. + +.. rst-class:: build +.. container:: + + First, select ``db.t2.micro`` as the instance type. + + Then, for multi-AZ deployment, select **no** (again) + + Finally, provide values for the last four inputs + + The database identifier must be unique to your account and region, use + "uwpce". + + For the master username, use "awsuser" + + Provide a password and repeat it to prove you can + +.. nextslide:: + +For Advanced Settings, make sure your DB is in the same availability zone as +your EC2 instance. + +.. rst-class:: build +.. container:: + + Also ensure that you select the same security group you used for your EC2 + instance from the list of VPC security groups. + + Enter a database name, use "djangodb" + + Finally, click "Launch DB Instance" + + While the database launches, let's return to setting up our application on + EC2 + +Deployment Layer 2: Application +------------------------------- + +Back on the EC2 instance, in your ssh terminal, clone your django application: + +.. code-block:: bash + + git clone + +.. rst-class:: build +.. container:: + + pip install the requirements for your app:: + + $ cd djangoblog_uwpce + $ pip install -r requirements.txt + +.. nextslide:: + +Finally, export a system environment variable called DATABASE_URL with the +following format:: + + postgres://username:password@host:port/dbname + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + export DATABASE_URL= + + You can now test access with dbshell: + + .. code-block:: bash + + python manage.py dbshell + + Work through any issues in getting that to work + +.. nextslide:: Wiring It Up + +Once working, we can point nginx at the instance: + +.. rst-class:: build +.. container:: + + .. code-block:: bash + + sudo mv /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak + sudo vi /etc/nginx/sites-available/default + + Add the following content: + + .. code-block:: nginx + + server { + listen 80; + server_name ; + access_log /var/log/nginx/django.log; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } + +.. nextslide:: + +Save that file and restart nginx: + +.. code-block:: bash + + sudo service nginx restart + +Then reload your aws instance in a web browser, you should see a BAD GATEWAY +error + +now start django and then reload: + +.. code-block:: bash + + python manage.py runserver + +This works, but as soon as you exit your ssh terminal, django will quit. We +want a long-running process we can leave behind. + + +Deployment Layer 4: Permanence +------------------------------ + +Install gunicorn on the server + +.. code-block:: bash + + pip install gunicorn + +Back on your own machine, create ``mysite/production.py`` and add the following +content: + +.. code-block:: python + + from settings import * + + DEBUG = False + TEMPLATE_DEBUG = False + ALLOWED_HOSTS = ['', 'localhost'] + STATIC_ROOT = os.path.join(BASE_DIR, 'static') + +Add the file to your repository and commit your changes. + +Then pull the changes back on your EC2 instance + +.. nextslide:: Configuration Changes for Nginx + +Update nginx config (/etc/nginx/sites-available/default) to serve static files: + +.. code-block:: nginx + + server { + # ... + + location /static/ { + root /home/ubuntu/djangoblog_uwpce; + } + + } + +.. nextslide:: Running with Gunicorn + +Then set an environment variable to point at production settings:: + + export DJANGO_SETTINGS_MODULE=mysite.production + +Now, run the site using gunicorn:: + + gunicorn -b 127.0.0.1:8000 -w 4 -D mysite.wsgi + +Wahooo! + +But still not great, because nothing is monitoring this process. + +There's no way to keep track of how it is doing. + +We can do better. First, let's kill the processes that spawned:: + + killall gunicorn + +.. nextslide:: Managing Gunicorn + +We can use a process manager to run the gunicorn command, and track the results. + +Using linux `upstart`_ is relatively simple. + +Put the following in ``/etc/init/djangoblog.conf`` + +.. code-block:: cfg + + description "djangoblog" + + start on (filesystem) + stop on runlevel [016] + + respawn + setuid nobody + setgid nogroup + chdir /home/ubuntu/djangoblog_uwpce + env DJANGO_SETTINGS_MODULE=mysite.production + env DATABASE_URL=postgres://:@:/djangoblog + exec gunicorn -b 127.0.0.1:8000 -w 4 mysite.wsgi + +.. _upstart: http://blog.terminal.com/getting-started-with-upstart/ + +.. nextslide:: Using Upstart + +Once you've completed that, you will find that you can use the Linux +``service`` command to control the gunicorn process. + +.. rst-class:: build +.. container:: + + Use the following commands:: + + $ sudo service djangoblog status + $ sudo service djangoblog start + $ sudo service djangoblog stop + $ sudo service djangoblog restart + + If you see an error message about an ``unknown job`` when you run one of those + commands, it means you have an error in your configuration file. + + Find the error with this command:: + + $ init-checkconf /etc/init/djangoblog.conf + + And that's it! diff --git a/source/presentations/session10.rst.norender b/source/presentations/session10.rst.norender deleted file mode 100644 index 8c3731fc..00000000 --- a/source/presentations/session10.rst.norender +++ /dev/null @@ -1,1225 +0,0 @@ -Internet Programming with Python -================================ - -.. image:: img/pyramid-medium.png - :align: left - :width: 50% - -Session 10: A Pyramid Application - -.. class:: intro-blurb right - -| The flexible framework. -| Totally not built by aliens. - - -Chameleon Templates -------------------- - -Chameleon page templates are valid XML/HTML. - -.. class:: incremental - -You can validate and view templates in browsers without the templating engine. - -.. class:: incremental - -This can be helpful in working with designers or front-end folks - -.. class:: incremental - -Instead of using special tags for processing directives, Chameleon uses XML -tag attributes. - - -TAL/METAL ---------- - -Chameleon is descended from Zope Page Templates (ZPT) - -.. class:: incremental - -It uses two XML namespaces for directives: - -.. class:: incremental - -* TAL (Template Attribute Language) -* METAL (Macro Extension to the Template Attribute Language) - -.. class:: incremental - -TAL provides basic directives for logical structures - -.. class:: incremental - -METAL provides directives for creating and using template Macros - - -TAL Statements --------------- - -TAL and METAL statements are XML tag attributes. - -.. class:: incremental - -This means they look just like ``id="foo"`` or ``class="bar"`` - -.. class:: incremental - -* ``tal:=””`` - -* The ``tal:`` is a ‘namespace identifier’ (xml) - - * Not strictly required, but helpful - - * Strongly encouraged :) - - -TAL Operators -------------- - -There are seven basic TAL operators, which are processed in this order - -.. class:: incremental - -* ``tal:define`` - set a value or values -* ``tal:condition`` - test truth value to execute -* ``tal:repeat`` - loop over sets of values -* ``tal:content`` - set the content of a tag -* ``tal:replace`` - replace an entire tag -* ``tal:attributes`` - set html/xml attributes of a tag -* ``tal:omit-tag`` - if expression is false, omit the tag - -.. class:: incremental - -``content`` and ``replace`` are mutually exclusive. - - -TAL Expressions ---------------- - -The right half of a TAL statement is an *expression* using the TAL expression -syntax (TALES): - -.. class:: incremental - -* Exists - ``exists:foo`` -* Import - ``import:foo.bar.baz`` -* Load = ``load:../other_template.pt`` -* Not - ``not: is_anon`` -* Python - ``python: here.Title()`` -* String - ``string:my ${value}`` -* Structure - ``structure:some_html`` - - -METAL Operators ---------------- - -METAL provides operators related to creating and using template macros: - -.. class:: incremental - -* ``metal:define-macro`` - designates a DOM scope as a macro -* ``metal:use-macro`` - indicates that a macro should be used -* ``metal:extend-macro`` - extend an existing macro -* ``metal:define-slot`` - designate a customization point for a macro -* ``metal:fill-slot`` - provide custom content for a macro slot - -.. class:: incremental - -Much of this will become clearer as we actually create our templates. - - -A Few Notes ------------ - -Take a look at our ``view.pt`` template again. - -.. class:: incremental - -```` and ```` tags are processed and removed by the engine. - -.. class:: incremental - -* ``use-macro="load: base.pt"``: we will be using ``base.pt`` as our main - template *macro*. -* Template *macros* define one or more *slots*. -* ``metal:fill-slot="main-content"``: everything goes in the ``main-content`` - slot. - - -More Notes ----------- - -.. code-block:: xml - -
- Page text goes here. -
- -The ``tal`` directive ``replace`` replaces the ``
`` tag with ``content``. - -The ``structure`` expression ensures that the HTML is not escaped. - -.. container:: incremental - - .. code-block:: xml - - - Edit this page - - - Here, we use the ``tal`` directive ``attributes`` to set the ``href`` for - our anchor to the value passed into our template as ``edit_url``. - - -Page Editing ------------- - -You'll notice that the page has a link to ``Edit This Page`` - -.. class:: incremental - -If you click it, you get a 404. We haven't created that view yet. - -.. class:: incremental - -Let's start by adding tests to ensure: - -.. class:: incremental - -* the edit view will submit to itself -* will save page data updates -* will redirect back to the page view after saving - - -Test Page Editing ------------------ - -In ``tests.py``: - -.. code-block:: python - :class: small - - class EditPageTests(unittest.TestCase): - def _callFUT(self, context, request): - from .views import edit_page - return edit_page(context, request) - - def test_it_notsubmitted(self): - context = testing.DummyResource() - request = testing.DummyRequest() - info = self._callFUT(context, request) - self.assertEqual(info['page'], context) - self.assertEqual(info['save_url'], - request.resource_url(context, 'edit_page')) - - -One More Method ---------------- - -.. code-block:: python - :class: small - - class EditPageTests(unittest.TestCase): - # ... - - def test_it_submitted(self): - context = testing.DummyResource() - request = testing.DummyRequest({'form.submitted':True, - 'body':'Chapel Hill Rocks'}) - response = self._callFUT(context, request) - self.assertEqual(response.location, 'http://example.com/') - self.assertEqual(context.data, 'Chapel Hill Rocks') - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - Ran 7 tests in 0.110s - FAILED (errors=2) - - -Editing a Page --------------- - -Back in ``views.py`` add the following: - -.. code-block:: python - :class: small - - @view_config(name='edit_page', context='.models.Page', - renderer='templates/edit.pt') - def edit_page(context, request): - if 'form.submitted' in request.params: - context.data = request.params['body'] - return HTTPFound(location = request.resource_url(context)) - - return dict(page=context, - save_url=request.resource_url(context, 'edit_page')) - -.. class:: incremental - -Note the ``name`` in ``view_config``. - -.. class:: incremental - -When traversal runs out of objects, it tries to find views by name - - -Check Your Tests ----------------- - -Even without a template we can run our tests: - -.. class:: small incremental - -:: - - (pyramidenv)$ python setup.py test - ... - ---------------------------------------------------------------------- - Ran 7 tests in 0.112s - - OK - - -The Edit Template ------------------ - -Create and fill ``edit.pt`` in ``templates``: - -.. code-block:: xml - :class: small - - - - Editing - Page Name Goes Here - - - -
-